feat: 新增项目配置选项和任务日志功能

添加分辨率配置选项,支持在项目设置中选择1K/2K/4K分辨率
为任务模型添加logs字段,记录任务执行过程中的详细日志
实现任务日志记录功能,包括时间戳和状态更新
新增多个前端对话框组件:终端日志、历史记录、JSON编辑器等
优化AI服务生成图像接口,支持传入分辨率和宽高比参数
重构任务管理界面,添加折叠功能和日志查看入口
This commit is contained in:
p
2026-01-17 18:35:57 +08:00
parent cc65e16349
commit 3f0815e997
16 changed files with 1958 additions and 970 deletions
+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
@@ -41,6 +42,7 @@ class TaskBase(SQLModel):
status: str # 'pending', 'processing', 'completed', 'failed' status: str # 'pending', 'processing', 'completed', 'failed'
progress: int = 0 # 0-100 progress: int = 0 # 0-100
message: Optional[str] = None message: Optional[str] = None
logs: List[str] = Field(default=[], sa_column=Column(JSON))
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
result: Dict = Field(default={}, sa_column=Column(JSON)) result: Dict = Field(default={}, sa_column=Column(JSON))
+120 -54
View File
@@ -18,6 +18,24 @@ import sys
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 +90,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 +134,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 +149,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"]
@@ -171,7 +189,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 +197,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 +301,49 @@ 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):
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 +353,26 @@ 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) # Update progress at start of loop
session.add(task) # task.progress = int((i / total_items) * 100)
session.commit() # 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 +414,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 +438,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 +476,14 @@ 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: # if char.image_url:
logger.info(f"Character {char.name} already has image, skipping.") # logger.info(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}")
# Construct Natural Language Prompt from JSON # Construct Natural Language Prompt from JSON
data = char.data data = char.data
@@ -475,26 +518,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 +611,12 @@ 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) image_bytes = ai.generate_image(
prompt,
aspect_ratio=char.project.aspect_ratio or "16:9",
resolution=char.project.resolution or "2K"
)
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 +627,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 +849,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 +865,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)
+1
View File
@@ -49,6 +49,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
+8 -1
View File
@@ -4,6 +4,7 @@ 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
@@ -39,7 +40,7 @@ class AIService:
print(f"Error generating storyboard: {e}") print(f"Error generating storyboard: {e}")
raise e 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]
@@ -62,6 +63,12 @@ class AIService:
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
),
)
) )
if response.parts: if response.parts:
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" v-if="item.data.prompt">
<label>Full Prompt:</label>
<div class="detail-content prompt-text">
{{ item.data.prompt }}
</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>
+176
View File
@@ -0,0 +1,176 @@
<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 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 } from '@element-plus/icons-vue'
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 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'
}
return map[status] || status
}
const getTaskProgressStatus = (status) => {
if (status === 'completed') return 'success'
if (status === 'failed') return 'exception'
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; }
</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>