feat: five-stage creation workbench (HEL-614)

- backend: project_stages/stage_runs tables; /api/workbench router
  (overview, run agent with confirmed-upstream context only, edit draft,
  confirm, restore, script import, in-flow image/video generation via the
  shared queue with source=workbench); project stats gain real
  stage_confirmed/stage_status/active_jobs
- frontend: /workbench picker + /workbench/[id] five-stage page (stepper,
  per-stage editors, style direction chooser, storyboard table, asset
  prompt cards with in-flow generation, shot video prompts with player,
  agent + task side rail); nav 创作工作台 now opens it; home entries go
  through the workbench (导入剧本 skips 剧本创作); project cards show
  real five-stage progress; i18n keys in all seven dictionaries
- tests: 9 backend workbench tests (flow handoff, edit/confirm/restore,
  import, generation, Chinese failure states)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总工
2026-09-28 13:30:19 +08:00
co-authored by multica-agent
parent eeafe739f7
commit 06b15dc3b6
31 changed files with 7178 additions and 738 deletions
+35
View File
@@ -283,6 +283,41 @@ CREATE TABLE IF NOT EXISTS shot_capture (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_shot_capture_composition ON shot_capture(composition_id);
-- Five-stage creation workbench (HEL-614): one row per project per stage.
-- content_json is the working draft the user can edit; confirmed_content_json
-- is the last confirmed snapshot — the only version handed to the next agent
-- (unconfirmed drafts are never treated as final).
CREATE TABLE IF NOT EXISTS project_stages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
stage_key TEXT NOT NULL CHECK(stage_key IN ('script','style','storyboard','assets','shot')),
status TEXT NOT NULL DEFAULT 'todo' CHECK(status IN ('todo','running','awaiting_confirm','confirmed')),
content_json TEXT,
confirmed_content_json TEXT,
chosen_style_id TEXT,
draft_edited INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project_id, stage_key)
);
CREATE INDEX IF NOT EXISTS idx_project_stages_project ON project_stages(project_id);
-- Audit trail of agent runs inside the flow (HEL-614): instruction in,
-- normalized JSON artifact out, or a Chinese-readable failure reason.
CREATE TABLE IF NOT EXISTS stage_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
stage_key TEXT NOT NULL CHECK(stage_key IN ('script','style','storyboard','assets','shot')),
instruction TEXT,
output_json TEXT,
status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running','done','failed')),
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_stage_runs_project ON stage_runs(project_id, stage_key);
"""
+2
View File
@@ -30,6 +30,7 @@ from calliope.routers import (
settings as settings_router,
shots,
story,
workbench,
workflows,
)
@@ -141,6 +142,7 @@ def create_app(static_dir: Path | None = None) -> FastAPI:
app.include_router(canvas.router, prefix="/api/canvas", tags=["canvas"])
app.include_router(shots.router, prefix="/api/shots", tags=["shots"])
app.include_router(agent.router, prefix="/api/agent", tags=["agent"])
app.include_router(workbench.router, prefix="/api/workbench", tags=["workbench"])
app.include_router(events.router, prefix="/api/events", tags=["events"])
@app.get("/api/health")
@@ -39,6 +39,10 @@ class ProjectStats(BaseModel):
character_count: int
asset_ready_count: int
asset_total_count: int
# Five-stage workbench progress (HEL-614) — real stage rows + live jobs.
stage_confirmed: int = 0
stage_status: dict[str, str] = Field(default_factory=dict)
active_jobs: int = 0
class Project(BaseModel):
@@ -9,7 +9,7 @@ from calliope.models.schemas import Project, ProjectCreate, ProjectUpdate
router = APIRouter()
def _project_stats(project_id: int, conn) -> dict[str, int]:
def _project_stats(project_id: int, conn) -> dict:
cur = conn.execute(
"""
SELECT
@@ -18,11 +18,26 @@ def _project_stats(project_id: int, conn) -> dict[str, int]:
(SELECT COUNT(*) FROM characters WHERE project_id = :pid AND sheet_path IS NOT NULL)
+ (SELECT COUNT(*) FROM locations WHERE project_id = :pid AND reference_image_path IS NOT NULL) AS asset_ready_count,
(SELECT COUNT(*) FROM characters WHERE project_id = :pid)
+ (SELECT COUNT(*) FROM locations WHERE project_id = :pid) AS asset_total_count
+ (SELECT COUNT(*) FROM locations WHERE project_id = :pid) AS asset_total_count,
(SELECT COUNT(*) FROM jobs WHERE project_id = :pid
AND status IN ('pending','running')) AS active_jobs
""",
{"pid": project_id},
)
return dict(cur.fetchone())
stats = dict(cur.fetchone())
# Five-stage creation flow (HEL-614) — real per-stage states. Fresh
# projects have no rows yet, which reads as "not started" honestly.
try:
rows = conn.execute(
"SELECT stage_key, status FROM project_stages WHERE project_id = ?",
(project_id,),
).fetchall()
except Exception: # pragma: no cover — pre-migration DB
rows = []
stage_status = {r["stage_key"]: r["status"] for r in rows}
stats["stage_status"] = stage_status
stats["stage_confirmed"] = sum(1 for s in stage_status.values() if s == "confirmed")
return stats
@router.post("", response_model=Project)
@@ -0,0 +1,916 @@
"""Five-stage creation workbench (HEL-614).
剧本创作 → 视觉设计 → 分镜设计 → 素材资产 → 镜头脚本 — one project, five
named agents in sequence. Each stage holds a working draft the user can edit
(``content_json``) and the last confirmed snapshot
(``confirmed_content_json``); only confirmed snapshots flow into the next
agent's context, so an unconfirmed draft is never treated as final.
Stage runs reuse the HEL-613 registry (persona + llm_role + skill bindings)
with a workbench-specific mode prompt and a per-stage JSON artifact schema.
In-flow generation reuses the HEL-612 shared queue: jobs are project-linked
with ``payload.source = "workbench"`` so tasks and results come back to the
project (and the tasks page) like every other job.
"""
from __future__ import annotations
import asyncio
import json
import re
from typing import Any, Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from calliope import config
from calliope.agent.agents_registry import AGENTS, AGENT_KEYS
from calliope.agent.llm import LLMClient, extract_json
from calliope.agent.skills_store import effective_skill_dirs, list_skills
from calliope.comfyui.parser import parse_dynamic_inputs
from calliope.comfyui.smart_fill import smart_fill_inputs
from calliope.db import get_db, row_to_dict
from calliope.events.bus import event_bus
from calliope.queue.manager import queue_manager
router = APIRouter()
STAGE_KEYS: tuple[str, ...] = AGENT_KEYS # script, style, storyboard, assets, shot
StageKey = Literal["script", "style", "storyboard", "assets", "shot"]
WORKBENCH_SOURCE = "workbench"
class StagePatch(BaseModel):
content: dict[str, Any]
chosen_style_id: str | None = None
class StageRunRequest(BaseModel):
instruction: str | None = Field(default=None, max_length=8000)
class StageConfirmRequest(BaseModel):
chosen_style_id: str | None = None
class ScriptImportRequest(BaseModel):
text: str = Field(min_length=1, max_length=200_000)
class WorkbenchGenerateRequest(BaseModel):
kind: Literal["image", "video"]
stage_key: StageKey
target_id: str | None = Field(default=None, max_length=64)
prompt: str = Field(min_length=1, max_length=8000)
negative_prompt: str | None = Field(default=None, max_length=2000)
duration_sec: float | None = None
# ─────────────────────────────────────────────────────────────────────────
# Content normalization — one defensive normalizer per stage. Accepts mild
# LLM shape drift, fills defaults, rejects unusable output with a Chinese
# reason that surfaces on the run row.
# ─────────────────────────────────────────────────────────────────────────
class StageContentError(ValueError):
"""Unusable artifact content; str(err) is user-facing Chinese."""
def _text(value: Any) -> str:
return str(value if value is not None else "").strip()
def _num(value: Any, default: float) -> float:
try:
out = float(value)
except (TypeError, ValueError):
return default
return out if out > 0 else default
def _slug_id(prefix: str, index: int) -> str:
return f"{prefix}{index + 1}"
def _norm_script(data: dict[str, Any]) -> dict[str, Any]:
text = _text(data.get("text") or data.get("script") or data.get("content"))
if len(text) < 30:
raise StageContentError("剧本内容太短(不足 30 字),请重试或在下方手动补充。")
return {"text": text}
def _norm_style(data: dict[str, Any]) -> dict[str, Any]:
raw = data.get("directions") or data.get("styles") or []
if not isinstance(raw, list):
raise StageContentError("风格数据格式不正确,缺少风格方向列表。")
directions = []
used_ids: set[str] = set()
for i, item in enumerate(raw):
if not isinstance(item, dict):
continue
name = _text(item.get("name") or item.get("title"))
prompt = _text(item.get("prompt") or item.get("keywords"))
if not name or not prompt:
continue
sid = _text(item.get("id"))
if not sid or sid in used_ids:
sid = _slug_id("style", i)
n = 2
while sid in used_ids:
sid = f"style{i + 1}-{n}"
n += 1
used_ids.add(sid)
directions.append(
{
"id": sid,
"name": name,
"description": _text(item.get("description")),
"prompt": prompt,
"negative_prompt": _text(item.get("negative_prompt")),
}
)
if len(directions) < 2:
raise StageContentError("风格方向不足 2 套。请让智能体重试,或手动补充更多方向。")
return {"directions": directions}
def _norm_storyboard(data: dict[str, Any]) -> dict[str, Any]:
raw = data.get("shots") or data.get("storyboard") or []
if not isinstance(raw, list):
raise StageContentError("分镜数据格式不正确,缺少逐镜列表。")
shots = []
for i, item in enumerate(raw):
if not isinstance(item, dict):
continue
description = _text(item.get("description") or item.get("action") or item.get("content"))
if not description:
continue
shots.append(
{
"id": _text(item.get("id")) or _slug_id("shot", i),
"scene": _text(item.get("scene")) or f"{i + 1}",
"shot_size": _text(item.get("shot_size") or item.get("size")) or "中景",
"camera": _text(item.get("camera") or item.get("movement")) or "固定",
"description": description,
"dialog": _text(item.get("dialog") or item.get("dialogue")),
"duration_sec": _num(item.get("duration_sec") or item.get("duration"), 3.0),
}
)
if not shots:
raise StageContentError("分镜表为空。请让智能体重试,或手动添加镜头。")
return {"shots": shots}
def _norm_assets(data: dict[str, Any]) -> dict[str, Any]:
raw = data.get("assets") or data.get("items") or []
if not isinstance(raw, list):
raise StageContentError("素材数据格式不正确,缺少资产列表。")
assets = []
for i, item in enumerate(raw):
if not isinstance(item, dict):
continue
name = _text(item.get("name") or item.get("title"))
prompt = _text(item.get("prompt"))
if not name or not prompt:
continue
kind = _text(item.get("kind") or item.get("type"))
if kind not in ("character", "location", "prop"):
kind = "prop"
assets.append(
{
"id": _text(item.get("id")) or _slug_id("asset", i),
"kind": kind,
"name": name,
"description": _text(item.get("description")),
"prompt": prompt,
"negative_prompt": _text(item.get("negative_prompt")),
}
)
if not assets:
raise StageContentError("素材清单为空。请让智能体重试,或手动添加素材。")
return {"assets": assets}
def _norm_shot(data: dict[str, Any]) -> dict[str, Any]:
raw = data.get("shots") or []
if not isinstance(raw, list):
raise StageContentError("镜头脚本数据格式不正确,缺少逐镜列表。")
shots = []
for i, item in enumerate(raw):
if not isinstance(item, dict):
continue
prompt = _text(item.get("prompt") or item.get("video_prompt"))
if not prompt:
continue
shots.append(
{
"id": _text(item.get("id")) or _slug_id("vshot", i),
"shot_no": _text(item.get("shot_no") or item.get("shot")) or _slug_id("S", i),
"description": _text(item.get("description")),
"prompt": prompt,
"duration_sec": _num(item.get("duration_sec") or item.get("duration"), 5.0),
}
)
if not shots:
raise StageContentError("镜头脚本为空。请让智能体重试,或手动添加镜头。")
return {"shots": shots}
NORMALIZERS = {
"script": _norm_script,
"style": _norm_style,
"storyboard": _norm_storyboard,
"assets": _norm_assets,
"shot": _norm_shot,
}
# Short artifact brief embedded in each run prompt (Chinese; keeps the five
# agents' deliverables aligned with what the workbench UI renders).
STAGE_BRIEFS = {
"script": (
"产出完整可拍摄的短视频剧本:故事大纲、按场次展开的分场(场景/时间/地点/人物)、"
"人物小传要点、逐场台词与动作描述。直接输出剧本正文,不要写「好的」之类的客套话。"
),
"style": (
"给出至少 2 套(建议 3 套)差异化视觉风格方向,每套包含:风格名称、中文风格说明"
"(色调/光影/材质/线条)、可直接用于生图模型的英文正向提示词词组(逗号分隔)、"
"可选的英文负面提示词。提示词词组必须完整可复制。"
),
"storyboard": (
"把剧本拆成逐镜分镜表,每镜包含:场次、景别(远/全/中/近/特写)、运镜"
"(推/拉/摇/移/跟/固定)、画面内容描述、台词或音效起点、建议时长(秒)。"
"重要情绪点给特写,节奏有起伏。"
),
"assets": (
"结合剧本、已确认的视觉风格与分镜表,整理需要生图的资产清单(角色/场景/道具),"
"每项资产给出:类型、名称、中文说明、完整可直接复制的英文生图提示词"
"(主体外观细节+风格关键词+构图视角+画质词)、可选负面提示词。"
"同一角色要保持跨视角一致性。"
),
"shot": (
"把分镜表转化为视频生成模型(如 MiniMax H3)可用的逐镜脚本,每镜包含:镜号、"
"中文画面说明、完整可直接复制的英文视频提示词(主体与动作+运镜指令+镜头语言+"
"光影氛围)、建议时长(秒)。同一项目运镜与风格保持一致。"
),
}
def _stage_name(stage_key: str) -> str:
return AGENTS[stage_key]["name"] if stage_key in AGENTS else stage_key
# ─────────────────────────────────────────────────────────────────────────
# DB helpers
# ─────────────────────────────────────────────────────────────────────────
def _get_project(conn, project_id: int) -> dict[str, Any]:
row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
if not row or row["status"] == "system":
raise HTTPException(status_code=404, detail="项目不存在")
return row_to_dict(row)
def _ensure_stage_rows(conn, project_id: int) -> None:
for key in STAGE_KEYS:
conn.execute(
"INSERT OR IGNORE INTO project_stages (project_id, stage_key) VALUES (?, ?)",
(project_id, key),
)
conn.commit()
def _load_stage(conn, project_id: int, stage_key: str) -> dict[str, Any]:
row = conn.execute(
"SELECT * FROM project_stages WHERE project_id = ? AND stage_key = ?",
(project_id, stage_key),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="阶段不存在")
return row_to_dict(row)
def _stage_public(row: dict[str, Any]) -> dict[str, Any]:
return {
"stage_key": row["stage_key"],
"status": row["status"],
"content": json.loads(row["content_json"]) if row["content_json"] else None,
"confirmed_content": (
json.loads(row["confirmed_content_json"]) if row["confirmed_content_json"] else None
),
"chosen_style_id": row["chosen_style_id"],
"draft_edited": bool(row["draft_edited"]),
"error": row["error"],
"updated_at": row["updated_at"],
}
def _last_run(conn, project_id: int, stage_key: str) -> dict[str, Any] | None:
row = conn.execute(
"SELECT * FROM stage_runs WHERE project_id = ? AND stage_key = ? "
"ORDER BY id DESC LIMIT 1",
(project_id, stage_key),
).fetchone()
if not row:
return None
return {
"id": row["id"],
"status": row["status"],
"error": row["error"],
"instruction": row["instruction"],
"created_at": row["created_at"],
"completed_at": row["completed_at"],
}
def _job_public(job: dict[str, Any]) -> dict[str, Any]:
out = dict(job)
out["payload"] = json.loads(job.get("payload_json") or "{}")
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
out.pop("payload_json", None)
out.pop("output_paths_json", None)
return out
def _workbench_job_outputs(conn, project_id: int) -> dict[str, dict[str, Any]]:
"""target_id → latest workbench job (any status) for asset/shot panels."""
rows = conn.execute(
"SELECT * FROM jobs WHERE project_id = ? ORDER BY id ASC",
(project_id,),
).fetchall()
out: dict[str, dict[str, Any]] = {}
for row in rows:
job = _job_public(row_to_dict(row))
payload = job.get("payload") or {}
if payload.get("source") != WORKBENCH_SOURCE:
continue
target = payload.get("target_id")
if not target:
continue
prev = out.get(target)
# later rows win; keep the newest per target
if prev is None or int(job["id"]) > int(prev["id"]):
out[target] = job
return out
def _enrich_stage(
stage: dict[str, Any], outputs: dict[str, dict[str, Any]]
) -> dict[str, Any]:
content = stage.get("content") or {}
if stage["stage_key"] == "assets" and isinstance(content.get("assets"), list):
for asset in content["assets"]:
job = outputs.get(asset.get("id"))
if job:
asset["job"] = _job_brief(job)
elif stage["stage_key"] == "shot" and isinstance(content.get("shots"), list):
for shot in content["shots"]:
job = outputs.get(shot.get("id"))
if job:
shot["job"] = _job_brief(job)
return stage
def _job_brief(job: dict[str, Any]) -> dict[str, Any]:
return {
"id": job["id"],
"status": job["status"],
"kind": job["kind"],
"output": (job.get("output_paths") or [None])[0],
"error": job.get("error"),
}
# ─────────────────────────────────────────────────────────────────────────
# Stage-run prompt assembly + background execution
# ─────────────────────────────────────────────────────────────────────────
_SKILL_BODY_MAX_CHARS = 1600
_SKILL_BODY_MAX_COUNT = 3
def _skills_context(agent_key: str) -> str:
try:
effective = effective_skill_dirs(agent_key)
skills = [s for s in list_skills(cap=None) if s["dir"] in effective]
except Exception:
return ""
if not skills:
return ""
lines = ["以下 Skill 已分配给该智能体(用户配置),生成时请吸收其要点:"]
shown = 0
for skill in skills:
lines.append(f"- {skill['name']}: {skill['description']}")
if shown >= _SKILL_BODY_MAX_COUNT:
continue
body = _skill_body(skill["dir"])
if body:
lines.append(f" {skill['name']} 内容摘要:\n{body}")
shown += 1
return "\n".join(lines)
def _skill_body(skill_dir: str) -> str:
from calliope.agent.skills_store import skills_root
path = skills_root() / skill_dir / "SKILL.md"
try:
text = path.read_text(encoding="utf-8")
except OSError:
return ""
text = re.sub(r"^---.*?---\s*", "", text, flags=re.S)
text = text.strip()
if len(text) > _SKILL_BODY_MAX_CHARS:
text = text[:_SKILL_BODY_MAX_CHARS] + "…"
return text
def _system_prompt(stage_key: str) -> str:
agent = AGENTS[stage_key]
idx = STAGE_KEYS.index(stage_key) + 1
parts = [
agent["persona"],
(
f"WORKBENCH MODE: 你正在「创作工作台」五段流程中负责第 {idx} 段「{agent['name']}」。"
"本次输出会被程序解析入库并在工作台展示给用户编辑、确认,因此:\n"
"- 只输出一个 JSON 对象,不要加 Markdown 代码围栏,不要输出 JSON 以外的任何文字。\n"
"- 严格遵守用户消息末尾给出的 JSON 结构与字段要求,中文内容用中文写,"
"提示词词组按各段惯例(英文逗号分隔)。\n"
"- 这是正式交付物:内容要完整、具体、可直接使用,不要输出占位符或「待补充」。"
),
f"本段任务:{STAGE_BRIEFS[stage_key]}",
]
skills_ctx = _skills_context(stage_key)
if skills_ctx:
parts.append(skills_ctx)
return "\n\n".join(parts)
def _stage_context(conn, project_id: int, stage_key: str) -> str:
"""Confirmed upstream artifacts only — drafts never leak downstream."""
project = _get_project(conn, project_id)
blocks: list[str] = [f"项目名称:{project['title']}"]
if project.get("idea") and stage_key != "script":
idea = _text(project["idea"])
if len(idea) <= 600:
blocks.append(f"用户创意:{idea}")
rows = conn.execute(
"SELECT * FROM project_stages WHERE project_id = ?",
(project_id,),
).fetchall()
stages = {r["stage_key"]: row_to_dict(r) for r in rows}
def confirmed(key: str) -> dict[str, Any] | None:
row = stages.get(key)
if not row or row["status"] != "confirmed" or not row["confirmed_content_json"]:
return None
return json.loads(row["confirmed_content_json"])
script = confirmed("script")
if script and stage_key in ("style", "storyboard", "assets", "shot"):
blocks.append(f"【已确认的剧本】\n{_text(script.get('text'))}")
style = confirmed("style")
if style and stage_key in ("storyboard", "assets", "shot"):
chosen_id = stages.get("style", {}).get("chosen_style_id")
directions = style.get("directions") or []
chosen = next(
(d for d in directions if d.get("id") == chosen_id), directions[0] if directions else None
)
if chosen:
blocks.append(
"【已确认的视觉风格】\n"
f"{chosen.get('name')}:{chosen.get('description') or ''}\n"
f"风格关键词:{chosen.get('prompt')}"
)
else:
names = "、".join(d.get("name", "") for d in directions)
blocks.append(f"【已确认的视觉风格】候选方向:{names}")
board = confirmed("storyboard")
if board and stage_key in ("assets", "shot"):
lines = []
for i, shot in enumerate(board.get("shots") or [], 1):
lines.append(
f"{i}. 场次{shot.get('scene')}|{shot.get('shot_size')}|{shot.get('camera')}|"
f"{shot.get('description')}"
+ (f"|台词:{shot.get('dialog')}" if shot.get("dialog") else "")
)
blocks.append("【已确认的分镜表】\n" + "\n".join(lines))
assets = confirmed("assets")
if assets and stage_key == "shot":
lines = [
f"- {a.get('kind')}|{a.get('name')}:{a.get('description') or a.get('prompt')}"
for a in assets.get("assets") or []
]
blocks.append("【已确认的素材清单】\n" + "\n".join(lines))
return "\n\n".join(blocks)
_JSON_SCHEMAS = {
"script": '{"text": "完整剧本正文(简体中文,含大纲/分场/台词)"}',
"style": (
'{"directions": [{"id": "style1", "name": "风格名称", '
'"description": "中文风格说明", "prompt": "english, style, keywords", '
'"negative_prompt": "optional english negative keywords"}]}(至少 2 套)'
),
"storyboard": (
'{"shots": [{"id": "shot1", "scene": "场次", "shot_size": "景别", '
'"camera": "运镜", "description": "画面内容", "dialog": "台词(可空)", '
'"duration_sec": 3}]}'
),
"assets": (
'{"assets": [{"id": "asset1", "kind": "character|location|prop", '
'"name": "名称", "description": "中文说明", '
'"prompt": "complete english image prompt", '
'"negative_prompt": "可空"}]}'
),
"shot": (
'{"shots": [{"id": "vshot1", "shot_no": "S1", '
'"description": "中文画面说明", "prompt": "complete english video prompt", '
'"duration_sec": 5}]}'
),
}
# Strong refs so in-flight runs are never garbage-collected mid-turn
# (same pattern as the harness runner).
_ACTIVE_RUNS: dict[int, asyncio.Task] = {}
async def _execute_run(project_id: int, stage_key: str, run_id: int, instruction: str | None) -> None:
db = config.settings.db_path
try:
conn = get_db(db)
try:
context = _stage_context(conn, project_id, stage_key)
finally:
conn.close()
user_parts = [context]
if instruction and instruction.strip():
user_parts.append(f"用户补充要求:{instruction.strip()}")
user_parts.append(
f"请产出本段({ _stage_name(stage_key)})的 JSON:{_JSON_SCHEMAS[stage_key]}"
)
messages = [
{"role": "system", "content": _system_prompt(stage_key)},
{"role": "user", "content": "\n\n".join(user_parts)},
]
role = AGENTS[stage_key]["llm_role"]
client = LLMClient.for_role(role, timeout=240.0)
try:
text = await client.chat(messages, temperature=0.7)
finally:
await client.close()
content = NORMALIZERS[stage_key](extract_json(text))
conn = get_db(db)
try:
conn.execute(
"UPDATE stage_runs SET status='done', output_json=?, completed_at=CURRENT_TIMESTAMP "
"WHERE id = ?",
(json.dumps(content, ensure_ascii=False), run_id),
)
conn.execute(
"UPDATE project_stages SET content_json=?, status='awaiting_confirm', "
"draft_edited=0, error=NULL, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id = ? AND stage_key = ?",
(json.dumps(content, ensure_ascii=False), project_id, stage_key),
)
conn.execute(
"UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id = ?",
(project_id,),
)
conn.commit()
finally:
conn.close()
except Exception as exc: # noqa: BLE001 — any failure becomes a readable run row
reason = _friendly_error(exc)
try:
conn = get_db(db)
try:
conn.execute(
"UPDATE stage_runs SET status='failed', error=?, completed_at=CURRENT_TIMESTAMP "
"WHERE id = ?",
(reason, run_id),
)
conn.execute(
"UPDATE project_stages SET status=CASE "
"WHEN confirmed_content_json IS NOT NULL THEN 'confirmed' ELSE 'todo' END, "
"error=?, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id = ? AND stage_key = ? AND status = 'running'",
(reason, project_id, stage_key),
)
conn.commit()
finally:
conn.close()
except Exception: # pragma: no cover — bookkeeping failure only
pass
def _friendly_error(exc: Exception) -> str:
text = str(exc)
lowered = text.lower()
if "connect" in lowered or "timeout" in lowered or "timed out" in lowered:
return (
"连接大模型服务失败:请到「模型设置」检查地址与密钥,或点「重新生成」再试一次。"
)
if "no json" in lowered or isinstance(exc, ValueError):
return "智能体没有返回可解析的结果,请点「重新生成」重试;多次失败可换一个模型。"
return f"生成失败:{text[:300]}"
# ─────────────────────────────────────────────────────────────────────────
# Endpoints
# ─────────────────────────────────────────────────────────────────────────
@router.get("/projects/{project_id}")
async def workbench_overview(project_id: int) -> dict[str, Any]:
conn = get_db(config.settings.db_path)
try:
project = _get_project(conn, project_id)
_ensure_stage_rows(conn, project_id)
rows = conn.execute(
"SELECT * FROM project_stages WHERE project_id = ? ORDER BY id ASC",
(project_id,),
).fetchall()
outputs = _workbench_job_outputs(conn, project_id)
stages = []
for row in rows:
stage = _stage_public(row_to_dict(row))
stages.append(_enrich_stage(stage, outputs))
runs = {key: _last_run(conn, project_id, key) for key in STAGE_KEYS}
for stage in stages:
stage["last_run"] = runs.get(stage["stage_key"])
return {
"project": {
"id": project["id"],
"title": project["title"],
"idea": project.get("idea"),
"status": project["status"],
"updated_at": project["updated_at"],
},
"stages": stages,
}
finally:
conn.close()
def _stage_or_404(conn, project_id: int, stage_key: str) -> dict[str, Any]:
if stage_key not in STAGE_KEYS:
raise HTTPException(status_code=404, detail="阶段不存在")
_ensure_stage_rows(conn, project_id)
return _load_stage(conn, project_id, stage_key)
@router.patch("/projects/{project_id}/stages/{stage_key}")
async def patch_stage(project_id: int, stage_key: str, payload: StagePatch) -> dict[str, Any]:
conn = get_db(config.settings.db_path)
try:
_get_project(conn, project_id)
stage = _stage_or_404(conn, project_id, stage_key)
if stage["status"] == "running":
raise HTTPException(status_code=409, detail="本阶段正在生成中,请稍候再编辑。")
try:
content = NORMALIZERS[stage_key](payload.content)
except StageContentError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if content == (json.loads(stage["content_json"]) if stage["content_json"] else None):
return {"ok": True, "stage": _stage_public({**stage, "content_json": json.dumps(content, ensure_ascii=False)})}
# Editing a confirmed stage produces a new draft awaiting re-confirm;
# the confirmed snapshot stays intact for 恢复.
next_status = "awaiting_confirm" if stage["status"] == "confirmed" else stage["status"]
if stage["status"] == "todo":
next_status = "awaiting_confirm"
chosen = payload.chosen_style_id
if stage_key == "style" and chosen:
ids = [d.get("id") for d in content.get("directions", [])]
if chosen not in ids:
raise HTTPException(status_code=400, detail="所选风格方向不存在,请刷新后重试。")
conn.execute(
"UPDATE project_stages SET content_json=?, status=?, draft_edited=1, "
"chosen_style_id=COALESCE(?, chosen_style_id), updated_at=CURRENT_TIMESTAMP "
"WHERE project_id = ? AND stage_key = ?",
(
json.dumps(content, ensure_ascii=False),
next_status,
chosen,
project_id,
stage_key,
),
)
conn.execute(
"UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id = ?", (project_id,)
)
conn.commit()
row = _load_stage(conn, project_id, stage_key)
return {"ok": True, "stage": _stage_public(row)}
finally:
conn.close()
@router.post("/projects/{project_id}/stages/{stage_key}/run")
async def run_stage(project_id: int, stage_key: str, payload: StageRunRequest | None = None) -> dict[str, Any]:
instruction = (payload.instruction if payload else None) or None
conn = get_db(config.settings.db_path)
try:
_get_project(conn, project_id)
stage = _stage_or_404(conn, project_id, stage_key)
if stage["status"] == "running":
raise HTTPException(status_code=409, detail="本阶段正在生成中,请稍候。")
# Prerequisite: the previous stage must be confirmed (drafts never
# unlock the next agent).
idx = STAGE_KEYS.index(stage_key)
if idx > 0:
prev = _load_stage(conn, project_id, STAGE_KEYS[idx - 1])
if prev["status"] != "confirmed":
raise HTTPException(
status_code=409,
detail=f"请先完成并确认上一阶段「{_stage_name(STAGE_KEYS[idx - 1])}」。",
)
# LLM must be resolvable — fail fast with a fix-it message.
try:
profile = config.settings.resolve_llm_for_role(AGENTS[stage_key]["llm_role"])
except Exception: # pragma: no cover — defensive
profile = None
if not profile or not (profile.get("base_url") or "").strip():
raise HTTPException(
status_code=400,
detail="尚未配置可用的大模型:请到「模型设置」添加并测试通过后再生成。",
)
cur = conn.execute(
"INSERT INTO stage_runs (project_id, stage_key, instruction) VALUES (?, ?, ?)",
(project_id, stage_key, instruction),
)
run_id = int(cur.lastrowid)
conn.execute(
"UPDATE project_stages SET status='running', error=NULL, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id = ? AND stage_key = ?",
(project_id, stage_key),
)
conn.commit()
finally:
conn.close()
task = asyncio.create_task(_execute_run(project_id, stage_key, run_id, instruction))
_ACTIVE_RUNS[run_id] = task
task.add_done_callback(lambda t: _ACTIVE_RUNS.pop(run_id, None))
return {"ok": True, "run_id": run_id}
@router.post("/projects/{project_id}/stages/{stage_key}/confirm")
async def confirm_stage(
project_id: int, stage_key: str, payload: StageConfirmRequest | None = None
) -> dict[str, Any]:
conn = get_db(config.settings.db_path)
try:
_get_project(conn, project_id)
stage = _stage_or_404(conn, project_id, stage_key)
if stage["status"] == "running":
raise HTTPException(status_code=409, detail="本阶段正在生成中,请等待完成。")
if not stage["content_json"]:
raise HTTPException(status_code=400, detail="本阶段还没有内容,请先生成或手动填写。")
content = json.loads(stage["content_json"])
chosen = payload.chosen_style_id if payload else None
if stage_key == "style":
directions = content.get("directions") or []
ids = [d.get("id") for d in directions]
if chosen and chosen not in ids:
raise HTTPException(status_code=400, detail="所选风格方向不存在,请刷新后重试。")
if not chosen:
chosen = stage["chosen_style_id"] or (ids[0] if ids else None)
conn.execute(
"UPDATE project_stages SET status='confirmed', confirmed_content_json=?, "
"chosen_style_id=?, draft_edited=0, error=NULL, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id = ? AND stage_key = ?",
(
json.dumps(content, ensure_ascii=False),
chosen if stage_key == "style" else stage["chosen_style_id"],
project_id,
stage_key,
),
)
conn.execute(
"UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id = ?", (project_id,)
)
conn.commit()
row = _load_stage(conn, project_id, stage_key)
return {"ok": True, "stage": _stage_public(row)}
finally:
conn.close()
@router.post("/projects/{project_id}/stages/{stage_key}/restore")
async def restore_stage(project_id: int, stage_key: str) -> dict[str, Any]:
conn = get_db(config.settings.db_path)
try:
_get_project(conn, project_id)
stage = _stage_or_404(conn, project_id, stage_key)
if not stage["confirmed_content_json"]:
raise HTTPException(status_code=400, detail="本阶段还没有已确认版本,无需恢复。")
conn.execute(
"UPDATE project_stages SET content_json=confirmed_content_json, "
"status='confirmed', draft_edited=0, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id = ? AND stage_key = ?",
(project_id, stage_key),
)
conn.commit()
row = _load_stage(conn, project_id, stage_key)
return {"ok": True, "stage": _stage_public(row)}
finally:
conn.close()
@router.post("/projects/{project_id}/stages/script/import")
async def import_script(project_id: int, payload: ScriptImportRequest) -> dict[str, Any]:
"""导入现成剧本:直接确认为剧本阶段基线,从视觉设计继续。"""
text = payload.text.strip()
if len(text) < 30:
raise HTTPException(status_code=400, detail="剧本内容太短,请检查粘贴是否完整。")
content = {"text": text}
conn = get_db(config.settings.db_path)
try:
_get_project(conn, project_id)
_stage_or_404(conn, project_id, "script")
conn.execute(
"UPDATE project_stages SET content_json=?, confirmed_content_json=?, "
"status='confirmed', draft_edited=0, error=NULL, updated_at=CURRENT_TIMESTAMP "
"WHERE project_id = ? AND stage_key = 'script'",
(
json.dumps(content, ensure_ascii=False),
json.dumps(content, ensure_ascii=False),
project_id,
),
)
conn.execute(
"UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id = ?", (project_id,)
)
conn.commit()
row = _load_stage(conn, project_id, "script")
return {"ok": True, "stage": _stage_public(row)}
finally:
conn.close()
@router.post("/projects/{project_id}/generate")
async def workbench_generate(project_id: int, payload: WorkbenchGenerateRequest) -> dict[str, Any]:
"""In-flow generation (HEL-612 shared queue): one image or video job,
project-linked, tagged workbench so results come back to the project."""
conn = get_db(config.settings.db_path)
try:
_get_project(conn, project_id)
_stage_or_404(conn, project_id, payload.stage_key)
wf = conn.execute(
"SELECT * FROM workflows WHERE kind = ? AND is_enabled = 1 ORDER BY id ASC LIMIT 1",
(payload.kind,),
).fetchone()
if not wf:
label = "生图" if payload.kind == "image" else "生视频"
raise HTTPException(
status_code=400,
detail=f"没有可用的{label}工作流:请先到「ComfyUI 连接」确认连接可用,"
"再到工作流管理导入并启用一个"
f"{label}工作流。",
)
workflow = row_to_dict(wf)
finally:
conn.close()
inputs = parse_dynamic_inputs(json.loads(workflow["workflow_json"] or "{}"))
duration = payload.duration_sec if payload.kind == "video" else None
input_values = smart_fill_inputs(
inputs,
prompt=payload.prompt,
negative_prompt=payload.negative_prompt or None,
duration=duration,
)
job = queue_manager.enqueue(
project_id=project_id,
kind=payload.kind,
workflow_id=int(workflow["id"]),
payload={
"input_values": input_values,
"prompt": payload.prompt,
"source": WORKBENCH_SOURCE,
"stage_key": payload.stage_key,
"target_id": payload.target_id,
},
)
await event_bus.publish(
"job.created",
{
"job_id": job["id"],
"kind": payload.kind,
"source": WORKBENCH_SOURCE,
"project_id": project_id,
},
)
return {"ok": True, "job": _job_public(job)}
+4
View File
@@ -89,6 +89,10 @@ def test_list_projects_includes_stats(client):
"character_count": 0,
"asset_ready_count": 0,
"asset_total_count": 0,
# Five-stage workbench progress (HEL-614): fresh project = not started.
"stage_confirmed": 0,
"stage_status": {},
"active_jobs": 0,
}
+421
View File
@@ -0,0 +1,421 @@
"""五段创作工作台 (HEL-614) — stage lifecycle, context handoff (confirmed
drafts only), edit/confirm/restore, script import, in-flow generation via
the shared queue, and honest failure states. LLM is faked by monkeypatching
the workbench router's LLMClient; ComfyUI runs in dry_run placeholder mode."""
from __future__ import annotations
import json
import time
import httpx
import pytest
from calliope.config import settings
from calliope.routers import workbench as wb
IMAGE_WORKFLOW = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "默认提示词"},
"_meta": {"title": "(Input:prompt) 正向提示词"},
},
"9": {
"class_type": "SaveImage",
"inputs": {"images": "x"},
"_meta": {"title": "(Output:image) 图片输出"},
},
}
SCRIPT_TEXT = (
"《夜行便利店》\n第一场·内景·便利店·凌晨两点:店员小夏整理货架,风铃轻响,"
"一位穿米色风衣的女孩推门而入,径直走到关东煮前。\n小夏:还是老样子?\n女孩点头。"
)
STYLE_REPLY = json.dumps(
{
"directions": [
{
"id": "style1",
"name": "霓虹赛博",
"description": "青紫霓虹、湿润反光、浅景深",
"prompt": "cyberpunk convenience store, neon rim light, wet floor reflections",
"negative_prompt": "blurry, lowres",
},
{
"id": "style2",
"name": "暖调生活流",
"description": "暖黄顶光、柔焦、生活质感",
"prompt": "warm cozy interior, soft rim light, film grain",
"negative_prompt": "",
},
]
},
ensure_ascii=False,
)
STORYBOARD_REPLY = json.dumps(
{
"shots": [
{
"id": "shot1",
"scene": "1",
"shot_size": "全景",
"camera": "固定",
"description": "凌晨便利店全貌,货架整齐,灯光惨白",
"dialog": "",
"duration_sec": 3,
},
{
"id": "shot2",
"scene": "1",
"shot_size": "特写",
"camera": "推",
"description": "风铃轻响,女孩的手推开玻璃门",
"dialog": "小夏:还是老样子?",
"duration_sec": 2,
},
]
},
ensure_ascii=False,
)
ASSETS_REPLY = json.dumps(
{
"assets": [
{
"id": "asset1",
"kind": "character",
"name": "小夏",
"description": "夜班店员,白色制服",
"prompt": "convenience store clerk, white uniform, name tag, half body",
},
{
"id": "asset2",
"kind": "location",
"name": "便利店",
"description": "深夜便利店内部",
"prompt": "japanese convenience store interior at night, bright shelves",
},
]
},
ensure_ascii=False,
)
SHOT_REPLY = json.dumps(
{
"shots": [
{
"id": "vshot1",
"shot_no": "S1",
"description": "开场全景",
"prompt": "slow push in on convenience store shelves at night, cinematic",
"duration_sec": 4,
},
{
"id": "vshot2",
"shot_no": "S2",
"description": "风铃特写",
"prompt": "close up wind chime, door opens, warm light spill",
"duration_sec": 3,
},
]
},
ensure_ascii=False,
)
class FakeLLM:
"""Scripted replies; records every chat() call for context assertions."""
replies: list[str] = []
calls: list[list[dict]] = []
@classmethod
def reset(cls, replies: list[str]) -> None:
cls.replies = list(replies)
cls.calls = []
@classmethod
def for_role(cls, role: str, *, timeout: float = 120.0) -> "FakeLLM":
return cls()
async def chat(self, messages, temperature=0.7, **kwargs) -> str:
type(self).calls.append(messages)
if type(self).replies:
return type(self).replies.pop(0)
return json.dumps({"text": SCRIPT_TEXT}, ensure_ascii=False)
async def close(self) -> None:
return None
class RefusingLLM(FakeLLM):
@classmethod
def for_role(cls, role: str, *, timeout: float = 120.0) -> "RefusingLLM":
return cls()
async def chat(self, messages, temperature=0.7, **kwargs) -> str:
type(self).calls.append(messages)
raise httpx.ConnectError("connection refused")
def _make_project(client, **fields) -> int:
payload = {"title": fields.get("title", "夜行便利店"), "idea": "凌晨便利店的温暖相遇"}
r = client.post("/api/projects", json=payload)
assert r.status_code == 200, r.text
return r.json()["id"]
def _overview(client, pid: int) -> dict:
r = client.get(f"/api/workbench/projects/{pid}")
assert r.status_code == 200, r.text
return r.json()
def _stage(overview: dict, key: str) -> dict:
return next(s for s in overview["stages"] if s["stage_key"] == key)
def _wait_stage_status(client, pid: int, key: str, statuses: set[str], timeout: float = 10.0) -> dict:
deadline = time.time() + timeout
stage = None
while time.time() < deadline:
stage = _stage(_overview(client, pid), key)
if stage["status"] in statuses:
return stage
time.sleep(0.2)
pytest.fail(f"stage {key} stuck in {stage and stage['status']!r}, want {statuses}")
def _run(client, pid: int, key: str, instruction: str | None = None):
body = {"instruction": instruction} if instruction else {}
return client.post(f"/api/workbench/projects/{pid}/stages/{key}/run", json=body)
def _confirm(client, pid: int, key: str, **extra):
return client.post(f"/api/workbench/projects/{pid}/stages/{key}/confirm", json=extra or None)
# ─────────────────────────────────────────────────────────────────────────
def test_overview_ensures_five_todo_stages(client):
pid = _make_project(client)
overview = _overview(client, pid)
assert [s["stage_key"] for s in overview["stages"]] == [
"script",
"style",
"storyboard",
"assets",
"shot",
]
assert all(s["status"] == "todo" for s in overview["stages"])
assert overview["project"]["title"] == "夜行便利店"
def test_run_requires_previous_stage_confirmed(client, monkeypatch):
monkeypatch.setattr(wb, "LLMClient", FakeLLM)
FakeLLM.reset([])
pid = _make_project(client)
r = _run(client, pid, "style")
assert r.status_code == 409
assert "剧本创作" in r.json()["detail"]
def test_five_stage_flow_confirmed_context_handoff(client, monkeypatch):
"""从创意起步端到端:每段生成→确认;只有已确认内容进入下一段上下文。"""
monkeypatch.setattr(wb, "LLMClient", FakeLLM)
FakeLLM.reset(
[
json.dumps({"text": SCRIPT_TEXT}, ensure_ascii=False),
STYLE_REPLY,
STORYBOARD_REPLY,
ASSETS_REPLY,
SHOT_REPLY,
]
)
pid = _make_project(client)
# 1. 剧本创作
assert _run(client, pid, "script").status_code == 200
stage = _wait_stage_status(client, pid, "script", {"awaiting_confirm"})
assert stage["content"]["text"] == SCRIPT_TEXT
assert _confirm(client, pid, "script").status_code == 200
# 2. 视觉设计 — 上下文带剧本
assert _run(client, pid, "style").status_code == 200
stage = _wait_stage_status(client, pid, "style", {"awaiting_confirm"})
assert len(stage["content"]["directions"]) == 2
assert SCRIPT_TEXT[:10] in FakeLLM.calls[-1][1]["content"]
r = _confirm(client, pid, "style", chosen_style_id="style2")
assert r.status_code == 200
assert r.json()["stage"]["chosen_style_id"] == "style2"
# 3. 分镜设计 — 上下文带剧本 + 已选风格(暖调生活流,非默认第一套)
assert _run(client, pid, "storyboard").status_code == 200
_wait_stage_status(client, pid, "storyboard", {"awaiting_confirm"})
ctx = FakeLLM.calls[-1][1]["content"]
assert "暖调生活流" in ctx
assert SCRIPT_TEXT[:10] in ctx
assert _confirm(client, pid, "storyboard").status_code == 200
# 4. 素材资产 — 上下文带分镜
assert _run(client, pid, "assets").status_code == 200
_wait_stage_status(client, pid, "assets", {"awaiting_confirm"})
ctx = FakeLLM.calls[-1][1]["content"]
assert "风铃轻响" in ctx
assert _confirm(client, pid, "assets").status_code == 200
# 5. 镜头脚本 — 上下文带素材清单
assert _run(client, pid, "shot").status_code == 200
stage = _wait_stage_status(client, pid, "shot", {"awaiting_confirm"})
ctx = FakeLLM.calls[-1][1]["content"]
assert "小夏" in ctx
assert stage["content"]["shots"][0]["prompt"].startswith("slow push")
# 进度反映到项目列表(真实阶段数据)
r = client.get("/api/projects")
stats = next(p for p in r.json() if p["id"] == pid)["stats"]
assert stats["stage_confirmed"] == 4
assert stats["stage_status"]["shot"] == "awaiting_confirm"
def test_run_failure_keeps_confirmed_snapshot_and_reads_chinese(client, monkeypatch):
monkeypatch.setattr(wb, "LLMClient", RefusingLLM)
pid = _make_project(client)
# 先用正常 LLM 确认剧本
monkeypatch.setattr(wb, "LLMClient", FakeLLM)
FakeLLM.reset([json.dumps({"text": SCRIPT_TEXT}, ensure_ascii=False)])
_run(client, pid, "script")
_wait_stage_status(client, pid, "script", {"awaiting_confirm"})
_confirm(client, pid, "script")
# 视觉设计生成失败(连接拒绝)
monkeypatch.setattr(wb, "LLMClient", RefusingLLM)
assert _run(client, pid, "style").status_code == 200
stage = _wait_stage_status(client, pid, "style", {"todo"})
assert "连接大模型服务失败" in (stage["last_run"]["error"] or "")
# 剧本阶段不受影响
assert _stage(_overview(client, pid), "script")["status"] == "confirmed"
def test_edit_after_confirm_and_restore(client, monkeypatch):
monkeypatch.setattr(wb, "LLMClient", FakeLLM)
FakeLLM.reset([json.dumps({"text": SCRIPT_TEXT}, ensure_ascii=False)])
pid = _make_project(client)
_run(client, pid, "script")
_wait_stage_status(client, pid, "script", {"awaiting_confirm"})
_confirm(client, pid, "script")
# 编辑已确认阶段 → 变回待确认草稿,已确认快照保留
edited = {"text": SCRIPT_TEXT + "\n(结尾新增:女孩微笑离开。)"}
r = client.patch(f"/api/workbench/projects/{pid}/stages/script", json={"content": edited})
assert r.status_code == 200
stage = r.json()["stage"]
assert stage["status"] == "awaiting_confirm"
assert stage["draft_edited"] is True
assert stage["confirmed_content"]["text"] == SCRIPT_TEXT
# 恢复已确认版本
r = client.post(f"/api/workbench/projects/{pid}/stages/script/restore")
assert r.status_code == 200
stage = r.json()["stage"]
assert stage["status"] == "confirmed"
assert stage["content"]["text"] == SCRIPT_TEXT
def test_import_script_confirms_and_unlocks_style(client, monkeypatch):
monkeypatch.setattr(wb, "LLMClient", FakeLLM)
FakeLLM.reset([STYLE_REPLY])
pid = _make_project(client)
r = client.post(f"/api/workbench/projects/{pid}/stages/script/import", json={"text": SCRIPT_TEXT})
assert r.status_code == 200
assert r.json()["stage"]["status"] == "confirmed"
# 跳过剧本创作,直接从视觉设计继续
assert _run(client, pid, "style").status_code == 200
_wait_stage_status(client, pid, "style", {"awaiting_confirm"})
# 太短的导入被拒绝
r = client.post(
f"/api/workbench/projects/{pid}/stages/script/import", json={"text": "太短"}
)
assert r.status_code == 400
assert "太短" in r.json()["detail"]
def test_generate_without_workflow_is_chinese_400(client):
pid = _make_project(client)
r = client.post(
f"/api/workbench/projects/{pid}/generate",
json={"kind": "image", "stage_key": "assets", "target_id": "asset1", "prompt": "a cat"},
)
assert r.status_code == 400
assert "没有可用的生图工作流" in r.json()["detail"]
def test_generate_image_job_returns_to_project(client):
pid = _make_project(client)
# 手动放入素材阶段草稿(带 asset1)
r = client.patch(
f"/api/workbench/projects/{pid}/stages/assets",
json={"content": json.loads(ASSETS_REPLY)},
)
assert r.status_code == 200
wf = client.post(
"/api/workflows",
json={"name": "假·文生图", "kind": "image", "workflow_json": IMAGE_WORKFLOW},
)
assert wf.status_code == 200, wf.text
r = client.post(
f"/api/workbench/projects/{pid}/generate",
json={
"kind": "image",
"stage_key": "assets",
"target_id": "asset1",
"prompt": "convenience store clerk portrait",
},
)
assert r.status_code == 200, r.text
job = r.json()["job"]
assert job["payload"]["source"] == "workbench"
assert job["payload"]["target_id"] == "asset1"
assert job["payload"]["input_values"]["1"] == "convenience store clerk portrait"
# dry_run worker completes with a placeholder; overview enriches the asset
deadline = time.time() + 40
job_done = False
mine = None
while time.time() < deadline:
jobs = client.get(f"/api/jobs?project_id={pid}").json()
mine = next((j for j in jobs if j["id"] == job["id"]), None)
if mine and mine["status"] in ("done", "failed"):
job_done = mine["status"] == "done"
break
time.sleep(0.4)
assert job_done, f"workbench job did not finish: {mine}"
stage = _stage(_overview(client, pid), "assets")
asset = next(a for a in stage["content"]["assets"] if a["id"] == "asset1")
assert asset["job"]["status"] == "done"
assert asset["job"]["output"] and asset["job"]["output"].endswith(".png")
def test_project_list_stats_include_active_jobs(client):
pid = _make_project(client)
wf = client.post(
"/api/workflows",
json={"name": "假·文生图2", "kind": "image", "workflow_json": IMAGE_WORKFLOW},
)
wf_id = wf.json()["id"]
r = client.post(
f"/api/workbench/projects/{pid}/generate",
json={"kind": "image", "stage_key": "assets", "prompt": "x"},
)
assert r.status_code == 200
r = client.get("/api/projects")
stats = next(p for p in r.json() if p["id"] == pid)["stats"]
assert stats["active_jobs"] >= 1
assert stats["stage_confirmed"] == 0
# a project that never opened the workbench reports no stage rows at all
pid2 = _make_project(client, title="未开工")
stats2 = next(p for p in client.get("/api/projects").json() if p["id"] == pid2)["stats"]
assert stats2["stage_status"] == {} and stats2["stage_confirmed"] == 0
+1031 -725
View File
File diff suppressed because it is too large Load Diff
@@ -28,9 +28,21 @@
character_count: 0,
asset_ready_count: 0,
asset_total_count: 0,
stage_confirmed: 0,
stage_status: {} as Record<string, string>,
active_jobs: 0,
},
);
// Five-stage creation progress (HEL-614) — real stage rows only.
const STAGE_SEGMENTS = ['script', 'style', 'storyboard', 'assets', 'shot'] as const;
function segClass(key: string): string {
const state = stats.stage_status?.[key];
if (state === 'confirmed') return 'seg-confirmed';
if (state === 'running' || state === 'awaiting_confirm') return 'seg-draft';
return '';
}
// Spec §2.1 allows the five avatar gradients as cover placeholders.
const PLACEHOLDER_GRADIENTS = [
'linear-gradient(135deg, #e8a33d, #b96a1b)',
@@ -143,7 +155,7 @@
type="button"
class="card-main"
aria-label={t('projectCard.open', { title: project.title })}
onclick={() => goto(`/project/${project.id}`)}
onclick={() => goto(`/workbench/${project.id}`)}
>
<span class="cover">
{#if coverUrl && !coverFailed}
@@ -167,6 +179,15 @@
<span class="arrow" aria-hidden="true">→</span>
</span>
<span class="idea">{project.idea || t('home.noIdea')}</span>
<span class="stage-bar" aria-label={t('home.stageProgress')}>
{#each STAGE_SEGMENTS as key (key)}
<span class="seg {segClass(key)}" title={t(`workbench.stage.${key}`)}></span>
{/each}
<span class="seg-count mono">{stats.stage_confirmed}/5</span>
{#if stats.active_jobs > 0}
<span class="jobs-pill">{t('home.activeJobs', { n: stats.active_jobs })}</span>
{/if}
</span>
<span class="meta">
<span class="meta-stats mono">
{t('home.statScenes', { n: stats.scene_count })}
@@ -369,6 +390,42 @@
overflow: hidden;
min-height: 2.2em;
}
/* Five-stage progress segments (real stage rows, HEL-614). */
.stage-bar {
display: flex;
align-items: center;
gap: 4px;
}
.seg {
width: 20px;
height: 5px;
border-radius: 2px;
background: var(--surface-3);
}
.seg-draft {
background: var(--accent);
}
.seg-confirmed {
background: var(--success);
}
.seg-count {
margin-left: 5px;
font-size: 11px;
color: var(--text-3);
font-variant-numeric: tabular-nums;
}
.jobs-pill {
margin-left: auto;
padding: 1px 8px;
border-radius: 999px;
background: var(--accent-dim);
color: var(--accent);
font-size: 10px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.meta {
display: flex;
align-items: center;
@@ -38,7 +38,7 @@
// 生成任务 / 智能体 / ComfyUI 连接 / 模型设置 / 设置.
const NAV: NavItem[] = [
{ id: 'projects', labelKey: 'shell.navProjects', icon: 'home', href: '/' },
{ id: 'workbench', labelKey: 'shell.navWorkbench', icon: 'story', href: '/canvas' },
{ id: 'workbench', labelKey: 'shell.navWorkbench', icon: 'story', href: '/workbench' },
{ id: 'image', labelKey: 'shell.navImage', icon: 'image', href: '/image' },
{ id: 'video', labelKey: 'shell.navVideo', icon: 'film', href: '/video' },
{ id: 'tasks', labelKey: 'shell.navTasks', icon: 'queue', href: '/tasks' },
@@ -0,0 +1,451 @@
<script lang="ts">
import type { WorkbenchAsset, WorkbenchStage } from '$lib/api';
import { assetUrl } from '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
import JobChip from './JobChip.svelte';
import PromptBlock from './PromptBlock.svelte';
import { t } from '$lib/i18n.svelte';
interface Props {
stage: WorkbenchStage;
onSave?: (content: Record<string, unknown>) => void;
onRun?: (instruction?: string) => void;
onGenerate?: (payload: {
kind: 'image' | 'video';
targetId: string;
prompt: string;
negativePrompt?: string;
}) => void;
onRetryJob?: (jobId: number) => void;
busy?: boolean;
}
let { stage, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
let editing = $state(false);
let draft = $state<WorkbenchAsset[]>([]);
const assets = $derived(
Array.isArray((stage.content as { assets?: unknown } | null)?.assets)
? ((stage.content as unknown as { assets: WorkbenchAsset[] }).assets ?? [])
: [],
);
const KIND_LABEL: Record<string, string> = {
character: 'workbench.asset.kindCharacter',
location: 'workbench.asset.kindLocation',
prop: 'workbench.asset.kindProp',
};
function startEdit() {
draft = assets.map((a) => ({ ...a }));
editing = true;
}
function save() {
const clean = draft.filter((a) => a.name.trim() && a.prompt.trim());
if (!clean.length) return;
onSave?.({ assets: clean });
editing = false;
}
function addAsset() {
draft = [
...draft,
{
id: `asset-new-${draft.length + 1}-${Date.now() % 1000}`,
kind: 'prop',
name: '',
description: '',
prompt: '',
negative_prompt: '',
},
];
}
function removeAsset(id: string) {
draft = draft.filter((a) => a.id !== id);
}
function jobActive(job: WorkbenchAsset['job']): boolean {
return !!job && (job.status === 'pending' || job.status === 'running');
}
</script>
{#if editing}
<div class="editor">
<div class="edit-list">
{#each draft as asset (asset.id)}
<div class="asset-edit">
<div class="asset-edit-head">
<select class="wb-input w-kind" bind:value={asset.kind}>
<option value="character">{t('workbench.asset.kindCharacter')}</option>
<option value="location">{t('workbench.asset.kindLocation')}</option>
<option value="prop">{t('workbench.asset.kindProp')}</option>
</select>
<input class="wb-input name" bind:value={asset.name} placeholder={t('workbench.asset.namePh')} />
<button
type="button"
class="icon-btn danger"
title={t('workbench.asset.remove')}
onclick={() => removeAsset(asset.id)}
>
<Icon name="trash" size={13} />
</button>
</div>
<input class="wb-input" bind:value={asset.description} placeholder={t('workbench.asset.descPh')} />
<textarea
class="wb-input mono"
rows="2"
bind:value={asset.prompt}
placeholder={t('workbench.asset.promptPh')}
></textarea>
<input
class="wb-input mono"
bind:value={asset.negative_prompt}
placeholder={t('workbench.style.negativePh')}
/>
</div>
{/each}
</div>
<div class="editor-foot">
<button type="button" class="btn ghost" onclick={addAsset}>
<Icon name="plus" size={13} />
{t('workbench.asset.add')}
</button>
<div class="foot-btns">
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
{t('workbench.cancelEdit')}
</button>
<button
type="button"
class="btn primary"
disabled={!draft.some((a) => a.name.trim() && a.prompt.trim())}
onclick={save}
>
{t('workbench.saveDraft')}
</button>
</div>
</div>
</div>
{:else if assets.length === 0}
<div class="todo-card">
<p class="todo-title">{t('workbench.asset.todoTitle')}</p>
<p class="todo-body">{t('workbench.asset.todoBody')}</p>
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.asset.generate')}
</button>
</div>
{:else}
<div class="asset-grid" data-testid="asset-grid">
{#each assets as asset (asset.id)}
<div class="asset-card">
<div class="asset-head">
<span class="kind-tag kind-{asset.kind}">{t(KIND_LABEL[asset.kind] ?? 'workbench.asset.kindProp')}</span>
<span class="asset-name">{asset.name}</span>
{#if asset.job}
<JobChip job={asset.job} />
{#if asset.job.status === 'failed'}
<button
type="button"
class="retry-link"
onclick={() => onRetryJob?.(asset.job!.id)}
>
{t('workbench.retryJob')}
</button>
{/if}
{/if}
</div>
{#if asset.description}<p class="asset-desc">{asset.description}</p>{/if}
{#if asset.job?.status === 'done' && asset.job.output}
<a class="asset-shot" href={assetUrl(asset.job.output) ?? '#'} target="_blank" rel="noopener">
<img src={assetUrl(asset.job.output) ?? ''} alt={asset.name} loading="lazy" />
</a>
{/if}
<PromptBlock label={t('workbench.asset.promptLabel')} text={asset.prompt} />
{#if asset.negative_prompt}
<PromptBlock label={t('workbench.style.negativeLabel')} text={asset.negative_prompt} />
{/if}
<div class="asset-foot">
<button
type="button"
class="btn ghost gen"
disabled={busy || jobActive(asset.job)}
title={t('workbench.asset.generateHint')}
onclick={() =>
onGenerate?.({
kind: 'image',
targetId: asset.id,
prompt: asset.prompt,
negativePrompt: asset.negative_prompt || undefined,
})}
>
<Icon name="image" size={13} />
{jobActive(asset.job) ? t('workbench.generating') : t('workbench.asset.generateImage')}
</button>
</div>
</div>
{/each}
</div>
<div class="view-foot">
<span class="hint">{t('workbench.asset.summary', { n: assets.length })}</span>
<div class="foot-btns">
{#if stage.status !== 'running'}
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
<Icon name="edit" size={13} />
{t('workbench.editDraft')}
</button>
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
<Icon name="retry" size={13} />
{t('workbench.regenerate')}
</button>
{/if}
</div>
</div>
{/if}
<style>
.todo-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 22px;
border: 1px dashed var(--border-strong);
border-radius: 10px;
background: var(--surface);
}
.todo-title {
margin: 0;
font-size: 14.5px;
font-weight: 600;
color: var(--text);
}
.todo-body {
margin: 0;
font-size: 12.5px;
line-height: 1.7;
color: var(--text-2);
}
.asset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
}
.asset-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
min-width: 0;
}
.asset-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.kind-tag {
padding: 1px 9px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
flex-shrink: 0;
}
.kind-character {
color: var(--accent);
background: var(--accent-dim);
}
.kind-location {
color: var(--success);
background: var(--success-dim);
}
.kind-prop {
color: var(--info);
background: var(--info-dim);
}
.asset-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.asset-desc {
margin: 0;
font-size: 12px;
line-height: 1.7;
color: var(--text-2);
}
.asset-shot {
display: block;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
}
.asset-shot img {
display: block;
width: 100%;
height: 170px;
object-fit: cover;
}
.asset-foot {
display: flex;
justify-content: flex-end;
}
.retry-link {
border: none;
background: none;
color: var(--error);
font-size: 11px;
text-decoration: underline;
cursor: pointer;
font-family: inherit;
padding: 0;
}
.editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.edit-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.asset-edit {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
}
.asset-edit-head {
display: flex;
align-items: center;
gap: 8px;
}
.editor-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.view-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.hint {
font-size: 11.5px;
color: var(--text-3);
}
.foot-btns {
display: flex;
gap: 8px;
}
.wb-input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 12.5px;
line-height: 1.6;
transition: border-color 150ms;
}
.wb-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
}
.wb-input::placeholder {
color: var(--text-3);
}
.wb-input.mono {
font-family: var(--font-a-mono);
font-size: 11.5px;
}
.wb-input.name {
font-weight: 600;
}
.wb-input.w-kind {
width: 92px;
flex-shrink: 0;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 7px;
border: 1px solid transparent;
background: transparent;
color: var(--text-3);
cursor: pointer;
flex-shrink: 0;
}
.icon-btn:hover {
background: var(--surface-3);
color: var(--text);
}
.icon-btn.danger:hover {
background: var(--error-dim);
color: var(--error);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms,
opacity 150ms;
}
.btn.primary {
background: var(--accent);
color: var(--accent-contrast);
font-weight: 600;
}
.btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn.ghost {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text-2);
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.24);
}
.btn.gen {
color: var(--accent);
border-color: var(--border-strong);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,103 @@
<script lang="ts">
import type { WorkbenchJobBrief } from '$lib/api';
import { progressFor } from '$lib/jobProgress';
import { t } from '$lib/i18n.svelte';
/**
* Inline workbench job state chip (spec §5 任务条目四态):
* 排队灰 / 进行中琥珀百分比 / 完成绿 / 失败红(附原因).
*/
interface Props {
job: WorkbenchJobBrief;
}
let { job }: Props = $props();
const live = $derived(progressFor(job.id));
const pct = $derived(live?.progress ?? null);
const state = $derived(
job.status === 'done'
? 'done'
: job.status === 'failed'
? 'failed'
: job.status === 'running'
? 'running'
: 'pending',
);
</script>
<span class="job-chip st-{state}" data-testid="wb-job-chip">
{#if state === 'running'}
<span class="breath" aria-hidden="true"></span>
{#if pct != null}{t('workbench.jobRunningPct', { n: Math.round(pct) })}{:else}{t('workbench.jobRunning')}{/if}
{:else if state === 'pending'}
{t('workbench.jobQueued')}
{:else if state === 'done'}
{t('workbench.jobDone')}
{:else}
{t('workbench.jobFailed')}
{/if}
</span>
{#if state === 'failed' && job.error}
<span class="job-error">{job.error}</span>
{/if}
<style>
.job-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 1px 9px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.st-pending {
color: var(--text-2);
background: var(--surface-3);
}
.st-running {
color: var(--accent);
background: var(--accent-dim);
}
.st-done {
color: var(--success);
background: var(--success-dim);
}
.st-failed {
color: var(--error);
background: var(--error-dim);
}
.job-error {
display: block;
font-size: 11px;
color: var(--error);
line-height: 1.5;
margin-top: 2px;
}
.breath {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
animation: wb-chip-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
@keyframes wb-chip-breath {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.breath {
animation: none;
}
}
</style>
@@ -0,0 +1,102 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
/**
* Mono prompt card (spec §4.3 提示词卡): dark bg, strong border, header
* label + copy action, horizontally scrollable mono body.
*/
interface Props {
label: string;
text: string;
copyKey?: string;
}
let { label, text, copyKey = 'workbench.copied' }: Props = $props();
async function copy() {
try {
await navigator.clipboard.writeText(text);
toast.success(t(copyKey));
} catch {
toast.error(t('workbench.copyFailed'));
}
}
</script>
<div class="prompt-card">
<div class="prompt-head">
<span class="prompt-label">{label}</span>
<button type="button" class="copy-btn" onclick={copy}>
<Icon name="copy" size={12} />
{t('workbench.copy')}
</button>
</div>
<pre class="prompt-body">{text}</pre>
</div>
<style>
.prompt-card {
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: 8px;
overflow: hidden;
min-width: 0;
}
.prompt-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
background: var(--surface-2);
}
.prompt-label {
font-size: 11px;
font-weight: 600;
color: var(--text-2);
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid var(--border-strong);
background: var(--surface-3);
color: var(--accent);
font-size: 10.5px;
font-family: inherit;
cursor: pointer;
flex-shrink: 0;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms;
}
.copy-btn:hover {
border-color: var(--accent);
}
.prompt-body {
margin: 0;
padding: 9px 10px;
font-family: var(--font-a-mono);
font-size: 11.5px;
line-height: 1.7;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
font-variant-numeric: tabular-nums;
max-height: 220px;
overflow: auto;
}
</style>
@@ -0,0 +1,307 @@
<script lang="ts">
import type { WorkbenchStage } from '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
import PromptBlock from './PromptBlock.svelte';
import { t } from '$lib/i18n.svelte';
interface Props {
stage: WorkbenchStage;
projectIdea?: string | null;
onSave?: (content: Record<string, unknown>) => void;
onImport?: (text: string) => void;
onRun?: (instruction?: string) => void;
busy?: boolean;
}
let { stage, projectIdea = null, onSave, onImport, onRun, busy = false }: Props = $props();
let editing = $state(false);
let draft = $state('');
let importText = $state('');
let importOpen = $state(false);
const text = $derived(
typeof (stage.content as { text?: unknown } | null)?.text === 'string'
? (stage.content as unknown as { text: string }).text
: '',
);
const charCount = $derived(text.replace(/\s/g, '').length);
function startEdit() {
draft = text;
editing = true;
}
function save() {
const value = draft.trim();
if (value.length < 30) return;
onSave?.({ text: value });
editing = false;
}
function doImport() {
const value = importText.trim();
if (value.length < 30) return;
onImport?.(value);
importOpen = false;
importText = '';
}
</script>
{#if stage.status === 'todo' && !text}
<div class="todo-card">
<p class="todo-title">{t('workbench.script.todoTitle')}</p>
{#if projectIdea}
<p class="todo-idea">{t('workbench.script.todoIdea')}</p>
<blockquote class="idea-quote">{projectIdea}</blockquote>
{/if}
<div class="todo-actions">
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.script.generate')}
</button>
<button type="button" class="btn ghost" onclick={() => (importOpen = !importOpen)}>
<Icon name="upload" size={14} />
{t('workbench.script.importBtn')}
</button>
<button type="button" class="btn ghost" onclick={startEdit}>
<Icon name="edit" size={14} />
{t('workbench.script.writeManually')}
</button>
</div>
{#if importOpen}
<div class="import-box">
<textarea
class="wb-input"
rows="8"
bind:value={importText}
placeholder={t('workbench.script.importPlaceholder')}
></textarea>
<div class="import-foot">
<span class="hint">{t('workbench.script.importHint')}</span>
<button
type="button"
class="btn primary sm"
disabled={importText.trim().length < 30}
onclick={doImport}
>
{t('workbench.script.importConfirm')}
</button>
</div>
</div>
{/if}
</div>
{:else if editing}
<div class="editor">
<textarea
class="wb-input mono-area"
rows="18"
bind:value={draft}
data-testid="script-editor"
></textarea>
<div class="editor-foot">
<span class="hint">{t('workbench.script.editorHint')}</span>
<div class="foot-btns">
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
{t('workbench.cancelEdit')}
</button>
<button
type="button"
class="btn primary"
disabled={draft.trim().length < 30}
onclick={save}
>
{t('workbench.saveDraft')}
</button>
</div>
</div>
</div>
{:else}
<article class="script-text" data-testid="script-view">{text}</article>
<div class="view-foot">
<span class="char-count mono">{t('workbench.charCount', { n: charCount })}</span>
<div class="foot-btns">
{#if stage.status !== 'running'}
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
<Icon name="edit" size={13} />
{t('workbench.editDraft')}
</button>
<button
type="button"
class="btn ghost"
disabled={busy}
onclick={() => onRun?.()}
title={t('workbench.regenerateHint')}
>
<Icon name="retry" size={13} />
{t('workbench.regenerate')}
</button>
{/if}
</div>
</div>
<PromptBlock label={t('workbench.script.copyLabel')} {text} />
{/if}
<style>
.todo-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 22px;
border: 1px dashed var(--border-strong);
border-radius: 10px;
background: var(--surface);
}
.todo-title {
margin: 0;
font-size: 14.5px;
font-weight: 600;
color: var(--text);
}
.todo-idea {
margin: 0;
font-size: 12px;
color: var(--text-2);
}
.idea-quote {
margin: 0;
padding: 10px 12px;
border-left: 2px solid var(--accent);
background: var(--surface-2);
border-radius: 0 8px 8px 0;
font-size: 12.5px;
line-height: 1.7;
color: var(--text-2);
white-space: pre-wrap;
max-height: 180px;
overflow: auto;
}
.todo-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.import-box {
display: flex;
flex-direction: column;
gap: 8px;
}
.import-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.script-text {
padding: 18px 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
font-size: 13.5px;
line-height: 1.8;
color: var(--text);
white-space: pre-wrap;
max-height: 460px;
overflow: auto;
}
.editor {
display: flex;
flex-direction: column;
gap: 10px;
}
.mono-area {
min-height: 320px;
resize: vertical;
line-height: 1.8;
}
.editor-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.view-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.char-count {
font-size: 11px;
color: var(--text-3);
}
.foot-btns {
display: flex;
gap: 8px;
}
.hint {
font-size: 11.5px;
color: var(--text-3);
}
.wb-input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 13px;
line-height: 1.7;
transition: border-color 150ms;
}
.wb-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
}
.wb-input::placeholder {
color: var(--text-3);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms,
opacity 150ms;
}
.btn.primary {
background: var(--accent);
color: var(--accent-contrast);
font-weight: 600;
}
.btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn.ghost {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text-2);
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.24);
}
.btn.sm {
padding: 6px 12px;
font-size: 12px;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,425 @@
<script lang="ts">
import type { VideoShot, WorkbenchStage } from '$lib/api';
import { assetUrl } from '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
import JobChip from './JobChip.svelte';
import PromptBlock from './PromptBlock.svelte';
import { t } from '$lib/i18n.svelte';
interface Props {
stage: WorkbenchStage;
onSave?: (content: Record<string, unknown>) => void;
onRun?: (instruction?: string) => void;
onGenerate?: (payload: {
kind: 'image' | 'video';
targetId: string;
prompt: string;
durationSec?: number;
}) => void;
onRetryJob?: (jobId: number) => void;
busy?: boolean;
}
let { stage, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
let editing = $state(false);
let draft = $state<VideoShot[]>([]);
const shots = $derived(
Array.isArray((stage.content as { shots?: unknown } | null)?.shots)
? ((stage.content as unknown as { shots: VideoShot[] }).shots ?? [])
: [],
);
function startEdit() {
draft = shots.map((s) => ({ ...s }));
editing = true;
}
function save() {
const clean = draft.filter((s) => s.prompt.trim());
if (!clean.length) return;
onSave?.({ shots: clean });
editing = false;
}
function addShot() {
draft = [
...draft,
{
id: `vshot-new-${draft.length + 1}-${Date.now() % 1000}`,
shot_no: `S${draft.length + 1}`,
description: '',
prompt: '',
duration_sec: 5,
},
];
}
function removeShot(id: string) {
draft = draft.filter((s) => s.id !== id);
}
function jobActive(job: VideoShot['job']): boolean {
return !!job && (job.status === 'pending' || job.status === 'running');
}
</script>
{#if editing}
<div class="editor">
<div class="edit-list">
{#each draft as shot (shot.id)}
<div class="vshot-edit">
<div class="vshot-edit-head">
<input class="wb-input w-no mono" bind:value={shot.shot_no} placeholder={t('workbench.vshot.noPh')} />
<input
class="wb-input w-dur mono"
type="number"
min="1"
step="1"
bind:value={shot.duration_sec}
placeholder={t('workbench.board.duration')}
/>
<button
type="button"
class="icon-btn danger"
title={t('workbench.vshot.remove')}
onclick={() => removeShot(shot.id)}
>
<Icon name="trash" size={13} />
</button>
</div>
<input class="wb-input" bind:value={shot.description} placeholder={t('workbench.vshot.descPh')} />
<textarea
class="wb-input mono"
rows="3"
bind:value={shot.prompt}
placeholder={t('workbench.vshot.promptPh')}
></textarea>
</div>
{/each}
</div>
<div class="editor-foot">
<button type="button" class="btn ghost" onclick={addShot}>
<Icon name="plus" size={13} />
{t('workbench.vshot.add')}
</button>
<div class="foot-btns">
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
{t('workbench.cancelEdit')}
</button>
<button
type="button"
class="btn primary"
disabled={!draft.some((s) => s.prompt.trim())}
onclick={save}
>
{t('workbench.saveDraft')}
</button>
</div>
</div>
</div>
{:else if shots.length === 0}
<div class="todo-card">
<p class="todo-title">{t('workbench.vshot.todoTitle')}</p>
<p class="todo-body">{t('workbench.vshot.todoBody')}</p>
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.vshot.generate')}
</button>
</div>
{:else}
<div class="vshot-list" data-testid="vshot-list">
{#each shots as shot (shot.id)}
<div class="vshot-card">
<div class="vshot-head">
<span class="vshot-no mono">{shot.shot_no}</span>
<span class="vshot-dur mono">{shot.duration_sec}s</span>
{#if shot.job}
<JobChip job={shot.job} />
{#if shot.job.status === 'failed'}
<button
type="button"
class="retry-link"
onclick={() => onRetryJob?.(shot.job!.id)}
>
{t('workbench.retryJob')}
</button>
{/if}
{/if}
</div>
{#if shot.description}<p class="vshot-desc">{shot.description}</p>{/if}
{#if shot.job?.status === 'done' && shot.job.output}
<video
class="vshot-video"
src={assetUrl(shot.job.output) ?? undefined}
controls
preload="metadata"
></video>
{/if}
<PromptBlock label={t('workbench.vshot.promptLabel')} text={shot.prompt} />
<div class="vshot-foot">
<button
type="button"
class="btn ghost gen"
disabled={busy || jobActive(shot.job)}
title={t('workbench.vshot.generateHint')}
onclick={() =>
onGenerate?.({
kind: 'video',
targetId: shot.id,
prompt: shot.prompt,
durationSec: shot.duration_sec,
})}
>
<Icon name="film" size={13} />
{jobActive(shot.job) ? t('workbench.generating') : t('workbench.vshot.generateVideo')}
</button>
</div>
</div>
{/each}
</div>
<div class="view-foot">
<span class="hint">{t('workbench.vshot.summary', { n: shots.length })}</span>
<div class="foot-btns">
{#if stage.status !== 'running'}
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
<Icon name="edit" size={13} />
{t('workbench.editDraft')}
</button>
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
<Icon name="retry" size={13} />
{t('workbench.regenerate')}
</button>
{/if}
</div>
</div>
{/if}
<style>
.todo-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 22px;
border: 1px dashed var(--border-strong);
border-radius: 10px;
background: var(--surface);
}
.todo-title {
margin: 0;
font-size: 14.5px;
font-weight: 600;
color: var(--text);
}
.todo-body {
margin: 0;
font-size: 12.5px;
line-height: 1.7;
color: var(--text-2);
}
.vshot-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.vshot-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
}
.vshot-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.vshot-no {
font-size: 13px;
font-weight: 700;
color: var(--accent);
font-variant-numeric: tabular-nums;
}
.vshot-dur {
font-size: 11px;
color: var(--text-3);
font-variant-numeric: tabular-nums;
}
.vshot-desc {
margin: 0;
font-size: 12.5px;
line-height: 1.7;
color: var(--text-2);
}
.vshot-video {
width: 100%;
max-height: 300px;
border-radius: 8px;
border: 1px solid var(--border);
background: #000;
}
.vshot-foot {
display: flex;
justify-content: flex-end;
}
.retry-link {
border: none;
background: none;
color: var(--error);
font-size: 11px;
text-decoration: underline;
cursor: pointer;
font-family: inherit;
padding: 0;
}
.editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.edit-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.vshot-edit {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
}
.vshot-edit-head {
display: flex;
align-items: center;
gap: 8px;
}
.editor-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.view-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.hint {
font-size: 11.5px;
color: var(--text-3);
}
.foot-btns {
display: flex;
gap: 8px;
}
.wb-input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 12.5px;
line-height: 1.6;
transition: border-color 150ms;
}
.wb-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
}
.wb-input::placeholder {
color: var(--text-3);
}
.wb-input.mono {
font-family: var(--font-a-mono);
font-size: 11.5px;
}
.wb-input.w-no {
width: 90px;
flex-shrink: 0;
}
.wb-input.w-dur {
width: 110px;
flex-shrink: 0;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 7px;
border: 1px solid transparent;
background: transparent;
color: var(--text-3);
cursor: pointer;
margin-left: auto;
flex-shrink: 0;
}
.icon-btn:hover {
background: var(--surface-3);
color: var(--text);
}
.icon-btn.danger:hover {
background: var(--error-dim);
color: var(--error);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms,
opacity 150ms;
}
.btn.primary {
background: var(--accent);
color: var(--accent-contrast);
font-weight: 600;
}
.btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn.ghost {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text-2);
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.24);
}
.btn.gen {
color: var(--accent);
border-color: var(--border-strong);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,231 @@
<script lang="ts">
import type { WorkbenchStage } from '$lib/api';
import { t } from '$lib/i18n.svelte';
/**
* Five-stage stepper (a2): one step per agent with the spec §2.1 semantic
* colors — gray=待开始, amber=进行中/待确认 (+breath while running),
* green=已确认, blue=用户改过草稿. A step unlocks when itself or any
* earlier stage has content; later steps stay clickable only after the
* previous one is confirmed.
*/
interface Props {
stages: WorkbenchStage[];
activeKey: string;
onSelect: (key: string) => void;
}
let { stages, activeKey, onSelect }: Props = $props();
const ORDER = ['script', 'style', 'storyboard', 'assets', 'shot'];
function label(key: string): string {
return t(`workbench.stage.${key}`);
}
function charOf(key: string): string {
const map: Record<string, string> = {
script: '剧',
style: '视',
storyboard: '分',
assets: '素',
shot: '镜',
};
return map[key] ?? key;
}
function avatarVar(key: string): string {
const idx = ORDER.indexOf(key) + 1;
return `var(--avatar-${idx})`;
}
function stateOf(stage: WorkbenchStage): 'todo' | 'running' | 'draft' | 'edited' | 'confirmed' {
if (stage.status === 'running') return 'running';
if (stage.status === 'confirmed' && !stage.draft_edited) return 'confirmed';
if (stage.status === 'confirmed' && stage.draft_edited) return 'edited';
if (stage.status === 'awaiting_confirm') return stage.draft_edited ? 'edited' : 'draft';
return 'todo';
}
function stateLabel(stage: WorkbenchStage): string {
const s = stateOf(stage);
if (s === 'running') return t('workbench.state.running');
if (s === 'confirmed') return t('workbench.state.confirmed');
if (s === 'edited') return t('workbench.state.edited');
if (s === 'draft') return t('workbench.state.draft');
return t('workbench.state.todo');
}
function unlocked(stages: WorkbenchStage[], key: string): boolean {
const idx = ORDER.indexOf(key);
if (idx <= 0) return true;
const prev = stages.find((s) => s.stage_key === ORDER[idx - 1]);
return prev?.status === 'confirmed';
}
</script>
<ol class="stepper" data-testid="workbench-stepper">
{#each ORDER as key, i}
{@const stage = stages.find((s) => s.stage_key === key)}
{@const state = stage ? stateOf(stage) : 'todo'}
{@const open = stage ? unlocked(stages, key) : i === 0}
<li class="step">
{#if i > 0}<span class="bar" class:bar-done={unlocked(stages, key)}></span>{/if}
<button
type="button"
class="step-btn"
class:active={activeKey === key}
class:locked={!open}
data-stage={key}
data-state={state}
onclick={() => onSelect(key)}
aria-current={activeKey === key ? 'step' : undefined}
title={open ? label(key) : t('workbench.lockedStep', { stage: label(ORDER[i - 1]) })}
>
<span class="avatar" style:background={avatarVar(key)}>{charOf(key)}</span>
<span class="step-copy">
<span class="step-name">{label(key)}</span>
<span class="step-state st-{state}">
{#if state === 'running'}<span class="breath" aria-hidden="true"></span>{/if}
{stateLabel(stage!)}
</span>
</span>
</button>
</li>
{/each}
</ol>
<style>
.stepper {
display: flex;
align-items: stretch;
gap: 0;
list-style: none;
margin: 0;
padding: 12px 0 4px;
overflow-x: auto;
scrollbar-width: thin;
}
.step {
display: flex;
align-items: center;
flex-shrink: 0;
}
.bar {
width: 26px;
height: 2px;
background: var(--surface-3);
border-radius: 1px;
}
.bar-done {
background: var(--success);
}
.step-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
cursor: pointer;
font-family: inherit;
text-align: left;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms;
}
.step-btn:hover:not(.locked) {
background: var(--surface-3);
}
.step-btn.active {
background: var(--surface-2);
border-color: var(--border-strong);
}
.step-btn.locked {
opacity: 0.55;
cursor: not-allowed;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
color: #edeef0;
flex-shrink: 0;
}
.step-copy {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 58px;
}
.step-name {
font-size: 12.5px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.step-state {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
white-space: nowrap;
}
.st-todo {
color: var(--text-3);
}
.st-running,
.st-draft {
color: var(--accent);
}
.st-edited {
color: var(--info);
}
.st-confirmed {
color: var(--success);
}
.step-btn.active[data-state='running'] .avatar,
.step-btn[data-state='running'] .avatar {
box-shadow: 0 0 10px rgba(232, 163, 61, 0.35);
}
.breath {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
animation: wb-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
@keyframes wb-breath {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.breath {
animation: none;
opacity: 0.8;
}
}
</style>
@@ -0,0 +1,395 @@
<script lang="ts">
import type { StoryboardShot, WorkbenchStage } from '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
import { t } from '$lib/i18n.svelte';
interface Props {
stage: WorkbenchStage;
onSave?: (content: Record<string, unknown>) => void;
onRun?: (instruction?: string) => void;
busy?: boolean;
}
let { stage, onSave, onRun, busy = false }: Props = $props();
let editing = $state(false);
let draft = $state<StoryboardShot[]>([]);
const shots = $derived(
Array.isArray((stage.content as { shots?: unknown } | null)?.shots)
? ((stage.content as unknown as { shots: StoryboardShot[] }).shots ?? [])
: [],
);
const totalDuration = $derived(shots.reduce((acc, s) => acc + (s.duration_sec || 0), 0));
function startEdit() {
draft = shots.map((s) => ({ ...s }));
editing = true;
}
function save() {
const clean = draft.filter((s) => s.description.trim());
if (!clean.length) return;
onSave?.({ shots: clean });
editing = false;
}
function addShot() {
draft = [
...draft,
{
id: `shot-new-${draft.length + 1}-${Date.now() % 1000}`,
scene: `${draft.length + 1}`,
shot_size: '中景',
camera: '固定',
description: '',
dialog: '',
duration_sec: 3,
},
];
}
function removeShot(id: string) {
draft = draft.filter((s) => s.id !== id);
}
</script>
{#if editing}
<div class="editor">
<div class="edit-list">
{#each draft as shot, i (shot.id)}
<div class="shot-edit">
<div class="shot-edit-head">
<span class="shot-index mono">{String(i + 1).padStart(2, '0')}</span>
<button
type="button"
class="icon-btn danger"
title={t('workbench.board.remove')}
onclick={() => removeShot(shot.id)}
>
<Icon name="trash" size={13} />
</button>
</div>
<div class="shot-fields">
<input class="wb-input w-xs" bind:value={shot.scene} placeholder={t('workbench.board.scene')} />
<input class="wb-input w-xs" bind:value={shot.shot_size} placeholder={t('workbench.board.size')} />
<input class="wb-input w-xs" bind:value={shot.camera} placeholder={t('workbench.board.camera')} />
<input
class="wb-input w-xs mono"
type="number"
min="0.5"
step="0.5"
bind:value={shot.duration_sec}
placeholder={t('workbench.board.duration')}
/>
</div>
<textarea
class="wb-input"
rows="2"
bind:value={shot.description}
placeholder={t('workbench.board.descPh')}
></textarea>
<input class="wb-input" bind:value={shot.dialog} placeholder={t('workbench.board.dialogPh')} />
</div>
{/each}
</div>
<div class="editor-foot">
<button type="button" class="btn ghost" onclick={addShot}>
<Icon name="plus" size={13} />
{t('workbench.board.add')}
</button>
<div class="foot-btns">
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
{t('workbench.cancelEdit')}
</button>
<button
type="button"
class="btn primary"
disabled={!draft.some((s) => s.description.trim())}
onclick={save}
>
{t('workbench.saveDraft')}
</button>
</div>
</div>
</div>
{:else if shots.length === 0}
<div class="todo-card">
<p class="todo-title">{t('workbench.board.todoTitle')}</p>
<p class="todo-body">{t('workbench.board.todoBody')}</p>
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.board.generate')}
</button>
</div>
{:else}
<div class="board-table" data-testid="storyboard-table">
<div class="bt-head">
<span class="col-no">#</span>
<span class="col-scene">{t('workbench.board.scene')}</span>
<span class="col-size">{t('workbench.board.size')}</span>
<span class="col-camera">{t('workbench.board.camera')}</span>
<span class="col-desc">{t('workbench.board.desc')}</span>
<span class="col-dialog">{t('workbench.board.dialog')}</span>
<span class="col-dur mono">{t('workbench.board.duration')}</span>
</div>
{#each shots as shot, i (shot.id)}
<div class="bt-row">
<span class="col-no mono">{String(i + 1).padStart(2, '0')}</span>
<span class="col-scene">{shot.scene}</span>
<span class="col-size"><span class="tag">{shot.shot_size}</span></span>
<span class="col-camera">{shot.camera}</span>
<span class="col-desc">{shot.description}</span>
<span class="col-dialog">{shot.dialog || '—'}</span>
<span class="col-dur mono">{shot.duration_sec}s</span>
</div>
{/each}
</div>
<div class="view-foot">
<span class="hint mono">{t('workbench.board.summary', { n: shots.length, sec: totalDuration })}</span>
<div class="foot-btns">
{#if stage.status !== 'running'}
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
<Icon name="edit" size={13} />
{t('workbench.editDraft')}
</button>
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
<Icon name="retry" size={13} />
{t('workbench.regenerate')}
</button>
{/if}
</div>
</div>
{/if}
<style>
.todo-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 22px;
border: 1px dashed var(--border-strong);
border-radius: 10px;
background: var(--surface);
}
.todo-title {
margin: 0;
font-size: 14.5px;
font-weight: 600;
color: var(--text);
}
.todo-body {
margin: 0;
font-size: 12.5px;
line-height: 1.7;
color: var(--text-2);
}
.board-table {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
overflow-x: auto;
}
.bt-head,
.bt-row {
display: grid;
grid-template-columns: 34px 64px 76px 76px minmax(220px, 1fr) minmax(140px, 0.8fr) 56px;
gap: 10px;
align-items: start;
padding: 10px 14px;
min-width: 680px;
}
.bt-head {
font-size: 11px;
font-weight: 600;
color: var(--text-3);
border-bottom: 1px solid var(--border);
background: var(--surface-2);
position: sticky;
top: 0;
}
.bt-row {
border-bottom: 1px solid var(--border);
font-size: 12.5px;
line-height: 1.6;
color: var(--text);
}
.bt-row:last-child {
border-bottom: none;
}
.col-no {
color: var(--text-3);
}
.col-scene,
.col-camera {
color: var(--text-2);
white-space: nowrap;
}
.col-desc {
color: var(--text);
}
.col-dialog {
color: var(--text-2);
}
.col-dur {
text-align: right;
color: var(--text-2);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.tag {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
background: var(--surface-3);
color: var(--text-2);
font-size: 10.5px;
white-space: nowrap;
}
.editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.edit-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.shot-edit {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
}
.shot-edit-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.shot-index {
color: var(--text-3);
font-size: 11px;
}
.shot-fields {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.wb-input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 12.5px;
line-height: 1.6;
transition: border-color 150ms;
}
.wb-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
}
.wb-input::placeholder {
color: var(--text-3);
}
.wb-input.mono {
font-family: var(--font-a-mono);
font-size: 11.5px;
}
.wb-input.w-xs {
width: 110px;
flex: 1;
min-width: 96px;
}
.editor-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.view-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.hint {
font-size: 11.5px;
color: var(--text-3);
font-variant-numeric: tabular-nums;
}
.foot-btns {
display: flex;
gap: 8px;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 7px;
border: 1px solid transparent;
background: transparent;
color: var(--text-3);
cursor: pointer;
}
.icon-btn:hover {
background: var(--surface-3);
color: var(--text);
}
.icon-btn.danger:hover {
background: var(--error-dim);
color: var(--error);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms,
opacity 150ms;
}
.btn.primary {
background: var(--accent);
color: var(--accent-contrast);
font-weight: 600;
}
.btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn.ghost {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text-2);
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.24);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,384 @@
<script lang="ts">
import type { StyleDirection, WorkbenchStage } from '$lib/api';
import Icon from '$lib/components/ui/Icon.svelte';
import PromptBlock from './PromptBlock.svelte';
import { t } from '$lib/i18n.svelte';
interface Props {
stage: WorkbenchStage;
chosenStyleId?: string | null;
onSave?: (content: Record<string, unknown>) => void;
onRun?: (instruction?: string) => void;
busy?: boolean;
}
let { stage, chosenStyleId = $bindable(null), onSave, onRun, busy = false }: Props = $props();
let editing = $state(false);
let draft = $state<StyleDirection[]>([]);
const directions = $derived(
Array.isArray((stage.content as { directions?: unknown } | null)?.directions)
? ((stage.content as unknown as { directions: StyleDirection[] }).directions ?? [])
: [],
);
// Default selection: kept choice → first direction.
const effectiveChosen = $derived(
chosenStyleId && directions.some((d) => d.id === chosenStyleId)
? chosenStyleId
: (directions[0]?.id ?? null),
);
function startEdit() {
draft = directions.map((d) => ({ ...d }));
editing = true;
}
function save() {
const clean = draft.filter((d) => d.name.trim() && d.prompt.trim());
if (clean.length < 2) return;
onSave?.({ directions: clean });
editing = false;
}
function removeDirection(id: string) {
draft = draft.filter((d) => d.id !== id);
}
function addDirection() {
const n = draft.length + 1;
draft = [
...draft,
{
id: `style-new-${n}-${Date.now() % 1000}`,
name: '',
description: '',
prompt: '',
negative_prompt: '',
},
];
}
</script>
{#if editing}
<div class="editor">
{#each draft as dir, i (dir.id)}
<div class="dir-edit">
<div class="dir-edit-head">
<span class="dir-index mono">{i + 1}</span>
<input class="wb-input name" bind:value={dir.name} placeholder={t('workbench.style.namePh')} />
<button
type="button"
class="icon-btn danger"
title={t('workbench.style.remove')}
onclick={() => removeDirection(dir.id)}
>
<Icon name="trash" size={13} />
</button>
</div>
<input
class="wb-input"
bind:value={dir.description}
placeholder={t('workbench.style.descPh')}
/>
<textarea
class="wb-input mono"
rows="2"
bind:value={dir.prompt}
placeholder={t('workbench.style.promptPh')}
></textarea>
<input
class="wb-input mono"
bind:value={dir.negative_prompt}
placeholder={t('workbench.style.negativePh')}
/>
</div>
{/each}
<div class="editor-foot">
<button type="button" class="btn ghost" onclick={addDirection}>
<Icon name="plus" size={13} />
{t('workbench.style.add')}
</button>
<div class="foot-btns">
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
{t('workbench.cancelEdit')}
</button>
<button
type="button"
class="btn primary"
disabled={draft.filter((d) => d.name.trim() && d.prompt.trim()).length < 2}
onclick={save}
>
{t('workbench.saveDraft')}
</button>
</div>
</div>
</div>
{:else if directions.length === 0}
<div class="todo-card">
<p class="todo-title">{t('workbench.style.todoTitle')}</p>
<p class="todo-body">{t('workbench.style.todoBody')}</p>
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.style.generate')}
</button>
</div>
{:else}
<div class="dir-grid" role="radiogroup" aria-label={t('workbench.stage.style')} data-testid="style-directions">
{#each directions as dir (dir.id)}
<label class="dir-card" class:chosen={dir.id === effectiveChosen}>
<input
type="radio"
name="wb-style"
value={dir.id}
checked={dir.id === effectiveChosen}
onchange={() => (chosenStyleId = dir.id)}
/>
<div class="dir-head">
<span class="dir-name">{dir.name}</span>
{#if dir.id === effectiveChosen}
<span class="chosen-tag">{t('workbench.style.chosen')}</span>
{/if}
</div>
{#if dir.description}<p class="dir-desc">{dir.description}</p>{/if}
<PromptBlock label={t('workbench.style.promptLabel')} text={dir.prompt} />
{#if dir.negative_prompt}
<PromptBlock label={t('workbench.style.negativeLabel')} text={dir.negative_prompt} />
{/if}
</label>
{/each}
</div>
<div class="view-foot">
<span class="hint">{t('workbench.style.chooseHint')}</span>
<div class="foot-btns">
{#if stage.status !== 'running'}
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
<Icon name="edit" size={13} />
{t('workbench.editDraft')}
</button>
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
<Icon name="retry" size={13} />
{t('workbench.regenerate')}
</button>
{/if}
</div>
</div>
{/if}
<style>
.todo-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 22px;
border: 1px dashed var(--border-strong);
border-radius: 10px;
background: var(--surface);
}
.todo-title {
margin: 0;
font-size: 14.5px;
font-weight: 600;
color: var(--text);
}
.todo-body {
margin: 0;
font-size: 12.5px;
line-height: 1.7;
color: var(--text-2);
}
.dir-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
}
.dir-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
transition:
border-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 150ms;
}
.dir-card:hover {
border-color: var(--border-strong);
}
.dir-card.chosen {
border-color: var(--accent);
box-shadow: 0 0 10px rgba(232, 163, 61, 0.35);
}
.dir-card input[type='radio'] {
position: absolute;
opacity: 0;
pointer-events: none;
}
.dir-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.dir-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.chosen-tag {
padding: 1px 9px;
border-radius: 999px;
background: var(--accent-dim);
color: var(--accent);
font-size: 10.5px;
font-weight: 600;
flex-shrink: 0;
}
.dir-desc {
margin: 0;
font-size: 12px;
line-height: 1.7;
color: var(--text-2);
}
.editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.dir-edit {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
}
.dir-edit-head {
display: flex;
align-items: center;
gap: 8px;
}
.dir-index {
color: var(--text-3);
font-size: 11px;
}
.editor-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.view-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 4px;
}
.hint {
font-size: 11.5px;
color: var(--text-3);
}
.foot-btns {
display: flex;
gap: 8px;
}
.wb-input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 12.5px;
line-height: 1.6;
transition: border-color 150ms;
}
.wb-input:focus {
outline: none;
border-color: rgba(232, 163, 61, 0.6);
}
.wb-input::placeholder {
color: var(--text-3);
}
.wb-input.mono {
font-family: var(--font-a-mono);
font-size: 11.5px;
}
.wb-input.name {
font-weight: 600;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 7px;
border: 1px solid transparent;
background: transparent;
color: var(--text-3);
cursor: pointer;
flex-shrink: 0;
}
.icon-btn:hover {
background: var(--surface-3);
color: var(--text);
}
.icon-btn.danger:hover {
background: var(--error-dim);
color: var(--error);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms,
opacity 150ms;
}
.btn.primary {
background: var(--accent);
color: var(--accent-contrast);
font-weight: 600;
}
.btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn.ghost {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text-2);
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.24);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
+136
View File
@@ -1570,4 +1570,140 @@ export const de: Dict = {
'agentsPage.sugShot2P': 'Zeige, wie man einen Erst-/Letztbild-Prompt schreibt: Beschreibung, Bewegung und Dauer.',
'workbench.title': 'Kreativ-Werkbank',
'workbench.subtitle': 'Drehbuch → Stil → Storyboard → Assets → Shotliste, fünf Agenten im Staffellauf',
'workbench.pickerSubtitle': 'Projekt wählen und den Fünf-Phasen-Fluss fortsetzen',
'workbench.pickerEmpty': 'Noch keine Projekte',
'workbench.pickerEmptyBody': 'Zur Übersicht zurückgehen und mit einer Idee oder einem importierten Drehbuch starten.',
'workbench.pickerGoHome': 'Zur Übersicht',
'workbench.pickerFootHint': 'Die Werkbank ist auch über die Übersicht (Idee / Drehbuch importieren) erreichbar.',
'workbench.stageProgress': 'Fünf-Phasen-Fortschritt',
'workbench.activeJobs': '{n} laufende Aufgaben',
'workbench.stage.script': 'Drehbuch',
'workbench.stage.style': 'Bildstil',
'workbench.stage.storyboard': 'Storyboard',
'workbench.stage.assets': 'Assets',
'workbench.stage.shot': 'Shotliste',
'workbench.lockedStep': 'Wird freigeschaltet, wenn „{stage}“ bestätigt ist',
'workbench.state.todo': 'Nicht begonnen',
'workbench.state.running': 'Generiert',
'workbench.state.draft': 'Zu bestätigen',
'workbench.state.confirmed': 'Bestätigt',
'workbench.state.edited': 'Bearbeitet',
'workbench.copied': 'Kopiert — direkt in die Bild-/Video-Generierung einfügen',
'workbench.copyFailed': 'Kopieren fehlgeschlagen, bitte manuell kopieren',
'workbench.copy': 'Kopieren',
'workbench.charCount': '{n} Zeichen',
'workbench.runStage': 'Mit {agent} generieren',
'workbench.runDone': '{agent} hat den Entwurf fertig — bitte prüfen',
'workbench.runFailed': 'Generierung fehlgeschlagen, erneut versuchen',
'workbench.runningHint': '{agent} schreibt diese Phase…',
'workbench.confirmNext': 'Bestätigen & weiter',
'workbench.confirmLast': 'Phase bestätigen',
'workbench.confirmed': 'Phase „{agent}“ bestätigt',
'workbench.confirmFailed': 'Bestätigen fehlgeschlagen, erneut versuchen',
'workbench.restore': 'Bestätigte Version wiederherstellen',
'workbench.restored': 'Zuletzt bestätigte Version wiederhergestellt',
'workbench.restoreFailed': 'Wiederherstellen fehlgeschlagen, erneut versuchen',
'workbench.draftNotice': 'Unbestätigter Entwurf: Der nächste Agent sieht ihn erst nach der Bestätigung.',
'workbench.draftSaved': 'Entwurf gespeichert — Bestätigung ausstehend',
'workbench.saveFailed': 'Speichern fehlgeschlagen, erneut versuchen',
'workbench.saveDraft': 'Entwurf speichern',
'workbench.cancelEdit': 'Abbrechen',
'workbench.editDraft': 'Bearbeiten',
'workbench.regenerate': 'Neu generieren',
'workbench.regenerateHint': 'Phase neu generieren (die bestätigte Version bleibt wiederherstellbar)',
'workbench.instructionPh': 'Zusätzliche Vorgaben (optional): filmischer, natürlichere Dialoge…',
'workbench.jobSubmitted': 'Generierungsauftrag eingereicht — Ergebnisse kehren zum Projekt zurück',
'workbench.jobSubmitFailed': 'Auftrag konnte nicht eingereicht werden',
'workbench.jobRequeued': 'Auftrag erneut eingereiht',
'workbench.retryJob': 'Erneut versuchen',
'workbench.cancelJob': 'Auftrag abbrechen',
'workbench.openOutput': 'Ergebnis ansehen',
'workbench.jobRunningPct': '{n} %',
'workbench.jobRunning': 'Läuft',
'workbench.jobQueued': 'Warteschlange',
'workbench.jobDone': 'Fertig',
'workbench.jobFailed': 'Fehlgeschlagen',
'workbench.generating': 'Generiert…',
'workbench.progressChip': 'Phase {n}/5',
'workbench.backHome': 'Übersicht',
'workbench.loadFailed': 'Werkbank konnte nicht geladen werden.',
'workbench.retryLoad': 'Erneut versuchen',
'workbench.agentSkills': '{n} Skills',
'workbench.chatWithAgent': 'Direkt chatten',
'workbench.tasksTitle': 'Phasen-Aufträge',
'workbench.tasksAll': 'Alle Aufträge',
'workbench.tasksEmpty': 'Noch keine Aufträge. Sende Prompts aus der Asset- oder Shotliste-Phase.',
'workbench.flowTitle': 'Fünf-Phasen-Fortschritt',
'workbench.script.todoTitle': 'Mit dem Drehbuch beginnen: Idee an den Drehbuch-Agenten übergeben',
'workbench.script.todoIdea': 'Deine Idee:',
'workbench.script.generate': 'Drehbuch-Entwurf erstellen',
'workbench.script.importBtn': 'Bestehendes Drehbuch importieren',
'workbench.script.writeManually': 'Selbst schreiben',
'workbench.script.importPlaceholder': 'Bestehendes Drehbuch hier einfügen… Beim Import wird die Entwurfsphase übersprungen und mit dem Bildstil fortgefahren.',
'workbench.script.importHint': 'Das importierte Drehbuch wird zur bestätigten Baseline dieser Phase.',
'workbench.script.importConfirm': 'Importieren & überspringen',
'workbench.script.importDone': 'Drehbuch importiert — mit Bildstil fortfahren',
'workbench.script.importFailed': 'Import fehlgeschlagen, Text prüfen und erneut versuchen',
'workbench.script.editorHint': 'Text direkt bearbeiten; nach dem Speichern ist die Bestätigung nötig.',
'workbench.script.copyLabel': 'Vollständiges Drehbuch',
'workbench.style.todoTitle': 'Bildstil für das Drehbuch festlegen',
'workbench.style.todoBody': 'Der Stil-Agent schlägt mehrere Richtungen vor — jeweils mit kopierfertigen Bild-Prompts.',
'workbench.style.generate': 'Stilrichtungen vorschlagen',
'workbench.style.namePh': 'Name, z. B. Neon-Cyber',
'workbench.style.descPh': 'Beschreibung (Palette / Licht / Material)',
'workbench.style.promptPh': 'Englische Bild-Schlüsselwörter, kommagetrennt',
'workbench.style.negativePh': 'Negativ-Prompt (optional, Englisch)',
'workbench.style.negativeLabel': 'Negativ-Prompt',
'workbench.style.remove': 'Richtung entfernen',
'workbench.style.add': 'Richtung hinzufügen',
'workbench.style.chosen': 'Ausgewählt',
'workbench.style.chooseHint': 'Beim Bestätigen wird der gewählte Stil an die Folgephasen übergeben.',
'workbench.style.promptLabel': 'Stil-Prompt',
'workbench.board.scene': 'Szene',
'workbench.board.size': 'Einstellung',
'workbench.board.camera': 'Kamera',
'workbench.board.duration': 'Sek',
'workbench.board.desc': 'Beschreibung',
'workbench.board.dialog': 'Dialog/SFX',
'workbench.board.descPh': 'Was dieser Shot zeigt',
'workbench.board.dialogPh': 'Dialog oder Toneinsatz (optional)',
'workbench.board.remove': 'Shot entfernen',
'workbench.board.add': 'Shot hinzufügen',
'workbench.board.todoTitle': 'Drehbuch Shot für Shot zerlegen',
'workbench.board.todoBody': 'Der Storyboard-Agent liefert Nummern, Einstellungen, Kamerafahrten und Dauern.',
'workbench.board.generate': 'Storyboard erstellen',
'workbench.board.summary': '{n} Shots · ~{sec} s',
'workbench.asset.kindCharacter': 'Figur',
'workbench.asset.kindLocation': 'Location',
'workbench.asset.kindProp': 'Requisite',
'workbench.asset.namePh': 'Asset-Name',
'workbench.asset.descPh': 'Beschreibung (Aussehen, Verwendung)',
'workbench.asset.promptPh': 'Vollständiger englischer Bild-Prompt',
'workbench.asset.promptLabel': 'Bild-Prompt',
'workbench.asset.remove': 'Asset entfernen',
'workbench.asset.add': 'Asset hinzufügen',
'workbench.asset.todoTitle': 'Asset-Liste mit Bild-Prompts aufbauen',
'workbench.asset.todoBody': 'Der Asset-Agent kombiniert Drehbuch, Stil und Storyboard zu kopierfertigen Prompts.',
'workbench.asset.generate': 'Liste erstellen',
'workbench.asset.generateImage': 'Bild generieren',
'workbench.asset.generateHint': 'Generiert über den verbundenen ComfyUI-Workflow; Ergebnisse kehren zum Projekt zurück',
'workbench.asset.summary': '{n} Assets',
'workbench.vshot.noPh': 'Shot-Nr., z. B. S1',
'workbench.vshot.descPh': 'Beschreibung des Shots',
'workbench.vshot.promptPh': 'Vollständiger Video-Prompt (Englisch)',
'workbench.vshot.promptLabel': 'Video-Prompt',
'workbench.vshot.remove': 'Shot entfernen',
'workbench.vshot.add': 'Shot hinzufügen',
'workbench.vshot.todoTitle': 'Shot-weise Videoskripte erstellen',
'workbench.vshot.todoBody': 'Der Shotlisten-Agent wandelt das Storyboard in videomodell-taugliche Prompts.',
'workbench.vshot.generate': 'Shotliste erstellen',
'workbench.vshot.generateVideo': 'Shot-Video generieren',
'workbench.vshot.generateHint': 'Generiert über den verbundenen ComfyUI-Video-Workflow; Ergebnisse kehren zum Projekt zurück',
'workbench.vshot.summary': '{n} Shots',
'home.errScriptShort': 'Drehbuch zu kurz — prüfe den eingefügten Text (mind. 30 Zeichen)',
'home.stageProgress': 'Fünf-Phasen-Fortschritt',
'tasks.sourceWorkbench': 'Kreativ-Fluss',
};
+136
View File
@@ -1569,6 +1569,142 @@ export const en = {
'agentsPage.sugShot2P': 'Show how to write a first/last-frame video prompt: how to describe first frame, last frame, camera move, and duration.',
'workbench.title': 'Creation Workbench',
'workbench.subtitle': 'Script → style → storyboard → assets → shot list, five agents in relay',
'workbench.pickerSubtitle': 'Pick a project to continue its five-stage flow',
'workbench.pickerEmpty': 'No projects yet',
'workbench.pickerEmptyBody': 'Go back to the overview and start from an idea or import a script.',
'workbench.pickerGoHome': 'Open overview',
'workbench.pickerFootHint': 'You can also enter the workbench from the overview\'s 从创意开始 / 导入剧本 entries.',
'workbench.stageProgress': 'Five-stage progress',
'workbench.activeJobs': '{n} jobs running',
'workbench.stage.script': 'Script',
'workbench.stage.style': 'Visual Style',
'workbench.stage.storyboard': 'Storyboard',
'workbench.stage.assets': 'Assets',
'workbench.stage.shot': 'Shot List',
'workbench.lockedStep': 'Unlocks after 「{stage}」 is confirmed',
'workbench.state.todo': 'Not started',
'workbench.state.running': 'Generating',
'workbench.state.draft': 'Awaiting confirm',
'workbench.state.confirmed': 'Confirmed',
'workbench.state.edited': 'Edited',
'workbench.copied': 'Copied — paste it into image/video generation',
'workbench.copyFailed': 'Copy failed, please copy manually',
'workbench.copy': 'Copy',
'workbench.charCount': '{n} chars',
'workbench.runStage': 'Generate with {agent}',
'workbench.runDone': '{agent} finished the draft — please review and confirm',
'workbench.runFailed': 'Generation failed, please retry',
'workbench.runningHint': '{agent} is writing this stage…',
'workbench.confirmNext': 'Confirm & continue',
'workbench.confirmLast': 'Confirm stage',
'workbench.confirmed': '「{agent}」 stage confirmed',
'workbench.confirmFailed': 'Confirm failed, please retry',
'workbench.restore': 'Restore confirmed',
'workbench.restored': 'Restored to the last confirmed version',
'workbench.restoreFailed': 'Restore failed, please retry',
'workbench.draftNotice': 'This is an unconfirmed draft — the next agent only sees it after you confirm.',
'workbench.draftSaved': 'Draft saved — still needs confirmation',
'workbench.saveFailed': 'Save failed, please retry',
'workbench.saveDraft': 'Save draft',
'workbench.cancelEdit': 'Cancel',
'workbench.editDraft': 'Edit',
'workbench.regenerate': 'Regenerate',
'workbench.regenerateHint': 'Ask the agent to redo this stage (the confirmed version is kept and restorable)',
'workbench.instructionPh': 'Extra instructions (optional), e.g. more cinematic, casual dialogue…',
'workbench.jobSubmitted': 'Generation queued — results return to this project',
'workbench.jobSubmitFailed': 'Failed to submit the job',
'workbench.jobRequeued': 'Job re-queued',
'workbench.retryJob': 'Retry',
'workbench.cancelJob': 'Cancel job',
'workbench.openOutput': 'Open result',
'workbench.jobRunningPct': '{n}%',
'workbench.jobRunning': 'Running',
'workbench.jobQueued': 'Queued',
'workbench.jobDone': 'Done',
'workbench.jobFailed': 'Failed',
'workbench.generating': 'Generating…',
'workbench.progressChip': 'Stage {n}/5',
'workbench.backHome': 'Overview',
'workbench.loadFailed': 'Failed to load the workbench.',
'workbench.retryLoad': 'Retry',
'workbench.agentSkills': '{n} skills',
'workbench.chatWithAgent': 'Chat solo',
'workbench.tasksTitle': 'Stage jobs',
'workbench.tasksAll': 'All jobs',
'workbench.tasksEmpty': 'No generation jobs yet. Send prompts from the assets or shot-list stage.',
'workbench.flowTitle': 'Five-stage progress',
'workbench.script.todoTitle': 'Start from the script: hand your idea to the Script agent',
'workbench.script.todoIdea': 'Your idea:',
'workbench.script.generate': 'Draft the script',
'workbench.script.importBtn': 'Import existing script',
'workbench.script.writeManually': 'Write manually',
'workbench.script.importPlaceholder': 'Paste an existing script here… Importing skips script drafting and continues from visual style.',
'workbench.script.importHint': 'The imported script becomes the confirmed baseline of this stage.',
'workbench.script.importConfirm': 'Import & skip drafting',
'workbench.script.importDone': 'Script imported — continue from visual style',
'workbench.script.importFailed': 'Import failed, please check the text and retry',
'workbench.script.editorHint': 'Edit the script text directly; confirmation is still required after saving.',
'workbench.script.copyLabel': 'Full script',
'workbench.style.todoTitle': 'Define the visual style for the script',
'workbench.style.todoBody': 'The Visual Style agent proposes several distinct directions, each with copy-ready image prompts.',
'workbench.style.generate': 'Propose style directions',
'workbench.style.namePh': 'Direction name, e.g. Neon Cyber',
'workbench.style.descPh': 'Description (palette / lighting / texture)',
'workbench.style.promptPh': 'English image keywords, comma separated',
'workbench.style.negativePh': 'Negative prompt (optional, English)',
'workbench.style.negativeLabel': 'Negative prompt',
'workbench.style.remove': 'Remove direction',
'workbench.style.add': 'Add direction',
'workbench.style.chosen': 'Selected',
'workbench.style.chooseHint': 'When confirming this stage, your selected style is handed to later stages.',
'workbench.style.promptLabel': 'Style prompt',
'workbench.board.scene': 'Scene',
'workbench.board.size': 'Shot size',
'workbench.board.camera': 'Camera',
'workbench.board.duration': 'Sec',
'workbench.board.desc': 'Description',
'workbench.board.dialog': 'Dialog/SFX',
'workbench.board.descPh': 'What this shot shows',
'workbench.board.dialogPh': 'Dialog or SFX cue (optional)',
'workbench.board.remove': 'Remove shot',
'workbench.board.add': 'Add shot',
'workbench.board.todoTitle': 'Break the script into a shot-by-shot storyboard',
'workbench.board.todoBody': 'The Storyboard agent produces shot numbers, sizes, camera moves, framing and durations.',
'workbench.board.generate': 'Draft the storyboard',
'workbench.board.summary': '{n} shots · ~{sec}s',
'workbench.asset.kindCharacter': 'Character',
'workbench.asset.kindLocation': 'Location',
'workbench.asset.kindProp': 'Prop',
'workbench.asset.namePh': 'Asset name',
'workbench.asset.descPh': 'Description (appearance, usage)',
'workbench.asset.promptPh': 'Full English image prompt',
'workbench.asset.promptLabel': 'Image prompt',
'workbench.asset.remove': 'Remove asset',
'workbench.asset.add': 'Add asset',
'workbench.asset.todoTitle': 'Build the asset list with image prompts',
'workbench.asset.todoBody': 'The Assets agent combines script, style and storyboard into copy-ready prompts for characters/locations/props.',
'workbench.asset.generate': 'Draft the asset list',
'workbench.asset.generateImage': 'Generate image',
'workbench.asset.generateHint': 'Generate with the connected ComfyUI workflow; results return to this project',
'workbench.asset.summary': '{n} assets',
'workbench.vshot.noPh': 'Shot no., e.g. S1',
'workbench.vshot.descPh': 'Description of the shot',
'workbench.vshot.promptPh': 'Full video-model prompt (English)',
'workbench.vshot.promptLabel': 'Video prompt',
'workbench.vshot.remove': 'Remove shot',
'workbench.vshot.add': 'Add shot',
'workbench.vshot.todoTitle': 'Produce the shot-by-shot video script',
'workbench.vshot.todoBody': 'The Shot List agent turns the storyboard into prompts ready for video models.',
'workbench.vshot.generate': 'Draft the shot list',
'workbench.vshot.generateVideo': 'Generate shot video',
'workbench.vshot.generateHint': 'Generate with the connected ComfyUI video workflow; results return to this project',
'workbench.vshot.summary': '{n} shots',
'home.errScriptShort': 'The script looks too short — check the paste is complete (min 30 characters)',
'home.stageProgress': 'Five-stage progress',
'tasks.sourceWorkbench': 'Creation flow',
};
export type Dict = typeof en;
+136
View File
@@ -1570,4 +1570,140 @@ export const es: Dict = {
'agentsPage.sugShot2P': 'Muestra cómo escribir un prompt con primer y último fotograma: descripción, movimiento y duración.',
'workbench.title': 'Taller de creación',
'workbench.subtitle': 'Guion → estilo → storyboard → assets → lista de planos, cinco agentes en relevo',
'workbench.pickerSubtitle': 'Elige un proyecto para continuar su flujo de cinco etapas',
'workbench.pickerEmpty': 'Aún no hay proyectos',
'workbench.pickerEmptyBody': 'Vuelve al resumen general y empieza desde una idea o importa un guion.',
'workbench.pickerGoHome': 'Ir al resumen',
'workbench.pickerFootHint': 'También puedes entrar al taller desde el resumen (idea / importar guion).',
'workbench.stageProgress': 'Progreso de cinco etapas',
'workbench.activeJobs': '{n} tareas en curso',
'workbench.stage.script': 'Guion',
'workbench.stage.style': 'Estilo visual',
'workbench.stage.storyboard': 'Storyboard',
'workbench.stage.assets': 'Assets',
'workbench.stage.shot': 'Lista de planos',
'workbench.lockedStep': 'Se desbloquea al confirmar «{stage}»',
'workbench.state.todo': 'Sin empezar',
'workbench.state.running': 'Generando',
'workbench.state.draft': 'Por confirmar',
'workbench.state.confirmed': 'Confirmado',
'workbench.state.edited': 'Editado',
'workbench.copied': 'Copiado — pégalo en la generación de imagen/vídeo',
'workbench.copyFailed': 'Error al copiar, cópialo manualmente',
'workbench.copy': 'Copiar',
'workbench.charCount': '{n} caracteres',
'workbench.runStage': 'Generar con {agent}',
'workbench.runDone': '{agent} terminó el borrador — revísalo y confirma',
'workbench.runFailed': 'Falló la generación, reintenta',
'workbench.runningHint': '{agent} está escribiendo esta etapa…',
'workbench.confirmNext': 'Confirmar y continuar',
'workbench.confirmLast': 'Confirmar etapa',
'workbench.confirmed': 'Etapa «{agent}» confirmada',
'workbench.confirmFailed': 'Error al confirmar, reintenta',
'workbench.restore': 'Restaurar versión confirmada',
'workbench.restored': 'Se restauró la última versión confirmada',
'workbench.restoreFailed': 'Error al restaurar, reintenta',
'workbench.draftNotice': 'Borrador sin confirmar: el siguiente agente solo lo verá tras confirmar.',
'workbench.draftSaved': 'Borrador guardado — falta confirmarlo',
'workbench.saveFailed': 'Error al guardar, reintenta',
'workbench.saveDraft': 'Guardar borrador',
'workbench.cancelEdit': 'Cancelar',
'workbench.editDraft': 'Editar',
'workbench.regenerate': 'Regenerar',
'workbench.regenerateHint': 'Vuelve a generar esta etapa (la versión confirmada se conserva)',
'workbench.instructionPh': 'Indicaciones extra (opcional): más cinematográfico, diálogos naturales…',
'workbench.jobSubmitted': 'Tarea en cola — los resultados vuelven al proyecto',
'workbench.jobSubmitFailed': 'Error al enviar la tarea',
'workbench.jobRequeued': 'Tarea reencolada',
'workbench.retryJob': 'Reintentar',
'workbench.cancelJob': 'Cancelar tarea',
'workbench.openOutput': 'Ver resultado',
'workbench.jobRunningPct': '{n}%',
'workbench.jobRunning': 'En curso',
'workbench.jobQueued': 'En cola',
'workbench.jobDone': 'Listo',
'workbench.jobFailed': 'Fallido',
'workbench.generating': 'Generando…',
'workbench.progressChip': 'Etapa {n}/5',
'workbench.backHome': 'Resumen',
'workbench.loadFailed': 'Error al cargar el taller.',
'workbench.retryLoad': 'Reintentar',
'workbench.agentSkills': '{n} skills',
'workbench.chatWithAgent': 'Chat individual',
'workbench.tasksTitle': 'Tareas de etapa',
'workbench.tasksAll': 'Todas las tareas',
'workbench.tasksEmpty': 'Aún no hay tareas. Envía prompts desde la etapa de assets o planos.',
'workbench.flowTitle': 'Progreso de cinco etapas',
'workbench.script.todoTitle': 'Empieza por el guion: entrega tu idea al agente de Guion',
'workbench.script.todoIdea': 'Tu idea:',
'workbench.script.generate': 'Redactar el borrador',
'workbench.script.importBtn': 'Importar guion existente',
'workbench.script.writeManually': 'Escribir a mano',
'workbench.script.importPlaceholder': 'Pega aquí un guion existente… Al importar se salta la redacción y sigue en estilo visual.',
'workbench.script.importHint': 'El guion importado pasa a ser la base confirmada de esta etapa.',
'workbench.script.importConfirm': 'Importar y saltar',
'workbench.script.importDone': 'Guion importado — sigue desde estilo visual',
'workbench.script.importFailed': 'Error al importar, revisa el texto y reintenta',
'workbench.script.editorHint': 'Edita el texto directamente; aún necesitarás confirmar.',
'workbench.script.copyLabel': 'Guion completo',
'workbench.style.todoTitle': 'Define el estilo visual del guion',
'workbench.style.todoBody': 'El agente de Estilo propone varias direcciones con prompts de imagen listos para copiar.',
'workbench.style.generate': 'Proponer direcciones',
'workbench.style.namePh': 'Nombre, p. ej. Neón Cyber',
'workbench.style.descPh': 'Descripción (paleta / luz / materia)',
'workbench.style.promptPh': 'Palabras clave en inglés, separadas por comas',
'workbench.style.negativePh': 'Prompt negativo (opcional, inglés)',
'workbench.style.negativeLabel': 'Prompt negativo',
'workbench.style.remove': 'Eliminar dirección',
'workbench.style.add': 'Añadir dirección',
'workbench.style.chosen': 'Elegido',
'workbench.style.chooseHint': 'Al confirmar, el estilo elegido pasa a las siguientes etapas.',
'workbench.style.promptLabel': 'Prompt de estilo',
'workbench.board.scene': 'Escena',
'workbench.board.size': 'Plano',
'workbench.board.camera': 'Cámara',
'workbench.board.duration': 'Seg',
'workbench.board.desc': 'Descripción',
'workbench.board.dialog': 'Diálogo/SFX',
'workbench.board.descPh': 'Qué muestra este plano',
'workbench.board.dialogPh': 'Diálogo o efecto (opcional)',
'workbench.board.remove': 'Eliminar plano',
'workbench.board.add': 'Añadir plano',
'workbench.board.todoTitle': 'Divide el guion plano a plano',
'workbench.board.todoBody': 'El agente de Storyboard produce números, escalas, movimientos y duraciones.',
'workbench.board.generate': 'Redactar el storyboard',
'workbench.board.summary': '{n} planos · ~{sec} s',
'workbench.asset.kindCharacter': 'Personaje',
'workbench.asset.kindLocation': 'Localización',
'workbench.asset.kindProp': 'Atrezo',
'workbench.asset.namePh': 'Nombre del asset',
'workbench.asset.descPh': 'Descripción (apariencia, uso)',
'workbench.asset.promptPh': 'Prompt de imagen completo en inglés',
'workbench.asset.promptLabel': 'Prompt de imagen',
'workbench.asset.remove': 'Eliminar asset',
'workbench.asset.add': 'Añadir asset',
'workbench.asset.todoTitle': 'Construye la lista de assets con prompts',
'workbench.asset.todoBody': 'El agente de Assets combina guion, estilo y storyboard en prompts listos para copiar.',
'workbench.asset.generate': 'Redactar la lista',
'workbench.asset.generateImage': 'Generar imagen',
'workbench.asset.generateHint': 'Genera con el workflow ComfyUI conectado; los resultados vuelven al proyecto',
'workbench.asset.summary': '{n} assets',
'workbench.vshot.noPh': 'N.º de plano, p. ej. S1',
'workbench.vshot.descPh': 'Descripción del plano',
'workbench.vshot.promptPh': 'Prompt completo de vídeo (inglés)',
'workbench.vshot.promptLabel': 'Prompt de vídeo',
'workbench.vshot.remove': 'Eliminar plano',
'workbench.vshot.add': 'Añadir plano',
'workbench.vshot.todoTitle': 'Produce el guion de vídeo plano a plano',
'workbench.vshot.todoBody': 'El agente de Planos convierte el storyboard en prompts para modelos de vídeo.',
'workbench.vshot.generate': 'Redactar los planos',
'workbench.vshot.generateVideo': 'Generar vídeo del plano',
'workbench.vshot.generateHint': 'Genera con el workflow de vídeo ComfyUI; los resultados vuelven al proyecto',
'workbench.vshot.summary': '{n} planos',
'home.errScriptShort': 'El guion es demasiado corto — revisa el pegado (mín. 30 caracteres)',
'home.stageProgress': 'Progreso de cinco etapas',
'tasks.sourceWorkbench': 'Flujo de creación',
};
+136
View File
@@ -1570,4 +1570,140 @@ export const fr: Dict = {
'agentsPage.sugShot2P': 'Montrez comment écrire un prompt premier/dernier cadre : description, mouvement et durée.',
'workbench.title': 'Atelier de création',
'workbench.subtitle': 'Scénario → style → storyboard → assets → plan de tournage, cinq agents en relais',
'workbench.pickerSubtitle': 'Choisissez un projet pour reprendre son flux en cinq étapes',
'workbench.pickerEmpty': 'Aucun projet pour l\'instant',
'workbench.pickerEmptyBody': 'Retournez à la vue d\'ensemble : partez d\'une idée ou importez un scénario.',
'workbench.pickerGoHome': 'Vue d\'ensemble',
'workbench.pickerFootHint': 'Vous pouvez aussi entrer dans l\'atelier depuis la vue d\'ensemble (idée / import de scénario).',
'workbench.stageProgress': 'Progression en cinq étapes',
'workbench.activeJobs': '{n} tâches en cours',
'workbench.stage.script': 'Scénario',
'workbench.stage.style': 'Style visuel',
'workbench.stage.storyboard': 'Storyboard',
'workbench.stage.assets': 'Assets',
'workbench.stage.shot': 'Plan de tournage',
'workbench.lockedStep': 'Se déverrouille après confirmation de « {stage} »',
'workbench.state.todo': 'Non commencé',
'workbench.state.running': 'Génération',
'workbench.state.draft': 'À confirmer',
'workbench.state.confirmed': 'Confirmé',
'workbench.state.edited': 'Modifié',
'workbench.copied': 'Copié — à coller dans la génération image/vidéo',
'workbench.copyFailed': 'Échec de la copie, copiez manuellement',
'workbench.copy': 'Copier',
'workbench.charCount': '{n} caractères',
'workbench.runStage': 'Générer avec {agent}',
'workbench.runDone': '{agent} a terminé le brouillon — à valider',
'workbench.runFailed': 'Échec de génération, réessayez',
'workbench.runningHint': '{agent} rédige cette étape…',
'workbench.confirmNext': 'Confirmer et continuer',
'workbench.confirmLast': 'Confirmer l\'étape',
'workbench.confirmed': 'Étape « {agent} » confirmée',
'workbench.confirmFailed': 'Échec de confirmation, réessayez',
'workbench.restore': 'Restaurer la version confirmée',
'workbench.restored': 'Version confirmée restaurée',
'workbench.restoreFailed': 'Échec de restauration, réessayez',
'workbench.draftNotice': 'Brouillon non confirmé : l\'agent suivant ne le verra qu\'après confirmation.',
'workbench.draftSaved': 'Brouillon enregistré — confirmation requise',
'workbench.saveFailed': 'Échec de l\'enregistrement, réessayez',
'workbench.saveDraft': 'Enregistrer le brouillon',
'workbench.cancelEdit': 'Annuler',
'workbench.editDraft': 'Modifier',
'workbench.regenerate': 'Régénérer',
'workbench.regenerateHint': 'Regénérer cette étape (la version confirmée est conservée et restaurable)',
'workbench.instructionPh': 'Consignes supplémentaires (facultatif) : plus cinématographique, dialogues plus naturels…',
'workbench.jobSubmitted': 'Tâche de génération envoyée — les résultats reviennent au projet',
'workbench.jobSubmitFailed': 'Échec de l\'envoi de la tâche',
'workbench.jobRequeued': 'Tâche remise en file',
'workbench.retryJob': 'Réessayer',
'workbench.cancelJob': 'Annuler la tâche',
'workbench.openOutput': 'Voir le résultat',
'workbench.jobRunningPct': '{n} %',
'workbench.jobRunning': 'En cours',
'workbench.jobQueued': 'En file',
'workbench.jobDone': 'Terminé',
'workbench.jobFailed': 'Échec',
'workbench.generating': 'Génération…',
'workbench.progressChip': 'Étape {n}/5',
'workbench.backHome': 'Vue d\'ensemble',
'workbench.loadFailed': 'Échec du chargement de l\'atelier.',
'workbench.retryLoad': 'Réessayer',
'workbench.agentSkills': '{n} skills',
'workbench.chatWithAgent': 'Discuter en solo',
'workbench.tasksTitle': 'Tâches de l\'étape',
'workbench.tasksAll': 'Toutes les tâches',
'workbench.tasksEmpty': 'Aucune tâche. Envoyez des prompts depuis l\'étape assets ou plans.',
'workbench.flowTitle': 'Progression en cinq étapes',
'workbench.script.todoTitle': 'Commencez par le scénario : confiez votre idée à l\'agent Scénario',
'workbench.script.todoIdea': 'Votre idée :',
'workbench.script.generate': 'Rédiger le brouillon',
'workbench.script.importBtn': 'Importer un scénario',
'workbench.script.writeManually': 'Écrire manuellement',
'workbench.script.importPlaceholder': 'Collez ici un scénario existant… L\'import saute la rédaction et reprend au style visuel.',
'workbench.script.importHint': 'Le scénario importé devient la base confirmée de cette étape.',
'workbench.script.importConfirm': 'Importer et passer',
'workbench.script.importDone': 'Scénario importé — continuez au style visuel',
'workbench.script.importFailed': 'Échec de l\'import, vérifiez le texte et réessayez',
'workbench.script.editorHint': 'Modifiez le texte directement ; la confirmation reste nécessaire.',
'workbench.script.copyLabel': 'Scénario complet',
'workbench.style.todoTitle': 'Définir le style visuel du scénario',
'workbench.style.todoBody': 'L\'agent Style propose plusieurs directions, chacune avec des prompts image prêts à copier.',
'workbench.style.generate': 'Proposer des directions',
'workbench.style.namePh': 'Nom de la direction, ex. Néon Cyber',
'workbench.style.descPh': 'Description (palette / lumière / matière)',
'workbench.style.promptPh': 'Mots-clés image en anglais, séparés par des virgules',
'workbench.style.negativePh': 'Prompt négatif (facultatif, anglais)',
'workbench.style.negativeLabel': 'Prompt négatif',
'workbench.style.remove': 'Supprimer la direction',
'workbench.style.add': 'Ajouter une direction',
'workbench.style.chosen': 'Sélectionné',
'workbench.style.chooseHint': 'En confirmant, le style choisi est transmis aux étapes suivantes.',
'workbench.style.promptLabel': 'Prompt de style',
'workbench.board.scene': 'Scène',
'workbench.board.size': 'Échelle',
'workbench.board.camera': 'Caméra',
'workbench.board.duration': 'Sec',
'workbench.board.desc': 'Description',
'workbench.board.dialog': 'Dialogue/SFX',
'workbench.board.descPh': 'Ce que montre ce plan',
'workbench.board.dialogPh': 'Dialogue ou effet sonore (facultatif)',
'workbench.board.remove': 'Supprimer le plan',
'workbench.board.add': 'Ajouter un plan',
'workbench.board.todoTitle': 'Découper le scénario plan par plan',
'workbench.board.todoBody': 'L\'agent Storyboard produit numéros, échelles, mouvements et durées.',
'workbench.board.generate': 'Rédiger le storyboard',
'workbench.board.summary': '{n} plans · ~{sec} s',
'workbench.asset.kindCharacter': 'Personnage',
'workbench.asset.kindLocation': 'Lieu',
'workbench.asset.kindProp': 'Accessoire',
'workbench.asset.namePh': 'Nom de l\'asset',
'workbench.asset.descPh': 'Description (apparence, usage)',
'workbench.asset.promptPh': 'Prompt image complet en anglais',
'workbench.asset.promptLabel': 'Prompt image',
'workbench.asset.remove': 'Supprimer l\'asset',
'workbench.asset.add': 'Ajouter un asset',
'workbench.asset.todoTitle': 'Construire la liste d\'assets avec prompts',
'workbench.asset.todoBody': 'L\'agent Assets combine scénario, style et storyboard en prompts prêts à copier.',
'workbench.asset.generate': 'Rédiger la liste',
'workbench.asset.generateImage': 'Générer l\'image',
'workbench.asset.generateHint': 'Génère via le workflow ComfyUI connecté ; les résultats reviennent au projet',
'workbench.asset.summary': '{n} assets',
'workbench.vshot.noPh': 'N° de plan, ex. S1',
'workbench.vshot.descPh': 'Description du plan',
'workbench.vshot.promptPh': 'Prompt vidéo complet (anglais)',
'workbench.vshot.promptLabel': 'Prompt vidéo',
'workbench.vshot.remove': 'Supprimer le plan',
'workbench.vshot.add': 'Ajouter un plan',
'workbench.vshot.todoTitle': 'Produire le script vidéo plan par plan',
'workbench.vshot.todoBody': 'L\'agent Plans transforme le storyboard en prompts prêts pour les modèles vidéo.',
'workbench.vshot.generate': 'Rédiger les plans',
'workbench.vshot.generateVideo': 'Générer la vidéo du plan',
'workbench.vshot.generateHint': 'Génère via le workflow vidéo ComfyUI connecté ; résultats dans le projet',
'workbench.vshot.summary': '{n} plans',
'home.errScriptShort': 'Scénario trop court — vérifiez le collage (30 caractères min.)',
'home.stageProgress': 'Progression en cinq étapes',
'tasks.sourceWorkbench': 'Flux de création',
};
+136
View File
@@ -1570,4 +1570,140 @@ export const ja: Dict = {
'agentsPage.sugShot2P': '最初/最終フレームの動画プロンプトの書き方を実演して:描写・カメラワーク・秒数。',
'workbench.title': '制作ワークベンチ',
'workbench.subtitle': '脚本 → ビジュアル → 絵コンテ → 素材 → ショットリスト、5つのエージェントがリレーで制作',
'workbench.pickerSubtitle': 'プロジェクトを選んで五段階フローを続行',
'workbench.pickerEmpty': 'プロジェクトがまだありません',
'workbench.pickerEmptyBody': 'まずトップページへ戻り、アイデアから始めるか脚本を取り込んでください。',
'workbench.pickerGoHome': 'トップへ戻る',
'workbench.pickerFootHint': 'トップの「アイデアから始める / 脚本を取り込む」からも入れます。',
'workbench.stageProgress': '五段階の進捗',
'workbench.activeJobs': '{n} 件のタスク実行中',
'workbench.stage.script': '脚本制作',
'workbench.stage.style': 'ビジュアル設計',
'workbench.stage.storyboard': '絵コンテ',
'workbench.stage.assets': '素材アセット',
'workbench.stage.shot': 'ショットリスト',
'workbench.lockedStep': '「{stage}」を確認すると解放されます',
'workbench.state.todo': '未着手',
'workbench.state.running': '生成中',
'workbench.state.draft': '確認待ち',
'workbench.state.confirmed': '確認済み',
'workbench.state.edited': '編集済み',
'workbench.copied': 'コピーしました。生成画面にそのまま貼り付けられます',
'workbench.copyFailed': 'コピーに失敗しました。手動で選択してコピーしてください',
'workbench.copy': 'コピー',
'workbench.charCount': '{n} 文字',
'workbench.runStage': '{agent}に生成させる',
'workbench.runDone': '{agent}がドラフトを完成させました。確認してください',
'workbench.runFailed': '生成に失敗しました。再試行してください',
'workbench.runningHint': '{agent}がこの段階を書いています…',
'workbench.confirmNext': '確認して次へ',
'workbench.confirmLast': 'この段階を確認',
'workbench.confirmed': '「{agent}」段階を確認しました',
'workbench.confirmFailed': '確認に失敗しました。再試行してください',
'workbench.restore': '確認済み版に戻す',
'workbench.restored': '直近の確認済み版に戻しました',
'workbench.restoreFailed': '復元に失敗しました。再試行してください',
'workbench.draftNotice': 'これは未確認のドラフトです。確認後にのみ次のエージェントへ渡ります。',
'workbench.draftSaved': 'ドラフトを保存しました。確認が必要です',
'workbench.saveFailed': '保存に失敗しました。再試行してください',
'workbench.saveDraft': 'ドラフトを保存',
'workbench.cancelEdit': 'キャンセル',
'workbench.editDraft': '編集',
'workbench.regenerate': '再生成',
'workbench.regenerateHint': 'この段階を再度生成します(確認済み版は保持され、いつでも戻せます)',
'workbench.instructionPh': '追加の要望(任意)。例:映画的に、台詞をより口語的に…',
'workbench.jobSubmitted': '生成タスクを投入しました。結果はこのプロジェクトに戻ります',
'workbench.jobSubmitFailed': 'タスクの投入に失敗しました',
'workbench.jobRequeued': 'タスクを再び並べました',
'workbench.retryJob': '再試行',
'workbench.cancelJob': 'タスクをキャンセル',
'workbench.openOutput': '結果を見る',
'workbench.jobRunningPct': '{n}%',
'workbench.jobRunning': '実行中',
'workbench.jobQueued': '待機中',
'workbench.jobDone': '完了',
'workbench.jobFailed': '失敗',
'workbench.generating': '生成中…',
'workbench.progressChip': '段階 {n}/5',
'workbench.backHome': 'トップ',
'workbench.loadFailed': 'ワークベンチの読み込みに失敗しました。',
'workbench.retryLoad': '再試行',
'workbench.agentSkills': 'Skill {n} 件',
'workbench.chatWithAgent': '個別にチャット',
'workbench.tasksTitle': '段階タスク',
'workbench.tasksAll': 'すべてのタスク',
'workbench.tasksEmpty': '生成タスクはまだありません。素材・ショット段階からプロンプトを送れます。',
'workbench.flowTitle': '五段階の進捗',
'workbench.script.todoTitle': '脚本から始める:アイデアを脚本エージェントへ',
'workbench.script.todoIdea': 'あなたのアイデア:',
'workbench.script.generate': '脚本のドラフトを生成',
'workbench.script.importBtn': '既存の脚本を取り込む',
'workbench.script.writeManually': '手動で書く',
'workbench.script.importPlaceholder': '既存の脚本をここに貼り付け…取り込むと脚本制作をスキップし、ビジュアル設計から始まります。',
'workbench.script.importHint': '取り込んだ脚本はこの段階の確認済みベースラインになります。',
'workbench.script.importConfirm': '取り込んで脚本制作をスキップ',
'workbench.script.importDone': '脚本を取り込みました。ビジュアル設計から続行',
'workbench.script.importFailed': '取り込みに失敗しました。内容を確認して再試行してください',
'workbench.script.editorHint': '脚本本文を直接編集できます。保存後も確認が必要です。',
'workbench.script.copyLabel': '脚本全文',
'workbench.style.todoTitle': '脚本にビジュアルスタイルを定める',
'workbench.style.todoBody': 'ビジュアル設計エージェントが、コピーして使える画像プロンプト付きの複数のスタイル方向を提案します。',
'workbench.style.generate': 'スタイル方向を生成',
'workbench.style.namePh': 'スタイル名(例:ネオンサイバー)',
'workbench.style.descPh': 'スタイル説明(色調 / 光 / 質感)',
'workbench.style.promptPh': '英語の画像キーワード(カンマ区切り)',
'workbench.style.negativePh': 'ネガティブプロンプト(任意・英語)',
'workbench.style.negativeLabel': 'ネガティブプロンプト',
'workbench.style.remove': 'この方向を削除',
'workbench.style.add': 'スタイル方向を追加',
'workbench.style.chosen': '選択中',
'workbench.style.chooseHint': 'この段階を確認すると、選んだスタイルが以降の段階に渡ります。',
'workbench.style.promptLabel': 'スタイルプロンプト',
'workbench.board.scene': '場面',
'workbench.board.size': 'サイズ',
'workbench.board.camera': 'カメラ',
'workbench.board.duration': '秒',
'workbench.board.desc': '内容',
'workbench.board.dialog': '台詞・効果音',
'workbench.board.descPh': 'このショットの内容',
'workbench.board.dialogPh': '台詞や効果音(任意)',
'workbench.board.remove': 'このショットを削除',
'workbench.board.add': 'ショットを追加',
'workbench.board.todoTitle': '脚本をショットごとの絵コンテに分解',
'workbench.board.todoBody': '絵コンテエージェントがショット番号・サイズ・カメラワーク・内容・秒数を作成します。',
'workbench.board.generate': '絵コンテを生成',
'workbench.board.summary': '全 {n} ショット · 約 {sec} 秒',
'workbench.asset.kindCharacter': 'キャラクター',
'workbench.asset.kindLocation': 'ロケーション',
'workbench.asset.kindProp': '小道具',
'workbench.asset.namePh': 'アセット名',
'workbench.asset.descPh': '説明(外見・用途)',
'workbench.asset.promptPh': '完全な英語の画像プロンプト',
'workbench.asset.promptLabel': '画像プロンプト',
'workbench.asset.remove': 'このアセットを削除',
'workbench.asset.add': 'アセットを追加',
'workbench.asset.todoTitle': '素材リストと画像プロンプトを作成',
'workbench.asset.todoBody': '素材エージェントが脚本・スタイル・絵コンテから、复制可能な完全なプロンプトを作成します。',
'workbench.asset.generate': '素材リストを生成',
'workbench.asset.generateImage': 'この画像を生成',
'workbench.asset.generateHint': '接続済み ComfyUI ワークフローで生成し、結果はこのプロジェクトに戻ります',
'workbench.asset.summary': '素材 {n} 点',
'workbench.vshot.noPh': 'ショット番号(例 S1)',
'workbench.vshot.descPh': 'ショットの説明',
'workbench.vshot.promptPh': '完全な動画モデル用プロンプト(英語)',
'workbench.vshot.promptLabel': '動画プロンプト',
'workbench.vshot.remove': 'このショットを削除',
'workbench.vshot.add': 'ショットを追加',
'workbench.vshot.todoTitle': 'ショットごとの動画スクリプトを作成',
'workbench.vshot.todoBody': 'ショットリストエージェントが絵コンテを動画モデル用プロンプトに変換します。',
'workbench.vshot.generate': 'ショットリストを生成',
'workbench.vshot.generateVideo': 'このショットの動画を生成',
'workbench.vshot.generateHint': '接続済み ComfyUI 動画ワークフローで生成し、結果はこのプロジェクトに戻ります',
'workbench.vshot.summary': '全 {n} ショット',
'home.errScriptShort': '脚本が短すぎます。貼り付けが完全か確認してください(30 文字以上)',
'home.stageProgress': '五段階の進捗',
'tasks.sourceWorkbench': '制作フロー',
};
+136
View File
@@ -1570,4 +1570,140 @@ export const ko: Dict = {
'agentsPage.sugShot2P': '첫/끝 프레임 영상 프롬프트 작성법을 보여 줘: 묘사, 카메라 무브, 길이.',
'workbench.title': '제작 워크벤치',
'workbench.subtitle': '대본 → 비주얼 → 스토리보드 → 소재 → 숏리스트, 다섯 에이전트의 릴레이',
'workbench.pickerSubtitle': '프로젝트를 골라 5단계 흐름을 이어가세요',
'workbench.pickerEmpty': '아직 프로젝트가 없습니다',
'workbench.pickerEmptyBody': '먼저 개요로 돌아가 아이디어로 시작하거나 대본을 가져오세요.',
'workbench.pickerGoHome': '개요로 가기',
'workbench.pickerFootHint': '개요의 \'아이디어로 시작 / 대본 가져오기\'에서도 들어갈 수 있습니다.',
'workbench.stageProgress': '5단계 진행',
'workbench.activeJobs': '작업 {n}개 실행 중',
'workbench.stage.script': '대본 작성',
'workbench.stage.style': '비주얼 디자인',
'workbench.stage.storyboard': '스토리보드',
'workbench.stage.assets': '소재 에셋',
'workbench.stage.shot': '숏 리스트',
'workbench.lockedStep': '\'{stage}\'을(를) 확인하면 잠금 해제됩니다',
'workbench.state.todo': '시작 전',
'workbench.state.running': '생성 중',
'workbench.state.draft': '확인 대기',
'workbench.state.confirmed': '확인됨',
'workbench.state.edited': '수정됨',
'workbench.copied': '복사됐습니다. 이미지/영상 생성에 바로 붙여넣으세요',
'workbench.copyFailed': '복사 실패, 직접 선택해 복사해 주세요',
'workbench.copy': '복사',
'workbench.charCount': '{n}자',
'workbench.runStage': '{agent} 에이전트가 생성',
'workbench.runDone': '{agent} 에이전트가 초안을 완성했습니다. 확인해 주세요',
'workbench.runFailed': '생성 실패, 다시 시도해 주세요',
'workbench.runningHint': '{agent} 에이전트가 이 단계를 작성 중입니다…',
'workbench.confirmNext': '확인하고 다음 단계로',
'workbench.confirmLast': '이 단계 확인',
'workbench.confirmed': '\'{agent}\' 단계가 확인되었습니다',
'workbench.confirmFailed': '확인 실패, 다시 시도해 주세요',
'workbench.restore': '확인본 복원',
'workbench.restored': '마지막 확인 버전으로 복원했습니다',
'workbench.restoreFailed': '복원 실패, 다시 시도해 주세요',
'workbench.draftNotice': '확인되지 않은 초안입니다. 확인 후 다음 에이전트에 전달됩니다.',
'workbench.draftSaved': '초안 저장됨 — 여전히 확인이 필요합니다',
'workbench.saveFailed': '저장 실패, 다시 시도해 주세요',
'workbench.saveDraft': '초안 저장',
'workbench.cancelEdit': '취소',
'workbench.editDraft': '편집',
'workbench.regenerate': '다시 생성',
'workbench.regenerateHint': '이 단계를 다시 생성합니다(확인본은 유지되어 언제든 복원 가능)',
'workbench.instructionPh': '추가 요청(선택). 예: 영화감 있게, 대사를 구어체로…',
'workbench.jobSubmitted': '생성 작업이 등록되었습니다. 결과는 이 프로젝트로 돌아옵니다',
'workbench.jobSubmitFailed': '작업 등록 실패',
'workbench.jobRequeued': '작업이 다시 대기열에 등록되었습니다',
'workbench.retryJob': '재시도',
'workbench.cancelJob': '작업 취소',
'workbench.openOutput': '결과 보기',
'workbench.jobRunningPct': '{n}%',
'workbench.jobRunning': '실행 중',
'workbench.jobQueued': '대기 중',
'workbench.jobDone': '완료',
'workbench.jobFailed': '실패',
'workbench.generating': '생성 중…',
'workbench.progressChip': '단계 {n}/5',
'workbench.backHome': '개요',
'workbench.loadFailed': '워크벤치를 불러오지 못했습니다.',
'workbench.retryLoad': '재시도',
'workbench.agentSkills': '스킬 {n}개',
'workbench.chatWithAgent': '따로 대화하기',
'workbench.tasksTitle': '단계 작업',
'workbench.tasksAll': '전체 작업',
'workbench.tasksEmpty': '아직 생성 작업이 없습니다. 소재·숏 단계에서 프롬프트를 보내세요.',
'workbench.flowTitle': '5단계 진행',
'workbench.script.todoTitle': '대본부터 시작: 아이디어를 대본 에이전트에게',
'workbench.script.todoIdea': '당신의 아이디어:',
'workbench.script.generate': '대본 초안 생성',
'workbench.script.importBtn': '기존 대본 가져오기',
'workbench.script.writeManually': '직접 작성',
'workbench.script.importPlaceholder': '기존 대본을 여기에 붙여넣으세요… 가져오면 대본 작성을 건너뛰고 비주얼 디자인부터 시작합니다.',
'workbench.script.importHint': '가져온 대본은 이 단계의 확인된 기준선이 됩니다.',
'workbench.script.importConfirm': '가져오고 대본 작성 건너뛰기',
'workbench.script.importDone': '대본을 가져왔습니다. 비주얼 디자인부터 계속',
'workbench.script.importFailed': '가져오기 실패, 내용을 확인하고 다시 시도하세요',
'workbench.script.editorHint': '대본 본문을 직접 수정할 수 있습니다. 저장 후에도 확인이 필요합니다.',
'workbench.script.copyLabel': '대본 전문',
'workbench.style.todoTitle': '대본에 어울리는 비주얼 스타일 정하기',
'workbench.style.todoBody': '비주얼 디자인 에이전트가 복사해 바로 쓸 수 있는 이미지 프롬프트와 함께 여러 스타일 방향을 제안합니다.',
'workbench.style.generate': '스타일 방향 생성',
'workbench.style.namePh': '스타일 이름(예: 네온 사이버)',
'workbench.style.descPh': '스타일 설명(톤 / 조명 / 질감)',
'workbench.style.promptPh': '영어 이미지 키워드, 쉼표 구분',
'workbench.style.negativePh': '네거티브 프롬프트(선택, 영어)',
'workbench.style.negativeLabel': '네거티브 프롬프트',
'workbench.style.remove': '이 방향 삭제',
'workbench.style.add': '스타일 방향 추가',
'workbench.style.chosen': '선택됨',
'workbench.style.chooseHint': '이 단계를 확인하면 선택한 스타일이 이후 단계에 전달됩니다.',
'workbench.style.promptLabel': '스타일 프롬프트',
'workbench.board.scene': '장면',
'workbench.board.size': '샷 크기',
'workbench.board.camera': '카메라',
'workbench.board.duration': '초',
'workbench.board.desc': '내용',
'workbench.board.dialog': '대사/효과음',
'workbench.board.descPh': '이 샷의 내용',
'workbench.board.dialogPh': '대사나 효과음(선택)',
'workbench.board.remove': '이 샷 삭제',
'workbench.board.add': '샷 추가',
'workbench.board.todoTitle': '대본을 샷 단위 스토리보드로 분해',
'workbench.board.todoBody': '스토리보드 에이전트가 샷 번호·크기·카메라·내용·길이를 만듭니다.',
'workbench.board.generate': '스토리보드 생성',
'workbench.board.summary': '총 {n}샷 · 약 {sec}초',
'workbench.asset.kindCharacter': '캐릭터',
'workbench.asset.kindLocation': '장소',
'workbench.asset.kindProp': '소품',
'workbench.asset.namePh': '에셋 이름',
'workbench.asset.descPh': '설명(외형, 용도)',
'workbench.asset.promptPh': '완전한 영어 이미지 프롬프트',
'workbench.asset.promptLabel': '이미지 프롬프트',
'workbench.asset.remove': '이 에셋 삭제',
'workbench.asset.add': '에셋 추가',
'workbench.asset.todoTitle': '소재 목록과 이미지 프롬프트 만들기',
'workbench.asset.todoBody': '소재 에이전트가 대본·스타일·스토리보드를 조합해 복사 가능한 완전한 프롬프트를 만듭니다.',
'workbench.asset.generate': '소재 목록 생성',
'workbench.asset.generateImage': '이 이미지 생성',
'workbench.asset.generateHint': '연결된 ComfyUI 워크플로로 생성하며 결과는 이 프로젝트로 돌아옵니다',
'workbench.asset.summary': '소재 {n}건',
'workbench.vshot.noPh': '샷 번호(예 S1)',
'workbench.vshot.descPh': '샷 설명',
'workbench.vshot.promptPh': '완전한 영어 영상 모델 프롬프트',
'workbench.vshot.promptLabel': '영상 프롬프트',
'workbench.vshot.remove': '이 샷 삭제',
'workbench.vshot.add': '샷 추가',
'workbench.vshot.todoTitle': '샷별 영상 스크립트 만들기',
'workbench.vshot.todoBody': '숏 리스트 에이전트가 스토리보드를 영상 모델용 프롬프트로 바꿉니다.',
'workbench.vshot.generate': '숏 리스트 생성',
'workbench.vshot.generateVideo': '이 샷 영상 생성',
'workbench.vshot.generateHint': '연결된 ComfyUI 영상 워크플로로 생성하며 결과는 이 프로젝트로 돌아옵니다',
'workbench.vshot.summary': '총 {n}샷',
'home.errScriptShort': '대본이 너무 짧습니다. 붙여넣기가 완전한지 확인하세요(30자 이상)',
'home.stageProgress': '5단계 진행',
'tasks.sourceWorkbench': '제작 흐름',
};
+136
View File
@@ -1569,4 +1569,140 @@ export const zh: Dict = {
'agentsPage.sugShot2P': '请演示一个首尾帧生视频镜头的提示词写法:首帧画面、尾帧画面、运镜与时长分别怎么描述。',
'workbench.title': '创作工作台',
'workbench.subtitle': '剧本 → 视觉 → 分镜 → 素材 → 镜头脚本,五个智能体接力完成创作',
'workbench.pickerSubtitle': '选择一个项目,继续它的五段创作流程',
'workbench.pickerEmpty': '还没有项目',
'workbench.pickerEmptyBody': '先回到项目总览,从创意开始或导入现成剧本。',
'workbench.pickerGoHome': '去项目总览',
'workbench.pickerFootHint': '也可以在项目总览直接「从创意开始」或「导入剧本」进入工作台。',
'workbench.stageProgress': '五段进度',
'workbench.activeJobs': '{n} 个任务进行中',
'workbench.stage.script': '剧本创作',
'workbench.stage.style': '视觉设计',
'workbench.stage.storyboard': '分镜设计',
'workbench.stage.assets': '素材资产',
'workbench.stage.shot': '镜头脚本',
'workbench.lockedStep': '先完成并确认「{stage}」后解锁',
'workbench.state.todo': '待开始',
'workbench.state.running': '生成中',
'workbench.state.draft': '待确认',
'workbench.state.confirmed': '已确认',
'workbench.state.edited': '已修改',
'workbench.copied': '已复制,可直接粘贴到生图/生视频',
'workbench.copyFailed': '复制失败,请手动选择复制',
'workbench.copy': '复制',
'workbench.charCount': '{n} 字',
'workbench.runStage': '让{agent}生成',
'workbench.runDone': '{agent}已完成本段初稿,请确认',
'workbench.runFailed': '生成失败,请重试',
'workbench.runningHint': '{agent}正在生成本段内容,请稍候……',
'workbench.confirmNext': '确认,进入下一阶段',
'workbench.confirmLast': '确认本阶段',
'workbench.confirmed': '已确认「{agent}」阶段产出',
'workbench.confirmFailed': '确认失败,请重试',
'workbench.restore': '恢复已确认版',
'workbench.restored': '已恢复到最近确认的版本',
'workbench.restoreFailed': '恢复失败,请重试',
'workbench.draftNotice': '当前是未确认草稿:确认后才会交给下一阶段的智能体使用。',
'workbench.draftSaved': '草稿已保存,仍需确认后才生效',
'workbench.saveFailed': '保存失败,请重试',
'workbench.saveDraft': '保存草稿',
'workbench.cancelEdit': '取消',
'workbench.editDraft': '编辑',
'workbench.regenerate': '重新生成',
'workbench.regenerateHint': '重新让智能体生成本段内容(已确认版本会保留,可随时恢复)',
'workbench.instructionPh': '补充要求(可选),例如:偏电影感、台词更口语化……',
'workbench.jobSubmitted': '已提交生成任务,结果会自动回到本项目',
'workbench.jobSubmitFailed': '任务提交失败',
'workbench.jobRequeued': '任务已重新排队',
'workbench.retryJob': '重试',
'workbench.cancelJob': '取消任务',
'workbench.openOutput': '查看结果',
'workbench.jobRunningPct': '{n}%',
'workbench.jobRunning': '生成中',
'workbench.jobQueued': '排队',
'workbench.jobDone': '完成',
'workbench.jobFailed': '失败',
'workbench.generating': '生成中…',
'workbench.progressChip': '阶段 {n}/5',
'workbench.backHome': '项目总览',
'workbench.loadFailed': '工作台加载失败。',
'workbench.retryLoad': '重试',
'workbench.agentSkills': '{n} 个 Skill',
'workbench.chatWithAgent': '单独聊聊',
'workbench.tasksTitle': '阶段任务',
'workbench.tasksAll': '全部任务',
'workbench.tasksEmpty': '还没有生成任务。在素材资产或镜头脚本阶段可把提示词送入生成。',
'workbench.flowTitle': '五段进度',
'workbench.script.todoTitle': '从剧本开始:写下创意,交给「剧本创作」智能体',
'workbench.script.todoIdea': '你的创意:',
'workbench.script.generate': '生成剧本初稿',
'workbench.script.importBtn': '导入已有剧本',
'workbench.script.writeManually': '手动撰写',
'workbench.script.importPlaceholder': '把现成剧本粘贴到这里……导入后将跳过剧本创作,直接从视觉设计开始。',
'workbench.script.importHint': '导入的剧本会直接作为已确认的剧本阶段基线。',
'workbench.script.importConfirm': '导入并跳过剧本创作',
'workbench.script.importDone': '剧本已导入,从视觉设计继续',
'workbench.script.importFailed': '剧本导入失败,请检查内容后重试',
'workbench.script.editorHint': '直接编辑剧本正文,保存后仍需确认。',
'workbench.script.copyLabel': '剧本全文',
'workbench.style.todoTitle': '为剧本确定视觉风格',
'workbench.style.todoBody': '「视觉设计」智能体会给出多套差异化风格方向,每套附可直接复制的生图提示词。',
'workbench.style.generate': '生成风格方向',
'workbench.style.namePh': '风格名称,如:霓虹赛博',
'workbench.style.descPh': '中文风格说明(色调/光影/材质)',
'workbench.style.promptPh': '英文生图关键词,逗号分隔',
'workbench.style.negativePh': '负面提示词(可选,英文)',
'workbench.style.negativeLabel': '负面提示词',
'workbench.style.remove': '删除该方向',
'workbench.style.add': '添加风格方向',
'workbench.style.chosen': '当前选择',
'workbench.style.chooseHint': '确认视觉设计阶段时,会把你选中的风格交给后续阶段。',
'workbench.style.promptLabel': '风格提示词',
'workbench.board.scene': '场次',
'workbench.board.size': '景别',
'workbench.board.camera': '运镜',
'workbench.board.duration': '时长(秒)',
'workbench.board.desc': '画面内容',
'workbench.board.dialog': '台词/音效',
'workbench.board.descPh': '这一镜的画面内容描述',
'workbench.board.dialogPh': '台词或音效起点(可空)',
'workbench.board.remove': '删除该镜',
'workbench.board.add': '添加镜头',
'workbench.board.todoTitle': '把剧本拆成逐镜分镜表',
'workbench.board.todoBody': '「分镜设计」智能体按剧本产出镜号、景别、运镜、画面与时长建议。',
'workbench.board.generate': '生成分镜表',
'workbench.board.summary': '共 {n} 镜 · 约 {sec} 秒',
'workbench.asset.kindCharacter': '角色',
'workbench.asset.kindLocation': '场景',
'workbench.asset.kindProp': '道具',
'workbench.asset.namePh': '资产名称',
'workbench.asset.descPh': '中文说明(外观细节、用途)',
'workbench.asset.promptPh': '完整英文生图提示词',
'workbench.asset.promptLabel': '生图提示词',
'workbench.asset.remove': '删除该资产',
'workbench.asset.add': '添加素材',
'workbench.asset.todoTitle': '整理素材清单与生图提示词',
'workbench.asset.todoBody': '「素材资产」智能体结合剧本、风格与分镜,为角色/场景/道具产出完整可复制的生图提示词。',
'workbench.asset.generate': '生成素材清单',
'workbench.asset.generateImage': '生成本图',
'workbench.asset.generateHint': '用本地/已连接的 ComfyUI 工作流生成这张图,结果回到本项目',
'workbench.asset.summary': '共 {n} 项素材',
'workbench.vshot.noPh': '镜号,如 S1',
'workbench.vshot.descPh': '中文画面说明',
'workbench.vshot.promptPh': '完整英文视频模型提示词',
'workbench.vshot.promptLabel': '视频提示词',
'workbench.vshot.remove': '删除该镜',
'workbench.vshot.add': '添加镜头',
'workbench.vshot.todoTitle': '产出逐镜视频脚本',
'workbench.vshot.todoBody': '「镜头脚本」智能体把分镜转化为视频模型可用的逐镜提示词。',
'workbench.vshot.generate': '生成镜头脚本',
'workbench.vshot.generateVideo': '生成本镜视频',
'workbench.vshot.generateHint': '用已连接的 ComfyUI 视频工作流生成这一镜,结果回到本项目',
'workbench.vshot.summary': '共 {n} 镜',
'home.errScriptShort': '剧本内容太短,请检查粘贴是否完整(至少 30 字)',
'home.stageProgress': '五段进度',
'tasks.sourceWorkbench': '创作流程',
};
+25 -8
View File
@@ -5,8 +5,7 @@
// the sample progress rendered in the mockup.
import { goto } from '$app/navigation';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { jobsApi, projects, type Project } from '$lib/api';
import { agentDeepLink } from '$lib/agentTasks';
import { jobsApi, projects, workbenchApi, type Project } from '$lib/api';
import AppShell from '$lib/components/shell/AppShell.svelte';
import AProjectCard from '$lib/components/home/AProjectCard.svelte';
import Button from '$lib/components/ui/Button.svelte';
@@ -32,10 +31,10 @@
const inProgressCount = $derived(allProjects.filter((p) => p.status === 'in_progress').length);
// ------------------------------------------------------------------
// Entry flows — both lead to already-working Calliope flows:
// 从创意开始 → create project → project stage workspace (/project/:id)
// 导入剧本 → create project with the script stored → hand off to the
// project-linked script agent (/agents?project=…&task=script)
// Entry flows — both open the five-stage creation workbench (a2):
// 从创意开始 → create project → workbench stage 1 (剧本创作)
// 导入剧本 → create project → script stage confirmed via import
// → workbench stage 2 (视觉设计), skipping 剧本创作
// ------------------------------------------------------------------
type EntryMode = 'none' | 'idea' | 'script';
let entryMode = $state<EntryMode>('none');
@@ -98,20 +97,38 @@
const errs: Record<string, string> = {};
if (!scriptForm.title.trim()) errs.title = t('home.errTitle');
if (!scriptForm.script.trim()) errs.script = t('home.errScript');
else if (scriptForm.script.trim().length < 30) errs.script = t('home.errScriptShort');
return errs;
});
function onIdeaCreated(p: Project) {
client.invalidateQueries({ queryKey: ['projects'] });
toast.success(t('projects.created', { title: p.title }));
goto(`/project/${p.id}`);
goto(`/workbench/${p.id}?stage=script`);
}
function onScriptCreated(p: Project) {
client.invalidateQueries({ queryKey: ['projects'] });
toast.success(t('projects.created', { title: p.title }));
goto(agentDeepLink(p.id, 'script'));
// Import the pasted script as the confirmed 剧本 baseline, then
// continue from 视觉设计 (导入剧本可跳过剧本创作).
$importScriptMutation.mutate({ projectId: p.id, text: scriptForm.script.trim() });
}
const importScriptMutation = createMutation({
mutationFn: (args: { projectId: number; text: string }) =>
workbenchApi.importScript(args.projectId, args.text),
onSuccess: (_data, args) => {
client.invalidateQueries({ queryKey: ['projects'] });
goto(`/workbench/${args.projectId}?stage=style`);
},
onError: (err, args) => {
toast.error(err instanceof Error ? err.message : t('projects.createFailed'));
// Never strand the user: open the workbench at 剧本创作 so the
// text can be pasted/edited there even if the import failed.
if (args) goto(`/workbench/${args.projectId}?stage=script`);
},
});
const ideaMutation = createMutation({
mutationFn: () =>
projects.create({
@@ -55,6 +55,7 @@
const src = job.payload?.source;
if (src === 'studio-image') return t('tasks.sourceStudioImage');
if (src === 'studio-video') return t('tasks.sourceStudioVideo');
if (src === 'workbench') return t('tasks.sourceWorkbench');
if (src === 'playground') return t('tasks.sourcePlayground');
if (job.scene_id || job.clip_id) return t('tasks.sourceFlow');
if (!src) return t('tasks.sourceFlow');
@@ -0,0 +1,234 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query';
import { projects, type Project } from '$lib/api';
import AppShell from '$lib/components/shell/AppShell.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import { t } from '$lib/i18n.svelte';
/**
* 创作工作台 entry: pick a project to open its five-stage flow. All
* numbers come from real project stats (stage progress + live jobs).
*/
const STAGES = ['script', 'style', 'storyboard', 'assets', 'shot'] as const;
const projectsQuery = createQuery({
queryKey: ['projects'],
queryFn: projects.list,
});
const items = $derived(($projectsQuery.data ?? []).slice());
function stageStates(p: Project): Record<string, string> {
return p.stats?.stage_status ?? {};
}
function confirmedCount(p: Project): number {
return p.stats?.stage_confirmed ?? 0;
}
function activeJobs(p: Project): number {
return p.stats?.active_jobs ?? 0;
}
function stageClass(state: string | undefined): string {
if (state === 'confirmed') return 'st-confirmed';
if (state === 'running' || state === 'awaiting_confirm') return 'st-draft';
return 'st-todo';
}
function open(p: Project) {
goto(`/workbench/${p.id}`);
}
function firstOpenStage(p: Project): string {
const states = stageStates(p);
for (const key of STAGES) {
if (states[key] !== 'confirmed') return key;
}
return 'shot';
}
</script>
<AppShell active="workbench" title={t('workbench.title')} subtitle={t('workbench.pickerSubtitle')}>
<div class="picker">
{#if $projectsQuery.isLoading}
<div class="skeleton-col">
{#each Array(3) as _}
<Skeleton height="72px" />
{/each}
</div>
{:else if items.length === 0}
<EmptyState title={t('workbench.pickerEmpty')} body={t('workbench.pickerEmptyBody')}>
{#snippet action()}
<a class="entry-btn primary" href="/">{t('workbench.pickerGoHome')}</a>
{/snippet}
</EmptyState>
{:else}
<ul class="project-list" data-testid="workbench-project-list">
{#each items as p (p.id)}
<li>
<button type="button" class="row" onclick={() => open(p)}>
<span class="row-title">{p.title}</span>
<span class="row-stages" aria-label={t('workbench.stageProgress')}>
{#each STAGES as key (key)}
{@const state = stageStates(p)[key]}
<span class="seg {stageClass(state)}" title={t(`workbench.stage.${key}`)}></span>
{/each}
<span class="seg-count mono">{confirmedCount(p)}/5</span>
</span>
<span class="row-meta">
{#if activeJobs(p) > 0}
<span class="jobs-chip">{t('workbench.activeJobs', { n: activeJobs(p) })}</span>
{/if}
<span class="go"><Icon name="chevron-right" size={15} /></span>
</span>
</button>
</li>
{/each}
</ul>
<p class="foot-hint">{t('workbench.pickerFootHint')}</p>
{/if}
</div>
</AppShell>
<style>
.picker {
max-width: 860px;
margin: 0 auto;
padding: 24px 28px 40px;
display: flex;
flex-direction: column;
gap: 14px;
}
.skeleton-col {
display: flex;
flex-direction: column;
gap: 10px;
}
.project-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 16px 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
font-family: inherit;
text-align: left;
transition:
border-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
background-color 150ms;
}
.row:hover {
border-color: var(--border-strong);
background: var(--surface-2);
}
.row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.row-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
flex: 1;
min-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-stages {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.seg {
width: 18px;
height: 5px;
border-radius: 2px;
background: var(--surface-3);
}
.seg.st-draft {
background: var(--accent);
}
.seg.st-confirmed {
background: var(--success);
}
.seg-count {
margin-left: 6px;
font-size: 11px;
color: var(--text-3);
font-variant-numeric: tabular-nums;
}
.row-meta {
display: inline-flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.jobs-chip {
padding: 1px 9px;
border-radius: 999px;
background: var(--accent-dim);
color: var(--accent);
font-size: 10.5px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.go {
color: var(--text-3);
display: inline-flex;
}
.foot-hint {
margin: 4px 0 0;
font-size: 11.5px;
color: var(--text-3);
text-align: center;
}
.entry-btn.primary {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 9px 18px;
border-radius: 8px;
background: var(--accent);
color: var(--accent-contrast);
font-size: 13px;
font-weight: 600;
text-decoration: none;
}
.entry-btn.primary:hover {
filter: brightness(1.08);
}
@media (max-width: 780px) {
.picker {
padding: 16px 14px 32px;
}
.row {
flex-wrap: wrap;
}
}
</style>
@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = false;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = false;