diff --git a/backend/app/core/prompts.py b/backend/app/core/prompts.py index 3b5f8d5..455788e 100644 --- a/backend/app/core/prompts.py +++ b/backend/app/core/prompts.py @@ -80,10 +80,10 @@ Behaviors and Rules: } - 'characters': ['List of characters appearing in this group of panels'] - 'plot_breakdown': [ - {'panel': 1, 'scene': '...', 'action': '...', 'dialogue': '...'}, - {'panel': 2, 'scene': '...', 'action': '...', 'dialogue': '...'}, - {'panel': 3, 'scene': '...', 'action': '...', 'dialogue': '...'}, - {'panel': 4, 'scene': '...', 'action': '...', 'dialogue': '...'} + {'panel': 1, 'scene': '...', 'action': '...', 'dialogue': '...', 'prompt': 'Detailed visual description for image generation...'}, + {'panel': 2, 'scene': '...', 'action': '...', 'dialogue': '...', 'prompt': '...'}, + {'panel': 3, 'scene': '...', 'action': '...', 'dialogue': '...', 'prompt': '...'}, + {'panel': 4, 'scene': '...', 'action': '...', 'dialogue': '...', 'prompt': '...'} ] 3) Quality Control (Quality Control): diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 09bb084..be4fb5b 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -22,6 +22,7 @@ class ProjectBase(SQLModel): language: Optional[str] = "zh-CN" panel_count: Optional[int] = 16 aspect_ratio: Optional[str] = "16:9" + resolution: Optional[str] = "2K" class CharacterBase(SQLModel): name: str @@ -41,6 +42,7 @@ class TaskBase(SQLModel): status: str # 'pending', 'processing', 'completed', 'failed' progress: int = 0 # 0-100 message: Optional[str] = None + logs: List[str] = Field(default=[], sa_column=Column(JSON)) name: Optional[str] = None description: Optional[str] = None result: Dict = Field(default={}, sa_column=Column(JSON)) diff --git a/backend/app/routers/generation.py b/backend/app/routers/generation.py index 03b24db..eb6bfa1 100644 --- a/backend/app/routers/generation.py +++ b/backend/app/routers/generation.py @@ -18,6 +18,24 @@ import sys logging.basicConfig(level=logging.INFO, stream=sys.stdout) 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): 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) @@ -72,7 +90,7 @@ def generate_storyboard_task(task_id: str, project_id: str, user_input: str): try: 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) project.story_input = user_input @@ -116,7 +134,7 @@ def generate_storyboard_task(task_id: str, project_id: str, user_input: str): if 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) # --- Save Generated Text to Temp File --- @@ -131,12 +149,12 @@ def generate_storyboard_task(task_id: str, project_id: str, user_input: str): try: with open(temp_file, "w", encoding="utf-8") as f: f.write(generated_text) - logger.info(f"Saved raw AI output to {temp_file}") + log_task_event(session, task_id, f"Saved raw AI output to {temp_file}") except Exception as e: logger.error(f"Failed to save temp AI output: {e}") # ---------------------------------------- - logger.info("AI generation complete. Extracting JSON blocks...") + log_task_event(session, task_id, "AI generation complete. Extracting JSON blocks...") json_blocks = extract_json_blocks(generated_text) char_blocks = [b for b in json_blocks if b.get("type") == "character_sheet"] @@ -171,7 +189,7 @@ def generate_storyboard_task(task_id: str, project_id: str, user_input: str): missing_chars.append(name) 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." 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) new_chars = [b for b in fix_blocks if b.get("type") == "character_sheet"] if new_chars: - logger.info(f"Successfully generated {len(new_chars)} missing characters.") + log_task_event(session, task_id, f"Successfully generated {len(new_chars)} missing characters.") char_blocks.extend(new_chars) except Exception as e: logger.error(f"Failed to generate missing characters: {e}") @@ -283,35 +301,49 @@ def generate_all_images_task(task_id: str, project_id: str): # 1. Generate 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): 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 - 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 += "\n\n generate a character design sheet with 4 panels: front view, side view, clothing details, accessories." try: - image_bytes = ai.generate_image(json_prompt) + 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) char.image_url = relative_url session.add(char) 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: 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?) - # Let's simple split: chars + storyboard items + # Update task progress + # progress = int(((i + 1) / total_chars) * 100) + # task.progress = progress + # session.add(task) + # session.commit() # 2. Generate Storyboard Items (Sequential) # Re-fetch items to ensure order items = sorted(project.storyboard_items, key=lambda x: x.sequence) total_items = len(items) - logger.info(f"Generating {total_items} storyboard panels...") + log_task_event(session, task_id, f"Generating {total_items} storyboard panels...") 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 i, item in enumerate(items): - task.progress = int((i / total_items) * 100) - session.add(task) - session.commit() + # Update progress at start of loop + # task.progress = int((i / total_items) * 100) + # session.add(task) + # session.commit() - if item.image_url: - # Add to history - filename = os.path.basename(item.image_url) - # We need to find where it is stored. - # Assuming standard structure - # We need absolute path for history - # item.image_url is like /static/{project_id}/panels/{filename} - rel_path = item.image_url.lstrip("/") - abs_path = os.path.join(base_dir, rel_path.replace("/", os.sep)) - - if os.path.exists(abs_path): - generated_history.append(abs_path) - continue + # if item.image_url: + # # Add to history + # filename = os.path.basename(item.image_url) + # # We need to find where it is stored. + # # Assuming standard structure + # # We need absolute path for history + # # item.image_url is like /static/{project_id}/panels/{filename} + # rel_path = item.image_url.lstrip("/") + # abs_path = os.path.join(base_dir, rel_path.replace("/", os.sep)) + # + # if os.path.exists(abs_path): + # generated_history.append(abs_path) + # continue - logger.info(f"Generating panel {item.sequence}...") + log_task_event(session, task_id, f"Generating panel {item.sequence}...") # Prepare Context 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." 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) item.image_url = relative_url session.add(item) 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) # We need absolute path for next context # save_generated_image returns relative /static/... @@ -395,17 +438,17 @@ def generate_all_images_task(task_id: str, project_id: str): # strip leading / abs_path = os.path.join(base_dir, relative_url.lstrip("/").replace("/", os.sep)) generated_history.append(abs_path) - logger.info(f"Panel {item.sequence} generated successfully.") + log_task_event(session, task_id, f"Panel {item.sequence} generated successfully.") except Exception as e: logger.error(f"Failed to generate panel {item.id}: {e}") - print(f"Failed to generate panel {item.id}: {e}") + log_task_event(session, task_id, f"Failed to generate panel {item.id}: {e}") task.status = "completed" task.progress = 100 session.add(task) session.commit() - logger.info(f"Batch generation task {task_id} completed successfully.") + log_task_event(session, task_id, f"Batch generation task {task_id} completed successfully.") except Exception as 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) 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): - if char.image_url: - logger.info(f"Character {char.name} already has image, skipping.") - continue + # 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 data = char.data @@ -475,26 +518,38 @@ Ensure the character's expression and pose reflect their personality: {personali """ 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) char.image_url = relative_url session.add(char) 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: 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 - progress = int(((i + 1) / total_chars) * 100) - task.progress = progress - session.add(task) - session.commit() + # progress = int(((i + 1) / total_chars) * 100) + # task.progress = progress + # session.add(task) + # session.commit() task.status = "completed" task.progress = 100 session.add(task) session.commit() - logger.info(f"Batch character generation task {task_id} completed successfully.") + log_task_event(session, task_id, f"Batch character generation task {task_id} completed successfully.") except Exception as 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}. """ - logger.info(f"Calling AI service for character {char.name}...") - image_bytes = ai.generate_image(prompt) + log_task_event(session, task_id, f"Calling AI service for character {char.name}...") + 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) @@ -568,10 +627,11 @@ Ensure the character's expression and pose reflect their personality: {personali task.progress = 100 session.add(task) 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: 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() task.status = "failed" task.message = str(e) @@ -789,8 +849,13 @@ def generate_panel_task(task_id: str, item_id: int): if meta_style: json_prompt += f"\n\nStyle Consistency Requirement: {meta_style}. Ensure the visual style matches the provided context images." - logger.info(f"Calling AI service for panel {item.sequence}...") - image_bytes = ai.generate_image(json_prompt, context_images) + log_task_event(session, task_id, f"Calling AI service for panel {item.sequence}...") + 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) item.image_url = relative_url @@ -800,10 +865,11 @@ def generate_panel_task(task_id: str, item_id: int): task.progress = 100 session.add(task) 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: 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() task.status = "failed" task.message = str(e) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 8238f2e..b59d4ac 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -49,6 +49,7 @@ class ProjectUpdate(BaseModel): language: Optional[str] = None panel_count: Optional[int] = None aspect_ratio: Optional[str] = None + resolution: Optional[str] = None class ProjectRead(ProjectBase): id: str diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 8133421..aeac605 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -4,6 +4,7 @@ from typing import List, Optional from sqlmodel import Session from app.models.models import ModelConfig from google import genai +from google.genai import types from PIL import Image import io @@ -39,7 +40,7 @@ class AIService: print(f"Error generating storyboard: {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") contents = [prompt] @@ -62,6 +63,12 @@ class AIService: response = client.models.generate_content( model=model_name, contents=contents, + config=types.GenerateContentConfig( + image_config=types.ImageConfig( + aspect_ratio=aspect_ratio, + image_size=resolution + ), + ) ) if response.parts: diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index 279f4a8..5e03581 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -1,319 +1,77 @@ @@ -321,212 +79,126 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { useRoute } from 'vue-router' import axios from 'axios' -import { ElMessage, ElNotification } from 'element-plus' -import { Warning, ArrowUp, ArrowDown } from '@element-plus/icons-vue' +import { ElNotification } from 'element-plus' + +import ProjectHeader from './project/ProjectHeader.vue' +import StoryTab from './project/StoryTab.vue' +import CharacterTab from './project/CharacterTab.vue' +import StoryboardTab from './project/StoryboardTab.vue' +import TaskManager from './project/TaskManager.vue' +import HistoryDialog from './project/HistoryDialog.vue' +import MergeDialog from './project/MergeDialog.vue' +import ExportDialog from './project/ExportDialog.vue' +import TerminalDialog from './project/TerminalDialog.vue' const route = useRoute() const projectId = route.params.id + +// Project State const project = ref({ title: '', theme: '', language: 'zh-CN', panel_count: undefined, aspect_ratio: '16:9', + resolution: '2K', characters: [], - storyboard_items: [] + storyboard_items: [], + story_input: '', + global_config: null }) const loading = ref(false) const activeTab = ref('story') -const storyInput = ref('') -const activeTasks = ref([]) // Changed to array +const imageVersion = ref(Date.now()) + +// Task State +const activeTasks = ref([]) const isTaskManagerCollapsed = ref(false) const taskPollingInterval = ref(null) -const fileInput = ref(null) -const fileName = ref('') -const activeCharId = ref('') -// Export State -const showExportDialog = ref(false) -const splitImages = ref(false) - -// Merge State +// Dialog State const showMergeDialog = ref(false) -const mergeTargetId = ref(null) -const mergeSourceIds = ref([]) +const showExportDialog = ref(false) +const showHistoryDialog = ref(false) +const showTerminalDialog = ref(false) +const currentTerminalTaskId = ref('') // History State -const showHistoryDialog = ref(false) -const historyList = ref([]) const currentHistoryType = ref('') const currentHistoryEntityId = ref('') -// Editors state -const editors = ref({ - global_config: '', - characters: {}, - storyboard: {} -}) - -const sortedStoryboard = computed(() => { - if (!project.value.storyboard_items) return [] - return [...project.value.storyboard_items].sort((a, b) => a.sequence - b.sequence) +const currentHistoryImageUrl = computed(() => { + if (currentHistoryType.value === 'character') { + const char = project.value.characters.find(c => c.id === currentHistoryEntityId.value) + return char?.image_url + } else { + const item = project.value.storyboard_items.find(i => i.id === currentHistoryEntityId.value) + return item?.image_url + } }) const isTaskRunning = computed(() => { return activeTasks.value.some(t => ['pending', 'processing'].includes(t.status)) }) -const runningTasksCount = computed(() => { - return activeTasks.value.filter(t => ['pending', 'processing'].includes(t.status)).length -}) - -const hasCharacters = computed(() => { - if (!project.value.characters) return false - return project.value.characters.some(c => c.image_url) -}) - -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 '' -} - -const selectedChar = computed(() => { - if (!activeCharId.value) return null - return project.value.characters.find(c => String(c.id) === activeCharId.value) -}) - -const handleCharSelect = (index) => { - activeCharId.value = index -} - -const getPanelCharacters = (itemId) => { - const item = project.value.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 initEditors = () => { - if (project.value.global_config) { - editors.value.global_config = JSON.stringify(project.value.global_config.data, null, 2) - } - if (project.value.characters) { - project.value.characters.forEach(char => { - editors.value.characters[char.id] = JSON.stringify(char.data, null, 2) - }) - if (!activeCharId.value && project.value.characters.length > 0) { - activeCharId.value = String(project.value.characters[0].id) - } - } - if (project.value.storyboard_items) { - project.value.storyboard_items.forEach(item => { - editors.value.storyboard[item.id] = JSON.stringify(item.data, null, 2) - }) - } - if (project.value.story_input) { - storyInput.value = project.value.story_input - } -} - +// Fetch Data const fetchProject = async () => { - // if (!project.value.id) loading.value = true // Don't show global loading on refresh try { const res = await axios.get(`/api/v1/projects/${projectId}`) - // Merge data to prevent flickering - // project.value = { ...project.value, ...res.data } - - // Smarter update: only update what changed - project.value.title = res.data.title - project.value.theme = res.data.theme - project.value.language = res.data.language - project.value.panel_count = res.data.panel_count - project.value.aspect_ratio = res.data.aspect_ratio - project.value.story_input = res.data.story_input - project.value.global_config = res.data.global_config - - // Update characters (keep references if possible) - if (res.data.characters) { - project.value.characters = res.data.characters - } - - // Update storyboard items (keep references if possible) - if (res.data.storyboard_items) { - project.value.storyboard_items = res.data.storyboard_items - } - - // Only init editors if first load or explicitly requested? - // Or if we are not editing? - // Let's not overwrite editors if user is typing! - // initEditors() - if (!editors.value.global_config) initEditors() - + // Update fields individually to preserve references where possible, + // though replacing the whole object is cleaner if children watch correctly. + // Our children watch deep or props change, so replacing is fine but might reset some local state if not careful. + // Let's do a merge or simple assign. + project.value = res.data + // Ensure arrays are at least empty arrays + if (!project.value.characters) project.value.characters = [] + if (!project.value.storyboard_items) project.value.storyboard_items = [] + // imageVersion.value = Date.now() // Disabled to prevent flickering. Backend uses unique filenames. } catch (error) { console.error('Fetch project error', error) - // ElMessage.error('无法加载项目') } finally { loading.value = false } } -// Poll for all project tasks +// Task Polling const pollActiveTasks = async () => { if (taskPollingInterval.value) clearInterval(taskPollingInterval.value) - taskPollingInterval.value = setInterval(async () => { - try { - const res = await axios.get(`/api/v1/tasks/project/${projectId}`) - // Filter to show running tasks or recently completed (last 1 min?) - // For simplicity, let's just show top 5 recent tasks - activeTasks.value = res.data.slice(0, 5) - - // Check if we need to refresh project data (if any task just completed) - const hasCompleted = res.data.some(t => t.status === 'completed' && (!activeTasks.value.find(old => old.id === t.id)?.status === 'completed')) - - // Check if any processing task has progress update or if we should refresh images - // If tasks are processing, we should fetch project data periodically to show new images - const anyProcessing = res.data.some(t => t.status === 'processing') - - if (hasCompleted || anyProcessing) { - // If completed or processing, refresh project to show new images/status - fetchProject() - } - - } catch (e) { - console.error("Polling error", e) - } - }, 2000) + // Immediate check + checkTasks() + + taskPollingInterval.value = setInterval(checkTasks, 2000) } -// Watch active tasks to trigger project refresh on completion +const checkTasks = async () => { + try { + const res = await axios.get(`/api/v1/tasks/project/${projectId}`) + activeTasks.value = res.data.slice(0, 5) // Top 5 + + // Check if we need to refresh project data + // If any task completed since last check (we can't easily track "since last check" without state, + // but we can check if any task is processing, or if we just had a completion) + + const anyProcessing = res.data.some(t => t.status === 'processing') + if (anyProcessing) { + // Optional: periodically refresh project to see partial updates? + // Or just wait for completion. + // Original code refreshed on completion or processing. + // Let's refresh only on completion events handled by the watcher below. + + // To support real-time image updates during batch generation: + fetchProject() + } + } catch (e) { + console.error("Polling error", e) + } +} + +// Watch tasks for completion watch(activeTasks, (newTasks, oldTasks) => { if (oldTasks.length === 0) return - // If any task changed from processing/pending to completed const changed = newTasks.some(t => { const old = oldTasks.find(o => o.id === t.id) return t.status === 'completed' && old && old.status !== 'completed' @@ -538,230 +210,27 @@ watch(activeTasks, (newTasks, oldTasks) => { } }, { deep: true }) - -const saveStoryInput = async () => { - try { - await axios.put(`/api/v1/projects/${projectId}`, { story_input: storyInput.value }) - } catch (e) { - console.error("Failed to save story input", e) - } -} - -const saveSettings = async () => { - try { - await axios.put(`/api/v1/projects/${projectId}`, { - theme: project.value.theme, - language: project.value.language, - panel_count: project.value.panel_count, - aspect_ratio: project.value.aspect_ratio - }) - ElMessage.success('Settings saved') - } catch (e) { - ElMessage.error('Failed to save settings') - } -} - -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/${projectId}`, { - user_input: storyInput.value - }) - pollActiveTasks() // Ensure polling is active - ElMessage.info('Storyboard generation task started in background...') - } catch (error) { - ElMessage.error('Failed to start task: ' + (error.response?.data?.detail || error.message)) - } -} - -const generateAllImages = async () => { - try { - await axios.post(`/api/v1/generate/all-images/${projectId}`) - pollActiveTasks() - ElMessage.info('Full image generation task started in background...') - } catch (error) { - ElMessage.error('Failed to start task: ' + (error.response?.data?.detail || error.message)) - } -} - -const updateGlobalConfig = async () => { - try { - const data = JSON.parse(editors.value.global_config) - await axios.put(`/api/v1/projects/${projectId}/global_config`, data) - ElMessage.success('Global config synced') - await fetchProject() - } catch (e) { - ElMessage.error('JSON format error or save failed') - } -} - -const updateCharacter = async (charId) => { - try { - const data = JSON.parse(editors.value.characters[charId]) - await axios.put(`/api/v1/projects/${projectId}/characters/${charId}`, data) - ElMessage.success('Character setting saved') - } catch (e) { - ElMessage.error('JSON format error or save failed') - } -} - -const updateStoryboardItem = async (itemId) => { - try { - const data = JSON.parse(editors.value.storyboard[itemId]) - await axios.put(`/api/v1/projects/${projectId}/storyboard/${itemId}`, data) - ElMessage.success('Storyboard content saved') - } catch (e) { - ElMessage.error('JSON format error or save failed') - } -} - -const generateCharacter = async (charId) => { - try { - // Now returns task_id - await axios.post(`/api/v1/generate/character/${charId}`) - pollActiveTasks() - 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/${projectId}`) - pollActiveTasks() - ElMessage.info('Batch character drawing task started in background...') - } catch (error) { - ElMessage.error('Failed to start task: ' + (error.response?.data?.detail || error.message)) - } -} - -const generatePanel = async (itemId) => { - // Single panel generation is still sync for now, or should we make it async? - // User said "Storyboard generation... show json... save json... download image". - // User also said "Draw/Redraw character not using background task" -> Fixed. - // User didn't explicitly say single panel generation must be background, but "Generate all images" is background. - // Let's keep single panel sync for now unless user complains, as it's faster feedback for one image. - try { - await axios.post(`/api/v1/generate/panel/${itemId}`) - pollActiveTasks() - 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)) - } -} - -const deleteCharacter = async (charId) => { - try { - await axios.delete(`/api/v1/projects/${projectId}/characters/${charId}`) - ElMessage.success('Character deleted') - // Clear selection if deleted - if (activeCharId.value === String(charId)) { - activeCharId.value = '' - } - await fetchProject() - } catch (e) { - ElMessage.error('Delete failed') - } -} - -const openMergeDialog = () => { - mergeTargetId.value = null - mergeSourceIds.value = [] - showMergeDialog.value = true -} - -const confirmMerge = async () => { - try { - await axios.post(`/api/v1/projects/${projectId}/characters/merge`, { - target_char_id: mergeTargetId.value, - source_char_ids: mergeSourceIds.value - }) - ElMessage.success('Merge successful') - showMergeDialog.value = false - // Reset selection if needed - if (mergeSourceIds.value.includes(Number(activeCharId.value))) { - activeCharId.value = String(mergeTargetId.value) - } - await fetchProject() - } catch (e) { - ElMessage.error('Merge failed: ' + (e.response?.data?.detail || e.message)) - } -} - +// Actions const openExportDialog = () => { - if (!hasCharacters.value && !project.value.storyboard_items.some(i => i.image_url)) { - ElMessage.warning('No exportable image content') + if (!project.value.characters.length && !project.value.storyboard_items.some(i => i.image_url)) { + ElNotification({ title: 'Warning', message: 'No exportable image content', type: 'warning' }) return } showExportDialog.value = true } -const confirmExport = async () => { - try { - const res = await axios.get(`/api/v1/export/${projectId}`, { - params: { split_images: splitImages.value } - }) - window.open(res.data.download_url, '_blank') - showExportDialog.value = false - ElMessage.success('Export download started') - } catch (error) { - ElMessage.error('Export failed: ' + (error.response?.data?.detail || error.message)) - } -} - -const openHistory = async (type, id) => { +const openHistory = (type, id) => { currentHistoryType.value = type currentHistoryEntityId.value = id - try { - const res = await axios.get(`/api/v1/history/${type}/${id}`) - historyList.value = res.data - showHistoryDialog.value = true - } catch (e) { - ElMessage.error('Failed to load history') - } + showHistoryDialog.value = true } -const selectHistoryImage = async (historyItem) => { - try { - await axios.post(`/api/v1/history/select/${historyItem.id}`) - ElMessage.success('Image switched') - showHistoryDialog.value = false - await fetchProject() - } catch (e) { - ElMessage.error('Switch failed') - } -} - -const isCurrentHistory = (h) => { - let currentUrl = '' - if (currentHistoryType.value === 'character') { - const char = project.value.characters.find(c => c.id === currentHistoryEntityId.value) - currentUrl = char?.image_url - } else { - const item = project.value.storyboard_items.find(i => i.id === currentHistoryEntityId.value) - currentUrl = item?.image_url - } - return h.image_url === currentUrl +const openTerminal = (taskId) => { + currentTerminalTaskId.value = taskId + showTerminalDialog.value = true } +// Lifecycle onMounted(() => { fetchProject() pollActiveTasks() @@ -779,243 +248,4 @@ onUnmounted(() => { display: flex; flex-direction: column; } -.header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; -} -.flex-row-between { - display: flex; - justify-content: space-between; - align-items: center; -} -.left-actions { - display: flex; - gap: 10px; -} -.mt-2 { margin-top: 10px; } -.mb-2 { margin-bottom: 10px; } -.ml-2 { margin-left: 10px; } -.mr-1 { margin-right: 5px; } -.mt-1 { margin-top: 5px; } -.text-gray { color: #666; font-size: 0.9em; } -.warning-text { color: #E6A23C; font-size: 0.9em; display: inline-flex; align-items: center; gap: 4px; } - -/* Task Manager */ -.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: 70%; -} -.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; } - -.image-preview, .comic-preview { - width: 100%; - height: 400px; - background: #333; - border-radius: 4px; -} -.no-image { - width: 100%; - height: 300px; - display: flex; - justify-content: center; - align-items: center; - background: #2a2a2a; - color: #666; - border-radius: 4px; -} -.comic-row { - margin-bottom: 20px; -} -.json-item { - margin-bottom: 15px; - border-bottom: 1px solid #333; - padding-bottom: 10px; -} -.json-header { - margin-bottom: 5px; - font-size: 1.1em; - color: #409EFF; -} -.panel-info p { margin: 5px 0; color: #bbb; } -.actions { display: flex; gap: 10px; } -.button-group { display: flex; gap: 10px; align-items: center; } -.upload-area { display: flex; align-items: center; gap: 10px; } -.file-name { color: #888; font-size: 0.9em; } -.settings-form { margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid #333; } - -.char-studio-container { - height: 600px; - border: 1px solid #333; - background: #1e1e1e; -} -.char-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - border-bottom: 1px solid #333; - padding-bottom: 10px; -} -.empty-state { - display: flex; - justify-content: center; - align-items: center; - height: 100%; - color: #666; - font-size: 1.2em; -} -.json-content { - background: #111; - padding: 10px; - border-radius: 4px; - overflow: auto; - max-height: 300px; - font-size: 0.85em; - color: #a6e22e; -} -.card-header { - display: flex; - justify-content: space-between; - align-items: center; -} -.tip-text { color: #888; font-size: 0.9em; } - -/* History Dialog Styles */ -.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; -} -.header-actions { - display: flex; - gap: 10px; -} -.image-actions { - display: flex; - align-items: center; - justify-content: center; -} - + \ No newline at end of file diff --git a/frontend/src/views/project/CharacterTab.vue b/frontend/src/views/project/CharacterTab.vue new file mode 100644 index 0000000..f93671b --- /dev/null +++ b/frontend/src/views/project/CharacterTab.vue @@ -0,0 +1,462 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/project/ExportDialog.vue b/frontend/src/views/project/ExportDialog.vue new file mode 100644 index 0000000..b108533 --- /dev/null +++ b/frontend/src/views/project/ExportDialog.vue @@ -0,0 +1,55 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/project/HistoryDialog.vue b/frontend/src/views/project/HistoryDialog.vue new file mode 100644 index 0000000..771a012 --- /dev/null +++ b/frontend/src/views/project/HistoryDialog.vue @@ -0,0 +1,115 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/project/JsonEditorDialog.vue b/frontend/src/views/project/JsonEditorDialog.vue new file mode 100644 index 0000000..ceda22a --- /dev/null +++ b/frontend/src/views/project/JsonEditorDialog.vue @@ -0,0 +1,63 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/project/MergeDialog.vue b/frontend/src/views/project/MergeDialog.vue new file mode 100644 index 0000000..5de6cea --- /dev/null +++ b/frontend/src/views/project/MergeDialog.vue @@ -0,0 +1,83 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/project/ProjectHeader.vue b/frontend/src/views/project/ProjectHeader.vue new file mode 100644 index 0000000..151b597 --- /dev/null +++ b/frontend/src/views/project/ProjectHeader.vue @@ -0,0 +1,28 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/project/StoryTab.vue b/frontend/src/views/project/StoryTab.vue new file mode 100644 index 0000000..b202daf --- /dev/null +++ b/frontend/src/views/project/StoryTab.vue @@ -0,0 +1,243 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/project/StoryboardTab.vue b/frontend/src/views/project/StoryboardTab.vue new file mode 100644 index 0000000..2768297 --- /dev/null +++ b/frontend/src/views/project/StoryboardTab.vue @@ -0,0 +1,323 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/project/TaskManager.vue b/frontend/src/views/project/TaskManager.vue new file mode 100644 index 0000000..fafdb5b --- /dev/null +++ b/frontend/src/views/project/TaskManager.vue @@ -0,0 +1,176 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/project/TerminalDialog.vue b/frontend/src/views/project/TerminalDialog.vue new file mode 100644 index 0000000..e177822 --- /dev/null +++ b/frontend/src/views/project/TerminalDialog.vue @@ -0,0 +1,134 @@ + + + + + \ No newline at end of file