feat: initial commit of AI Comic Generator

This commit is contained in:
p
2026-01-17 15:26:14 +08:00
commit 06d76e0e69
278 changed files with 8612 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# Python
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
*.db
*.sqlite3
# Node
node_modules/
dist/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Project specific
comic_gen/
backend/comic_app.db
backend/*.db
backend/static/*.png
@@ -0,0 +1,83 @@
# Full-Stack AI Comic Generator Implementation Plan
This plan details the creation of a web-based AI comic generator using Vue 3 (Frontend) and FastAPI (Backend), separating concerns into a modular architecture.
## 1. Project Structure Setup
We will create a root directory containing two main folders: `backend` and `frontend`.
### Backend Structure (`backend/`)
- **Framework**: FastAPI
- **Database**: SQLite (via SQLModel/SQLAlchemy) for storing project data and configs.
- **Directory Layout**:
- `app/`
- `core/`: Configuration (env vars, DB settings).
- `models/`: Database models (SQLModel).
- `schemas/`: Pydantic models for request/response validation.
- `cruds/`: Database CRUD operations.
- `routers/`: API endpoints grouped by functionality.
- `services/`: Business logic (AI generation, File management).
- `utils/`: Helper functions.
- `static/`: Serving generated images.
- `main.py`: Application entry point.
### Frontend Structure (`frontend/`)
- **Framework**: Vue 3 + Vite
- **UI Library**: Element Plus (Dark Mode enabled for "Tech" style).
- **Directory Layout**:
- `src/`
- `api/`: Axios instances for backend communication.
- `components/`: Reusable UI components (JSON Editor, Image Cards).
- `views/`: Main pages (Config, Workspace).
- `stores/`: Pinia state management.
## 2. Backend Implementation Steps
### Phase 1: Core & Configuration
1. **Environment**: Setup `requirements.txt` (FastAPI, SQLModel, Uvicorn, Google GenAI, OpenAI, python-dotenv).
2. **Models & Schemas**:
- `ModelConfig`: Store API keys, provider (Google/OpenAI/DeepSeek), model names.
- `Project`: Store comic project metadata (title, status).
- `ComicData`: Store the generated JSONs (Global Config, Characters, Storyboard).
3. **CRUDs**: Implement basic Create/Read/Update/Delete operations for Configs and Projects.
### Phase 2: AI Services Integration
1. **AI Provider Adapter**: Create a unified interface to handle different providers (Google, DeepSeek, ChatGPT, etc.).
2. **Migration**: Refactor logic from `comic_generator.py` into `services/comic_service.py`.
- Implement `generate_storyboard` (Text generation).
- Implement `generate_character_image` (Image generation).
- Implement `generate_comic_panel` (Image generation with context).
3. **Endpoints**:
- `POST /api/generate/json`: Generate initial JSONs from user input.
- `POST /api/generate/image`: Generate specific image (Character or Panel).
- `POST /api/project/{id}/export`: Package and zip output.
## 3. Frontend Implementation Steps
### Phase 1: UI Framework & Configuration
1. **Setup**: Initialize Vue 3 project, install Element Plus, Axios, Pinia, Vue Router.
2. **Theme**: Configure Element Plus for Dark Mode/Tech style.
3. **Model Configuration Page**:
- Form to add/edit API keys and select models for Text and Image generation.
### Phase 2: Comic Workflow Page
1. **Step 1: Concept & JSON**:
- Input field for story idea.
- "Generate" button.
- **JSON Editor**: Integrated code editor (e.g., Monaco Editor) to modify generated JSONs (Global Config, Characters, Storyboard).
2. **Step 2: Character Studio**:
- Display list of characters from JSON.
- "Generate/Regenerate" button for each character.
- Support "Add Character" manually.
3. **Step 3: Comic Board**:
- Display storyboard panels.
- "Generate/Regenerate" button for each panel (4-grid or single).
- Support modifying prompt per panel.
4. **Step 4: Export**:
- Button to download the complete comic package.
## 4. Execution Strategy
1. **Backend First**: I will build the FastAPI backend, ensuring the API is functional and can replicate the existing script's logic.
2. **Frontend Second**: I will build the Vue frontend and connect it to the backend.
3. **Verification**: I will test the full flow: Config -> Story Input -> Edit JSON -> Generate Images -> Export.
I will begin by setting up the backend structure and dependencies.
+148
View File
@@ -0,0 +1,148 @@
# AI Comic Generator
[English](./README.md) | [中文](./README_CN.md)
An open-source AI-powered manga creation tool that transforms text stories into fully illustrated comics using Google Gemini models. Features include automatic storyboard generation, character consistency checks, and a visual editor.
![Project Status](https://img.shields.io/badge/Status-Active-success)
![Python](https://img.shields.io/badge/Backend-FastAPI-blue)
![Vue](https://img.shields.io/badge/Frontend-Vue3-green)
## ✨ Core Features
* **Project Management**: Supports multi-project management, with each project independently saving story, characters, and storyboard data.
* **Intelligent Scriptwriting**: Input simple story ideas, and AI automatically expands the plot and breaks it down into professional comic storyboard scripts (JSON format).
* **Character Workshop**:
* Automatically extracts characters from the story and generates detailed character settings (three views).
* **Character Consistency**: Automatically references character setting images as references (Image-to-Image) when drawing.
* **Merge & Deduplication**: Supports manual merging of duplicate generated characters (e.g., "Butler Ma" and "Old Ma").
* **Storyboard Editor**:
* Visually edit prompts, characters, and actions for each panel.
* Supports single-panel redrawing and batch generation.
* **Context Awareness**: Automatically reads previous storyboard panels and character images when generating storyboards to maintain style and plot continuity.
* **Style Control**:
* Global style configuration (e.g., "Cyberpunk", "Ink Style"), forcing AI to follow settings.
* Supports custom dialog box and border styles.
* **Background Tasks**: Time-consuming batch drawing tasks run in the background, supporting real-time progress viewing.
## 🛠️ Tech Stack
### Backend
* **Framework**: FastAPI
* **Database**: SQLite + SQLModel
* **AI Service**: Google Gemini (Currently only supports Google's latest models)
* **Text Model**: `gemini-3-flash-preview`
* **Image Model**: `gemini-3-pro-image-preview`
* **Task Queue**: FastAPI BackgroundTasks
### Frontend
* **Framework**: Vue 3 + Vite
* **UI Library**: Element Plus
* **State Management**: Pinia
* **HTTP Client**: Axios
* **Package Manager**: pnpm
## 🚀 Quick Start
### Prerequisites
* Python 3.9+
* Node.js 16+
* pnpm
* Google Cloud API Key (Requires Gemini API access)
### 1. Backend Configuration
Enter the `backend` directory:
```bash
cd backend
```
Create virtual environment and install dependencies:
```bash
python -m venv .venv
# Windows
.\.venv\Scripts\activate
# Linux/Mac
source .venv/bin/activate
pip install -r requirements.txt
```
Create `.env` file and fill in API Key:
```ini
# backend/.env
GOOGLE_API_KEY="your_google_api_key_here"
```
Start backend service:
```bash
# Windows (using provided script)
..\start_backend.bat
# Or run manually
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
### 2. Frontend Configuration
Enter the `frontend` directory:
```bash
cd frontend
```
Install dependencies:
```bash
pnpm install
```
Start frontend service:
```bash
# Windows (using provided script)
..\start_frontend.bat
# Or run manually
pnpm dev
```
Access in browser: `http://localhost:5173`
## 📖 User Guide
1. **Create Project**: Click "New Project" on the homepage and enter the comic title and introduction.
2. **Story & Configuration**:
* Enter your story outline.
* Set global styles (e.g., "Japanese Shonen"), aspect ratio, etc.
* Click "Generate Storyboard Config", and AI will generate the character list and storyboard script.
3. **Character Workshop**:
* View AI-generated character settings.
* Click "Draw" to generate character portraits.
* If there are duplicate characters, use the "Merge Characters" function to clean them up.
4. **Storyboard Editing**:
* Check the description of each panel in the storyboard list.
* Click "Generate Image" or "Generate All" to start drawing the comic.
* Click on an image to view it in large size and support downloading.
## 📁 Directory Structure
```
aImanhua/
├── backend/ # FastAPI Backend
│ ├── app/ # Application Code
│ ├── static/ # Generated images and temp files storage
│ └── ...
├── frontend/ # Vue3 Frontend
│ ├── src/ # Pages and Components
│ └── ...
└── ...
```
## 📝 License
MIT License
+148
View File
@@ -0,0 +1,148 @@
# AI Comic Generator
[English](./README.md) | [中文](./README_CN.md)
一个基于 AI 的全流程漫画创作辅助工具,集成了故事生成、分镜拆解、角色设定、一致性控制和批量绘图功能。
![Project Status](https://img.shields.io/badge/Status-Active-success)
![Python](https://img.shields.io/badge/Backend-FastAPI-blue)
![Vue](https://img.shields.io/badge/Frontend-Vue3-green)
## ✨ 核心功能
* **项目管理**: 支持多项目管理,每个项目独立保存故事、角色和分镜数据。
* **智能编剧**: 输入简单的故事点子,AI 自动扩充情节并拆分为专业的漫画分镜脚本 (JSON 格式)。
* **角色工坊**:
* 自动从故事中提取角色并生成详细的人物设定(三视图)。
* **角色一致性**: 绘图时自动引用角色设定图作为参考(Image-to-Image)。
* **合并与去重**: 支持手动合并重复生成的角色(如“马管家”和“马老”)。
* **分镜编辑器**:
* 可视化编辑每一格分镜的提示词、角色和动作。
* 支持单格重绘、批量生成。
* **上下文感知**: 生成分镜时自动读取前序分镜和角色图,保持画风和剧情连贯性。
* **风格控制**:
* 全局风格配置(如“赛博朋克”、“水墨风”),强制 AI 遵循设定。
* 支持自定义对话框、边框样式。
* **后台任务**: 耗时的批量生图任务在后台运行,支持进度实时查看。
## 🛠️ 技术栈
### Backend (后端)
* **Framework**: FastAPI
* **Database**: SQLite + SQLModel
* **AI Service**: Google Gemini (目前仅支持 Google 最新模型)
* **文本模型**: `gemini-3-flash-preview`
* **图像模型**: `gemini-3-pro-image-preview`
* **Task Queue**: FastAPI BackgroundTasks
### Frontend (前端)
* **Framework**: Vue 3 + Vite
* **UI Library**: Element Plus
* **State Management**: Pinia
* **HTTP Client**: Axios
* **Package Manager**: pnpm
## 🚀 快速开始
### 前置要求
* Python 3.9+
* Node.js 16+
* pnpm
* Google Cloud API Key (需开通 Gemini API 权限)
### 1. 后端配置
进入 `backend` 目录:
```bash
cd backend
```
创建虚拟环境并安装依赖:
```bash
python -m venv .venv
# Windows
.\.venv\Scripts\activate
# Linux/Mac
source .venv/bin/activate
pip install -r requirements.txt
```
创建 `.env` 文件并填入 API Key
```ini
# backend/.env
GOOGLE_API_KEY="your_google_api_key_here"
```
启动后端服务:
```bash
# Windows (使用提供的脚本)
..\start_backend.bat
# 或者手动运行
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
### 2. 前端配置
进入 `frontend` 目录:
```bash
cd frontend
```
安装依赖:
```bash
pnpm install
```
启动前端服务:
```bash
# Windows (使用提供的脚本)
..\start_frontend.bat
# 或者手动运行
pnpm dev
```
访问浏览器:`http://localhost:5173`
## 📖 使用指南
1. **创建项目**: 在首页点击“新建项目”,输入漫画标题和简介。
2. **故事与配置**:
* 输入你的故事大纲。
* 设置全局风格(如“日系少年漫”)、画幅比例等。
* 点击“生成分镜配置”,AI 将生成角色表和分镜脚本。
3. **角色工坊**:
* 查看 AI 生成的角色设定。
* 点击“绘制”生成角色立绘。
* 如有重复角色,使用“合并角色”功能进行清理。
4. **分镜编辑**:
* 在分镜列表中检查每一格的描述。
* 点击“生成图片”或“一键生成所有”开始绘制漫画。
* 点击图片可查看大图,支持下载。
## 📁 目录结构
```
aImanhua/
├── backend/ # FastAPI 后端
│ ├── app/ # 应用代码
│ ├── static/ # 生成的图片和临时文件存储
│ └── ...
├── frontend/ # Vue3 Frontend
│ ├── src/ # 页面与组件
│ └── ...
└── ...
```
## 📝 License
MIT License
+1
View File
@@ -0,0 +1 @@
DATABASE_URL=sqlite:///./comic_app.db
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+83
View File
@@ -0,0 +1,83 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Import SQLModel and your models
from sqlmodel import SQLModel
from app.models import models # Ensure models are imported to register metadata
from app.core.config import settings
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Set the SQLAlchemy URL from settings
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# target_metadata = None
target_metadata = SQLModel.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,110 @@
"""Initial Full Schema
Revision ID: ca058f9ddb60
Revises:
Create Date: 2026-01-17 10:58:17.473117
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ca058f9ddb60'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('modelconfig',
sa.Column('provider', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('api_key', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('base_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('model_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('model_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('project',
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('story_input', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('theme', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('language', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('panel_count', sa.Integer(), nullable=True),
sa.Column('aspect_ratio', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('character',
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('project_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('globalconfig',
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('project_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('imagehistory',
sa.Column('entity_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('entity_id', sa.Integer(), nullable=False),
sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('project_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('storyboarditem',
sa.Column('sequence', sa.Integer(), nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('project_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('task',
sa.Column('type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('progress', sa.Integer(), nullable=False),
sa.Column('message', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('result', sa.JSON(), nullable=True),
sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('project_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('task')
op.drop_table('storyboarditem')
op.drop_table('imagehistory')
op.drop_table('globalconfig')
op.drop_table('character')
op.drop_table('project')
op.drop_table('modelconfig')
# ### end Alembic commands ###
View File
View File
+12
View File
@@ -0,0 +1,12 @@
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
PROJECT_NAME: str = "AI Comic Generator"
API_V1_STR: str = "/api/v1"
DATABASE_URL: str = "sqlite:///./comic_app.db"
class Config:
env_file = ".env"
settings = Settings()
+15
View File
@@ -0,0 +1,15 @@
from sqlmodel import SQLModel, create_engine, Session
from app.core.config import settings
connect_args = {}
if "sqlite" in settings.DATABASE_URL:
connect_args["check_same_thread"] = False
engine = create_engine(settings.DATABASE_URL, echo=False, connect_args=connect_args)
def init_db():
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session
+102
View File
@@ -0,0 +1,102 @@
# This file is used to centrally manage the core system prompt (System Prompt) of the comic generation system
# Provided to the backend service in the form of Python variables to avoid path or encoding issues caused by directly reading text files
COMIC_GENERATION_SYSTEM_PROMPT = """As a 'Comic Split Generation' expert, you will play the dual role of a comic editing expert and a comic drawing expert. Your goal is to assist users in the entire process from story creation to storyboard design, and finally generate a serialized comic.
Purpose and Goals:
* Expand or optimize the user-provided content into a story with a complete plot, ensuring logical self-consistency and engagement.
* Clarify the overall comic style. You must strictly use the user-specified style (if any) and are prohibited from making decisions on your own.
* Extract characters from the story and generate character setting cards containing core information about appearance, clothing, and personality.
* Split the story into serialized comic storyboards, ensuring natural transitions between panels and appropriate narrative pacing.
* Provide extremely detailed visual descriptions for each panel, precise to lighting, composition, character expression, and key actions.
* Provide storyboard content in JSON file format, strictly stipulating that every four panels constitute an independent JSON object block for structured processing.
Behaviors and Rules:
1) Story Optimization & Character Setting (Story & Character):
a) Receive the user's initial idea, enrich its background details, emotional ups and downs, and climax ending.
b) Confirm Art Style: If the user provides specific "Theme" or "Style" requirements, you must follow them unconditionally. Do not modify the style based on the story content. For example, if the user requests "Cyberpunk", even if the story is set in a martial arts background, you must generate "Cyberpunk style martial arts".
c) Before starting the storyboard, list the main characters and their physical characteristics (such as hair color, eye color, signature accessories, etc.) in detail to ensure visual consistency of characters in subsequent storyboards.
2) Storyboard Splitting & JSON Construction (Storyboard & JSON):
a) Comic Global Configuration (Comic Configuration):
- **Must be generated first**: Before starting to generate characters and storyboards, you must generate an independent 'comic_config' JSON block.
- **Function**: Define the visual tone, typography standards, and border styles of the entire comic.
- JSON structure should contain:
- 'type': 'comic_config'
- 'language': 'English',
- 'style': '{User Specified Style}' (Must fill in the user-specified style, if not specified, default to 'Chibi/Fantasy Style')
- 'bubble_style': { 'shape': 'Bubble Shape', 'color': 'Background Color', 'font_color': 'Font Color', 'stroke_width': 'Stroke Width' }
- 'narration_style': { 'shape': 'Box/Rounded', 'color': 'Background Color', 'font_color': 'Font Color', 'opacity': 'Opacity' }
- 'border_style': { 'width': 'Line Width', 'color': 'Color', 'type': 'Solid/Hand-drawn' }
- 'gutter_style': { 'type': 'Standard Cross Split', 'color': 'White', 'width': '10px' }
- 'layout_settings': {
'show_panel_numbers': false, // [Switch] Whether to show panel numbers
'panel_number_style': { 'position': 'top-left', 'bg_color': '#000000', 'text_color': '#00FF41', 'font_size': '14px' },
'force_uniform_borders': true,
'composition_mode': 'grid' // grid=Grid Splicing, cinematic=Cinematic Widescreen
}
- 'aspect_ratio': '16:9'
b) Character Sheet Generation (Character Sheets):
- **All characters must have setting cards**: Before generating story storyboards, you must generate independent JSON setting blocks for all named characters appearing in the story, including protagonists, frequent supporting characters, and villains.
- **Protagonist Design**: The protagonist's image must be designed to be extremely attractive, with distinct physical features, meeting "high aesthetic" standards.
- **Character Deduplication**: When generating the character list, carefully identify different names for the same character (e.g., "Butler Ma" and "Old Ma" are the same person). If found to be the same person, generate only one character setting card and use the most formal or common name in the name field. Strictly prohibit generating multiple duplicate setting cards for the same character.
- If there are multiple main characters, please generate multiple independent 'character_sheet' JSON blocks respectively, or output them in a JSON array.
- JSON structure should contain:
- 'type': 'character_sheet'
- 'name': 'Character Name'
- 'meta_info':{
- 'language': Language based on user input
- 'role': 'Protagonist' | 'Supporting' | 'Extra' (Must indicate character type)
- 'personality': 'Character personality traits, e.g., Cheerful, Cold, Hot-blooded, etc., which will affect expressions and poses'
- 'age': 'Age description, approximate range'
- 'relationships': 'Description of relationship with protagonist or other characters' (Must indicate interpersonal relationships)
- 'style': '{User Specified Style}',
- 'feature': 'Explicit character features, e.g., Youthful, Plump, etc.'
- 'aspect_ratio': '16:9'
}
- 'design_panels': [
{'view': 'Front View', 'description': 'Detailed front full-body description...'},
{'view': 'Side View', 'description': 'Detailed side view description...'},
{'view': 'Clothing', 'description': 'Detailed clothing details...'},
{'view': 'Accessories', 'description': 'Detailed accessories/weapon details...'}
]
c) Story Storyboard Generation (Story Storyboard):
- Decompose the optimized story into concrete, visualizable storyboard frames.
- **Storyboard Count Mandatory Requirement**: The total number of generated panels must be determined based on the story content. The richer the story, the more panels, unless the user input requires a minimum number of panels (e.g., "at least 36 panels").
- The total number of generated panels must be >= the minimum number required by the user.
- The total number of generated panels must be an integer multiple of 4 (e.g., 36, 40, 44...), rounding up if not satisfied.
- Construct every four panels as an independent JSON code block output.
- JSON structure should contain:
- 'type': 'storyboard'
- 'meta_info': {
'style': '{User Specified Style}',
'language': 'English',
'volume': 'Current Volume/Total Volumes',
'aspect_ratio': '16:9',
}
- 'characters': ['List of characters appearing in this group of panels']
- 'plot_breakdown': [
{'panel': 1, 'scene': '...', 'action': '...', 'dialogue': '...'},
{'panel': 2, 'scene': '...', 'action': '...', 'dialogue': '...'},
{'panel': 3, 'scene': '...', 'action': '...', 'dialogue': '...'},
{'panel': 4, 'scene': '...', 'action': '...', 'dialogue': '...'}
]
3) Quality Control (Quality Control):
a) Ensure visual logic consistency between panels, avoiding sudden changes in characters or environment.
b) Visual prompts should include elements such as environment, weather, shot type (e.g., close-up, panoramic), etc.
4) Language & Format Requirements (Language & Format):
a) Use the same language for dialogue and narration as the user input. English is the standard.
b) All JSON outputs must maintain a strict, parsable code block format.
c) All JSON keys must use lowercase English (e.g., 'language', 'style').
Overall Tone:
* Professional and highly creative, demonstrating the rigor and aesthetics of a senior industry practitioner.
* Descriptions of visual details should be precise and evocative.
"""
View File
+35
View File
@@ -0,0 +1,35 @@
from sqlmodel import Session, select
from app.models.models import ModelConfig
from app.schemas.schemas import ModelConfigCreate, ModelConfigUpdate
from typing import List, Optional
def create_model_config(session: Session, config_in: ModelConfigCreate) -> ModelConfig:
db_config = ModelConfig.model_validate(config_in)
session.add(db_config)
session.commit()
session.refresh(db_config)
return db_config
def get_model_configs(session: Session, skip: int = 0, limit: int = 100) -> List[ModelConfig]:
statement = select(ModelConfig).offset(skip).limit(limit)
return session.exec(statement).all()
def get_model_config(session: Session, config_id: int) -> Optional[ModelConfig]:
return session.get(ModelConfig, config_id)
def update_model_config(session: Session, db_config: ModelConfig, config_in: ModelConfigUpdate) -> ModelConfig:
config_data = config_in.model_dump(exclude_unset=True)
for key, value in config_data.items():
setattr(db_config, key, value)
session.add(db_config)
session.commit()
session.refresh(db_config)
return db_config
def delete_model_config(session: Session, db_config: ModelConfig):
session.delete(db_config)
session.commit()
def get_active_config(session: Session, model_type: str) -> Optional[ModelConfig]:
statement = select(ModelConfig).where(ModelConfig.model_type == model_type, ModelConfig.is_active == True)
return session.exec(statement).first()
+99
View File
@@ -0,0 +1,99 @@
from sqlmodel import Session, select
from app.models.models import Project, Character, StoryboardItem, GlobalConfig
from app.schemas.schemas import ProjectCreate, ProjectUpdate
from typing import List, Optional
def create_project(session: Session, project_in: ProjectCreate) -> Project:
db_project = Project.model_validate(project_in)
session.add(db_project)
session.commit()
session.refresh(db_project)
return db_project
def get_projects(session: Session, skip: int = 0, limit: int = 100) -> List[Project]:
statement = select(Project).offset(skip).limit(limit).order_by(Project.updated_at.desc())
return session.exec(statement).all()
def get_project(session: Session, project_id: str) -> Optional[Project]:
return session.get(Project, project_id)
def update_project(session: Session, db_project: Project, project_in: ProjectUpdate) -> Project:
project_data = project_in.model_dump(exclude_unset=True)
for key, value in project_data.items():
setattr(db_project, key, value)
session.add(db_project)
session.commit()
session.refresh(db_project)
return db_project
def delete_project(session: Session, db_project: Project):
session.delete(db_project)
session.commit()
# Helpers for sub-entities
def create_global_config(session: Session, project_id: str, data: dict) -> GlobalConfig:
# Check if exists
statement = select(GlobalConfig).where(GlobalConfig.project_id == project_id)
existing = session.exec(statement).first()
if existing:
existing.data = data
session.add(existing)
session.commit()
session.refresh(existing)
return existing
db_config = GlobalConfig(project_id=project_id, data=data)
session.add(db_config)
session.commit()
session.refresh(db_config)
return db_config
def save_characters(session: Session, project_id: str, characters_data: List[dict]) -> List[Character]:
# Strategy: Clear existing or update?
# For simplicity in this flow: Clear and Re-insert is easier if we regenerate all.
# But user might want to edit specific ones.
# Better: Update by name match, create if new.
results = []
for char_data in characters_data:
name = char_data.get("name")
if not name: continue
statement = select(Character).where(Character.project_id == project_id, Character.name == name)
existing = session.exec(statement).first()
if existing:
existing.data = char_data
session.add(existing)
results.append(existing)
else:
new_char = Character(project_id=project_id, name=name, data=char_data)
session.add(new_char)
results.append(new_char)
session.commit()
return results
def save_storyboard(session: Session, project_id: str, storyboard_data: List[dict]) -> List[StoryboardItem]:
# Similar strategy: Clear and Re-insert is risky if we have images.
# But storyboard is sequential.
# Let's delete all and re-insert for now as "Regenerate JSON" usually means fresh start.
# IF the user is just editing JSON text, we replace everything.
# Check if there are existing items with images we want to preserve?
# Ideally, we should try to map them back, but it's hard if sequence changes.
# For now: delete all items for this project and insert new.
statement = select(StoryboardItem).where(StoryboardItem.project_id == project_id)
existing_items = session.exec(statement).all()
for item in existing_items:
session.delete(item)
results = []
for i, item_data in enumerate(storyboard_data):
new_item = StoryboardItem(project_id=project_id, sequence=i+1, data=item_data)
session.add(new_item)
results.append(new_item)
session.commit()
return results
+40
View File
@@ -0,0 +1,40 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.core.database import init_db
from app.routers import configs, projects, generation, export, tasks, history
import os
app = FastAPI(title=settings.PROJECT_NAME)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount static files
static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
if not os.path.exists(static_dir):
os.makedirs(static_dir)
app.mount("/static", StaticFiles(directory=static_dir), name="static")
# Include routers
app.include_router(configs.router, prefix=f"{settings.API_V1_STR}/configs", tags=["configs"])
app.include_router(projects.router, prefix=f"{settings.API_V1_STR}/projects", tags=["projects"])
app.include_router(generation.router, prefix=f"{settings.API_V1_STR}/generate", tags=["generation"])
app.include_router(export.router, prefix=f"{settings.API_V1_STR}/export", tags=["export"])
app.include_router(tasks.router, prefix=f"{settings.API_V1_STR}/tasks", tags=["tasks"])
app.include_router(history.router, prefix=f"{settings.API_V1_STR}/history", tags=["history"])
@app.on_event("startup")
def on_startup():
init_db()
@app.get("/")
def read_root():
return {"message": "Welcome to AI Comic Generator API"}
View File
+97
View File
@@ -0,0 +1,97 @@
from typing import Optional, List, Dict, Any
from sqlmodel import SQLModel, Field, Relationship, Column, JSON
from datetime import datetime
import uuid
# --- Base Models ---
class ModelConfigBase(SQLModel):
provider: str
api_key: str
base_url: Optional[str] = None
model_name: str
model_type: str
is_active: bool = True
class ProjectBase(SQLModel):
title: str
description: Optional[str] = None
story_input: Optional[str] = None
# Generation Preferences
theme: Optional[str] = None
language: Optional[str] = "zh-CN"
panel_count: Optional[int] = 16
aspect_ratio: Optional[str] = "16:9"
class CharacterBase(SQLModel):
name: str
data: Dict = Field(default={}, sa_column=Column(JSON))
image_url: Optional[str] = None
class StoryboardItemBase(SQLModel):
sequence: int
data: Dict = Field(default={}, sa_column=Column(JSON))
image_url: Optional[str] = None
class GlobalConfigBase(SQLModel):
data: Dict = Field(default={}, sa_column=Column(JSON))
class TaskBase(SQLModel):
type: str # 'storyboard', 'image_generation', 'export'
status: str # 'pending', 'processing', 'completed', 'failed'
progress: int = 0 # 0-100
message: Optional[str] = None
name: Optional[str] = None
description: Optional[str] = None
result: Dict = Field(default={}, sa_column=Column(JSON))
class ImageHistoryBase(SQLModel):
entity_type: str # 'character' or 'storyboard_item'
entity_id: int
image_url: str
# --- Table Models ---
class ModelConfig(ModelConfigBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
class Project(ProjectBase, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
characters: List["Character"] = Relationship(back_populates="project", sa_relationship_kwargs={"cascade": "all, delete"})
storyboard_items: List["StoryboardItem"] = Relationship(back_populates="project", sa_relationship_kwargs={"cascade": "all, delete"})
global_config: Optional["GlobalConfig"] = Relationship(back_populates="project", sa_relationship_kwargs={"cascade": "all, delete"})
tasks: List["Task"] = Relationship(back_populates="project", sa_relationship_kwargs={"cascade": "all, delete"})
image_history: List["ImageHistory"] = Relationship(back_populates="project", sa_relationship_kwargs={"cascade": "all, delete"})
class Character(CharacterBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
project_id: str = Field(foreign_key="project.id")
project: Project = Relationship(back_populates="characters")
class StoryboardItem(StoryboardItemBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
project_id: str = Field(foreign_key="project.id")
project: Project = Relationship(back_populates="storyboard_items")
class GlobalConfig(GlobalConfigBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
project_id: str = Field(foreign_key="project.id")
project: Project = Relationship(back_populates="global_config")
class Task(TaskBase, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
project_id: str = Field(foreign_key="project.id")
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
project: Project = Relationship(back_populates="tasks")
class ImageHistory(ImageHistoryBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
project_id: str = Field(foreign_key="project.id")
created_at: datetime = Field(default_factory=datetime.utcnow)
project: Project = Relationship(back_populates="image_history")
View File
+39
View File
@@ -0,0 +1,39 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from typing import List
from app.core.database import get_session
from app.models.models import ModelConfig
from app.schemas.schemas import ModelConfigCreate, ModelConfigUpdate
from app.cruds import crud_config
router = APIRouter()
@router.post("/", response_model=ModelConfig)
def create_config(config_in: ModelConfigCreate, session: Session = Depends(get_session)):
return crud_config.create_model_config(session, config_in)
@router.get("/", response_model=List[ModelConfig])
def read_configs(skip: int = 0, limit: int = 100, session: Session = Depends(get_session)):
return crud_config.get_model_configs(session, skip, limit)
@router.get("/{config_id}", response_model=ModelConfig)
def read_config(config_id: int, session: Session = Depends(get_session)):
config = crud_config.get_model_config(session, config_id)
if not config:
raise HTTPException(status_code=404, detail="Config not found")
return config
@router.put("/{config_id}", response_model=ModelConfig)
def update_config(config_id: int, config_in: ModelConfigUpdate, session: Session = Depends(get_session)):
config = crud_config.get_model_config(session, config_id)
if not config:
raise HTTPException(status_code=404, detail="Config not found")
return crud_config.update_model_config(session, config, config_in)
@router.delete("/{config_id}")
def delete_config(config_id: int, session: Session = Depends(get_session)):
config = crud_config.get_model_config(session, config_id)
if not config:
raise HTTPException(status_code=404, detail="Config not found")
crud_config.delete_model_config(session, config)
return {"ok": True}
+80
View File
@@ -0,0 +1,80 @@
import shutil
import os
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from app.core.database import get_session
from app.models.models import Project
from app.services.image_service import split_comic_page
router = APIRouter()
@router.get("/{project_id}")
def export_project(
project_id: str,
split_images: bool = False,
session: Session = Depends(get_session)
):
project = session.get(Project, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Check if any images generated
has_images = any(item.image_url for item in project.storyboard_items) or any(c.image_url for c in project.characters)
if not has_images:
raise HTTPException(status_code=400, detail="No images generated yet. Cannot export.")
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
project_static_dir = os.path.join(base_dir, "static", project_id)
export_dir = os.path.join(project_static_dir, "export")
if os.path.exists(export_dir):
shutil.rmtree(export_dir)
os.makedirs(export_dir)
# Export Characters
chars_dir = os.path.join(export_dir, "characters")
os.makedirs(chars_dir)
for char in project.characters:
if char.image_url:
# Resolve absolute path from relative URL
# URL: /static/{project_id}/characters/xxx.png
# Path: backend/static/{project_id}/characters/xxx.png
# We can construct it directly if we know the structure, but let's parse url
rel_path = char.image_url.lstrip("/") # static/...
local_path = os.path.join(base_dir, rel_path.replace("/", os.sep))
if os.path.exists(local_path):
shutil.copy(local_path, os.path.join(chars_dir, f"{char.name}.png"))
panels_dir = os.path.join(export_dir, "panels")
if split_images:
os.makedirs(panels_dir)
# Sort items
items = sorted(project.storyboard_items, key=lambda x: x.sequence)
for item in items:
if item.image_url:
rel_path = item.image_url.lstrip("/")
local_path = os.path.join(base_dir, rel_path.replace("/", os.sep))
if os.path.exists(local_path):
shutil.copy(local_path, os.path.join(export_dir, f"comic_part_{item.sequence}.png"))
if split_images:
with open(local_path, "rb") as f:
img_bytes = f.read()
try:
panels = split_comic_page(img_bytes)
for idx, panel_bytes in enumerate(panels):
p_name = f"panel_{item.sequence}_{idx+1}.png"
with open(os.path.join(panels_dir, p_name), "wb") as f:
f.write(panel_bytes)
except Exception as e:
print(f"Failed to split panel {item.id}: {e}")
zip_path_base = os.path.join(project_static_dir, "export_archive")
shutil.make_archive(zip_path_base, 'zip', export_dir)
return {"download_url": f"/static/{project_id}/export_archive.zip"}
+824
View File
@@ -0,0 +1,824 @@
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlmodel import Session
from app.core.database import get_session
from app.models.models import Project, Character, StoryboardItem, Task, ImageHistory
from app.services.ai_service import AIService
from app.services.consistency_service import ConsistencyService
from app.utils.json_utils import extract_json_blocks
from app.cruds import crud_project
import os
import uuid
import json
import traceback
import logging
import sys
# Configure logging
logging.basicConfig(level=logging.INFO, stream=sys.stdout)
logger = logging.getLogger(__name__)
def save_generated_image(session, project_id, entity_type, entity_id, image_bytes):
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
project_static_dir = os.path.join(base_dir, "static", project_id)
sub_dir = "characters" if entity_type == "character" else "panels"
target_dir = os.path.join(project_static_dir, sub_dir)
if not os.path.exists(target_dir):
os.makedirs(target_dir)
filename = f"{entity_type}_{entity_id}_{uuid.uuid4().hex[:8]}.png"
filepath = os.path.join(target_dir, filename)
with open(filepath, "wb") as f:
f.write(image_bytes)
relative_url = f"/static/{project_id}/{sub_dir}/{filename}"
# Save History
history = ImageHistory(
project_id=project_id,
entity_type=entity_type,
entity_id=entity_id,
image_url=relative_url
)
session.add(history)
return relative_url
router = APIRouter()
from app.core.prompts import COMIC_GENERATION_SYSTEM_PROMPT
def get_system_prompt():
return COMIC_GENERATION_SYSTEM_PROMPT
# --- Background Task Functions ---
def generate_storyboard_task(task_id: str, project_id: str, user_input: str):
logger.info(f"Starting storyboard generation task: {task_id} for project: {project_id}")
# We need a fresh session for the background task
from app.core.database import engine
with Session(engine) as session:
task = session.get(Task, task_id)
if not task:
logger.error(f"Task {task_id} not found")
return
task.status = "processing"
session.add(task)
session.commit()
try:
project = crud_project.get_project(session, project_id)
logger.info(f"Project found: {project.title}")
# Save User Input (Persist it)
project.story_input = user_input
session.add(project)
session.commit()
ai = AIService(session)
system_prompt = get_system_prompt()
# Construct Final Prompt with Preferences
final_prompt = user_input
# --- Replace Placeholders in System Prompt ---
system_prompt = get_system_prompt()
# Defaults
style = "Standard"
if project.theme: style = project.theme
lang = "English"
if project.language:
lang_map = {"zh-CN": "Simplified Chinese", "en-US": "English", "ja-JP": "Japanese"}
lang = lang_map.get(project.language, project.language)
# Inject into System Prompt
system_prompt = system_prompt.replace("{User Specified Style}", style)
# We could also inject language if we had a placeholder, but style is the main one failing.
# Let's add language instruction to system prompt dynamically if needed,
# or rely on the "Language & Format" section in prompt which says "Use user input language".
# --- Construct User Prompt ---
final_prompt = user_input
# Append preferences as normal requirements
prefs = []
if project.theme: prefs.append(f"Theme: {project.theme}")
if project.language: prefs.append(f"Language: {project.language}")
if project.panel_count: prefs.append(f"Estimated Panel Count: {project.panel_count}")
if project.aspect_ratio: prefs.append(f"Aspect Ratio: {project.aspect_ratio}")
if prefs:
final_prompt += "\n\nRequirements:\n" + "\n".join(prefs)
logger.info("Calling AI service for storyboard generation...")
generated_text = ai.generate_storyboard(system_prompt, final_prompt)
# --- Save Generated Text to Temp File ---
import time
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
temp_dir = os.path.join(base_dir, "static", project_id, "temp")
if not os.path.exists(temp_dir):
os.makedirs(temp_dir)
timestamp = int(time.time())
temp_file = os.path.join(temp_dir, f"ai_output_{timestamp}.txt")
try:
with open(temp_file, "w", encoding="utf-8") as f:
f.write(generated_text)
logger.info(f"Saved raw AI output to {temp_file}")
except Exception as e:
logger.error(f"Failed to save temp AI output: {e}")
# ----------------------------------------
logger.info("AI generation complete. Extracting JSON blocks...")
json_blocks = extract_json_blocks(generated_text)
char_blocks = [b for b in json_blocks if b.get("type") == "character_sheet"]
story_blocks = [b for b in json_blocks if b.get("type") == "storyboard"]
if not story_blocks:
story_blocks = [b for b in json_blocks if b.get("type") not in ["character_sheet", "comic_config"]]
# --- Missing Character Check & Fix ---
story_char_names = set()
for block in story_blocks:
chars = block.get("characters", [])
if isinstance(chars, str):
story_char_names.add(chars)
elif isinstance(chars, list):
for c in chars:
if isinstance(c, str): story_char_names.add(c)
elif isinstance(c, dict): story_char_names.add(c.get("name", ""))
generated_char_names = set(b.get("name") for b in char_blocks if b.get("name"))
# Simple fuzzy matching or direct check
missing_chars = []
for name in story_char_names:
# Check if name is contained in any generated char name (e.g. "Xiao Ming" vs "Ming")
found = False
for g_name in generated_char_names:
if name in g_name or g_name in name:
found = True
break
if not found and name and len(name) > 1: # Ignore single chars or empty
missing_chars.append(name)
if missing_chars:
logger.info(f"Detected missing characters: {missing_chars}. Requesting AI to generate them...")
fix_prompt = f"You missed generating character sheets for the following characters that appeared in the storyboard: {', '.join(missing_chars)}. Please generate 'character_sheet' JSON blocks for them now. Do not generate anything else."
try:
fix_response = ai.generate_storyboard(system_prompt, fix_prompt) # Re-use generate method
fix_blocks = extract_json_blocks(fix_response)
new_chars = [b for b in fix_blocks if b.get("type") == "character_sheet"]
if new_chars:
logger.info(f"Successfully generated {len(new_chars)} missing characters.")
char_blocks.extend(new_chars)
except Exception as e:
logger.error(f"Failed to generate missing characters: {e}")
config_block = next((b for b in json_blocks if b.get("type") == "comic_config"), None)
# If AI didn't return config, create one from project prefs
if not config_block and (project.aspect_ratio or project.language):
config_block = {
"type": "comic_config",
"style": "Standard", # Default
"aspect_ratio": project.aspect_ratio or "16:9",
"language": project.language or "en-US"
}
if config_block:
crud_project.create_global_config(session, project_id, config_block)
# --- Enforce Consistency: Update meta_info for all blocks ---
# Re-read global config if we just created/updated it
# Or use the config_block we have
if not config_block:
# Try to fetch existing
# But we just generated it. If None, we create a default one above.
# Let's use the one we have.
pass
if config_block:
global_style = config_block.get("style", "")
global_aspect = config_block.get("aspect_ratio", "16:9")
global_lang = config_block.get("language", "en-US")
# Update Character Sheets
for char in char_blocks:
char["meta_info"] = char.get("meta_info", {})
char["meta_info"]["language"] = global_lang
char["meta_info"]["style"] = global_style
# Remove top-level redundant keys if they exist to avoid confusion
char.pop("language", None)
char.pop("style", None)
# Update Storyboard Items
for block in story_blocks:
meta = block.get("meta_info", {})
meta["style"] = global_style
meta["language"] = global_lang
meta["aspect_ratio"] = global_aspect
# Also inject specific style configs if present
if "bubble_style" in config_block: meta["bubble_style"] = config_block["bubble_style"]
if "narration_style" in config_block: meta["narration_style"] = config_block["narration_style"]
if "border_style" in config_block: meta["border_style"] = config_block["border_style"]
if "gutter_style" in config_block: meta["gutter_style"] = config_block["gutter_style"]
if "layout_settings" in config_block: meta["layout_settings"] = config_block["layout_settings"]
block["meta_info"] = meta
# Save to DB
crud_project.save_characters(session, project_id, char_blocks)
crud_project.save_storyboard(session, project_id, story_blocks)
# Consistency
consistency = ConsistencyService(session)
consistency.normalize_project(project_id)
task.status = "completed"
task.result = {"blocks_found": len(json_blocks)}
task.progress = 100
session.add(task)
session.commit()
logger.info(f"Storyboard task {task_id} completed successfully.")
except Exception as e:
logger.error(f"Storyboard task {task_id} failed: {e}")
traceback.print_exc()
task.status = "failed"
task.message = str(e)
session.add(task)
session.commit()
def generate_all_images_task(task_id: str, project_id: str):
logger.info(f"Starting batch image generation task: {task_id} for project: {project_id}")
from app.core.database import engine
with Session(engine) as session:
task = session.get(Task, task_id)
if not task:
logger.error(f"Task {task_id} not found")
return
task.status = "processing"
session.add(task)
session.commit()
try:
project = session.get(Project, project_id)
ai = AIService(session)
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
static_root = os.path.join(base_dir, "static")
if not os.path.exists(static_root): os.makedirs(static_root)
# 1. Generate Characters
total_chars = len(project.characters)
logger.info(f"Generating {total_chars} characters...")
for i, char in enumerate(project.characters):
if char.image_url:
logger.info(f"Character {char.name} already has image, skipping.")
continue
logger.info(f"Generating image for character: {char.name}")
json_prompt = json.dumps(char.data, ensure_ascii=False, indent=2)
json_prompt += "\n\n generate a character design sheet with 4 panels: front view, side view, clothing details, accessories."
try:
image_bytes = ai.generate_image(json_prompt)
relative_url = save_generated_image(session, project_id, "character", char.id, image_bytes)
char.image_url = relative_url
session.add(char)
session.commit()
logger.info(f"Character {char.name} generated successfully.")
except Exception as e:
logger.error(f"Failed to generate char {char.id}: {e}")
print(f"Failed to generate char {char.id}: {e}")
# Update task progress (Characters are 20% of work?)
# Let's simple split: chars + storyboard items
# 2. Generate Storyboard Items (Sequential)
# Re-fetch items to ensure order
items = sorted(project.storyboard_items, key=lambda x: x.sequence)
total_items = len(items)
logger.info(f"Generating {total_items} storyboard panels...")
generated_history = [] # Keep track of generated images for context
# Populate history with existing images
# Actually comic_generator logic builds history as it goes.
# We should probably load existing images into history if we are resuming?
# For "one click", let's assume we scan all items.
for i, item in enumerate(items):
task.progress = int((i / total_items) * 100)
session.add(task)
session.commit()
if item.image_url:
# Add to history
filename = os.path.basename(item.image_url)
# We need to find where it is stored.
# Assuming standard structure
# We need absolute path for history
# item.image_url is like /static/{project_id}/panels/{filename}
rel_path = item.image_url.lstrip("/")
abs_path = os.path.join(base_dir, rel_path.replace("/", os.sep))
if os.path.exists(abs_path):
generated_history.append(abs_path)
continue
logger.info(f"Generating panel {item.sequence}...")
# Prepare Context
context_images = []
# a) Character Sheets
char_names = item.data.get("characters", [])
if isinstance(char_names, str): char_names = [char_names]
elif isinstance(char_names, list):
names = []
for c in char_names:
if isinstance(c, dict): names.append(c.get("name", ""))
elif isinstance(c, str): names.append(c)
char_names = names
for name in char_names:
for p_char in project.characters:
if p_char.image_url and (p_char.name in name or name in p_char.name):
# Resolve absolute path for char image
rel_path = p_char.image_url.lstrip("/")
abs_path = os.path.join(base_dir, rel_path.replace("/", os.sep))
if os.path.exists(abs_path) and abs_path not in context_images:
context_images.append(abs_path)
# b) Previous History (Last 3 logic)
if len(generated_history) >= 3:
selected = [generated_history[0]] + generated_history[-2:]
else:
selected = generated_history
for path in selected:
if path not in context_images:
context_images.append(path)
# Generate
json_prompt = json.dumps(item.data, ensure_ascii=False, indent=2)
json_prompt += "\n\n use json block as user input prompt to generate 2*2 grid comic image."
try:
image_bytes = ai.generate_image(json_prompt, context_images)
relative_url = save_generated_image(session, project_id, "panel", item.id, image_bytes)
item.image_url = relative_url
session.add(item)
session.commit()
# Add to history (absolute path for context usage)
# We need absolute path for next context
# save_generated_image returns relative /static/...
# Reconstruct absolute path
# strip leading /
abs_path = os.path.join(base_dir, relative_url.lstrip("/").replace("/", os.sep))
generated_history.append(abs_path)
logger.info(f"Panel {item.sequence} generated successfully.")
except Exception as e:
logger.error(f"Failed to generate panel {item.id}: {e}")
print(f"Failed to generate panel {item.id}: {e}")
task.status = "completed"
task.progress = 100
session.add(task)
session.commit()
logger.info(f"Batch generation task {task_id} completed successfully.")
except Exception as e:
logger.error(f"Batch generation task {task_id} failed: {e}")
traceback.print_exc()
task.status = "failed"
task.message = str(e)
session.add(task)
session.commit()
def generate_all_characters_task(task_id: str, project_id: str):
logger.info(f"Starting batch character generation task: {task_id} for project: {project_id}")
from app.core.database import engine
with Session(engine) as session:
task = session.get(Task, task_id)
if not task:
logger.error(f"Task {task_id} not found")
return
task.status = "processing"
session.add(task)
session.commit()
try:
project = session.get(Project, project_id)
ai = AIService(session)
total_chars = len(project.characters)
logger.info(f"Generating {total_chars} characters...")
for i, char in enumerate(project.characters):
if char.image_url:
logger.info(f"Character {char.name} already has image, skipping.")
continue
logger.info(f"Generating image for character: {char.name}")
# Construct Natural Language Prompt from JSON
data = char.data
meta = data.get("meta_info", {})
name = data.get("name", "Unknown")
role = meta.get("role", "")
age = meta.get("age", "")
personality = data.get("personality", "") or meta.get("personality", "")
style = meta.get("style", "")
# Build Description from panels
description = ""
panels = data.get("design_panels", [])
for p in panels:
view = p.get("view", "")
desc = p.get("description", "")
description += f"- {view}: {desc}\n"
prompt = f"""Character Design Request:
Name: {name}
Role: {role}
Age: {age}
Personality: {personality}
Style: {style}
Visual Description:
{description}
Task: Generate a high-quality character reference sheet (Character Design) based on the above description.
Include Front View, Side View, and detailed clothing/accessories.
Ensure the character's expression and pose reflect their personality: {personality}.
"""
try:
image_bytes = ai.generate_image(prompt)
relative_url = save_generated_image(session, project_id, "character", char.id, image_bytes)
char.image_url = relative_url
session.add(char)
session.commit()
logger.info(f"Character {char.name} generated successfully.")
except Exception as e:
logger.error(f"Failed to generate char {char.id}: {e}")
# Update progress
progress = int(((i + 1) / total_chars) * 100)
task.progress = progress
session.add(task)
session.commit()
task.status = "completed"
task.progress = 100
session.add(task)
session.commit()
logger.info(f"Batch character generation task {task_id} completed successfully.")
except Exception as e:
logger.error(f"Batch character generation task {task_id} failed: {e}")
traceback.print_exc()
task.status = "failed"
task.message = str(e)
session.add(task)
session.commit()
def generate_character_task(task_id: str, character_id: int):
logger.info(f"Starting character generation task: {task_id} for char: {character_id}")
from app.core.database import engine
with Session(engine) as session:
task = session.get(Task, task_id)
if not task:
logger.error(f"Task {task_id} not found")
return
task.status = "processing"
session.add(task)
session.commit()
try:
char = session.get(Character, character_id)
if not char:
raise ValueError("Character not found")
ai = AIService(session)
# Construct Natural Language Prompt from JSON
data = char.data
meta = data.get("meta_info", {})
name = data.get("name", "Unknown")
role = meta.get("role", "")
age = meta.get("age", "")
personality = data.get("personality", "") or meta.get("personality", "")
style = meta.get("style", "")
# Build Description from panels
description = ""
panels = data.get("design_panels", [])
for p in panels:
view = p.get("view", "")
desc = p.get("description", "")
description += f"- {view}: {desc}\n"
prompt = f"""Character Design Request:
Name: {name}
Role: {role}
Age: {age}
Personality: {personality}
Style: {style}
Visual Description:
{description}
Task: Generate a high-quality character reference sheet (Character Design) based on the above description.
Include Front View, Side View, and detailed clothing/accessories.
Ensure the character's expression and pose reflect their personality: {personality}.
"""
logger.info(f"Calling AI service for character {char.name}...")
image_bytes = ai.generate_image(prompt)
relative_url = save_generated_image(session, char.project_id, "character", char.id, image_bytes)
char.image_url = relative_url
session.add(char)
task.status = "completed"
task.progress = 100
session.add(task)
session.commit()
logger.info(f"Character task {task_id} completed successfully.")
except Exception as e:
logger.error(f"Character task {task_id} failed: {e}")
traceback.print_exc()
task.status = "failed"
task.message = str(e)
session.add(task)
session.commit()
# --- Endpoints ---
from pydantic import BaseModel
class StoryboardRequest(BaseModel):
user_input: str
@router.post("/storyboard/{project_id}")
def generate_storyboard(
project_id: str,
request: StoryboardRequest,
background_tasks: BackgroundTasks,
session: Session = Depends(get_session)
):
user_input = request.user_input
logger.info(f"Received request to generate storyboard for project {project_id}")
project = crud_project.get_project(session, project_id)
if not project:
logger.error(f"Project {project_id} not found")
raise HTTPException(status_code=404, detail="Project not found")
# Save User Input Immediately
project.story_input = user_input
session.add(project)
session.commit()
# Create Task
task = Task(
type="storyboard",
status="pending",
project_id=project_id,
name="Generate Storyboard",
description=f"Generating storyboard based on user input..."
)
session.add(task)
session.commit()
session.refresh(task)
logger.info(f"Task created: {task.id}")
background_tasks.add_task(generate_storyboard_task, task.id, project_id, user_input)
return {"task_id": task.id}
@router.post("/all-images/{project_id}")
def generate_all_images(
project_id: str,
background_tasks: BackgroundTasks,
session: Session = Depends(get_session)
):
logger.info(f"Received request to generate all images for project {project_id}")
project = crud_project.get_project(session, project_id)
if not project:
logger.error(f"Project {project_id} not found")
raise HTTPException(status_code=404, detail="Project not found")
task = Task(
type="image_generation",
status="pending",
project_id=project_id,
name="Batch Generate Images",
description="Generating all storyboard images"
)
session.add(task)
session.commit()
session.refresh(task)
logger.info(f"Task created: {task.id}")
background_tasks.add_task(generate_all_images_task, task.id, project_id)
return {"task_id": task.id}
# Keep individual endpoints for manual control, but maybe make them async too?
# User asked for "Back task" for "generation". Usually implies the bulk actions.
# Single panel generation is usually fast enough (5-10s), but can be async if desired.
# For now, let's keep single endpoints sync for immediate feedback, or make them async if user insists "All generation".
# The prompt says "Generate text/image takes long time".
# Let's keep single endpoints sync for simplicity of interaction (user waits 5s is ok),
# but "One Click" and "Storyboard" are definitely async.
@router.post("/all-characters/{project_id}")
def generate_all_characters(
project_id: str,
background_tasks: BackgroundTasks,
session: Session = Depends(get_session)
):
logger.info(f"Received request to generate all characters for project {project_id}")
project = crud_project.get_project(session, project_id)
if not project:
logger.error(f"Project {project_id} not found")
raise HTTPException(status_code=404, detail="Project not found")
task = Task(
type="character_generation",
status="pending",
project_id=project_id,
name="Batch Generate Characters",
description="Generating all character design sheets"
)
session.add(task)
session.commit()
session.refresh(task)
logger.info(f"Task created: {task.id}")
background_tasks.add_task(generate_all_characters_task, task.id, project_id)
return {"task_id": task.id}
@router.post("/character/{character_id}")
def generate_character(
character_id: int,
background_tasks: BackgroundTasks,
session: Session = Depends(get_session)
):
char = session.get(Character, character_id)
if not char:
raise HTTPException(status_code=404, detail="Character not found")
task = Task(
type="character_generation",
status="pending",
project_id=char.project_id,
name=f"Draw Character: {char.name}",
description=f"Drawing design sheet for character {char.name}"
)
session.add(task)
session.commit()
session.refresh(task)
background_tasks.add_task(generate_character_task, task.id, character_id)
return {"task_id": task.id}
def generate_panel_task(task_id: str, item_id: int):
logger.info(f"Starting panel generation task: {task_id} for item: {item_id}")
from app.core.database import engine
with Session(engine) as session:
task = session.get(Task, task_id)
if not task:
logger.error(f"Task {task_id} not found")
return
task.status = "processing"
session.add(task)
session.commit()
try:
item = session.get(StoryboardItem, item_id)
if not item:
raise ValueError("Storyboard item not found")
project = item.project
ai = AIService(session)
json_prompt = json.dumps(item.data, ensure_ascii=False, indent=2)
json_prompt += "\n\n use json block as user input prompt to generate 2*2 grid comic image."
context_images = []
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 1. Find Character Images
char_names = item.data.get("characters", [])
if isinstance(char_names, str): char_names = [char_names]
elif isinstance(char_names, list):
names = []
for c in char_names:
if isinstance(c, dict): names.append(c.get("name", ""))
elif isinstance(c, str): names.append(c)
char_names = names
if project.characters:
for name in char_names:
for p_char in project.characters:
if p_char.image_url and (p_char.name in name or name in p_char.name):
rel_path = p_char.image_url.lstrip("/")
abs_path = os.path.join(base_dir, rel_path.replace("/", os.sep))
if os.path.exists(abs_path) and abs_path not in context_images:
context_images.append(abs_path)
# 2. Previous Panels
prev_items = sorted([i for i in project.storyboard_items if i.sequence < item.sequence and i.image_url], key=lambda x: x.sequence)
if prev_items:
selected = []
if len(prev_items) >= 3:
selected = [prev_items[0]] + prev_items[-2:]
else:
selected = prev_items
for prev in selected:
rel_path = prev.image_url.lstrip("/")
abs_path = os.path.join(base_dir, rel_path.replace("/", os.sep))
if os.path.exists(abs_path) and abs_path not in context_images:
context_images.append(abs_path)
# Style Consistency
meta_style = item.data.get("meta_info", {}).get("style", "")
if not meta_style and project.global_config:
meta_style = project.global_config.data.get("style", "")
if meta_style:
json_prompt += f"\n\nStyle Consistency Requirement: {meta_style}. Ensure the visual style matches the provided context images."
logger.info(f"Calling AI service for panel {item.sequence}...")
image_bytes = ai.generate_image(json_prompt, context_images)
relative_url = save_generated_image(session, project.id, "panel", item.id, image_bytes)
item.image_url = relative_url
session.add(item)
task.status = "completed"
task.progress = 100
session.add(task)
session.commit()
logger.info(f"Panel task {task_id} completed successfully.")
except Exception as e:
logger.error(f"Panel task {task_id} failed: {e}")
traceback.print_exc()
task.status = "failed"
task.message = str(e)
session.add(task)
session.commit()
@router.post("/panel/{item_id}")
def generate_panel(
item_id: int,
background_tasks: BackgroundTasks,
session: Session = Depends(get_session)
):
item = session.get(StoryboardItem, item_id)
if not item:
raise HTTPException(status_code=404, detail="Storyboard item not found")
task = Task(
type="image_generation",
status="pending",
project_id=item.project_id,
name=f"Draw Panel: #{item.sequence}",
description=f"Drawing panel {item.sequence}"
)
session.add(task)
session.commit()
session.refresh(task)
background_tasks.add_task(generate_panel_task, task.id, item_id)
return {"task_id": task.id}
+38
View File
@@ -0,0 +1,38 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from app.core.database import get_session
from app.models.models import ImageHistory, Character, StoryboardItem
router = APIRouter()
@router.get("/{entity_type}/{entity_id}")
def get_history(entity_type: str, entity_id: int, session: Session = Depends(get_session)):
# entity_type: 'character' or 'panel'
statement = select(ImageHistory).where(
ImageHistory.entity_type == entity_type,
ImageHistory.entity_id == entity_id
).order_by(ImageHistory.created_at.desc())
history = session.exec(statement).all()
return history
@router.post("/select/{history_id}")
def select_image(history_id: int, session: Session = Depends(get_session)):
history = session.get(ImageHistory, history_id)
if not history:
raise HTTPException(status_code=404, detail="History item not found")
if history.entity_type == "character":
entity = session.get(Character, history.entity_id)
elif history.entity_type == "panel":
entity = session.get(StoryboardItem, history.entity_id)
else:
raise HTTPException(status_code=400, detail="Unknown entity type")
if not entity:
raise HTTPException(status_code=404, detail="Entity not found")
entity.image_url = history.image_url
session.add(entity)
session.commit()
session.refresh(entity)
return {"status": "success", "image_url": entity.image_url}
+205
View File
@@ -0,0 +1,205 @@
from fastapi import APIRouter, Depends, HTTPException, Body
from sqlmodel import Session
from typing import List, Dict
from pydantic import BaseModel
from app.core.database import get_session
from app.models.models import Project, GlobalConfig, Character, StoryboardItem
from app.schemas.schemas import ProjectCreate, ProjectUpdate, ProjectRead
from app.cruds import crud_project
from app.services.consistency_service import ConsistencyService
router = APIRouter()
@router.post("/", response_model=Project)
def create_project(project_in: ProjectCreate, session: Session = Depends(get_session)):
return crud_project.create_project(session, project_in)
@router.get("/", response_model=List[Project])
def read_projects(skip: int = 0, limit: int = 100, session: Session = Depends(get_session)):
return crud_project.get_projects(session, skip, limit)
@router.get("/{project_id}", response_model=ProjectRead)
def read_project(project_id: str, session: Session = Depends(get_session)):
project = crud_project.get_project(session, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
@router.put("/{project_id}", response_model=Project)
def update_project(project_id: str, project_in: ProjectUpdate, session: Session = Depends(get_session)):
project = crud_project.get_project(session, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return crud_project.update_project(session, project, project_in)
@router.delete("/{project_id}")
def delete_project(project_id: str, session: Session = Depends(get_session)):
project = crud_project.get_project(session, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
crud_project.delete_project(session, project)
return {"ok": True}
# --- Data Management Endpoints ---
@router.put("/{project_id}/global_config")
def update_global_config(project_id: str, data: Dict = Body(...), session: Session = Depends(get_session)):
project = crud_project.get_project(session, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# 1. Update Project level fields first
# This ensures consistency between project.theme/language and global_config
if "language" in data:
project.language = data["language"]
if "style" in data:
project.theme = data["style"]
if "aspect_ratio" in data:
project.aspect_ratio = data["aspect_ratio"]
session.add(project)
session.commit()
session.refresh(project)
# 2. Update GlobalConfig in DB
config = crud_project.create_global_config(session, project_id, data)
# 3. Trigger consistency check (Propagate to all items)
consistency = ConsistencyService(session)
consistency.normalize_project(project_id)
return config
@router.put("/{project_id}/characters/{char_id}")
def update_character(project_id: str, char_id: int, data: Dict = Body(...), session: Session = Depends(get_session)):
char = session.get(Character, char_id)
if not char:
raise HTTPException(status_code=404, detail="Character not found")
char.data = data
session.add(char)
session.commit()
session.refresh(char)
# We might want to trigger consistency here too if character style changes,
# but primarily it's driven by global config.
return char
@router.put("/{project_id}/storyboard/{item_id}")
def update_storyboard_item(project_id: str, item_id: int, data: Dict = Body(...), session: Session = Depends(get_session)):
item = session.get(StoryboardItem, item_id)
if not item:
raise HTTPException(status_code=404, detail="Storyboard Item not found")
item.data = data
session.add(item)
session.commit()
session.refresh(item)
return item
@router.delete("/{project_id}/characters/{char_id}")
def delete_character(project_id: str, char_id: int, session: Session = Depends(get_session)):
char = session.get(Character, char_id)
if not char:
raise HTTPException(status_code=404, detail="Character not found")
session.delete(char)
session.commit()
return {"ok": True}
class MergeCharacterRequest(BaseModel):
target_char_id: int
source_char_ids: List[int]
@router.post("/{project_id}/characters/merge")
def merge_characters(
project_id: str,
request: MergeCharacterRequest,
session: Session = Depends(get_session)
):
target_char = session.get(Character, request.target_char_id)
if not target_char:
raise HTTPException(status_code=404, detail="Target character not found")
source_chars = []
for cid in request.source_char_ids:
c = session.get(Character, cid)
if c:
source_chars.append(c)
if not source_chars:
raise HTTPException(status_code=400, detail="No valid source characters found")
target_name = target_char.name
source_names = [c.name for c in source_chars]
# 1. Update Storyboard Items
# We need to scan all items and replace source names with target name
project = session.get(Project, project_id)
if project.storyboard_items:
for item in project.storyboard_items:
data = item.data
# 'characters' field in storyboard item data
# It can be a list of strings, or list of dicts with 'name' key, or a single string
chars = data.get("characters", [])
new_chars = []
modified = False
# Helper to normalize input to list
char_list = []
if isinstance(chars, str): char_list = [chars]
elif isinstance(chars, list): char_list = chars
for c_entry in char_list:
c_name = ""
if isinstance(c_entry, str): c_name = c_entry
elif isinstance(c_entry, dict): c_name = c_entry.get("name", "")
# Check if this name matches any source name
# Fuzzy match or exact? Let's do exact or containment for safety
# User said "Ma Laoguanjia" vs "Ma Guanjia".
# Ideally we replace if it matches one of the source characters' name EXACTLY or close enough?
# Since we selected source characters by ID, we know their names.
# Let's replace if the name in storyboard matches a source character name.
is_source = False
for src_name in source_names:
if src_name == c_name:
is_source = True
break
if is_source:
# Replace with target name
# If entry was dict, update name field? Or just use string?
# Let's keep format.
if isinstance(c_entry, str):
new_chars.append(target_name)
elif isinstance(c_entry, dict):
c_entry['name'] = target_name
new_chars.append(c_entry)
modified = True
else:
new_chars.append(c_entry)
if modified:
# Deduplicate if target name already existed?
# Simple dedup for strings
final_chars = []
seen = set()
for c in new_chars:
n = c if isinstance(c, str) else c.get("name", "")
if n not in seen:
final_chars.append(c)
seen.add(n)
data['characters'] = final_chars
item.data = data
session.add(item)
# 2. Delete Source Characters
for c in source_chars:
session.delete(c)
session.commit()
return {"ok": True, "merged_count": len(source_chars)}
+23
View File
@@ -0,0 +1,23 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from app.core.database import get_session
from app.models.models import Task
from app.schemas.schemas import TaskRead
router = APIRouter()
@router.get("/{task_id}", response_model=TaskRead)
def get_task_status(task_id: str, session: Session = Depends(get_session)):
task = session.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.get("/project/{project_id}", response_model=list[TaskRead])
def get_project_tasks(project_id: str, session: Session = Depends(get_session)):
from sqlmodel import select
statement = select(Task).where(Task.project_id == project_id).order_by(Task.created_at.desc())
tasks = session.exec(statement).all()
# Filter only recent or active tasks if list is too long?
# For now return all, maybe limit 20
return tasks[:20]
View File
+59
View File
@@ -0,0 +1,59 @@
from pydantic import BaseModel
from typing import Optional, Dict, List
from datetime import datetime
from app.models.models import (
ModelConfigBase, ProjectBase, CharacterBase, StoryboardItemBase, GlobalConfigBase, TaskBase,
ModelConfig, Project, Character, StoryboardItem, GlobalConfig, Task
)
# ModelConfig
class ModelConfigCreate(ModelConfigBase):
pass
class ModelConfigUpdate(BaseModel):
provider: Optional[str] = None
api_key: Optional[str] = None
base_url: Optional[str] = None
model_name: Optional[str] = None
model_type: Optional[str] = None
is_active: Optional[bool] = None
# Read Models for nested response
class CharacterRead(CharacterBase):
id: int
project_id: str
class StoryboardItemRead(StoryboardItemBase):
id: int
project_id: str
class GlobalConfigRead(GlobalConfigBase):
id: int
project_id: str
class TaskRead(TaskBase):
id: str
project_id: str
created_at: datetime
updated_at: datetime
# Project
class ProjectCreate(ProjectBase):
pass
class ProjectUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
story_input: Optional[str] = None
theme: Optional[str] = None
language: Optional[str] = None
panel_count: Optional[int] = None
aspect_ratio: Optional[str] = None
class ProjectRead(ProjectBase):
id: str
created_at: datetime
updated_at: datetime
characters: List[CharacterRead] = []
storyboard_items: List[StoryboardItemRead] = []
global_config: Optional[GlobalConfigRead] = None
View File
+92
View File
@@ -0,0 +1,92 @@
import os
import time
from typing import List, Optional
from sqlmodel import Session
from app.models.models import ModelConfig
from google import genai
from PIL import Image
import io
class AIService:
def __init__(self, session: Session):
self.session = session
def _get_client(self, model_type: str):
from app.cruds.crud_config import get_active_config
config = get_active_config(self.session, model_type)
if not config:
raise ValueError(f"No active configuration found for {model_type} model.")
if config.provider.lower() == "google":
# Initialize Google Client
return genai.Client(api_key=config.api_key), config.model_name
raise NotImplementedError(f"Provider {config.provider} not supported yet.")
def generate_storyboard(self, system_prompt: str, user_input: str) -> str:
client, model_name = self._get_client("text")
full_prompt = f"{system_prompt}\n\nUser Input: {user_input}\n\nPlease generate the full storyboard in JSON format as requested."
try:
response = client.models.generate_content(
model=model_name,
contents=full_prompt
)
return response.text
except Exception as e:
print(f"Error generating storyboard: {e}")
raise e
def generate_image(self, prompt: str, context_images: List[str] = None) -> bytes:
client, model_name = self._get_client("image")
contents = [prompt]
if context_images:
for img_path in context_images:
if os.path.exists(img_path):
try:
prev_img = Image.open(img_path)
contents.append(prev_img)
except Exception as e:
print(f"Failed to load context image {img_path}: {e}")
else:
# Log missing context image but don't fail, just skip it
print(f"Warning: Context image not found at {img_path}, skipping.")
# Retry loop
max_retries = 3
for attempt in range(max_retries):
try:
response = client.models.generate_content(
model=model_name,
contents=contents,
)
if response.parts:
for part in response.parts:
if part.inline_data is not None:
image_data = part.inline_data.data
if len(image_data) > 0:
return image_data
else:
print(f"Warning: Received empty image data on attempt {attempt + 1}")
# Check for text refusal/error
if response.text:
print(f"Model response text (no image): {response.text}")
print(f"Attempt {attempt + 1} failed: No valid image data found in response.")
if attempt == max_retries - 1:
raise ValueError(f"No image found in response after {max_retries} retries. Last response: {response.text if response.text else 'Empty'}")
time.sleep(2 ** attempt)
except Exception as e:
print(f"Error generating image (Attempt {attempt + 1}/{max_retries}): {e}")
if attempt < max_retries - 1:
time.sleep(2 ** attempt)
continue
raise e
return b""
+181
View File
@@ -0,0 +1,181 @@
import re
import json
import copy
from typing import List, Dict, Any
from sqlmodel import Session, select
from app.models.models import Project, Character, StoryboardItem, GlobalConfig
class ConsistencyService:
def __init__(self, session: Session):
self.session = session
def normalize_project(self, project_id: str):
"""
Normalizes the project's data (characters, storyboard) based on the global config.
This mirrors the logic in comic_generator.py.
"""
project = self.session.get(Project, project_id)
if not project:
return
# Fetch all related data
# Note: relationships are loaded if accessed, but let's be explicit if needed.
# ProjectRead should handle loading, but here we work with ORM objects.
global_config = project.global_config
characters = project.characters
storyboard_items = sorted(project.storyboard_items, key=lambda x: x.sequence)
master_style = None
master_meta = {}
# Priority 0: Comic Config
if global_config and global_config.data:
config_data = global_config.data
master_style = config_data.get("style")
# Explicitly map all fields we want to sync
master_meta = {
"style": master_style,
"bubble_style": config_data.get("bubble_style"),
"narration_style": config_data.get("narration_style"),
"border_style": config_data.get("border_style"),
"gutter_style": config_data.get("gutter_style"),
"layout_settings": config_data.get("layout_settings"),
"aspect_ratio": config_data.get("aspect_ratio", "16:9"),
"language": config_data.get("language", "English")
}
# Priority 1: First Character Sheet (if no config style)
if not master_style and characters:
# Try to find one with style
for char in characters:
if char.data.get("style"):
master_style = char.data.get("style")
break
# Priority 2: First Story Block
if storyboard_items:
first_item = storyboard_items[0]
first_meta = first_item.data.get("meta_info", {})
if not master_style:
master_style = first_meta.get("style")
if not master_meta:
master_meta = first_meta.copy()
# Build Character Registry
known_characters = {}
for char in characters:
full_name = char.name
if not full_name: continue
keywords = [full_name]
simplified = re.split(r'[\(]', full_name)[0].strip()
if simplified and simplified != full_name:
keywords.append(simplified)
for kw in keywords:
if kw:
known_characters[kw] = full_name
# Apply Normalization
# Even if master_style is None, we might still have layout_settings to sync
# So we check if we have ANY master_meta to apply
if master_meta:
import logging
logger = logging.getLogger(__name__)
logger.info(f"Normalizing project {project_id} with master config: {json.dumps(master_meta, ensure_ascii=False)}")
# 1. Normalize Characters
for char in characters:
char_data = copy.deepcopy(char.data)
if not isinstance(char_data, dict):
char_data = dict(char_data)
meta = char_data.get("meta_info", {})
# Sync Core Fields
if master_meta.get("style"): meta["style"] = master_meta.get("style")
if master_meta.get("language"): meta["language"] = master_meta.get("language")
if master_meta.get("aspect_ratio"): meta["aspect_ratio"] = master_meta.get("aspect_ratio")
char_data["meta_info"] = meta
# Cleanup top-level legacy fields
char_data.pop("style", None)
char_data.pop("language", None)
char_data.pop("Language", None)
char.data = char_data
self.session.add(char)
# 2. Normalize Storyboard Items
total_volumes = len(storyboard_items)
for i, item in enumerate(storyboard_items):
# Use deepcopy to ensure we don't mutate the original object in place before assignment
# and to ensure SQLAlchemy detects the change when we reassign.
item_data = copy.deepcopy(item.data)
if not isinstance(item_data, dict):
item_data = dict(item_data)
# Check for missing characters
plot_text = json.dumps(item_data.get("plot_breakdown", []), ensure_ascii=False)
current_chars = item_data.get("characters", [])
if isinstance(current_chars, str):
current_chars = [current_chars]
if not isinstance(current_chars, list):
current_chars = []
found_missing = []
for kw, full_name in known_characters.items():
if kw in plot_text:
is_present = False
for char_name in current_chars:
if kw in char_name or char_name in full_name:
is_present = True
break
if not is_present:
if full_name not in current_chars and full_name not in found_missing:
found_missing.append(full_name)
if found_missing:
current_chars.extend(found_missing)
item_data["characters"] = current_chars
# Sync Meta Info
meta = item_data.get("meta_info", {})
# We want to preserve existing fields in meta (like volume) but overwrite style configs
# master_meta has style, bubble_style, etc.
# Force update fields from master_meta even if they exist in meta
# But careful with 'None' values in master_meta (though we constructed it from config)
for key, value in master_meta.items():
if key == "volume": continue
# Special handling for boolean values (like False in show_panel_numbers)
# "if value is not None" is correct for booleans.
# For nested dictionaries (like layout_settings), we should merge to preserve other keys.
if value is not None:
if isinstance(value, dict) and isinstance(meta.get(key), dict):
meta[key].update(value)
else:
meta[key] = value
# If value is None but key exists in master_meta keys (explicitly set to null), we might want to unset it?
# But our master_meta construction uses .get() defaults, so None usually means "not in config".
# Ensure Volume format
meta["volume"] = f"{i+1}/{total_volumes}"
item_data["meta_info"] = meta
logger.info(f"Updated Item {i+1} meta: {json.dumps(meta, ensure_ascii=False)}")
item.data = item_data
self.session.add(item)
self.session.commit()
+30
View File
@@ -0,0 +1,30 @@
import os
from PIL import Image
import io
def split_comic_page(image_bytes: bytes) -> list[bytes]:
"""Splits a 2x2 grid comic page into 4 individual panel images (bytes)."""
try:
img = Image.open(io.BytesIO(image_bytes))
width, height = img.size
mid_w = width // 2
mid_h = height // 2
quadrants = [
(0, 0, mid_w, mid_h), # Top-Left
(mid_w, 0, width, mid_h), # Top-Right
(0, mid_h, mid_w, height), # Bottom-Left
(mid_w, mid_h, width, height) # Bottom-Right
]
panels = []
for box in quadrants:
panel = img.crop(box)
buf = io.BytesIO()
panel.save(buf, format="PNG")
panels.append(buf.getvalue())
return panels
except Exception as e:
print(f"Failed to split image: {e}")
return []
View File
+72
View File
@@ -0,0 +1,72 @@
import json
import re
from typing import List, Dict, Any
def extract_json_blocks(text: str) -> List[Dict[str, Any]]:
"""Extracts JSON blocks from the generated text."""
json_blocks = []
# 1. Try to find ```json ... ``` blocks
pattern = r"```(?:json|JSON)?\s*(.*?)\s*```"
matches = re.findall(pattern, text, re.DOTALL)
# 2. If no code blocks found, or even if found, we should also look for raw JSON objects
# because sometimes models output mixed content.
# But let's stick to code blocks first if they exist.
if not matches:
# Fallback: Try to find top-level JSON objects/arrays directly in text
# This regex looks for { ... } or [ ... ] that span multiple lines
# It's not perfect but better than nothing
# We search for anything starting with { or [ and ending with } or ]
# non-greedy match might be safer for multiple blocks
raw_pattern = r"(\{[\s\S]*?\}|\[[\s\S]*?\])"
matches = re.findall(raw_pattern, text)
def repair_json(json_str: str) -> Dict[str, Any]:
"""Attempts to repair common JSON errors, specifically missing commas."""
try:
return json.loads(json_str)
except json.JSONDecodeError:
pass
repaired_str = json_str
max_attempts = 10
for _ in range(max_attempts):
try:
return json.loads(repaired_str)
except json.JSONDecodeError as e:
# print(f"JSON Decode Error at {e.pos}: {e.msg}")
if "Expecting ',' delimiter" in str(e) or "Expecting property name enclosed in double quotes" in str(e):
pos = e.pos
search_str = repaired_str[:pos]
match = re.search(r'([\"}\]0-9])\s*$', search_str)
if match:
insert_idx = match.end()
repaired_str = repaired_str[:insert_idx] + "," + repaired_str[insert_idx:]
continue
# If we can't fix it, re-raise
raise e
return json.loads(repaired_str)
for match in matches:
if not match.strip(): continue
# Simple heuristic to filter out non-json text blocks that might be caught by raw regex
if not (match.strip().startswith('{') or match.strip().startswith('[')):
continue
try:
data = repair_json(match)
if isinstance(data, list):
json_blocks.extend(data)
elif isinstance(data, dict):
json_blocks.append(data)
except Exception as e:
print(f"Failed to parse a JSON block: {e}")
# print(f"Block content: {match[:100]}...")
return json_blocks
Binary file not shown.

After

Width:  |  Height:  |  Size: 822 KiB

+12
View File
@@ -0,0 +1,12 @@
fastapi
uvicorn
sqlmodel
pydantic
pydantic-settings
python-dotenv
google-genai
openai
requests
alembic
pymysql
aiofiles
Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 953 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 953 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

Some files were not shown because too many files have changed in this diff Show More