feat: refine UI, improve UX, optimize the analysis pipeline, and add character standing positions

This commit is contained in:
saturn
2026-04-02 17:39:16 +08:00
parent c3e74c228a
commit 9703714b69
153 changed files with 4472 additions and 1088 deletions

View File

@@ -29,5 +29,9 @@ Rules:
2. Keep continuity across neighboring panels.
3. Adapt to scene_type and story rhythm.
4. Technical notes must be directly actionable by image/video generation.
5. JSON only, no markdown.
6. ⚠️ JSON SAFETY: All quotation marks in text (""''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes " inside string values.
5. If characters already carry `slot`, treat it as a preferred placement anchor, not an absolute boundary.
6. When a panel is about movement, entry/exit, path traversal, transition space, temporary space, empty space, imagination, dream, memory, or abstract/non-literal space, composition and placement may deviate from a static slot if the shot logic requires it.
7. Treat `slot` as one full placement phrase from the location context, not as a short token, whenever you reference it.
8. Do not shorten, rewrite, summarize, or replace a provided `slot` phrase with a short token.
9. JSON only, no markdown.
10. ⚠️ JSON SAFETY: All quotation marks in text (""''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes " inside string values.

View File

@@ -129,8 +129,11 @@
3. 每个元素必须包含 panel_number 字段
4. 使用相对方向(画面左侧/右侧),禁止使用东南西北
5. 角色位置必须与镜头描述一致!
6. 景深根据 shot_type全景/中景/近景/特写)自动调整
7. ⚠️ 对话镜头必须使用浅景深T2.8或更小),并且注明其他人虚化,确保只有说话者脸部清晰
8. 如果镜头涉及不同场景,灯光和色调要相应调整
9. 输出要简洁,每个镜头的规则独立完
10. ⚠️ JSON安全所有引号""''等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 "
6. 如果角色对象中包含 slotscreen_position / posture / facing 应优先参考该位置语义,但 slot 不是绝对硬限制
7. 当镜头属于移动过程、入口/出口、过渡区域、路径空间、临时位置、空镜、想象空间、梦境、回忆或抽象空间时,可以基于镜头描述自由决定构图与位置,不必强行贴合 slot
8. slot 若被引用,必须视为一条完整的位置描述,禁止缩写、改写、总结或替换成短词
9. 景深根据 shot_type全景/中景/近景/特写)自动调
10. ⚠️ 对话镜头必须使用浅景深T2.8或更小),并且注明其他人虚化,确保只有说话者脸部清晰
11. 如果镜头涉及不同场景,灯光和色调要相应调整
12. 输出要简洁,每个镜头的规则独立完整
13. ⚠️ JSON安全所有引号""''等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 "

View File

@@ -33,4 +33,5 @@ Execution rules:
1. Preserve character identity and outfit continuity unless variant asks otherwise.
2. Preserve location continuity.
3. Change framing/angle/composition according to target shot and camera move.
4. Keep one-frame output only, no text overlays.
4. If characters_info or location_asset includes fixed slots / available slots, keep every visible character anchored to the same fixed slot instead of drifting to another area.
5. Keep one-frame output only, no text overlays.

View File

@@ -66,7 +66,8 @@
2. 保持角色外观与参考图一致(服装、发型、体型)
3. 保持场景氛围与参考图一致(室内布置、光线、色调)
4. 改变镜头视角/景别/构图以匹配变体要求
5. 输出图像比例: {aspect_ratio}
5. 如果角色信息或场景参考中提供了固定站位 / 可站位置,必须保持人物仍然处于同一固定站位,不得随意换边、换前后景或漂移到其他区域
6. 输出图像比例: {aspect_ratio}
======================================
【风格要求】

View File

@@ -12,6 +12,8 @@ Location info:
Task:
For each panel, output a complete panel object with improved cinematic detail.
If any character already has `slot`, prefer preserving it exactly and use it as a preferred placement anchor.
Treat `slot` as one full placement phrase from the location context, not as a short token.
Required fields per panel:
- panel_number
@@ -30,7 +32,7 @@ Output schema example (field names must be preserved):
{
"panel_number": 1,
"description": "panel description",
"characters": [{ "name": "Character", "appearance": "appearance" }],
"characters": [{ "name": "Character", "appearance": "appearance", "slot": "the position beneath the throne steps at the center of the hall" }],
"location": "location name",
"scene_type": "daily",
"source_text": "source text excerpt",
@@ -46,5 +48,9 @@ Rules:
2. Keep source_text semantically aligned with input; do not rewrite story meaning.
3. video_prompt should be motion-ready and concrete.
4. Prefer age+gender wording in video_prompt when naming actors in camera directions.
5. Return JSON array only.
6. ⚠️ JSON SAFETY: All quotation marks in text (""''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes " inside string values.
5. Preserve every input `slot` when the character is stably positioned, and reflect it as a preferred anchor in refined description/video_prompt.
6. `slot` is not an absolute boundary. If the shot is clearly about movement, entry/exit, path traversal, transition space, temporary space, empty space, imagination, dream, memory, or abstract/non-literal space, you may remove `slot` or keep it without forcing a rigid static match.
7. When no `slot` is used, decide placement freely from source text, action flow, spatial logic, and cinematic staging.
8. Do not shorten, rewrite, summarize, or replace a provided `slot` phrase with a short token.
9. Return JSON array only.
10. ⚠️ JSON SAFETY: All quotation marks in text (""''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes " inside string values.

View File

@@ -5,6 +5,7 @@
- 为每个分镜设计景别、视角、镜头运动
- 撰写video_prompt用年龄段+性别替代角色名)
- ⚠️ 保留输入分镜中的所有原始字段(特别是 source_text必须原样保留
- 如果输入角色包含 slot应优先原样保留并让 refined description/video_prompt 优先参考该位置
【镜头语言库】
@@ -147,7 +148,7 @@
"camera_move": "固定",
"description": "角色A站在桌前双手撑在桌面上表情严肃地看着对面的角色B",
"video_prompt": "年轻男子站在桌前,双手撑在桌面上,表情严肃,正在说话,镜头固定拍摄",
"characters": [{"name": "角色A", "appearance": "初始形象"}],
"characters": [{"name": "角色A", "appearance": "初始形象", "slot": "皇宫正中龙椅前方台阶下的位置"}],
"location": "办公室",
"scene_type": "daily",
"source_text": "角色A对角色B说你好"
@@ -179,5 +180,9 @@
8. 根据输入的分镜数量动态处理
9. panel_number、characters、location、scene_type保持不变
10. description可以适当优化但不要改变核心内容
11. ⚠️ 必须保留输入分镜中的 source_text 字段,原样输出到结果中,不得遗漏或修改
12. ⚠️ JSON安全所有引号""''等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 "
11. 如果输入中存在 slot应优先保留并优先参考该位置但 slot 不是绝对硬边界
12. 当镜头明显属于移动过程、入口/出口、过渡区域、路径空间、临时位置、空镜、想象空间、梦境、回忆或抽象空间时,可以删除 slot 或保留 slot 但不严格贴合其静态位置
13. 若不使用 slot应根据 source_text、动作过程、空间关系与镜头调度自由决定人物位置不要为了命中 slot 而破坏叙事逻辑
14. slot 若被保留,必须原样保留为完整位置描述,禁止缩写、改写、总结或替换成短词
15. ⚠️ 必须保留输入分镜中的 source_text 字段,原样输出到结果中,不得遗漏或修改
16. ⚠️ JSON安全所有引号""''等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 "

View File

@@ -23,7 +23,7 @@ Output format (single JSON object only):
{
"panel_number": 0,
"description": "visual description",
"characters": [{ "name": "Character Name", "appearance": "appearance name" }],
"characters": [{ "name": "Character Name", "appearance": "appearance name", "slot": "the position beneath the throne steps at the center of the hall" }],
"location": "location name",
"scene_type": "daily",
"source_text": "source text or transition shot",
@@ -37,5 +37,8 @@ Rules:
1. Return one object only (not array).
2. Keep narrative and spatial continuity between previous and next panel.
3. Use valid character and location names from provided context.
4. JSON only, no markdown.
5. ⚠️ JSON SAFETY: All quotation marks in text (""''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes " inside string values.
4. If location details include available slots, treat them as preferred anchors. Reuse a provided `slot` for characters who are stably positioned in the scene.
5. You may omit `slot` when the inserted panel is mainly about movement, entry/exit, path traversal, transition space, temporary space, empty space, imagination, dream, memory, or abstract/non-literal space.
6. If `slot` is used, it must copy one full placement phrase from the available slots list verbatim. Do not shorten or rename it.
7. JSON only, no markdown.
8. ⚠️ JSON SAFETY: All quotation marks in text (""''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes " inside string values.

View File

@@ -54,7 +54,7 @@
|------|------|------|
| panel_number | number | 固定填 0由系统重新编号 |
| description | string | 画面描述:包含角色动作、位置、表情。禁止身份称呼(如"母亲"),使用具体角色名。禁止主观情绪词(如"显得尴尬"),只描述可视化动作。 |
| characters | array | 出现的角色列表,格式:`[{"name": "角色名", "appearance": "形象名"}]`。角色名必须与角色信息中的名字完全一致。形象名从角色信息的形象列表中选择。 |
| characters | array | 出现的角色列表,格式:`[{"name": "角色名", "appearance": "形象名", "slot": "场景位置描述"}]`。角色名必须与角色信息中的名字完全一致。形象名从角色信息的形象列表中选择。如果场景信息中提供了可站位置,应优先为稳定停留的角色选择 slot并直接复用可站位置列表中的完整位置描述。动态移动、过渡区域、入口出口、空镜、想象空间等情况可以不使用 slot。 |
| location | string | 场景名称,必须与场景信息中的名字完全一致 |
| scene_type | string | 场景类型,枚举值:`daily`(日常)/ `emotion`(情感)/ `action`(动作)/ `epic`(史诗)/ `suspense`(悬疑) |
| source_text | string | 对应的原文片段。可以基于前后镜头的 source_text 推断,或填写"过渡镜头" |
@@ -72,6 +72,8 @@
❌ location 使用不存在的场景名 → ✅ 必须与场景信息完全一致
❌ 特写镜头使用非固定的镜头运动 → ✅ 特写必须用"固定"
❌ video_prompt 中使用角色名 → ✅ 必须用年龄段+性别
❌ 稳定停留位置明明适合使用已有 slot却完全无视场景锚点 → ✅ 优先复用场景可站位置中的 slot
❌ 把 slot 改写成短词、代号、缩写 → ✅ 若使用 slot必须直接复制可站位置列表中的完整位置描述
======================================
【输出格式】
@@ -83,7 +85,7 @@
{
"panel_number": 0,
"description": "...",
"characters": [{"name": "...", "appearance": "..."}],
"characters": [{"name": "...", "appearance": "...", "slot": "皇宫正中龙椅前方台阶下的位置"}],
"location": "...",
"scene_type": "...",
"source_text": "...",
@@ -91,3 +93,8 @@
"camera_move": "...",
"video_prompt": "..."
}
补充原则:
- slot 是优先锚点,不是绝对硬边界
- 当新镜头主要表现角色走动、进入/离开、穿过空间、临时停留、空白空间或想象空间时,可以不使用 slot
- 若不使用 slot应根据前后镜头、原文空间关系和过渡逻辑自由决定人物位置

View File

@@ -34,7 +34,7 @@ Output format (JSON array only):
"panel_number": 1,
"description": "visual action description",
"characters": [
{ "name": "Character Name", "appearance": "appearance name" }
{ "name": "Character Name", "appearance": "appearance name", "slot": "the position beneath the throne steps at the center of the hall" }
],
"location": "location name",
"scene_type": "daily",
@@ -52,5 +52,10 @@ Planning rules:
3. Keep panel transitions smooth and logically continuous.
4. Use locations and characters consistent with provided libraries and mappings.
5. Prefer concrete, visible actions over abstract wording.
6. Return strict JSON only.
7. ⚠️ JSON SAFETY: All quotation marks in text (""''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes " inside string values.
6. If location context contains available slots, treat them as preferred placement anchors rather than mandatory hard boundaries.
7. Prefer assigning a `slot` by copying one full placement phrase from `available_slots` verbatim when a character is stably positioned in the scene.
8. You may omit `slot` when the shot is mainly about movement, entry/exit, path traversal, transition space, temporary space, empty space, imagination, dream, memory, or abstract/non-literal space.
9. When no `slot` is used, decide placement freely from source text, action flow, and cinematic staging instead of forcing one of the provided slots.
10. Do not shorten, rewrite, summarize, or replace a provided slot phrase with a short token such as `slot_1`, `left`, or `throne_front`.
11. Return strict JSON only.
12. ⚠️ JSON SAFETY: All quotation marks in text (""''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes " inside string values.

View File

@@ -26,7 +26,7 @@
2. 每个分镜必须包含:
- panel_number: 分镜序号1, 2, 3...
- description: 画面描述(人物动作、场景元素、构图要点)
- characters: [{name: "角色名", appearance: "形象名"}]
- characters: [{name: "角色名", appearance: "形象名", slot: "场景固定位置描述"}]
- location: 场景名称(从资产库选择)
- scene_type: daily/emotion/action/epic/suspense
- source_text: 对应原文片段 ⚠️ 必填,不得为空
@@ -317,10 +317,13 @@ Clip信息
4. 只返回JSON数组不得有其他文字
5. ⚠️ source_text 必填不得为空或null
6. 空间关系必须清晰(朝向、阻挡、位置)
7. 镜头连续性:前后镜头要有动作承接
8. 禁止身份称呼:必须使用资产库中的具体名字
9. 禁止主观情绪词:只描述可视化动作和状态
10. 禁止长句单镜头:包含逗号分隔多个动作/对话的长句必须拆分
11. 对话必须拆分:每段对话至少 2 个镜头(说话者 + 听者反应)
12. ⚠️ 镜头合理性:只描述当前镜头**实际能拍摄到**的角色,特写/反打等拍不到的可省略
13. ⚠️ JSON安全原文中的所有引号""''「」等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 "
7. 如果场景描述中提供了可站位置列表,应将其视为优先位置锚点,而不是强制覆盖所有可能位置的硬边界
8. 当角色在场景中稳定停留时,优先从可站位置列表中选择 slotslot 字段的值必须直接复制完整位置描述,禁止缩写、改写、总结或替换成短词
9. 当镜头主要表现角色移动、进入/离开、穿过空间、过渡区域、路径空间、临时空间、空白空间、想象/梦境/回忆/抽象空间时,可以不使用 slot并根据原文和镜头逻辑自由决定位置
10. 镜头连续性:前后镜头要有动作承接
11. 禁止身份称呼:必须使用资产库中的具体名字
12. 禁止主观情绪词:只描述可视化动作和状态
13. 禁止长句单镜头:包含逗号分隔多个动作/对话的长句必须拆分
14. 对话必须拆分:每段对话至少 2 个镜头(说话者 + 听者反应)
15. ⚠️ 镜头合理性:只描述当前镜头**实际能拍摄到**的角色,特写/反打等拍不到的可省略
16. ⚠️ JSON安全原文中的所有引号""''「」等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 "

View File

@@ -7,13 +7,23 @@ User request:
Rules:
1. Output in English only.
2. Start with scene name in this format: "[Scene Name] ..."
3. Describe a wide, clear environment with spatial layout and key objects.
4. Mention lighting direction and atmosphere.
5. No protagonist actions or dialogue.
6. If crowd is implied by context, use generic crowd terms only (guests, pedestrians, audience).
3. Describe a wide, complete environment with controllable spatial layout, key structures, and visible depth.
4. Make foreground, midground, and background explicit.
5. Define at least 3 clear anchor objects or anchor areas and make the nearby open space visible.
6. If the user input is generic, such as 「classroom」 or 「office」, proactively make it specific enough for stable image layout instead of staying generic.
7. Mention lighting direction and atmosphere.
8. No protagonist actions or dialogue.
9. If crowd is implied by context, use generic crowd terms only (guests, pedestrians, audience).
10. Also generate 2-6 fixed `available_slots` as complete descriptive placement phrases tied to concrete scene anchors.
11. Do not mention posture, action, or emotion in `available_slots`. Describe position only.
12. Every anchor mentioned in `available_slots` must appear clearly in the scene prompt.
Output format:
Return JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:
{
"prompt": "[Scene Name] environment description"
"prompt": "[Scene Name] environment description",
"available_slots": [
"the position beside the left edge of the dining table",
"the open space just inside the doorway against the wall"
]
}

View File

@@ -2,7 +2,7 @@
【场景生成要求(用于出图,中文描述)】
1. 生成1条中文环境描述60-120字像真实摄影场景一样描述
1. 生成1条中文环境描述80-140字像真实摄影场景一样描述,必须足够具体到可以稳定控制画面布局
2. **开头必须明确写明场景名称**
- 描述开头必须以"【场景名称】"的形式标注空间属性
@@ -14,6 +14,10 @@
- 材质要具体(深棕色实木地板、青灰色石砖墙、做旧铁艺栏杆)
- 物品要有使用痕迹和生活气息(桌上散落的书籍、墙角堆放的杂物、窗台晒干的植物)
- 光线要写清楚来源和效果(午后阳光斜照进来在地板上拉出长影、暖黄色壁灯打在墙面上)
- 必须写出完整空间结构,不要只写泛场景名词
- 必须写出前景/中景/背景或近处/中部/远处层次
- 必须写出至少3个清晰可见的关键锚点及其周边空位
- 如果用户输入很泛(如「学校教室」「办公室」),你必须主动把它具体化为可控构图,而不是停留在泛描述
4. 禁止:不写主角人物具体动作、不写画风、不写"温馨""优雅"等抽象词
@@ -22,9 +26,20 @@
- 人群描述示例:"大厅中宾客三两成群"、"街道上行人往来"、"座位上零散坐着几位观众"
- 如果是私密空间或用户明确要求空镜,则不添加人群
6. 额外生成 2-6 个该场景的固定可站位置:
- 每个位置必须是一条完整的位置描述短语,而不是短词
- 每个位置必须依附于明确的场景锚物或区域
- 位置描述中禁止写人物姿态、动作、情绪,只写空间位置
- 这些位置里提到的锚点必须在场景描述中真实出现
- 示例:饭桌左侧靠桌边的位置、教室后排靠窗那组课桌外侧的位置、皇宫正中龙椅前方台阶下的位置
以下是用户的生成指令:{user_input}
只返回以下json格式禁止返回一切除json以外的多余内容。⚠ 所有引号(""''等)在 JSON 字符串值中必须替换为「」,严禁出现未转义的英文双引号 "。
{
"prompt":"「场景名称」场景描述内容"
"prompt":"「场景名称」场景描述内容",
"available_slots":[
"饭桌左侧靠桌边的位置",
"门口内侧靠墙的位置"
]
}

View File

@@ -18,9 +18,18 @@ Rules:
2. Return one complete updated description in English.
3. Keep scene name at the beginning: "[{location_name}] ..."
4. No protagonist actions or story narration.
5. Keep the scene spatially specific, with visible structure, depth, and stable anchors.
6. Also regenerate 2-6 `available_slots` that match the updated scene.
7. Each `available_slots` item must be one complete descriptive placement phrase, not a short token and not an object.
8. Do not mention posture, action, or emotion in `available_slots`.
9. Every anchor mentioned in `available_slots` must also appear clearly in the updated scene description.
Output format:
Return JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:
{
"prompt": "updated location description"
"prompt": "updated location description",
"available_slots": [
"the outer-side position beside the rear window desks",
"the open floor directly below the center of the blackboard"
]
}

View File

@@ -27,10 +27,20 @@
6. 保留未被修改的原有特征
7. 遵循以下描述规范:
- 只描述场景本身,禁止描述人物
- 使用中文输出,长度 50-100 字
- 使用中文输出,长度 80-140 字
- 必须让空间结构、关键锚点、前后层次具体可见,不能退化成泛场景描述
- 若用户修改后引入新的关键锚点或删掉旧锚点,描述必须同步更新
8. 同时重新生成 2-6 个固定可站位置,且必须与更新后的场景描述一致
9. 每个可站位置必须是一条完整的位置描述短语,不是短词,不是对象
10. 可站位置中禁止写人物姿态、动作、情绪,只写位置
11. 可站位置里提到的关键锚点必须在更新后的场景描述中明确出现
【输出格式】
只返回JSON格式禁止返回任何其他内容。⚠ 所有引号(""''等)在 JSON 字符串值中必须替换为「」,严禁出现未转义的英文双引号 "
{
"prompt": "「场景名」更新后的完整场景描述"
"prompt": "「场景名」更新后的完整场景描述",
"available_slots":[
"教室后排靠窗那组课桌外侧的位置",
"讲台前方黑板正下方的位置"
]
}

View File

@@ -16,9 +16,19 @@ Rules:
3. Output in English only.
4. Start with scene name: "[{location_name}] ..."
5. No protagonist actions, dialogue, or narrative plot.
6. Make the scene spatially specific enough for controllable image generation, with visible structure, depth, and stable anchors.
7. Keep at least 3 clear anchor objects or anchor areas visible in the modified scene.
8. Regenerate 2-6 `available_slots` that stay consistent with the modified scene.
9. Each `available_slots` item must be one complete descriptive placement phrase, not a short token and not an object.
10. Do not mention posture, action, or emotion in `available_slots`.
11. Every anchor mentioned in `available_slots` must also appear in the modified scene description.
Output format:
Return JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:
{
"prompt": "modified location description"
"prompt": "modified location description",
"available_slots": [
"the position beneath the throne steps at the center of the hall",
"the open space between the left column and the long table"
]
}

View File

@@ -45,6 +45,9 @@
* ✅ 好:"木质书桌"/"灰色布艺沙发"/"白色窗帘"
* ❌ 差:"优雅的家具"/"舒适的环境"/"温馨的氛围"
- 描述固定元素,不写主角人物具体动作、情绪
- 必须让空间结构足够具体,不能只剩泛场景标签
- 必须让前景/中景/背景或近处/中部/远处关系清楚
- 必须保留至少 3 个清晰可见、可供后续落位的锚点或区域
- 【人群处理规则】如果用户要求添加人群,或场景本身暗示有人群(如宴会、集市等):
* 可以加入模糊人群描述:\"人群\"、\"宾客\"、\"路人\"等
* 示例:\"大厅远处三两宾客交谈\"、\"街角有行人匆匆走过\"
@@ -54,11 +57,22 @@
你的目标是根据用户的修改指令,在原有场景描述的基础上进行修改
同时你必须重新生成该场景的固定可站位置:
- 输出 2-6 个站位
- 每个站位必须是一条完整的位置描述短语,不是短词
- 每个站位必须依附于明确场景锚物或区域
- 站位中禁止写人物姿态、动作、情绪,只写位置
- 新站位必须与修改后的场景描述一致,且其中提到的锚点必须在场景描述中出现
当前场景描述:{location_input}
用户的修改指令:{user_input}
发送json格式给我只返回以下json格式禁止返回一切除json以外的多余内容注释文字等等只返回无任何markdown标识符的纯净json格式。⚠ 所有引号(""''等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 "。json格式如下
{
"prompt":"「场景名」xxxxx"
"prompt":"「场景名」xxxxx",
"available_slots":[
"皇宫正中龙椅前方台阶下的位置",
"左侧立柱与长案之间的空位"
]
}

View File

@@ -12,7 +12,11 @@ Requirements:
2. Keep the scene name prefix in each line: "[{location_name}] ..."
3. Output in English only.
4. Keep environment-only description (no protagonist actions).
5. Keep each variant concise and image-generation friendly.
5. Keep each variant specific enough for controllable image generation, with visible structure, depth, and stable anchors.
6. Also generate 2-6 shared `available_slots` for this location.
7. Each `available_slots` item must be one complete descriptive placement phrase, not a short token and not an object.
8. Do not mention posture, action, or emotion in `available_slots`.
9. Every anchor mentioned in `available_slots` must remain valid across all three regenerated descriptions.
Output format:
Return JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:
@@ -21,5 +25,9 @@ Return JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to c
"[{location_name}] variant 1",
"[{location_name}] variant 2",
"[{location_name}] variant 3"
],
"available_slots": [
"the position beneath the throne steps at the center of the hall",
"the position against the inner wall beside the rear doorway"
]
}

View File

@@ -20,6 +20,8 @@
- 材质细节:地面、墙面、物体的材质质感
- 环境元素:植物、天气、装饰物等
- 独特标识:该场景的标志性元素或特殊物件
- 至少 3 个可供后续固定人物位置的稳定锚点或区域
- 每个锚点周边可落位的空白区域
5. 描述规范:
- 禁止写主角人物具体动作、剧情
@@ -31,11 +33,23 @@
- 【年代一致性】根据场景特征判断年代,建筑、装饰、物品必须符合该年代特征
- 【时间一致性】如场景名包含"白天/黑夜/黄昏"等,描述中的光影必须匹配
6. 额外输出 2-6 个固定可站位置:
- 与该场景的共通构图一致
- 每个站位必须是一条完整的位置描述短语,不是短词,不是对象
- 依附于明确场景锚物或区域
- 禁止抽象站位
- 禁止写人物姿态、动作、情绪
- 站位中提到的锚点必须在三条 descriptions 中都成立
【输出格式】只返回以下 JSON不要任何其他内容。⚠ 所有引号(""''等)在 JSON 字符串值中必须替换为「」,严禁出现未转义的英文双引号 "。
{
"descriptions": [
"「场景名」新描述180-150字",
"「场景名」新描述280-150字",
"「场景名」新描述380-150字"
],
"available_slots": [
"皇宫正中龙椅前方台阶下的位置",
"右后方殿门内侧靠墙的位置"
]
}

View File

@@ -17,9 +17,17 @@ For each selected location, generate 3 wide-angle environment descriptions.
Each description should:
- start with location name in brackets: "[Location Name] ..."
- describe spatial layout, depth layers, major objects, and lighting direction
- define at least 3 stable anchor objects or anchor areas that can support later character placement
- make the usable open space around those anchors visually clear
- remain environment-only (no named protagonist actions)
- use concise, production-ready English
If the source location is generic, such as 「classroom」, 「office」, or 「living room」, you must proactively make it specific enough for controllable image generation:
- define the visible main structure
- define foreground, midground, and background
- define at least 3 stable anchor areas and nearby open space
- avoid vague noun piles
Output format (JSON only):
{
"locations": [
@@ -28,6 +36,11 @@ Output format (JSON only):
"summary": "short usage summary",
"has_crowd": false,
"crowd_description": "",
"available_slots": [
"the position beneath the throne steps at the center of the palace hall",
"the open space between the left column and the long table",
"the position against the inner wall beside the rear doorway"
],
"descriptions": [
"[location_name] description 1",
"[location_name] description 2",
@@ -40,5 +53,10 @@ Output format (JSON only):
Strict constraints:
1. JSON only.
2. If no valid location exists, return: {"locations":[]}.
3. ⚠️ JSON SAFETY: All quotation marks in text (""''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes " inside string values.
3. Each location must include 2-6 `available_slots`.
4. Each `available_slots` item must be a complete descriptive placement phrase, not a short token and not an object.
5. Slot descriptions must be concrete and reusable, tied to visible anchors such as the outer side of a desk row, the open floor before the blackboard, the inner side of a doorway, the space beside a window wall.
6. Do not mention character posture, action, or emotion inside `available_slots`. Describe position only.
7. Every anchor mentioned in `available_slots` must also appear clearly in the location descriptions.
8. ⚠️ JSON SAFETY: All quotation marks in text (""''「」 etc.) MUST be converted to corner brackets「」in JSON string values. NEVER use raw ASCII double quotes " inside string values.

View File

@@ -72,9 +72,15 @@
- 使用明确的位置词:左侧/右侧/中央/角落/靠窗/远处
- 描述物体之间的空间关系和前后层次
- 5-8件物体每件都有位置说明
- 至少 2-3 个后续可作为人物落位锚点的关键物体或区域必须被明确写出,如桌边、门内侧、窗下墙边、讲台前、龙椅前台阶
**光线方向**:光从哪个方向照入,照亮哪些区域
**可落位空间**(必须体现):
- 必须说明哪些区域留有可供人物站立或出现的空白空间
- 这些空白区域必须与关键锚点相邻,便于后续固定人物位置
- 禁止把所有锚点都塞满家具或遮挡物,导致后续无法落人
5. 描述规范:
- 强调位置关系词:前方、远处、左侧、角落、靠近、深处
- 长度 100-150 字
@@ -108,6 +114,12 @@
8.如无特殊要求,使用用户输入的语言来进行场景生成,例如输入英文输出偏西方场景,中文则输出偏中国场景,但是原则要按照文字剧本里实际发生的地点为准,
9. 如果原文或用户输入过于泛化(如「学校教室」「办公室」「客厅」),你必须主动将其具体化为可控画面的完整空间:
- 明确主视角下能看到的关键结构
- 明确前景/中景/背景
- 明确至少 3 个稳定锚点及其周边空位
- 禁止只输出泛泛的场景名词堆砌
【输出规范(只允许以下 JSON 结构;字段名中文;不得输出任何多余文字)】
{
"locations": [
@@ -116,6 +128,11 @@
"summary": "场景简要说明(用途/人物关联,如:张三居住的主卧室、公司高层会议室等)",
"has_crowd": true/false,
"crowd_description": "人群类型描述仅当has_crowd为true时填写宴会宾客、集市人群、学生们等",
"available_slots": [
"皇宫正中龙椅前方台阶下的位置",
"左侧立柱与长案之间的空位",
"右后方殿门内侧靠墙的位置"
],
"descriptions": [
"「场景名」场景环境描述1如has_crowd为true则包含人群元素",
"「场景名」场景环境描述2",
@@ -127,6 +144,11 @@
【严格性】
- 若无符合条件的场景locations数组返回 []。
- 每个场景必须生成 2-6 个 available_slots且每个站位都必须具体、可复用、与场景内明确锚物相关。
- 每个 available_slots 元素必须是一条完整的位置描述短语,不是短词,不是结构化对象。
- 站位描述必须像「皇宫正中龙椅前方台阶下的位置」「教室后排靠窗那组课桌外侧的位置」这样,直接说明锚物、方位和具体区域。
- 禁止抽象站位,如「左边」「中间」「角落」;禁止写人物姿态、动作、情绪;只描述位置本身。
- available_slots 中提到的所有关键锚点,必须在 descriptions 中清楚出现,否则该站位无效,不能输出。
- 只返回上述 JSON不得输出markdown代码块标记、如```json注释或解释不得添加未定义字段。
- 每条描述必须遵守长度限制100-150字发现超长请自行截断。
- 禁止在 JSON 字符串值中出现英文双引号 "。原文中的所有引号(""''等)必须统一替换为「」。如字符串内确实需要英文双引号,必须转义为 \"

View File

@@ -21,7 +21,10 @@ Style requirement:
Execution rules:
1. Respect panel composition, character placement, and action logic.
2. Use reference images for style/identity consistency only.
3. Repaint the background according to shot type and angle.
4. If storyboard conflicts with source text, keep narrative logic from source text.
5. Keep final visual style consistent with provided references.
2. If storyboard data contains `slot`, treat it as a preferred placement anchor for consistency, not as an absolute restriction.
3. If location data contains `available_slots`, treat them as typical anchor references rather than a complete map of all valid positions.
4. When the panel description, source text, action flow, or scene nature implies movement, entry/exit, path traversal, transition space, temporary space, empty space, or abstract/non-literal space, do not force the character to remain inside an existing slot.
5. Use reference images for style/identity consistency only.
6. Repaint the background according to shot type and angle.
7. If storyboard conflicts with source text, keep narrative logic from source text.
8. Keep final visual style consistent with provided references.

View File

@@ -50,6 +50,10 @@
- 严格按照分镜要求绘制画面
- 禁止添加、删除或重排任何镜头
- 镜头必须与输入完全匹配
- 如果分镜数据中包含角色 slot可将其视为优先位置参考用于保持人物落位一致性但不是绝对硬限制
- 如果场景数据中包含 available_slots应将其理解为典型位置参考而不是完整空间边界
- 当镜头描述、原文空间关系、动作过程或场景性质表明人物正在移动、处于过渡区域、入口出口、路径空间、临时空间、空白空间或想象/梦境/回忆/抽象空间时,不要强行把人物锁死在现有 slot 中
- 最终以镜头叙事正确、空间关系自然、人物位置合理为最高原则
【分镜数据】
{storyboard_text_json_input}

View File

@@ -2,6 +2,7 @@
"title": "From Inspiration to Screen",
"subtitle": "Describe your story and let AI generate cinematic short dramas",
"inputPlaceholder": "Enter your story idea, novel excerpt, or script outline...",
"defaultProjectName": "New Project {timestamp}",
"startCreation": "Start Creating",
"recentProjects": "Recent Projects",
"viewAll": "View All Projects",

View File

@@ -346,7 +346,22 @@
"actingNotes": "Acting Direction (acting_notes)",
"actingTitle": "Acting Direction",
"actingDescription": "Performance Notes",
"noActingData": "No acting data"
"noActingData": "No acting data",
"appearance": "Appearance",
"appearanceReadonly": "Appearance (read-only)",
"slot": "Slot",
"slotUnset": "Unset",
"framePosition": "Frame Position",
"screenPosition": "Screen Position",
"actingGuide": "Acting Direction",
"photoEnv": "Photography Settings",
"availableSlots": "Available Slots",
"optional": "Optional",
"reference": "Reference",
"aiRequired": "AI Required",
"characterDetails": "Character Details",
"shotAndScene": "Shot · Scene",
"jsonCheck": "JSON Review"
},
"insertModal": {
"insertBetween": "Insert between #{before} and #{after}",

View File

@@ -23,6 +23,11 @@
"createFailed": "Failed to create project",
"analysisModelRequiredAfterCreate": "Project created. Please configure default models in Profile first (at minimum an analysis model), or the project cannot be used.",
"updateFailed": "Failed to update project",
"validation": {
"nameRequired": "Project name is required.",
"nameTooLong": "Project name cannot exceed 100 characters.",
"descriptionTooLong": "Project description cannot exceed 500 characters."
},
"deleteFailed": "Failed to delete project",
"totalProjects": "{count} projects in total",
"statsEpisodes": "Episodes",

View File

@@ -2,6 +2,7 @@
"title": "从灵感到银幕",
"subtitle": "描述你想要创作的故事AI 为你智能生成影视短剧",
"inputPlaceholder": "输入你的故事创意、小说片段或剧本大纲...",
"defaultProjectName": "新项目 {timestamp}",
"startCreation": "开始创作",
"recentProjects": "最近项目",
"viewAll": "查看全部项目",

View File

@@ -346,7 +346,22 @@
"actingNotes": "演技指导 (acting_notes)",
"actingTitle": "演技指导",
"actingDescription": "表演指令",
"noActingData": "无演技数据"
"noActingData": "无演技数据",
"appearance": "外貌描述",
"appearanceReadonly": "外貌(只读)",
"slot": "站位slot",
"slotUnset": "未指定",
"framePosition": "画面站位",
"screenPosition": "屏幕位置",
"actingGuide": "表演指导",
"photoEnv": "摄影环境",
"availableSlots": "场景可用位置",
"optional": "可选",
"reference": "参考",
"aiRequired": "AI 必读",
"characterDetails": "角色详情",
"shotAndScene": "镜头 · 场景",
"jsonCheck": "JSON 核查"
},
"insertModal": {
"insertBetween": "在 #{before} 和 #{after} 之间插入",

View File

@@ -23,6 +23,11 @@
"createFailed": "创建项目失败",
"analysisModelRequiredAfterCreate": "项目已创建。请先前往个人设置配置默认模型(至少设置分析模型),否则无法使用。",
"updateFailed": "更新项目失败",
"validation": {
"nameRequired": "项目名称不能为空。",
"nameTooLong": "项目名称不能超过 100 个字符。",
"descriptionTooLong": "项目描述不能超过 500 个字符。"
},
"deleteFailed": "删除项目失败",
"totalProjects": "共 {count} 个项目",
"statsEpisodes": "章节数",

View File

@@ -0,0 +1,5 @@
ALTER TABLE location_images
ADD COLUMN availableSlots TEXT NULL;
ALTER TABLE global_location_images
ADD COLUMN availableSlots TEXT NULL;

View File

@@ -58,6 +58,7 @@ model LocationImage {
locationId String
imageIndex Int
description String? @db.Text
availableSlots String? @db.Text
imageUrl String? @db.Text
isSelected Boolean @default(false)
createdAt DateTime @default(now())
@@ -899,6 +900,7 @@ model GlobalLocationImage {
locationId String
imageIndex Int
description String? @db.Text
availableSlots String? @db.Text
imageUrl String? @db.Text
imageMediaId String?
imageMedia MediaObject? @relation("GlobalLocationImageMedia", fields: [imageMediaId], references: [id], onDelete: SetNull)

View File

@@ -58,6 +58,7 @@ model LocationImage {
locationId String
imageIndex Int
description String?
availableSlots String?
imageUrl String?
isSelected Boolean @default(false)
createdAt DateTime @default(now())
@@ -736,6 +737,7 @@ model GlobalLocationImage {
locationId String
imageIndex Int
description String?
availableSlots String?
imageUrl String?
imageMediaId String?
imageMedia MediaObject? @relation("GlobalLocationImageMedia", fields: [imageMediaId], references: [id], onDelete: SetNull)

View File

@@ -17,6 +17,7 @@ import { Link, useRouter } from '@/i18n/navigation'
import { apiFetch } from '@/lib/api-fetch'
import { expandHomeStory } from '@/lib/home/ai-story-expand'
import { createHomeProjectLaunch } from '@/lib/home/create-project-launch'
import { formatDefaultProjectTimestamp } from '@/lib/projects/default-name'
import { HOME_QUICK_START_MIN_ROWS } from '@/lib/ui/textarea-height'
import AiWriteModal from '@/components/home/AiWriteModal'
@@ -52,6 +53,7 @@ export default function HomePage() {
const [artStyle, setArtStyle] = useState('american-comic')
const [stylePresetValue, setStylePresetValue] = useState<string>(DEFAULT_STYLE_PRESET_VALUE)
const [createLoading, setCreateLoading] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [aiWriteOpen, setAiWriteOpen] = useState(false)
const [aiWriteLoading, setAiWriteLoading] = useState(false)
@@ -92,12 +94,15 @@ export default function HomePage() {
// 创建项目并跳转
const handleCreate = async () => {
if (!inputValue.trim() || createLoading) return
setCreateError(null)
setCreateLoading(true)
try {
const storyText = inputValue.trim()
const result = await createHomeProjectLaunch({
apiFetch,
projectName: storyText.slice(0, 50),
projectName: t('defaultProjectName', {
timestamp: formatDefaultProjectTimestamp(new Date()),
}),
storyText,
videoRatio,
artStyle,
@@ -107,7 +112,7 @@ export default function HomePage() {
router.push(result.target)
} catch (error) {
const message = error instanceof Error ? error.message : t('createFailed')
window.alert(message)
setCreateError(message)
} finally {
setCreateLoading(false)
}
@@ -245,9 +250,15 @@ export default function HomePage() {
<StoryInputComposer
value={inputValue}
onValueChange={setInputValue}
onValueChange={(nextValue) => {
setInputValue(nextValue)
if (createError) {
setCreateError(null)
}
}}
placeholder={t('inputPlaceholder')}
minRows={HOME_QUICK_START_MIN_ROWS}
textareaClassName="px-0 pt-0 pb-3 align-top"
videoRatio={videoRatio}
onVideoRatioChange={setVideoRatio}
ratioOptions={ratioOptions}
@@ -286,6 +297,11 @@ export default function HomePage() {
</span>
</button>
)}
footer={createError ? (
<p className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-600">
{createError}
</p>
) : null}
/>
</div>
</div>

View File

@@ -42,13 +42,11 @@ function NovelPromotionWorkspaceContent(props: NovelPromotionWorkspaceProps) {
const showStoryToScriptMinBadge =
storyToScriptStream.isVisible &&
storyToScriptStream.stages.length > 0 &&
storyToScriptActive &&
vm.execution.storyToScriptConsoleMinimized
const showScriptToStoryboardMinBadge =
scriptToStoryboardStream.isVisible &&
scriptToStoryboardStream.stages.length > 0 &&
scriptToStoryboardActive &&
vm.execution.scriptToStoryboardConsoleMinimized

View File

@@ -9,6 +9,7 @@ import { useTranslations } from 'next-intl'
import { useState, useRef, useEffect, useCallback } from 'react'
import '@/styles/animations.css'
import AiWriteModal from '@/components/home/AiWriteModal'
import LongTextDetectionPrompt from '@/components/story-input/LongTextDetectionPrompt'
import StoryInputComposer from '@/components/story-input/StoryInputComposer'
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
import TaskStatusInline from '@/components/task/TaskStatusInline'
@@ -197,7 +198,7 @@ export default function NovelInputStage({
stylePresetValue={stylePresetValue}
onStylePresetChange={setStylePresetValue}
stylePresetOptions={STYLE_PRESETS}
textareaClassName="text-base p-5 pb-3"
textareaClassName="px-0 pt-0 pb-3 align-top"
primaryAction={(
<button
onClick={handleStartClick}
@@ -285,88 +286,29 @@ export default function NovelInputStage({
</div>
)}
{/* 长文本检测 — 智能分集强引导弹窗 */}
{showLongTextPrompt && (
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 backdrop-blur-sm">
<div className="w-full max-w-lg mx-4 relative">
{/* 渐变描边外壳 */}
<div
className="rounded-2xl p-[1.5px]"
style={{ background: 'linear-gradient(135deg, #3b82f6, #8b5cf6, #06b6d4)' }}
>
<div className="glass-surface-modal rounded-2xl p-6 space-y-5">
{/* 标题行 */}
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.15), rgba(139,92,246,0.15))' }}
>
<AppIcon name="sparkles" className="w-5 h-5 text-[#7c3aed]" />
</div>
<h3 className="text-lg font-bold text-[var(--glass-text-primary)]">
{t('storyInput.longTextDetection.title')}
</h3>
</div>
{/* 描述 */}
<p className="text-sm text-[var(--glass-text-secondary)] leading-relaxed">
{t('storyInput.longTextDetection.description', { count: localText.trim().length.toLocaleString() })}
</p>
{/* 强烈推荐文案 */}
<div
className="p-4 rounded-xl text-sm leading-relaxed"
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.08), rgba(139,92,246,0.08))' }}
>
<p
className="font-semibold"
style={{
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
<LongTextDetectionPrompt
open={showLongTextPrompt}
copy={{
title: t('storyInput.longTextDetection.title'),
description: t('storyInput.longTextDetection.description', {
count: localText.trim().length.toLocaleString(),
}),
strongRecommend: t('storyInput.longTextDetection.strongRecommend'),
smartSplitLabel: t('storyInput.longTextDetection.smartSplit'),
smartSplitBadge: t('storyInput.longTextDetection.smartSplitRecommend'),
continueLabel: t('storyInput.longTextDetection.continueAnyway'),
continueHint: t('storyInput.longTextDetection.singleEpisodeWarning'),
}}
>
{t('storyInput.longTextDetection.strongRecommend')}
</p>
</div>
{/* 按钮区域 */}
<div className="flex flex-col gap-3 pt-1">
{/* 智能分集 — 主按钮 */}
<button
onClick={() => {
onClose={() => setShowLongTextPrompt(false)}
onSmartSplit={() => {
setShowLongTextPrompt(false)
onSmartSplit?.(localText)
}}
className="w-full py-3.5 rounded-xl text-white font-semibold text-base flex items-center justify-center gap-2 transition-all hover:opacity-90 active:scale-[0.98]"
style={{ background: 'linear-gradient(135deg, #3b82f6, #7c3aed)' }}
>
<AppIcon name="sparkles" className="w-5 h-5" />
<span>{t('storyInput.longTextDetection.smartSplit')}</span>
<span className="text-xs bg-white/20 px-2 py-0.5 rounded-full">
{t('storyInput.longTextDetection.smartSplitRecommend')}
</span>
</button>
{/* 直接创作 — 弱化按钮 */}
<button
onClick={() => {
onContinue={() => {
setShowLongTextPrompt(false)
onNext()
}}
className="w-full py-2.5 text-sm text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)] transition-colors"
>
{t('storyInput.longTextDetection.continueAnyway')}
<span className="text-xs ml-1 opacity-60">
{t('storyInput.longTextDetection.singleEpisodeWarning')}
</span>
</button>
</div>
</div>
</div>
</div>
</div>
)}
/>
</div>
)
}

View File

@@ -21,7 +21,7 @@ export interface PanelEditData {
cameraMove: string | null
description: string | null
location: string | null
characters: { name: string; appearance: string }[]
characters: { name: string; appearance: string; slot?: string }[]
srtStart: number | null
srtEnd: number | null
duration: number | null
@@ -75,7 +75,7 @@ export default function PanelEditForm({
interface CharacterPickerModalProps {
projectId: string
currentCharacters: { name: string; appearance: string }[]
currentCharacters: { name: string; appearance: string; slot?: string }[]
onSelect: (charName: string, appearance: string) => void
onClose: () => void
}

View File

@@ -5,6 +5,7 @@ import { SettingsModal, WorldContextModal } from '@/components/ui/ConfigModals'
import WorkspaceTopActions from './WorkspaceTopActions'
import type { NovelPromotionPanel } from '@/types/project'
import type { CapabilitySelections, ModelCapabilities } from '@/lib/model-config-contract'
import { resolveEpisodeStageArtifacts } from '@/lib/novel-promotion/stage-readiness'
interface EpisodeSummary {
id: string
@@ -164,15 +165,23 @@ export default function WorkspaceHeaderShell({
return (
<EpisodeSelector
projectName={projectName}
episodes={sorted.map((ep) => ({
episodes={sorted.map((ep) => {
const stageArtifacts = resolveEpisodeStageArtifacts({
novelText: null,
clips: ep.clips || [],
storyboards: ep.storyboards || [],
voiceLines: [],
})
return {
id: ep.id,
title: ep.name,
summary: ep.description ?? undefined,
status: {
script: ep.clips?.length ? 'ready' as const : 'empty' as const,
visual: ep.storyboards?.some((sb) => sb.panels?.some((panel) => panel.videoUrl)) ? 'ready' as const : 'empty' as const,
script: stageArtifacts.hasScript ? 'ready' as const : 'empty' as const,
visual: stageArtifacts.hasVideo ? 'ready' as const : 'empty' as const,
},
}))}
}
})}
currentId={currentEpisodeId}
onSelect={(id) => onEpisodeSelect?.(id)}
onAdd={onEpisodeCreate}

View File

@@ -65,7 +65,7 @@ export default function WorkspaceRunStreamConsoles({
const showStoryToScriptConsole =
storyToScriptStream.isVisible &&
(storyToScriptStream.stages.length > 0 || !!storyToScriptStream.errorMessage)
(storyToScriptStream.stages.length > 0 || !!storyToScriptStream.errorMessage || storyToScriptActive)
const storyFallbackStatus: LLMStageViewItem['status'] =
storyToScriptStream.status === 'failed' ? 'failed' : 'processing'
const storyToScriptStages = storyToScriptStream.stages.length > 0
@@ -94,7 +94,7 @@ export default function WorkspaceRunStreamConsoles({
storyToScriptSelectedStage?.status === 'processing'
const showScriptToStoryboardConsole =
scriptToStoryboardStream.isVisible &&
(scriptToStoryboardStream.stages.length > 0 || !!scriptToStoryboardStream.errorMessage)
(scriptToStoryboardStream.stages.length > 0 || !!scriptToStoryboardStream.errorMessage || scriptToStoryboardActive)
const storyboardFallbackStatus: LLMStageViewItem['status'] =
scriptToStoryboardStream.status === 'failed' ? 'failed' : 'processing'
const scriptToStoryboardStages = scriptToStoryboardStream.stages.length > 0

View File

@@ -10,6 +10,7 @@ import { useAiCreateProjectLocation, useCreateProjectLocation } from '@/lib/quer
import TaskStatusInline from '@/components/task/TaskStatusInline'
import { resolveTaskPresentationState } from '@/lib/task/presentation'
import { AppIcon } from '@/components/ui/icons'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
interface AddLocationModalProps {
projectId: string
@@ -58,6 +59,7 @@ export default function AddLocationModal({
const [description, setDescription] = useState('')
const [aiInstruction, setAiInstruction] = useState('')
const [artStyle, setArtStyle] = useState('american-comic')
const [availableSlots, setAvailableSlots] = useState<LocationAvailableSlot[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [isAiDesigning, setIsAiDesigning] = useState(false)
const aiDesigningState = isAiDesigning
@@ -87,6 +89,7 @@ export default function AddLocationModal({
userInstruction: aiInstruction,
})
setDescription(data.prompt || '')
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
setAiInstruction('')
} catch (error: unknown) {
if (getErrorStatus(error) === 402) {
@@ -113,6 +116,7 @@ export default function AddLocationModal({
description: description.trim(),
artStyle,
count: locationGenerationCount,
availableSlots,
})
onSuccess()
onClose()

View File

@@ -140,6 +140,7 @@ export default function CharacterCard({
const imageUrlsWithIndex = rawImageUrls
.map((url, idx) => ({ url, originalIndex: idx }))
.filter((item) => !!item.url) as { url: string; originalIndex: number }[]
const generatedImageCount = imageUrlsWithIndex.length
const hasMultipleImages = imageUrlsWithIndex.length > 1
const selectedIndex = appearance.selectedIndex ?? null
@@ -218,22 +219,24 @@ export default function CharacterCard({
<>
<ImageGenerationInlineCountButton
prefix={isGroupTaskRunning ? (
<>
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
</>
) : (
<>
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
</>
)}
suffix={<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{t('image.regenCountSuffix')}</span>}
value={generationCount}
options={getImageGenerationCountOptions('character')}
onValueChange={setGenerationCount}
onClick={() => onRegenerate(generationCount)}
onClick={() => onRegenerate(generatedImageCount)}
disabled={isAppearanceTaskRunning || isAnyTaskRunning || uploadImage.isPending}
ariaLabel={t('image.regenCountAriaLabel')}
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
showCountControl={false}
ariaLabel={t('image.regenCountPrefix')}
className="inline-flex h-6 items-center justify-center rounded-md px-1.5 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
/>
{onUndo && (appearance.previousImageUrl || appearance.previousImageUrls.length > 0) && (
<button

View File

@@ -170,7 +170,7 @@ export default function LocationCard({
const displaySelectionImages = resolveDisplayImageSlots(orderedImages, {
hasRunningTask: isTaskRunning,
requestedCount: generationCount,
requestedCount: generatedImageCount > 1 ? generatedImageCount : generationCount,
})
const displaySlotCount = displaySelectionImages.length
const hasMultipleImages = generatedImageCount > 1
@@ -192,22 +192,24 @@ export default function LocationCard({
<>
<ImageGenerationInlineCountButton
prefix={isGroupTaskRunning ? (
<>
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
</>
) : (
<>
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
</>
)}
suffix={<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{t('image.regenCountSuffix')}</span>}
value={generationCount}
options={getImageGenerationCountOptions('location')}
onValueChange={setGenerationCount}
onClick={() => onRegenerate(generationCount)}
onClick={() => onRegenerate(generatedImageCount)}
disabled={isTaskRunning || isAnyTaskRunning || uploadImage.isPending}
ariaLabel={t('image.regenCountAriaLabel')}
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
showCountControl={false}
ariaLabel={t('image.regenCountPrefix')}
className="inline-flex h-6 items-center justify-center rounded-md px-1.5 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
/>
{onUndo && hasPreviousVersion && (
<button

View File

@@ -1,11 +1,15 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import AIDataModalFormPane from './AIDataModalFormPane'
import AIDataModalPreviewPane from './AIDataModalPreviewPane'
import { createPortal } from 'react-dom'
import { AppIcon } from '@/components/ui/icons'
import GlassButton from '@/components/ui/primitives/GlassButton'
import type { AIDataModalProps } from './AIDataModal.types'
import { useAIDataModalState } from './hooks/useAIDataModalState'
import { AppIcon } from '@/components/ui/icons'
import AIDataModalFormPane from './AIDataModalFormPane'
import AIDataModalPreviewPane from './AIDataModalPreviewPane'
import { lockModalPageScroll } from './modal-scroll-lock'
export type {
AIDataModalProps,
@@ -13,6 +17,7 @@ export type {
PhotographyRules,
ActingCharacter,
ActingNotes,
AIDataCharacter,
} from './AIDataModal.types'
export default function AIDataModal({
@@ -32,6 +37,7 @@ export default function AIDataModal({
onSave,
}: AIDataModalProps) {
const t = useTranslations('storyboard')
const [activeCharIdx, setActiveCharIdx] = useState(0)
const {
shotType,
@@ -78,29 +84,49 @@ export default function AIDataModal({
...(actingNotes.length > 0 ? { acting_notes: actingNotes } : {}),
}
if (!isOpen) return null
useEffect(() => {
if (!isOpen || typeof document === 'undefined') return undefined
return lockModalPageScroll(document)
}, [isOpen])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-[var(--glass-overlay)] backdrop-blur-sm" onClick={onClose} />
if (!isOpen || typeof document === 'undefined') return null
<div className="relative bg-[var(--glass-bg-surface)] rounded-2xl shadow-2xl w-[90vw] max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)]">
return createPortal(
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<div className="glass-overlay absolute inset-0" onClick={onClose} />
<div
className="relative z-10 glass-surface-modal w-full max-w-[920px] flex flex-col overflow-hidden"
style={{ maxHeight: '92vh' }}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[var(--glass-stroke-base)] flex-shrink-0">
<div className="flex items-center gap-3">
<span className="text-2xl" />
<div className="flex h-8 w-8 items-center justify-center rounded-[var(--glass-radius-xs)] bg-[var(--glass-tone-info-bg)] flex-shrink-0">
<AppIcon name="clapperboard" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--glass-text-primary)]">{t('aiData.title')}</h2>
<p className="text-xs text-[var(--glass-text-tertiary)]">{t('aiData.subtitle', { number: panelNumber })}</p>
<h2 className="text-sm font-semibold text-[var(--glass-text-primary)] leading-none">
{t('aiData.title')}
</h2>
<p className="text-[11px] text-[var(--glass-text-tertiary)] mt-0.5">
{t('aiData.subtitle', { number: panelNumber })} · {videoRatio}
</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-[var(--glass-bg-muted)] rounded-lg transition-colors">
<AppIcon name="close" className="w-5 h-5 text-[var(--glass-text-tertiary)]" />
<button
onClick={onClose}
className="glass-btn-base glass-btn-ghost h-7 w-7 flex-shrink-0"
aria-label={t('common.cancel')}
>
<AppIcon name="close" className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-hidden flex">
{/* Body */}
<div className="flex flex-1 min-h-0 overflow-hidden">
<AIDataModalFormPane
t={(key) => t(key as never)}
t={t}
shotType={shotType}
cameraMove={cameraMove}
description={description}
@@ -109,6 +135,8 @@ export default function AIDataModal({
videoPrompt={videoPrompt}
photographyRules={photographyRules}
actingNotes={actingNotes}
activeCharIdx={activeCharIdx}
onActiveCharIdxChange={setActiveCharIdx}
onShotTypeChange={setShotType}
onCameraMoveChange={setCameraMove}
onDescriptionChange={setDescription}
@@ -117,29 +145,34 @@ export default function AIDataModal({
onPhotographyCharacterChange={updatePhotographyCharacter}
onActingCharacterChange={updateActingCharacter}
/>
<AIDataModalPreviewPane
t={(key) => t(key as never)}
t={t}
previewJson={previewJson}
/>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)]">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] hover:bg-[var(--glass-bg-muted)] rounded-lg transition-colors"
>
{t('candidate.cancel')}
</button>
<button
{/* Footer */}
<div className="flex items-center justify-between border-t border-[var(--glass-stroke-base)] px-5 py-3 flex-shrink-0">
<p className="text-[11px] text-[var(--glass-text-tertiary)]">
{characters.map(c => c.name).join('、')}
{location ? ` · ${location}` : ''}
</p>
<div className="flex gap-2">
<GlassButton variant="secondary" size="sm" onClick={onClose}>
{t('common.cancel')}
</GlassButton>
<GlassButton
variant="primary"
size="sm"
onClick={handleSave}
className="px-4 py-2 text-sm text-white bg-[var(--glass-accent-from)] hover:bg-[var(--glass-accent-to)] rounded-lg transition-colors flex items-center gap-2"
iconLeft={<AppIcon name="check" className="h-3.5 w-3.5" />}
>
<AppIcon name="check" className="w-4 h-4" />
{t('aiData.save')}
</button>
</GlassButton>
</div>
</div>
</div>
</div>,
document.body
)
}

View File

@@ -1,5 +1,11 @@
'use client'
export interface AIDataCharacter {
name: string
appearance: string
slot?: string
}
export interface PhotographyCharacter {
name: string
screen_position: string
@@ -47,7 +53,7 @@ export interface AIDataModalProps {
cameraMove: string | null
description: string | null
location: string | null
characters: string[]
characters: AIDataCharacter[]
videoPrompt: string | null
photographyRules: PhotographyRules | null
actingNotes: ActingNotes | ActingCharacter[] | null

View File

@@ -1,21 +1,29 @@
'use client'
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
import type { useTranslations } from 'next-intl'
import GlassInput from '@/components/ui/primitives/GlassInput'
import GlassTextarea from '@/components/ui/primitives/GlassTextarea'
import { AppIcon } from '@/components/ui/icons'
import type {
ActingCharacter,
AIDataCharacter,
PhotographyCharacter,
PhotographyRules,
} from './AIDataModal.types'
interface AIDataModalFormPaneProps {
t: (key: string) => string
t: ReturnType<typeof useTranslations<'storyboard'>>
shotType: string
cameraMove: string
description: string
location: string | null
characters: string[]
characters: AIDataCharacter[]
videoPrompt: string
photographyRules: PhotographyRules | null
actingNotes: ActingCharacter[]
activeCharIdx: number
onActiveCharIdxChange: (idx: number) => void
onShotTypeChange: (value: string) => void
onCameraMoveChange: (value: string) => void
onDescriptionChange: (value: string) => void
@@ -25,6 +33,101 @@ interface AIDataModalFormPaneProps {
onActingCharacterChange: (index: number, field: keyof ActingCharacter, value: string) => void
}
function FL({ children }: { children: string }) {
return <p className="mb-1 text-[10.5px] font-semibold text-[var(--glass-text-tertiary)]">{children}</p>
}
function AutoGrowTextarea({
value,
onChange,
rows,
placeholder,
density = 'default',
className,
}: {
value: string
onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void
rows: number
placeholder?: string
density?: 'compact' | 'default'
className?: string
}) {
const ref = useRef<HTMLTextAreaElement | null>(null)
useEffect(() => {
const el = ref.current
if (!el) return
el.style.height = '0px'
el.style.height = `${el.scrollHeight}px`
}, [value])
return (
<GlassTextarea
ref={ref}
rows={rows}
value={value}
onChange={onChange}
onInput={(event) => {
const el = event.currentTarget
el.style.height = '0px'
el.style.height = `${el.scrollHeight}px`
}}
placeholder={placeholder}
density={density}
className={['overflow-hidden', className].filter(Boolean).join(' ')}
/>
)
}
function SectionLabel({ children }: { children: string }) {
return (
<div className="flex items-center gap-2 mb-2.5">
<AppIcon name="sparkles" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)] flex-shrink-0" />
<span className="text-[11px] font-semibold text-[var(--glass-text-primary)]">{children}</span>
</div>
)
}
function CollapseSection({
label,
iconName,
children,
}: {
label: string
iconName?: 'video' | 'film'
children: React.ReactNode
}) {
const [open, setOpen] = useState(false)
return (
<div className="border border-[var(--glass-stroke-base)] rounded-[var(--glass-radius-xs)] overflow-hidden">
<button
type="button"
onClick={() => setOpen(v => !v)}
className="w-full flex items-center justify-between px-3.5 py-2.5 bg-[var(--glass-bg-muted)] hover:bg-[var(--glass-bg-surface)] transition-colors"
>
<div className="flex items-center gap-2">
{iconName ? (
<AppIcon
name={iconName}
className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)] flex-shrink-0"
/>
) : null}
<span className="text-[11px] font-semibold text-[var(--glass-text-secondary)]">{label}</span>
</div>
<AppIcon
name={open ? 'chevronUp' : 'chevronDown'}
className="h-3.5 w-3.5 text-[var(--glass-text-tertiary)] flex-shrink-0"
/>
</button>
{open && (
<div className="px-3.5 py-3 space-y-3 bg-[var(--glass-bg-surface)]">
{children}
</div>
)}
</div>
)
}
export default function AIDataModalFormPane({
t,
shotType,
@@ -35,6 +138,8 @@ export default function AIDataModalFormPane({
videoPrompt,
photographyRules,
actingNotes,
activeCharIdx,
onActiveCharIdxChange,
onShotTypeChange,
onCameraMoveChange,
onDescriptionChange,
@@ -43,194 +148,257 @@ export default function AIDataModalFormPane({
onPhotographyCharacterChange,
onActingCharacterChange,
}: AIDataModalFormPaneProps) {
const activeChar = characters[activeCharIdx]
const photoChar = photographyRules?.characters.find(c => c.name === activeChar?.name)
const actingCharIdx = actingNotes.findIndex(n => n.name === activeChar?.name)
const actingChar = actingCharIdx >= 0 ? actingNotes[actingCharIdx] : null
return (
<div className="w-1/2 border-r border-[var(--glass-stroke-base)] overflow-y-auto p-6 space-y-5">
<div className="text-sm font-medium text-[var(--glass-text-secondary)] mb-3">{t('aiData.basicData')}</div>
<div className="w-[55%] border-r border-[var(--glass-stroke-base)] overflow-y-auto p-5 space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.shotType')}</label>
<input
type="text"
value={shotType}
onChange={(event) => onShotTypeChange(event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]"
placeholder={t('aiData.shotTypePlaceholder')}
/>
{/* ① 视觉描述 — 最高优先 */}
<section>
<div className="flex items-center gap-2 mb-2.5">
<AppIcon name="fileText" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)] flex-shrink-0" />
<span className="text-[11px] font-semibold text-[var(--glass-text-primary)]">
{t('aiData.visualDescription')}
</span>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.cameraMove')}</label>
<input
type="text"
value={cameraMove}
onChange={(event) => onCameraMoveChange(event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]"
placeholder={t('aiData.cameraMovePlaceholder')}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.scene')}</label>
<div className="px-3 py-2 bg-[var(--glass-bg-muted)] border border-[var(--glass-stroke-base)] rounded-lg text-sm text-[var(--glass-text-secondary)]">
{location || t('aiData.notSelected')}
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.characters')}</label>
<div className="px-3 py-2 bg-[var(--glass-bg-muted)] border border-[var(--glass-stroke-base)] rounded-lg text-sm text-[var(--glass-text-secondary)]">
{characters.length > 0 ? characters.join('、') : t('common.none')}
</div>
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.visualDescription')}</label>
<textarea
value={description}
onChange={(event) => onDescriptionChange(event.target.value)}
<AutoGrowTextarea
rows={3}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm resize-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]"
value={description}
onChange={e => onDescriptionChange(e.target.value)}
placeholder={t('insert.placeholder.description')}
/>
</section>
{/* ② 镜头设置 */}
<section>
<SectionLabel>{t('aiData.shotAndScene')}</SectionLabel>
<div className="grid grid-cols-2 gap-3 mb-2">
<div>
<FL>{t('aiData.shotType')}</FL>
<div className="relative">
<AppIcon name="clapperboard" className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--glass-text-tertiary)]" />
<GlassInput
density="compact"
value={shotType}
onChange={e => onShotTypeChange(e.target.value)}
placeholder={t('aiData.shotTypePlaceholder')}
className="pl-9"
/>
</div>
</div>
<div>
<FL>{t('aiData.cameraMove')}</FL>
<div className="relative">
<AppIcon name="video" className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--glass-text-tertiary)]" />
<GlassInput
density="compact"
value={cameraMove}
onChange={e => onCameraMoveChange(e.target.value)}
placeholder={t('aiData.cameraMovePlaceholder')}
className="pl-9"
/>
</div>
</div>
</div>
{/* 场景 + 比例 — 只读文字,不用 input 避免视觉干扰 */}
{location && (
<div className="flex items-center gap-2 text-[11.5px] text-[var(--glass-text-tertiary)]">
<AppIcon name="imageAlt" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)] flex-shrink-0" />
<span>
{t('aiData.scene').replace('(只读)', '')}<span className="text-[var(--glass-text-secondary)] font-medium">{location}</span>
</span>
</div>
)}
</section>
{/* ③ 角色详情 — tab 切换 */}
{characters.length > 0 && (
<section>
<SectionLabel>{t('aiData.characterDetails')}</SectionLabel>
{/* Tab 按钮 */}
<div className="flex gap-2 mb-3 flex-wrap">
{characters.map((char, i) => (
<button
key={i}
type="button"
onClick={() => onActiveCharIdxChange(i)}
className={[
'flex items-center gap-2 px-3 py-1.5 rounded-[var(--glass-radius-xs)] border text-xs font-semibold transition-all',
activeCharIdx === i
? 'border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]'
: 'border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)] text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]',
].join(' ')}
>
<div className={[
'h-5 w-5 rounded-full flex items-center justify-center flex-shrink-0',
activeCharIdx === i ? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]' : 'bg-[var(--glass-bg-surface)] text-[var(--glass-text-tertiary)]',
].join(' ')}>
<AppIcon name="user" className="h-3 w-3" />
</div>
{char.name}
{char.slot && (
<span className="glass-chip glass-chip-neutral text-[9.5px] inline-flex items-center gap-1">
<AppIcon name="badgeCheck" className="h-3 w-3" />
{char.slot}
</span>
)}
</button>
))}
</div>
{/* 当前角色详情卡 */}
{activeChar && (
<div className="rounded-[var(--glass-radius-sm)] border border-[var(--glass-stroke-focus)] overflow-hidden">
{/* slot 行 */}
<div className="flex items-center gap-2 px-3.5 py-2 bg-[var(--glass-bg-muted)] border-b border-[var(--glass-stroke-base)] flex-wrap">
<AppIcon name="badgeCheck" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)] flex-shrink-0" />
<span className="text-[10.5px] font-semibold text-[var(--glass-text-tertiary)]">
{t('aiData.slot')}
</span>
<span className="glass-chip glass-chip-info text-[10.5px]">
{activeChar.slot ?? t('aiData.slotUnset')}
</span>
</div>
<div className="px-3.5 py-3 space-y-3 bg-[var(--glass-bg-surface)]">
{/* 外貌 — 只读 */}
{activeChar.appearance && (
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.videoPrompt')}</label>
<textarea
<FL>{t('aiData.appearanceReadonly')}</FL>
<div className="flex items-start gap-2 rounded-[var(--glass-radius-xs)] bg-[var(--glass-bg-muted)] px-3 py-2">
<AppIcon name="sparkles" className="mt-0.5 h-3.5 w-3.5 text-[var(--glass-tone-warning-fg)] flex-shrink-0" />
<p className="text-[12px] text-[var(--glass-text-secondary)] leading-relaxed">
{activeChar.appearance}
</p>
</div>
</div>
)}
{/* 画面站位 */}
{photoChar && (
<div>
<FL>{t('aiData.framePosition')}</FL>
<div className="space-y-2">
<div>
<p className="text-[10px] text-[var(--glass-text-tertiary)] mb-1">{t('aiData.screenPosition')}</p>
<GlassInput
density="compact"
value={photoChar.screen_position}
onChange={e => {
const idx = photographyRules!.characters.findIndex(c => c.name === activeChar.name)
if (idx >= 0) onPhotographyCharacterChange(idx, 'screen_position', e.target.value)
}}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<p className="text-[10px] text-[var(--glass-text-tertiary)] mb-1">{t('aiData.posture')}</p>
<GlassInput
density="compact"
value={photoChar.posture}
onChange={e => {
const idx = photographyRules!.characters.findIndex(c => c.name === activeChar.name)
if (idx >= 0) onPhotographyCharacterChange(idx, 'posture', e.target.value)
}}
/>
</div>
<div>
<p className="text-[10px] text-[var(--glass-text-tertiary)] mb-1">{t('aiData.facing')}</p>
<GlassInput
density="compact"
value={photoChar.facing}
onChange={e => {
const idx = photographyRules!.characters.findIndex(c => c.name === activeChar.name)
if (idx >= 0) onPhotographyCharacterChange(idx, 'facing', e.target.value)
}}
/>
</div>
</div>
</div>
</div>
)}
{/* 表演指导 */}
{actingChar && (
<div>
<FL>{t('aiData.actingGuide')}</FL>
<AutoGrowTextarea
density="compact"
rows={2}
value={actingChar.acting}
onChange={e => onActingCharacterChange(actingCharIdx, 'acting', e.target.value)}
/>
</div>
)}
</div>
</div>
)}
</section>
)}
{/* ④ 视频提示词 — 折叠 */}
<CollapseSection label={t('aiData.videoPrompt')} iconName="video">
<AutoGrowTextarea
rows={4}
value={videoPrompt}
onChange={(event) => onVideoPromptChange(event.target.value)}
rows={2}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm resize-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-warning-bg)]"
onChange={e => onVideoPromptChange(e.target.value)}
placeholder={t('panel.videoPromptPlaceholder')}
className="bg-[var(--glass-tone-warning-bg)]"
/>
</div>
</CollapseSection>
{/* ⑤ 摄影环境 — 折叠 */}
{photographyRules && (
<>
<div className="border-t border-[var(--glass-stroke-base)] pt-4 mt-4">
<div className="text-sm font-medium text-[var(--glass-text-secondary)] mb-3">{t('aiData.photographyRules')}</div>
</div>
<CollapseSection label={t('aiData.photoEnv')} iconName="film">
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.summary')}</label>
<input
type="text"
value={photographyRules.scene_summary || ''}
onChange={(event) => onPhotographyFieldChange('scene_summary', event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]"
<FL>{t('aiData.summary')}</FL>
<GlassInput
density="compact"
value={photographyRules.scene_summary}
onChange={e => onPhotographyFieldChange('scene_summary', e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.lightingDirection')}</label>
<input
type="text"
value={photographyRules.lighting?.direction || ''}
onChange={(event) => onPhotographyFieldChange('lighting.direction', event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]"
<FL>{t('aiData.lightingDirection')}</FL>
<GlassInput
density="compact"
value={photographyRules.lighting?.direction ?? ''}
onChange={e => onPhotographyFieldChange('lighting.direction', e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.lightingQuality')}</label>
<input
type="text"
value={photographyRules.lighting?.quality || ''}
onChange={(event) => onPhotographyFieldChange('lighting.quality', event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]"
<FL>{t('aiData.lightingQuality')}</FL>
<GlassInput
density="compact"
value={photographyRules.lighting?.quality ?? ''}
onChange={e => onPhotographyFieldChange('lighting.quality', e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.depthOfField')}</label>
<input
type="text"
value={photographyRules.depth_of_field || ''}
onChange={(event) => onPhotographyFieldChange('depth_of_field', event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]"
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.colorTone')}</label>
<input
type="text"
value={photographyRules.color_tone || ''}
onChange={(event) => onPhotographyFieldChange('color_tone', event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]"
/>
</div>
{photographyRules.characters && photographyRules.characters.length > 0 && (
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-2">{t('aiData.characterPosition')}</label>
<div className="space-y-3">
{photographyRules.characters.map((character, index) => (
<div key={index} className="p-3 bg-[var(--glass-bg-muted)] rounded-lg border border-[var(--glass-stroke-base)]">
<div className="text-xs font-medium text-[var(--glass-tone-info-fg)] mb-2">{character.name}</div>
<div className="grid grid-cols-3 gap-2">
<div>
<label className="block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5">{t('aiData.position')}</label>
<input
type="text"
value={character.screen_position || ''}
onChange={(event) => onPhotographyCharacterChange(index, 'screen_position', event.target.value)}
className="w-full px-2 py-1 border border-[var(--glass-stroke-base)] rounded text-xs"
<FL>{t('aiData.depthOfField')}</FL>
<GlassInput
density="compact"
value={photographyRules.depth_of_field}
onChange={e => onPhotographyFieldChange('depth_of_field', e.target.value)}
/>
</div>
<div>
<label className="block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5">{t('aiData.posture')}</label>
<input
type="text"
value={character.posture || ''}
onChange={(event) => onPhotographyCharacterChange(index, 'posture', event.target.value)}
className="w-full px-2 py-1 border border-[var(--glass-stroke-base)] rounded text-xs"
/>
</div>
<div>
<label className="block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5">{t('aiData.facing')}</label>
<input
type="text"
value={character.facing || ''}
onChange={(event) => onPhotographyCharacterChange(index, 'facing', event.target.value)}
className="w-full px-2 py-1 border border-[var(--glass-stroke-base)] rounded text-xs"
<FL>{t('aiData.colorTone')}</FL>
<GlassInput
density="compact"
value={photographyRules.color_tone}
onChange={e => onPhotographyFieldChange('color_tone', e.target.value)}
/>
</div>
</div>
</div>
))}
</div>
</div>
)}
</>
)}
{actingNotes.length > 0 && (
<>
<div className="border-t border-[var(--glass-stroke-base)] pt-4 mt-4">
<div className="text-sm font-medium text-[var(--glass-text-secondary)] mb-3">{t('aiData.actingNotes')}</div>
</div>
<div className="space-y-3">
{actingNotes.map((character, index) => (
<div key={index} className="p-3 bg-[var(--glass-tone-info-bg)] rounded-lg border border-[var(--glass-stroke-focus)]">
<div className="text-xs font-medium text-[var(--glass-tone-info-fg)] mb-2">{character.name}</div>
<div>
<label className="block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5">{t('aiData.actingDescription')}</label>
<textarea
value={character.acting || ''}
onChange={(event) => onActingCharacterChange(index, 'acting', event.target.value)}
rows={2}
className="w-full px-2 py-1 border border-[var(--glass-stroke-focus)] rounded text-xs resize-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]"
/>
</div>
</div>
))}
</div>
</>
</CollapseSection>
)}
</div>
)

View File

@@ -1,46 +1,89 @@
'use client'
import { useState } from 'react'
import type { useTranslations } from 'next-intl'
import { AppIcon } from '@/components/ui/icons'
import GlassButton from '@/components/ui/primitives/GlassButton'
interface AIDataModalPreviewPaneProps {
t: (key: string) => string
t: ReturnType<typeof useTranslations<'storyboard'>>
previewJson: Record<string, unknown>
}
export default function AIDataModalPreviewPane({
t,
previewJson,
}: AIDataModalPreviewPaneProps) {
return (
<div className="w-1/2 bg-[var(--glass-text-primary)] overflow-y-auto p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-[var(--glass-text-tertiary)]">{t('aiData.jsonPreview')}</span>
<button
onClick={() => {
const text = JSON.stringify(previewJson, null, 2)
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
navigator.clipboard.writeText(text).catch(() => { })
} else {
// HTTP 环境 fallback
export async function copyPreviewJsonText(text: string): Promise<void> {
const clipboardApi = globalThis.navigator?.clipboard
if (clipboardApi && typeof clipboardApi.writeText === 'function') {
try {
await clipboardApi.writeText(text)
return
} catch {
// Fall through to manual copy fallback.
}
}
if (typeof document === 'undefined') {
throw new Error('Clipboard unavailable')
}
const el = document.createElement('textarea')
el.value = text
el.style.position = 'fixed'
el.style.opacity = '0'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
const copied = typeof document.execCommand === 'function' && document.execCommand('copy')
document.body.removeChild(el)
if (!copied) {
throw new Error('Clipboard fallback failed')
}
}}
className="text-xs text-[var(--glass-tone-info-fg)] hover:text-[var(--glass-text-primary)] flex items-center gap-1"
>
<AppIcon name="copy" className="w-3.5 h-3.5" />
{t('common.copy')}
</button>
}
export default function AIDataModalPreviewPane({
t,
previewJson,
}: AIDataModalPreviewPaneProps) {
const [copyState, setCopyState] = useState<'idle' | 'success' | 'error'>('idle')
const handleCopy = async () => {
const text = JSON.stringify(previewJson, null, 2)
try {
await copyPreviewJsonText(text)
setCopyState('success')
} catch {
setCopyState('error')
}
window.setTimeout(() => setCopyState('idle'), 1600)
}
const copyLabel = t('common.copy')
const copyIconName = copyState === 'success' ? 'clipboardCheck' : copyState === 'error' ? 'alert' : 'copy'
return (
<div className="w-[45%] flex flex-col overflow-hidden bg-[var(--glass-bg-muted)]">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] flex-shrink-0">
<div className="flex items-center gap-2">
<AppIcon name="fileText" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)]" />
<span className="text-xs font-medium text-[var(--glass-text-tertiary)]">
{t('aiData.jsonCheck')}
</span>
</div>
<pre className="text-xs text-[var(--glass-tone-success-fg)] font-mono whitespace-pre-wrap break-all">
<GlassButton
size="sm"
variant="ghost"
onClick={handleCopy}
iconLeft={<AppIcon name={copyIconName} className="h-3 w-3" />}
>
{copyLabel}
</GlassButton>
</div>
<div className="flex-1 overflow-y-auto p-4">
<pre className="text-[11px] font-mono leading-relaxed text-[var(--glass-text-secondary)] whitespace-pre-wrap break-all">
{JSON.stringify(previewJson, null, 2)}
</pre>
</div>
</div>
)
}

View File

@@ -64,13 +64,13 @@ export function useStoryboardAiDataRuntime({
const panelData = getPanelEditData(panel)
const photographyRules = parseJsonSafely(panel.photographyRules, 'photographyRules')
const actingNotes = parseJsonSafely(panel.actingNotes, 'actingNotes')
const characterNames = panelData.characters.map((character) => character.name)
const characters = panelData.characters.map((character) => ({ ...character }))
return {
panelData,
panel,
storyboardId: storyboard.id,
characterNames,
characters,
photographyRules,
actingNotes,
}

View File

@@ -20,7 +20,7 @@ export interface StoryboardPanel {
shot_type: string
camera_move: string | null
description: string
characters: { name: string; appearance: string }[]
characters: { name: string; appearance: string; slot?: string }[]
location?: string
srt_range?: string
duration?: number
@@ -112,12 +112,22 @@ export function useStoryboardState({
return sortedPanels.map((p) => {
const parsedChars = p.characters ? JSON.parse(p.characters) : []
const characters = Array.isArray(parsedChars)
? parsedChars.filter((item): item is { name: string; appearance: string } => (
typeof item === 'object'
&& item !== null
&& typeof (item as { name?: unknown }).name === 'string'
&& typeof (item as { appearance?: unknown }).appearance === 'string'
))
? parsedChars.flatMap((item): Array<{ name: string; appearance: string; slot?: string }> => {
if (
typeof item !== 'object'
|| item === null
|| typeof (item as { name?: unknown }).name !== 'string'
|| typeof (item as { appearance?: unknown }).appearance !== 'string'
) {
return []
}
const candidate = item as { name: string; appearance: string; slot?: unknown }
return [{
name: candidate.name,
appearance: candidate.appearance,
slot: typeof candidate.slot === 'string' ? candidate.slot : undefined,
}]
})
: []
return {
id: p.id,

View File

@@ -237,7 +237,7 @@ export default function StoryboardStage({
cameraMove={modalRuntime.aiDataRuntime.panelData.cameraMove}
description={modalRuntime.aiDataRuntime.panelData.description}
location={modalRuntime.aiDataRuntime.panelData.location}
characters={modalRuntime.aiDataRuntime.characterNames}
characters={modalRuntime.aiDataRuntime.characters}
videoPrompt={modalRuntime.aiDataRuntime.panelData.videoPrompt}
photographyRules={modalRuntime.aiDataRuntime.photographyRules}
actingNotes={modalRuntime.aiDataRuntime.actingNotes}

View File

@@ -0,0 +1,23 @@
interface ScrollLockTarget {
style: {
overflow: string
}
}
interface ScrollLockDocumentLike {
body: ScrollLockTarget
documentElement: ScrollLockTarget
}
export function lockModalPageScroll(doc: ScrollLockDocumentLike): () => void {
const previousBodyOverflow = doc.body.style.overflow
const previousHtmlOverflow = doc.documentElement.style.overflow
doc.body.style.overflow = 'hidden'
doc.documentElement.style.overflow = 'hidden'
return () => {
doc.body.style.overflow = previousBodyOverflow
doc.documentElement.style.overflow = previousHtmlOverflow
}
}

View File

@@ -20,6 +20,7 @@ import { useWorkspaceAutoRun } from './useWorkspaceAutoRun'
import { buildWorkspaceControllerViewModel } from './workspace-controller-view-model'
import type { NovelPromotionWorkspaceProps } from '../types'
import { useRouter } from '@/i18n/navigation'
import { resolveEpisodeStageArtifacts } from '@/lib/novel-promotion/stage-readiness'
export function useNovelPromotionWorkspaceController({
project,
@@ -38,7 +39,7 @@ export function useNovelPromotionWorkspaceController({
const { onRefresh } = useWorkspaceProvider()
const projectSnapshot = useWorkspaceProjectSnapshot({ project, episode, urlStage })
const { currentStage, episodeStoryboards, ...projectSection } = projectSnapshot
const { currentStage, ...projectSection } = projectSnapshot
const assetsLoading = false
const assetsLoadingState = assetsLoading
@@ -116,6 +117,11 @@ export function useNovelPromotionWorkspaceController({
execution.storyToScriptStream.isRunning ||
execution.storyToScriptStream.isRecoveredRunning ||
execution.storyToScriptStream.status === 'running'
const isScriptToStoryboardRunning =
execution.scriptToStoryboardStream.isRunning ||
execution.scriptToStoryboardStream.isRecoveredRunning ||
execution.scriptToStoryboardStream.status === 'running'
const stageArtifacts = resolveEpisodeStageArtifacts(episode)
const isAnyOperationRunning =
isStartingStoryToScript ||
@@ -124,8 +130,8 @@ export function useNovelPromotionWorkspaceController({
execution.isAssetAnalysisRunning ||
execution.isConfirmingAssets ||
execution.isTransitioning ||
execution.storyToScriptStream.isRunning ||
execution.scriptToStoryboardStream.isRunning
isStoryToScriptRunning ||
isScriptToStoryboardRunning
useWorkspaceAutoRun({
searchParams,
@@ -140,9 +146,7 @@ export function useNovelPromotionWorkspaceController({
const capsuleNavItems = useWorkspaceStageNavigation({
isAnyOperationRunning,
episode,
projectCharacterCount: projectSnapshot.projectCharacters.length,
episodeStoryboards,
stageArtifacts,
t,
})

View File

@@ -1,15 +1,6 @@
'use client'
import type { NovelPromotionPanel } from '@/types/project'
interface EpisodeLike {
novelText?: string | null
voiceLines?: unknown[] | null
}
interface StoryboardLike {
panels?: NovelPromotionPanel[] | null
}
import type { StageArtifactReadiness } from '@/lib/novel-promotion/stage-readiness'
interface CapsuleNavItem {
id: string
@@ -22,17 +13,13 @@ interface CapsuleNavItem {
interface UseWorkspaceStageNavigationParams {
isAnyOperationRunning: boolean
episode?: EpisodeLike | null
projectCharacterCount: number
episodeStoryboards: StoryboardLike[]
stageArtifacts: StageArtifactReadiness
t: (key: string) => string
}
export function useWorkspaceStageNavigation({
isAnyOperationRunning,
episode,
projectCharacterCount,
episodeStoryboards,
stageArtifacts,
t,
}: UseWorkspaceStageNavigationParams): CapsuleNavItem[] {
const getStageStatus = (stageId: string): 'empty' | 'active' | 'processing' | 'ready' => {
@@ -40,16 +27,16 @@ export function useWorkspaceStageNavigation({
switch (stageId) {
case 'config':
return episode?.novelText ? 'ready' : 'active'
return stageArtifacts.hasStory ? 'ready' : 'active'
case 'assets':
return projectCharacterCount > 0 ? 'ready' : 'empty'
return stageArtifacts.hasScript ? 'ready' : 'empty'
case 'storyboard':
return episodeStoryboards.some((sb) => sb.panels?.length) ? 'ready' : 'empty'
return stageArtifacts.hasStoryboard ? 'ready' : 'empty'
case 'videos':
case 'editor':
return episodeStoryboards.some((sb) => sb.panels?.some((panel) => panel.videoUrl)) ? 'ready' : 'empty'
return stageArtifacts.hasVideo ? 'ready' : 'empty'
case 'voice':
return (episode?.voiceLines?.length || 0) > 0 ? 'ready' : 'empty'
return stageArtifacts.hasVoice ? 'ready' : 'empty'
default:
return 'empty'
}

View File

@@ -9,6 +9,7 @@ import { useImageGenerationCount } from '@/lib/image-generation/use-image-genera
import TaskStatusInline from '@/components/task/TaskStatusInline'
import { resolveTaskPresentationState } from '@/lib/task/presentation'
import { AppIcon } from '@/components/ui/icons'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
interface AddLocationModalProps {
folderId: string | null
@@ -33,6 +34,7 @@ export function AddLocationModal({ folderId, onClose, onSuccess }: AddLocationMo
const [summary, setSummary] = useState('')
const [aiInstruction, setAiInstruction] = useState('')
const [artStyle, setArtStyle] = useState('american-comic')
const [availableSlots, setAvailableSlots] = useState<LocationAvailableSlot[]>([])
const aiDesignMutation = useAiDesignLocation()
const createLocationMutation = useCreateAssetHubLocation()
@@ -63,6 +65,7 @@ export function AddLocationModal({ folderId, onClose, onSuccess }: AddLocationMo
try {
const data = await aiDesignMutation.mutateAsync(aiInstruction.trim())
setSummary(data.prompt || '')
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
setAiInstruction('')
} catch (error) {
_ulogError('AI设计失败:', error)
@@ -80,6 +83,7 @@ export function AddLocationModal({ folderId, onClose, onSuccess }: AddLocationMo
folderId,
artStyle,
count: locationGenerationCount,
availableSlots,
})
onSuccess()
} catch (error) {

View File

@@ -91,7 +91,8 @@ export function CharacterCard({ character, onImageClick, onImageEdit, onVoiceDes
}
const imageUrls = appearance?.imageUrls || []
const hasMultipleImages = imageUrls.filter(u => isValidUrl(u)).length > 1
const generatedImageCount = imageUrls.filter(u => isValidUrl(u)).length
const hasMultipleImages = generatedImageCount > 1
const effectiveSelectedIndex: number | null = appearance?.selectedIndex ?? null
const currentImageUrl = appearance?.imageUrl || (effectiveSelectedIndex !== null ? imageUrls[effectiveSelectedIndex] : null) || imageUrls.find(u => u) || null
const hasPreviousVersion = !!(appearance?.previousImageUrl || (appearance?.previousImageUrls && appearance.previousImageUrls.length > 0))
@@ -250,22 +251,27 @@ export function CharacterCard({ character, onImageClick, onImageEdit, onVoiceDes
<div className="flex items-center gap-1">
<ImageGenerationInlineCountButton
prefix={isAppearanceTaskRunning ? (
<>
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{tAssets('image.regenCountPrefix')}</span>
</>
) : (
<>
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{tAssets('image.regenCountPrefix')}</span>
</>
)}
suffix={null}
value={generationCount}
options={getImageGenerationCountOptions('character')}
onValueChange={setGenerationCount}
onClick={() => {
_ulogInfo('[CharacterCard] 多图模式 - 重新生成按钮点击, characterId:', character.id, 'appearanceCount:', appearanceCount)
handleGenerate(generationCount)
handleGenerate(generatedImageCount)
}}
disabled={isAppearanceTaskRunning}
ariaLabel={tAssets('image.selectCount')}
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
showCountControl={false}
ariaLabel={tAssets('image.regenCountPrefix')}
className="inline-flex h-6 items-center justify-center gap-1 rounded-md px-1.5 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
/>
{hasPreviousVersion && (
<button onClick={handleUndo} className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md" title={tAssets('image.undo')}>

View File

@@ -94,7 +94,7 @@ export function LocationCard({ location, assetType = 'location', onImageClick, o
const isTaskRunning = serverTaskRunning || transientSubmitting
const displaySelectionImages = resolveDisplayImageSlots(orderedImages, {
hasRunningTask: isTaskRunning,
requestedCount: generationCount,
requestedCount: generatedImageCount > 1 ? generatedImageCount : generationCount,
})
const displaySlotCount = displaySelectionImages.length
const hasMultipleImages = generatedImageCount > 1
@@ -226,19 +226,24 @@ export function LocationCard({ location, assetType = 'location', onImageClick, o
<div className="flex items-center gap-1 ml-2">
<ImageGenerationInlineCountButton
prefix={isTaskRunning ? (
<>
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{tAssets('image.regenCountPrefix')}</span>
</>
) : (
<>
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{tAssets('image.regenCountPrefix')}</span>
</>
)}
suffix={null}
value={generationCount}
options={getImageGenerationCountOptions('location')}
onValueChange={setGenerationCount}
onClick={() => handleGenerate(generationCount)}
onClick={() => handleGenerate(generatedImageCount)}
disabled={isTaskRunning}
ariaLabel={tAssets('image.selectCount')}
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
showCountControl={false}
ariaLabel={tAssets('image.regenCountPrefix')}
className="inline-flex h-6 items-center justify-center gap-1 rounded-md px-1.5 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
/>
{hasPreviousVersion && (
<button onClick={handleUndo} className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md" title={tAssets('image.undo')}>

View File

@@ -11,6 +11,8 @@ import { AppIcon, IconGradientDefs } from '@/components/ui/icons'
import { shouldGuideToModelSetup } from '@/lib/workspace/model-setup'
import { Link, useRouter } from '@/i18n/navigation'
import { apiFetch } from '@/lib/api-fetch'
import { readApiErrorMessage } from '@/lib/api/read-error-message'
import { validateProjectDraft } from '@/lib/projects/validation'
interface ProjectStats {
episodes: number
@@ -45,6 +47,24 @@ function formatProjectCost(amount: number, currency = DEFAULT_BILLING_CURRENCY):
return `¥${amount.toFixed(2)}`
}
function toProjectValidationMessage(
issue: ReturnType<typeof validateProjectDraft>,
t: ReturnType<typeof useTranslations>,
): string | null {
if (!issue) return null
switch (issue.code) {
case 'PROJECT_NAME_REQUIRED':
return t('validation.nameRequired')
case 'PROJECT_NAME_TOO_LONG':
return t('validation.nameTooLong')
case 'PROJECT_DESCRIPTION_TOO_LONG':
return t('validation.descriptionTooLong')
}
return null
}
export default function WorkspacePage() {
const { data: session, status } = useSession()
const router = useRouter()
@@ -52,12 +72,14 @@ export default function WorkspacePage() {
const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [createLoading, setCreateLoading] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [formData, setFormData] = useState({
name: '',
description: ''
})
const [editingProject, setEditingProject] = useState<Project | null>(null)
const [showEditModal, setShowEditModal] = useState(false)
const [editError, setEditError] = useState<string | null>(null)
const [editFormData, setEditFormData] = useState({
name: '',
description: ''
@@ -124,6 +146,7 @@ export default function WorkspacePage() {
// 打开新建项目弹窗并检测模型配置
const openCreateModal = useCallback(() => {
setCreateError(null)
setShowCreateModal(true)
// 异步检测模型配置状态
void (async () => {
@@ -146,8 +169,13 @@ export default function WorkspacePage() {
const handleCreateProject = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.name.trim()) return
const validationMessage = toProjectValidationMessage(validateProjectDraft(formData), t)
if (validationMessage) {
setCreateError(validationMessage)
return
}
setCreateError(null)
setCreateLoading(true)
try {
const response = await apiFetch('/api/projects', {
@@ -181,11 +209,11 @@ export default function WorkspacePage() {
router.push({ pathname: '/profile' })
}
} else {
alert(t('createFailed'))
setCreateError(await readApiErrorMessage(response, t('createFailed')))
}
} catch (error) {
_ulogError('创建项目失败:', error)
alert(t('createFailed'))
setCreateError(error instanceof Error ? error.message : t('createFailed'))
} finally {
setCreateLoading(false)
}
@@ -207,8 +235,15 @@ export default function WorkspacePage() {
const handleEditProject = async (e: React.FormEvent) => {
e.preventDefault()
if (!editingProject || !editFormData.name.trim()) return
if (!editingProject) return
const validationMessage = toProjectValidationMessage(validateProjectDraft(editFormData), t)
if (validationMessage) {
setEditError(validationMessage)
return
}
setEditError(null)
setCreateLoading(true)
try {
const response = await apiFetch(`/api/projects/${editingProject.id}`, {
@@ -226,10 +261,10 @@ export default function WorkspacePage() {
setEditingProject(null)
setEditFormData({ name: '', description: '' })
} else {
alert(t('updateFailed'))
setEditError(await readApiErrorMessage(response, t('updateFailed')))
}
} catch {
alert(t('updateFailed'))
} catch (error) {
setEditError(error instanceof Error ? error.message : t('updateFailed'))
} finally {
setCreateLoading(false)
}
@@ -276,6 +311,7 @@ export default function WorkspacePage() {
e.preventDefault() // 阻止 Link 导航
e.stopPropagation()
setEditingProject(project)
setEditError(null)
setEditFormData({
name: project.name,
description: project.description || ''
@@ -574,7 +610,12 @@ export default function WorkspacePage() {
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
onChange={(e) => {
setFormData({ ...formData, name: e.target.value })
if (createError) {
setCreateError(null)
}
}}
className="glass-input-base w-full px-3 py-2"
placeholder={t('projectNamePlaceholder')}
maxLength={100}
@@ -589,18 +630,29 @@ export default function WorkspacePage() {
<textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
onChange={(e) => {
setFormData({ ...formData, description: e.target.value })
if (createError) {
setCreateError(null)
}
}}
className="glass-textarea-base w-full px-3 py-2"
placeholder={t('projectDescriptionPlaceholder')}
rows={3}
maxLength={500}
/>
</div>
{createError && (
<p className="mb-4 rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm text-red-600">
{createError}
</p>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => {
setShowCreateModal(false)
setCreateError(null)
setFormData({ name: '', description: '' })
}}
className="glass-btn-base glass-btn-secondary px-4 py-2"
@@ -635,7 +687,12 @@ export default function WorkspacePage() {
id="edit-name"
type="text"
value={editFormData.name}
onChange={(e) => setEditFormData({ ...editFormData, name: e.target.value })}
onChange={(e) => {
setEditFormData({ ...editFormData, name: e.target.value })
if (editError) {
setEditError(null)
}
}}
className="glass-input-base w-full px-3 py-2"
placeholder={t('projectNamePlaceholder')}
maxLength={100}
@@ -649,19 +706,30 @@ export default function WorkspacePage() {
<textarea
id="edit-description"
value={editFormData.description}
onChange={(e) => setEditFormData({ ...editFormData, description: e.target.value })}
onChange={(e) => {
setEditFormData({ ...editFormData, description: e.target.value })
if (editError) {
setEditError(null)
}
}}
className="glass-textarea-base w-full px-3 py-2"
placeholder={t('projectDescriptionPlaceholder')}
rows={3}
maxLength={500}
/>
</div>
{editError && (
<p className="mb-4 rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm text-red-600">
{editError}
</p>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => {
setShowEditModal(false)
setEditingProject(null)
setEditError(null)
setEditFormData({ name: '', description: '' })
}}
className="glass-btn-base glass-btn-secondary px-4 py-2"

View File

@@ -5,6 +5,10 @@ import { ApiError, apiHandler } from '@/lib/api-errors'
import { attachMediaFieldsToGlobalLocation } from '@/lib/media/attach'
import { isArtStyleValue } from '@/lib/constants'
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
import {
normalizeLocationAvailableSlots,
stringifyLocationAvailableSlots,
} from '@/lib/location-available-slots'
// 获取用户所有场景(支持 folderId 筛选)
export const GET = apiHandler(async (request: NextRequest) => {
@@ -45,6 +49,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const body = await request.json()
const { name, summary, folderId, artStyle } = body
const availableSlots = normalizeLocationAvailableSlots((body as Record<string, unknown>).availableSlots)
const count = Object.prototype.hasOwnProperty.call(body as Record<string, unknown>, 'count')
? normalizeImageGenerationCount('location', (body as Record<string, unknown>).count)
: 1
@@ -84,6 +89,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
locationId: location.id,
imageIndex,
description: summary?.trim() || name.trim(),
availableSlots: stringifyLocationAvailableSlots(availableSlots),
}))
})

View File

@@ -1,6 +1,10 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { removeLocationPromptSuffix, isArtStyleValue, type ArtStyleValue } from '@/lib/constants'
import {
normalizeLocationAvailableSlots,
stringifyLocationAvailableSlots,
} from '@/lib/location-available-slots'
import { requireProjectAuth, requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'
import { apiHandler, ApiError } from '@/lib/api-errors'
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
@@ -56,6 +60,7 @@ export const POST = apiHandler(async (
const name = normalizeString(body.name)
const description = normalizeString(body.description)
const summary = normalizeString(body.summary)
const availableSlots = normalizeLocationAvailableSlots(body.availableSlots)
const count = Object.prototype.hasOwnProperty.call(body, 'count')
? normalizeImageGenerationCount('location', body.count)
: 1
@@ -91,6 +96,7 @@ export const POST = apiHandler(async (
locationId: location.id,
imageIndex,
description: cleanDescription,
availableSlots: stringifyLocationAvailableSlots(availableSlots),
})),
})
@@ -141,7 +147,12 @@ export const PATCH = apiHandler(async (
where: {
locationId_imageIndex: { locationId, imageIndex }
},
data: { description: cleanDescription }
data: {
description: cleanDescription,
...(Object.prototype.hasOwnProperty.call(body, 'availableSlots')
? { availableSlots: stringifyLocationAvailableSlots(normalizeLocationAvailableSlots(body.availableSlots)) }
: {}),
}
})
return NextResponse.json({ success: true, image })
}

View File

@@ -4,6 +4,25 @@ import { requireUserAuth, isErrorResponse } from '@/lib/api-auth'
import { apiHandler, ApiError } from '@/lib/api-errors'
import { toMoneyNumber } from '@/lib/billing/money'
import { isArtStyleValue } from '@/lib/constants'
import { resolveTaskLocale } from '@/lib/task/resolve-locale'
import {
formatProjectValidationIssue,
normalizeProjectDraft,
validateProjectDraft,
type ProjectDraftInput,
} from '@/lib/projects/validation'
function readProjectDraftBody(body: unknown): ProjectDraftInput {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return { name: '' }
}
const payload = body as Record<string, unknown>
return {
name: typeof payload.name === 'string' ? payload.name : '',
description: typeof payload.description === 'string' ? payload.description : null,
}
}
// GET - 获取用户的项目(支持分页和搜索)
export const GET = apiHandler(async (request: NextRequest) => {
@@ -169,19 +188,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (isErrorResponse(authResult)) return authResult
const { session } = authResult
const { name, description } = await request.json()
if (!name || name.trim().length === 0) {
throw new ApiError('INVALID_PARAMS')
const body = await request.json()
const draft = readProjectDraftBody(body)
const validationIssue = validateProjectDraft(draft)
if (validationIssue) {
const locale = resolveTaskLocale(request, body) ?? 'zh'
throw new ApiError('INVALID_PARAMS', {
code: validationIssue.code,
field: validationIssue.field,
...(typeof validationIssue.limit === 'number' ? { limit: validationIssue.limit } : {}),
message: formatProjectValidationIssue(validationIssue, locale),
})
}
if (name.length > 100) {
throw new ApiError('INVALID_PARAMS')
}
if (description && description.length > 500) {
throw new ApiError('INVALID_PARAMS')
}
const { name, description } = normalizeProjectDraft(draft)
// 获取用户偏好配置
const userPreference = await prisma.userPreference.findUnique({

View File

@@ -35,6 +35,14 @@ function normalizeStatuses(values: string[]): RunStatus[] {
return next
}
function isActiveRunStatus(status: RunStatus) {
return (
status === RUN_STATUS.QUEUED
|| status === RUN_STATUS.RUNNING
|| status === RUN_STATUS.CANCELING
)
}
export const GET = apiHandler(async (request: NextRequest) => {
const authResult = await requireUserAuth()
if (isErrorResponse(authResult)) return authResult
@@ -48,6 +56,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
const statuses = normalizeStatuses(query.getAll('status'))
const limitRaw = Number.parseInt(query.get('limit') || '50', 10)
const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 200) : 50
const activeOnlyQuery = statuses.length > 0 && statuses.every(isActiveRunStatus)
const scopedActiveRecoveryQuery =
activeOnlyQuery
&& !!workflowType
&& !!targetType
&& !!targetId
const runs = await listRuns({
userId: session.user.id,
projectId: projectId || undefined,
@@ -57,6 +71,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
episodeId: episodeId || undefined,
statuses: statuses.length > 0 ? statuses : undefined,
limit,
recoverableOnly: scopedActiveRecoveryQuery,
latestOnly: scopedActiveRecoveryQuery,
})
return NextResponse.json({ runs })
})

View File

@@ -5,7 +5,7 @@ import { AppIcon } from '@/components/ui/icons'
interface ImageGenerationInlineCountButtonProps {
prefix: ReactNode
suffix: ReactNode
suffix?: ReactNode
value: number
options: number[]
onValueChange: (value: number) => void
@@ -13,7 +13,11 @@ interface ImageGenerationInlineCountButtonProps {
disabled?: boolean
actionDisabled?: boolean
selectDisabled?: boolean
showCountControl?: boolean
splitInteractiveZones?: boolean
className?: string
actionClassName?: string
countClassName?: string
selectClassName?: string
labelClassName?: string
ariaLabel: string
@@ -29,7 +33,11 @@ export default function ImageGenerationInlineCountButton({
disabled = false,
actionDisabled,
selectDisabled,
showCountControl = true,
splitInteractiveZones = false,
className = '',
actionClassName = '',
countClassName = '',
selectClassName = '',
labelClassName = '',
ariaLabel,
@@ -42,6 +50,68 @@ export default function ImageGenerationInlineCountButton({
const selectStateClassName = isSelectDisabled
? 'pointer-events-none opacity-70'
: 'cursor-pointer'
const resolvedActionClassName = (actionClassName || className).trim()
if (!showCountControl) {
return (
<button
type="button"
onClick={() => {
if (isActionDisabled) return
onClick()
}}
disabled={isActionDisabled}
aria-label={ariaLabel}
className={`${resolvedActionClassName} ${rootStateClassName}`.trim()}
>
<span className={`${labelClassName} inline-flex items-center gap-1 whitespace-nowrap`.trim()}>{prefix}</span>
</button>
)
}
if (splitInteractiveZones) {
return (
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={() => {
if (isActionDisabled) return
onClick()
}}
disabled={isActionDisabled}
aria-label={ariaLabel}
className={`${resolvedActionClassName} ${rootStateClassName}`.trim()}
>
<span className={`${labelClassName} inline-flex items-center gap-1 whitespace-nowrap`.trim()}>{prefix}</span>
</button>
<span
className={`group relative inline-flex h-6 items-center gap-1 rounded-md px-1.5 transition-colors ${
isSelectDisabled ? '' : 'hover:bg-white/12 focus-within:bg-white/14'
} ${countClassName}`.trim()}
>
<select
value={String(value)}
onChange={(event) => onValueChange(Number(event.target.value))}
aria-label={ariaLabel}
disabled={isSelectDisabled}
className={`${selectClassName} ${selectStateClassName}`.trim()}
>
{options.map((option) => (
<option key={option} value={option} className="text-black">
{option}
</option>
))}
</select>
<span className="pointer-events-none absolute inset-y-0 right-1 flex items-center text-current opacity-85 transition-colors group-hover:opacity-100 group-focus-within:opacity-100">
<AppIcon name="chevronDown" className="h-3 w-3" />
</span>
{suffix ? (
<span className={`${labelClassName} whitespace-nowrap pr-4`.trim()}>{suffix}</span>
) : null}
</span>
</div>
)
}
return (
<div
@@ -61,10 +131,10 @@ export default function ImageGenerationInlineCountButton({
aria-disabled={isActionDisabled}
className={`${className} ${rootStateClassName}`.trim()}
>
<span className={labelClassName}>{prefix}</span>
<span className={`${labelClassName} inline-flex shrink-0 items-center whitespace-nowrap leading-none`.trim()}>{prefix}</span>
<span
className={`group relative inline-flex items-center rounded-md px-1.5 py-0.5 transition-colors ${
isSelectDisabled ? '' : 'hover:bg-white/12 focus-within:bg-white/14'
className={`group relative inline-flex h-8 shrink-0 items-center rounded-full bg-white/12 px-2 transition-colors ${
isSelectDisabled ? '' : 'hover:bg-white/16 focus-within:bg-white/18'
}`}
onClick={(event: MouseEvent<HTMLSpanElement>) => event.stopPropagation()}
>
@@ -81,11 +151,11 @@ export default function ImageGenerationInlineCountButton({
</option>
))}
</select>
<span className="pointer-events-none absolute inset-y-0 right-1 flex items-center text-current opacity-85 transition-colors group-hover:opacity-100 group-focus-within:opacity-100">
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-current opacity-85 transition-colors group-hover:opacity-100 group-focus-within:opacity-100">
<AppIcon name="chevronDown" className="h-3 w-3" />
</span>
</span>
<span className={labelClassName}>{suffix}</span>
<span className={`${labelClassName} inline-flex shrink-0 items-center whitespace-nowrap leading-none`.trim()}>{suffix}</span>
</div>
)
}

View File

@@ -19,6 +19,7 @@ import {
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
import ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'
import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
export interface LocationCreationModalProps {
mode: 'asset-hub' | 'project'
@@ -63,6 +64,7 @@ export function LocationCreationModal({
const [description, setDescription] = useState('')
const [aiInstruction, setAiInstruction] = useState('')
const [artStyle, setArtStyle] = useState('american-comic')
const [availableSlots, setAvailableSlots] = useState<LocationAvailableSlot[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [isAiDesigning, setIsAiDesigning] = useState(false)
@@ -119,6 +121,7 @@ export function LocationCreationModal({
? await aiDesignAssetHubLocation.mutateAsync(aiInstruction)
: await aiCreateProjectLocation.mutateAsync({ userInstruction: aiInstruction })
setDescription(data.prompt || '')
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
setAiInstruction('')
} catch (error: unknown) {
if (getErrorStatus(error) === 402) {
@@ -168,12 +171,14 @@ export function LocationCreationModal({
summary: body.description,
artStyle: body.artStyle,
folderId: body.folderId ?? null,
availableSlots,
})
} else {
await createProjectLocation.mutateAsync({
name: body.name,
description: body.description,
artStyle: body.artStyle,
availableSlots,
})
}
@@ -203,6 +208,7 @@ export function LocationCreationModal({
artStyle,
folderId: folderId ?? null,
count: locationGenerationCount,
availableSlots,
}) as CreatedLocationResponse
const createdLocationId = result.location?.id
if (!createdLocationId) {
@@ -219,6 +225,7 @@ export function LocationCreationModal({
description: description.trim(),
artStyle,
count: locationGenerationCount,
availableSlots,
}) as CreatedLocationResponse
const createdLocationId = result.location?.id
if (!createdLocationId) {

View File

@@ -14,6 +14,7 @@ import {
useUpdateProjectLocationDescription,
useUpdateProjectLocationName,
} from '@/lib/query/hooks'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
export interface LocationEditModalProps {
mode: 'asset-hub' | 'project'
@@ -56,6 +57,7 @@ export function LocationEditModal({
const [editingName, setEditingName] = useState(locationName)
const [editingDescription, setEditingDescription] = useState(description || summary || '')
const [availableSlots, setAvailableSlots] = useState<LocationAvailableSlot[]>([])
const [aiModifyInstruction, setAiModifyInstruction] = useState('')
const [isAiModifying, setIsAiModifying] = useState(false)
const [isSaving, setIsSaving] = useState(false)
@@ -113,6 +115,7 @@ export function LocationEditModal({
await updateAssetHubSummary.mutateAsync({
locationId,
summary: editingDescription,
availableSlots,
})
return
}
@@ -121,6 +124,7 @@ export function LocationEditModal({
locationId,
imageIndex: resolvedImageIndex,
description: editingDescription,
availableSlots,
})
}
@@ -139,6 +143,7 @@ export function LocationEditModal({
})
if (data?.modifiedDescription) {
setEditingDescription(data.modifiedDescription)
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
onUpdate?.(data.modifiedDescription)
setAiModifyInstruction('')
}
@@ -154,6 +159,7 @@ export function LocationEditModal({
const nextDescription = data?.modifiedDescription || data?.prompt || ''
if (nextDescription) {
setEditingDescription(nextDescription)
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
onUpdate?.(nextDescription)
setAiModifyInstruction('')
}

View File

@@ -0,0 +1,124 @@
'use client'
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { AppIcon } from '@/components/ui/icons'
interface LongTextDetectionPromptCopy {
title: string
description: string
strongRecommend: string
smartSplitLabel: string
smartSplitBadge: string
continueLabel: string
continueHint: string
}
interface LongTextDetectionPromptProps {
open: boolean
copy: LongTextDetectionPromptCopy
onClose: () => void
onSmartSplit: () => void
onContinue: () => void
}
export default function LongTextDetectionPrompt({
open,
copy,
onClose,
onSmartSplit,
onContinue,
}: LongTextDetectionPromptProps) {
useEffect(() => {
if (!open) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose, open])
if (!open || typeof document === 'undefined') {
return null
}
return createPortal(
<div
className="fixed inset-0 z-[120] flex items-center justify-center glass-overlay p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose()
}
}}
>
<div className="glass-surface-modal w-full max-w-lg rounded-2xl border border-[var(--glass-stroke-base)] p-6 shadow-[0_20px_80px_-32px_rgba(15,23,42,0.45)]">
<div className="space-y-5">
<div className="flex items-center gap-3">
<div
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl"
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.15), rgba(139,92,246,0.15))' }}
>
<AppIcon name="sparkles" className="h-5 w-5 text-[#7c3aed]" />
</div>
<h3 className="text-lg font-bold text-[var(--glass-text-primary)]">
{copy.title}
</h3>
</div>
<p className="text-sm leading-relaxed text-[var(--glass-text-secondary)]">
{copy.description}
</p>
<div
className="rounded-xl p-4 text-sm leading-relaxed"
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.08), rgba(139,92,246,0.08))' }}
>
<p
className="font-semibold"
style={{
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
{copy.strongRecommend}
</p>
</div>
<div className="flex flex-col gap-3 pt-1">
<button
type="button"
onClick={onSmartSplit}
className="flex w-full items-center justify-center gap-2 rounded-xl py-3.5 text-base font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ background: 'linear-gradient(135deg, #3b82f6, #7c3aed)' }}
>
<AppIcon name="sparkles" className="h-5 w-5" />
<span>{copy.smartSplitLabel}</span>
<span className="rounded-full bg-white/20 px-2 py-0.5 text-xs">
{copy.smartSplitBadge}
</span>
</button>
<button
type="button"
onClick={onContinue}
className="w-full py-2.5 text-sm text-[var(--glass-text-tertiary)] transition-colors hover:text-[var(--glass-text-secondary)]"
>
{copy.continueLabel}
<span className="ml-1 text-xs opacity-60">
- {copy.continueHint}
</span>
</button>
</div>
</div>
</div>
</div>,
document.body,
)
}

View File

@@ -109,7 +109,7 @@ export default function StoryInputComposer({
return (
<div className="relative w-full glass-surface-elevated rounded-2xl">
<div className="p-6 pb-0">
<div className="p-6 pb-4">
{topRight && (
<div className="mb-3 flex items-center justify-end">
{topRight}

View File

@@ -133,7 +133,7 @@ export function CapsuleNav({ items, activeId, onItemClick, projectId, episodeId
}
return (
<nav className="fixed top-20 left-1/2 -translate-x-1/2 z-50 animate-fadeInDown">
<nav className="fixed top-20 left-1/2 -translate-x-1/2 z-40 animate-fadeInDown">
<div
className="flex rounded-full px-2 py-1"
style={{
@@ -215,7 +215,7 @@ export function EpisodeSelector({
if (!currentEp) return null
return (
<div className="fixed top-20 left-6 z-[60]" ref={menuRef}>
<div className="fixed top-20 left-6 z-40" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="glass-btn-base glass-btn-secondary flex items-center gap-3 px-4 py-3 transition-all group"

View File

@@ -0,0 +1,22 @@
interface ApiErrorPayload {
error?: string | { message?: string | null } | null
message?: string | null
}
export async function readApiErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const payload = await response.json() as ApiErrorPayload
if (typeof payload?.error === 'string' && payload.error.trim()) {
return payload.error
}
if (payload?.error && typeof payload.error === 'object' && typeof payload.error.message === 'string' && payload.error.message.trim()) {
return payload.error.message
}
if (typeof payload?.message === 'string' && payload.message.trim()) {
return payload.message
}
} catch {
// Keep the explicit fallback when the backend does not return JSON.
}
return fallback
}

View File

@@ -6,6 +6,11 @@ import { logError as _ulogError } from '@/lib/logging/core'
import { executeAiTextStep } from '@/lib/ai-runtime'
import { withTextBilling } from '@/lib/billing'
import { safeParseJsonObject } from '@/lib/json-repair'
import {
type LocationAvailableSlot,
normalizeLocationAvailableSlots,
} from '@/lib/location-available-slots'
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
import type { Locale } from '@/i18n/routing'
@@ -26,6 +31,7 @@ export interface AIDesignOptions {
export interface AIDesignResult {
success: boolean
prompt?: string
availableSlots?: LocationAvailableSlot[]
error?: string
}
@@ -111,23 +117,13 @@ export async function aiDesign(options: AIDesignOptions): Promise<AIDesignResult
}
// 解析 JSON 响应
let parsedResponse
let parsedResponse: Record<string, unknown>
try {
parsedResponse = JSON.parse(aiResponse)
} catch {
const jsonMatch = aiResponse.match(/\{[\s\S]*\}/)
if (jsonMatch) {
try {
parsedResponse = JSON.parse(jsonMatch[0])
parsedResponse = safeParseJsonObject(aiResponse)
} catch {
_ulogError('[AI Design] AI 响应解析失败:', aiResponse)
return { success: false, error: 'AI返回格式错误' }
}
} else {
_ulogError('[AI Design] AI 响应解析失败:', aiResponse)
return { success: false, error: 'AI返回格式错误' }
}
}
if (!parsedResponse.prompt) {
return { success: false, error: 'AI返回缺少prompt字段' }
@@ -135,6 +131,9 @@ export async function aiDesign(options: AIDesignOptions): Promise<AIDesignResult
return {
success: true,
prompt: parsedResponse.prompt
prompt: typeof parsedResponse.prompt === 'string' ? parsedResponse.prompt : '',
availableSlots: assetType === 'location'
? normalizeLocationAvailableSlots(parsedResponse.available_slots)
: [],
}
}

View File

@@ -17,6 +17,10 @@ import { deleteObject } from '@/lib/storage'
import { resolveStorageKeyFromMediaValue } from '@/lib/media/service'
import { updateCharacterAppearanceLabels, updateLocationImageLabels } from '@/lib/image-label'
import type { AssetKind, AssetScope } from '@/lib/assets/contracts'
import {
normalizeLocationAvailableSlots,
stringifyLocationAvailableSlots,
} from '@/lib/location-available-slots'
import {
createGlobalLocationBackedAsset,
createProjectLocationBackedAsset,
@@ -849,6 +853,7 @@ async function copyLocationFromGlobal(input: AssetCopyInput) {
locationId: input.targetId,
imageIndex: image.imageIndex,
description: image.description,
availableSlots: image.availableSlots,
imageUrl: labelUpdate?.imageUrl || image.imageUrl,
isSelected: image.isSelected,
},
@@ -921,6 +926,16 @@ async function updateGlobalAsset(input: AssetUpdateInput) {
where: { id: input.assetId },
data: updateData,
})
if (input.body.availableSlots !== undefined) {
await prisma.globalLocationImage.updateMany({
where: { locationId: input.assetId },
data: {
availableSlots: stringifyLocationAvailableSlots(
normalizeLocationAvailableSlots(input.body.availableSlots),
),
},
})
}
return { success: true, location }
}
if (input.kind === 'prop') {

View File

@@ -1,4 +1,10 @@
import { buildCharactersIntroduction } from '@/lib/constants'
import {
formatLocationAvailableSlotsText,
parseLocationAvailableSlots,
} from '@/lib/location-available-slots'
type PromptLocale = 'zh' | 'en'
export type ClipCharacterRef = string | { name?: string | null }
@@ -20,6 +26,7 @@ export type PromptLocationAsset = {
images?: Array<{
isSelected?: boolean
description?: string | null
availableSlots?: string | null
}>
}
@@ -35,6 +42,7 @@ export type PromptAssetContextInput = {
clipCharacters: ClipCharacterRef[]
clipLocation: string | null
clipProps: string[]
locale?: PromptLocale
}
export type PromptAssetContext = {
@@ -134,7 +142,14 @@ export function buildPromptAssetContext(input: PromptAssetContextInput): PromptA
? input.locations.find((location) => normalizeName(location.name) === normalizeName(environmentName))
: null
const selectedImage = matchedLocation?.images?.find((image) => image.isSelected) ?? matchedLocation?.images?.[0]
const locationDescriptionText = selectedImage?.description || '无'
const locationDescription = selectedImage?.description || '无'
const locationSlotsText = formatLocationAvailableSlotsText(
parseLocationAvailableSlots(selectedImage?.availableSlots),
input.locale ?? 'zh',
)
const locationDescriptionText = locationSlotsText
? `${locationDescription}\n\n${locationSlotsText}`
: locationDescription
return {
subjectNames,

View File

@@ -1,6 +1,10 @@
import { randomUUID } from 'crypto'
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import {
type LocationAvailableSlot,
stringifyLocationAvailableSlots,
} from '@/lib/location-available-slots'
export type LocationBackedAssetKind = 'location' | 'prop'
@@ -28,6 +32,7 @@ type LocationBackedImageRow = {
id: string
imageIndex: number
description: string | null
availableSlots: string | null
imageUrl: string | null
imageMediaId: string | null
previousImageUrl: string | null
@@ -88,6 +93,7 @@ async function readProjectLocationBackedImages(locationIds: string[]): Promise<M
id,
imageIndex,
description,
availableSlots,
imageUrl,
imageMediaId,
previousImageUrl,
@@ -111,6 +117,7 @@ async function readGlobalLocationBackedImages(locationIds: string[]): Promise<Ma
id,
imageIndex,
description,
availableSlots,
imageUrl,
imageMediaId,
previousImageUrl,
@@ -214,6 +221,7 @@ export async function createProjectLocationBackedAsset(input: {
locationId: id,
fallbackDescription: input.summary,
descriptions: [input.summary],
availableSlots: [],
})
return { id }
}
@@ -254,6 +262,7 @@ export async function createGlobalLocationBackedAsset(input: {
locationId: id,
fallbackDescription: input.summary,
descriptions: [input.summary],
availableSlots: [],
})
return { id }
}
@@ -262,17 +271,31 @@ export async function seedProjectLocationBackedImageSlots(input: {
locationId: string
fallbackDescription: string
descriptions?: string[]
availableSlots?: LocationAvailableSlot[]
locationImageModel?: {
createMany: (args: {
data: Array<{
locationId: string
imageIndex: number
description: string
availableSlots: string
}>
}) => Promise<unknown>
}
}): Promise<void> {
const descriptions = normalizeSeedDescriptions(input)
if (descriptions.length === 0) {
return
}
const availableSlots = stringifyLocationAvailableSlots(input.availableSlots ?? [])
await prisma.locationImage.createMany({
const locationImageModel = input.locationImageModel ?? prisma.locationImage
await locationImageModel.createMany({
data: descriptions.map((description, imageIndex) => ({
locationId: input.locationId,
imageIndex,
description,
availableSlots,
})),
})
}
@@ -281,17 +304,20 @@ export async function seedGlobalLocationBackedImageSlots(input: {
locationId: string
fallbackDescription: string
descriptions?: string[]
availableSlots?: LocationAvailableSlot[]
}): Promise<void> {
const descriptions = normalizeSeedDescriptions(input)
if (descriptions.length === 0) {
return
}
const availableSlots = stringifyLocationAvailableSlots(input.availableSlots ?? [])
await prisma.globalLocationImage.createMany({
data: descriptions.map((description, imageIndex) => ({
locationId: input.locationId,
imageIndex,
description,
availableSlots,
})),
})
}

View File

@@ -1,6 +1,4 @@
interface ApiErrorPayload {
error?: string | { message?: string } | null
}
import { readApiErrorMessage } from '@/lib/api/read-error-message'
interface ProjectCreationPayload {
project?: {
@@ -56,21 +54,6 @@ function readNestedString(
return typeof value === 'string' && value.trim() ? value : null
}
async function readApiErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const payload = await response.json() as ApiErrorPayload
if (typeof payload?.error === 'string' && payload.error.trim()) {
return payload.error
}
if (payload?.error && typeof payload.error === 'object' && typeof payload.error.message === 'string' && payload.error.message.trim()) {
return payload.error.message
}
} catch {
// Keep the explicit fallback when the backend does not return JSON.
}
return fallback
}
async function readProjectId(response: Response): Promise<string> {
const payload = await response.json() as ProjectCreationPayload
const projectId = readNestedString(readObject(payload), 'project', 'id')
@@ -112,7 +95,6 @@ export async function createHomeProjectLaunch({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: projectName,
description: storyText,
}),
})

View File

@@ -0,0 +1,45 @@
export type LocationAvailableSlot = string
export type LocationSlotLocale = 'zh' | 'en'
function normalizeText(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
export function normalizeLocationAvailableSlots(value: unknown): LocationAvailableSlot[] {
if (!Array.isArray(value)) return []
const seen = new Set<string>()
const slots: LocationAvailableSlot[] = []
for (const item of value) {
const normalized = normalizeText(item)
if (!normalized || seen.has(normalized)) continue
seen.add(normalized)
slots.push(normalized)
}
return slots
}
export function parseLocationAvailableSlots(raw: string | null | undefined): LocationAvailableSlot[] {
if (!raw) return []
try {
return normalizeLocationAvailableSlots(JSON.parse(raw))
} catch {
return []
}
}
export function stringifyLocationAvailableSlots(slots: LocationAvailableSlot[]): string {
return JSON.stringify(normalizeLocationAvailableSlots(slots))
}
export function formatLocationAvailableSlotsText(
slots: LocationAvailableSlot[],
locale: LocationSlotLocale = 'zh',
): string {
const normalized = normalizeLocationAvailableSlots(slots)
if (normalized.length === 0) return ''
const lines = normalized.map((slot) => `- ${slot}`)
const header = locale === 'en' ? 'Available character slots:' : '可站位置:'
return `${header}\n${lines.join('\n')}`
}

View File

@@ -0,0 +1,32 @@
import {
formatLocationAvailableSlotsText,
parseLocationAvailableSlots,
} from '@/lib/location-available-slots'
type Locale = 'zh' | 'en'
export function buildLocationImagePromptCore(params: {
description: string
availableSlotsRaw?: string | null
locale: Locale
}): string {
const promptBody = params.description.trim()
const slotText = formatLocationAvailableSlotsText(
parseLocationAvailableSlots(params.availableSlotsRaw),
params.locale,
)
const spatialConstraints = params.locale === 'en'
? 'Use a wide, complete environment composition that clearly shows the main structure, foreground/midground/background, and visible spatial boundaries. Every anchor object or anchor area implied by the available slots must be clearly visible in the frame, and each slot must still have enough open space around it for a character to be placed there later. Do not generate a generic partial background, cropped anchor, or ambiguous layout that makes the slot positions unusable.'
: '必须使用宽广完整的场景全景构图,清楚展示主要结构、前景/中景/背景和空间边界。可站位置中提到的关键锚物或区域必须在画面中清晰可见,且每个位置附近都要保留足够的空白区域,方便后续角色落位。禁止生成局部裁切、锚点缺失、空间关系模糊的泛化背景。'
if (!slotText) {
return `${promptBody}\n\n${spatialConstraints}`.trim()
}
const slotHeader = params.locale === 'en'
? 'This scene must clearly support the following fixed character positions:'
: '该场景必须清楚支持以下固定人物位置:'
return `${promptBody}\n\n${slotHeader}\n${slotText}\n\n${spatialConstraints}`.trim()
}

View File

@@ -0,0 +1,46 @@
import {
formatLocationAvailableSlotsText,
parseLocationAvailableSlots,
} from '@/lib/location-available-slots'
type PromptLocationImage = {
isSelected?: boolean
description?: string | null
availableSlots?: string | null
}
type PromptLocationAsset = {
name: string
images?: PromptLocationImage[]
}
type Locale = 'zh' | 'en'
export function buildInsertPanelLocationsDescription(
locations: PromptLocationAsset[],
relatedLocations: string[],
locale: Locale = 'zh',
): string {
const filteredLocations = locations.filter(
(location) => relatedLocations.length === 0 || relatedLocations.includes(location.name),
)
if (filteredLocations.length === 0) {
return '无'
}
return filteredLocations
.map((location) => {
const selectedImage = location.images?.find((image) => image.isSelected) ?? location.images?.[0]
const description = selectedImage?.description || '无描述'
const slotsText = formatLocationAvailableSlotsText(
parseLocationAvailableSlots(selectedImage?.availableSlots),
locale,
)
return slotsText
? `${location.name}: ${description}\n${slotsText}`
: `${location.name}: ${description}`
})
.join('\n')
}

View File

@@ -1,6 +1,7 @@
export interface PanelCharacterRef {
name: string
appearance: string
slot?: string
}
type JsonRecord = Record<string, unknown>

View File

@@ -70,6 +70,7 @@ export type ClipStoryboardPanels = {
export type ScriptToStoryboardOrchestratorInput = {
concurrency?: number
locale?: 'zh' | 'en'
clips: ClipInput[]
novelPromotionData: {
characters: CharacterAsset[]
@@ -303,20 +304,26 @@ export async function runScriptToStoryboardOrchestrator(
const phase2ActingByClipId = new Map<string, ActingDirection[]>()
const phase3PanelsByClipId = new Map<string, StoryboardPanel[]>()
const phase1Results = await mapWithConcurrency(
const clipPanels = await mapWithConcurrency(
clips,
concurrency,
async (clip, i) => {
const clipIndex = i + 1
async (clip, index): Promise<ClipStoryboardPanels> => {
const clipIndex = index + 1
const clipContent = typeof clip.content === 'string' ? clip.content.trim() : ''
if (!clipContent) {
throw new Error(`Clip ${formatClipId(clip)} content is empty`)
}
const clipCharacters = parseClipCharacters(clip.characters)
const clipLocation = clip.location || null
const clipProps = parseClipProps(clip.props ?? null)
const filteredAppearanceList = getFilteredAppearanceList(novelPromotionData.characters || [], clipCharacters)
const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters || [], clipCharacters)
const filteredPropDescription = compileAssetPromptFragments(buildPromptAssetContext({
const filteredLocationsDescription = getFilteredLocationsDescription(
novelPromotionData.locations || [],
clipLocation,
input.locale ?? 'zh',
)
const filteredPropsDescription = compileAssetPromptFragments(buildPromptAssetContext({
characters: [],
locations: [],
props: novelPromotionData.props || [],
@@ -342,7 +349,7 @@ export async function runScriptToStoryboardOrchestrator(
.replace('{characters_introduction}', charactersIntroduction)
.replace('{characters_appearance_list}', filteredAppearanceList)
.replace('{characters_full_description}', filteredFullDescription)
.replace('{props_description}', filteredPropDescription)
.replace('{props_description}', filteredPropsDescription)
.replace('{clip_json}', clipJson)
const screenplay = parseScreenplay(clip.screenplay)
@@ -373,44 +380,7 @@ export async function runScriptToStoryboardOrchestrator(
return panels
},
)
return {
clipId: clip.id,
planPanels,
}
},
)
for (const result of phase1Results) {
phase1PanelsByClipId.set(result.clipId, result.planPanels)
}
const clipPanels = await mapWithConcurrency(
clips,
concurrency,
async (clip, index): Promise<ClipStoryboardPanels> => {
const clipIndex = index + 1
const clipCharacters = parseClipCharacters(clip.characters)
const clipLocation = clip.location || null
const clipProps = parseClipProps(clip.props ?? null)
const planPanels = phase1PanelsByClipId.get(clip.id) || []
if (planPanels.length === 0) {
throw new Error(`Missing phase1 result for clip ${formatClipId(clip)}`)
}
const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters || [], clipCharacters)
const filteredLocationsDescription = getFilteredLocationsDescription(
novelPromotionData.locations || [],
clipLocation,
)
const filteredPropsDescription = compileAssetPromptFragments(buildPromptAssetContext({
characters: [],
locations: [],
props: novelPromotionData.props || [],
clipCharacters: [],
clipLocation: null,
clipProps,
})).propsDescriptionText
phase1PanelsByClipId.set(clip.id, planPanels)
const phase2Meta = withStepMeta(
`clip_${clip.id}_phase2_cinematography`,

View File

@@ -0,0 +1,74 @@
export type StageArtifactReadiness = {
hasStory: boolean
hasScript: boolean
hasStoryboard: boolean
hasVideo: boolean
hasVoice: boolean
}
type EpisodeClipLike = {
screenplay?: string | null
[key: string]: unknown
}
type StoryboardPanelLike = {
videoUrl?: string | null
[key: string]: unknown
}
type StoryboardLike = {
panels?: StoryboardPanelLike[] | null
[key: string]: unknown
}
type EpisodeLike = {
novelText?: string | null
clips?: unknown[] | null
storyboards?: unknown[] | null
voiceLines?: unknown[] | null
}
function hasNonEmptyText(value: string | null | undefined) {
return typeof value === 'string' && value.trim().length > 0
}
function isEpisodeClipLike(value: unknown): value is EpisodeClipLike {
return typeof value === 'object' && value !== null
}
function isStoryboardPanelLike(value: unknown): value is StoryboardPanelLike {
return typeof value === 'object' && value !== null
}
function isStoryboardLike(value: unknown): value is StoryboardLike {
return typeof value === 'object' && value !== null
}
export function hasScriptArtifacts(clips: unknown[] | null | undefined) {
if (!Array.isArray(clips) || clips.length === 0) return false
return clips.some((clip) => isEpisodeClipLike(clip) && hasNonEmptyText(clip.screenplay))
}
export function hasStoryboardArtifacts(storyboards: unknown[] | null | undefined) {
if (!Array.isArray(storyboards) || storyboards.length === 0) return false
return storyboards.some((storyboard) => isStoryboardLike(storyboard)
&& Array.isArray(storyboard.panels)
&& storyboard.panels.some((panel) => isStoryboardPanelLike(panel)))
}
export function hasVideoArtifacts(storyboards: unknown[] | null | undefined) {
if (!Array.isArray(storyboards) || storyboards.length === 0) return false
return storyboards.some((storyboard) => isStoryboardLike(storyboard)
&& Array.isArray(storyboard.panels)
&& storyboard.panels.some((panel) => isStoryboardPanelLike(panel) && hasNonEmptyText(panel.videoUrl)))
}
export function resolveEpisodeStageArtifacts(episode: EpisodeLike | null | undefined): StageArtifactReadiness {
return {
hasStory: hasNonEmptyText(episode?.novelText),
hasScript: hasScriptArtifacts(episode?.clips),
hasStoryboard: hasStoryboardArtifacts(episode?.storyboards),
hasVideo: hasVideoArtifacts(episode?.storyboards),
hasVoice: Array.isArray(episode?.voiceLines) && episode.voiceLines.length > 0,
}
}

View File

@@ -0,0 +1,11 @@
function padTwoDigits(value: number): string {
return String(value).padStart(2, '0')
}
export function formatDefaultProjectTimestamp(date: Date): string {
const month = padTwoDigits(date.getMonth() + 1)
const day = padTwoDigits(date.getDate())
const hours = padTwoDigits(date.getHours())
const minutes = padTwoDigits(date.getMinutes())
return `${month}-${day} ${hours}:${minutes}`
}

View File

@@ -0,0 +1,82 @@
import type { Locale } from '@/i18n/routing'
export const PROJECT_NAME_MAX_LENGTH = 100
export const PROJECT_DESCRIPTION_MAX_LENGTH = 500
export interface ProjectDraftInput {
name: string
description?: string | null
}
export interface NormalizedProjectDraft {
name: string
description: string | null
}
export type ProjectValidationCode =
| 'PROJECT_NAME_REQUIRED'
| 'PROJECT_NAME_TOO_LONG'
| 'PROJECT_DESCRIPTION_TOO_LONG'
export interface ProjectValidationIssue {
code: ProjectValidationCode
field: 'name' | 'description'
limit?: number
}
function normalizeNullableText(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed ? trimmed : null
}
export function normalizeProjectDraft(input: ProjectDraftInput): NormalizedProjectDraft {
return {
name: input.name.trim(),
description: normalizeNullableText(input.description),
}
}
export function validateProjectDraft(input: ProjectDraftInput): ProjectValidationIssue | null {
const normalized = normalizeProjectDraft(input)
if (!normalized.name) {
return {
code: 'PROJECT_NAME_REQUIRED',
field: 'name',
}
}
if (normalized.name.length > PROJECT_NAME_MAX_LENGTH) {
return {
code: 'PROJECT_NAME_TOO_LONG',
field: 'name',
limit: PROJECT_NAME_MAX_LENGTH,
}
}
if (normalized.description && normalized.description.length > PROJECT_DESCRIPTION_MAX_LENGTH) {
return {
code: 'PROJECT_DESCRIPTION_TOO_LONG',
field: 'description',
limit: PROJECT_DESCRIPTION_MAX_LENGTH,
}
}
return null
}
export function formatProjectValidationIssue(issue: ProjectValidationIssue, locale: Locale): string {
switch (issue.code) {
case 'PROJECT_NAME_REQUIRED':
return locale === 'en' ? 'Project name is required.' : '项目名称不能为空。'
case 'PROJECT_NAME_TOO_LONG':
return locale === 'en'
? `Project name cannot exceed ${PROJECT_NAME_MAX_LENGTH} characters.`
: `项目名称不能超过 ${PROJECT_NAME_MAX_LENGTH} 个字符。`
case 'PROJECT_DESCRIPTION_TOO_LONG':
return locale === 'en'
? `Project description cannot exceed ${PROJECT_DESCRIPTION_MAX_LENGTH} characters.`
: `项目描述不能超过 ${PROJECT_DESCRIPTION_MAX_LENGTH} 个字符。`
}
}

View File

@@ -0,0 +1,90 @@
'use client'
const PROBE_SUCCESS_COOLDOWN_MS = 60_000
const PROBE_RETRY_INTERVAL_MS = 2_000
const successfulProbeScopes = new Map<string, number>()
type RecoveryProbeContext = {
projectId: string
storageScopeKey?: string
}
type StartRecoveryProbeArgs = {
projectId: string
storageKey: string
storageScopeKey?: string
hasRunState: () => boolean
resolveActiveRunId: (context: RecoveryProbeContext) => Promise<string | null>
onRecovered: (runId: string) => void
}
function scheduleProbe(
callback: () => void,
delayMs: number,
): ReturnType<typeof setTimeout> {
return globalThis.setTimeout(callback, delayMs)
}
export function startRecoveryProbe(args: StartRecoveryProbeArgs): () => void {
let cancelled = false
let retryTimer: ReturnType<typeof setTimeout> | null = null
const clearRetryTimer = () => {
if (retryTimer) {
globalThis.clearTimeout(retryTimer)
retryTimer = null
}
}
const scheduleRetry = (delayMs: number) => {
if (cancelled || args.hasRunState()) return
clearRetryTimer()
retryTimer = scheduleProbe(() => {
void probe()
}, delayMs)
}
const probe = async () => {
if (cancelled || args.hasRunState()) return
const lastSuccessAt = successfulProbeScopes.get(args.storageKey)
if (lastSuccessAt) {
const cooldownRemainingMs =
PROBE_SUCCESS_COOLDOWN_MS - (Date.now() - lastSuccessAt)
if (cooldownRemainingMs > 0) {
scheduleRetry(cooldownRemainingMs)
return
}
}
const activeRunId = await args.resolveActiveRunId({
projectId: args.projectId,
storageScopeKey: args.storageScopeKey,
}).catch(() => null)
if (cancelled || args.hasRunState()) return
if (!activeRunId) {
scheduleRetry(PROBE_RETRY_INTERVAL_MS)
return
}
successfulProbeScopes.set(args.storageKey, Date.now())
args.onRecovered(activeRunId)
}
void probe()
return () => {
cancelled = true
clearRetryTimer()
}
}
export const recoveryProbeTestUtils = {
clearSuccessfulProbeScopes() {
successfulProbeScopes.clear()
},
PROBE_RETRY_INTERVAL_MS,
PROBE_SUCCESS_COOLDOWN_MS,
}

View File

@@ -3,12 +3,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
import { applyRunStreamEvent } from './state-machine'
import { clearRunSnapshot, loadRunSnapshot, saveRunSnapshot } from './snapshot'
import { subscribeRecoveredRun } from './recovered-run-subscription'
import { executeRunRequest } from './run-request-executor'
import { deriveRunStreamView } from './run-stream-view'
import type { RunResult, RunState, RunStreamView, UseRunStreamStateOptions } from './types'
import { apiFetch } from '@/lib/api-fetch'
import { startRecoveryProbe } from './recovery-probe'
export type {
RunResult,
@@ -18,8 +18,6 @@ export type {
} from './types'
const TASK_STREAM_TIMEOUT_MS = 1000 * 60 * 30
const PROBE_COOLDOWN_MS = 60_000
const probedScopes = new Map<string, number>()
export function useRunStreamState<TParams extends Record<string, unknown>>(
options: UseRunStreamStateOptions<TParams>,
@@ -40,7 +38,6 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
const [isRecoveredRunning, setIsRecoveredRunning] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const finalResultRef = useRef<RunResult | null>(null)
const hydratedStorageKeyRef = useRef<string | null>(null)
const resolveActiveRunIdRef = useRef(resolveActiveRunId)
const storageKey = useMemo(() => {
if (storageScopeKey) {
@@ -61,36 +58,19 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
resolveActiveRunIdRef.current = resolveActiveRunId
}, [resolveActiveRunId])
useEffect(() => {
if (!projectId) return
if (hydratedStorageKeyRef.current === storageKey) return
hydratedStorageKeyRef.current = storageKey
const snapshotRunState = loadRunSnapshot(storageKey)
if (!snapshotRunState) return
setRunState(snapshotRunState)
if (snapshotRunState.status === 'running') {
setIsRecoveredRunning(true)
}
}, [projectId, storageKey])
useEffect(() => {
if (!projectId || !resolveActiveRunIdRef.current) return
const lastProbed = probedScopes.get(storageKey)
if (lastProbed && Date.now() - lastProbed < PROBE_COOLDOWN_MS) return
probedScopes.set(storageKey, Date.now())
if (runStateRef.current) return
const existingSnapshot = loadRunSnapshot(storageKey)
if (existingSnapshot) return
let cancelled = false
void (async () => {
const activeRunId = await resolveActiveRunIdRef.current?.({
return startRecoveryProbe({
projectId,
storageKey,
storageScopeKey,
}).catch(() => null)
if (cancelled || !activeRunId) return
hasRunState: () => runStateRef.current !== null,
resolveActiveRunId: (context) =>
resolveActiveRunIdRef.current?.(context) ?? Promise.resolve(null),
onRecovered: (activeRunId) => {
const now = Date.now()
setRunState((prev) => {
if (prev) return prev
@@ -110,11 +90,8 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
}
})
setIsRecoveredRunning(true)
})()
return () => {
cancelled = true
}
},
})
}, [projectId, storageKey, storageScopeKey])
useEffect(() => {
@@ -150,11 +127,6 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
}
}, [isRecoveredRunning, runState, runState?.status])
useEffect(() => {
if (!projectId) return
saveRunSnapshot(storageKey, runState)
}, [projectId, runState, storageKey])
const run = useCallback(
async (params: TParams): Promise<RunResult> => {
if (!projectId) {
@@ -270,8 +242,7 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
setRunState(null)
finalResultRef.current = null
setIsRecoveredRunning(false)
clearRunSnapshot(storageKey)
}, [storageKey, stop])
}, [stop])
useEffect(() => {
const timer = window.setInterval(() => setClock(Date.now()), 500)

View File

@@ -1,58 +0,0 @@
import type { RunState } from './types'
export const SNAPSHOT_TTL_MS = 1000 * 60 * 60 * 6
type RunSnapshot = {
savedAt: number
runState: RunState
}
export function loadRunSnapshot(storageKey: string): RunState | null {
if (typeof window === 'undefined') return null
try {
const raw = window.sessionStorage.getItem(storageKey)
if (!raw) return null
const parsed = JSON.parse(raw) as RunSnapshot
if (!parsed || typeof parsed !== 'object') {
window.sessionStorage.removeItem(storageKey)
return null
}
if (typeof parsed.savedAt !== 'number' || Date.now() - parsed.savedAt > SNAPSHOT_TTL_MS) {
window.sessionStorage.removeItem(storageKey)
return null
}
const snapshotRunState = parsed.runState
if (!snapshotRunState || typeof snapshotRunState !== 'object' || typeof snapshotRunState.runId !== 'string') {
window.sessionStorage.removeItem(storageKey)
return null
}
return snapshotRunState
} catch {
try {
window.sessionStorage.removeItem(storageKey)
} catch { }
return null
}
}
export function saveRunSnapshot(storageKey: string, runState: RunState | null) {
if (typeof window === 'undefined') return
try {
if (!runState) {
window.sessionStorage.removeItem(storageKey)
return
}
const snapshot: RunSnapshot = {
savedAt: Date.now(),
runState,
}
window.sessionStorage.setItem(storageKey, JSON.stringify(snapshot))
} catch { }
}
export function clearRunSnapshot(storageKey: string) {
if (typeof window === 'undefined') return
try {
window.sessionStorage.removeItem(storageKey)
} catch { }
}

View File

@@ -3,6 +3,7 @@
import { useRunStreamState, type RunResult } from './useRunStreamState'
import { TASK_TYPE } from '@/lib/task/types'
import { apiFetch } from '@/lib/api-fetch'
import { selectRecoverableRun } from '@/lib/run-runtime/recovery'
export type ScriptToStoryboardRunParams = {
episodeId: string
@@ -46,13 +47,26 @@ export function useScriptToStoryboardRunStream({ projectId, episodeId }: UseScri
if (!response.ok) return null
const data = await response.json().catch(() => null)
const runs = data && typeof data === 'object' && Array.isArray((data as { runs?: unknown[] }).runs)
? (data as { runs: Array<{ id?: unknown; targetType?: unknown; targetId?: unknown; status?: unknown }> }).runs
? (data as {
runs: Array<{
id?: unknown
status?: unknown
createdAt?: unknown
updatedAt?: unknown
leaseExpiresAt?: unknown
heartbeatAt?: unknown
}>
}).runs
: []
for (const run of runs) {
if (!run || typeof run.id !== 'string' || !run.id) continue
return run.id
}
return null
const decision = selectRecoverableRun(runs.map((run) => ({
id: typeof run?.id === 'string' ? run.id : null,
status: typeof run?.status === 'string' ? run.status : null,
createdAt: typeof run?.createdAt === 'string' ? run.createdAt : null,
updatedAt: typeof run?.updatedAt === 'string' ? run.updatedAt : null,
leaseExpiresAt: typeof run?.leaseExpiresAt === 'string' ? run.leaseExpiresAt : null,
heartbeatAt: typeof run?.heartbeatAt === 'string' ? run.heartbeatAt : null,
})))
return decision.runId
},
validateParams: (params) => {
if (!params.episodeId) {

View File

@@ -3,6 +3,7 @@
import { useRunStreamState, type RunResult } from './useRunStreamState'
import { TASK_TYPE } from '@/lib/task/types'
import { apiFetch } from '@/lib/api-fetch'
import { selectRecoverableRun } from '@/lib/run-runtime/recovery'
export type StoryToScriptRunParams = {
episodeId: string
@@ -47,13 +48,26 @@ export function useStoryToScriptRunStream({ projectId, episodeId }: UseStoryToSc
if (!response.ok) return null
const data = await response.json().catch(() => null)
const runs = data && typeof data === 'object' && Array.isArray((data as { runs?: unknown[] }).runs)
? (data as { runs: Array<{ id?: unknown; targetType?: unknown; targetId?: unknown; status?: unknown }> }).runs
? (data as {
runs: Array<{
id?: unknown
status?: unknown
createdAt?: unknown
updatedAt?: unknown
leaseExpiresAt?: unknown
heartbeatAt?: unknown
}>
}).runs
: []
for (const run of runs) {
if (!run || typeof run.id !== 'string' || !run.id) continue
return run.id
}
return null
const decision = selectRecoverableRun(runs.map((run) => ({
id: typeof run?.id === 'string' ? run.id : null,
status: typeof run?.status === 'string' ? run.status : null,
createdAt: typeof run?.createdAt === 'string' ? run.createdAt : null,
updatedAt: typeof run?.updatedAt === 'string' ? run.updatedAt : null,
leaseExpiresAt: typeof run?.leaseExpiresAt === 'string' ? run.leaseExpiresAt : null,
heartbeatAt: typeof run?.heartbeatAt === 'string' ? run.heartbeatAt : null,
})))
return decision.runId
},
validateParams: (params) => {
if (!params.episodeId) {

View File

@@ -8,6 +8,7 @@ import {
invalidateGlobalCharacters,
invalidateGlobalLocations,
} from './asset-hub-mutations-shared'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
export function useAiDesignLocation() {
return useMutation({
@@ -21,7 +22,7 @@ export function useAiDesignLocation() {
},
'Failed to design location',
)
return resolveTaskResponse<{ prompt?: string }>(response)
return resolveTaskResponse<{ prompt?: string; availableSlots?: LocationAvailableSlot[] }>(response)
},
})
}
@@ -37,6 +38,7 @@ export function useCreateAssetHubLocation() {
folderId: string | null
artStyle: string
count?: number
availableSlots?: LocationAvailableSlot[]
}) => {
return await requestJsonWithError('/api/asset-hub/locations', {
method: 'POST',

View File

@@ -9,6 +9,7 @@ import {
invalidateGlobalCharacters,
invalidateGlobalLocations,
} from './asset-hub-mutations-shared'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
export function useUpdateCharacterName() {
const queryClient = useQueryClient()
@@ -134,9 +135,11 @@ export function useUpdateLocationSummary() {
mutationFn: async ({
locationId,
summary,
availableSlots,
}: {
locationId: string
summary: string
availableSlots?: LocationAvailableSlot[]
}) => {
return await requestJsonWithError(`/api/assets/${locationId}`, {
method: 'PATCH',
@@ -145,6 +148,7 @@ export function useUpdateLocationSummary() {
scope: 'global',
kind: 'location',
summary,
...(availableSlots ? { availableSlots } : {}),
}),
}, 'Failed to update location summary')
},
@@ -179,7 +183,7 @@ export function useAiModifyCharacterDescription() {
},
'Failed to modify character description',
)
return resolveTaskResponse<{ modifiedDescription?: string }>(response)
return resolveTaskResponse<{ modifiedDescription?: string; availableSlots?: LocationAvailableSlot[] }>(response)
},
})
}
@@ -211,7 +215,7 @@ export function useAiModifyLocationDescription() {
},
'Failed to modify location description',
)
return resolveTaskResponse<{ modifiedDescription?: string }>(response)
return resolveTaskResponse<{ modifiedDescription?: string; availableSlots?: LocationAvailableSlot[] }>(response)
},
})
}

View File

@@ -3,6 +3,7 @@ import { logError as _ulogError } from '@/lib/logging/core'
import type { Project } from '@/types/project'
import { queryKeys } from '../keys'
import type { ProjectAssetsData } from '../hooks/useProjectAssets'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
import { resolveTaskResponse } from '@/lib/task/client'
import { apiFetch } from '@/lib/api-fetch'
import {
@@ -149,10 +150,12 @@ export function useUpdateProjectLocationDescription(projectId: string) {
locationId,
description,
imageIndex,
availableSlots,
}: {
locationId: string
description: string
imageIndex?: number
availableSlots?: LocationAvailableSlot[]
}) => {
return await requestJsonWithError(`/api/novel-promotion/${projectId}/location`, {
method: 'PATCH',
@@ -161,6 +164,7 @@ export function useUpdateProjectLocationDescription(projectId: string) {
locationId,
imageIndex: typeof imageIndex === 'number' ? imageIndex : 0,
description,
...(availableSlots ? { availableSlots } : {}),
}),
}, 'Failed to update location description')
},
@@ -199,7 +203,7 @@ export function useAiModifyProjectLocationDescription(projectId: string) {
},
'Failed to modify location description',
)
return resolveTaskResponse<{ prompt?: string; modifiedDescription?: string }>(response)
return resolveTaskResponse<{ prompt?: string; modifiedDescription?: string; availableSlots?: LocationAvailableSlot[] }>(response)
},
})
}
@@ -220,7 +224,7 @@ export function useAiCreateProjectLocation(projectId: string) {
},
'Failed to design location',
)
return await resolveTaskResponse<{ prompt?: string }>(response)
return await resolveTaskResponse<{ prompt?: string; availableSlots?: LocationAvailableSlot[] }>(response)
},
})
}
@@ -240,6 +244,7 @@ export function useCreateProjectLocation(projectId: string) {
description: string
artStyle?: string
count?: number
availableSlots?: LocationAvailableSlot[]
}) =>
await requestJsonWithError(
`/api/novel-promotion/${projectId}/location`,

View File

@@ -0,0 +1,305 @@
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { TASK_STATUS } from '@/lib/task/types'
import { RUN_STATUS, RUN_STEP_STATUS } from './types'
const ACTIVE_RUN_STATUSES = [
RUN_STATUS.QUEUED,
RUN_STATUS.RUNNING,
RUN_STATUS.CANCELING,
] as const
const ACTIVE_STEP_STATUSES = [
RUN_STEP_STATUS.PENDING,
RUN_STEP_STATUS.RUNNING,
] as const
const TERMINAL_TASK_STATUSES = [
TASK_STATUS.COMPLETED,
TASK_STATUS.FAILED,
TASK_STATUS.CANCELED,
TASK_STATUS.DISMISSED,
] as const
type ActiveRunRow = {
id: string
status: string
taskId: string | null
updatedAt: Date
leaseExpiresAt: Date | null
heartbeatAt: Date | null
cancelRequestedAt: Date | null
}
type LinkedTaskRow = {
id: string
status: string
result: unknown
errorCode: string | null
errorMessage: string | null
finishedAt: Date | null
}
export type ReconciledActiveRun = {
runId: string
taskId: string | null
nextStatus: 'completed' | 'failed'
reason: string
}
const LEASE_EXPIRED_RECONCILE_GRACE_MS = 30_000
const CANCELING_RUN_TIMEOUT_MS = 5 * 60_000
function toObject(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
function toNullableJson(value: Record<string, unknown> | null) {
if (value === null) return Prisma.JsonNull
return value as Prisma.InputJsonValue
}
function buildFailedReason(task: LinkedTaskRow): string {
if (task.status === TASK_STATUS.CANCELED) {
return task.errorMessage?.trim() || 'Linked task was canceled'
}
if (task.status === TASK_STATUS.DISMISSED) {
return task.errorMessage?.trim() || 'Linked task was dismissed'
}
return task.errorMessage?.trim() || 'Linked task failed'
}
export async function reconcileActiveRunsFromTasks(limit = 200): Promise<ReconciledActiveRun[]> {
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(Math.floor(limit), 1), 500) : 200
const now = Date.now()
const activeRuns = await prisma.graphRun.findMany({
where: {
status: {
in: [...ACTIVE_RUN_STATUSES],
},
},
orderBy: {
updatedAt: 'asc',
},
take: safeLimit,
select: {
id: true,
status: true,
taskId: true,
updatedAt: true,
leaseExpiresAt: true,
heartbeatAt: true,
cancelRequestedAt: true,
},
}) as ActiveRunRow[]
if (activeRuns.length === 0) return []
const staleRuns = activeRuns.filter((run) => {
if (
run.status === RUN_STATUS.RUNNING
&& run.leaseExpiresAt
&& now - run.leaseExpiresAt.getTime() >= LEASE_EXPIRED_RECONCILE_GRACE_MS
) {
return !run.heartbeatAt || run.heartbeatAt.getTime() <= run.leaseExpiresAt.getTime()
}
if (
run.status === RUN_STATUS.CANCELING
&& run.cancelRequestedAt
&& now - run.cancelRequestedAt.getTime() >= CANCELING_RUN_TIMEOUT_MS
) {
return true
}
return false
})
const reconciled: ReconciledActiveRun[] = []
for (const run of staleRuns) {
const failureMessage = run.status === RUN_STATUS.CANCELING
? 'Run cancel request timed out'
: 'Run lease expired without heartbeat'
const failureCode = run.status === RUN_STATUS.CANCELING ? 'RUN_CANCEL_TIMEOUT' : 'RUN_LEASE_EXPIRED'
const settled = await prisma.$transaction(async (tx) => {
const updatedRun = await tx.graphRun.updateMany({
where: {
id: run.id,
status: {
in: [...ACTIVE_RUN_STATUSES],
},
},
data: {
status: RUN_STATUS.FAILED,
errorCode: failureCode,
errorMessage: failureMessage,
finishedAt: new Date(),
leaseOwner: null,
leaseExpiresAt: null,
heartbeatAt: null,
},
})
if (updatedRun.count === 0) return false
await tx.graphStep.updateMany({
where: {
runId: run.id,
status: {
in: [...ACTIVE_STEP_STATUSES],
},
},
data: {
status: RUN_STEP_STATUS.FAILED,
finishedAt: new Date(),
lastErrorCode: failureCode,
lastErrorMessage: failureMessage,
},
})
return true
})
if (settled) {
reconciled.push({
runId: run.id,
taskId: run.taskId,
nextStatus: 'failed',
reason: failureCode,
})
}
}
const taskIds = activeRuns
.map((run) => run.taskId)
.filter((taskId): taskId is string => typeof taskId === 'string' && taskId.trim().length > 0)
if (taskIds.length === 0) return reconciled
const tasks = await prisma.task.findMany({
where: {
id: {
in: taskIds,
},
status: {
in: [...TERMINAL_TASK_STATUSES],
},
},
select: {
id: true,
status: true,
result: true,
errorCode: true,
errorMessage: true,
finishedAt: true,
},
}) as LinkedTaskRow[]
if (tasks.length === 0) return reconciled
const taskMap = new Map<string, LinkedTaskRow>(tasks.map((task) => [task.id, task]))
for (const run of activeRuns) {
const taskId = run.taskId?.trim() || ''
if (!taskId) continue
const task = taskMap.get(taskId)
if (!task) continue
const settledAt = task.finishedAt || new Date()
if (task.status === TASK_STATUS.COMPLETED) {
const settled = await prisma.$transaction(async (tx) => {
const updatedRun = await tx.graphRun.updateMany({
where: {
id: run.id,
status: {
in: [...ACTIVE_RUN_STATUSES],
},
},
data: {
status: RUN_STATUS.COMPLETED,
output: toNullableJson(toObject(task.result)),
errorCode: null,
errorMessage: null,
finishedAt: settledAt,
leaseOwner: null,
leaseExpiresAt: null,
heartbeatAt: null,
},
})
if (updatedRun.count === 0) return false
await tx.graphStep.updateMany({
where: {
runId: run.id,
status: {
in: [...ACTIVE_STEP_STATUSES],
},
},
data: {
status: RUN_STEP_STATUS.COMPLETED,
finishedAt: settledAt,
lastErrorCode: null,
lastErrorMessage: null,
},
})
return true
})
if (settled) {
reconciled.push({
runId: run.id,
taskId: run.taskId,
nextStatus: 'completed',
reason: 'linked task already completed',
})
}
continue
}
const failureMessage = buildFailedReason(task)
const settled = await prisma.$transaction(async (tx) => {
const updatedRun = await tx.graphRun.updateMany({
where: {
id: run.id,
status: {
in: [...ACTIVE_RUN_STATUSES],
},
},
data: {
status: RUN_STATUS.FAILED,
errorCode: task.errorCode?.trim() || 'TASK_TERMINATED',
errorMessage: failureMessage,
finishedAt: settledAt,
leaseOwner: null,
leaseExpiresAt: null,
heartbeatAt: null,
},
})
if (updatedRun.count === 0) return false
await tx.graphStep.updateMany({
where: {
runId: run.id,
status: {
in: [...ACTIVE_STEP_STATUSES],
},
},
data: {
status: RUN_STEP_STATUS.FAILED,
finishedAt: settledAt,
lastErrorCode: task.errorCode?.trim() || 'TASK_TERMINATED',
lastErrorMessage: failureMessage,
},
})
return true
})
if (settled) {
reconciled.push({
runId: run.id,
taskId: run.taskId,
nextStatus: 'failed',
reason: `linked task already ${task.status}`,
})
}
}
return reconciled
}

View File

@@ -0,0 +1,85 @@
import { RUN_STATUS, type RunStatus } from './types'
export type RecoverableRunRecord = {
id?: string | null
status?: string | null
createdAt?: string | null
updatedAt?: string | null
leaseExpiresAt?: string | null
heartbeatAt?: string | null
}
export type RunRecoveryDecision = {
runId: string | null
reason: 'latest_active' | 'expired_lease' | 'terminal' | 'missing'
}
const ACTIVE_RUN_STATUSES = new Set<RunStatus>([
RUN_STATUS.QUEUED,
RUN_STATUS.RUNNING,
RUN_STATUS.CANCELING,
])
function toTimestamp(value: string | null | undefined): number {
if (!value) return 0
const ts = Date.parse(value)
return Number.isFinite(ts) ? ts : 0
}
export function isRecoverableRunRecord(run: RecoverableRunRecord, now = Date.now()) {
const status = typeof run.status === 'string' ? run.status : ''
if (!ACTIVE_RUN_STATUSES.has(status as RunStatus)) {
return false
}
if (status === RUN_STATUS.QUEUED) {
return true
}
const leaseExpiresAt = toTimestamp(run.leaseExpiresAt)
if (!leaseExpiresAt) {
return true
}
if (leaseExpiresAt >= now) {
return true
}
const heartbeatAt = toTimestamp(run.heartbeatAt)
return heartbeatAt > leaseExpiresAt
}
export function selectRecoverableRun(runs: RecoverableRunRecord[], now = Date.now()): RunRecoveryDecision {
if (!Array.isArray(runs) || runs.length === 0) {
return {
runId: null,
reason: 'missing',
}
}
const orderedRuns = [...runs].sort((left, right) => {
const updatedDelta = toTimestamp(right.updatedAt) - toTimestamp(left.updatedAt)
if (updatedDelta !== 0) return updatedDelta
return toTimestamp(right.createdAt) - toTimestamp(left.createdAt)
})
let sawExpiredLease = false
for (const run of orderedRuns) {
const runId = typeof run.id === 'string' ? run.id.trim() : ''
if (!runId) continue
const status = typeof run.status === 'string' ? run.status : ''
if (!ACTIVE_RUN_STATUSES.has(status as RunStatus)) {
continue
}
if (isRecoverableRunRecord(run, now)) {
return {
runId,
reason: 'latest_active',
}
}
sawExpiredLease = true
}
return {
runId: null,
reason: sawExpiredLease ? 'expired_lease' : 'terminal',
}
}

View File

@@ -1,4 +1,5 @@
import { prisma } from '@/lib/prisma'
import { selectRecoverableRun } from '@/lib/run-runtime/recovery'
import { resolveRetryInvalidationStepKeys } from '@/lib/workflow-engine/dependencies'
import {
RUN_EVENT_TYPE,
@@ -245,6 +246,24 @@ function mapRunRow(run: GraphRunRow) {
}
}
function mapRunRowToRecoverableRecord(run: GraphRunRow) {
return {
id: run.id,
status: run.status,
createdAt: run.createdAt.toISOString(),
updatedAt: run.updatedAt.toISOString(),
leaseExpiresAt: toIso(run.leaseExpiresAt),
heartbeatAt: toIso(run.heartbeatAt),
}
}
function filterRecoverableRunRows(rows: GraphRunRow[]): GraphRunRow[] {
if (rows.length === 0) return []
const recoverableRunId = selectRecoverableRun(rows.map(mapRunRowToRecoverableRecord)).runId
if (!recoverableRunId) return []
return rows.filter((row) => row.id === recoverableRunId)
}
function mapStepRow(step: GraphStepRow) {
return {
id: step.id,
@@ -718,9 +737,9 @@ export async function findReusableActiveRun(params: {
{ updatedAt: 'desc' },
{ createdAt: 'desc' },
],
take: 1,
take: 20,
})
const row = rows[0]
const row = filterRecoverableRunRows(rows)[0] || null
return row ? mapRunRow(row) : null
}
@@ -839,10 +858,19 @@ export async function listRuns(input: ListRunsInput) {
...(input.episodeId ? { episodeId: input.episodeId } : {}),
...(statusFilter ? { status: statusFilter } : {}),
},
orderBy: { createdAt: 'desc' },
orderBy: [
{ updatedAt: 'desc' },
{ createdAt: 'desc' },
],
take: safeLimit,
})
return rows.map(mapRunRow)
const filteredRows = input.recoverableOnly
? filterRecoverableRunRows(rows)
: rows
const latestRows = input.latestOnly
? filteredRows.slice(0, 1)
: filteredRows
return latestRows.map(mapRunRow)
}
export async function requestRunCancel(params: {

View File

@@ -85,6 +85,8 @@ export type ListRunsInput = {
episodeId?: string
statuses?: RunStatus[]
limit?: number
recoverableOnly?: boolean
latestOnly?: boolean
}
export type StateRef = {

View File

@@ -173,7 +173,11 @@ export function getFilteredFullDescription(characters: CharacterAsset[], clipCha
}
// 根据 clip.location 筛选场景描述
export function getFilteredLocationsDescription(locations: LocationAsset[], clipLocation: string | null): string {
export function getFilteredLocationsDescription(
locations: LocationAsset[],
clipLocation: string | null,
locale: Locale = 'zh',
): string {
return compileAssetPromptFragments(buildPromptAssetContext({
characters: [],
locations,
@@ -181,6 +185,7 @@ export function getFilteredLocationsDescription(locations: LocationAsset[], clip
clipCharacters: [],
clipLocation,
clipProps: [],
locale,
})).locationDescriptionText
}
@@ -405,7 +410,11 @@ export async function executePhase2(
const clipProps = parseClipProps(clip.props)
const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters, clipCharacters)
const filteredLocationsDescription = getFilteredLocationsDescription(novelPromotionData.locations, clipLocation)
const filteredLocationsDescription = getFilteredLocationsDescription(
novelPromotionData.locations,
clipLocation,
locale,
)
const filteredPropsDescription = compileAssetPromptFragments(buildPromptAssetContext({
characters: [],
locations: [],
@@ -588,7 +597,11 @@ export async function executePhase3(
const clipProps = parseClipProps(clip.props)
const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters, clipCharacters)
const filteredLocationsDescription = getFilteredLocationsDescription(novelPromotionData.locations, clipLocation)
const filteredLocationsDescription = getFilteredLocationsDescription(
novelPromotionData.locations,
clipLocation,
locale,
)
const filteredPropsDescription = compileAssetPromptFragments(buildPromptAssetContext({
characters: [],
locations: [],

View File

@@ -255,12 +255,14 @@ export function startTaskWatchdog() {
// 2. 对账 DB vs BullMQ
const reconciled = await reconcileActiveTasks()
const { reconcileActiveRunsFromTasks } = await import('@/lib/run-runtime/reconcile')
const reconciledRuns = await reconcileActiveRunsFromTasks()
const total = sweptProcessing.length + reconciled.length
const total = sweptProcessing.length + reconciled.length + reconciledRuns.length
if (total > 0) {
logger.info({
action: 'watchdog.cycle',
message: `Watchdog: ${sweptProcessing.length} heartbeat-timeout, ${reconciled.length} orphan-reconciled`,
message: `Watchdog: ${sweptProcessing.length} heartbeat-timeout, ${reconciled.length} orphan-reconciled, ${reconciledRuns.length} run-reconciled`,
})
}
} catch (error) {

View File

@@ -48,6 +48,14 @@ function resolveRunIdFromPayload(payload: unknown): string | null {
return runIdFromMeta || null
}
export function isActiveTaskStatus(status: string | null | undefined) {
return status === TASK_STATUS.QUEUED || status === TASK_STATUS.PROCESSING
}
export function shouldAttachNewTaskToReusableRun(reusableRunTaskStatus: string | null | undefined) {
return !isActiveTaskStatus(reusableRunTaskStatus)
}
export function normalizeTaskPayload(type: TaskType, payload?: Record<string, unknown> | null) {
const nextPayload = {
...(payload || {}),
@@ -146,23 +154,20 @@ export async function submitTask(params: {
targetId: params.targetId,
})
: null
const reusableRunTask = reusableRun?.taskId
? await getTaskById(reusableRun.taskId)
: null
if (runCentricTask && reusableRun?.taskId) {
const existingTask = await getTaskById(reusableRun.taskId)
if (
existingTask
&& (existingTask.status === TASK_STATUS.QUEUED || existingTask.status === TASK_STATUS.PROCESSING)
) {
if (runCentricTask && reusableRun && reusableRunTask && isActiveTaskStatus(reusableRunTask.status)) {
return {
success: true,
async: true,
taskId: existingTask.id,
taskId: reusableRunTask.id,
runId: reusableRun.id,
status: existingTask.status,
status: reusableRunTask.status,
deduped: true as const,
}
}
}
const { task, deduped } = await createTask({
userId: params.userId,
@@ -177,8 +182,11 @@ export async function submitTask(params: {
maxAttempts: params.maxAttempts,
billingInfo: resolvedBillingInfo || null,
})
let runId = reusableRun?.id || resolveRunIdFromPayload(task.payload)
if (!deduped && reusableRun && runId) {
const reusableRunId = reusableRun && shouldAttachNewTaskToReusableRun(reusableRunTask?.status)
? (reusableRun?.id || null)
: null
let runId = reusableRunId || resolveRunIdFromPayload(task.payload)
if (!deduped && reusableRunId && runId) {
const payloadWithRunId = {
...normalizedPayload,
runId,

View File

@@ -10,6 +10,7 @@ import {
type CharacterBrief,
} from './analyze-global-parse'
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
import { normalizeLocationAvailableSlots } from '@/lib/location-available-slots'
export type AnalyzeGlobalStats = {
totalChunks: number
@@ -166,6 +167,7 @@ export async function persistAnalyzeGlobalChunk(params: {
: (readText(loc.description) ? [readText(loc.description)] : [])
const descriptions = descriptionsRaw.map((item) => readText(item)).filter(Boolean)
const cleanDescriptions = descriptions.map((item) => removeLocationPromptSuffix(item))
const availableSlots = normalizeLocationAvailableSlots(loc.available_slots)
const created = await prisma.novelPromotionLocation.create({
data: {
@@ -182,6 +184,7 @@ export async function persistAnalyzeGlobalChunk(params: {
locationId: created.id,
descriptions: cleanDescriptions,
fallbackDescription: summary || name,
availableSlots,
})
params.existingLocationNames.push(name)
@@ -219,6 +222,7 @@ export async function persistAnalyzeGlobalChunk(params: {
locationId: created.id,
descriptions: [summary],
fallbackDescription: summary,
availableSlots: [],
})
params.existingPropNames.push(name)
params.stats.newProps += 1

View File

@@ -11,6 +11,7 @@ import type { TaskJobData } from '@/lib/task/types'
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
import { resolveAnalysisModel } from './resolve-analysis-model'
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
import { normalizeLocationAvailableSlots } from '@/lib/location-available-slots'
function readAssetKind(value: Record<string, unknown>): string {
return typeof value.assetKind === 'string' ? value.assetKind : 'location'
@@ -316,10 +317,12 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
})
const cleanDescriptions = descriptions.map((value) => removeLocationPromptSuffix(value || ''))
const availableSlots = normalizeLocationAvailableSlots(item.available_slots)
await seedProjectLocationBackedImageSlots({
locationId: created.id,
descriptions: cleanDescriptions,
fallbackDescription: readText(item.summary) || name,
availableSlots,
})
createdLocations.push(created)
@@ -352,6 +355,7 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
locationId: created.id,
descriptions: [summary],
fallbackDescription: summary,
availableSlots: [],
})
existingPropNameSet.add(normalizedName)
createdProps.push(created)

View File

@@ -68,5 +68,6 @@ export async function handleAssetHubAIDesignTask(job: Job<TaskJobData>) {
return {
prompt: result.prompt,
availableSlots: result.availableSlots ?? [],
}
}

View File

@@ -9,6 +9,7 @@ import type { TaskJobData } from '@/lib/task/types'
import { TASK_TYPE } from '@/lib/task/types'
import { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
import { normalizeLocationAvailableSlots } from '@/lib/location-available-slots'
function readRequiredString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) {
@@ -19,13 +20,19 @@ function readRequiredString(value: unknown, field: string): string {
import { safeParseJsonObject } from '@/lib/json-repair'
function parseJsonPrompt(responseText: string): string {
function parseJsonPrompt(responseText: string): {
prompt: string
availableSlots: ReturnType<typeof normalizeLocationAvailableSlots>
} {
const parsed = safeParseJsonObject(responseText)
const prompt = typeof parsed.prompt === 'string' ? parsed.prompt.trim() : ''
if (!prompt) {
throw new Error('No prompt field in response')
}
return prompt
return {
prompt,
availableSlots: normalizeLocationAvailableSlots(parsed.available_slots),
}
}
export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
@@ -96,7 +103,7 @@ export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
await streamCallbacks.flush()
await assertTaskActive(job, 'asset_hub_ai_modify_parse')
const modifiedDescription = parseJsonPrompt(completion.text)
const parsed = parseJsonPrompt(completion.text)
await reportTaskProgress(job, 96, {
stage: 'asset_hub_ai_modify_done',
@@ -110,6 +117,7 @@ export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
return {
success: true,
modifiedDescription,
modifiedDescription: parsed.prompt,
availableSlots: parsed.availableSlots,
}
}

View File

@@ -5,6 +5,7 @@ import { type TaskJobData } from '@/lib/task/types'
import { encodeImageUrls } from '@/lib/contracts/image-urls-contract'
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'
import { buildLocationImagePromptCore } from '@/lib/location-image-prompt'
import {
assertTaskActive,
getUserModels,
@@ -32,6 +33,7 @@ interface GlobalCharacterRecord {
interface GlobalLocationImageRecord {
id: string
description: string | null
availableSlots?: string | null
}
interface GlobalLocationRecord {
@@ -140,7 +142,12 @@ export async function handleAssetHubImageTask(job: Job<TaskJobData>) {
for (const image of targetImages) {
if (!image.description) continue
const prompt = artStyle ? `${addLocationPromptSuffix(image.description)}${artStyle}` : addLocationPromptSuffix(image.description)
const promptCore = buildLocationImagePromptCore({
description: image.description,
availableSlotsRaw: image.availableSlots,
locale: job.data.locale === 'en' ? 'en' : 'zh',
})
const prompt = artStyle ? `${addLocationPromptSuffix(promptCore)}${artStyle}` : addLocationPromptSuffix(promptCore)
const cosKey = await generateLabeledImageToCos({
job,

View File

@@ -13,6 +13,10 @@ import {
import {
normalizeReferenceImagesForGeneration,
} from '@/lib/media/outbound-image'
import {
type LocationAvailableSlot,
stringifyLocationAvailableSlots,
} from '@/lib/location-available-slots'
import {
AnyObj,
parseImageUrls,
@@ -51,6 +55,7 @@ interface GlobalLocationImageRecord {
id: string
imageIndex: number
description: string | null
availableSlots?: string | null
imageUrl: string | null
previousDescription: string | null
}
@@ -166,7 +171,7 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
descriptions: appearance.descriptions,
fallbackDescription: appearance.description,
index: targetImageIndex,
nextDescription,
nextDescription: nextDescription.prompt,
})
} catch (err) {
logger.warn({ message: '资产库角色描述同步失败', details: { error: String(err) } })
@@ -231,7 +236,10 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
const labeled = await withLabelBar(source, location.name)
const cosKey = await uploadImageSourceToCos(labeled, 'global-location-modify', locationImage.id)
let extractedDescription: string | undefined
let extractedDescription: {
prompt: string
availableSlots: LocationAvailableSlot[]
} | null = null
if (locationImage.description && modifyInstruction && userModels.analysisModel) {
try {
extractedDescription = await generateModifiedAssetDescription({
@@ -256,7 +264,10 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
previousImageUrl: locationImage.imageUrl,
previousDescription: locationImage.description || null,
imageUrl: cosKey,
...(extractedDescription ? { description: extractedDescription } : {}),
...(extractedDescription ? {
description: extractedDescription.prompt,
availableSlots: stringifyLocationAvailableSlots(extractedDescription.availableSlots),
} : {}),
},
})

View File

@@ -28,6 +28,7 @@ interface CharacterLike {
interface LocationImageLike {
description?: string | null
availableSlots?: string | null
imageIndex?: number
isSelected: boolean
imageUrl: string | null
@@ -53,6 +54,7 @@ interface PanelLike {
export interface PanelCharacterReference {
name: string
appearance?: string
slot?: string
}
interface NovelDataDb {
@@ -145,11 +147,12 @@ export function parsePanelCharacterReferences(value: string | null | undefined):
.map((item: unknown) => {
if (typeof item === 'string') return { name: item }
if (!item || typeof item !== 'object') return null
const candidate = item as { name?: unknown; appearance?: unknown }
const candidate = item as { name?: unknown; appearance?: unknown; slot?: unknown }
if (typeof candidate.name === 'string') {
return {
name: candidate.name,
appearance: typeof candidate.appearance === 'string' ? candidate.appearance : undefined,
slot: typeof candidate.slot === 'string' ? candidate.slot : undefined,
}
}
return null

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