feat: refine UI, improve UX, optimize the analysis pipeline, and add character standing positions
This commit is contained in:
@@ -29,5 +29,9 @@ Rules:
|
|||||||
2. Keep continuity across neighboring panels.
|
2. Keep continuity across neighboring panels.
|
||||||
3. Adapt to scene_type and story rhythm.
|
3. Adapt to scene_type and story rhythm.
|
||||||
4. Technical notes must be directly actionable by image/video generation.
|
4. Technical notes must be directly actionable by image/video generation.
|
||||||
5. JSON only, no markdown.
|
5. If characters already carry `slot`, treat it as a preferred placement anchor, not an absolute boundary.
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -129,8 +129,11 @@
|
|||||||
3. 每个元素必须包含 panel_number 字段
|
3. 每个元素必须包含 panel_number 字段
|
||||||
4. 使用相对方向(画面左侧/右侧),禁止使用东南西北
|
4. 使用相对方向(画面左侧/右侧),禁止使用东南西北
|
||||||
5. 角色位置必须与镜头描述一致!
|
5. 角色位置必须与镜头描述一致!
|
||||||
6. 景深根据 shot_type(全景/中景/近景/特写)自动调整
|
6. 如果角色对象中包含 slot,screen_position / posture / facing 应优先参考该位置语义,但 slot 不是绝对硬限制
|
||||||
7. ⚠️ 对话镜头必须使用浅景深(T2.8或更小),并且注明其他人虚化,确保只有说话者脸部清晰
|
7. 当镜头属于移动过程、入口/出口、过渡区域、路径空间、临时位置、空镜、想象空间、梦境、回忆或抽象空间时,可以基于镜头描述自由决定构图与位置,不必强行贴合 slot
|
||||||
8. 如果镜头涉及不同场景,灯光和色调要相应调整
|
8. slot 若被引用,必须视为一条完整的位置描述,禁止缩写、改写、总结或替换成短词
|
||||||
9. 输出要简洁,每个镜头的规则独立完整
|
9. 景深根据 shot_type(全景/中景/近景/特写)自动调整
|
||||||
10. ⚠️ JSON安全:所有引号(""''等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 "
|
10. ⚠️ 对话镜头必须使用浅景深(T2.8或更小),并且注明其他人虚化,确保只有说话者脸部清晰
|
||||||
|
11. 如果镜头涉及不同场景,灯光和色调要相应调整
|
||||||
|
12. 输出要简洁,每个镜头的规则独立完整
|
||||||
|
13. ⚠️ JSON安全:所有引号(""''等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 "
|
||||||
|
|||||||
@@ -33,4 +33,5 @@ Execution rules:
|
|||||||
1. Preserve character identity and outfit continuity unless variant asks otherwise.
|
1. Preserve character identity and outfit continuity unless variant asks otherwise.
|
||||||
2. Preserve location continuity.
|
2. Preserve location continuity.
|
||||||
3. Change framing/angle/composition according to target shot and camera move.
|
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.
|
||||||
|
|||||||
@@ -66,7 +66,8 @@
|
|||||||
2. 保持角色外观与参考图一致(服装、发型、体型)
|
2. 保持角色外观与参考图一致(服装、发型、体型)
|
||||||
3. 保持场景氛围与参考图一致(室内布置、光线、色调)
|
3. 保持场景氛围与参考图一致(室内布置、光线、色调)
|
||||||
4. 改变镜头视角/景别/构图以匹配变体要求
|
4. 改变镜头视角/景别/构图以匹配变体要求
|
||||||
5. 输出图像比例: {aspect_ratio}
|
5. 如果角色信息或场景参考中提供了固定站位 / 可站位置,必须保持人物仍然处于同一固定站位,不得随意换边、换前后景或漂移到其他区域
|
||||||
|
6. 输出图像比例: {aspect_ratio}
|
||||||
|
|
||||||
======================================
|
======================================
|
||||||
【风格要求】
|
【风格要求】
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ Location info:
|
|||||||
|
|
||||||
Task:
|
Task:
|
||||||
For each panel, output a complete panel object with improved cinematic detail.
|
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:
|
Required fields per panel:
|
||||||
- panel_number
|
- panel_number
|
||||||
@@ -30,7 +32,7 @@ Output schema example (field names must be preserved):
|
|||||||
{
|
{
|
||||||
"panel_number": 1,
|
"panel_number": 1,
|
||||||
"description": "panel description",
|
"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",
|
"location": "location name",
|
||||||
"scene_type": "daily",
|
"scene_type": "daily",
|
||||||
"source_text": "source text excerpt",
|
"source_text": "source text excerpt",
|
||||||
@@ -46,5 +48,9 @@ Rules:
|
|||||||
2. Keep source_text semantically aligned with input; do not rewrite story meaning.
|
2. Keep source_text semantically aligned with input; do not rewrite story meaning.
|
||||||
3. video_prompt should be motion-ready and concrete.
|
3. video_prompt should be motion-ready and concrete.
|
||||||
4. Prefer age+gender wording in video_prompt when naming actors in camera directions.
|
4. Prefer age+gender wording in video_prompt when naming actors in camera directions.
|
||||||
5. Return JSON array only.
|
5. Preserve every input `slot` when the character is stably positioned, and reflect it as a preferred anchor in refined description/video_prompt.
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
- 为每个分镜设计景别、视角、镜头运动
|
- 为每个分镜设计景别、视角、镜头运动
|
||||||
- 撰写video_prompt(用年龄段+性别替代角色名)
|
- 撰写video_prompt(用年龄段+性别替代角色名)
|
||||||
- ⚠️ 保留输入分镜中的所有原始字段(特别是 source_text,必须原样保留)
|
- ⚠️ 保留输入分镜中的所有原始字段(特别是 source_text,必须原样保留)
|
||||||
|
- 如果输入角色包含 slot,应优先原样保留,并让 refined description/video_prompt 优先参考该位置
|
||||||
|
|
||||||
【镜头语言库】
|
【镜头语言库】
|
||||||
|
|
||||||
@@ -147,7 +148,7 @@
|
|||||||
"camera_move": "固定",
|
"camera_move": "固定",
|
||||||
"description": "角色A站在桌前,双手撑在桌面上,表情严肃地看着对面的角色B",
|
"description": "角色A站在桌前,双手撑在桌面上,表情严肃地看着对面的角色B",
|
||||||
"video_prompt": "年轻男子站在桌前,双手撑在桌面上,表情严肃,正在说话,镜头固定拍摄",
|
"video_prompt": "年轻男子站在桌前,双手撑在桌面上,表情严肃,正在说话,镜头固定拍摄",
|
||||||
"characters": [{"name": "角色A", "appearance": "初始形象"}],
|
"characters": [{"name": "角色A", "appearance": "初始形象", "slot": "皇宫正中龙椅前方台阶下的位置"}],
|
||||||
"location": "办公室",
|
"location": "办公室",
|
||||||
"scene_type": "daily",
|
"scene_type": "daily",
|
||||||
"source_text": "角色A对角色B说:你好"
|
"source_text": "角色A对角色B说:你好"
|
||||||
@@ -179,5 +180,9 @@
|
|||||||
8. 根据输入的分镜数量动态处理
|
8. 根据输入的分镜数量动态处理
|
||||||
9. panel_number、characters、location、scene_type保持不变
|
9. panel_number、characters、location、scene_type保持不变
|
||||||
10. description可以适当优化,但不要改变核心内容
|
10. description可以适当优化,但不要改变核心内容
|
||||||
11. ⚠️ 必须保留输入分镜中的 source_text 字段,原样输出到结果中,不得遗漏或修改
|
11. 如果输入中存在 slot,应优先保留并优先参考该位置,但 slot 不是绝对硬边界
|
||||||
12. ⚠️ JSON安全:所有引号(""''等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 "
|
12. 当镜头明显属于移动过程、入口/出口、过渡区域、路径空间、临时位置、空镜、想象空间、梦境、回忆或抽象空间时,可以删除 slot 或保留 slot 但不严格贴合其静态位置
|
||||||
|
13. 若不使用 slot,应根据 source_text、动作过程、空间关系与镜头调度自由决定人物位置,不要为了命中 slot 而破坏叙事逻辑
|
||||||
|
14. slot 若被保留,必须原样保留为完整位置描述,禁止缩写、改写、总结或替换成短词
|
||||||
|
15. ⚠️ 必须保留输入分镜中的 source_text 字段,原样输出到结果中,不得遗漏或修改
|
||||||
|
16. ⚠️ JSON安全:所有引号(""''等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 "
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Output format (single JSON object only):
|
|||||||
{
|
{
|
||||||
"panel_number": 0,
|
"panel_number": 0,
|
||||||
"description": "visual description",
|
"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",
|
"location": "location name",
|
||||||
"scene_type": "daily",
|
"scene_type": "daily",
|
||||||
"source_text": "source text or transition shot",
|
"source_text": "source text or transition shot",
|
||||||
@@ -37,5 +37,8 @@ Rules:
|
|||||||
1. Return one object only (not array).
|
1. Return one object only (not array).
|
||||||
2. Keep narrative and spatial continuity between previous and next panel.
|
2. Keep narrative and spatial continuity between previous and next panel.
|
||||||
3. Use valid character and location names from provided context.
|
3. Use valid character and location names from provided context.
|
||||||
4. JSON only, no markdown.
|
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. ⚠️ 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. 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.
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| panel_number | number | 固定填 0(由系统重新编号) |
|
| panel_number | number | 固定填 0(由系统重新编号) |
|
||||||
| description | string | 画面描述:包含角色动作、位置、表情。禁止身份称呼(如"母亲"),使用具体角色名。禁止主观情绪词(如"显得尴尬"),只描述可视化动作。 |
|
| description | string | 画面描述:包含角色动作、位置、表情。禁止身份称呼(如"母亲"),使用具体角色名。禁止主观情绪词(如"显得尴尬"),只描述可视化动作。 |
|
||||||
| characters | array | 出现的角色列表,格式:`[{"name": "角色名", "appearance": "形象名"}]`。角色名必须与角色信息中的名字完全一致。形象名从角色信息的形象列表中选择。 |
|
| characters | array | 出现的角色列表,格式:`[{"name": "角色名", "appearance": "形象名", "slot": "场景位置描述"}]`。角色名必须与角色信息中的名字完全一致。形象名从角色信息的形象列表中选择。如果场景信息中提供了可站位置,应优先为稳定停留的角色选择 slot,并直接复用可站位置列表中的完整位置描述。动态移动、过渡区域、入口出口、空镜、想象空间等情况可以不使用 slot。 |
|
||||||
| location | string | 场景名称,必须与场景信息中的名字完全一致 |
|
| location | string | 场景名称,必须与场景信息中的名字完全一致 |
|
||||||
| scene_type | string | 场景类型,枚举值:`daily`(日常)/ `emotion`(情感)/ `action`(动作)/ `epic`(史诗)/ `suspense`(悬疑) |
|
| scene_type | string | 场景类型,枚举值:`daily`(日常)/ `emotion`(情感)/ `action`(动作)/ `epic`(史诗)/ `suspense`(悬疑) |
|
||||||
| source_text | string | 对应的原文片段。可以基于前后镜头的 source_text 推断,或填写"过渡镜头" |
|
| source_text | string | 对应的原文片段。可以基于前后镜头的 source_text 推断,或填写"过渡镜头" |
|
||||||
@@ -72,6 +72,8 @@
|
|||||||
❌ location 使用不存在的场景名 → ✅ 必须与场景信息完全一致
|
❌ location 使用不存在的场景名 → ✅ 必须与场景信息完全一致
|
||||||
❌ 特写镜头使用非固定的镜头运动 → ✅ 特写必须用"固定"
|
❌ 特写镜头使用非固定的镜头运动 → ✅ 特写必须用"固定"
|
||||||
❌ video_prompt 中使用角色名 → ✅ 必须用年龄段+性别
|
❌ video_prompt 中使用角色名 → ✅ 必须用年龄段+性别
|
||||||
|
❌ 稳定停留位置明明适合使用已有 slot,却完全无视场景锚点 → ✅ 优先复用场景可站位置中的 slot
|
||||||
|
❌ 把 slot 改写成短词、代号、缩写 → ✅ 若使用 slot,必须直接复制可站位置列表中的完整位置描述
|
||||||
|
|
||||||
======================================
|
======================================
|
||||||
【输出格式】
|
【输出格式】
|
||||||
@@ -83,7 +85,7 @@
|
|||||||
{
|
{
|
||||||
"panel_number": 0,
|
"panel_number": 0,
|
||||||
"description": "...",
|
"description": "...",
|
||||||
"characters": [{"name": "...", "appearance": "..."}],
|
"characters": [{"name": "...", "appearance": "...", "slot": "皇宫正中龙椅前方台阶下的位置"}],
|
||||||
"location": "...",
|
"location": "...",
|
||||||
"scene_type": "...",
|
"scene_type": "...",
|
||||||
"source_text": "...",
|
"source_text": "...",
|
||||||
@@ -91,3 +93,8 @@
|
|||||||
"camera_move": "...",
|
"camera_move": "...",
|
||||||
"video_prompt": "..."
|
"video_prompt": "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
补充原则:
|
||||||
|
- slot 是优先锚点,不是绝对硬边界
|
||||||
|
- 当新镜头主要表现角色走动、进入/离开、穿过空间、临时停留、空白空间或想象空间时,可以不使用 slot
|
||||||
|
- 若不使用 slot,应根据前后镜头、原文空间关系和过渡逻辑自由决定人物位置
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Output format (JSON array only):
|
|||||||
"panel_number": 1,
|
"panel_number": 1,
|
||||||
"description": "visual action description",
|
"description": "visual action description",
|
||||||
"characters": [
|
"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",
|
"location": "location name",
|
||||||
"scene_type": "daily",
|
"scene_type": "daily",
|
||||||
@@ -52,5 +52,10 @@ Planning rules:
|
|||||||
3. Keep panel transitions smooth and logically continuous.
|
3. Keep panel transitions smooth and logically continuous.
|
||||||
4. Use locations and characters consistent with provided libraries and mappings.
|
4. Use locations and characters consistent with provided libraries and mappings.
|
||||||
5. Prefer concrete, visible actions over abstract wording.
|
5. Prefer concrete, visible actions over abstract wording.
|
||||||
6. Return strict JSON only.
|
6. If location context contains available slots, treat them as preferred placement anchors rather than mandatory hard boundaries.
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
2. 每个分镜必须包含:
|
2. 每个分镜必须包含:
|
||||||
- panel_number: 分镜序号(1, 2, 3...)
|
- panel_number: 分镜序号(1, 2, 3...)
|
||||||
- description: 画面描述(人物动作、场景元素、构图要点)
|
- description: 画面描述(人物动作、场景元素、构图要点)
|
||||||
- characters: [{name: "角色名", appearance: "形象名"}]
|
- characters: [{name: "角色名", appearance: "形象名", slot: "场景固定位置描述"}]
|
||||||
- location: 场景名称(从资产库选择)
|
- location: 场景名称(从资产库选择)
|
||||||
- scene_type: daily/emotion/action/epic/suspense
|
- scene_type: daily/emotion/action/epic/suspense
|
||||||
- source_text: 对应原文片段 ⚠️ 必填,不得为空
|
- source_text: 对应原文片段 ⚠️ 必填,不得为空
|
||||||
@@ -317,10 +317,13 @@ Clip信息:
|
|||||||
4. 只返回JSON数组,不得有其他文字
|
4. 只返回JSON数组,不得有其他文字
|
||||||
5. ⚠️ source_text 必填,不得为空或null
|
5. ⚠️ source_text 必填,不得为空或null
|
||||||
6. 空间关系必须清晰(朝向、阻挡、位置)
|
6. 空间关系必须清晰(朝向、阻挡、位置)
|
||||||
7. 镜头连续性:前后镜头要有动作承接
|
7. 如果场景描述中提供了可站位置列表,应将其视为优先位置锚点,而不是强制覆盖所有可能位置的硬边界
|
||||||
8. 禁止身份称呼:必须使用资产库中的具体名字
|
8. 当角色在场景中稳定停留时,优先从可站位置列表中选择 slot;slot 字段的值必须直接复制完整位置描述,禁止缩写、改写、总结或替换成短词
|
||||||
9. 禁止主观情绪词:只描述可视化动作和状态
|
9. 当镜头主要表现角色移动、进入/离开、穿过空间、过渡区域、路径空间、临时空间、空白空间、想象/梦境/回忆/抽象空间时,可以不使用 slot,并根据原文和镜头逻辑自由决定位置
|
||||||
10. 禁止长句单镜头:包含逗号分隔多个动作/对话的长句必须拆分
|
10. 镜头连续性:前后镜头要有动作承接
|
||||||
11. 对话必须拆分:每段对话至少 2 个镜头(说话者 + 听者反应)
|
11. 禁止身份称呼:必须使用资产库中的具体名字
|
||||||
12. ⚠️ 镜头合理性:只描述当前镜头**实际能拍摄到**的角色,特写/反打等拍不到的可省略
|
12. 禁止主观情绪词:只描述可视化动作和状态
|
||||||
13. ⚠️ JSON安全:原文中的所有引号(""''「」等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 "
|
13. 禁止长句单镜头:包含逗号分隔多个动作/对话的长句必须拆分
|
||||||
|
14. 对话必须拆分:每段对话至少 2 个镜头(说话者 + 听者反应)
|
||||||
|
15. ⚠️ 镜头合理性:只描述当前镜头**实际能拍摄到**的角色,特写/反打等拍不到的可省略
|
||||||
|
16. ⚠️ JSON安全:原文中的所有引号(""''「」等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 "
|
||||||
|
|||||||
@@ -7,13 +7,23 @@ User request:
|
|||||||
Rules:
|
Rules:
|
||||||
1. Output in English only.
|
1. Output in English only.
|
||||||
2. Start with scene name in this format: "[Scene Name] ..."
|
2. Start with scene name in this format: "[Scene Name] ..."
|
||||||
3. Describe a wide, clear environment with spatial layout and key objects.
|
3. Describe a wide, complete environment with controllable spatial layout, key structures, and visible depth.
|
||||||
4. Mention lighting direction and atmosphere.
|
4. Make foreground, midground, and background explicit.
|
||||||
5. No protagonist actions or dialogue.
|
5. Define at least 3 clear anchor objects or anchor areas and make the nearby open space visible.
|
||||||
6. If crowd is implied by context, use generic crowd terms only (guests, pedestrians, audience).
|
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:
|
Output format:
|
||||||
Return JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:
|
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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
【场景生成要求(用于出图,中文描述)】
|
【场景生成要求(用于出图,中文描述)】
|
||||||
|
|
||||||
1. 生成1条中文环境描述(60-120字),像真实摄影场景一样描述
|
1. 生成1条中文环境描述(80-140字),像真实摄影场景一样描述,必须足够具体到可以稳定控制画面布局
|
||||||
|
|
||||||
2. **开头必须明确写明场景名称**:
|
2. **开头必须明确写明场景名称**:
|
||||||
- 描述开头必须以"【场景名称】"的形式标注空间属性
|
- 描述开头必须以"【场景名称】"的形式标注空间属性
|
||||||
@@ -14,6 +14,10 @@
|
|||||||
- 材质要具体(深棕色实木地板、青灰色石砖墙、做旧铁艺栏杆)
|
- 材质要具体(深棕色实木地板、青灰色石砖墙、做旧铁艺栏杆)
|
||||||
- 物品要有使用痕迹和生活气息(桌上散落的书籍、墙角堆放的杂物、窗台晒干的植物)
|
- 物品要有使用痕迹和生活气息(桌上散落的书籍、墙角堆放的杂物、窗台晒干的植物)
|
||||||
- 光线要写清楚来源和效果(午后阳光斜照进来在地板上拉出长影、暖黄色壁灯打在墙面上)
|
- 光线要写清楚来源和效果(午后阳光斜照进来在地板上拉出长影、暖黄色壁灯打在墙面上)
|
||||||
|
- 必须写出完整空间结构,不要只写泛场景名词
|
||||||
|
- 必须写出前景/中景/背景或近处/中部/远处层次
|
||||||
|
- 必须写出至少3个清晰可见的关键锚点及其周边空位
|
||||||
|
- 如果用户输入很泛(如「学校教室」「办公室」),你必须主动把它具体化为可控构图,而不是停留在泛描述
|
||||||
|
|
||||||
4. 禁止:不写主角人物具体动作、不写画风、不写"温馨""优雅"等抽象词
|
4. 禁止:不写主角人物具体动作、不写画风、不写"温馨""优雅"等抽象词
|
||||||
|
|
||||||
@@ -22,9 +26,20 @@
|
|||||||
- 人群描述示例:"大厅中宾客三两成群"、"街道上行人往来"、"座位上零散坐着几位观众"
|
- 人群描述示例:"大厅中宾客三两成群"、"街道上行人往来"、"座位上零散坐着几位观众"
|
||||||
- 如果是私密空间或用户明确要求空镜,则不添加人群
|
- 如果是私密空间或用户明确要求空镜,则不添加人群
|
||||||
|
|
||||||
|
6. 额外生成 2-6 个该场景的固定可站位置:
|
||||||
|
- 每个位置必须是一条完整的位置描述短语,而不是短词
|
||||||
|
- 每个位置必须依附于明确的场景锚物或区域
|
||||||
|
- 位置描述中禁止写人物姿态、动作、情绪,只写空间位置
|
||||||
|
- 这些位置里提到的锚点必须在场景描述中真实出现
|
||||||
|
- 示例:饭桌左侧靠桌边的位置、教室后排靠窗那组课桌外侧的位置、皇宫正中龙椅前方台阶下的位置
|
||||||
|
|
||||||
以下是用户的生成指令:{user_input}
|
以下是用户的生成指令:{user_input}
|
||||||
|
|
||||||
只返回以下json格式,禁止返回一切除json以外的多余内容。⚠️ 所有引号(""''等)在 JSON 字符串值中必须替换为「」,严禁出现未转义的英文双引号 "。
|
只返回以下json格式,禁止返回一切除json以外的多余内容。⚠️ 所有引号(""''等)在 JSON 字符串值中必须替换为「」,严禁出现未转义的英文双引号 "。
|
||||||
{
|
{
|
||||||
"prompt":"「场景名称」场景描述内容"
|
"prompt":"「场景名称」场景描述内容",
|
||||||
|
"available_slots":[
|
||||||
|
"饭桌左侧靠桌边的位置",
|
||||||
|
"门口内侧靠墙的位置"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,18 @@ Rules:
|
|||||||
2. Return one complete updated description in English.
|
2. Return one complete updated description in English.
|
||||||
3. Keep scene name at the beginning: "[{location_name}] ..."
|
3. Keep scene name at the beginning: "[{location_name}] ..."
|
||||||
4. No protagonist actions or story narration.
|
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:
|
Output format:
|
||||||
Return JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:
|
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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,10 +27,20 @@
|
|||||||
6. 保留未被修改的原有特征
|
6. 保留未被修改的原有特征
|
||||||
7. 遵循以下描述规范:
|
7. 遵循以下描述规范:
|
||||||
- 只描述场景本身,禁止描述人物
|
- 只描述场景本身,禁止描述人物
|
||||||
- 使用中文输出,长度 50-100 字
|
- 使用中文输出,长度 80-140 字
|
||||||
|
- 必须让空间结构、关键锚点、前后层次具体可见,不能退化成泛场景描述
|
||||||
|
- 若用户修改后引入新的关键锚点或删掉旧锚点,描述必须同步更新
|
||||||
|
8. 同时重新生成 2-6 个固定可站位置,且必须与更新后的场景描述一致
|
||||||
|
9. 每个可站位置必须是一条完整的位置描述短语,不是短词,不是对象
|
||||||
|
10. 可站位置中禁止写人物姿态、动作、情绪,只写位置
|
||||||
|
11. 可站位置里提到的关键锚点必须在更新后的场景描述中明确出现
|
||||||
|
|
||||||
【输出格式】
|
【输出格式】
|
||||||
只返回JSON格式,禁止返回任何其他内容。⚠️ 所有引号(""''等)在 JSON 字符串值中必须替换为「」,严禁出现未转义的英文双引号 ":
|
只返回JSON格式,禁止返回任何其他内容。⚠️ 所有引号(""''等)在 JSON 字符串值中必须替换为「」,严禁出现未转义的英文双引号 ":
|
||||||
{
|
{
|
||||||
"prompt": "「场景名」更新后的完整场景描述"
|
"prompt": "「场景名」更新后的完整场景描述",
|
||||||
|
"available_slots":[
|
||||||
|
"教室后排靠窗那组课桌外侧的位置",
|
||||||
|
"讲台前方黑板正下方的位置"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,19 @@ Rules:
|
|||||||
3. Output in English only.
|
3. Output in English only.
|
||||||
4. Start with scene name: "[{location_name}] ..."
|
4. Start with scene name: "[{location_name}] ..."
|
||||||
5. No protagonist actions, dialogue, or narrative plot.
|
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:
|
Output format:
|
||||||
Return JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:
|
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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,9 @@
|
|||||||
* ✅ 好:"木质书桌"/"灰色布艺沙发"/"白色窗帘"
|
* ✅ 好:"木质书桌"/"灰色布艺沙发"/"白色窗帘"
|
||||||
* ❌ 差:"优雅的家具"/"舒适的环境"/"温馨的氛围"
|
* ❌ 差:"优雅的家具"/"舒适的环境"/"温馨的氛围"
|
||||||
- 描述固定元素,不写主角人物具体动作、情绪
|
- 描述固定元素,不写主角人物具体动作、情绪
|
||||||
|
- 必须让空间结构足够具体,不能只剩泛场景标签
|
||||||
|
- 必须让前景/中景/背景或近处/中部/远处关系清楚
|
||||||
|
- 必须保留至少 3 个清晰可见、可供后续落位的锚点或区域
|
||||||
- 【人群处理规则】如果用户要求添加人群,或场景本身暗示有人群(如宴会、集市等):
|
- 【人群处理规则】如果用户要求添加人群,或场景本身暗示有人群(如宴会、集市等):
|
||||||
* 可以加入模糊人群描述:\"人群\"、\"宾客\"、\"路人\"等
|
* 可以加入模糊人群描述:\"人群\"、\"宾客\"、\"路人\"等
|
||||||
* 示例:\"大厅远处三两宾客交谈\"、\"街角有行人匆匆走过\"
|
* 示例:\"大厅远处三两宾客交谈\"、\"街角有行人匆匆走过\"
|
||||||
@@ -54,11 +57,22 @@
|
|||||||
|
|
||||||
你的目标是根据用户的修改指令,在原有场景描述的基础上进行修改
|
你的目标是根据用户的修改指令,在原有场景描述的基础上进行修改
|
||||||
|
|
||||||
|
同时你必须重新生成该场景的固定可站位置:
|
||||||
|
- 输出 2-6 个站位
|
||||||
|
- 每个站位必须是一条完整的位置描述短语,不是短词
|
||||||
|
- 每个站位必须依附于明确场景锚物或区域
|
||||||
|
- 站位中禁止写人物姿态、动作、情绪,只写位置
|
||||||
|
- 新站位必须与修改后的场景描述一致,且其中提到的锚点必须在场景描述中出现
|
||||||
|
|
||||||
当前场景描述:{location_input}
|
当前场景描述:{location_input}
|
||||||
|
|
||||||
用户的修改指令:{user_input}
|
用户的修改指令:{user_input}
|
||||||
|
|
||||||
发送json格式给我,只返回以下json格式,禁止返回一切除json以外的多余内容,注释,文字等等,只返回无任何markdown标识符的纯净json格式。⚠️ 所有引号(""''等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 "。json格式如下
|
发送json格式给我,只返回以下json格式,禁止返回一切除json以外的多余内容,注释,文字等等,只返回无任何markdown标识符的纯净json格式。⚠️ 所有引号(""''等)在 JSON 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 "。json格式如下
|
||||||
{
|
{
|
||||||
"prompt":"「场景名」xxxxx"
|
"prompt":"「场景名」xxxxx",
|
||||||
|
"available_slots":[
|
||||||
|
"皇宫正中龙椅前方台阶下的位置",
|
||||||
|
"左侧立柱与长案之间的空位"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ Requirements:
|
|||||||
2. Keep the scene name prefix in each line: "[{location_name}] ..."
|
2. Keep the scene name prefix in each line: "[{location_name}] ..."
|
||||||
3. Output in English only.
|
3. Output in English only.
|
||||||
4. Keep environment-only description (no protagonist actions).
|
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:
|
Output format:
|
||||||
Return JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:
|
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 1",
|
||||||
"[{location_name}] variant 2",
|
"[{location_name}] variant 2",
|
||||||
"[{location_name}] variant 3"
|
"[{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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
- 材质细节:地面、墙面、物体的材质质感
|
- 材质细节:地面、墙面、物体的材质质感
|
||||||
- 环境元素:植物、天气、装饰物等
|
- 环境元素:植物、天气、装饰物等
|
||||||
- 独特标识:该场景的标志性元素或特殊物件
|
- 独特标识:该场景的标志性元素或特殊物件
|
||||||
|
- 至少 3 个可供后续固定人物位置的稳定锚点或区域
|
||||||
|
- 每个锚点周边可落位的空白区域
|
||||||
|
|
||||||
5. 描述规范:
|
5. 描述规范:
|
||||||
- 禁止写主角人物具体动作、剧情
|
- 禁止写主角人物具体动作、剧情
|
||||||
@@ -31,11 +33,23 @@
|
|||||||
- 【年代一致性】根据场景特征判断年代,建筑、装饰、物品必须符合该年代特征
|
- 【年代一致性】根据场景特征判断年代,建筑、装饰、物品必须符合该年代特征
|
||||||
- 【时间一致性】如场景名包含"白天/黑夜/黄昏"等,描述中的光影必须匹配
|
- 【时间一致性】如场景名包含"白天/黑夜/黄昏"等,描述中的光影必须匹配
|
||||||
|
|
||||||
|
6. 额外输出 2-6 个固定可站位置:
|
||||||
|
- 与该场景的共通构图一致
|
||||||
|
- 每个站位必须是一条完整的位置描述短语,不是短词,不是对象
|
||||||
|
- 依附于明确场景锚物或区域
|
||||||
|
- 禁止抽象站位
|
||||||
|
- 禁止写人物姿态、动作、情绪
|
||||||
|
- 站位中提到的锚点必须在三条 descriptions 中都成立
|
||||||
|
|
||||||
【输出格式】只返回以下 JSON,不要任何其他内容。⚠️ 所有引号(""''等)在 JSON 字符串值中必须替换为「」,严禁出现未转义的英文双引号 "。
|
【输出格式】只返回以下 JSON,不要任何其他内容。⚠️ 所有引号(""''等)在 JSON 字符串值中必须替换为「」,严禁出现未转义的英文双引号 "。
|
||||||
{
|
{
|
||||||
"descriptions": [
|
"descriptions": [
|
||||||
"「场景名」新描述1(80-150字)",
|
"「场景名」新描述1(80-150字)",
|
||||||
"「场景名」新描述2(80-150字)",
|
"「场景名」新描述2(80-150字)",
|
||||||
"「场景名」新描述3(80-150字)"
|
"「场景名」新描述3(80-150字)"
|
||||||
|
],
|
||||||
|
"available_slots": [
|
||||||
|
"皇宫正中龙椅前方台阶下的位置",
|
||||||
|
"右后方殿门内侧靠墙的位置"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,17 @@ For each selected location, generate 3 wide-angle environment descriptions.
|
|||||||
Each description should:
|
Each description should:
|
||||||
- start with location name in brackets: "[Location Name] ..."
|
- start with location name in brackets: "[Location Name] ..."
|
||||||
- describe spatial layout, depth layers, major objects, and lighting direction
|
- 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)
|
- remain environment-only (no named protagonist actions)
|
||||||
- use concise, production-ready English
|
- 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):
|
Output format (JSON only):
|
||||||
{
|
{
|
||||||
"locations": [
|
"locations": [
|
||||||
@@ -28,6 +36,11 @@ Output format (JSON only):
|
|||||||
"summary": "short usage summary",
|
"summary": "short usage summary",
|
||||||
"has_crowd": false,
|
"has_crowd": false,
|
||||||
"crowd_description": "",
|
"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": [
|
"descriptions": [
|
||||||
"[location_name] description 1",
|
"[location_name] description 1",
|
||||||
"[location_name] description 2",
|
"[location_name] description 2",
|
||||||
@@ -40,5 +53,10 @@ Output format (JSON only):
|
|||||||
Strict constraints:
|
Strict constraints:
|
||||||
1. JSON only.
|
1. JSON only.
|
||||||
2. If no valid location exists, return: {"locations":[]}.
|
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.
|
||||||
|
|
||||||
@@ -72,9 +72,15 @@
|
|||||||
- 使用明确的位置词:左侧/右侧/中央/角落/靠窗/远处
|
- 使用明确的位置词:左侧/右侧/中央/角落/靠窗/远处
|
||||||
- 描述物体之间的空间关系和前后层次
|
- 描述物体之间的空间关系和前后层次
|
||||||
- 5-8件物体,每件都有位置说明
|
- 5-8件物体,每件都有位置说明
|
||||||
|
- 至少 2-3 个后续可作为人物落位锚点的关键物体或区域必须被明确写出,如桌边、门内侧、窗下墙边、讲台前、龙椅前台阶
|
||||||
|
|
||||||
**光线方向**:光从哪个方向照入,照亮哪些区域
|
**光线方向**:光从哪个方向照入,照亮哪些区域
|
||||||
|
|
||||||
|
**可落位空间**(必须体现):
|
||||||
|
- 必须说明哪些区域留有可供人物站立或出现的空白空间
|
||||||
|
- 这些空白区域必须与关键锚点相邻,便于后续固定人物位置
|
||||||
|
- 禁止把所有锚点都塞满家具或遮挡物,导致后续无法落人
|
||||||
|
|
||||||
5. 描述规范:
|
5. 描述规范:
|
||||||
- 强调位置关系词:前方、远处、左侧、角落、靠近、深处
|
- 强调位置关系词:前方、远处、左侧、角落、靠近、深处
|
||||||
- 长度 100-150 字
|
- 长度 100-150 字
|
||||||
@@ -108,6 +114,12 @@
|
|||||||
|
|
||||||
8.如无特殊要求,使用用户输入的语言来进行场景生成,例如输入英文输出偏西方场景,中文则输出偏中国场景,但是原则要按照文字剧本里实际发生的地点为准,
|
8.如无特殊要求,使用用户输入的语言来进行场景生成,例如输入英文输出偏西方场景,中文则输出偏中国场景,但是原则要按照文字剧本里实际发生的地点为准,
|
||||||
|
|
||||||
|
9. 如果原文或用户输入过于泛化(如「学校教室」「办公室」「客厅」),你必须主动将其具体化为可控画面的完整空间:
|
||||||
|
- 明确主视角下能看到的关键结构
|
||||||
|
- 明确前景/中景/背景
|
||||||
|
- 明确至少 3 个稳定锚点及其周边空位
|
||||||
|
- 禁止只输出泛泛的场景名词堆砌
|
||||||
|
|
||||||
【输出规范(只允许以下 JSON 结构;字段名中文;不得输出任何多余文字)】
|
【输出规范(只允许以下 JSON 结构;字段名中文;不得输出任何多余文字)】
|
||||||
{
|
{
|
||||||
"locations": [
|
"locations": [
|
||||||
@@ -116,6 +128,11 @@
|
|||||||
"summary": "场景简要说明(用途/人物关联,如:张三居住的主卧室、公司高层会议室等)",
|
"summary": "场景简要说明(用途/人物关联,如:张三居住的主卧室、公司高层会议室等)",
|
||||||
"has_crowd": true/false,
|
"has_crowd": true/false,
|
||||||
"crowd_description": "人群类型描述(仅当has_crowd为true时填写,如:宴会宾客、集市人群、学生们等)",
|
"crowd_description": "人群类型描述(仅当has_crowd为true时填写,如:宴会宾客、集市人群、学生们等)",
|
||||||
|
"available_slots": [
|
||||||
|
"皇宫正中龙椅前方台阶下的位置",
|
||||||
|
"左侧立柱与长案之间的空位",
|
||||||
|
"右后方殿门内侧靠墙的位置"
|
||||||
|
],
|
||||||
"descriptions": [
|
"descriptions": [
|
||||||
"「场景名」场景环境描述1(如has_crowd为true则包含人群元素)",
|
"「场景名」场景环境描述1(如has_crowd为true则包含人群元素)",
|
||||||
"「场景名」场景环境描述2",
|
"「场景名」场景环境描述2",
|
||||||
@@ -127,6 +144,11 @@
|
|||||||
|
|
||||||
【严格性】
|
【严格性】
|
||||||
- 若无符合条件的场景,locations数组返回 []。
|
- 若无符合条件的场景,locations数组返回 []。
|
||||||
|
- 每个场景必须生成 2-6 个 available_slots,且每个站位都必须具体、可复用、与场景内明确锚物相关。
|
||||||
|
- 每个 available_slots 元素必须是一条完整的位置描述短语,不是短词,不是结构化对象。
|
||||||
|
- 站位描述必须像「皇宫正中龙椅前方台阶下的位置」「教室后排靠窗那组课桌外侧的位置」这样,直接说明锚物、方位和具体区域。
|
||||||
|
- 禁止抽象站位,如「左边」「中间」「角落」;禁止写人物姿态、动作、情绪;只描述位置本身。
|
||||||
|
- available_slots 中提到的所有关键锚点,必须在 descriptions 中清楚出现,否则该站位无效,不能输出。
|
||||||
- 只返回上述 JSON;不得输出markdown代码块标记、如```json注释或解释;不得添加未定义字段。
|
- 只返回上述 JSON;不得输出markdown代码块标记、如```json注释或解释;不得添加未定义字段。
|
||||||
- 每条描述必须遵守长度限制(100-150字);发现超长请自行截断。
|
- 每条描述必须遵守长度限制(100-150字);发现超长请自行截断。
|
||||||
- 禁止在 JSON 字符串值中出现英文双引号 "。原文中的所有引号(""''等)必须统一替换为「」。如字符串内确实需要英文双引号,必须转义为 \"
|
- 禁止在 JSON 字符串值中出现英文双引号 "。原文中的所有引号(""''等)必须统一替换为「」。如字符串内确实需要英文双引号,必须转义为 \"
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ Style requirement:
|
|||||||
|
|
||||||
Execution rules:
|
Execution rules:
|
||||||
1. Respect panel composition, character placement, and action logic.
|
1. Respect panel composition, character placement, and action logic.
|
||||||
2. Use reference images for style/identity consistency only.
|
2. If storyboard data contains `slot`, treat it as a preferred placement anchor for consistency, not as an absolute restriction.
|
||||||
3. Repaint the background according to shot type and angle.
|
3. If location data contains `available_slots`, treat them as typical anchor references rather than a complete map of all valid positions.
|
||||||
4. If storyboard conflicts with source text, keep narrative logic from source text.
|
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. Keep final visual style consistent with provided references.
|
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.
|
||||||
|
|||||||
@@ -50,6 +50,10 @@
|
|||||||
- 严格按照分镜要求绘制画面
|
- 严格按照分镜要求绘制画面
|
||||||
- 禁止添加、删除或重排任何镜头
|
- 禁止添加、删除或重排任何镜头
|
||||||
- 镜头必须与输入完全匹配
|
- 镜头必须与输入完全匹配
|
||||||
|
- 如果分镜数据中包含角色 slot,可将其视为优先位置参考,用于保持人物落位一致性,但不是绝对硬限制
|
||||||
|
- 如果场景数据中包含 available_slots,应将其理解为典型位置参考,而不是完整空间边界
|
||||||
|
- 当镜头描述、原文空间关系、动作过程或场景性质表明人物正在移动、处于过渡区域、入口出口、路径空间、临时空间、空白空间或想象/梦境/回忆/抽象空间时,不要强行把人物锁死在现有 slot 中
|
||||||
|
- 最终以镜头叙事正确、空间关系自然、人物位置合理为最高原则
|
||||||
|
|
||||||
【分镜数据】
|
【分镜数据】
|
||||||
{storyboard_text_json_input}
|
{storyboard_text_json_input}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"title": "From Inspiration to Screen",
|
"title": "From Inspiration to Screen",
|
||||||
"subtitle": "Describe your story and let AI generate cinematic short dramas",
|
"subtitle": "Describe your story and let AI generate cinematic short dramas",
|
||||||
"inputPlaceholder": "Enter your story idea, novel excerpt, or script outline...",
|
"inputPlaceholder": "Enter your story idea, novel excerpt, or script outline...",
|
||||||
|
"defaultProjectName": "New Project {timestamp}",
|
||||||
"startCreation": "Start Creating",
|
"startCreation": "Start Creating",
|
||||||
"recentProjects": "Recent Projects",
|
"recentProjects": "Recent Projects",
|
||||||
"viewAll": "View All Projects",
|
"viewAll": "View All Projects",
|
||||||
|
|||||||
@@ -346,7 +346,22 @@
|
|||||||
"actingNotes": "Acting Direction (acting_notes)",
|
"actingNotes": "Acting Direction (acting_notes)",
|
||||||
"actingTitle": "Acting Direction",
|
"actingTitle": "Acting Direction",
|
||||||
"actingDescription": "Performance Notes",
|
"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": {
|
"insertModal": {
|
||||||
"insertBetween": "Insert between #{before} and #{after}",
|
"insertBetween": "Insert between #{before} and #{after}",
|
||||||
|
|||||||
@@ -23,6 +23,11 @@
|
|||||||
"createFailed": "Failed to create project",
|
"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.",
|
"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",
|
"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",
|
"deleteFailed": "Failed to delete project",
|
||||||
"totalProjects": "{count} projects in total",
|
"totalProjects": "{count} projects in total",
|
||||||
"statsEpisodes": "Episodes",
|
"statsEpisodes": "Episodes",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"title": "从灵感到银幕",
|
"title": "从灵感到银幕",
|
||||||
"subtitle": "描述你想要创作的故事,AI 为你智能生成影视短剧",
|
"subtitle": "描述你想要创作的故事,AI 为你智能生成影视短剧",
|
||||||
"inputPlaceholder": "输入你的故事创意、小说片段或剧本大纲...",
|
"inputPlaceholder": "输入你的故事创意、小说片段或剧本大纲...",
|
||||||
|
"defaultProjectName": "新项目 {timestamp}",
|
||||||
"startCreation": "开始创作",
|
"startCreation": "开始创作",
|
||||||
"recentProjects": "最近项目",
|
"recentProjects": "最近项目",
|
||||||
"viewAll": "查看全部项目",
|
"viewAll": "查看全部项目",
|
||||||
|
|||||||
@@ -346,7 +346,22 @@
|
|||||||
"actingNotes": "演技指导 (acting_notes)",
|
"actingNotes": "演技指导 (acting_notes)",
|
||||||
"actingTitle": "演技指导",
|
"actingTitle": "演技指导",
|
||||||
"actingDescription": "表演指令",
|
"actingDescription": "表演指令",
|
||||||
"noActingData": "无演技数据"
|
"noActingData": "无演技数据",
|
||||||
|
"appearance": "外貌描述",
|
||||||
|
"appearanceReadonly": "外貌(只读)",
|
||||||
|
"slot": "站位(slot)",
|
||||||
|
"slotUnset": "未指定",
|
||||||
|
"framePosition": "画面站位",
|
||||||
|
"screenPosition": "屏幕位置",
|
||||||
|
"actingGuide": "表演指导",
|
||||||
|
"photoEnv": "摄影环境",
|
||||||
|
"availableSlots": "场景可用位置",
|
||||||
|
"optional": "可选",
|
||||||
|
"reference": "参考",
|
||||||
|
"aiRequired": "AI 必读",
|
||||||
|
"characterDetails": "角色详情",
|
||||||
|
"shotAndScene": "镜头 · 场景",
|
||||||
|
"jsonCheck": "JSON 核查"
|
||||||
},
|
},
|
||||||
"insertModal": {
|
"insertModal": {
|
||||||
"insertBetween": "在 #{before} 和 #{after} 之间插入",
|
"insertBetween": "在 #{before} 和 #{after} 之间插入",
|
||||||
|
|||||||
@@ -23,6 +23,11 @@
|
|||||||
"createFailed": "创建项目失败",
|
"createFailed": "创建项目失败",
|
||||||
"analysisModelRequiredAfterCreate": "项目已创建。请先前往个人设置配置默认模型(至少设置分析模型),否则无法使用。",
|
"analysisModelRequiredAfterCreate": "项目已创建。请先前往个人设置配置默认模型(至少设置分析模型),否则无法使用。",
|
||||||
"updateFailed": "更新项目失败",
|
"updateFailed": "更新项目失败",
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "项目名称不能为空。",
|
||||||
|
"nameTooLong": "项目名称不能超过 100 个字符。",
|
||||||
|
"descriptionTooLong": "项目描述不能超过 500 个字符。"
|
||||||
|
},
|
||||||
"deleteFailed": "删除项目失败",
|
"deleteFailed": "删除项目失败",
|
||||||
"totalProjects": "共 {count} 个项目",
|
"totalProjects": "共 {count} 个项目",
|
||||||
"statsEpisodes": "章节数",
|
"statsEpisodes": "章节数",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE location_images
|
||||||
|
ADD COLUMN availableSlots TEXT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE global_location_images
|
||||||
|
ADD COLUMN availableSlots TEXT NULL;
|
||||||
@@ -58,6 +58,7 @@ model LocationImage {
|
|||||||
locationId String
|
locationId String
|
||||||
imageIndex Int
|
imageIndex Int
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
|
availableSlots String? @db.Text
|
||||||
imageUrl String? @db.Text
|
imageUrl String? @db.Text
|
||||||
isSelected Boolean @default(false)
|
isSelected Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -899,6 +900,7 @@ model GlobalLocationImage {
|
|||||||
locationId String
|
locationId String
|
||||||
imageIndex Int
|
imageIndex Int
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
|
availableSlots String? @db.Text
|
||||||
imageUrl String? @db.Text
|
imageUrl String? @db.Text
|
||||||
imageMediaId String?
|
imageMediaId String?
|
||||||
imageMedia MediaObject? @relation("GlobalLocationImageMedia", fields: [imageMediaId], references: [id], onDelete: SetNull)
|
imageMedia MediaObject? @relation("GlobalLocationImageMedia", fields: [imageMediaId], references: [id], onDelete: SetNull)
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ model LocationImage {
|
|||||||
locationId String
|
locationId String
|
||||||
imageIndex Int
|
imageIndex Int
|
||||||
description String?
|
description String?
|
||||||
|
availableSlots String?
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
isSelected Boolean @default(false)
|
isSelected Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -736,6 +737,7 @@ model GlobalLocationImage {
|
|||||||
locationId String
|
locationId String
|
||||||
imageIndex Int
|
imageIndex Int
|
||||||
description String?
|
description String?
|
||||||
|
availableSlots String?
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
imageMediaId String?
|
imageMediaId String?
|
||||||
imageMedia MediaObject? @relation("GlobalLocationImageMedia", fields: [imageMediaId], references: [id], onDelete: SetNull)
|
imageMedia MediaObject? @relation("GlobalLocationImageMedia", fields: [imageMediaId], references: [id], onDelete: SetNull)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Link, useRouter } from '@/i18n/navigation'
|
|||||||
import { apiFetch } from '@/lib/api-fetch'
|
import { apiFetch } from '@/lib/api-fetch'
|
||||||
import { expandHomeStory } from '@/lib/home/ai-story-expand'
|
import { expandHomeStory } from '@/lib/home/ai-story-expand'
|
||||||
import { createHomeProjectLaunch } from '@/lib/home/create-project-launch'
|
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 { HOME_QUICK_START_MIN_ROWS } from '@/lib/ui/textarea-height'
|
||||||
import AiWriteModal from '@/components/home/AiWriteModal'
|
import AiWriteModal from '@/components/home/AiWriteModal'
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ export default function HomePage() {
|
|||||||
const [artStyle, setArtStyle] = useState('american-comic')
|
const [artStyle, setArtStyle] = useState('american-comic')
|
||||||
const [stylePresetValue, setStylePresetValue] = useState<string>(DEFAULT_STYLE_PRESET_VALUE)
|
const [stylePresetValue, setStylePresetValue] = useState<string>(DEFAULT_STYLE_PRESET_VALUE)
|
||||||
const [createLoading, setCreateLoading] = useState(false)
|
const [createLoading, setCreateLoading] = useState(false)
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null)
|
||||||
const [aiWriteOpen, setAiWriteOpen] = useState(false)
|
const [aiWriteOpen, setAiWriteOpen] = useState(false)
|
||||||
const [aiWriteLoading, setAiWriteLoading] = useState(false)
|
const [aiWriteLoading, setAiWriteLoading] = useState(false)
|
||||||
|
|
||||||
@@ -92,12 +94,15 @@ export default function HomePage() {
|
|||||||
// 创建项目并跳转
|
// 创建项目并跳转
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!inputValue.trim() || createLoading) return
|
if (!inputValue.trim() || createLoading) return
|
||||||
|
setCreateError(null)
|
||||||
setCreateLoading(true)
|
setCreateLoading(true)
|
||||||
try {
|
try {
|
||||||
const storyText = inputValue.trim()
|
const storyText = inputValue.trim()
|
||||||
const result = await createHomeProjectLaunch({
|
const result = await createHomeProjectLaunch({
|
||||||
apiFetch,
|
apiFetch,
|
||||||
projectName: storyText.slice(0, 50),
|
projectName: t('defaultProjectName', {
|
||||||
|
timestamp: formatDefaultProjectTimestamp(new Date()),
|
||||||
|
}),
|
||||||
storyText,
|
storyText,
|
||||||
videoRatio,
|
videoRatio,
|
||||||
artStyle,
|
artStyle,
|
||||||
@@ -107,7 +112,7 @@ export default function HomePage() {
|
|||||||
router.push(result.target)
|
router.push(result.target)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('createFailed')
|
const message = error instanceof Error ? error.message : t('createFailed')
|
||||||
window.alert(message)
|
setCreateError(message)
|
||||||
} finally {
|
} finally {
|
||||||
setCreateLoading(false)
|
setCreateLoading(false)
|
||||||
}
|
}
|
||||||
@@ -245,9 +250,15 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<StoryInputComposer
|
<StoryInputComposer
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onValueChange={setInputValue}
|
onValueChange={(nextValue) => {
|
||||||
|
setInputValue(nextValue)
|
||||||
|
if (createError) {
|
||||||
|
setCreateError(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder={t('inputPlaceholder')}
|
placeholder={t('inputPlaceholder')}
|
||||||
minRows={HOME_QUICK_START_MIN_ROWS}
|
minRows={HOME_QUICK_START_MIN_ROWS}
|
||||||
|
textareaClassName="px-0 pt-0 pb-3 align-top"
|
||||||
videoRatio={videoRatio}
|
videoRatio={videoRatio}
|
||||||
onVideoRatioChange={setVideoRatio}
|
onVideoRatioChange={setVideoRatio}
|
||||||
ratioOptions={ratioOptions}
|
ratioOptions={ratioOptions}
|
||||||
@@ -286,6 +297,11 @@ export default function HomePage() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,13 +42,11 @@ function NovelPromotionWorkspaceContent(props: NovelPromotionWorkspaceProps) {
|
|||||||
|
|
||||||
const showStoryToScriptMinBadge =
|
const showStoryToScriptMinBadge =
|
||||||
storyToScriptStream.isVisible &&
|
storyToScriptStream.isVisible &&
|
||||||
storyToScriptStream.stages.length > 0 &&
|
|
||||||
storyToScriptActive &&
|
storyToScriptActive &&
|
||||||
vm.execution.storyToScriptConsoleMinimized
|
vm.execution.storyToScriptConsoleMinimized
|
||||||
|
|
||||||
const showScriptToStoryboardMinBadge =
|
const showScriptToStoryboardMinBadge =
|
||||||
scriptToStoryboardStream.isVisible &&
|
scriptToStoryboardStream.isVisible &&
|
||||||
scriptToStoryboardStream.stages.length > 0 &&
|
|
||||||
scriptToStoryboardActive &&
|
scriptToStoryboardActive &&
|
||||||
vm.execution.scriptToStoryboardConsoleMinimized
|
vm.execution.scriptToStoryboardConsoleMinimized
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useTranslations } from 'next-intl'
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import '@/styles/animations.css'
|
import '@/styles/animations.css'
|
||||||
import AiWriteModal from '@/components/home/AiWriteModal'
|
import AiWriteModal from '@/components/home/AiWriteModal'
|
||||||
|
import LongTextDetectionPrompt from '@/components/story-input/LongTextDetectionPrompt'
|
||||||
import StoryInputComposer from '@/components/story-input/StoryInputComposer'
|
import StoryInputComposer from '@/components/story-input/StoryInputComposer'
|
||||||
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
|
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
|
||||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||||
@@ -197,7 +198,7 @@ export default function NovelInputStage({
|
|||||||
stylePresetValue={stylePresetValue}
|
stylePresetValue={stylePresetValue}
|
||||||
onStylePresetChange={setStylePresetValue}
|
onStylePresetChange={setStylePresetValue}
|
||||||
stylePresetOptions={STYLE_PRESETS}
|
stylePresetOptions={STYLE_PRESETS}
|
||||||
textareaClassName="text-base p-5 pb-3"
|
textareaClassName="px-0 pt-0 pb-3 align-top"
|
||||||
primaryAction={(
|
primaryAction={(
|
||||||
<button
|
<button
|
||||||
onClick={handleStartClick}
|
onClick={handleStartClick}
|
||||||
@@ -285,88 +286,29 @@ export default function NovelInputStage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 长文本检测 — 智能分集强引导弹窗 */}
|
<LongTextDetectionPrompt
|
||||||
{showLongTextPrompt && (
|
open={showLongTextPrompt}
|
||||||
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 backdrop-blur-sm">
|
copy={{
|
||||||
<div className="w-full max-w-lg mx-4 relative">
|
title: t('storyInput.longTextDetection.title'),
|
||||||
{/* 渐变描边外壳 */}
|
description: t('storyInput.longTextDetection.description', {
|
||||||
<div
|
count: localText.trim().length.toLocaleString(),
|
||||||
className="rounded-2xl p-[1.5px]"
|
}),
|
||||||
style={{ background: 'linear-gradient(135deg, #3b82f6, #8b5cf6, #06b6d4)' }}
|
strongRecommend: t('storyInput.longTextDetection.strongRecommend'),
|
||||||
>
|
smartSplitLabel: t('storyInput.longTextDetection.smartSplit'),
|
||||||
<div className="glass-surface-modal rounded-2xl p-6 space-y-5">
|
smartSplitBadge: t('storyInput.longTextDetection.smartSplitRecommend'),
|
||||||
{/* 标题行 */}
|
continueLabel: t('storyInput.longTextDetection.continueAnyway'),
|
||||||
<div className="flex items-center gap-3">
|
continueHint: t('storyInput.longTextDetection.singleEpisodeWarning'),
|
||||||
<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',
|
|
||||||
}}
|
}}
|
||||||
>
|
onClose={() => setShowLongTextPrompt(false)}
|
||||||
{t('storyInput.longTextDetection.strongRecommend')}
|
onSmartSplit={() => {
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 按钮区域 */}
|
|
||||||
<div className="flex flex-col gap-3 pt-1">
|
|
||||||
{/* 智能分集 — 主按钮 */}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowLongTextPrompt(false)
|
setShowLongTextPrompt(false)
|
||||||
onSmartSplit?.(localText)
|
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]"
|
onContinue={() => {
|
||||||
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={() => {
|
|
||||||
setShowLongTextPrompt(false)
|
setShowLongTextPrompt(false)
|
||||||
onNext()
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface PanelEditData {
|
|||||||
cameraMove: string | null
|
cameraMove: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
location: string | null
|
location: string | null
|
||||||
characters: { name: string; appearance: string }[]
|
characters: { name: string; appearance: string; slot?: string }[]
|
||||||
srtStart: number | null
|
srtStart: number | null
|
||||||
srtEnd: number | null
|
srtEnd: number | null
|
||||||
duration: number | null
|
duration: number | null
|
||||||
@@ -75,7 +75,7 @@ export default function PanelEditForm({
|
|||||||
|
|
||||||
interface CharacterPickerModalProps {
|
interface CharacterPickerModalProps {
|
||||||
projectId: string
|
projectId: string
|
||||||
currentCharacters: { name: string; appearance: string }[]
|
currentCharacters: { name: string; appearance: string; slot?: string }[]
|
||||||
onSelect: (charName: string, appearance: string) => void
|
onSelect: (charName: string, appearance: string) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SettingsModal, WorldContextModal } from '@/components/ui/ConfigModals'
|
|||||||
import WorkspaceTopActions from './WorkspaceTopActions'
|
import WorkspaceTopActions from './WorkspaceTopActions'
|
||||||
import type { NovelPromotionPanel } from '@/types/project'
|
import type { NovelPromotionPanel } from '@/types/project'
|
||||||
import type { CapabilitySelections, ModelCapabilities } from '@/lib/model-config-contract'
|
import type { CapabilitySelections, ModelCapabilities } from '@/lib/model-config-contract'
|
||||||
|
import { resolveEpisodeStageArtifacts } from '@/lib/novel-promotion/stage-readiness'
|
||||||
|
|
||||||
interface EpisodeSummary {
|
interface EpisodeSummary {
|
||||||
id: string
|
id: string
|
||||||
@@ -164,15 +165,23 @@ export default function WorkspaceHeaderShell({
|
|||||||
return (
|
return (
|
||||||
<EpisodeSelector
|
<EpisodeSelector
|
||||||
projectName={projectName}
|
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,
|
id: ep.id,
|
||||||
title: ep.name,
|
title: ep.name,
|
||||||
summary: ep.description ?? undefined,
|
summary: ep.description ?? undefined,
|
||||||
status: {
|
status: {
|
||||||
script: ep.clips?.length ? 'ready' as const : 'empty' as const,
|
script: stageArtifacts.hasScript ? 'ready' as const : 'empty' as const,
|
||||||
visual: ep.storyboards?.some((sb) => sb.panels?.some((panel) => panel.videoUrl)) ? 'ready' as const : 'empty' as const,
|
visual: stageArtifacts.hasVideo ? 'ready' as const : 'empty' as const,
|
||||||
},
|
},
|
||||||
}))}
|
}
|
||||||
|
})}
|
||||||
currentId={currentEpisodeId}
|
currentId={currentEpisodeId}
|
||||||
onSelect={(id) => onEpisodeSelect?.(id)}
|
onSelect={(id) => onEpisodeSelect?.(id)}
|
||||||
onAdd={onEpisodeCreate}
|
onAdd={onEpisodeCreate}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function WorkspaceRunStreamConsoles({
|
|||||||
|
|
||||||
const showStoryToScriptConsole =
|
const showStoryToScriptConsole =
|
||||||
storyToScriptStream.isVisible &&
|
storyToScriptStream.isVisible &&
|
||||||
(storyToScriptStream.stages.length > 0 || !!storyToScriptStream.errorMessage)
|
(storyToScriptStream.stages.length > 0 || !!storyToScriptStream.errorMessage || storyToScriptActive)
|
||||||
const storyFallbackStatus: LLMStageViewItem['status'] =
|
const storyFallbackStatus: LLMStageViewItem['status'] =
|
||||||
storyToScriptStream.status === 'failed' ? 'failed' : 'processing'
|
storyToScriptStream.status === 'failed' ? 'failed' : 'processing'
|
||||||
const storyToScriptStages = storyToScriptStream.stages.length > 0
|
const storyToScriptStages = storyToScriptStream.stages.length > 0
|
||||||
@@ -94,7 +94,7 @@ export default function WorkspaceRunStreamConsoles({
|
|||||||
storyToScriptSelectedStage?.status === 'processing'
|
storyToScriptSelectedStage?.status === 'processing'
|
||||||
const showScriptToStoryboardConsole =
|
const showScriptToStoryboardConsole =
|
||||||
scriptToStoryboardStream.isVisible &&
|
scriptToStoryboardStream.isVisible &&
|
||||||
(scriptToStoryboardStream.stages.length > 0 || !!scriptToStoryboardStream.errorMessage)
|
(scriptToStoryboardStream.stages.length > 0 || !!scriptToStoryboardStream.errorMessage || scriptToStoryboardActive)
|
||||||
const storyboardFallbackStatus: LLMStageViewItem['status'] =
|
const storyboardFallbackStatus: LLMStageViewItem['status'] =
|
||||||
scriptToStoryboardStream.status === 'failed' ? 'failed' : 'processing'
|
scriptToStoryboardStream.status === 'failed' ? 'failed' : 'processing'
|
||||||
const scriptToStoryboardStages = scriptToStoryboardStream.stages.length > 0
|
const scriptToStoryboardStages = scriptToStoryboardStream.stages.length > 0
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useAiCreateProjectLocation, useCreateProjectLocation } from '@/lib/quer
|
|||||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||||
import { AppIcon } from '@/components/ui/icons'
|
import { AppIcon } from '@/components/ui/icons'
|
||||||
|
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
|
||||||
|
|
||||||
interface AddLocationModalProps {
|
interface AddLocationModalProps {
|
||||||
projectId: string
|
projectId: string
|
||||||
@@ -58,6 +59,7 @@ export default function AddLocationModal({
|
|||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [aiInstruction, setAiInstruction] = useState('')
|
const [aiInstruction, setAiInstruction] = useState('')
|
||||||
const [artStyle, setArtStyle] = useState('american-comic')
|
const [artStyle, setArtStyle] = useState('american-comic')
|
||||||
|
const [availableSlots, setAvailableSlots] = useState<LocationAvailableSlot[]>([])
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [isAiDesigning, setIsAiDesigning] = useState(false)
|
const [isAiDesigning, setIsAiDesigning] = useState(false)
|
||||||
const aiDesigningState = isAiDesigning
|
const aiDesigningState = isAiDesigning
|
||||||
@@ -87,6 +89,7 @@ export default function AddLocationModal({
|
|||||||
userInstruction: aiInstruction,
|
userInstruction: aiInstruction,
|
||||||
})
|
})
|
||||||
setDescription(data.prompt || '')
|
setDescription(data.prompt || '')
|
||||||
|
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
|
||||||
setAiInstruction('')
|
setAiInstruction('')
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (getErrorStatus(error) === 402) {
|
if (getErrorStatus(error) === 402) {
|
||||||
@@ -113,6 +116,7 @@ export default function AddLocationModal({
|
|||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
artStyle,
|
artStyle,
|
||||||
count: locationGenerationCount,
|
count: locationGenerationCount,
|
||||||
|
availableSlots,
|
||||||
})
|
})
|
||||||
onSuccess()
|
onSuccess()
|
||||||
onClose()
|
onClose()
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export default function CharacterCard({
|
|||||||
const imageUrlsWithIndex = rawImageUrls
|
const imageUrlsWithIndex = rawImageUrls
|
||||||
.map((url, idx) => ({ url, originalIndex: idx }))
|
.map((url, idx) => ({ url, originalIndex: idx }))
|
||||||
.filter((item) => !!item.url) as { url: string; originalIndex: number }[]
|
.filter((item) => !!item.url) as { url: string; originalIndex: number }[]
|
||||||
|
const generatedImageCount = imageUrlsWithIndex.length
|
||||||
|
|
||||||
const hasMultipleImages = imageUrlsWithIndex.length > 1
|
const hasMultipleImages = imageUrlsWithIndex.length > 1
|
||||||
const selectedIndex = appearance.selectedIndex ?? null
|
const selectedIndex = appearance.selectedIndex ?? null
|
||||||
@@ -218,22 +219,24 @@ export default function CharacterCard({
|
|||||||
<>
|
<>
|
||||||
<ImageGenerationInlineCountButton
|
<ImageGenerationInlineCountButton
|
||||||
prefix={isGroupTaskRunning ? (
|
prefix={isGroupTaskRunning ? (
|
||||||
|
<>
|
||||||
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
|
<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)]" />
|
<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>
|
<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}
|
value={generationCount}
|
||||||
options={getImageGenerationCountOptions('character')}
|
options={getImageGenerationCountOptions('character')}
|
||||||
onValueChange={setGenerationCount}
|
onValueChange={setGenerationCount}
|
||||||
onClick={() => onRegenerate(generationCount)}
|
onClick={() => onRegenerate(generatedImageCount)}
|
||||||
disabled={isAppearanceTaskRunning || isAnyTaskRunning || uploadImage.isPending}
|
disabled={isAppearanceTaskRunning || isAnyTaskRunning || uploadImage.isPending}
|
||||||
ariaLabel={t('image.regenCountAriaLabel')}
|
showCountControl={false}
|
||||||
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"
|
ariaLabel={t('image.regenCountPrefix')}
|
||||||
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"
|
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) && (
|
{onUndo && (appearance.previousImageUrl || appearance.previousImageUrls.length > 0) && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export default function LocationCard({
|
|||||||
|
|
||||||
const displaySelectionImages = resolveDisplayImageSlots(orderedImages, {
|
const displaySelectionImages = resolveDisplayImageSlots(orderedImages, {
|
||||||
hasRunningTask: isTaskRunning,
|
hasRunningTask: isTaskRunning,
|
||||||
requestedCount: generationCount,
|
requestedCount: generatedImageCount > 1 ? generatedImageCount : generationCount,
|
||||||
})
|
})
|
||||||
const displaySlotCount = displaySelectionImages.length
|
const displaySlotCount = displaySelectionImages.length
|
||||||
const hasMultipleImages = generatedImageCount > 1
|
const hasMultipleImages = generatedImageCount > 1
|
||||||
@@ -192,22 +192,24 @@ export default function LocationCard({
|
|||||||
<>
|
<>
|
||||||
<ImageGenerationInlineCountButton
|
<ImageGenerationInlineCountButton
|
||||||
prefix={isGroupTaskRunning ? (
|
prefix={isGroupTaskRunning ? (
|
||||||
|
<>
|
||||||
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
|
<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)]" />
|
<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>
|
<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}
|
value={generationCount}
|
||||||
options={getImageGenerationCountOptions('location')}
|
options={getImageGenerationCountOptions('location')}
|
||||||
onValueChange={setGenerationCount}
|
onValueChange={setGenerationCount}
|
||||||
onClick={() => onRegenerate(generationCount)}
|
onClick={() => onRegenerate(generatedImageCount)}
|
||||||
disabled={isTaskRunning || isAnyTaskRunning || uploadImage.isPending}
|
disabled={isTaskRunning || isAnyTaskRunning || uploadImage.isPending}
|
||||||
ariaLabel={t('image.regenCountAriaLabel')}
|
showCountControl={false}
|
||||||
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"
|
ariaLabel={t('image.regenCountPrefix')}
|
||||||
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"
|
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 && (
|
{onUndo && hasPreviousVersion && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import AIDataModalFormPane from './AIDataModalFormPane'
|
import { createPortal } from 'react-dom'
|
||||||
import AIDataModalPreviewPane from './AIDataModalPreviewPane'
|
import { AppIcon } from '@/components/ui/icons'
|
||||||
|
import GlassButton from '@/components/ui/primitives/GlassButton'
|
||||||
import type { AIDataModalProps } from './AIDataModal.types'
|
import type { AIDataModalProps } from './AIDataModal.types'
|
||||||
import { useAIDataModalState } from './hooks/useAIDataModalState'
|
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 {
|
export type {
|
||||||
AIDataModalProps,
|
AIDataModalProps,
|
||||||
@@ -13,6 +17,7 @@ export type {
|
|||||||
PhotographyRules,
|
PhotographyRules,
|
||||||
ActingCharacter,
|
ActingCharacter,
|
||||||
ActingNotes,
|
ActingNotes,
|
||||||
|
AIDataCharacter,
|
||||||
} from './AIDataModal.types'
|
} from './AIDataModal.types'
|
||||||
|
|
||||||
export default function AIDataModal({
|
export default function AIDataModal({
|
||||||
@@ -32,6 +37,7 @@ export default function AIDataModal({
|
|||||||
onSave,
|
onSave,
|
||||||
}: AIDataModalProps) {
|
}: AIDataModalProps) {
|
||||||
const t = useTranslations('storyboard')
|
const t = useTranslations('storyboard')
|
||||||
|
const [activeCharIdx, setActiveCharIdx] = useState(0)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
shotType,
|
shotType,
|
||||||
@@ -78,29 +84,49 @@ export default function AIDataModal({
|
|||||||
...(actingNotes.length > 0 ? { acting_notes: actingNotes } : {}),
|
...(actingNotes.length > 0 ? { acting_notes: actingNotes } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOpen) return null
|
useEffect(() => {
|
||||||
|
if (!isOpen || typeof document === 'undefined') return undefined
|
||||||
|
return lockModalPageScroll(document)
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
return (
|
if (!isOpen || typeof document === 'undefined') return null
|
||||||
<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} />
|
|
||||||
|
|
||||||
<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">
|
return createPortal(
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)]">
|
<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">
|
<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>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-[var(--glass-text-primary)]">{t('aiData.title')}</h2>
|
<h2 className="text-sm font-semibold text-[var(--glass-text-primary)] leading-none">
|
||||||
<p className="text-xs text-[var(--glass-text-tertiary)]">{t('aiData.subtitle', { number: panelNumber })}</p>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-2 hover:bg-[var(--glass-bg-muted)] rounded-lg transition-colors">
|
<button
|
||||||
<AppIcon name="close" className="w-5 h-5 text-[var(--glass-text-tertiary)]" />
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden flex">
|
{/* Body */}
|
||||||
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
<AIDataModalFormPane
|
<AIDataModalFormPane
|
||||||
t={(key) => t(key as never)}
|
t={t}
|
||||||
shotType={shotType}
|
shotType={shotType}
|
||||||
cameraMove={cameraMove}
|
cameraMove={cameraMove}
|
||||||
description={description}
|
description={description}
|
||||||
@@ -109,6 +135,8 @@ export default function AIDataModal({
|
|||||||
videoPrompt={videoPrompt}
|
videoPrompt={videoPrompt}
|
||||||
photographyRules={photographyRules}
|
photographyRules={photographyRules}
|
||||||
actingNotes={actingNotes}
|
actingNotes={actingNotes}
|
||||||
|
activeCharIdx={activeCharIdx}
|
||||||
|
onActiveCharIdxChange={setActiveCharIdx}
|
||||||
onShotTypeChange={setShotType}
|
onShotTypeChange={setShotType}
|
||||||
onCameraMoveChange={setCameraMove}
|
onCameraMoveChange={setCameraMove}
|
||||||
onDescriptionChange={setDescription}
|
onDescriptionChange={setDescription}
|
||||||
@@ -117,29 +145,34 @@ export default function AIDataModal({
|
|||||||
onPhotographyCharacterChange={updatePhotographyCharacter}
|
onPhotographyCharacterChange={updatePhotographyCharacter}
|
||||||
onActingCharacterChange={updateActingCharacter}
|
onActingCharacterChange={updateActingCharacter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AIDataModalPreviewPane
|
<AIDataModalPreviewPane
|
||||||
t={(key) => t(key as never)}
|
t={t}
|
||||||
previewJson={previewJson}
|
previewJson={previewJson}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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)]">
|
{/* Footer */}
|
||||||
<button
|
<div className="flex items-center justify-between border-t border-[var(--glass-stroke-base)] px-5 py-3 flex-shrink-0">
|
||||||
onClick={onClose}
|
<p className="text-[11px] text-[var(--glass-text-tertiary)]">
|
||||||
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"
|
{characters.map(c => c.name).join('、')}
|
||||||
>
|
{location ? ` · ${location}` : ''}
|
||||||
{t('candidate.cancel')}
|
</p>
|
||||||
</button>
|
<div className="flex gap-2">
|
||||||
<button
|
<GlassButton variant="secondary" size="sm" onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</GlassButton>
|
||||||
|
<GlassButton
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={handleSave}
|
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')}
|
{t('aiData.save')}
|
||||||
</button>
|
</GlassButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
export interface AIDataCharacter {
|
||||||
|
name: string
|
||||||
|
appearance: string
|
||||||
|
slot?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface PhotographyCharacter {
|
export interface PhotographyCharacter {
|
||||||
name: string
|
name: string
|
||||||
screen_position: string
|
screen_position: string
|
||||||
@@ -47,7 +53,7 @@ export interface AIDataModalProps {
|
|||||||
cameraMove: string | null
|
cameraMove: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
location: string | null
|
location: string | null
|
||||||
characters: string[]
|
characters: AIDataCharacter[]
|
||||||
videoPrompt: string | null
|
videoPrompt: string | null
|
||||||
photographyRules: PhotographyRules | null
|
photographyRules: PhotographyRules | null
|
||||||
actingNotes: ActingNotes | ActingCharacter[] | null
|
actingNotes: ActingNotes | ActingCharacter[] | null
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
'use client'
|
'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 {
|
import type {
|
||||||
ActingCharacter,
|
ActingCharacter,
|
||||||
|
AIDataCharacter,
|
||||||
PhotographyCharacter,
|
PhotographyCharacter,
|
||||||
PhotographyRules,
|
PhotographyRules,
|
||||||
} from './AIDataModal.types'
|
} from './AIDataModal.types'
|
||||||
|
|
||||||
interface AIDataModalFormPaneProps {
|
interface AIDataModalFormPaneProps {
|
||||||
t: (key: string) => string
|
t: ReturnType<typeof useTranslations<'storyboard'>>
|
||||||
shotType: string
|
shotType: string
|
||||||
cameraMove: string
|
cameraMove: string
|
||||||
description: string
|
description: string
|
||||||
location: string | null
|
location: string | null
|
||||||
characters: string[]
|
characters: AIDataCharacter[]
|
||||||
videoPrompt: string
|
videoPrompt: string
|
||||||
photographyRules: PhotographyRules | null
|
photographyRules: PhotographyRules | null
|
||||||
actingNotes: ActingCharacter[]
|
actingNotes: ActingCharacter[]
|
||||||
|
activeCharIdx: number
|
||||||
|
onActiveCharIdxChange: (idx: number) => void
|
||||||
onShotTypeChange: (value: string) => void
|
onShotTypeChange: (value: string) => void
|
||||||
onCameraMoveChange: (value: string) => void
|
onCameraMoveChange: (value: string) => void
|
||||||
onDescriptionChange: (value: string) => void
|
onDescriptionChange: (value: string) => void
|
||||||
@@ -25,6 +33,101 @@ interface AIDataModalFormPaneProps {
|
|||||||
onActingCharacterChange: (index: number, field: keyof ActingCharacter, value: string) => void
|
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({
|
export default function AIDataModalFormPane({
|
||||||
t,
|
t,
|
||||||
shotType,
|
shotType,
|
||||||
@@ -35,6 +138,8 @@ export default function AIDataModalFormPane({
|
|||||||
videoPrompt,
|
videoPrompt,
|
||||||
photographyRules,
|
photographyRules,
|
||||||
actingNotes,
|
actingNotes,
|
||||||
|
activeCharIdx,
|
||||||
|
onActiveCharIdxChange,
|
||||||
onShotTypeChange,
|
onShotTypeChange,
|
||||||
onCameraMoveChange,
|
onCameraMoveChange,
|
||||||
onDescriptionChange,
|
onDescriptionChange,
|
||||||
@@ -43,194 +148,257 @@ export default function AIDataModalFormPane({
|
|||||||
onPhotographyCharacterChange,
|
onPhotographyCharacterChange,
|
||||||
onActingCharacterChange,
|
onActingCharacterChange,
|
||||||
}: AIDataModalFormPaneProps) {
|
}: 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 (
|
return (
|
||||||
<div className="w-1/2 border-r border-[var(--glass-stroke-base)] overflow-y-auto p-6 space-y-5">
|
<div className="w-[55%] border-r border-[var(--glass-stroke-base)] overflow-y-auto p-5 space-y-5">
|
||||||
<div className="text-sm font-medium text-[var(--glass-text-secondary)] mb-3">{t('aiData.basicData')}</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{/* ① 视觉描述 — 最高优先 */}
|
||||||
<div>
|
<section>
|
||||||
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.shotType')}</label>
|
<div className="flex items-center gap-2 mb-2.5">
|
||||||
<input
|
<AppIcon name="fileText" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)] flex-shrink-0" />
|
||||||
type="text"
|
<span className="text-[11px] font-semibold text-[var(--glass-text-primary)]">
|
||||||
value={shotType}
|
{t('aiData.visualDescription')}
|
||||||
onChange={(event) => onShotTypeChange(event.target.value)}
|
</span>
|
||||||
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')}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<AutoGrowTextarea
|
||||||
<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)}
|
|
||||||
rows={3}
|
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')}
|
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>
|
</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>
|
<div>
|
||||||
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.videoPrompt')}</label>
|
<FL>{t('aiData.appearanceReadonly')}</FL>
|
||||||
<textarea
|
<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}
|
value={videoPrompt}
|
||||||
onChange={(event) => onVideoPromptChange(event.target.value)}
|
onChange={e => onVideoPromptChange(e.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)]"
|
|
||||||
placeholder={t('panel.videoPromptPlaceholder')}
|
placeholder={t('panel.videoPromptPlaceholder')}
|
||||||
|
className="bg-[var(--glass-tone-warning-bg)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</CollapseSection>
|
||||||
|
|
||||||
|
{/* ⑤ 摄影环境 — 折叠 */}
|
||||||
{photographyRules && (
|
{photographyRules && (
|
||||||
<>
|
<CollapseSection label={t('aiData.photoEnv')} iconName="film">
|
||||||
<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>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.summary')}</label>
|
<FL>{t('aiData.summary')}</FL>
|
||||||
<input
|
<GlassInput
|
||||||
type="text"
|
density="compact"
|
||||||
value={photographyRules.scene_summary || ''}
|
value={photographyRules.scene_summary}
|
||||||
onChange={(event) => onPhotographyFieldChange('scene_summary', event.target.value)}
|
onChange={e => onPhotographyFieldChange('scene_summary', e.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>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.lightingDirection')}</label>
|
<FL>{t('aiData.lightingDirection')}</FL>
|
||||||
<input
|
<GlassInput
|
||||||
type="text"
|
density="compact"
|
||||||
value={photographyRules.lighting?.direction || ''}
|
value={photographyRules.lighting?.direction ?? ''}
|
||||||
onChange={(event) => onPhotographyFieldChange('lighting.direction', event.target.value)}
|
onChange={e => onPhotographyFieldChange('lighting.direction', e.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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.lightingQuality')}</label>
|
<FL>{t('aiData.lightingQuality')}</FL>
|
||||||
<input
|
<GlassInput
|
||||||
type="text"
|
density="compact"
|
||||||
value={photographyRules.lighting?.quality || ''}
|
value={photographyRules.lighting?.quality ?? ''}
|
||||||
onChange={(event) => onPhotographyFieldChange('lighting.quality', event.target.value)}
|
onChange={e => onPhotographyFieldChange('lighting.quality', e.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>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.depthOfField')}</label>
|
<FL>{t('aiData.depthOfField')}</FL>
|
||||||
<input
|
<GlassInput
|
||||||
type="text"
|
density="compact"
|
||||||
value={photographyRules.depth_of_field || ''}
|
value={photographyRules.depth_of_field}
|
||||||
onChange={(event) => onPhotographyFieldChange('depth_of_field', event.target.value)}
|
onChange={e => onPhotographyFieldChange('depth_of_field', e.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"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5">{t('aiData.posture')}</label>
|
<FL>{t('aiData.colorTone')}</FL>
|
||||||
<input
|
<GlassInput
|
||||||
type="text"
|
density="compact"
|
||||||
value={character.posture || ''}
|
value={photographyRules.color_tone}
|
||||||
onChange={(event) => onPhotographyCharacterChange(index, 'posture', event.target.value)}
|
onChange={e => onPhotographyFieldChange('color_tone', e.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"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CollapseSection>
|
||||||
))}
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,46 +1,89 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { useTranslations } from 'next-intl'
|
||||||
import { AppIcon } from '@/components/ui/icons'
|
import { AppIcon } from '@/components/ui/icons'
|
||||||
|
import GlassButton from '@/components/ui/primitives/GlassButton'
|
||||||
|
|
||||||
interface AIDataModalPreviewPaneProps {
|
interface AIDataModalPreviewPaneProps {
|
||||||
t: (key: string) => string
|
t: ReturnType<typeof useTranslations<'storyboard'>>
|
||||||
previewJson: Record<string, unknown>
|
previewJson: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AIDataModalPreviewPane({
|
export async function copyPreviewJsonText(text: string): Promise<void> {
|
||||||
t,
|
const clipboardApi = globalThis.navigator?.clipboard
|
||||||
previewJson,
|
if (clipboardApi && typeof clipboardApi.writeText === 'function') {
|
||||||
}: AIDataModalPreviewPaneProps) {
|
try {
|
||||||
return (
|
await clipboardApi.writeText(text)
|
||||||
<div className="w-1/2 bg-[var(--glass-text-primary)] overflow-y-auto p-4">
|
return
|
||||||
<div className="flex items-center justify-between mb-3">
|
} catch {
|
||||||
<span className="text-xs text-[var(--glass-text-tertiary)]">{t('aiData.jsonPreview')}</span>
|
// Fall through to manual copy fallback.
|
||||||
<button
|
}
|
||||||
onClick={() => {
|
}
|
||||||
const text = JSON.stringify(previewJson, null, 2)
|
|
||||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
if (typeof document === 'undefined') {
|
||||||
navigator.clipboard.writeText(text).catch(() => { })
|
throw new Error('Clipboard unavailable')
|
||||||
} else {
|
}
|
||||||
// HTTP 环境 fallback
|
|
||||||
const el = document.createElement('textarea')
|
const el = document.createElement('textarea')
|
||||||
el.value = text
|
el.value = text
|
||||||
el.style.position = 'fixed'
|
el.style.position = 'fixed'
|
||||||
el.style.opacity = '0'
|
el.style.opacity = '0'
|
||||||
document.body.appendChild(el)
|
document.body.appendChild(el)
|
||||||
el.select()
|
el.select()
|
||||||
document.execCommand('copy')
|
const copied = typeof document.execCommand === 'function' && document.execCommand('copy')
|
||||||
document.body.removeChild(el)
|
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"
|
|
||||||
>
|
export default function AIDataModalPreviewPane({
|
||||||
<AppIcon name="copy" className="w-3.5 h-3.5" />
|
t,
|
||||||
{t('common.copy')}
|
previewJson,
|
||||||
</button>
|
}: 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>
|
</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)}
|
{JSON.stringify(previewJson, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,13 +64,13 @@ export function useStoryboardAiDataRuntime({
|
|||||||
const panelData = getPanelEditData(panel)
|
const panelData = getPanelEditData(panel)
|
||||||
const photographyRules = parseJsonSafely(panel.photographyRules, 'photographyRules')
|
const photographyRules = parseJsonSafely(panel.photographyRules, 'photographyRules')
|
||||||
const actingNotes = parseJsonSafely(panel.actingNotes, 'actingNotes')
|
const actingNotes = parseJsonSafely(panel.actingNotes, 'actingNotes')
|
||||||
const characterNames = panelData.characters.map((character) => character.name)
|
const characters = panelData.characters.map((character) => ({ ...character }))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
panelData,
|
panelData,
|
||||||
panel,
|
panel,
|
||||||
storyboardId: storyboard.id,
|
storyboardId: storyboard.id,
|
||||||
characterNames,
|
characters,
|
||||||
photographyRules,
|
photographyRules,
|
||||||
actingNotes,
|
actingNotes,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface StoryboardPanel {
|
|||||||
shot_type: string
|
shot_type: string
|
||||||
camera_move: string | null
|
camera_move: string | null
|
||||||
description: string
|
description: string
|
||||||
characters: { name: string; appearance: string }[]
|
characters: { name: string; appearance: string; slot?: string }[]
|
||||||
location?: string
|
location?: string
|
||||||
srt_range?: string
|
srt_range?: string
|
||||||
duration?: number
|
duration?: number
|
||||||
@@ -112,12 +112,22 @@ export function useStoryboardState({
|
|||||||
return sortedPanels.map((p) => {
|
return sortedPanels.map((p) => {
|
||||||
const parsedChars = p.characters ? JSON.parse(p.characters) : []
|
const parsedChars = p.characters ? JSON.parse(p.characters) : []
|
||||||
const characters = Array.isArray(parsedChars)
|
const characters = Array.isArray(parsedChars)
|
||||||
? parsedChars.filter((item): item is { name: string; appearance: string } => (
|
? parsedChars.flatMap((item): Array<{ name: string; appearance: string; slot?: string }> => {
|
||||||
typeof item === 'object'
|
if (
|
||||||
&& item !== null
|
typeof item !== 'object'
|
||||||
&& typeof (item as { name?: unknown }).name === 'string'
|
|| item === null
|
||||||
&& typeof (item as { appearance?: unknown }).appearance === 'string'
|
|| 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 {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export default function StoryboardStage({
|
|||||||
cameraMove={modalRuntime.aiDataRuntime.panelData.cameraMove}
|
cameraMove={modalRuntime.aiDataRuntime.panelData.cameraMove}
|
||||||
description={modalRuntime.aiDataRuntime.panelData.description}
|
description={modalRuntime.aiDataRuntime.panelData.description}
|
||||||
location={modalRuntime.aiDataRuntime.panelData.location}
|
location={modalRuntime.aiDataRuntime.panelData.location}
|
||||||
characters={modalRuntime.aiDataRuntime.characterNames}
|
characters={modalRuntime.aiDataRuntime.characters}
|
||||||
videoPrompt={modalRuntime.aiDataRuntime.panelData.videoPrompt}
|
videoPrompt={modalRuntime.aiDataRuntime.panelData.videoPrompt}
|
||||||
photographyRules={modalRuntime.aiDataRuntime.photographyRules}
|
photographyRules={modalRuntime.aiDataRuntime.photographyRules}
|
||||||
actingNotes={modalRuntime.aiDataRuntime.actingNotes}
|
actingNotes={modalRuntime.aiDataRuntime.actingNotes}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { useWorkspaceAutoRun } from './useWorkspaceAutoRun'
|
|||||||
import { buildWorkspaceControllerViewModel } from './workspace-controller-view-model'
|
import { buildWorkspaceControllerViewModel } from './workspace-controller-view-model'
|
||||||
import type { NovelPromotionWorkspaceProps } from '../types'
|
import type { NovelPromotionWorkspaceProps } from '../types'
|
||||||
import { useRouter } from '@/i18n/navigation'
|
import { useRouter } from '@/i18n/navigation'
|
||||||
|
import { resolveEpisodeStageArtifacts } from '@/lib/novel-promotion/stage-readiness'
|
||||||
|
|
||||||
export function useNovelPromotionWorkspaceController({
|
export function useNovelPromotionWorkspaceController({
|
||||||
project,
|
project,
|
||||||
@@ -38,7 +39,7 @@ export function useNovelPromotionWorkspaceController({
|
|||||||
const { onRefresh } = useWorkspaceProvider()
|
const { onRefresh } = useWorkspaceProvider()
|
||||||
|
|
||||||
const projectSnapshot = useWorkspaceProjectSnapshot({ project, episode, urlStage })
|
const projectSnapshot = useWorkspaceProjectSnapshot({ project, episode, urlStage })
|
||||||
const { currentStage, episodeStoryboards, ...projectSection } = projectSnapshot
|
const { currentStage, ...projectSection } = projectSnapshot
|
||||||
|
|
||||||
const assetsLoading = false
|
const assetsLoading = false
|
||||||
const assetsLoadingState = assetsLoading
|
const assetsLoadingState = assetsLoading
|
||||||
@@ -116,6 +117,11 @@ export function useNovelPromotionWorkspaceController({
|
|||||||
execution.storyToScriptStream.isRunning ||
|
execution.storyToScriptStream.isRunning ||
|
||||||
execution.storyToScriptStream.isRecoveredRunning ||
|
execution.storyToScriptStream.isRecoveredRunning ||
|
||||||
execution.storyToScriptStream.status === 'running'
|
execution.storyToScriptStream.status === 'running'
|
||||||
|
const isScriptToStoryboardRunning =
|
||||||
|
execution.scriptToStoryboardStream.isRunning ||
|
||||||
|
execution.scriptToStoryboardStream.isRecoveredRunning ||
|
||||||
|
execution.scriptToStoryboardStream.status === 'running'
|
||||||
|
const stageArtifacts = resolveEpisodeStageArtifacts(episode)
|
||||||
|
|
||||||
const isAnyOperationRunning =
|
const isAnyOperationRunning =
|
||||||
isStartingStoryToScript ||
|
isStartingStoryToScript ||
|
||||||
@@ -124,8 +130,8 @@ export function useNovelPromotionWorkspaceController({
|
|||||||
execution.isAssetAnalysisRunning ||
|
execution.isAssetAnalysisRunning ||
|
||||||
execution.isConfirmingAssets ||
|
execution.isConfirmingAssets ||
|
||||||
execution.isTransitioning ||
|
execution.isTransitioning ||
|
||||||
execution.storyToScriptStream.isRunning ||
|
isStoryToScriptRunning ||
|
||||||
execution.scriptToStoryboardStream.isRunning
|
isScriptToStoryboardRunning
|
||||||
|
|
||||||
useWorkspaceAutoRun({
|
useWorkspaceAutoRun({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -140,9 +146,7 @@ export function useNovelPromotionWorkspaceController({
|
|||||||
|
|
||||||
const capsuleNavItems = useWorkspaceStageNavigation({
|
const capsuleNavItems = useWorkspaceStageNavigation({
|
||||||
isAnyOperationRunning,
|
isAnyOperationRunning,
|
||||||
episode,
|
stageArtifacts,
|
||||||
projectCharacterCount: projectSnapshot.projectCharacters.length,
|
|
||||||
episodeStoryboards,
|
|
||||||
t,
|
t,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { NovelPromotionPanel } from '@/types/project'
|
import type { StageArtifactReadiness } from '@/lib/novel-promotion/stage-readiness'
|
||||||
|
|
||||||
interface EpisodeLike {
|
|
||||||
novelText?: string | null
|
|
||||||
voiceLines?: unknown[] | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StoryboardLike {
|
|
||||||
panels?: NovelPromotionPanel[] | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CapsuleNavItem {
|
interface CapsuleNavItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -22,17 +13,13 @@ interface CapsuleNavItem {
|
|||||||
|
|
||||||
interface UseWorkspaceStageNavigationParams {
|
interface UseWorkspaceStageNavigationParams {
|
||||||
isAnyOperationRunning: boolean
|
isAnyOperationRunning: boolean
|
||||||
episode?: EpisodeLike | null
|
stageArtifacts: StageArtifactReadiness
|
||||||
projectCharacterCount: number
|
|
||||||
episodeStoryboards: StoryboardLike[]
|
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkspaceStageNavigation({
|
export function useWorkspaceStageNavigation({
|
||||||
isAnyOperationRunning,
|
isAnyOperationRunning,
|
||||||
episode,
|
stageArtifacts,
|
||||||
projectCharacterCount,
|
|
||||||
episodeStoryboards,
|
|
||||||
t,
|
t,
|
||||||
}: UseWorkspaceStageNavigationParams): CapsuleNavItem[] {
|
}: UseWorkspaceStageNavigationParams): CapsuleNavItem[] {
|
||||||
const getStageStatus = (stageId: string): 'empty' | 'active' | 'processing' | 'ready' => {
|
const getStageStatus = (stageId: string): 'empty' | 'active' | 'processing' | 'ready' => {
|
||||||
@@ -40,16 +27,16 @@ export function useWorkspaceStageNavigation({
|
|||||||
|
|
||||||
switch (stageId) {
|
switch (stageId) {
|
||||||
case 'config':
|
case 'config':
|
||||||
return episode?.novelText ? 'ready' : 'active'
|
return stageArtifacts.hasStory ? 'ready' : 'active'
|
||||||
case 'assets':
|
case 'assets':
|
||||||
return projectCharacterCount > 0 ? 'ready' : 'empty'
|
return stageArtifacts.hasScript ? 'ready' : 'empty'
|
||||||
case 'storyboard':
|
case 'storyboard':
|
||||||
return episodeStoryboards.some((sb) => sb.panels?.length) ? 'ready' : 'empty'
|
return stageArtifacts.hasStoryboard ? 'ready' : 'empty'
|
||||||
case 'videos':
|
case 'videos':
|
||||||
case 'editor':
|
case 'editor':
|
||||||
return episodeStoryboards.some((sb) => sb.panels?.some((panel) => panel.videoUrl)) ? 'ready' : 'empty'
|
return stageArtifacts.hasVideo ? 'ready' : 'empty'
|
||||||
case 'voice':
|
case 'voice':
|
||||||
return (episode?.voiceLines?.length || 0) > 0 ? 'ready' : 'empty'
|
return stageArtifacts.hasVoice ? 'ready' : 'empty'
|
||||||
default:
|
default:
|
||||||
return 'empty'
|
return 'empty'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useImageGenerationCount } from '@/lib/image-generation/use-image-genera
|
|||||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||||
import { AppIcon } from '@/components/ui/icons'
|
import { AppIcon } from '@/components/ui/icons'
|
||||||
|
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
|
||||||
|
|
||||||
interface AddLocationModalProps {
|
interface AddLocationModalProps {
|
||||||
folderId: string | null
|
folderId: string | null
|
||||||
@@ -33,6 +34,7 @@ export function AddLocationModal({ folderId, onClose, onSuccess }: AddLocationMo
|
|||||||
const [summary, setSummary] = useState('')
|
const [summary, setSummary] = useState('')
|
||||||
const [aiInstruction, setAiInstruction] = useState('')
|
const [aiInstruction, setAiInstruction] = useState('')
|
||||||
const [artStyle, setArtStyle] = useState('american-comic')
|
const [artStyle, setArtStyle] = useState('american-comic')
|
||||||
|
const [availableSlots, setAvailableSlots] = useState<LocationAvailableSlot[]>([])
|
||||||
|
|
||||||
const aiDesignMutation = useAiDesignLocation()
|
const aiDesignMutation = useAiDesignLocation()
|
||||||
const createLocationMutation = useCreateAssetHubLocation()
|
const createLocationMutation = useCreateAssetHubLocation()
|
||||||
@@ -63,6 +65,7 @@ export function AddLocationModal({ folderId, onClose, onSuccess }: AddLocationMo
|
|||||||
try {
|
try {
|
||||||
const data = await aiDesignMutation.mutateAsync(aiInstruction.trim())
|
const data = await aiDesignMutation.mutateAsync(aiInstruction.trim())
|
||||||
setSummary(data.prompt || '')
|
setSummary(data.prompt || '')
|
||||||
|
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
|
||||||
setAiInstruction('')
|
setAiInstruction('')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_ulogError('AI设计失败:', error)
|
_ulogError('AI设计失败:', error)
|
||||||
@@ -80,6 +83,7 @@ export function AddLocationModal({ folderId, onClose, onSuccess }: AddLocationMo
|
|||||||
folderId,
|
folderId,
|
||||||
artStyle,
|
artStyle,
|
||||||
count: locationGenerationCount,
|
count: locationGenerationCount,
|
||||||
|
availableSlots,
|
||||||
})
|
})
|
||||||
onSuccess()
|
onSuccess()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ export function CharacterCard({ character, onImageClick, onImageEdit, onVoiceDes
|
|||||||
}
|
}
|
||||||
|
|
||||||
const imageUrls = appearance?.imageUrls || []
|
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 effectiveSelectedIndex: number | null = appearance?.selectedIndex ?? null
|
||||||
const currentImageUrl = appearance?.imageUrl || (effectiveSelectedIndex !== null ? imageUrls[effectiveSelectedIndex] : null) || imageUrls.find(u => u) || 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))
|
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">
|
<div className="flex items-center gap-1">
|
||||||
<ImageGenerationInlineCountButton
|
<ImageGenerationInlineCountButton
|
||||||
prefix={isAppearanceTaskRunning ? (
|
prefix={isAppearanceTaskRunning ? (
|
||||||
|
<>
|
||||||
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
|
<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)]" />
|
<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}
|
value={generationCount}
|
||||||
options={getImageGenerationCountOptions('character')}
|
options={getImageGenerationCountOptions('character')}
|
||||||
onValueChange={setGenerationCount}
|
onValueChange={setGenerationCount}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
_ulogInfo('[CharacterCard] 多图模式 - 重新生成按钮点击, characterId:', character.id, 'appearanceCount:', appearanceCount)
|
_ulogInfo('[CharacterCard] 多图模式 - 重新生成按钮点击, characterId:', character.id, 'appearanceCount:', appearanceCount)
|
||||||
handleGenerate(generationCount)
|
handleGenerate(generatedImageCount)
|
||||||
}}
|
}}
|
||||||
disabled={isAppearanceTaskRunning}
|
disabled={isAppearanceTaskRunning}
|
||||||
ariaLabel={tAssets('image.selectCount')}
|
showCountControl={false}
|
||||||
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"
|
ariaLabel={tAssets('image.regenCountPrefix')}
|
||||||
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"
|
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 && (
|
{hasPreviousVersion && (
|
||||||
<button onClick={handleUndo} className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md" title={tAssets('image.undo')}>
|
<button onClick={handleUndo} className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md" title={tAssets('image.undo')}>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function LocationCard({ location, assetType = 'location', onImageClick, o
|
|||||||
const isTaskRunning = serverTaskRunning || transientSubmitting
|
const isTaskRunning = serverTaskRunning || transientSubmitting
|
||||||
const displaySelectionImages = resolveDisplayImageSlots(orderedImages, {
|
const displaySelectionImages = resolveDisplayImageSlots(orderedImages, {
|
||||||
hasRunningTask: isTaskRunning,
|
hasRunningTask: isTaskRunning,
|
||||||
requestedCount: generationCount,
|
requestedCount: generatedImageCount > 1 ? generatedImageCount : generationCount,
|
||||||
})
|
})
|
||||||
const displaySlotCount = displaySelectionImages.length
|
const displaySlotCount = displaySelectionImages.length
|
||||||
const hasMultipleImages = generatedImageCount > 1
|
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">
|
<div className="flex items-center gap-1 ml-2">
|
||||||
<ImageGenerationInlineCountButton
|
<ImageGenerationInlineCountButton
|
||||||
prefix={isTaskRunning ? (
|
prefix={isTaskRunning ? (
|
||||||
|
<>
|
||||||
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
|
<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)]" />
|
<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}
|
value={generationCount}
|
||||||
options={getImageGenerationCountOptions('location')}
|
options={getImageGenerationCountOptions('location')}
|
||||||
onValueChange={setGenerationCount}
|
onValueChange={setGenerationCount}
|
||||||
onClick={() => handleGenerate(generationCount)}
|
onClick={() => handleGenerate(generatedImageCount)}
|
||||||
disabled={isTaskRunning}
|
disabled={isTaskRunning}
|
||||||
ariaLabel={tAssets('image.selectCount')}
|
showCountControl={false}
|
||||||
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"
|
ariaLabel={tAssets('image.regenCountPrefix')}
|
||||||
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"
|
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 && (
|
{hasPreviousVersion && (
|
||||||
<button onClick={handleUndo} className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md" title={tAssets('image.undo')}>
|
<button onClick={handleUndo} className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md" title={tAssets('image.undo')}>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { AppIcon, IconGradientDefs } from '@/components/ui/icons'
|
|||||||
import { shouldGuideToModelSetup } from '@/lib/workspace/model-setup'
|
import { shouldGuideToModelSetup } from '@/lib/workspace/model-setup'
|
||||||
import { Link, useRouter } from '@/i18n/navigation'
|
import { Link, useRouter } from '@/i18n/navigation'
|
||||||
import { apiFetch } from '@/lib/api-fetch'
|
import { apiFetch } from '@/lib/api-fetch'
|
||||||
|
import { readApiErrorMessage } from '@/lib/api/read-error-message'
|
||||||
|
import { validateProjectDraft } from '@/lib/projects/validation'
|
||||||
|
|
||||||
interface ProjectStats {
|
interface ProjectStats {
|
||||||
episodes: number
|
episodes: number
|
||||||
@@ -45,6 +47,24 @@ function formatProjectCost(amount: number, currency = DEFAULT_BILLING_CURRENCY):
|
|||||||
return `¥${amount.toFixed(2)}`
|
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() {
|
export default function WorkspacePage() {
|
||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -52,12 +72,14 @@ export default function WorkspacePage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [createLoading, setCreateLoading] = useState(false)
|
const [createLoading, setCreateLoading] = useState(false)
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null)
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
description: ''
|
description: ''
|
||||||
})
|
})
|
||||||
const [editingProject, setEditingProject] = useState<Project | null>(null)
|
const [editingProject, setEditingProject] = useState<Project | null>(null)
|
||||||
const [showEditModal, setShowEditModal] = useState(false)
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
|
const [editError, setEditError] = useState<string | null>(null)
|
||||||
const [editFormData, setEditFormData] = useState({
|
const [editFormData, setEditFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
description: ''
|
description: ''
|
||||||
@@ -124,6 +146,7 @@ export default function WorkspacePage() {
|
|||||||
|
|
||||||
// 打开新建项目弹窗并检测模型配置
|
// 打开新建项目弹窗并检测模型配置
|
||||||
const openCreateModal = useCallback(() => {
|
const openCreateModal = useCallback(() => {
|
||||||
|
setCreateError(null)
|
||||||
setShowCreateModal(true)
|
setShowCreateModal(true)
|
||||||
// 异步检测模型配置状态
|
// 异步检测模型配置状态
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -146,8 +169,13 @@ export default function WorkspacePage() {
|
|||||||
|
|
||||||
const handleCreateProject = async (e: React.FormEvent) => {
|
const handleCreateProject = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!formData.name.trim()) return
|
const validationMessage = toProjectValidationMessage(validateProjectDraft(formData), t)
|
||||||
|
if (validationMessage) {
|
||||||
|
setCreateError(validationMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateError(null)
|
||||||
setCreateLoading(true)
|
setCreateLoading(true)
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch('/api/projects', {
|
const response = await apiFetch('/api/projects', {
|
||||||
@@ -181,11 +209,11 @@ export default function WorkspacePage() {
|
|||||||
router.push({ pathname: '/profile' })
|
router.push({ pathname: '/profile' })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alert(t('createFailed'))
|
setCreateError(await readApiErrorMessage(response, t('createFailed')))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_ulogError('创建项目失败:', error)
|
_ulogError('创建项目失败:', error)
|
||||||
alert(t('createFailed'))
|
setCreateError(error instanceof Error ? error.message : t('createFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setCreateLoading(false)
|
setCreateLoading(false)
|
||||||
}
|
}
|
||||||
@@ -207,8 +235,15 @@ export default function WorkspacePage() {
|
|||||||
|
|
||||||
const handleEditProject = async (e: React.FormEvent) => {
|
const handleEditProject = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
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)
|
setCreateLoading(true)
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(`/api/projects/${editingProject.id}`, {
|
const response = await apiFetch(`/api/projects/${editingProject.id}`, {
|
||||||
@@ -226,10 +261,10 @@ export default function WorkspacePage() {
|
|||||||
setEditingProject(null)
|
setEditingProject(null)
|
||||||
setEditFormData({ name: '', description: '' })
|
setEditFormData({ name: '', description: '' })
|
||||||
} else {
|
} else {
|
||||||
alert(t('updateFailed'))
|
setEditError(await readApiErrorMessage(response, t('updateFailed')))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
alert(t('updateFailed'))
|
setEditError(error instanceof Error ? error.message : t('updateFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setCreateLoading(false)
|
setCreateLoading(false)
|
||||||
}
|
}
|
||||||
@@ -276,6 +311,7 @@ export default function WorkspacePage() {
|
|||||||
e.preventDefault() // 阻止 Link 导航
|
e.preventDefault() // 阻止 Link 导航
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setEditingProject(project)
|
setEditingProject(project)
|
||||||
|
setEditError(null)
|
||||||
setEditFormData({
|
setEditFormData({
|
||||||
name: project.name,
|
name: project.name,
|
||||||
description: project.description || ''
|
description: project.description || ''
|
||||||
@@ -574,7 +610,12 @@ export default function WorkspacePage() {
|
|||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.name}
|
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"
|
className="glass-input-base w-full px-3 py-2"
|
||||||
placeholder={t('projectNamePlaceholder')}
|
placeholder={t('projectNamePlaceholder')}
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
@@ -589,18 +630,29 @@ export default function WorkspacePage() {
|
|||||||
<textarea
|
<textarea
|
||||||
id="description"
|
id="description"
|
||||||
value={formData.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"
|
className="glass-textarea-base w-full px-3 py-2"
|
||||||
placeholder={t('projectDescriptionPlaceholder')}
|
placeholder={t('projectDescriptionPlaceholder')}
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex justify-end space-x-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowCreateModal(false)
|
setShowCreateModal(false)
|
||||||
|
setCreateError(null)
|
||||||
setFormData({ name: '', description: '' })
|
setFormData({ name: '', description: '' })
|
||||||
}}
|
}}
|
||||||
className="glass-btn-base glass-btn-secondary px-4 py-2"
|
className="glass-btn-base glass-btn-secondary px-4 py-2"
|
||||||
@@ -635,7 +687,12 @@ export default function WorkspacePage() {
|
|||||||
id="edit-name"
|
id="edit-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={editFormData.name}
|
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"
|
className="glass-input-base w-full px-3 py-2"
|
||||||
placeholder={t('projectNamePlaceholder')}
|
placeholder={t('projectNamePlaceholder')}
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
@@ -649,19 +706,30 @@ export default function WorkspacePage() {
|
|||||||
<textarea
|
<textarea
|
||||||
id="edit-description"
|
id="edit-description"
|
||||||
value={editFormData.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"
|
className="glass-textarea-base w-full px-3 py-2"
|
||||||
placeholder={t('projectDescriptionPlaceholder')}
|
placeholder={t('projectDescriptionPlaceholder')}
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex justify-end space-x-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowEditModal(false)
|
setShowEditModal(false)
|
||||||
setEditingProject(null)
|
setEditingProject(null)
|
||||||
|
setEditError(null)
|
||||||
setEditFormData({ name: '', description: '' })
|
setEditFormData({ name: '', description: '' })
|
||||||
}}
|
}}
|
||||||
className="glass-btn-base glass-btn-secondary px-4 py-2"
|
className="glass-btn-base glass-btn-secondary px-4 py-2"
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { ApiError, apiHandler } from '@/lib/api-errors'
|
|||||||
import { attachMediaFieldsToGlobalLocation } from '@/lib/media/attach'
|
import { attachMediaFieldsToGlobalLocation } from '@/lib/media/attach'
|
||||||
import { isArtStyleValue } from '@/lib/constants'
|
import { isArtStyleValue } from '@/lib/constants'
|
||||||
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
|
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
|
||||||
|
import {
|
||||||
|
normalizeLocationAvailableSlots,
|
||||||
|
stringifyLocationAvailableSlots,
|
||||||
|
} from '@/lib/location-available-slots'
|
||||||
|
|
||||||
// 获取用户所有场景(支持 folderId 筛选)
|
// 获取用户所有场景(支持 folderId 筛选)
|
||||||
export const GET = apiHandler(async (request: NextRequest) => {
|
export const GET = apiHandler(async (request: NextRequest) => {
|
||||||
@@ -45,6 +49,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
|||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { name, summary, folderId, artStyle } = body
|
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')
|
const count = Object.prototype.hasOwnProperty.call(body as Record<string, unknown>, 'count')
|
||||||
? normalizeImageGenerationCount('location', (body as Record<string, unknown>).count)
|
? normalizeImageGenerationCount('location', (body as Record<string, unknown>).count)
|
||||||
: 1
|
: 1
|
||||||
@@ -84,6 +89,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
|||||||
locationId: location.id,
|
locationId: location.id,
|
||||||
imageIndex,
|
imageIndex,
|
||||||
description: summary?.trim() || name.trim(),
|
description: summary?.trim() || name.trim(),
|
||||||
|
availableSlots: stringifyLocationAvailableSlots(availableSlots),
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { removeLocationPromptSuffix, isArtStyleValue, type ArtStyleValue } from '@/lib/constants'
|
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 { requireProjectAuth, requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'
|
||||||
import { apiHandler, ApiError } from '@/lib/api-errors'
|
import { apiHandler, ApiError } from '@/lib/api-errors'
|
||||||
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
|
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
|
||||||
@@ -56,6 +60,7 @@ export const POST = apiHandler(async (
|
|||||||
const name = normalizeString(body.name)
|
const name = normalizeString(body.name)
|
||||||
const description = normalizeString(body.description)
|
const description = normalizeString(body.description)
|
||||||
const summary = normalizeString(body.summary)
|
const summary = normalizeString(body.summary)
|
||||||
|
const availableSlots = normalizeLocationAvailableSlots(body.availableSlots)
|
||||||
const count = Object.prototype.hasOwnProperty.call(body, 'count')
|
const count = Object.prototype.hasOwnProperty.call(body, 'count')
|
||||||
? normalizeImageGenerationCount('location', body.count)
|
? normalizeImageGenerationCount('location', body.count)
|
||||||
: 1
|
: 1
|
||||||
@@ -91,6 +96,7 @@ export const POST = apiHandler(async (
|
|||||||
locationId: location.id,
|
locationId: location.id,
|
||||||
imageIndex,
|
imageIndex,
|
||||||
description: cleanDescription,
|
description: cleanDescription,
|
||||||
|
availableSlots: stringifyLocationAvailableSlots(availableSlots),
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -141,7 +147,12 @@ export const PATCH = apiHandler(async (
|
|||||||
where: {
|
where: {
|
||||||
locationId_imageIndex: { locationId, imageIndex }
|
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 })
|
return NextResponse.json({ success: true, image })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,25 @@ import { requireUserAuth, isErrorResponse } from '@/lib/api-auth'
|
|||||||
import { apiHandler, ApiError } from '@/lib/api-errors'
|
import { apiHandler, ApiError } from '@/lib/api-errors'
|
||||||
import { toMoneyNumber } from '@/lib/billing/money'
|
import { toMoneyNumber } from '@/lib/billing/money'
|
||||||
import { isArtStyleValue } from '@/lib/constants'
|
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 - 获取用户的项目(支持分页和搜索)
|
// GET - 获取用户的项目(支持分页和搜索)
|
||||||
export const GET = apiHandler(async (request: NextRequest) => {
|
export const GET = apiHandler(async (request: NextRequest) => {
|
||||||
@@ -169,19 +188,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
|||||||
if (isErrorResponse(authResult)) return authResult
|
if (isErrorResponse(authResult)) return authResult
|
||||||
const { session } = authResult
|
const { session } = authResult
|
||||||
|
|
||||||
const { name, description } = await request.json()
|
const body = await request.json()
|
||||||
|
const draft = readProjectDraftBody(body)
|
||||||
if (!name || name.trim().length === 0) {
|
const validationIssue = validateProjectDraft(draft)
|
||||||
throw new ApiError('INVALID_PARAMS')
|
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) {
|
const { name, description } = normalizeProjectDraft(draft)
|
||||||
throw new ApiError('INVALID_PARAMS')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (description && description.length > 500) {
|
|
||||||
throw new ApiError('INVALID_PARAMS')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户偏好配置
|
// 获取用户偏好配置
|
||||||
const userPreference = await prisma.userPreference.findUnique({
|
const userPreference = await prisma.userPreference.findUnique({
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ function normalizeStatuses(values: string[]): RunStatus[] {
|
|||||||
return next
|
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) => {
|
export const GET = apiHandler(async (request: NextRequest) => {
|
||||||
const authResult = await requireUserAuth()
|
const authResult = await requireUserAuth()
|
||||||
if (isErrorResponse(authResult)) return authResult
|
if (isErrorResponse(authResult)) return authResult
|
||||||
@@ -48,6 +56,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
|||||||
const statuses = normalizeStatuses(query.getAll('status'))
|
const statuses = normalizeStatuses(query.getAll('status'))
|
||||||
const limitRaw = Number.parseInt(query.get('limit') || '50', 10)
|
const limitRaw = Number.parseInt(query.get('limit') || '50', 10)
|
||||||
const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 200) : 50
|
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({
|
const runs = await listRuns({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
projectId: projectId || undefined,
|
projectId: projectId || undefined,
|
||||||
@@ -57,6 +71,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
|||||||
episodeId: episodeId || undefined,
|
episodeId: episodeId || undefined,
|
||||||
statuses: statuses.length > 0 ? statuses : undefined,
|
statuses: statuses.length > 0 ? statuses : undefined,
|
||||||
limit,
|
limit,
|
||||||
|
recoverableOnly: scopedActiveRecoveryQuery,
|
||||||
|
latestOnly: scopedActiveRecoveryQuery,
|
||||||
})
|
})
|
||||||
return NextResponse.json({ runs })
|
return NextResponse.json({ runs })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { AppIcon } from '@/components/ui/icons'
|
|||||||
|
|
||||||
interface ImageGenerationInlineCountButtonProps {
|
interface ImageGenerationInlineCountButtonProps {
|
||||||
prefix: ReactNode
|
prefix: ReactNode
|
||||||
suffix: ReactNode
|
suffix?: ReactNode
|
||||||
value: number
|
value: number
|
||||||
options: number[]
|
options: number[]
|
||||||
onValueChange: (value: number) => void
|
onValueChange: (value: number) => void
|
||||||
@@ -13,7 +13,11 @@ interface ImageGenerationInlineCountButtonProps {
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
actionDisabled?: boolean
|
actionDisabled?: boolean
|
||||||
selectDisabled?: boolean
|
selectDisabled?: boolean
|
||||||
|
showCountControl?: boolean
|
||||||
|
splitInteractiveZones?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
actionClassName?: string
|
||||||
|
countClassName?: string
|
||||||
selectClassName?: string
|
selectClassName?: string
|
||||||
labelClassName?: string
|
labelClassName?: string
|
||||||
ariaLabel: string
|
ariaLabel: string
|
||||||
@@ -29,7 +33,11 @@ export default function ImageGenerationInlineCountButton({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
actionDisabled,
|
actionDisabled,
|
||||||
selectDisabled,
|
selectDisabled,
|
||||||
|
showCountControl = true,
|
||||||
|
splitInteractiveZones = false,
|
||||||
className = '',
|
className = '',
|
||||||
|
actionClassName = '',
|
||||||
|
countClassName = '',
|
||||||
selectClassName = '',
|
selectClassName = '',
|
||||||
labelClassName = '',
|
labelClassName = '',
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
@@ -42,6 +50,68 @@ export default function ImageGenerationInlineCountButton({
|
|||||||
const selectStateClassName = isSelectDisabled
|
const selectStateClassName = isSelectDisabled
|
||||||
? 'pointer-events-none opacity-70'
|
? 'pointer-events-none opacity-70'
|
||||||
: 'cursor-pointer'
|
: '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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -61,10 +131,10 @@ export default function ImageGenerationInlineCountButton({
|
|||||||
aria-disabled={isActionDisabled}
|
aria-disabled={isActionDisabled}
|
||||||
className={`${className} ${rootStateClassName}`.trim()}
|
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
|
<span
|
||||||
className={`group relative inline-flex items-center rounded-md px-1.5 py-0.5 transition-colors ${
|
className={`group relative inline-flex h-8 shrink-0 items-center rounded-full bg-white/12 px-2 transition-colors ${
|
||||||
isSelectDisabled ? '' : 'hover:bg-white/12 focus-within:bg-white/14'
|
isSelectDisabled ? '' : 'hover:bg-white/16 focus-within:bg-white/18'
|
||||||
}`}
|
}`}
|
||||||
onClick={(event: MouseEvent<HTMLSpanElement>) => event.stopPropagation()}
|
onClick={(event: MouseEvent<HTMLSpanElement>) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -81,11 +151,11 @@ export default function ImageGenerationInlineCountButton({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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" />
|
<AppIcon name="chevronDown" className="h-3 w-3" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className={labelClassName}>{suffix}</span>
|
<span className={`${labelClassName} inline-flex shrink-0 items-center whitespace-nowrap leading-none`.trim()}>{suffix}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
|
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
|
||||||
import ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'
|
import ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'
|
||||||
import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
|
import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
|
||||||
|
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
|
||||||
|
|
||||||
export interface LocationCreationModalProps {
|
export interface LocationCreationModalProps {
|
||||||
mode: 'asset-hub' | 'project'
|
mode: 'asset-hub' | 'project'
|
||||||
@@ -63,6 +64,7 @@ export function LocationCreationModal({
|
|||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [aiInstruction, setAiInstruction] = useState('')
|
const [aiInstruction, setAiInstruction] = useState('')
|
||||||
const [artStyle, setArtStyle] = useState('american-comic')
|
const [artStyle, setArtStyle] = useState('american-comic')
|
||||||
|
const [availableSlots, setAvailableSlots] = useState<LocationAvailableSlot[]>([])
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [isAiDesigning, setIsAiDesigning] = useState(false)
|
const [isAiDesigning, setIsAiDesigning] = useState(false)
|
||||||
@@ -119,6 +121,7 @@ export function LocationCreationModal({
|
|||||||
? await aiDesignAssetHubLocation.mutateAsync(aiInstruction)
|
? await aiDesignAssetHubLocation.mutateAsync(aiInstruction)
|
||||||
: await aiCreateProjectLocation.mutateAsync({ userInstruction: aiInstruction })
|
: await aiCreateProjectLocation.mutateAsync({ userInstruction: aiInstruction })
|
||||||
setDescription(data.prompt || '')
|
setDescription(data.prompt || '')
|
||||||
|
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
|
||||||
setAiInstruction('')
|
setAiInstruction('')
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (getErrorStatus(error) === 402) {
|
if (getErrorStatus(error) === 402) {
|
||||||
@@ -168,12 +171,14 @@ export function LocationCreationModal({
|
|||||||
summary: body.description,
|
summary: body.description,
|
||||||
artStyle: body.artStyle,
|
artStyle: body.artStyle,
|
||||||
folderId: body.folderId ?? null,
|
folderId: body.folderId ?? null,
|
||||||
|
availableSlots,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await createProjectLocation.mutateAsync({
|
await createProjectLocation.mutateAsync({
|
||||||
name: body.name,
|
name: body.name,
|
||||||
description: body.description,
|
description: body.description,
|
||||||
artStyle: body.artStyle,
|
artStyle: body.artStyle,
|
||||||
|
availableSlots,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +208,7 @@ export function LocationCreationModal({
|
|||||||
artStyle,
|
artStyle,
|
||||||
folderId: folderId ?? null,
|
folderId: folderId ?? null,
|
||||||
count: locationGenerationCount,
|
count: locationGenerationCount,
|
||||||
|
availableSlots,
|
||||||
}) as CreatedLocationResponse
|
}) as CreatedLocationResponse
|
||||||
const createdLocationId = result.location?.id
|
const createdLocationId = result.location?.id
|
||||||
if (!createdLocationId) {
|
if (!createdLocationId) {
|
||||||
@@ -219,6 +225,7 @@ export function LocationCreationModal({
|
|||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
artStyle,
|
artStyle,
|
||||||
count: locationGenerationCount,
|
count: locationGenerationCount,
|
||||||
|
availableSlots,
|
||||||
}) as CreatedLocationResponse
|
}) as CreatedLocationResponse
|
||||||
const createdLocationId = result.location?.id
|
const createdLocationId = result.location?.id
|
||||||
if (!createdLocationId) {
|
if (!createdLocationId) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
useUpdateProjectLocationDescription,
|
useUpdateProjectLocationDescription,
|
||||||
useUpdateProjectLocationName,
|
useUpdateProjectLocationName,
|
||||||
} from '@/lib/query/hooks'
|
} from '@/lib/query/hooks'
|
||||||
|
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
|
||||||
|
|
||||||
export interface LocationEditModalProps {
|
export interface LocationEditModalProps {
|
||||||
mode: 'asset-hub' | 'project'
|
mode: 'asset-hub' | 'project'
|
||||||
@@ -56,6 +57,7 @@ export function LocationEditModal({
|
|||||||
|
|
||||||
const [editingName, setEditingName] = useState(locationName)
|
const [editingName, setEditingName] = useState(locationName)
|
||||||
const [editingDescription, setEditingDescription] = useState(description || summary || '')
|
const [editingDescription, setEditingDescription] = useState(description || summary || '')
|
||||||
|
const [availableSlots, setAvailableSlots] = useState<LocationAvailableSlot[]>([])
|
||||||
const [aiModifyInstruction, setAiModifyInstruction] = useState('')
|
const [aiModifyInstruction, setAiModifyInstruction] = useState('')
|
||||||
const [isAiModifying, setIsAiModifying] = useState(false)
|
const [isAiModifying, setIsAiModifying] = useState(false)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
@@ -113,6 +115,7 @@ export function LocationEditModal({
|
|||||||
await updateAssetHubSummary.mutateAsync({
|
await updateAssetHubSummary.mutateAsync({
|
||||||
locationId,
|
locationId,
|
||||||
summary: editingDescription,
|
summary: editingDescription,
|
||||||
|
availableSlots,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -121,6 +124,7 @@ export function LocationEditModal({
|
|||||||
locationId,
|
locationId,
|
||||||
imageIndex: resolvedImageIndex,
|
imageIndex: resolvedImageIndex,
|
||||||
description: editingDescription,
|
description: editingDescription,
|
||||||
|
availableSlots,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +143,7 @@ export function LocationEditModal({
|
|||||||
})
|
})
|
||||||
if (data?.modifiedDescription) {
|
if (data?.modifiedDescription) {
|
||||||
setEditingDescription(data.modifiedDescription)
|
setEditingDescription(data.modifiedDescription)
|
||||||
|
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
|
||||||
onUpdate?.(data.modifiedDescription)
|
onUpdate?.(data.modifiedDescription)
|
||||||
setAiModifyInstruction('')
|
setAiModifyInstruction('')
|
||||||
}
|
}
|
||||||
@@ -154,6 +159,7 @@ export function LocationEditModal({
|
|||||||
const nextDescription = data?.modifiedDescription || data?.prompt || ''
|
const nextDescription = data?.modifiedDescription || data?.prompt || ''
|
||||||
if (nextDescription) {
|
if (nextDescription) {
|
||||||
setEditingDescription(nextDescription)
|
setEditingDescription(nextDescription)
|
||||||
|
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
|
||||||
onUpdate?.(nextDescription)
|
onUpdate?.(nextDescription)
|
||||||
setAiModifyInstruction('')
|
setAiModifyInstruction('')
|
||||||
}
|
}
|
||||||
|
|||||||
124
src/components/story-input/LongTextDetectionPrompt.tsx
Normal file
124
src/components/story-input/LongTextDetectionPrompt.tsx
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -109,7 +109,7 @@ export default function StoryInputComposer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full glass-surface-elevated rounded-2xl">
|
<div className="relative w-full glass-surface-elevated rounded-2xl">
|
||||||
<div className="p-6 pb-0">
|
<div className="p-6 pb-4">
|
||||||
{topRight && (
|
{topRight && (
|
||||||
<div className="mb-3 flex items-center justify-end">
|
<div className="mb-3 flex items-center justify-end">
|
||||||
{topRight}
|
{topRight}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export function CapsuleNav({ items, activeId, onItemClick, projectId, episodeId
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className="flex rounded-full px-2 py-1"
|
className="flex rounded-full px-2 py-1"
|
||||||
style={{
|
style={{
|
||||||
@@ -215,7 +215,7 @@ export function EpisodeSelector({
|
|||||||
if (!currentEp) return null
|
if (!currentEp) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-20 left-6 z-[60]" ref={menuRef}>
|
<div className="fixed top-20 left-6 z-40" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="glass-btn-base glass-btn-secondary flex items-center gap-3 px-4 py-3 transition-all group"
|
className="glass-btn-base glass-btn-secondary flex items-center gap-3 px-4 py-3 transition-all group"
|
||||||
|
|||||||
22
src/lib/api/read-error-message.ts
Normal file
22
src/lib/api/read-error-message.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ import { logError as _ulogError } from '@/lib/logging/core'
|
|||||||
|
|
||||||
import { executeAiTextStep } from '@/lib/ai-runtime'
|
import { executeAiTextStep } from '@/lib/ai-runtime'
|
||||||
import { withTextBilling } from '@/lib/billing'
|
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 { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
|
||||||
import type { Locale } from '@/i18n/routing'
|
import type { Locale } from '@/i18n/routing'
|
||||||
|
|
||||||
@@ -26,6 +31,7 @@ export interface AIDesignOptions {
|
|||||||
export interface AIDesignResult {
|
export interface AIDesignResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
prompt?: string
|
prompt?: string
|
||||||
|
availableSlots?: LocationAvailableSlot[]
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,23 +117,13 @@ export async function aiDesign(options: AIDesignOptions): Promise<AIDesignResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 解析 JSON 响应
|
// 解析 JSON 响应
|
||||||
let parsedResponse
|
let parsedResponse: Record<string, unknown>
|
||||||
try {
|
try {
|
||||||
parsedResponse = JSON.parse(aiResponse)
|
parsedResponse = safeParseJsonObject(aiResponse)
|
||||||
} catch {
|
|
||||||
const jsonMatch = aiResponse.match(/\{[\s\S]*\}/)
|
|
||||||
if (jsonMatch) {
|
|
||||||
try {
|
|
||||||
parsedResponse = JSON.parse(jsonMatch[0])
|
|
||||||
} catch {
|
} catch {
|
||||||
_ulogError('[AI Design] AI 响应解析失败:', aiResponse)
|
_ulogError('[AI Design] AI 响应解析失败:', aiResponse)
|
||||||
return { success: false, error: 'AI返回格式错误' }
|
return { success: false, error: 'AI返回格式错误' }
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
_ulogError('[AI Design] AI 响应解析失败:', aiResponse)
|
|
||||||
return { success: false, error: 'AI返回格式错误' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsedResponse.prompt) {
|
if (!parsedResponse.prompt) {
|
||||||
return { success: false, error: 'AI返回缺少prompt字段' }
|
return { success: false, error: 'AI返回缺少prompt字段' }
|
||||||
@@ -135,6 +131,9 @@ export async function aiDesign(options: AIDesignOptions): Promise<AIDesignResult
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
prompt: parsedResponse.prompt
|
prompt: typeof parsedResponse.prompt === 'string' ? parsedResponse.prompt : '',
|
||||||
|
availableSlots: assetType === 'location'
|
||||||
|
? normalizeLocationAvailableSlots(parsedResponse.available_slots)
|
||||||
|
: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import { deleteObject } from '@/lib/storage'
|
|||||||
import { resolveStorageKeyFromMediaValue } from '@/lib/media/service'
|
import { resolveStorageKeyFromMediaValue } from '@/lib/media/service'
|
||||||
import { updateCharacterAppearanceLabels, updateLocationImageLabels } from '@/lib/image-label'
|
import { updateCharacterAppearanceLabels, updateLocationImageLabels } from '@/lib/image-label'
|
||||||
import type { AssetKind, AssetScope } from '@/lib/assets/contracts'
|
import type { AssetKind, AssetScope } from '@/lib/assets/contracts'
|
||||||
|
import {
|
||||||
|
normalizeLocationAvailableSlots,
|
||||||
|
stringifyLocationAvailableSlots,
|
||||||
|
} from '@/lib/location-available-slots'
|
||||||
import {
|
import {
|
||||||
createGlobalLocationBackedAsset,
|
createGlobalLocationBackedAsset,
|
||||||
createProjectLocationBackedAsset,
|
createProjectLocationBackedAsset,
|
||||||
@@ -849,6 +853,7 @@ async function copyLocationFromGlobal(input: AssetCopyInput) {
|
|||||||
locationId: input.targetId,
|
locationId: input.targetId,
|
||||||
imageIndex: image.imageIndex,
|
imageIndex: image.imageIndex,
|
||||||
description: image.description,
|
description: image.description,
|
||||||
|
availableSlots: image.availableSlots,
|
||||||
imageUrl: labelUpdate?.imageUrl || image.imageUrl,
|
imageUrl: labelUpdate?.imageUrl || image.imageUrl,
|
||||||
isSelected: image.isSelected,
|
isSelected: image.isSelected,
|
||||||
},
|
},
|
||||||
@@ -921,6 +926,16 @@ async function updateGlobalAsset(input: AssetUpdateInput) {
|
|||||||
where: { id: input.assetId },
|
where: { id: input.assetId },
|
||||||
data: updateData,
|
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 }
|
return { success: true, location }
|
||||||
}
|
}
|
||||||
if (input.kind === 'prop') {
|
if (input.kind === 'prop') {
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { buildCharactersIntroduction } from '@/lib/constants'
|
import { buildCharactersIntroduction } from '@/lib/constants'
|
||||||
|
import {
|
||||||
|
formatLocationAvailableSlotsText,
|
||||||
|
parseLocationAvailableSlots,
|
||||||
|
} from '@/lib/location-available-slots'
|
||||||
|
|
||||||
|
type PromptLocale = 'zh' | 'en'
|
||||||
|
|
||||||
export type ClipCharacterRef = string | { name?: string | null }
|
export type ClipCharacterRef = string | { name?: string | null }
|
||||||
|
|
||||||
@@ -20,6 +26,7 @@ export type PromptLocationAsset = {
|
|||||||
images?: Array<{
|
images?: Array<{
|
||||||
isSelected?: boolean
|
isSelected?: boolean
|
||||||
description?: string | null
|
description?: string | null
|
||||||
|
availableSlots?: string | null
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +42,7 @@ export type PromptAssetContextInput = {
|
|||||||
clipCharacters: ClipCharacterRef[]
|
clipCharacters: ClipCharacterRef[]
|
||||||
clipLocation: string | null
|
clipLocation: string | null
|
||||||
clipProps: string[]
|
clipProps: string[]
|
||||||
|
locale?: PromptLocale
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptAssetContext = {
|
export type PromptAssetContext = {
|
||||||
@@ -134,7 +142,14 @@ export function buildPromptAssetContext(input: PromptAssetContextInput): PromptA
|
|||||||
? input.locations.find((location) => normalizeName(location.name) === normalizeName(environmentName))
|
? input.locations.find((location) => normalizeName(location.name) === normalizeName(environmentName))
|
||||||
: null
|
: null
|
||||||
const selectedImage = matchedLocation?.images?.find((image) => image.isSelected) ?? matchedLocation?.images?.[0]
|
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 {
|
return {
|
||||||
subjectNames,
|
subjectNames,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import {
|
||||||
|
type LocationAvailableSlot,
|
||||||
|
stringifyLocationAvailableSlots,
|
||||||
|
} from '@/lib/location-available-slots'
|
||||||
|
|
||||||
export type LocationBackedAssetKind = 'location' | 'prop'
|
export type LocationBackedAssetKind = 'location' | 'prop'
|
||||||
|
|
||||||
@@ -28,6 +32,7 @@ type LocationBackedImageRow = {
|
|||||||
id: string
|
id: string
|
||||||
imageIndex: number
|
imageIndex: number
|
||||||
description: string | null
|
description: string | null
|
||||||
|
availableSlots: string | null
|
||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
imageMediaId: string | null
|
imageMediaId: string | null
|
||||||
previousImageUrl: string | null
|
previousImageUrl: string | null
|
||||||
@@ -88,6 +93,7 @@ async function readProjectLocationBackedImages(locationIds: string[]): Promise<M
|
|||||||
id,
|
id,
|
||||||
imageIndex,
|
imageIndex,
|
||||||
description,
|
description,
|
||||||
|
availableSlots,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imageMediaId,
|
imageMediaId,
|
||||||
previousImageUrl,
|
previousImageUrl,
|
||||||
@@ -111,6 +117,7 @@ async function readGlobalLocationBackedImages(locationIds: string[]): Promise<Ma
|
|||||||
id,
|
id,
|
||||||
imageIndex,
|
imageIndex,
|
||||||
description,
|
description,
|
||||||
|
availableSlots,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imageMediaId,
|
imageMediaId,
|
||||||
previousImageUrl,
|
previousImageUrl,
|
||||||
@@ -214,6 +221,7 @@ export async function createProjectLocationBackedAsset(input: {
|
|||||||
locationId: id,
|
locationId: id,
|
||||||
fallbackDescription: input.summary,
|
fallbackDescription: input.summary,
|
||||||
descriptions: [input.summary],
|
descriptions: [input.summary],
|
||||||
|
availableSlots: [],
|
||||||
})
|
})
|
||||||
return { id }
|
return { id }
|
||||||
}
|
}
|
||||||
@@ -254,6 +262,7 @@ export async function createGlobalLocationBackedAsset(input: {
|
|||||||
locationId: id,
|
locationId: id,
|
||||||
fallbackDescription: input.summary,
|
fallbackDescription: input.summary,
|
||||||
descriptions: [input.summary],
|
descriptions: [input.summary],
|
||||||
|
availableSlots: [],
|
||||||
})
|
})
|
||||||
return { id }
|
return { id }
|
||||||
}
|
}
|
||||||
@@ -262,17 +271,31 @@ export async function seedProjectLocationBackedImageSlots(input: {
|
|||||||
locationId: string
|
locationId: string
|
||||||
fallbackDescription: string
|
fallbackDescription: string
|
||||||
descriptions?: string[]
|
descriptions?: string[]
|
||||||
|
availableSlots?: LocationAvailableSlot[]
|
||||||
|
locationImageModel?: {
|
||||||
|
createMany: (args: {
|
||||||
|
data: Array<{
|
||||||
|
locationId: string
|
||||||
|
imageIndex: number
|
||||||
|
description: string
|
||||||
|
availableSlots: string
|
||||||
|
}>
|
||||||
|
}) => Promise<unknown>
|
||||||
|
}
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const descriptions = normalizeSeedDescriptions(input)
|
const descriptions = normalizeSeedDescriptions(input)
|
||||||
if (descriptions.length === 0) {
|
if (descriptions.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const availableSlots = stringifyLocationAvailableSlots(input.availableSlots ?? [])
|
||||||
|
|
||||||
await prisma.locationImage.createMany({
|
const locationImageModel = input.locationImageModel ?? prisma.locationImage
|
||||||
|
await locationImageModel.createMany({
|
||||||
data: descriptions.map((description, imageIndex) => ({
|
data: descriptions.map((description, imageIndex) => ({
|
||||||
locationId: input.locationId,
|
locationId: input.locationId,
|
||||||
imageIndex,
|
imageIndex,
|
||||||
description,
|
description,
|
||||||
|
availableSlots,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -281,17 +304,20 @@ export async function seedGlobalLocationBackedImageSlots(input: {
|
|||||||
locationId: string
|
locationId: string
|
||||||
fallbackDescription: string
|
fallbackDescription: string
|
||||||
descriptions?: string[]
|
descriptions?: string[]
|
||||||
|
availableSlots?: LocationAvailableSlot[]
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const descriptions = normalizeSeedDescriptions(input)
|
const descriptions = normalizeSeedDescriptions(input)
|
||||||
if (descriptions.length === 0) {
|
if (descriptions.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const availableSlots = stringifyLocationAvailableSlots(input.availableSlots ?? [])
|
||||||
|
|
||||||
await prisma.globalLocationImage.createMany({
|
await prisma.globalLocationImage.createMany({
|
||||||
data: descriptions.map((description, imageIndex) => ({
|
data: descriptions.map((description, imageIndex) => ({
|
||||||
locationId: input.locationId,
|
locationId: input.locationId,
|
||||||
imageIndex,
|
imageIndex,
|
||||||
description,
|
description,
|
||||||
|
availableSlots,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
interface ApiErrorPayload {
|
import { readApiErrorMessage } from '@/lib/api/read-error-message'
|
||||||
error?: string | { message?: string } | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectCreationPayload {
|
interface ProjectCreationPayload {
|
||||||
project?: {
|
project?: {
|
||||||
@@ -56,21 +54,6 @@ function readNestedString(
|
|||||||
return typeof value === 'string' && value.trim() ? value : null
|
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> {
|
async function readProjectId(response: Response): Promise<string> {
|
||||||
const payload = await response.json() as ProjectCreationPayload
|
const payload = await response.json() as ProjectCreationPayload
|
||||||
const projectId = readNestedString(readObject(payload), 'project', 'id')
|
const projectId = readNestedString(readObject(payload), 'project', 'id')
|
||||||
@@ -112,7 +95,6 @@ export async function createHomeProjectLaunch({
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: projectName,
|
name: projectName,
|
||||||
description: storyText,
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
45
src/lib/location-available-slots.ts
Normal file
45
src/lib/location-available-slots.ts
Normal 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')}`
|
||||||
|
}
|
||||||
32
src/lib/location-image-prompt.ts
Normal file
32
src/lib/location-image-prompt.ts
Normal 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()
|
||||||
|
}
|
||||||
46
src/lib/novel-promotion/insert-panel-prompt-context.ts
Normal file
46
src/lib/novel-promotion/insert-panel-prompt-context.ts
Normal 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')
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface PanelCharacterRef {
|
export interface PanelCharacterRef {
|
||||||
name: string
|
name: string
|
||||||
appearance: string
|
appearance: string
|
||||||
|
slot?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type JsonRecord = Record<string, unknown>
|
type JsonRecord = Record<string, unknown>
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export type ClipStoryboardPanels = {
|
|||||||
|
|
||||||
export type ScriptToStoryboardOrchestratorInput = {
|
export type ScriptToStoryboardOrchestratorInput = {
|
||||||
concurrency?: number
|
concurrency?: number
|
||||||
|
locale?: 'zh' | 'en'
|
||||||
clips: ClipInput[]
|
clips: ClipInput[]
|
||||||
novelPromotionData: {
|
novelPromotionData: {
|
||||||
characters: CharacterAsset[]
|
characters: CharacterAsset[]
|
||||||
@@ -303,20 +304,26 @@ export async function runScriptToStoryboardOrchestrator(
|
|||||||
const phase2ActingByClipId = new Map<string, ActingDirection[]>()
|
const phase2ActingByClipId = new Map<string, ActingDirection[]>()
|
||||||
const phase3PanelsByClipId = new Map<string, StoryboardPanel[]>()
|
const phase3PanelsByClipId = new Map<string, StoryboardPanel[]>()
|
||||||
|
|
||||||
const phase1Results = await mapWithConcurrency(
|
const clipPanels = await mapWithConcurrency(
|
||||||
clips,
|
clips,
|
||||||
concurrency,
|
concurrency,
|
||||||
async (clip, i) => {
|
async (clip, index): Promise<ClipStoryboardPanels> => {
|
||||||
const clipIndex = i + 1
|
const clipIndex = index + 1
|
||||||
const clipContent = typeof clip.content === 'string' ? clip.content.trim() : ''
|
const clipContent = typeof clip.content === 'string' ? clip.content.trim() : ''
|
||||||
if (!clipContent) {
|
if (!clipContent) {
|
||||||
throw new Error(`Clip ${formatClipId(clip)} content is empty`)
|
throw new Error(`Clip ${formatClipId(clip)} content is empty`)
|
||||||
}
|
}
|
||||||
const clipCharacters = parseClipCharacters(clip.characters)
|
const clipCharacters = parseClipCharacters(clip.characters)
|
||||||
|
const clipLocation = clip.location || null
|
||||||
const clipProps = parseClipProps(clip.props ?? null)
|
const clipProps = parseClipProps(clip.props ?? null)
|
||||||
const filteredAppearanceList = getFilteredAppearanceList(novelPromotionData.characters || [], clipCharacters)
|
const filteredAppearanceList = getFilteredAppearanceList(novelPromotionData.characters || [], clipCharacters)
|
||||||
const filteredFullDescription = getFilteredFullDescription(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: [],
|
characters: [],
|
||||||
locations: [],
|
locations: [],
|
||||||
props: novelPromotionData.props || [],
|
props: novelPromotionData.props || [],
|
||||||
@@ -342,7 +349,7 @@ export async function runScriptToStoryboardOrchestrator(
|
|||||||
.replace('{characters_introduction}', charactersIntroduction)
|
.replace('{characters_introduction}', charactersIntroduction)
|
||||||
.replace('{characters_appearance_list}', filteredAppearanceList)
|
.replace('{characters_appearance_list}', filteredAppearanceList)
|
||||||
.replace('{characters_full_description}', filteredFullDescription)
|
.replace('{characters_full_description}', filteredFullDescription)
|
||||||
.replace('{props_description}', filteredPropDescription)
|
.replace('{props_description}', filteredPropsDescription)
|
||||||
.replace('{clip_json}', clipJson)
|
.replace('{clip_json}', clipJson)
|
||||||
|
|
||||||
const screenplay = parseScreenplay(clip.screenplay)
|
const screenplay = parseScreenplay(clip.screenplay)
|
||||||
@@ -373,44 +380,7 @@ export async function runScriptToStoryboardOrchestrator(
|
|||||||
return panels
|
return panels
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
phase1PanelsByClipId.set(clip.id, planPanels)
|
||||||
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
|
|
||||||
|
|
||||||
const phase2Meta = withStepMeta(
|
const phase2Meta = withStepMeta(
|
||||||
`clip_${clip.id}_phase2_cinematography`,
|
`clip_${clip.id}_phase2_cinematography`,
|
||||||
|
|||||||
74
src/lib/novel-promotion/stage-readiness.ts
Normal file
74
src/lib/novel-promotion/stage-readiness.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/lib/projects/default-name.ts
Normal file
11
src/lib/projects/default-name.ts
Normal 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}`
|
||||||
|
}
|
||||||
82
src/lib/projects/validation.ts
Normal file
82
src/lib/projects/validation.ts
Normal 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} 个字符。`
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/lib/query/hooks/run-stream/recovery-probe.ts
Normal file
90
src/lib/query/hooks/run-stream/recovery-probe.ts
Normal 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,
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
|
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
|
||||||
import { applyRunStreamEvent } from './state-machine'
|
import { applyRunStreamEvent } from './state-machine'
|
||||||
import { clearRunSnapshot, loadRunSnapshot, saveRunSnapshot } from './snapshot'
|
|
||||||
import { subscribeRecoveredRun } from './recovered-run-subscription'
|
import { subscribeRecoveredRun } from './recovered-run-subscription'
|
||||||
import { executeRunRequest } from './run-request-executor'
|
import { executeRunRequest } from './run-request-executor'
|
||||||
import { deriveRunStreamView } from './run-stream-view'
|
import { deriveRunStreamView } from './run-stream-view'
|
||||||
import type { RunResult, RunState, RunStreamView, UseRunStreamStateOptions } from './types'
|
import type { RunResult, RunState, RunStreamView, UseRunStreamStateOptions } from './types'
|
||||||
import { apiFetch } from '@/lib/api-fetch'
|
import { apiFetch } from '@/lib/api-fetch'
|
||||||
|
import { startRecoveryProbe } from './recovery-probe'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
RunResult,
|
RunResult,
|
||||||
@@ -18,8 +18,6 @@ export type {
|
|||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const TASK_STREAM_TIMEOUT_MS = 1000 * 60 * 30
|
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>>(
|
export function useRunStreamState<TParams extends Record<string, unknown>>(
|
||||||
options: UseRunStreamStateOptions<TParams>,
|
options: UseRunStreamStateOptions<TParams>,
|
||||||
@@ -40,7 +38,6 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
|
|||||||
const [isRecoveredRunning, setIsRecoveredRunning] = useState(false)
|
const [isRecoveredRunning, setIsRecoveredRunning] = useState(false)
|
||||||
const abortRef = useRef<AbortController | null>(null)
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
const finalResultRef = useRef<RunResult | null>(null)
|
const finalResultRef = useRef<RunResult | null>(null)
|
||||||
const hydratedStorageKeyRef = useRef<string | null>(null)
|
|
||||||
const resolveActiveRunIdRef = useRef(resolveActiveRunId)
|
const resolveActiveRunIdRef = useRef(resolveActiveRunId)
|
||||||
const storageKey = useMemo(() => {
|
const storageKey = useMemo(() => {
|
||||||
if (storageScopeKey) {
|
if (storageScopeKey) {
|
||||||
@@ -61,36 +58,19 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
|
|||||||
resolveActiveRunIdRef.current = resolveActiveRunId
|
resolveActiveRunIdRef.current = resolveActiveRunId
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!projectId || !resolveActiveRunIdRef.current) return
|
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
|
if (runStateRef.current) return
|
||||||
const existingSnapshot = loadRunSnapshot(storageKey)
|
|
||||||
if (existingSnapshot) return
|
|
||||||
|
|
||||||
let cancelled = false
|
return startRecoveryProbe({
|
||||||
void (async () => {
|
|
||||||
const activeRunId = await resolveActiveRunIdRef.current?.({
|
|
||||||
projectId,
|
projectId,
|
||||||
|
storageKey,
|
||||||
storageScopeKey,
|
storageScopeKey,
|
||||||
}).catch(() => null)
|
hasRunState: () => runStateRef.current !== null,
|
||||||
if (cancelled || !activeRunId) return
|
resolveActiveRunId: (context) =>
|
||||||
|
resolveActiveRunIdRef.current?.(context) ?? Promise.resolve(null),
|
||||||
|
onRecovered: (activeRunId) => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
setRunState((prev) => {
|
setRunState((prev) => {
|
||||||
if (prev) return prev
|
if (prev) return prev
|
||||||
@@ -110,11 +90,8 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
setIsRecoveredRunning(true)
|
setIsRecoveredRunning(true)
|
||||||
})()
|
},
|
||||||
|
})
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [projectId, storageKey, storageScopeKey])
|
}, [projectId, storageKey, storageScopeKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -150,11 +127,6 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
|
|||||||
}
|
}
|
||||||
}, [isRecoveredRunning, runState, runState?.status])
|
}, [isRecoveredRunning, runState, runState?.status])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!projectId) return
|
|
||||||
saveRunSnapshot(storageKey, runState)
|
|
||||||
}, [projectId, runState, storageKey])
|
|
||||||
|
|
||||||
const run = useCallback(
|
const run = useCallback(
|
||||||
async (params: TParams): Promise<RunResult> => {
|
async (params: TParams): Promise<RunResult> => {
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
@@ -270,8 +242,7 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
|
|||||||
setRunState(null)
|
setRunState(null)
|
||||||
finalResultRef.current = null
|
finalResultRef.current = null
|
||||||
setIsRecoveredRunning(false)
|
setIsRecoveredRunning(false)
|
||||||
clearRunSnapshot(storageKey)
|
}, [stop])
|
||||||
}, [storageKey, stop])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => setClock(Date.now()), 500)
|
const timer = window.setInterval(() => setClock(Date.now()), 500)
|
||||||
|
|||||||
@@ -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 { }
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useRunStreamState, type RunResult } from './useRunStreamState'
|
import { useRunStreamState, type RunResult } from './useRunStreamState'
|
||||||
import { TASK_TYPE } from '@/lib/task/types'
|
import { TASK_TYPE } from '@/lib/task/types'
|
||||||
import { apiFetch } from '@/lib/api-fetch'
|
import { apiFetch } from '@/lib/api-fetch'
|
||||||
|
import { selectRecoverableRun } from '@/lib/run-runtime/recovery'
|
||||||
|
|
||||||
export type ScriptToStoryboardRunParams = {
|
export type ScriptToStoryboardRunParams = {
|
||||||
episodeId: string
|
episodeId: string
|
||||||
@@ -46,13 +47,26 @@ export function useScriptToStoryboardRunStream({ projectId, episodeId }: UseScri
|
|||||||
if (!response.ok) return null
|
if (!response.ok) return null
|
||||||
const data = await response.json().catch(() => null)
|
const data = await response.json().catch(() => null)
|
||||||
const runs = data && typeof data === 'object' && Array.isArray((data as { runs?: unknown[] }).runs)
|
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) {
|
const decision = selectRecoverableRun(runs.map((run) => ({
|
||||||
if (!run || typeof run.id !== 'string' || !run.id) continue
|
id: typeof run?.id === 'string' ? run.id : null,
|
||||||
return run.id
|
status: typeof run?.status === 'string' ? run.status : null,
|
||||||
}
|
createdAt: typeof run?.createdAt === 'string' ? run.createdAt : null,
|
||||||
return 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) => {
|
validateParams: (params) => {
|
||||||
if (!params.episodeId) {
|
if (!params.episodeId) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useRunStreamState, type RunResult } from './useRunStreamState'
|
import { useRunStreamState, type RunResult } from './useRunStreamState'
|
||||||
import { TASK_TYPE } from '@/lib/task/types'
|
import { TASK_TYPE } from '@/lib/task/types'
|
||||||
import { apiFetch } from '@/lib/api-fetch'
|
import { apiFetch } from '@/lib/api-fetch'
|
||||||
|
import { selectRecoverableRun } from '@/lib/run-runtime/recovery'
|
||||||
|
|
||||||
export type StoryToScriptRunParams = {
|
export type StoryToScriptRunParams = {
|
||||||
episodeId: string
|
episodeId: string
|
||||||
@@ -47,13 +48,26 @@ export function useStoryToScriptRunStream({ projectId, episodeId }: UseStoryToSc
|
|||||||
if (!response.ok) return null
|
if (!response.ok) return null
|
||||||
const data = await response.json().catch(() => null)
|
const data = await response.json().catch(() => null)
|
||||||
const runs = data && typeof data === 'object' && Array.isArray((data as { runs?: unknown[] }).runs)
|
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) {
|
const decision = selectRecoverableRun(runs.map((run) => ({
|
||||||
if (!run || typeof run.id !== 'string' || !run.id) continue
|
id: typeof run?.id === 'string' ? run.id : null,
|
||||||
return run.id
|
status: typeof run?.status === 'string' ? run.status : null,
|
||||||
}
|
createdAt: typeof run?.createdAt === 'string' ? run.createdAt : null,
|
||||||
return 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) => {
|
validateParams: (params) => {
|
||||||
if (!params.episodeId) {
|
if (!params.episodeId) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
invalidateGlobalCharacters,
|
invalidateGlobalCharacters,
|
||||||
invalidateGlobalLocations,
|
invalidateGlobalLocations,
|
||||||
} from './asset-hub-mutations-shared'
|
} from './asset-hub-mutations-shared'
|
||||||
|
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
|
||||||
|
|
||||||
export function useAiDesignLocation() {
|
export function useAiDesignLocation() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -21,7 +22,7 @@ export function useAiDesignLocation() {
|
|||||||
},
|
},
|
||||||
'Failed to design location',
|
'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
|
folderId: string | null
|
||||||
artStyle: string
|
artStyle: string
|
||||||
count?: number
|
count?: number
|
||||||
|
availableSlots?: LocationAvailableSlot[]
|
||||||
}) => {
|
}) => {
|
||||||
return await requestJsonWithError('/api/asset-hub/locations', {
|
return await requestJsonWithError('/api/asset-hub/locations', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
invalidateGlobalCharacters,
|
invalidateGlobalCharacters,
|
||||||
invalidateGlobalLocations,
|
invalidateGlobalLocations,
|
||||||
} from './asset-hub-mutations-shared'
|
} from './asset-hub-mutations-shared'
|
||||||
|
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
|
||||||
|
|
||||||
export function useUpdateCharacterName() {
|
export function useUpdateCharacterName() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -134,9 +135,11 @@ export function useUpdateLocationSummary() {
|
|||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
locationId,
|
locationId,
|
||||||
summary,
|
summary,
|
||||||
|
availableSlots,
|
||||||
}: {
|
}: {
|
||||||
locationId: string
|
locationId: string
|
||||||
summary: string
|
summary: string
|
||||||
|
availableSlots?: LocationAvailableSlot[]
|
||||||
}) => {
|
}) => {
|
||||||
return await requestJsonWithError(`/api/assets/${locationId}`, {
|
return await requestJsonWithError(`/api/assets/${locationId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -145,6 +148,7 @@ export function useUpdateLocationSummary() {
|
|||||||
scope: 'global',
|
scope: 'global',
|
||||||
kind: 'location',
|
kind: 'location',
|
||||||
summary,
|
summary,
|
||||||
|
...(availableSlots ? { availableSlots } : {}),
|
||||||
}),
|
}),
|
||||||
}, 'Failed to update location summary')
|
}, 'Failed to update location summary')
|
||||||
},
|
},
|
||||||
@@ -179,7 +183,7 @@ export function useAiModifyCharacterDescription() {
|
|||||||
},
|
},
|
||||||
'Failed to modify character description',
|
'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',
|
'Failed to modify location description',
|
||||||
)
|
)
|
||||||
return resolveTaskResponse<{ modifiedDescription?: string }>(response)
|
return resolveTaskResponse<{ modifiedDescription?: string; availableSlots?: LocationAvailableSlot[] }>(response)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { logError as _ulogError } from '@/lib/logging/core'
|
|||||||
import type { Project } from '@/types/project'
|
import type { Project } from '@/types/project'
|
||||||
import { queryKeys } from '../keys'
|
import { queryKeys } from '../keys'
|
||||||
import type { ProjectAssetsData } from '../hooks/useProjectAssets'
|
import type { ProjectAssetsData } from '../hooks/useProjectAssets'
|
||||||
|
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
|
||||||
import { resolveTaskResponse } from '@/lib/task/client'
|
import { resolveTaskResponse } from '@/lib/task/client'
|
||||||
import { apiFetch } from '@/lib/api-fetch'
|
import { apiFetch } from '@/lib/api-fetch'
|
||||||
import {
|
import {
|
||||||
@@ -149,10 +150,12 @@ export function useUpdateProjectLocationDescription(projectId: string) {
|
|||||||
locationId,
|
locationId,
|
||||||
description,
|
description,
|
||||||
imageIndex,
|
imageIndex,
|
||||||
|
availableSlots,
|
||||||
}: {
|
}: {
|
||||||
locationId: string
|
locationId: string
|
||||||
description: string
|
description: string
|
||||||
imageIndex?: number
|
imageIndex?: number
|
||||||
|
availableSlots?: LocationAvailableSlot[]
|
||||||
}) => {
|
}) => {
|
||||||
return await requestJsonWithError(`/api/novel-promotion/${projectId}/location`, {
|
return await requestJsonWithError(`/api/novel-promotion/${projectId}/location`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -161,6 +164,7 @@ export function useUpdateProjectLocationDescription(projectId: string) {
|
|||||||
locationId,
|
locationId,
|
||||||
imageIndex: typeof imageIndex === 'number' ? imageIndex : 0,
|
imageIndex: typeof imageIndex === 'number' ? imageIndex : 0,
|
||||||
description,
|
description,
|
||||||
|
...(availableSlots ? { availableSlots } : {}),
|
||||||
}),
|
}),
|
||||||
}, 'Failed to update location description')
|
}, 'Failed to update location description')
|
||||||
},
|
},
|
||||||
@@ -199,7 +203,7 @@ export function useAiModifyProjectLocationDescription(projectId: string) {
|
|||||||
},
|
},
|
||||||
'Failed to modify location description',
|
'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',
|
'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
|
description: string
|
||||||
artStyle?: string
|
artStyle?: string
|
||||||
count?: number
|
count?: number
|
||||||
|
availableSlots?: LocationAvailableSlot[]
|
||||||
}) =>
|
}) =>
|
||||||
await requestJsonWithError(
|
await requestJsonWithError(
|
||||||
`/api/novel-promotion/${projectId}/location`,
|
`/api/novel-promotion/${projectId}/location`,
|
||||||
|
|||||||
305
src/lib/run-runtime/reconcile.ts
Normal file
305
src/lib/run-runtime/reconcile.ts
Normal 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
|
||||||
|
}
|
||||||
85
src/lib/run-runtime/recovery.ts
Normal file
85
src/lib/run-runtime/recovery.ts
Normal 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',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { selectRecoverableRun } from '@/lib/run-runtime/recovery'
|
||||||
import { resolveRetryInvalidationStepKeys } from '@/lib/workflow-engine/dependencies'
|
import { resolveRetryInvalidationStepKeys } from '@/lib/workflow-engine/dependencies'
|
||||||
import {
|
import {
|
||||||
RUN_EVENT_TYPE,
|
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) {
|
function mapStepRow(step: GraphStepRow) {
|
||||||
return {
|
return {
|
||||||
id: step.id,
|
id: step.id,
|
||||||
@@ -718,9 +737,9 @@ export async function findReusableActiveRun(params: {
|
|||||||
{ updatedAt: 'desc' },
|
{ updatedAt: 'desc' },
|
||||||
{ createdAt: 'desc' },
|
{ createdAt: 'desc' },
|
||||||
],
|
],
|
||||||
take: 1,
|
take: 20,
|
||||||
})
|
})
|
||||||
const row = rows[0]
|
const row = filterRecoverableRunRows(rows)[0] || null
|
||||||
return row ? mapRunRow(row) : null
|
return row ? mapRunRow(row) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -839,10 +858,19 @@ export async function listRuns(input: ListRunsInput) {
|
|||||||
...(input.episodeId ? { episodeId: input.episodeId } : {}),
|
...(input.episodeId ? { episodeId: input.episodeId } : {}),
|
||||||
...(statusFilter ? { status: statusFilter } : {}),
|
...(statusFilter ? { status: statusFilter } : {}),
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: [
|
||||||
|
{ updatedAt: 'desc' },
|
||||||
|
{ createdAt: 'desc' },
|
||||||
|
],
|
||||||
take: safeLimit,
|
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: {
|
export async function requestRunCancel(params: {
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export type ListRunsInput = {
|
|||||||
episodeId?: string
|
episodeId?: string
|
||||||
statuses?: RunStatus[]
|
statuses?: RunStatus[]
|
||||||
limit?: number
|
limit?: number
|
||||||
|
recoverableOnly?: boolean
|
||||||
|
latestOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StateRef = {
|
export type StateRef = {
|
||||||
|
|||||||
@@ -173,7 +173,11 @@ export function getFilteredFullDescription(characters: CharacterAsset[], clipCha
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 根据 clip.location 筛选场景描述
|
// 根据 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({
|
return compileAssetPromptFragments(buildPromptAssetContext({
|
||||||
characters: [],
|
characters: [],
|
||||||
locations,
|
locations,
|
||||||
@@ -181,6 +185,7 @@ export function getFilteredLocationsDescription(locations: LocationAsset[], clip
|
|||||||
clipCharacters: [],
|
clipCharacters: [],
|
||||||
clipLocation,
|
clipLocation,
|
||||||
clipProps: [],
|
clipProps: [],
|
||||||
|
locale,
|
||||||
})).locationDescriptionText
|
})).locationDescriptionText
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +410,11 @@ export async function executePhase2(
|
|||||||
const clipProps = parseClipProps(clip.props)
|
const clipProps = parseClipProps(clip.props)
|
||||||
|
|
||||||
const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters, clipCharacters)
|
const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters, clipCharacters)
|
||||||
const filteredLocationsDescription = getFilteredLocationsDescription(novelPromotionData.locations, clipLocation)
|
const filteredLocationsDescription = getFilteredLocationsDescription(
|
||||||
|
novelPromotionData.locations,
|
||||||
|
clipLocation,
|
||||||
|
locale,
|
||||||
|
)
|
||||||
const filteredPropsDescription = compileAssetPromptFragments(buildPromptAssetContext({
|
const filteredPropsDescription = compileAssetPromptFragments(buildPromptAssetContext({
|
||||||
characters: [],
|
characters: [],
|
||||||
locations: [],
|
locations: [],
|
||||||
@@ -588,7 +597,11 @@ export async function executePhase3(
|
|||||||
const clipProps = parseClipProps(clip.props)
|
const clipProps = parseClipProps(clip.props)
|
||||||
|
|
||||||
const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters, clipCharacters)
|
const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters, clipCharacters)
|
||||||
const filteredLocationsDescription = getFilteredLocationsDescription(novelPromotionData.locations, clipLocation)
|
const filteredLocationsDescription = getFilteredLocationsDescription(
|
||||||
|
novelPromotionData.locations,
|
||||||
|
clipLocation,
|
||||||
|
locale,
|
||||||
|
)
|
||||||
const filteredPropsDescription = compileAssetPromptFragments(buildPromptAssetContext({
|
const filteredPropsDescription = compileAssetPromptFragments(buildPromptAssetContext({
|
||||||
characters: [],
|
characters: [],
|
||||||
locations: [],
|
locations: [],
|
||||||
|
|||||||
@@ -255,12 +255,14 @@ export function startTaskWatchdog() {
|
|||||||
|
|
||||||
// 2. 对账 DB vs BullMQ
|
// 2. 对账 DB vs BullMQ
|
||||||
const reconciled = await reconcileActiveTasks()
|
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) {
|
if (total > 0) {
|
||||||
logger.info({
|
logger.info({
|
||||||
action: 'watchdog.cycle',
|
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) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ function resolveRunIdFromPayload(payload: unknown): string | null {
|
|||||||
return runIdFromMeta || 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) {
|
export function normalizeTaskPayload(type: TaskType, payload?: Record<string, unknown> | null) {
|
||||||
const nextPayload = {
|
const nextPayload = {
|
||||||
...(payload || {}),
|
...(payload || {}),
|
||||||
@@ -146,23 +154,20 @@ export async function submitTask(params: {
|
|||||||
targetId: params.targetId,
|
targetId: params.targetId,
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
const reusableRunTask = reusableRun?.taskId
|
||||||
|
? await getTaskById(reusableRun.taskId)
|
||||||
|
: null
|
||||||
|
|
||||||
if (runCentricTask && reusableRun?.taskId) {
|
if (runCentricTask && reusableRun && reusableRunTask && isActiveTaskStatus(reusableRunTask.status)) {
|
||||||
const existingTask = await getTaskById(reusableRun.taskId)
|
|
||||||
if (
|
|
||||||
existingTask
|
|
||||||
&& (existingTask.status === TASK_STATUS.QUEUED || existingTask.status === TASK_STATUS.PROCESSING)
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
async: true,
|
async: true,
|
||||||
taskId: existingTask.id,
|
taskId: reusableRunTask.id,
|
||||||
runId: reusableRun.id,
|
runId: reusableRun.id,
|
||||||
status: existingTask.status,
|
status: reusableRunTask.status,
|
||||||
deduped: true as const,
|
deduped: true as const,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const { task, deduped } = await createTask({
|
const { task, deduped } = await createTask({
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
@@ -177,8 +182,11 @@ export async function submitTask(params: {
|
|||||||
maxAttempts: params.maxAttempts,
|
maxAttempts: params.maxAttempts,
|
||||||
billingInfo: resolvedBillingInfo || null,
|
billingInfo: resolvedBillingInfo || null,
|
||||||
})
|
})
|
||||||
let runId = reusableRun?.id || resolveRunIdFromPayload(task.payload)
|
const reusableRunId = reusableRun && shouldAttachNewTaskToReusableRun(reusableRunTask?.status)
|
||||||
if (!deduped && reusableRun && runId) {
|
? (reusableRun?.id || null)
|
||||||
|
: null
|
||||||
|
let runId = reusableRunId || resolveRunIdFromPayload(task.payload)
|
||||||
|
if (!deduped && reusableRunId && runId) {
|
||||||
const payloadWithRunId = {
|
const payloadWithRunId = {
|
||||||
...normalizedPayload,
|
...normalizedPayload,
|
||||||
runId,
|
runId,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type CharacterBrief,
|
type CharacterBrief,
|
||||||
} from './analyze-global-parse'
|
} from './analyze-global-parse'
|
||||||
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
|
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
|
||||||
|
import { normalizeLocationAvailableSlots } from '@/lib/location-available-slots'
|
||||||
|
|
||||||
export type AnalyzeGlobalStats = {
|
export type AnalyzeGlobalStats = {
|
||||||
totalChunks: number
|
totalChunks: number
|
||||||
@@ -166,6 +167,7 @@ export async function persistAnalyzeGlobalChunk(params: {
|
|||||||
: (readText(loc.description) ? [readText(loc.description)] : [])
|
: (readText(loc.description) ? [readText(loc.description)] : [])
|
||||||
const descriptions = descriptionsRaw.map((item) => readText(item)).filter(Boolean)
|
const descriptions = descriptionsRaw.map((item) => readText(item)).filter(Boolean)
|
||||||
const cleanDescriptions = descriptions.map((item) => removeLocationPromptSuffix(item))
|
const cleanDescriptions = descriptions.map((item) => removeLocationPromptSuffix(item))
|
||||||
|
const availableSlots = normalizeLocationAvailableSlots(loc.available_slots)
|
||||||
|
|
||||||
const created = await prisma.novelPromotionLocation.create({
|
const created = await prisma.novelPromotionLocation.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -182,6 +184,7 @@ export async function persistAnalyzeGlobalChunk(params: {
|
|||||||
locationId: created.id,
|
locationId: created.id,
|
||||||
descriptions: cleanDescriptions,
|
descriptions: cleanDescriptions,
|
||||||
fallbackDescription: summary || name,
|
fallbackDescription: summary || name,
|
||||||
|
availableSlots,
|
||||||
})
|
})
|
||||||
|
|
||||||
params.existingLocationNames.push(name)
|
params.existingLocationNames.push(name)
|
||||||
@@ -219,6 +222,7 @@ export async function persistAnalyzeGlobalChunk(params: {
|
|||||||
locationId: created.id,
|
locationId: created.id,
|
||||||
descriptions: [summary],
|
descriptions: [summary],
|
||||||
fallbackDescription: summary,
|
fallbackDescription: summary,
|
||||||
|
availableSlots: [],
|
||||||
})
|
})
|
||||||
params.existingPropNames.push(name)
|
params.existingPropNames.push(name)
|
||||||
params.stats.newProps += 1
|
params.stats.newProps += 1
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { TaskJobData } from '@/lib/task/types'
|
|||||||
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
|
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
|
||||||
import { resolveAnalysisModel } from './resolve-analysis-model'
|
import { resolveAnalysisModel } from './resolve-analysis-model'
|
||||||
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
|
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
|
||||||
|
import { normalizeLocationAvailableSlots } from '@/lib/location-available-slots'
|
||||||
|
|
||||||
function readAssetKind(value: Record<string, unknown>): string {
|
function readAssetKind(value: Record<string, unknown>): string {
|
||||||
return typeof value.assetKind === 'string' ? value.assetKind : 'location'
|
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 cleanDescriptions = descriptions.map((value) => removeLocationPromptSuffix(value || ''))
|
||||||
|
const availableSlots = normalizeLocationAvailableSlots(item.available_slots)
|
||||||
await seedProjectLocationBackedImageSlots({
|
await seedProjectLocationBackedImageSlots({
|
||||||
locationId: created.id,
|
locationId: created.id,
|
||||||
descriptions: cleanDescriptions,
|
descriptions: cleanDescriptions,
|
||||||
fallbackDescription: readText(item.summary) || name,
|
fallbackDescription: readText(item.summary) || name,
|
||||||
|
availableSlots,
|
||||||
})
|
})
|
||||||
|
|
||||||
createdLocations.push(created)
|
createdLocations.push(created)
|
||||||
@@ -352,6 +355,7 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
|
|||||||
locationId: created.id,
|
locationId: created.id,
|
||||||
descriptions: [summary],
|
descriptions: [summary],
|
||||||
fallbackDescription: summary,
|
fallbackDescription: summary,
|
||||||
|
availableSlots: [],
|
||||||
})
|
})
|
||||||
existingPropNameSet.add(normalizedName)
|
existingPropNameSet.add(normalizedName)
|
||||||
createdProps.push(created)
|
createdProps.push(created)
|
||||||
|
|||||||
@@ -68,5 +68,6 @@ export async function handleAssetHubAIDesignTask(job: Job<TaskJobData>) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
prompt: result.prompt,
|
prompt: result.prompt,
|
||||||
|
availableSlots: result.availableSlots ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { TaskJobData } from '@/lib/task/types'
|
|||||||
import { TASK_TYPE } from '@/lib/task/types'
|
import { TASK_TYPE } from '@/lib/task/types'
|
||||||
import { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'
|
import { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'
|
||||||
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
|
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
|
||||||
|
import { normalizeLocationAvailableSlots } from '@/lib/location-available-slots'
|
||||||
|
|
||||||
function readRequiredString(value: unknown, field: string): string {
|
function readRequiredString(value: unknown, field: string): string {
|
||||||
if (typeof value !== 'string' || !value.trim()) {
|
if (typeof value !== 'string' || !value.trim()) {
|
||||||
@@ -19,13 +20,19 @@ function readRequiredString(value: unknown, field: string): string {
|
|||||||
|
|
||||||
import { safeParseJsonObject } from '@/lib/json-repair'
|
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 parsed = safeParseJsonObject(responseText)
|
||||||
const prompt = typeof parsed.prompt === 'string' ? parsed.prompt.trim() : ''
|
const prompt = typeof parsed.prompt === 'string' ? parsed.prompt.trim() : ''
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
throw new Error('No prompt field in response')
|
throw new Error('No prompt field in response')
|
||||||
}
|
}
|
||||||
return prompt
|
return {
|
||||||
|
prompt,
|
||||||
|
availableSlots: normalizeLocationAvailableSlots(parsed.available_slots),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
|
export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
|
||||||
@@ -96,7 +103,7 @@ export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
|
|||||||
await streamCallbacks.flush()
|
await streamCallbacks.flush()
|
||||||
await assertTaskActive(job, 'asset_hub_ai_modify_parse')
|
await assertTaskActive(job, 'asset_hub_ai_modify_parse')
|
||||||
|
|
||||||
const modifiedDescription = parseJsonPrompt(completion.text)
|
const parsed = parseJsonPrompt(completion.text)
|
||||||
|
|
||||||
await reportTaskProgress(job, 96, {
|
await reportTaskProgress(job, 96, {
|
||||||
stage: 'asset_hub_ai_modify_done',
|
stage: 'asset_hub_ai_modify_done',
|
||||||
@@ -110,6 +117,7 @@ export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
modifiedDescription,
|
modifiedDescription: parsed.prompt,
|
||||||
|
availableSlots: parsed.availableSlots,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { type TaskJobData } from '@/lib/task/types'
|
|||||||
import { encodeImageUrls } from '@/lib/contracts/image-urls-contract'
|
import { encodeImageUrls } from '@/lib/contracts/image-urls-contract'
|
||||||
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
|
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
|
||||||
import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'
|
import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'
|
||||||
|
import { buildLocationImagePromptCore } from '@/lib/location-image-prompt'
|
||||||
import {
|
import {
|
||||||
assertTaskActive,
|
assertTaskActive,
|
||||||
getUserModels,
|
getUserModels,
|
||||||
@@ -32,6 +33,7 @@ interface GlobalCharacterRecord {
|
|||||||
interface GlobalLocationImageRecord {
|
interface GlobalLocationImageRecord {
|
||||||
id: string
|
id: string
|
||||||
description: string | null
|
description: string | null
|
||||||
|
availableSlots?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GlobalLocationRecord {
|
interface GlobalLocationRecord {
|
||||||
@@ -140,7 +142,12 @@ export async function handleAssetHubImageTask(job: Job<TaskJobData>) {
|
|||||||
|
|
||||||
for (const image of targetImages) {
|
for (const image of targetImages) {
|
||||||
if (!image.description) continue
|
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({
|
const cosKey = await generateLabeledImageToCos({
|
||||||
job,
|
job,
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
normalizeReferenceImagesForGeneration,
|
normalizeReferenceImagesForGeneration,
|
||||||
} from '@/lib/media/outbound-image'
|
} from '@/lib/media/outbound-image'
|
||||||
|
import {
|
||||||
|
type LocationAvailableSlot,
|
||||||
|
stringifyLocationAvailableSlots,
|
||||||
|
} from '@/lib/location-available-slots'
|
||||||
import {
|
import {
|
||||||
AnyObj,
|
AnyObj,
|
||||||
parseImageUrls,
|
parseImageUrls,
|
||||||
@@ -51,6 +55,7 @@ interface GlobalLocationImageRecord {
|
|||||||
id: string
|
id: string
|
||||||
imageIndex: number
|
imageIndex: number
|
||||||
description: string | null
|
description: string | null
|
||||||
|
availableSlots?: string | null
|
||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
previousDescription: string | null
|
previousDescription: string | null
|
||||||
}
|
}
|
||||||
@@ -166,7 +171,7 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
|
|||||||
descriptions: appearance.descriptions,
|
descriptions: appearance.descriptions,
|
||||||
fallbackDescription: appearance.description,
|
fallbackDescription: appearance.description,
|
||||||
index: targetImageIndex,
|
index: targetImageIndex,
|
||||||
nextDescription,
|
nextDescription: nextDescription.prompt,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn({ message: '资产库角色描述同步失败', details: { error: String(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 labeled = await withLabelBar(source, location.name)
|
||||||
const cosKey = await uploadImageSourceToCos(labeled, 'global-location-modify', locationImage.id)
|
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) {
|
if (locationImage.description && modifyInstruction && userModels.analysisModel) {
|
||||||
try {
|
try {
|
||||||
extractedDescription = await generateModifiedAssetDescription({
|
extractedDescription = await generateModifiedAssetDescription({
|
||||||
@@ -256,7 +264,10 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
|
|||||||
previousImageUrl: locationImage.imageUrl,
|
previousImageUrl: locationImage.imageUrl,
|
||||||
previousDescription: locationImage.description || null,
|
previousDescription: locationImage.description || null,
|
||||||
imageUrl: cosKey,
|
imageUrl: cosKey,
|
||||||
...(extractedDescription ? { description: extractedDescription } : {}),
|
...(extractedDescription ? {
|
||||||
|
description: extractedDescription.prompt,
|
||||||
|
availableSlots: stringifyLocationAvailableSlots(extractedDescription.availableSlots),
|
||||||
|
} : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface CharacterLike {
|
|||||||
|
|
||||||
interface LocationImageLike {
|
interface LocationImageLike {
|
||||||
description?: string | null
|
description?: string | null
|
||||||
|
availableSlots?: string | null
|
||||||
imageIndex?: number
|
imageIndex?: number
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
@@ -53,6 +54,7 @@ interface PanelLike {
|
|||||||
export interface PanelCharacterReference {
|
export interface PanelCharacterReference {
|
||||||
name: string
|
name: string
|
||||||
appearance?: string
|
appearance?: string
|
||||||
|
slot?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NovelDataDb {
|
interface NovelDataDb {
|
||||||
@@ -145,11 +147,12 @@ export function parsePanelCharacterReferences(value: string | null | undefined):
|
|||||||
.map((item: unknown) => {
|
.map((item: unknown) => {
|
||||||
if (typeof item === 'string') return { name: item }
|
if (typeof item === 'string') return { name: item }
|
||||||
if (!item || typeof item !== 'object') return null
|
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') {
|
if (typeof candidate.name === 'string') {
|
||||||
return {
|
return {
|
||||||
name: candidate.name,
|
name: candidate.name,
|
||||||
appearance: typeof candidate.appearance === 'string' ? candidate.appearance : undefined,
|
appearance: typeof candidate.appearance === 'string' ? candidate.appearance : undefined,
|
||||||
|
slot: typeof candidate.slot === 'string' ? candidate.slot : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user