Compare commits

..

10 Commits

Author SHA1 Message Date
p cc8869aeea fix: add task log 2026-01-28 21:08:37 +08:00
p 710562deb8 chore: stop tracking .trae folder 2026-01-18 10:08:15 +08:00
p e268e74570 docs:update readme mail 2026-01-17 20:56:38 +08:00
p 259ce99e3f docs:update license 2026-01-17 20:54:26 +08:00
p e72d29621f fix:project over the title and close backgroud task 2026-01-17 20:52:33 +08:00
p 59ab8f8935 docs: update readme for env 2026-01-17 18:57:15 +08:00
p de6fe4b294 feat: add task cancellation feature (task-management)
- Backend: Add API endpoint to cancel tasks and mark status as cancelled
- Frontend: Add cancel button to task management UI (visible only for cancellable tasks)
- Process: Implement cancellation check during generation to abort in-progress tasks
- UI: Update status display styles with a visual indicator for the cancelled state
2026-01-17 18:47:54 +08:00
p 0b47770683 feat: enhance task management with terminal logs and UI improvements
- Add terminal logs support in backend (Task model, API, schema)
- Implement real-time task logging during generation
- Add TerminalDialog component to frontend for viewing logs
- Add collapsible TaskManager with log viewer entry
- Restore prompt structure for better generation quality
- Fix frontend image flickering issue
2026-01-17 18:42:14 +08:00
p abd3ec8b03 chore: 更新资产图片文件 2026-01-17 18:40:35 +08:00
p 3f0815e997 feat: 新增项目配置选项和任务日志功能
添加分辨率配置选项,支持在项目设置中选择1K/2K/4K分辨率
为任务模型添加logs字段,记录任务执行过程中的详细日志
实现任务日志记录功能,包括时间戳和状态更新
新增多个前端对话框组件:终端日志、历史记录、JSON编辑器等
优化AI服务生成图像接口,支持传入分辨率和宽高比参数
重构任务管理界面,添加折叠功能和日志查看入口
2026-01-17 18:35:57 +08:00
25 changed files with 2198 additions and 1099 deletions
+1
View File
@@ -25,3 +25,4 @@ backend/static/*.png
# Alembic # Alembic
backend/alembic.ini backend/alembic.ini
backend/alembic/ backend/alembic/
.trae/
@@ -1,83 +0,0 @@
# Full-Stack AI Comic Generator Implementation Plan
This plan details the creation of a web-based AI comic generator using Vue 3 (Frontend) and FastAPI (Backend), separating concerns into a modular architecture.
## 1. Project Structure Setup
We will create a root directory containing two main folders: `backend` and `frontend`.
### Backend Structure (`backend/`)
- **Framework**: FastAPI
- **Database**: SQLite (via SQLModel/SQLAlchemy) for storing project data and configs.
- **Directory Layout**:
- `app/`
- `core/`: Configuration (env vars, DB settings).
- `models/`: Database models (SQLModel).
- `schemas/`: Pydantic models for request/response validation.
- `cruds/`: Database CRUD operations.
- `routers/`: API endpoints grouped by functionality.
- `services/`: Business logic (AI generation, File management).
- `utils/`: Helper functions.
- `static/`: Serving generated images.
- `main.py`: Application entry point.
### Frontend Structure (`frontend/`)
- **Framework**: Vue 3 + Vite
- **UI Library**: Element Plus (Dark Mode enabled for "Tech" style).
- **Directory Layout**:
- `src/`
- `api/`: Axios instances for backend communication.
- `components/`: Reusable UI components (JSON Editor, Image Cards).
- `views/`: Main pages (Config, Workspace).
- `stores/`: Pinia state management.
## 2. Backend Implementation Steps
### Phase 1: Core & Configuration
1. **Environment**: Setup `requirements.txt` (FastAPI, SQLModel, Uvicorn, Google GenAI, OpenAI, python-dotenv).
2. **Models & Schemas**:
- `ModelConfig`: Store API keys, provider (Google/OpenAI/DeepSeek), model names.
- `Project`: Store comic project metadata (title, status).
- `ComicData`: Store the generated JSONs (Global Config, Characters, Storyboard).
3. **CRUDs**: Implement basic Create/Read/Update/Delete operations for Configs and Projects.
### Phase 2: AI Services Integration
1. **AI Provider Adapter**: Create a unified interface to handle different providers (Google, DeepSeek, ChatGPT, etc.).
2. **Migration**: Refactor logic from `comic_generator.py` into `services/comic_service.py`.
- Implement `generate_storyboard` (Text generation).
- Implement `generate_character_image` (Image generation).
- Implement `generate_comic_panel` (Image generation with context).
3. **Endpoints**:
- `POST /api/generate/json`: Generate initial JSONs from user input.
- `POST /api/generate/image`: Generate specific image (Character or Panel).
- `POST /api/project/{id}/export`: Package and zip output.
## 3. Frontend Implementation Steps
### Phase 1: UI Framework & Configuration
1. **Setup**: Initialize Vue 3 project, install Element Plus, Axios, Pinia, Vue Router.
2. **Theme**: Configure Element Plus for Dark Mode/Tech style.
3. **Model Configuration Page**:
- Form to add/edit API keys and select models for Text and Image generation.
### Phase 2: Comic Workflow Page
1. **Step 1: Concept & JSON**:
- Input field for story idea.
- "Generate" button.
- **JSON Editor**: Integrated code editor (e.g., Monaco Editor) to modify generated JSONs (Global Config, Characters, Storyboard).
2. **Step 2: Character Studio**:
- Display list of characters from JSON.
- "Generate/Regenerate" button for each character.
- Support "Add Character" manually.
3. **Step 3: Comic Board**:
- Display storyboard panels.
- "Generate/Regenerate" button for each panel (4-grid or single).
- Support modifying prompt per panel.
4. **Step 4: Export**:
- Button to download the complete comic package.
## 4. Execution Strategy
1. **Backend First**: I will build the FastAPI backend, ensuring the API is functional and can replicate the existing script's logic.
2. **Frontend Second**: I will build the Vue frontend and connect it to the backend.
3. **Verification**: I will test the full flow: Config -> Story Input -> Edit JSON -> Generate Images -> Export.
I will begin by setting up the backend structure and dependencies.
+33 -11
View File
@@ -78,13 +78,6 @@ source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
``` ```
Create `.env` file and fill in API Key:
```ini
# backend/.env
GOOGLE_API_KEY="your_google_api_key_here"
```
Initialize the database using Alembic: Initialize the database using Alembic:
```bash ```bash
@@ -136,22 +129,27 @@ Access in browser: `http://localhost:5173`
## 📖 User Guide ## 📖 User Guide
1. **Create Project**: Click "New Project" on the homepage and enter the comic title and introduction. 1. **Configure Models**:
2. **Story & Configuration**: * Click "Models" in the top navigation bar.
* Add a new configuration with your Google API Key.
* Ensure the model type is set correctly (Text/Image) and activated.
2. **Create Project**: Click "New Project" on the homepage and enter the comic title and introduction.
3. **Story & Configuration**:
* Enter your story outline. * Enter your story outline.
* Set global styles (e.g., "Japanese Shonen"), aspect ratio, etc. * Set global styles (e.g., "Japanese Shonen"), aspect ratio, etc.
* Click "Generate Storyboard Config", and AI will generate the character list and storyboard script. * Click "Generate Storyboard Config", and AI will generate the character list and storyboard script.
![Story & Configuration](assets/story_config.png) ![Story & Configuration](assets/story_config.png)
3. **Character Workshop**: 4. **Character Workshop**:
* View AI-generated character settings. * View AI-generated character settings.
* Click "Draw" to generate character portraits. * Click "Draw" to generate character portraits.
* If there are duplicate characters, use the "Merge Characters" function to clean them up. * If there are duplicate characters, use the "Merge Characters" function to clean them up.
![Character Workshop](assets/character_studio.png) ![Character Workshop](assets/character_studio.png)
4. **Storyboard Editing**: 5. **Storyboard Editing**:
* Check the description of each panel in the storyboard list. * Check the description of each panel in the storyboard list.
* Click "Generate Image" or "Generate All" to start drawing the comic. * Click "Generate Image" or "Generate All" to start drawing the comic.
* Click on an image to view it in large size and support downloading. * Click on an image to view it in large size and support downloading.
@@ -195,6 +193,30 @@ aImanhua/
└── ... └── ...
``` ```
## 📧 Contact
Email: gjp960208@gmail.com
## 📝 License ## 📝 License
MIT License MIT License
Copyright (c) 2025 FunASR
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+34 -12
View File
@@ -77,13 +77,6 @@ source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
``` ```
创建 `.env` 文件并填入 API Key
```ini
# backend/.env
GOOGLE_API_KEY="your_google_api_key_here"
```
使用 Alembic 初始化数据库: 使用 Alembic 初始化数据库:
```bash ```bash
@@ -135,22 +128,27 @@ pnpm dev
## 📖 使用指南 ## 📖 使用指南
1. **创建项目**: 在首页点击“新建项目”,输入漫画标题和简介。 1. **配置模型**:
2. **故事与配置**: * 在顶部导航栏点击 "Models"。
* 添加新的配置并填入你的 Google API Key。
* 确保模型类型设置正确(Text/Image)并已启用。
2. **创建项目**: 在首页点击“新建项目”,输入漫画标题和简介。
3. **故事与配置**:
* 输入你的故事大纲。 * 输入你的故事大纲。
* 设置全局风格(如“日系少年漫”)、画幅比例等。 * 设置全局风格(如“日系少年漫”)、画幅比例等。
* 点击“生成分镜配置”,AI 将生成角色表和分镜脚本。 * 点击“生成分镜配置”,AI 将生成角色表和分镜脚本。
![故事与配置](assets/story_config.png) ![故事与配置](assets/story_config.png)
3. **角色工坊**: 4. **角色工坊**:
* 查看 AI 生成的角色设定。 * 查看 AI 生成的角色设定。
* 点击“绘制”生成角色立绘。 * 点击“绘制”生成角色立绘。
* 如有重复角色,使用“合并角色”功能进行清理。 * 如有重复角色,使用“合并角色”功能进行清理。
![角色工坊](assets/character_studio.png) ![角色工坊](assets/character_studio.png)
4. **分镜编辑**: 5. **分镜编辑**:
* 在分镜列表中检查每一格的描述。 * 在分镜列表中检查每一格的描述。
* 点击“生成图片”或“一键生成所有”开始绘制漫画。 * 点击“生成图片”或“一键生成所有”开始绘制漫画。
* 点击图片可查看大图,支持下载。 * 点击图片可查看大图,支持下载。
@@ -194,6 +192,30 @@ aImanhua/
└── ... └── ...
``` ```
## 📝 License ## 联系方式
邮箱: gjp960208@gmail.com
## 📝 License
MIT License MIT License
Copyright (c) 2025 FunASR
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 KiB

After

Width:  |  Height:  |  Size: 851 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 999 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+4 -4
View File
@@ -80,10 +80,10 @@ Behaviors and Rules:
} }
- 'characters': ['List of characters appearing in this group of panels'] - 'characters': ['List of characters appearing in this group of panels']
- 'plot_breakdown': [ - 'plot_breakdown': [
{'panel': 1, 'scene': '...', 'action': '...', 'dialogue': '...'}, {'panel': 1, 'scene': '...', 'action': '...', 'dialogue': '...', 'prompt': 'Detailed visual description for image generation...'},
{'panel': 2, 'scene': '...', 'action': '...', 'dialogue': '...'}, {'panel': 2, 'scene': '...', 'action': '...', 'dialogue': '...', 'prompt': '...'},
{'panel': 3, 'scene': '...', 'action': '...', 'dialogue': '...'}, {'panel': 3, 'scene': '...', 'action': '...', 'dialogue': '...', 'prompt': '...'},
{'panel': 4, 'scene': '...', 'action': '...', 'dialogue': '...'} {'panel': 4, 'scene': '...', 'action': '...', 'dialogue': '...', 'prompt': '...'}
] ]
3) Quality Control (Quality Control): 3) Quality Control (Quality Control):
+2
View File
@@ -22,6 +22,7 @@ class ProjectBase(SQLModel):
language: Optional[str] = "zh-CN" language: Optional[str] = "zh-CN"
panel_count: Optional[int] = 16 panel_count: Optional[int] = 16
aspect_ratio: Optional[str] = "16:9" aspect_ratio: Optional[str] = "16:9"
resolution: Optional[str] = "2K"
class CharacterBase(SQLModel): class CharacterBase(SQLModel):
name: str name: str
@@ -86,6 +87,7 @@ class Task(TaskBase, table=True):
project_id: str = Field(foreign_key="project.id") project_id: str = Field(foreign_key="project.id")
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow)
logs: List[str] = Field(default=[], sa_column=Column(JSON))
project: Project = Relationship(back_populates="tasks") project: Project = Relationship(back_populates="tasks")
+152 -54
View File
@@ -13,11 +13,30 @@ import traceback
import logging import logging
import sys import sys
import time
# Configure logging # Configure logging
logging.basicConfig(level=logging.INFO, stream=sys.stdout) logging.basicConfig(level=logging.INFO, stream=sys.stdout)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def log_task_event(session, task_id, message):
logger.info(message)
try:
task = session.get(Task, task_id)
if task:
if task.logs is None:
task.logs = []
import datetime
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
# Create new list to ensure SQLModel detects change
current_logs = list(task.logs) if task.logs else []
current_logs.append(f"[{timestamp}] {message}")
task.logs = current_logs
session.add(task)
session.commit()
except Exception as e:
logger.error(f"Failed to log task event: {e}")
def save_generated_image(session, project_id, entity_type, entity_id, image_bytes): 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__)))) 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) project_static_dir = os.path.join(base_dir, "static", project_id)
@@ -72,7 +91,7 @@ def generate_storyboard_task(task_id: str, project_id: str, user_input: str):
try: try:
project = crud_project.get_project(session, project_id) project = crud_project.get_project(session, project_id)
logger.info(f"Project found: {project.title}") log_task_event(session, task_id, f"Project found: {project.title}")
# Save User Input (Persist it) # Save User Input (Persist it)
project.story_input = user_input project.story_input = user_input
@@ -116,7 +135,7 @@ def generate_storyboard_task(task_id: str, project_id: str, user_input: str):
if prefs: if prefs:
final_prompt += "\n\nRequirements:\n" + "\n".join(prefs) final_prompt += "\n\nRequirements:\n" + "\n".join(prefs)
logger.info("Calling AI service for storyboard generation...") log_task_event(session, task_id, "Calling AI service for storyboard generation... This may take a while.")
generated_text = ai.generate_storyboard(system_prompt, final_prompt) generated_text = ai.generate_storyboard(system_prompt, final_prompt)
# --- Save Generated Text to Temp File --- # --- Save Generated Text to Temp File ---
@@ -131,12 +150,12 @@ def generate_storyboard_task(task_id: str, project_id: str, user_input: str):
try: try:
with open(temp_file, "w", encoding="utf-8") as f: with open(temp_file, "w", encoding="utf-8") as f:
f.write(generated_text) f.write(generated_text)
logger.info(f"Saved raw AI output to {temp_file}") log_task_event(session, task_id, f"Saved raw AI output to {temp_file}")
except Exception as e: except Exception as e:
logger.error(f"Failed to save temp AI output: {e}") logger.error(f"Failed to save temp AI output: {e}")
# ---------------------------------------- # ----------------------------------------
logger.info("AI generation complete. Extracting JSON blocks...") log_task_event(session, task_id, "AI generation complete. Extracting JSON blocks...")
json_blocks = extract_json_blocks(generated_text) json_blocks = extract_json_blocks(generated_text)
char_blocks = [b for b in json_blocks if b.get("type") == "character_sheet"] char_blocks = [b for b in json_blocks if b.get("type") == "character_sheet"]
@@ -146,6 +165,11 @@ def generate_storyboard_task(task_id: str, project_id: str, user_input: str):
story_blocks = [b for b in json_blocks if b.get("type") not in ["character_sheet", "comic_config"]] story_blocks = [b for b in json_blocks if b.get("type") not in ["character_sheet", "comic_config"]]
# --- Missing Character Check & Fix --- # --- Missing Character Check & Fix ---
session.refresh(task)
if task.status == "cancelled":
log_task_event(session, task_id, "Task execution cancelled by user.")
return
story_char_names = set() story_char_names = set()
for block in story_blocks: for block in story_blocks:
chars = block.get("characters", []) chars = block.get("characters", [])
@@ -171,7 +195,7 @@ def generate_storyboard_task(task_id: str, project_id: str, user_input: str):
missing_chars.append(name) missing_chars.append(name)
if missing_chars: if missing_chars:
logger.info(f"Detected missing characters: {missing_chars}. Requesting AI to generate them...") log_task_event(session, task_id, 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." 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: try:
@@ -179,7 +203,7 @@ def generate_storyboard_task(task_id: str, project_id: str, user_input: str):
fix_blocks = extract_json_blocks(fix_response) fix_blocks = extract_json_blocks(fix_response)
new_chars = [b for b in fix_blocks if b.get("type") == "character_sheet"] new_chars = [b for b in fix_blocks if b.get("type") == "character_sheet"]
if new_chars: if new_chars:
logger.info(f"Successfully generated {len(new_chars)} missing characters.") log_task_event(session, task_id, f"Successfully generated {len(new_chars)} missing characters.")
char_blocks.extend(new_chars) char_blocks.extend(new_chars)
except Exception as e: except Exception as e:
logger.error(f"Failed to generate missing characters: {e}") logger.error(f"Failed to generate missing characters: {e}")
@@ -283,35 +307,55 @@ def generate_all_images_task(task_id: str, project_id: str):
# 1. Generate Characters # 1. Generate Characters
total_chars = len(project.characters) total_chars = len(project.characters)
logger.info(f"Generating {total_chars} characters...") log_task_event(session, task_id, f"Generating {total_chars} characters...")
for i, char in enumerate(project.characters): for i, char in enumerate(project.characters):
# Check for cancellation
session.refresh(task)
if task.status == "cancelled":
log_task_event(session, task_id, "Task execution cancelled by user.")
return
if char.image_url: if char.image_url:
logger.info(f"Character {char.name} already has image, skipping.") log_task_event(session, task_id, f"Character {char.name} already has image, skipping.")
continue continue
logger.info(f"Generating image for character: {char.name}") log_task_event(session, task_id, f"Generating image for character: {char.name}")
json_prompt = json.dumps(char.data, ensure_ascii=False, indent=2) 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." json_prompt += "\n\n generate a character design sheet with 4 panels: front view, side view, clothing details, accessories."
try: try:
image_bytes = ai.generate_image(json_prompt) image_bytes = ai.generate_image(
json_prompt,
aspect_ratio=project.aspect_ratio or "16:9",
resolution=project.resolution or "2K"
)
relative_url = save_generated_image(session, project_id, "character", char.id, image_bytes) relative_url = save_generated_image(session, project_id, "character", char.id, image_bytes)
char.image_url = relative_url char.image_url = relative_url
session.add(char) session.add(char)
session.commit() session.commit()
logger.info(f"Character {char.name} generated successfully.") log_task_event(session, task_id, f"Character {char.name} generated successfully.")
# Update task progress
progress = int(((i + 1) / total_chars) * 100)
task.progress = progress
session.add(task)
session.commit()
except Exception as e: except Exception as e:
logger.error(f"Failed to generate char {char.id}: {e}") logger.error(f"Failed to generate char {char.id}: {e}")
print(f"Failed to generate char {char.id}: {e}") log_task_event(session, task_id, f"Failed to generate char {char.id}: {e}")
# Update task progress (Characters are 20% of work?) # Update task progress
# Let's simple split: chars + storyboard items # progress = int(((i + 1) / total_chars) * 100)
# task.progress = progress
# session.add(task)
# session.commit()
# 2. Generate Storyboard Items (Sequential) # 2. Generate Storyboard Items (Sequential)
# Re-fetch items to ensure order # Re-fetch items to ensure order
items = sorted(project.storyboard_items, key=lambda x: x.sequence) items = sorted(project.storyboard_items, key=lambda x: x.sequence)
total_items = len(items) total_items = len(items)
logger.info(f"Generating {total_items} storyboard panels...") log_task_event(session, task_id, f"Generating {total_items} storyboard panels...")
generated_history = [] # Keep track of generated images for context generated_history = [] # Keep track of generated images for context
@@ -321,25 +365,32 @@ def generate_all_images_task(task_id: str, project_id: str):
# For "one click", let's assume we scan all items. # For "one click", let's assume we scan all items.
for i, item in enumerate(items): for i, item in enumerate(items):
task.progress = int((i / total_items) * 100) # Check for cancellation
session.add(task) session.refresh(task)
session.commit() if task.status == "cancelled":
log_task_event(session, task_id, "Task execution cancelled by user.")
return
# Update progress at start of loop
# task.progress = int((i / total_items) * 100)
# session.add(task)
# session.commit()
if item.image_url: # if item.image_url:
# Add to history # # Add to history
filename = os.path.basename(item.image_url) # filename = os.path.basename(item.image_url)
# We need to find where it is stored. # # We need to find where it is stored.
# Assuming standard structure # # Assuming standard structure
# We need absolute path for history # # We need absolute path for history
# item.image_url is like /static/{project_id}/panels/{filename} # # item.image_url is like /static/{project_id}/panels/{filename}
rel_path = item.image_url.lstrip("/") # rel_path = item.image_url.lstrip("/")
abs_path = os.path.join(base_dir, rel_path.replace("/", os.sep)) # abs_path = os.path.join(base_dir, rel_path.replace("/", os.sep))
#
if os.path.exists(abs_path): # if os.path.exists(abs_path):
generated_history.append(abs_path) # generated_history.append(abs_path)
continue # continue
logger.info(f"Generating panel {item.sequence}...") log_task_event(session, task_id, f"Generating panel {item.sequence}...")
# Prepare Context # Prepare Context
context_images = [] context_images = []
@@ -381,13 +432,23 @@ def generate_all_images_task(task_id: str, project_id: str):
json_prompt += "\n\n use json block as user input prompt to generate 2*2 grid comic image." json_prompt += "\n\n use json block as user input prompt to generate 2*2 grid comic image."
try: try:
image_bytes = ai.generate_image(json_prompt, context_images) image_bytes = ai.generate_image(
json_prompt,
context_images=context_images,
aspect_ratio=project.aspect_ratio or "16:9",
resolution=project.resolution or "2K"
)
relative_url = save_generated_image(session, project_id, "panel", item.id, image_bytes) relative_url = save_generated_image(session, project_id, "panel", item.id, image_bytes)
item.image_url = relative_url item.image_url = relative_url
session.add(item) session.add(item)
session.commit() session.commit()
# Update progress
task.progress = int(((i + 1) / total_items) * 100)
session.add(task)
session.commit()
# Add to history (absolute path for context usage) # Add to history (absolute path for context usage)
# We need absolute path for next context # We need absolute path for next context
# save_generated_image returns relative /static/... # save_generated_image returns relative /static/...
@@ -395,17 +456,17 @@ def generate_all_images_task(task_id: str, project_id: str):
# strip leading / # strip leading /
abs_path = os.path.join(base_dir, relative_url.lstrip("/").replace("/", os.sep)) abs_path = os.path.join(base_dir, relative_url.lstrip("/").replace("/", os.sep))
generated_history.append(abs_path) generated_history.append(abs_path)
logger.info(f"Panel {item.sequence} generated successfully.") log_task_event(session, task_id, f"Panel {item.sequence} generated successfully.")
except Exception as e: except Exception as e:
logger.error(f"Failed to generate panel {item.id}: {e}") logger.error(f"Failed to generate panel {item.id}: {e}")
print(f"Failed to generate panel {item.id}: {e}") log_task_event(session, task_id, f"Failed to generate panel {item.id}: {e}")
task.status = "completed" task.status = "completed"
task.progress = 100 task.progress = 100
session.add(task) session.add(task)
session.commit() session.commit()
logger.info(f"Batch generation task {task_id} completed successfully.") log_task_event(session, task_id, f"Batch generation task {task_id} completed successfully.")
except Exception as e: except Exception as e:
logger.error(f"Batch generation task {task_id} failed: {e}") logger.error(f"Batch generation task {task_id} failed: {e}")
@@ -433,14 +494,20 @@ def generate_all_characters_task(task_id: str, project_id: str):
ai = AIService(session) ai = AIService(session)
total_chars = len(project.characters) total_chars = len(project.characters)
logger.info(f"Generating {total_chars} characters...") log_task_event(session, task_id, f"Generating {total_chars} characters...")
for i, char in enumerate(project.characters): for i, char in enumerate(project.characters):
if char.image_url: # Check for cancellation
logger.info(f"Character {char.name} already has image, skipping.") session.refresh(task)
continue if task.status == "cancelled":
log_task_event(session, task_id, "Task execution cancelled by user.")
return
# if char.image_url:
# logger.info(f"Character {char.name} already has image, skipping.")
# continue
logger.info(f"Generating image for character: {char.name}") log_task_event(session, task_id, f"Generating image for character: {char.name}")
# Construct Natural Language Prompt from JSON # Construct Natural Language Prompt from JSON
data = char.data data = char.data
@@ -475,26 +542,38 @@ Ensure the character's expression and pose reflect their personality: {personali
""" """
try: try:
image_bytes = ai.generate_image(prompt) image_bytes = ai.generate_image(
prompt,
aspect_ratio=project.aspect_ratio or "16:9",
resolution=project.resolution or "2K"
)
relative_url = save_generated_image(session, project_id, "character", char.id, image_bytes) relative_url = save_generated_image(session, project_id, "character", char.id, image_bytes)
char.image_url = relative_url char.image_url = relative_url
session.add(char) session.add(char)
session.commit() session.commit()
logger.info(f"Character {char.name} generated successfully.") log_task_event(session, task_id, f"Character {char.name} generated successfully.")
# Update progress
progress = int(((i + 1) / total_chars) * 100)
task.progress = progress
session.add(task)
session.commit()
except Exception as e: except Exception as e:
logger.error(f"Failed to generate char {char.id}: {e}") logger.error(f"Failed to generate char {char.id}: {e}")
log_task_event(session, task_id, f"Failed to generate char {char.id}: {e}")
# Update progress # Update progress
progress = int(((i + 1) / total_chars) * 100) # progress = int(((i + 1) / total_chars) * 100)
task.progress = progress # task.progress = progress
session.add(task) # session.add(task)
session.commit() # session.commit()
task.status = "completed" task.status = "completed"
task.progress = 100 task.progress = 100
session.add(task) session.add(task)
session.commit() session.commit()
logger.info(f"Batch character generation task {task_id} completed successfully.") log_task_event(session, task_id, f"Batch character generation task {task_id} completed successfully.")
except Exception as e: except Exception as e:
logger.error(f"Batch character generation task {task_id} failed: {e}") logger.error(f"Batch character generation task {task_id} failed: {e}")
@@ -556,8 +635,20 @@ Include Front View, Side View, and detailed clothing/accessories.
Ensure the character's expression and pose reflect their personality: {personality}. Ensure the character's expression and pose reflect their personality: {personality}.
""" """
logger.info(f"Calling AI service for character {char.name}...") log_task_event(session, task_id, f"Calling AI service for character {char.name}...")
image_bytes = ai.generate_image(prompt) start_time = time.time()
try:
image_bytes = ai.generate_image(
prompt,
aspect_ratio=char.project.aspect_ratio or "16:9",
resolution=char.project.resolution or "2K"
)
elapsed = time.time() - start_time
log_task_event(session, task_id, f"AI generation finished in {elapsed:.2f}s. Image size: {len(image_bytes)} bytes.")
except Exception as e:
elapsed = time.time() - start_time
log_task_event(session, task_id, f"AI generation failed after {elapsed:.2f}s: {str(e)}")
raise e
relative_url = save_generated_image(session, char.project_id, "character", char.id, image_bytes) relative_url = save_generated_image(session, char.project_id, "character", char.id, image_bytes)
@@ -568,10 +659,11 @@ Ensure the character's expression and pose reflect their personality: {personali
task.progress = 100 task.progress = 100
session.add(task) session.add(task)
session.commit() session.commit()
logger.info(f"Character task {task_id} completed successfully.") log_task_event(session, task_id, f"Character task {task_id} completed successfully.")
except Exception as e: except Exception as e:
logger.error(f"Character task {task_id} failed: {e}") logger.error(f"Character task {task_id} failed: {e}")
log_task_event(session, task_id, f"Character task {task_id} failed: {e}")
traceback.print_exc() traceback.print_exc()
task.status = "failed" task.status = "failed"
task.message = str(e) task.message = str(e)
@@ -789,8 +881,13 @@ def generate_panel_task(task_id: str, item_id: int):
if meta_style: if meta_style:
json_prompt += f"\n\nStyle Consistency Requirement: {meta_style}. Ensure the visual style matches the provided context images." 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}...") log_task_event(session, task_id, f"Calling AI service for panel {item.sequence}...")
image_bytes = ai.generate_image(json_prompt, context_images) image_bytes = ai.generate_image(
json_prompt,
context_images=context_images,
aspect_ratio=project.aspect_ratio or "16:9",
resolution=project.resolution or "2K"
)
relative_url = save_generated_image(session, project.id, "panel", item.id, image_bytes) relative_url = save_generated_image(session, project.id, "panel", item.id, image_bytes)
item.image_url = relative_url item.image_url = relative_url
@@ -800,10 +897,11 @@ def generate_panel_task(task_id: str, item_id: int):
task.progress = 100 task.progress = 100
session.add(task) session.add(task)
session.commit() session.commit()
logger.info(f"Panel task {task_id} completed successfully.") log_task_event(session, task_id, f"Panel task {task_id} completed successfully.")
except Exception as e: except Exception as e:
logger.error(f"Panel task {task_id} failed: {e}") logger.error(f"Panel task {task_id} failed: {e}")
log_task_event(session, task_id, f"Panel task {task_id} failed: {e}")
traceback.print_exc() traceback.print_exc()
task.status = "failed" task.status = "failed"
task.message = str(e) task.message = str(e)
+16
View File
@@ -21,3 +21,19 @@ def get_project_tasks(project_id: str, session: Session = Depends(get_session)):
# Filter only recent or active tasks if list is too long? # Filter only recent or active tasks if list is too long?
# For now return all, maybe limit 20 # For now return all, maybe limit 20
return tasks[:20] return tasks[:20]
@router.post("/{task_id}/cancel", response_model=TaskRead)
def cancel_task(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")
if task.status in ["completed", "failed", "cancelled"]:
return task
task.status = "cancelled"
task.message = "Task cancelled by user"
session.add(task)
session.commit()
session.refresh(task)
return task
+2
View File
@@ -36,6 +36,7 @@ class TaskRead(TaskBase):
project_id: str project_id: str
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
logs: List[str] = []
# Project # Project
class ProjectCreate(ProjectBase): class ProjectCreate(ProjectBase):
@@ -49,6 +50,7 @@ class ProjectUpdate(BaseModel):
language: Optional[str] = None language: Optional[str] = None
panel_count: Optional[int] = None panel_count: Optional[int] = None
aspect_ratio: Optional[str] = None aspect_ratio: Optional[str] = None
resolution: Optional[str] = None
class ProjectRead(ProjectBase): class ProjectRead(ProjectBase):
id: str id: str
+38 -16
View File
@@ -1,12 +1,16 @@
import os import os
import time import time
import logging
from typing import List, Optional from typing import List, Optional
from sqlmodel import Session from sqlmodel import Session
from app.models.models import ModelConfig from app.models.models import ModelConfig
from google import genai from google import genai
from google.genai import types
from PIL import Image from PIL import Image
import io import io
logger = logging.getLogger(__name__)
class AIService: class AIService:
def __init__(self, session: Session): def __init__(self, session: Session):
self.session = session self.session = session
@@ -29,17 +33,22 @@ class AIService:
full_prompt = f"{system_prompt}\n\nUser Input: {user_input}\n\nPlease generate the full storyboard in JSON format as requested." full_prompt = f"{system_prompt}\n\nUser Input: {user_input}\n\nPlease generate the full storyboard in JSON format as requested."
try: max_retries = 3
response = client.models.generate_content( for attempt in range(max_retries):
model=model_name, try:
contents=full_prompt response = client.models.generate_content(
) model=model_name,
return response.text contents=full_prompt
except Exception as e: )
print(f"Error generating storyboard: {e}") return response.text
raise e except Exception as e:
logger.error(f"Error generating storyboard (Attempt {attempt + 1}/{max_retries}): {e}")
if attempt < max_retries - 1:
time.sleep(2 ** attempt)
continue
raise e
def generate_image(self, prompt: str, context_images: List[str] = None) -> bytes: def generate_image(self, prompt: str, context_images: List[str] = None, aspect_ratio: str = "16:9", resolution: str = "2K") -> bytes:
client, model_name = self._get_client("image") client, model_name = self._get_client("image")
contents = [prompt] contents = [prompt]
@@ -50,41 +59,54 @@ class AIService:
prev_img = Image.open(img_path) prev_img = Image.open(img_path)
contents.append(prev_img) contents.append(prev_img)
except Exception as e: except Exception as e:
print(f"Failed to load context image {img_path}: {e}") logger.warning(f"Failed to load context image {img_path}: {e}")
else: else:
# Log missing context image but don't fail, just skip it # Log missing context image but don't fail, just skip it
print(f"Warning: Context image not found at {img_path}, skipping.") logger.warning(f"Warning: Context image not found at {img_path}, skipping.")
# Retry loop # Retry loop
max_retries = 3 max_retries = 3
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
logger.info(f"DEBUG: Starting image generation attempt {attempt + 1}/{max_retries} with model {model_name}...")
logger.info(f"DEBUG: Prompt length: {len(prompt)}")
if context_images:
logger.info(f"DEBUG: Context images count: {len(context_images)}")
response = client.models.generate_content( response = client.models.generate_content(
model=model_name, model=model_name,
contents=contents, contents=contents,
config=types.GenerateContentConfig(
image_config=types.ImageConfig(
aspect_ratio=aspect_ratio,
image_size=resolution
),
)
) )
logger.info(f"DEBUG: Generation API call completed for attempt {attempt + 1}")
if response.parts: if response.parts:
for part in response.parts: for part in response.parts:
if part.inline_data is not None: if part.inline_data is not None:
image_data = part.inline_data.data image_data = part.inline_data.data
if len(image_data) > 0: if len(image_data) > 0:
logger.info(f"DEBUG: Successfully received image data ({len(image_data)} bytes)")
return image_data return image_data
else: else:
print(f"Warning: Received empty image data on attempt {attempt + 1}") logger.warning(f"Warning: Received empty image data on attempt {attempt + 1}")
# Check for text refusal/error # Check for text refusal/error
if response.text: if response.text:
print(f"Model response text (no image): {response.text}") logger.warning(f"Model response text (no image): {response.text}")
print(f"Attempt {attempt + 1} failed: No valid image data found in response.") logger.warning(f"Attempt {attempt + 1} failed: No valid image data found in response.")
if attempt == max_retries - 1: 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'}") 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) time.sleep(2 ** attempt)
except Exception as e: except Exception as e:
print(f"Error generating image (Attempt {attempt + 1}/{max_retries}): {e}") logger.error(f"Error generating image (Attempt {attempt + 1}/{max_retries}): {e}")
if attempt < max_retries - 1: if attempt < max_retries - 1:
time.sleep(2 ** attempt) time.sleep(2 ** attempt)
continue continue
+67 -7
View File
@@ -2,7 +2,7 @@
<div class="home-view"> <div class="home-view">
<div class="header"> <div class="header">
<h2>My Projects</h2> <h2>My Projects</h2>
<el-button type="primary" @click="dialogVisible = true">New Project</el-button> <el-button type="primary" @click="openCreateDialog">New Project</el-button>
</div> </div>
<el-row :gutter="20"> <el-row :gutter="20">
@@ -10,11 +10,14 @@
<el-card shadow="hover" class="project-card" @click="goToProject(project.id)"> <el-card shadow="hover" class="project-card" @click="goToProject(project.id)">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>{{ project.title }}</span> <span class="project-title" :title="project.title">{{ project.title }}</span>
<el-button type="text" icon="Delete" @click.stop="deleteProject(project.id)"></el-button> <div class="actions">
<el-button type="text" icon="Edit" @click.stop="openEditDialog(project)"></el-button>
<el-button type="text" icon="Delete" @click.stop="deleteProject(project.id)"></el-button>
</div>
</div> </div>
</template> </template>
<p>{{ project.description || 'No description' }}</p> <p class="project-desc">{{ project.description || 'No description' }}</p>
<div class="footer"> <div class="footer">
<span>{{ new Date(project.updated_at).toLocaleDateString() }}</span> <span>{{ new Date(project.updated_at).toLocaleDateString() }}</span>
</div> </div>
@@ -22,7 +25,7 @@
</el-col> </el-col>
</el-row> </el-row>
<el-dialog v-model="dialogVisible" title="Create New Project"> <el-dialog v-model="dialogVisible" :title="isEdit ? 'Edit Project' : 'Create New Project'">
<el-form :model="form"> <el-form :model="form">
<el-form-item label="Title"> <el-form-item label="Title">
<el-input v-model="form.title" /> <el-input v-model="form.title" />
@@ -33,7 +36,7 @@
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">Cancel</el-button> <el-button @click="dialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="createProject">Create</el-button> <el-button type="primary" @click="saveProject">{{ isEdit ? 'Save' : 'Create' }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
@@ -48,7 +51,8 @@ import { ElMessage } from 'element-plus'
const router = useRouter() const router = useRouter()
const projects = ref([]) const projects = ref([])
const dialogVisible = ref(false) const dialogVisible = ref(false)
const form = ref({ title: '', description: '' }) const isEdit = ref(false)
const form = ref({ id: '', title: '', description: '' })
const fetchProjects = async () => { const fetchProjects = async () => {
try { try {
@@ -59,6 +63,26 @@ const fetchProjects = async () => {
} }
} }
const openCreateDialog = () => {
isEdit.value = false
form.value = { title: '', description: '' }
dialogVisible.value = true
}
const openEditDialog = (project) => {
isEdit.value = true
form.value = { id: project.id, title: project.title, description: project.description }
dialogVisible.value = true
}
const saveProject = async () => {
if (isEdit.value) {
await updateProject()
} else {
await createProject()
}
}
const createProject = async () => { const createProject = async () => {
try { try {
const res = await axios.post('/api/v1/projects/', form.value) const res = await axios.post('/api/v1/projects/', form.value)
@@ -70,6 +94,20 @@ const createProject = async () => {
} }
} }
const updateProject = async () => {
try {
await axios.put(`/api/v1/projects/${form.value.id}`, {
title: form.value.title,
description: form.value.description
})
ElMessage.success('Updated successfully')
dialogVisible.value = false
fetchProjects()
} catch (error) {
ElMessage.error('Failed to update project')
}
}
const deleteProject = async (id) => { const deleteProject = async (id) => {
try { try {
await axios.delete(`/api/v1/projects/${id}`) await axios.delete(`/api/v1/projects/${id}`)
@@ -106,4 +144,26 @@ onMounted(fetchProjects)
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.project-title {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 60%;
display: inline-block;
}
.actions {
display: flex;
gap: 4px;
}
.project-desc {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
height: 3em;
color: #666;
font-size: 0.9em;
}
</style> </style>
File diff suppressed because it is too large Load Diff
+462
View File
@@ -0,0 +1,462 @@
<template>
<div class="character-tab">
<div class="mb-2 flex-row-between">
<div class="left-actions">
<el-popconfirm
title="This will regenerate ALL character images, overwriting existing ones. Continue?"
confirm-button-text="Yes, Overwrite"
cancel-button-text="Cancel"
@confirm="generateAllCharacters"
>
<template #reference>
<el-button type="primary" size="large" :disabled="!project.characters.length || isTaskRunning">
Draw All Characters (Overwrite)
</el-button>
</template>
</el-popconfirm>
<el-button @click="emit('open-merge-dialog')" size="large" :disabled="project.characters.length < 2">
Merge Characters
</el-button>
</div>
</div>
<el-container class="char-studio-container">
<el-aside width="250px" class="char-list-aside">
<el-menu
:default-active="activeCharId"
@select="handleCharSelect"
background-color="#1e1e1e"
text-color="#fff"
active-text-color="#409EFF"
class="char-menu"
>
<el-menu-item v-for="char in project.characters" :key="char.id" :index="String(char.id)">
<span class="text-truncate">{{ char.name }}</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main class="char-main">
<div v-if="selectedChar" class="char-detail">
<div class="char-header">
<h2>{{ selectedChar.name }}</h2>
<div class="header-actions">
<el-popconfirm title="Are you sure you want to delete this character?" @confirm="deleteCharacter(selectedChar.id)">
<template #reference>
<el-button type="danger" plain>Delete</el-button>
</template>
</el-popconfirm>
<el-button @click="emit('open-history', 'character', selectedChar.id)">History</el-button>
<el-button type="primary" @click="generateCharacter(selectedChar.id)" :loading="loading">
Draw / Redraw (Background)
</el-button>
</div>
</div>
<el-row :gutter="24">
<!-- Left: Attributes -->
<el-col :xs="24" :sm="10" :md="8" :lg="8">
<div class="info-card">
<div class="info-header">
<h4>Character Attributes</h4>
<el-button size="small" type="primary" link @click="openJsonEditor">Edit JSON</el-button>
</div>
<el-scrollbar max-height="600px">
<div v-if="Object.keys(displayData).length" class="attributes-list">
<div v-for="(value, key) in displayData" :key="key" class="attr-wrapper">
<!-- Complex Data (Array or Object) -->
<div v-if="isComplex(value)" class="complex-attr">
<div class="complex-label">{{ formatKey(key) }}</div>
<!-- Array -->
<div v-if="Array.isArray(value)" class="array-list">
<div v-for="(item, idx) in value" :key="idx" class="array-item">
<template v-if="isComplex(item)">
<div v-for="(v, k) in item" :key="k" class="nested-item">
<span class="nested-label">{{ formatKey(k) }}:</span>
<span class="nested-value">{{ v }}</span>
</div>
</template>
<template v-else>{{ item }}</template>
</div>
</div>
<!-- Object -->
<div v-else class="object-grid">
<div v-for="(v, k) in value" :key="k" class="grid-item">
<span class="nested-label">{{ formatKey(k) }}:</span>
<span class="nested-value">{{ v }}</span>
</div>
</div>
</div>
<!-- Simple Data -->
<div v-else class="attr-item">
<span class="attr-label">{{ formatKey(key) }}:</span>
<span class="attr-value">{{ value }}</span>
</div>
</div>
</div>
<div v-else class="text-gray p-2">
No structured attributes found. Click Edit JSON to add details.
</div>
<div v-if="selectedChar.data?.description" class="mt-4">
<span class="attr-label">Description:</span>
<p class="desc-text">{{ selectedChar.data.description }}</p>
</div>
</el-scrollbar>
</div>
</el-col>
<!-- Right: Preview -->
<el-col :xs="24" :sm="14" :md="16" :lg="16">
<div class="preview-card">
<h4>Character Preview</h4>
<div class="image-wrapper">
<el-image
v-if="selectedChar.image_url"
:src="`${selectedChar.image_url}?v=${imageVersion}`"
fit="contain"
class="image-preview"
:preview-src-list="[`${selectedChar.image_url}?v=${imageVersion}`]"
>
<template #error>
<div class="image-slot">
<el-icon><icon-picture /></el-icon>
</div>
</template>
</el-image>
<div v-else class="no-image">
<span>No Image Generated</span>
<span class="sub-text">Click "Draw / Redraw" to generate</span>
</div>
</div>
<div v-if="selectedChar.image_url" class="image-actions mt-2" style="text-align: center;">
<a :href="selectedChar.image_url" :download="`${selectedChar.name}.png`" target="_blank">
<el-button size="small" type="info" plain>Download Image</el-button>
</a>
</div>
</div>
</el-col>
</el-row>
</div>
<div v-else class="empty-state">
<el-empty description="Select a character to view details" />
</div>
</el-main>
</el-container>
<JsonEditorDialog
v-model:visible="showJsonEditor"
:content="characterEditor"
:title="`Edit ${selectedChar?.name || 'Character'}`"
@save="handleJsonSave"
/>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { Picture as IconPicture } from '@element-plus/icons-vue'
import JsonEditorDialog from './JsonEditorDialog.vue'
const props = defineProps({
project: Object,
projectId: [String, Number],
isTaskRunning: Boolean,
imageVersion: Number
})
const emit = defineEmits(['task-started', 'refresh-project', 'open-merge-dialog', 'open-history'])
const activeCharId = ref('')
const characterEditor = ref('')
const loading = ref(false)
const showJsonEditor = ref(false)
const selectedChar = computed(() => {
if (!activeCharId.value) return null
return props.project.characters.find(c => String(c.id) === activeCharId.value)
})
const displayData = computed(() => {
if (!selectedChar.value || !selectedChar.value.data) return {}
const data = selectedChar.value.data
const ignoredKeys = ['description', 'id', 'name', 'image_url', 'created_at']
const result = {}
for (const key in data) {
if (ignoredKeys.includes(key)) continue
if (data[key] !== null && data[key] !== undefined) {
result[key] = data[key]
}
}
return result
})
const isComplex = (val) => {
return typeof val === 'object' && val !== null
}
watch(() => props.project.characters, (newChars) => {
if (newChars && newChars.length > 0 && !activeCharId.value) {
activeCharId.value = String(newChars[0].id)
}
}, { immediate: true })
watch(selectedChar, (newChar) => {
if (newChar) {
characterEditor.value = JSON.stringify(newChar.data, null, 2)
} else {
characterEditor.value = ''
}
})
const handleCharSelect = (index) => {
activeCharId.value = index
}
const formatKey = (key) => {
// Convert snake_case or camelCase to Title Case
return key.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.replace(/_/g, ' ')
}
const openJsonEditor = () => {
if (!selectedChar.value) return
// Ensure editor has latest data
characterEditor.value = JSON.stringify(selectedChar.value.data, null, 2)
showJsonEditor.value = true
}
const handleJsonSave = async (newContent) => {
characterEditor.value = newContent
await updateCharacter(selectedChar.value.id)
}
const updateCharacter = async (charId) => {
try {
const data = JSON.parse(characterEditor.value)
await axios.put(`/api/v1/projects/${props.projectId}/characters/${charId}`, data)
ElMessage.success('Character setting saved')
emit('refresh-project')
} catch (e) {
ElMessage.error('JSON format error or save failed: ' + e.message)
}
}
const generateCharacter = async (charId) => {
try {
await axios.post(`/api/v1/generate/character/${charId}`)
emit('task-started')
ElMessage.info('Character drawing task started in background...')
} catch (error) {
console.error(error)
ElMessage.error('Generation failed: ' + (error.response?.data?.detail || error.message))
}
}
const generateAllCharacters = async () => {
try {
await axios.post(`/api/v1/generate/all-characters/${props.projectId}`)
emit('task-started')
ElMessage.info('Batch character drawing task started in background...')
} catch (error) {
ElMessage.error('Failed to start task: ' + (error.response?.data?.detail || error.message))
}
}
const deleteCharacter = async (charId) => {
try {
await axios.delete(`/api/v1/projects/${props.projectId}/characters/${charId}`)
ElMessage.success('Character deleted')
if (activeCharId.value === String(charId)) {
activeCharId.value = ''
}
emit('refresh-project')
} catch (e) {
ElMessage.error('Delete failed')
}
}
</script>
<style scoped>
.flex-row-between {
display: flex;
justify-content: space-between;
align-items: center;
}
.left-actions {
display: flex;
gap: 12px;
}
.char-studio-container {
height: calc(100vh - 200px); /* Dynamic height based on viewport */
min-height: 600px;
border: 1px solid #333;
background: #1e1e1e;
border-radius: 8px;
overflow: hidden;
}
.char-list-aside {
background-color: #1a1a1a;
border-right: 1px solid #333;
}
.char-menu {
border-right: none;
background-color: transparent;
}
.char-main {
padding: 24px;
overflow-y: auto;
}
.char-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #333;
}
.header-actions {
display: flex;
gap: 12px;
}
.info-card, .preview-card {
background: #252525;
border-radius: 8px;
padding: 20px;
height: 100%;
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
}
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.info-header h4, .preview-card h4 {
margin: 0;
color: #eee;
font-size: 1.1rem;
}
.attributes-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.attr-item {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #333;
padding-bottom: 8px;
}
.attr-label {
color: #909399;
font-weight: 500;
}
.attr-value {
color: #E5EAF3;
text-align: right;
max-width: 60%;
white-space: pre-wrap;
word-break: break-word;
}
.desc-text {
color: #ccc;
line-height: 1.6;
margin-top: 8px;
font-size: 0.95rem;
}
.image-wrapper {
margin-top: 16px;
width: 100%;
/* Flexible height container */
min-height: 400px;
display: flex;
justify-content: center;
background: #1a1a1a;
border-radius: 4px;
padding: 10px;
}
.image-preview {
width: 100%;
height: auto; /* Allow height to grow */
display: block;
}
.no-image {
width: 100%;
height: 400px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: #2a2a2a;
color: #666;
border-radius: 4px;
gap: 10px;
}
.sub-text {
font-size: 0.8rem;
color: #555;
}
.text-truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.mb-2 { margin-bottom: 16px; }
.mt-4 { margin-top: 24px; }
/* Complex Attributes Styling */
.complex-attr {
margin-bottom: 16px;
background: #2a2a2a;
border-radius: 6px;
padding: 10px;
}
.complex-label {
color: #409EFF;
font-weight: 600;
margin-bottom: 8px;
font-size: 0.95rem;
border-bottom: 1px solid #333;
padding-bottom: 4px;
}
.array-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.array-item {
background: #333;
padding: 8px;
border-radius: 4px;
font-size: 0.9em;
}
.object-grid {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
.nested-item {
margin-bottom: 4px;
}
.nested-label {
color: #bbb;
font-weight: 500;
margin-right: 4px;
}
.nested-value {
color: #eee;
}
</style>
@@ -0,0 +1,55 @@
<template>
<el-dialog
:model-value="visible"
@update:model-value="emit('update:visible', $event)"
title="Export Comic"
width="30%"
>
<span>Confirm export of current project comic?</span>
<div class="mt-2">
<el-checkbox v-model="splitImages">Auto-split 4-panel storyboard (1:1 split)</el-checkbox>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="emit('update:visible', false)">Cancel</el-button>
<el-button type="primary" @click="confirmExport" :loading="loading">Confirm Export</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const props = defineProps({
visible: Boolean,
projectId: [String, Number]
})
const emit = defineEmits(['update:visible'])
const splitImages = ref(false)
const loading = ref(false)
const confirmExport = async () => {
loading.value = true
try {
const res = await axios.get(`/api/v1/export/${props.projectId}`, {
params: { split_images: splitImages.value }
})
window.open(res.data.download_url, '_blank')
emit('update:visible', false)
ElMessage.success('Export download started')
} catch (error) {
ElMessage.error('Export failed: ' + (error.response?.data?.detail || error.message))
} finally {
loading.value = false
}
}
</script>
<style scoped>
.mt-2 { margin-top: 10px; }
</style>
@@ -0,0 +1,115 @@
<template>
<el-dialog
:model-value="visible"
@update:model-value="emit('update:visible', $event)"
title="Generation History"
width="60%"
@open="loadHistory"
>
<div class="history-list" v-loading="loading">
<div v-for="h in historyList" :key="h.id" class="history-item" @click="selectHistoryImage(h)" :class="{ active: isCurrentHistory(h) }">
<el-image :src="h.image_url" fit="cover" class="history-img" />
<div class="history-meta">
<span class="history-time">{{ new Date(h.created_at + 'Z').toLocaleString() }}</span>
<el-tag size="small" v-if="isCurrentHistory(h)" type="success">Current</el-tag>
</div>
</div>
<div v-if="historyList.length === 0 && !loading" class="empty-history">No History</div>
</div>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const props = defineProps({
visible: Boolean,
type: String, // 'character' or 'panel'
entityId: [String, Number],
currentImageUrl: String
})
const emit = defineEmits(['update:visible', 'image-selected'])
const loading = ref(false)
const historyList = ref([])
const loadHistory = async () => {
if (!props.type || !props.entityId) return
loading.value = true
try {
const res = await axios.get(`/api/v1/history/${props.type}/${props.entityId}`)
historyList.value = res.data
} catch (e) {
ElMessage.error('Failed to load history')
} finally {
loading.value = false
}
}
const selectHistoryImage = async (historyItem) => {
try {
await axios.post(`/api/v1/history/select/${historyItem.id}`)
ElMessage.success('Image switched')
emit('update:visible', false)
emit('image-selected')
} catch (e) {
ElMessage.error('Switch failed')
}
}
const isCurrentHistory = (h) => {
return h.image_url === props.currentImageUrl
}
</script>
<style scoped>
.history-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px;
max-height: 500px;
overflow-y: auto;
padding: 10px;
}
.history-item {
cursor: pointer;
border: 2px solid transparent;
border-radius: 4px;
overflow: hidden;
position: relative;
background: #222;
transition: all 0.2s;
}
.history-item:hover {
border-color: #409EFF;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
}
.history-item.active {
border-color: #67C23A;
}
.history-img {
width: 100%;
height: 180px;
display: block;
}
.history-meta {
padding: 8px;
font-size: 0.8em;
color: #888;
background: #1a1a1a;
text-align: center;
display: flex;
flex-direction: column;
gap: 5px;
align-items: center;
}
.empty-history {
text-align: center;
color: #666;
padding: 40px;
}
</style>
@@ -0,0 +1,63 @@
<template>
<el-dialog
:model-value="visible"
@update:model-value="emit('update:visible', $event)"
:title="title || 'Edit JSON'"
width="60%"
:close-on-click-modal="false"
>
<div class="json-editor-container">
<el-input
type="textarea"
:rows="20"
v-model="localContent"
class="json-textarea"
spellcheck="false"
/>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="emit('update:visible', false)">Cancel</el-button>
<el-button type="primary" @click="handleSave">Save Changes</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
visible: Boolean,
content: String, // Expecting stringified JSON
title: String
})
const emit = defineEmits(['update:visible', 'save'])
const localContent = ref('')
watch(() => props.visible, (val) => {
if (val) {
localContent.value = props.content || ''
}
})
const handleSave = () => {
try {
// Validate JSON
const parsed = JSON.parse(localContent.value)
emit('save', JSON.stringify(parsed, null, 2)) // Format it nicely
emit('update:visible', false)
} catch (e) {
ElMessage.error('Invalid JSON format: ' + e.message)
}
}
</script>
<style scoped>
.json-textarea {
font-family: monospace;
}
</style>
@@ -0,0 +1,83 @@
<template>
<el-dialog
:model-value="visible"
@update:model-value="emit('update:visible', $event)"
title="Merge Characters"
width="40%"
@open="resetForm"
>
<div class="merge-container">
<p class="mb-2">Merge duplicate characters into a target character. Merged characters will be deleted, and character names in the storyboard will be automatically updated to the target character.</p>
<el-form label-width="120px">
<el-form-item label="Keep Character">
<el-select v-model="mergeTargetId" placeholder="Select character to keep (Target)" style="width: 100%">
<el-option v-for="c in characters" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="Merge Source">
<el-select v-model="mergeSourceIds" multiple placeholder="Select characters to merge (Will be deleted)" style="width: 100%">
<el-option
v-for="c in characters"
:key="c.id"
:label="c.name"
:value="c.id"
:disabled="c.id === mergeTargetId"
/>
</el-select>
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="emit('update:visible', false)">Cancel</el-button>
<el-button type="primary" @click="confirmMerge" :disabled="!mergeTargetId || !mergeSourceIds.length" :loading="loading">Confirm Merge</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const props = defineProps({
visible: Boolean,
characters: Array,
projectId: [String, Number]
})
const emit = defineEmits(['update:visible', 'merged'])
const mergeTargetId = ref(null)
const mergeSourceIds = ref([])
const loading = ref(false)
const resetForm = () => {
mergeTargetId.value = null
mergeSourceIds.value = []
}
const confirmMerge = async () => {
loading.value = true
try {
await axios.post(`/api/v1/projects/${props.projectId}/characters/merge`, {
target_char_id: mergeTargetId.value,
source_char_ids: mergeSourceIds.value
})
ElMessage.success('Merge successful')
emit('update:visible', false)
emit('merged', { targetId: mergeTargetId.value, sourceIds: mergeSourceIds.value })
} catch (e) {
ElMessage.error('Merge failed: ' + (e.response?.data?.detail || e.message))
} finally {
loading.value = false
}
}
</script>
<style scoped>
.mb-2 { margin-bottom: 10px; }
</style>
@@ -0,0 +1,28 @@
<template>
<div class="header">
<h2>{{ title }}</h2>
<div class="actions">
<el-button type="success" @click="emit('export')">Export Comic</el-button>
</div>
</div>
</template>
<script setup>
defineProps({
title: String
})
const emit = defineEmits(['export'])
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.actions {
display: flex;
gap: 10px;
}
</style>
+243
View File
@@ -0,0 +1,243 @@
<template>
<div class="story-tab-content">
<!-- Top Section: Settings -->
<el-card class="box-card mb-4" shadow="never">
<template #header>
<div class="card-header">
<span>Project Configuration</span>
<el-button type="primary" link @click="openGlobalConfig">Advanced Config (JSON)</el-button>
</div>
</template>
<el-form :model="project" label-width="120px" class="settings-form" :inline="true">
<el-form-item label="Theme">
<el-input v-model="project.theme" placeholder="e.g. Cyberpunk" @change="saveSettings" class="w-200" />
</el-form-item>
<el-form-item label="Language">
<el-select v-model="project.language" placeholder="Select" @change="saveSettings" class="w-200">
<el-option label="Simplified Chinese" value="zh-CN" />
<el-option label="English" value="en-US" />
<el-option label="Japanese" value="ja-JP" />
</el-select>
</el-form-item>
<el-form-item label="Panels">
<el-input-number v-model="project.panel_count" :min="1" :max="100" @change="saveSettings" class="w-150" />
</el-form-item>
<el-form-item label="Aspect Ratio">
<el-select v-model="project.aspect_ratio" placeholder="Select" @change="saveSettings" class="w-150">
<el-option label="1:1" value="1:1" />
<el-option label="2:3" value="2:3" />
<el-option label="3:2" value="3:2" />
<el-option label="3:4" value="3:4" />
<el-option label="4:3" value="4:3" />
<el-option label="4:5" value="4:5" />
<el-option label="5:4" value="5:4" />
<el-option label="9:16" value="9:16" />
<el-option label="16:9" value="16:9" />
<el-option label="21:9" value="21:9" />
</el-select>
</el-form-item>
<el-form-item label="Resolution">
<el-select v-model="project.resolution" placeholder="Select" @change="saveSettings" class="w-150">
<el-option label="1K" value="1K" />
<el-option label="2K" value="2K" />
<el-option label="4K" value="4K" />
</el-select>
</el-form-item>
</el-form>
</el-card>
<!-- Main Section: Story Input -->
<el-card class="box-card story-card" shadow="never">
<template #header>
<div class="card-header">
<span>Story Input</span>
<div class="upload-area">
<input type="file" ref="fileInput" @change="handleFileUpload" accept=".txt" style="display: none" />
<el-button size="small" @click="$refs.fileInput.click()">Import from File (.txt)</el-button>
<span v-if="fileName" class="file-name ml-2">{{ fileName }}</span>
</div>
</div>
</template>
<el-input
type="textarea"
:rows="15"
v-model="storyInput"
placeholder="Enter your story idea, synopsis, or full text here..."
@change="saveStoryInput"
class="story-textarea"
resize="none"
/>
<div class="action-footer mt-4">
<el-button size="large" type="primary" @click="generateStoryboard" :disabled="isTaskRunning || !storyInput">
Generate Storyboard
</el-button>
<el-popconfirm
title="Regenerate will overwrite existing storyboard and character settings. Continue?"
confirm-button-text="Yes"
cancel-button-text="No"
@confirm="generateStoryboard"
>
<template #reference>
<el-button size="large" type="warning" plain :disabled="isTaskRunning || !storyInput">
Regenerate All
</el-button>
</template>
</el-popconfirm>
<el-button size="large" @click="saveStoryInput">Save Story Only</el-button>
</div>
</el-card>
<!-- Dialogs -->
<JsonEditorDialog
v-model:visible="showGlobalConfig"
:content="globalConfigEditor"
title="Global Configuration"
@save="handleGlobalConfigSave"
/>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import JsonEditorDialog from './JsonEditorDialog.vue'
const props = defineProps({
project: Object,
projectId: [String, Number],
isTaskRunning: Boolean
})
const emit = defineEmits(['refresh-project', 'task-started'])
const storyInput = ref('')
const fileName = ref('')
const fileInput = ref(null)
const globalConfigEditor = ref('')
const showGlobalConfig = ref(false)
// Initialize local state when project changes
watch(() => props.project, (newVal) => {
if (newVal) {
if (newVal.story_input && !storyInput.value) storyInput.value = newVal.story_input
if (newVal.global_config) {
globalConfigEditor.value = JSON.stringify(newVal.global_config.data, null, 2)
}
}
}, { immediate: true, deep: true })
const saveSettings = async () => {
try {
await axios.put(`/api/v1/projects/${props.projectId}`, {
theme: props.project.theme,
language: props.project.language,
panel_count: props.project.panel_count,
aspect_ratio: props.project.aspect_ratio,
resolution: props.project.resolution
})
ElMessage.success('Settings saved')
} catch (e) {
ElMessage.error('Failed to save settings')
}
}
const saveStoryInput = async () => {
try {
await axios.put(`/api/v1/projects/${props.projectId}`, { story_input: storyInput.value })
ElMessage.success('Story saved')
} catch (e) {
console.error("Failed to save story input", e)
}
}
const handleFileUpload = (event) => {
const file = event.target.files[0]
if (!file) return
fileName.value = file.name
const reader = new FileReader()
reader.onload = (e) => {
storyInput.value = e.target.result
saveStoryInput()
ElMessage.success('File read successfully')
}
reader.onerror = () => ElMessage.error('Failed to read file')
reader.readAsText(file)
}
const generateStoryboard = async () => {
if (!storyInput.value) return ElMessage.warning('Please enter story content')
try {
await saveStoryInput()
await axios.post(`/api/v1/generate/storyboard/${props.projectId}`, {
user_input: storyInput.value
})
emit('task-started')
ElMessage.info('Storyboard generation task started in background...')
} catch (error) {
ElMessage.error('Failed to start task: ' + (error.response?.data?.detail || error.message))
}
}
const openGlobalConfig = () => {
// Ensure editor has latest
if (props.project.global_config) {
globalConfigEditor.value = JSON.stringify(props.project.global_config.data, null, 2)
} else {
globalConfigEditor.value = '{}'
}
showGlobalConfig.value = true
}
const handleGlobalConfigSave = async (newContent) => {
try {
const data = JSON.parse(newContent)
await axios.put(`/api/v1/projects/${props.projectId}/global_config`, data)
ElMessage.success('Global config synced')
emit('refresh-project')
} catch (e) {
ElMessage.error('Save failed: ' + e.message)
}
}
</script>
<style scoped>
.story-tab-content {
max-width: 1200px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.settings-form {
/* padding: 10px 0; */
}
.w-200 { width: 200px; }
.w-150 { width: 150px; }
.story-textarea :deep(.el-textarea__inner) {
font-family: inherit;
font-size: 1.05rem;
line-height: 1.6;
padding: 16px;
}
.upload-area { display: flex; align-items: center; }
.file-name { color: #888; font-size: 0.9em; }
.action-footer {
display: flex;
gap: 16px;
justify-content: flex-start;
border-top: 1px solid #333;
padding-top: 20px;
}
.mb-4 { margin-bottom: 24px; }
.mt-4 { margin-top: 24px; }
.ml-2 { margin-left: 10px; }
</style>
@@ -0,0 +1,323 @@
<template>
<div class="storyboard-tab">
<div class="comic-actions mb-4">
<el-tooltip :disabled="hasCharacters" content="Please generate character images in Character Studio first" placement="top">
<div style="display: inline-block;">
<el-popconfirm
title="This will regenerate ALL panel images, overwriting existing ones. Continue?"
confirm-button-text="Yes, Overwrite"
cancel-button-text="Cancel"
@confirm="generateAllImages"
>
<template #reference>
<el-button type="primary" size="large" :disabled="!hasCharacters || isTaskRunning">
Generate All Panels (Overwrite)
</el-button>
</template>
</el-popconfirm>
</div>
</el-tooltip>
<div class="info-group">
<span class="tip-text ml-2">Generation will proceed in storyboard order, subsequent panels will reference previous image content.</span>
<span v-if="!hasCharacters" class="warning-text ml-2"><el-icon><Warning /></el-icon> Please generate characters first!</span>
</div>
</div>
<div v-for="item in sortedStoryboard" :key="item.id" class="comic-row">
<el-card class="panel-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="panel-title">Panel {{ item.sequence }}</span>
<div class="header-actions">
<el-button size="small" type="primary" link @click="openJsonEditor(item)">Edit JSON</el-button>
<el-tooltip :disabled="hasCharacters" content="Please generate character images in Character Studio first" placement="top">
<el-button size="small" type="primary" plain @click="generatePanel(item.id)" :disabled="!hasCharacters">Regenerate</el-button>
</el-tooltip>
</div>
</div>
</template>
<el-row :gutter="24">
<!-- Left: Panel Details -->
<el-col :xs="24" :sm="10" :md="8" :lg="8">
<div class="panel-details">
<!-- Scene Info -->
<div class="detail-group" v-if="item.data.scene">
<label>Scene:</label>
<div class="detail-content">{{ item.data.scene }}</div>
</div>
<!-- Action Info -->
<div class="detail-group" v-if="item.data.action">
<label>Action:</label>
<div class="detail-content">{{ item.data.action }}</div>
</div>
<!-- Dialogue Info -->
<div class="detail-group" v-if="item.data.dialogue">
<label>Dialogue:</label>
<div class="detail-content">{{ item.data.dialogue }}</div>
</div>
<!-- Prompt (Existing) -->
<div class="detail-group mt-3">
<label>Full Prompt:</label>
<div class="detail-content prompt-text">
{{ item.data.prompt || 'No prompt set' }}
</div>
</div>
<div class="detail-group mt-3" v-if="item.data.negative_prompt">
<label>Negative Prompt:</label>
<div class="detail-content sm-text text-gray">
{{ item.data.negative_prompt }}
</div>
</div>
<div class="detail-group mt-3">
<label>Included Characters:</label>
<div class="detail-content">
<div v-if="getPanelCharacters(item.id).length" class="tags-wrapper">
<el-tag v-for="name in getPanelCharacters(item.id)" :key="name" size="small" effect="dark">{{ name }}</el-tag>
</div>
<span v-else class="text-gray sm-text">No explicit characters</span>
</div>
</div>
</div>
</el-col>
<!-- Right: Image Preview -->
<el-col :xs="24" :sm="14" :md="16" :lg="16">
<div class="image-area">
<div class="image-wrapper">
<el-image
v-if="item.image_url"
:src="`${item.image_url}?v=${imageVersion}`"
fit="contain"
class="comic-preview"
:preview-src-list="[`${item.image_url}?v=${imageVersion}`]"
>
<template #error>
<div class="image-slot">
<el-icon><icon-picture /></el-icon>
</div>
</template>
</el-image>
<div v-else class="no-image">
<span>No Image Generated</span>
</div>
</div>
<div v-if="item.image_url" class="image-actions mt-2">
<a :href="item.image_url" :download="`panel_${item.sequence}.png`" target="_blank" class="mr-2">
<el-button size="small" type="info" plain>Download</el-button>
</a>
<el-button size="small" @click="emit('open-history', 'panel', item.id)">History</el-button>
</div>
</div>
</el-col>
</el-row>
</el-card>
</div>
<JsonEditorDialog
v-model:visible="showJsonEditor"
:content="currentEditorContent"
:title="`Edit Panel ${currentEditingItem?.sequence || ''}`"
@save="handleJsonSave"
/>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { Warning, Picture as IconPicture } from '@element-plus/icons-vue'
import JsonEditorDialog from './JsonEditorDialog.vue'
const props = defineProps({
project: Object,
projectId: [String, Number],
isTaskRunning: Boolean,
imageVersion: Number
})
const emit = defineEmits(['task-started', 'refresh-project', 'open-history'])
const showJsonEditor = ref(false)
const currentEditingItem = ref(null)
const currentEditorContent = ref('')
const sortedStoryboard = computed(() => {
if (!props.project.storyboard_items) return []
return [...props.project.storyboard_items].sort((a, b) => a.sequence - b.sequence)
})
const hasCharacters = computed(() => {
if (!props.project.characters) return false
return props.project.characters.some(c => c.image_url)
})
const getPanelCharacters = (itemId) => {
const item = props.project.storyboard_items.find(i => i.id === itemId)
if (!item || !item.data.characters) return []
let chars = item.data.characters
if (typeof chars === 'string') return [chars]
if (Array.isArray(chars)) {
return chars.map(c => typeof c === 'string' ? c : c.name)
}
return []
}
const openJsonEditor = (item) => {
currentEditingItem.value = item
currentEditorContent.value = JSON.stringify(item.data, null, 2)
showJsonEditor.value = true
}
const handleJsonSave = async (newContent) => {
if (!currentEditingItem.value) return
try {
const data = JSON.parse(newContent)
await axios.put(`/api/v1/projects/${props.projectId}/storyboard/${currentEditingItem.value.id}`, data)
ElMessage.success('Storyboard content saved')
emit('refresh-project')
} catch (e) {
ElMessage.error('JSON format error or save failed: ' + e.message)
}
}
const generateAllImages = async () => {
try {
await axios.post(`/api/v1/generate/all-images/${props.projectId}`)
emit('task-started')
ElMessage.info('Full image generation task started in background...')
} catch (error) {
ElMessage.error('Failed to start task: ' + (error.response?.data?.detail || error.message))
}
}
const generatePanel = async (itemId) => {
try {
await axios.post(`/api/v1/generate/panel/${itemId}`)
emit('task-started')
ElMessage.info('Panel drawing task started in background...')
} catch (error) {
console.error(error)
ElMessage.error('Failed to start task: ' + (error.response?.data?.detail || error.message))
}
}
</script>
<style scoped>
.storyboard-tab {
padding-bottom: 40px;
}
.comic-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.info-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.comic-row {
margin-bottom: 24px;
}
.panel-card {
border: 1px solid #333;
background-color: #1e1e1e;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-title {
font-size: 1.1rem;
font-weight: 600;
color: #409EFF;
}
.header-actions {
display: flex;
gap: 10px;
}
.panel-details {
padding-right: 16px;
}
.detail-group {
margin-bottom: 12px;
}
.detail-group label {
display: block;
color: #909399;
font-size: 0.9rem;
margin-bottom: 4px;
font-weight: 500;
}
.detail-content {
color: #E5EAF3;
line-height: 1.5;
}
.prompt-text {
background: #252525;
padding: 10px;
border-radius: 4px;
font-size: 0.95rem;
max-height: 200px;
overflow-y: auto;
}
.tags-wrapper {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.image-area {
display: flex;
flex-direction: column;
align-items: center;
}
.image-wrapper {
width: 100%;
/* Flexible height */
min-height: 300px;
display: flex;
justify-content: center;
background: #1a1a1a;
border-radius: 4px;
padding: 10px;
}
.comic-preview {
width: 100%;
height: auto;
display: block;
}
.no-image {
width: 100%;
height: 300px;
display: flex;
justify-content: center;
align-items: center;
background: #2a2a2a;
color: #666;
border-radius: 4px;
}
.image-actions {
display: flex;
justify-content: center;
width: 100%;
}
.tip-text { color: #888; font-size: 0.9em; }
.warning-text { color: #E6A23C; font-size: 0.9em; display: inline-flex; align-items: center; gap: 4px; }
.text-gray { color: #888; }
.sm-text { font-size: 0.85em; }
.mt-2 { margin-top: 10px; }
.mt-3 { margin-top: 16px; }
.mb-4 { margin-bottom: 24px; }
.ml-2 { margin-left: 10px; }
.mr-2 { margin-right: 10px; }
</style>
+201
View File
@@ -0,0 +1,201 @@
<template>
<div v-if="tasks.length > 0" class="task-manager" :class="{ collapsed: isCollapsed }">
<div class="task-header" @click="toggleCollapse">
<span>Background Tasks ({{ runningCount }})</span>
<el-icon><component :is="isCollapsed ? 'ArrowUp' : 'ArrowDown'" /></el-icon>
</div>
<div v-show="!isCollapsed" class="task-list">
<div v-for="task in tasks" :key="task.id" class="task-item">
<div class="task-info">
<div class="task-name-group">
<span class="task-name" :title="task.description">{{ task.name || getTaskTypeName(task.type) }}</span>
<span class="task-desc" v-if="task.description">{{ task.description }}</span>
<span class="task-error" v-if="task.status === 'failed' && task.message" :title="task.message">
Failure Reason: {{ task.message }}
</span>
</div>
<div class="status-group">
<el-button
v-if="['pending', 'processing'].includes(task.status)"
link
size="small"
type="danger"
@click.stop="cancelTask(task.id)"
title="Cancel Task"
>
<el-icon><CircleClose /></el-icon>
</el-button>
<el-button link size="small" @click.stop="openTerminal(task.id)" title="View Logs">
<el-icon><Monitor /></el-icon>
</el-button>
<span class="task-status" :class="task.status">{{ getTaskStatusText(task.status) }}</span>
</div>
</div>
<el-progress :percentage="task.progress" :status="getTaskProgressStatus(task.status)" :stroke-width="6"></el-progress>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { ArrowUp, ArrowDown, Monitor, CircleClose } from '@element-plus/icons-vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const props = defineProps({
tasks: {
type: Array,
required: true
},
isCollapsed: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:isCollapsed', 'open-terminal'])
const runningCount = computed(() => {
return props.tasks.filter(t => ['pending', 'processing'].includes(t.status)).length
})
const toggleCollapse = () => {
emit('update:isCollapsed', !props.isCollapsed)
}
const openTerminal = (taskId) => {
emit('open-terminal', taskId)
}
const cancelTask = async (taskId) => {
try {
await axios.post(`/api/v1/tasks/${taskId}/cancel`)
ElMessage.warning('Task cancellation requested')
} catch (e) {
console.error(e)
ElMessage.error('Failed to cancel task')
}
}
const getTaskTypeName = (type) => {
const map = {
'storyboard': 'Storyboard Generation',
'image_generation': 'Full Image Generation',
'character_generation': 'Character Drawing'
}
return map[type] || type
}
const getTaskStatusText = (status) => {
const map = {
'pending': 'Pending',
'processing': 'Processing',
'completed': 'Completed',
'failed': 'Failed',
'cancelled': 'Cancelled'
}
return map[status] || status
}
const getTaskProgressStatus = (status) => {
if (status === 'completed') return 'success'
if (status === 'failed') return 'exception'
if (status === 'cancelled') return 'warning'
return ''
}
</script>
<style scoped>
.task-manager {
position: fixed;
bottom: 20px;
right: 20px;
width: 320px;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 4px;
padding: 0;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
transition: all 0.3s ease;
overflow: hidden;
}
.task-manager.collapsed {
width: 200px;
}
.task-header {
font-weight: bold;
padding: 10px 15px;
background: #2b2b2b;
border-bottom: 1px solid #333;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
}
.task-header:hover {
background: #333;
}
.task-list {
max-height: 300px;
overflow-y: auto;
padding: 10px;
}
.task-item {
margin-bottom: 15px;
font-size: 0.9em;
padding-bottom: 10px;
border-bottom: 1px solid #2a2a2a;
}
.task-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.task-info {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 6px;
}
.task-name-group {
display: flex;
flex-direction: column;
max-width: 60%;
}
.status-group {
display: flex;
align-items: center;
gap: 8px;
}
.task-name {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-desc {
font-size: 0.8em;
color: #888;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-error {
font-size: 0.8em;
color: #F56C6C;
margin-top: 2px;
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.task-status.pending { color: #909399; }
.task-status.processing { color: #409EFF; }
.task-status.completed { color: #67C23A; }
.task-status.failed { color: #F56C6C; }
.task-status.cancelled { color: #E6A23C; }
</style>
@@ -0,0 +1,134 @@
<template>
<el-dialog
v-model="visible"
title="Terminal Console"
width="800px"
:before-close="handleClose"
class="terminal-dialog"
destroy-on-close
>
<div class="terminal-window" ref="terminalRef">
<div v-if="logs.length === 0" class="empty-logs">
No logs available.
</div>
<div v-for="(log, index) in logs" :key="index" class="log-line">
{{ log }}
</div>
<div v-if="isRunning" class="loading-indicator">
<span class="cursor">_</span>
</div>
</div>
</el-dialog>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import axios from 'axios'
const props = defineProps({
visible: Boolean,
taskId: String
})
const emit = defineEmits(['update:visible'])
const visible = ref(props.visible)
const logs = ref([])
const isRunning = ref(false)
const terminalRef = ref(null)
let pollingInterval = null
watch(() => props.visible, (val) => {
visible.value = val
if (val && props.taskId) {
startPolling()
} else {
stopPolling()
}
})
watch(() => props.taskId, (val) => {
if (visible.value && val) {
logs.value = []
startPolling()
}
})
const handleClose = () => {
emit('update:visible', false)
}
const fetchLogs = async () => {
if (!props.taskId) return
try {
const res = await axios.get(`/api/v1/tasks/${props.taskId}`)
const task = res.data
logs.value = task.logs || []
isRunning.value = ['pending', 'processing'].includes(task.status)
// Auto scroll to bottom
nextTick(() => {
if (terminalRef.value) {
terminalRef.value.scrollTop = terminalRef.value.scrollHeight
}
})
if (['completed', 'failed'].includes(task.status)) {
stopPolling()
}
} catch (e) {
console.error("Failed to fetch task logs", e)
}
}
const startPolling = () => {
stopPolling()
fetchLogs() // Immediate
pollingInterval = setInterval(fetchLogs, 1000)
}
const stopPolling = () => {
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
}
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped>
.terminal-window {
background-color: #1e1e1e;
color: #00ff00;
font-family: 'Courier New', Courier, monospace;
padding: 16px;
height: 400px;
overflow-y: auto;
border-radius: 4px;
font-size: 14px;
line-height: 1.4;
}
.log-line {
word-break: break-all;
white-space: pre-wrap;
margin-bottom: 4px;
}
.empty-logs {
color: #666;
font-style: italic;
}
.cursor {
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
</style>