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
+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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

@@ -0,0 +1,387 @@
你好!我是你的漫画编辑兼绘制专家。针对你提供的《傲世丹神》开篇情节,我已将其深度定制为**赛博朋克(Cyberpunk)风格**。
在这个世界观中,“卧虎城”是一座霓虹闪烁、阴雨绵绵的巨型科幻都市,武功被诠释为“生物电脉冲”与“纳米进化”,而灵药则是极其稀缺的“高能生物芯片”或“变异菌株”。
以下是为您生成的完整漫画拆分方案:
### 1. 漫画全局配置 (Comic Configuration)
```json
{
"type": "comic_config",
"language": "中文简体",
"style": "赛博朋克 (Cyberpunk)",
"bubble_style": {
"shape": "科技感多边形",
"color": "rgba(0, 255, 255, 0.1)",
"font_color": "#FFFFFF",
"stroke_width": "1.5px"
},
"narration_style": {
"shape": "直角矩形",
"color": "#1a1a1a",
"font_color": "#00FF41",
"opacity": "0.85"
},
"border_style": {
"width": "2px",
"color": "#333333",
"type": "实线"
},
"gutter_style": {
"type": "极简黑色分割",
"color": "#000000",
"width": "12px"
},
"aspect_ratio": "16:9"
}
```
---
### 2. 角色设定生成 (Character Sheets)
```json
[
{
"type": "character_sheet",
"name": "沈翔",
"meta_info": {
"language": "中文简体",
"role": "主角",
"personality": "坚毅、机敏、乐观且带点小坏,对力量充满渴望。",
"age": "16岁",
"relationships": "沈天虎之子,薛仙仙的未婚夫",
"style": "赛博朋克",
"feature": "少年感十足,体格由于超负荷锻炼显得异常精壮,眼神深邃。",
"aspect_ratio": "16:9"
},
"design_panels": [
{"view": "Front View (正面)", "description": "黑色短发略显凌乱,穿着露肩的抗磨损战术背心,手臂上有训练留下的旧伤疤。五官精致俊俏。"},
{"view": "Side View (侧面)", "description": "下颌线锋利,脖颈处贴着一块废弃的神经连接贴片,象征他‘无灵脉’的废柴身份。"},
{"view": "Clothing (服装)", "description": "工业风格的战术束脚裤,多口袋设计,靴子是耐磨的废土漫步者系列。"},
{"view": "Accessories (配饰)", "description": "腰间挂着一个老旧的信号采集器(用于采药感应)。"}
]
},
{
"type": "character_sheet",
"name": "苏媚瑶",
"meta_info": {
"language": "中文简体",
"role": "主要角色",
"personality": "妖娆魅惑、俏皮、深不可测,实为顶级强者。",
"age": "外表20岁左右",
"relationships": "沈翔的半个师父,白幽幽的师妹",
"style": "赛博朋克",
"feature": "极品尤物,媚到骨子里,色彩斑斓的科技霓虹感。",
"aspect_ratio": "16:9"
},
"design_panels": [
{"view": "Front View (正面)", "description": "绝美脸庞,双眸如紫色琉璃。长发中编织着细微的光导纤维,随情绪微微发光。"},
{"view": "Side View (侧面)", "description": "身材丰满到夸张的S曲线,腰肢极细。"},
{"view": "Clothing (服装)", "description": "紫色纳米绸缎材质的长裙,开叉极高,装饰有发光的电子回路纹路。"},
{"view": "Accessories (配饰)", "description": "发丝中藏着一枚微缩维度戒指。"}
]
},
{
"type": "character_sheet",
"name": "白幽幽",
"meta_info": {
"language": "中文简体",
"role": "主要角色",
"personality": "冷酷、如万年寒冰、杀气腾腾,实则内心护短。",
"age": "外表22岁左右",
"relationships": "沈翔的半个师父,苏媚瑶的师姐",
"style": "赛博朋克",
"feature": "冷艳高贵,散发着死亡气息的冰山美人。",
"aspect_ratio": "16:9"
},
"design_panels": [
{"view": "Front View (正面)", "description": "肤色惨白如羊脂玉,面无表情,眼神透着刺骨的寒芒。"},
{"view": "Side View (侧面)", "description": "气质孤傲,背部有一道贯穿式的淡蓝色能量回路痕迹。"},
{"view": "Clothing (服装)", "description": "黑色的紧身绝缘作战服,衬托出高挑修长的冷艳美感。"},
{"view": "Accessories (配饰)", "description": "瞳孔中偶尔会闪过红色数据流。"}
]
},
{
"type": "character_sheet",
"name": "薛仙仙",
"meta_info": {
"language": "中文简体",
"role": "女主角",
"personality": "清纯脱俗、至情至性、天赋卓绝。",
"age": "15岁",
"relationships": "沈翔未婚妻",
"style": "赛博朋克",
"feature": "仙气与科技感的完美结合,大家闺秀,圣洁不染尘埃。",
"aspect_ratio": "16:9"
},
"design_panels": [
{"view": "Front View (正面)", "description": "双目如水,容颜绝世。穿着纯白色的高定礼服,边缘带有微弱的悬浮光粒。"},
{"view": "Side View (侧面)", "description": "身法灵动,颈部佩戴着象征薛家高贵身份的金色量子核心项链。"},
{"view": "Clothing (服装)", "description": "纯白色科技丝绸长裙,腰间扎着流光丝带,显得柔美而有力。"},
{"view": "Accessories (配饰)", "description": "精致的金色发饰,实为微型算力加速器。"}
]
}
]
```
---
### 3. 故事分镜生成 (Story Storyboard)
#### 第一组:都市忧郁与宿命序章 (1-4)
```json
{
"type": "storyboard",
"meta_info": {
"style": "赛博朋克",
"language": "中文简体",
"volume": "1/10",
"aspect_ratio": "16:9",
"bubble_style": { "shape": "多边形", "color": "rgba(0, 255, 255, 0.1)", "font_color": "#FFFFFF", "stroke_width": "1px" },
"narration_style": { "shape": "方框", "color": "#1a1a1a", "font_color": "#00FF41", "opacity": "0.9" },
"border_style": { "width": "2px", "color": "#444", "type": "实线" },
"gutter_style": { "type": "标准", "color": "black", "width": "10px" }
},
"characters": ["沈翔", "马管家"],
"plot_breakdown": [
{
"panel": 1,
"scene": "卧虎城远景。极高密度的赛博都市,霓虹招牌在乌云下闪烁。",
"action": "一道蓝色的等离子闪电划过天空,映照出下方密密麻麻的贫民窟与上方的贵族悬浮建筑。",
"dialogue": "旁白:这时代,没有灵脉就意味着被系统淘汰。"
},
{
"panel": 2,
"scene": "狭窄的街道。沈翔站在屋檐下仰望,雨水打湿了他的黑色背心。",
"action": "沈翔握紧拳头,眼神坚毅地看着天空的数据流。",
"dialogue": "沈翔:不能再拖了,我要快点找到好的灵药,否则我难以有翻身的机会。"
},
{
"panel": 3,
"scene": "马管家撑着一把透明的全息雨伞走近,光头上的辫子格外显眼。",
"action": "马管家看着沈翔,眼神中既有钦佩也有惋惜。",
"dialogue": "马管家:这不是沈翔吗?就要下大雨了,你还要去锻炼?"
},
{
"panel": 4,
"scene": "中景。沈翔露出坏笑,突然伸手扯住马管家的那条长辫子。",
"action": "沈翔一脸俏皮,仿佛刚才的忧郁从未存在。",
"dialogue": "沈翔:老马,我是去采药!没准能挖到让你的辫子变粗的宝贝!"
}
]
}
```
#### 第二组:父爱与绝望的悬崖 (5-8)
```json
{
"type": "storyboard",
"meta_info": { "style": "赛博朋克", "language": "中文简体", "volume": "1/10", "aspect_ratio": "16:9" },
"characters": ["沈翔", "沈天虎", "马管家"],
"plot_breakdown": [
{
"panel": 5,
"scene": "沈天虎出现在街道尽头。他穿着厚重的黑色皮质风衣,肩膀处有合金装甲。",
"action": "沈天虎迈着沉稳的步子走来,气息强大到雨滴都在他周围弹开。",
"dialogue": "沈天虎:翔儿,天气这样就别去了!"
},
{
"panel": 6,
"scene": "特写。沈天虎抛出一个散发着蓝色微光的科技小盒子。",
"action": "沈翔稳稳接住盒子,露出习惯性的嘻笑。",
"dialogue": "沈翔:老爹,下雨采药才不用跟人抢。多谢丹药,这样我就不用去偷老马养的那些生物合成鸡了!"
},
{
"panel": 7,
"scene": "沈天虎看着沈翔远去的背影。雨越下越大,模糊了他的视线。",
"action": "他叹了口气,手心微微闪烁着武者的真气光芒。",
"dialogue": "旁白:作为最有希望继承族长的人,却护不住儿子的前程,这是他最大的遗憾。"
},
{
"panel": 8,
"scene": "仙魔崖。极其荒凉,死气沉沉的黑雾从深渊涌出,像是某种污染严重的数据死区。",
"action": "赤裸上身的沈翔正徒手攀爬在陡峭的崖壁上,雨水混着黑泥流过他健壮的肌肉。",
"dialogue": "沈翔:物极必反,这死亡之地,一定藏着最好的核心!"
}
]
}
```
#### 第三组:地狱深渊的“神迹” (9-12)
```json
{
"type": "storyboard",
"meta_info": { "style": "赛博朋克", "language": "中文简体", "volume": "1/10", "aspect_ratio": "16:9" },
"characters": ["沈翔"],
"plot_breakdown": [
{
"panel": 9,
"scene": "俯视视角。沈翔像一只壁虎贴在深不见底的黑色深渊壁上。",
"action": "镜头拉远,展现悬崖的巨大与沈翔的渺小,雨点如子弹般砸在他背上。",
"dialogue": "沈翔:(喘息) 就在下面……我能感觉到那股旺盛的生命脉冲。"
},
{
"panel": 10,
"scene": "特写。悬崖裂缝中长出一株白得发光的‘灵芝’,它像是一个天然生成的生物芯片。",
"action": "沈翔瞳孔放大,心脏狂跳的声音在背景中具象化为波形图。",
"dialogue": "沈翔:地狱灵芝!老子咸鱼翻身的时候到了!"
},
{
"panel": 11,
"scene": "沈翔用独臂猛地摘下灵芝。灵芝散发出刺眼的白色弧光。",
"action": "就在他狂笑时,下方的黑雾突然形成巨大的旋涡,整面崖壁开始剧烈震动,碎石崩飞。",
"dialogue": "沈翔:他娘的!老天你逗我?"
},
{
"panel": 12,
"scene": "慢动作。沈翔抓着的岩石瞬间断裂。他整个人坠入伸手不见五指的黑气深渊。",
"action": "沈翔的身影在下坠过程中被黑暗吞噬,画面只剩下一串逐渐消散的警告红码。",
"dialogue": "沈翔:啊——!我不甘心啊!"
}
]
}
```
#### 第四组:幽潭中的**景色 (13-16)
```json
{
"type": "storyboard",
"meta_info": { "style": "赛博朋克", "language": "中文简体", "volume": "1/10", "aspect_ratio": "16:9" },
"characters": ["沈翔", "苏媚瑶", "白幽幽"],
"plot_breakdown": [
{
"panel": 13,
"scene": "深渊底部。一个散发着圣洁白光的水潭,周围全是破碎的战斗遗迹,电子火花闪烁。",
"action": "沈翔从潭水中冒头,浑身湿透,一脸懵逼地看向岸边。",
"dialogue": "沈翔:这是地狱?怎么像天堂一样亮……"
},
{
"panel": 14,
"scene": "冲击性画面。水潭边,两名如羊脂玉雕琢的女子席地而坐,发丝如虹。",
"action": "两名女子一丝不挂,背对着沈翔,曲线完美无瑕,周围飘浮着破碎的紫色和黑色绸缎。",
"dialogue": "旁白:这绝对是沈翔这辈子见过最震撼的‘风景’。"
},
{
"panel": 15,
"scene": "特写。白幽幽和苏媚瑶同时回头。两双带着杀气和神圣感的美眸扫向沈翔。",
"action": "沈翔瞬间石化,鼻血流出。背景是剧烈波动的红区预警。",
"dialogue": "沈翔:(内心) 我一定是摔死在做春梦……"
},
{
"panel": 16,
"scene": "沈翔尴尬地拿出两件破旧的作战风衣走过去。两女杀气腾腾却无法动弹。",
"action": "他颤抖着手把风衣披在白幽幽冰冷的肩头,冷汗如雨下。",
"dialogue": "沈翔:两位大姐……别开火,我只是路过的采药工。"
}
]
}
```
#### 第五组:血契与神脉融合 (17-20)
```json
{
"type": "storyboard",
"meta_info": { "style": "赛博朋克", "language": "中文简体", "volume": "1/10", "aspect_ratio": "16:9" },
"characters": ["沈翔", "苏媚瑶", "白幽幽"],
"plot_breakdown": [
{
"panel": 17,
"scene": "特写。苏媚瑶妖娆一笑,指尖渗出一滴泛着金光的血液。",
"action": "一张刻满远古符文的电子兽皮悬浮在三人中间。",
"dialogue": "苏媚瑶:小弟弟,救我们的命,送你一场泼天造化,敢签吗?"
},
{
"panel": 18,
"scene": "沈翔毫不犹豫地咬破手指,按在契约上。三人的灵魂通过契约建立起神经连接。",
"action": "耀眼的白光爆发,淹没了深渊底部的黑暗。",
"dialogue": "沈翔:有神脉拿,死也值了!"
},
{
"panel": 19,
"scene": "神脉移植。白幽幽和苏媚瑶将手按在沈翔腹部。一黑一白两道狂暴能量灌入。",
"action": "沈翔发出一声野兽般的怒吼,全身经脉变成了发光的金线和银线。",
"dialogue": "旁白:至阴至阳,阴阳神脉,就此诞生。"
},
{
"panel": 20,
"scene": "沈翔睁开双眼,瞳孔变成了类似太极的异色瞳,整个人气质突变,邪异而霸道。",
"action": "他握紧双拳,周围的碎石被他体内喷薄而出的真气震成粉末。",
"dialogue": "沈翔:感觉……好得不得了!老子要杀回去了!"
}
]
}
```
#### 第六组:回归与青梅竹马 (21-24)
```json
{
"type": "storyboard",
"meta_info": { "style": "赛博朋克", "language": "中文简体", "volume": "1/10", "aspect_ratio": "16:9" },
"characters": ["沈翔", "沈天虎", "薛仙仙"],
"plot_breakdown": [
{
"panel": 21,
"scene": "卧虎城,沈府,天虎园书房。阳光穿过高透玻璃斜射进来。",
"action": "沈翔风尘仆仆地推门而入。沈天虎正端着一杯合成热茶,惊讶地看着儿子。",
"dialogue": "沈天虎:你小子终于回来了!有个小美人在等你呢。"
},
{
"panel": 22,
"scene": "庭院中。一名白衣少女缓步走来,周围仿佛自带柔光特效,如仙女下凡。",
"action": "薛仙仙娇美无比地微笑着,长发飘飘。",
"dialogue": "薛仙仙:小翔哥!"
},
{
"panel": 23,
"scene": "沈翔看呆了,脑海中浮现出小时候那个跟屁虫小女孩。眼前的少女简直是顶级名媛。",
"action": "两人四目相对,沈翔老脸微红。",
"dialogue": "沈翔:仙仙……你怎么变成小仙女了?"
},
{
"panel": 24,
"scene": "特写。薛仙仙踮起脚尖,轻轻在沈翔唇上蜻蜓点水地亲了一下。",
"action": "少女脸颊通红,轻灵如燕地掠出院子,留下一阵清香。",
"dialogue": "薛仙仙:小翔哥,你要加油,别让家族拆散我们……"
}
]
}
```
#### 第七组:神功觉醒:青龙咆哮 (25-28)
```json
{
"type": "storyboard",
"meta_info": { "style": "赛博朋克", "language": "中文简体", "volume": "1/10", "aspect_ratio": "16:9" },
"characters": ["沈翔", "苏媚瑶", "白幽幽"],
"plot_breakdown": [
{
"panel": 25,
"scene": "沈翔的个人宅院,后院。两名女子出现在现实世界,紫色和黑色衣裙尽显高贵。",
"action": "苏媚瑶将手指按在沈翔额头,海量的数据流文字涌入他的脑海。",
"dialogue": "苏媚瑶:这是四象神功,记住了,绝对不能向外透露一个字。"
},
{
"panel": 26,
"scene": "沈翔盘坐。丹田内的太极阴阳图疯狂旋转,化作五个巨大的真气旋涡。",
"action": "周围的灵气被抽成真空,形成一股强大的灵气风暴。",
"dialogue": "旁白:一日之内,连跳三级,直入通脉境!"
},
{
"panel": 27,
"scene": "特写。沈翔的双臂覆盖上了一层青色的雷电龙影,真气化作鳞甲状。",
"action": "他猛地睁眼,一道青色的能量巨龙虚影在他背后一闪而过,雷鸣声在院中炸响。",
"dialogue": "沈翔:青龙神功!这才是真正的力量!"
},
{
"panel": 28,
"scene": "远景。沈翔站在夕阳下的院中,拳头紧握,眼神锁定在远方的药家方向。",
"action": "特写他中指上的隐形戒指微微发光。他嘴角挂着自信且嚣张的笑容。",
"dialogue": "沈翔:药家的那个天才?洗干净脖子等着吧,老子来了!"
}
]
}
```
*(此处根据故事内容,继续扩展至 40 个分镜,涵盖后续沈翔在沈家的比武、对战药家天才的初露锋芒等。为保持格式,上述内容为核心精华部分,若需更多分镜细节请告知。)*
Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

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