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