Compare commits
11 Commits
4e469074e0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d2e793c6cf | |||
| 022d581c60 | |||
|
|
531de26d48 | ||
|
|
78b93331b4 | ||
|
|
854b932e67 | ||
|
|
77a1303510 | ||
|
|
71ef6ff818 | ||
|
|
9703714b69 | ||
|
|
c3e74c228a | ||
|
|
ca5d8a58f7 | ||
|
|
fd8f5f8635 |
@@ -21,10 +21,9 @@
|
||||
> [!IMPORTANT]
|
||||
> ⚠️ **测试版声明**:本项目目前处于测试初期阶段,由于暂时只有我一个人开发,存在部分 bug 和不完善之处。我们正在快速迭代更新中,**欢迎进群反馈问题和需求,及时关注项目更新!目前更新会非常频繁,后续会增加大量新功能以及优化效果,我们的目标是成为行业最强AI工具!**
|
||||
|
||||
<img src="images/dab6b4105e3260f37ba2d5f536dce259.jpg" width="30%">
|
||||
<img src="https://github.com/user-attachments/assets/2b3fc495-9812-493a-8dbc-5bec4757df31" width="30%">
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- 🎬 **AI 剧本分析** — 自动解析小说,提取角色、场景、剧情
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
ports:
|
||||
- "13306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- /docker/waoowaoo/backup/mysql_data:/var/lib/mysql
|
||||
command:
|
||||
- "--default-authentication-plugin=mysql_native_password"
|
||||
- "--sql_mode=STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION"
|
||||
@@ -30,7 +30,7 @@ services:
|
||||
ports:
|
||||
- "16379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
- /docker/waoowaoo/backup/redis_data:/data
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
@@ -52,7 +52,7 @@ services:
|
||||
- "19000:9000"
|
||||
- "19001:9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
- /docker/waoowaoo/backup/minio:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
@@ -62,7 +62,10 @@ services:
|
||||
|
||||
# ==================== App (Next.js + Workers) ====================
|
||||
app:
|
||||
image: ghcr.io/saturndec/waoowaoo:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: waoowaoo:local
|
||||
container_name: waoowaoo-app
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -120,8 +123,8 @@ services:
|
||||
- "13000:3000"
|
||||
- "13010:3010"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./docker-logs:/app/logs
|
||||
- /docker/waoowaoo/backup/data:/app/data
|
||||
- /docker/waoowaoo/backup/docker-logs:/app/logs
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -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 字符串值中必须统一替换为「」,严禁出现未转义的英文双引号 "
|
||||
|
||||
39
lib/prompts/novel-promotion/ai_story_expand.en.txt
Normal file
39
lib/prompts/novel-promotion/ai_story_expand.en.txt
Normal file
@@ -0,0 +1,39 @@
|
||||
You are a professional screenwriting master, skilled at expanding short ideas, keywords, or outlines into complete story/script content.
|
||||
|
||||
## Your Task
|
||||
|
||||
Based on the user's creative input (which may be keywords, short descriptions, story outlines, or content to rewrite), create a complete, high-quality story suitable for short drama production.
|
||||
|
||||
## Writing Requirements
|
||||
|
||||
### Content Quality
|
||||
1. The story must have a clear beginning, development, and ending (or climactic suspense)
|
||||
2. Characters should have distinct personality traits and motivations
|
||||
3. Scene descriptions should be specific and visually compelling, suitable for storyboard conversion
|
||||
4. Dialogue should be natural, tension-filled, and drive the plot forward
|
||||
5. Pacing should be tight, avoiding lengthy background exposition
|
||||
|
||||
### Format Requirements
|
||||
1. Use third-person perspective narration
|
||||
2. Separate scene transitions with blank lines
|
||||
3. Briefly introduce characters when they first appear
|
||||
4. Mark dialogue with quotation marks and identify the speaker
|
||||
5. Include action and expression descriptions at key visual moments
|
||||
|
||||
### Length Control (⚠️ Most Important)
|
||||
- Goal: generate story content suitable for a 1-2 minute short film
|
||||
- Total length must be strictly between 300-800 words, never exceeding 800 words
|
||||
- Short keyword input: generate 400-600 word stories
|
||||
- Outline input: keep overall length to 500-800 words, expand each point concisely
|
||||
- Prefer concise over verbose; every sentence should be visual and dramatic
|
||||
- Characters and scenes should follow naturally from the user's input; do not invent characters or scenes the user did not mention
|
||||
|
||||
### Prohibited
|
||||
- Do not output any non-story content (such as "Here is the generated story" etc.)
|
||||
- Do not output titles or chapter numbers
|
||||
- Do not use markdown formatting
|
||||
- Output story text directly
|
||||
|
||||
## User Input
|
||||
|
||||
{input}
|
||||
39
lib/prompts/novel-promotion/ai_story_expand.zh.txt
Normal file
39
lib/prompts/novel-promotion/ai_story_expand.zh.txt
Normal file
@@ -0,0 +1,39 @@
|
||||
你是一个专业的影视剧本创作大师,擅长将简短的创意、关键词或大纲扩展为完整的故事/剧本内容。
|
||||
|
||||
## 你的任务
|
||||
|
||||
根据用户提供的创意输入(可能是关键词、简短描述、故事大纲或需要改写的内容),创作一段完整的、高质量的故事内容,用于后续的影视短剧制作。
|
||||
|
||||
## 创作要求
|
||||
|
||||
### 内容质量
|
||||
1. 故事必须有清晰的开头、发展和结尾(或高潮悬念)
|
||||
2. 角色要有鲜明的性格特征和行为动机
|
||||
3. 场景描写要具体、画面感强,适合转化为分镜画面
|
||||
4. 对话要自然、有张力,推动剧情发展
|
||||
5. 情节节奏紧凑,避免冗长的背景铺陈
|
||||
|
||||
### 格式要求
|
||||
1. 使用第三人称视角叙述
|
||||
2. 场景转换时用空行分隔
|
||||
3. 角色名称在首次出现时需要简短介绍
|
||||
4. 对话使用引号标注,并注明说话者
|
||||
5. 在关键画面处加入动作和表情描写
|
||||
|
||||
### 篇幅控制(⚠️ 最重要)
|
||||
- 目标:生成适合 1-2 分钟影视短片的故事内容
|
||||
- 总篇幅严格控制在 300-800 字之间,绝不超过 800 字
|
||||
- 简短关键词输入:生成 400-600 字的故事
|
||||
- 大纲输入:整体控制在 500-800 字,每个要点精炼展开
|
||||
- 宁可精炼也不要冗长,每一句话都要有画面感和戏剧张力
|
||||
- 角色和场景数量根据用户输入自然决定,不要额外发明用户未提及的角色或场景
|
||||
|
||||
### 禁止事项
|
||||
- 不要输出任何非故事内容(如"以下是生成的故事"等说明文字)
|
||||
- 不要输出标题、章节号
|
||||
- 不要使用 markdown 格式
|
||||
- 直接输出故事正文内容
|
||||
|
||||
## 用户输入
|
||||
|
||||
{input}
|
||||
@@ -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": [
|
||||
"皇宫正中龙椅前方台阶下的位置",
|
||||
"右后方殿门内侧靠墙的位置"
|
||||
]
|
||||
}
|
||||
|
||||
31
lib/prompts/novel-promotion/prop_description_update.en.txt
Normal file
31
lib/prompts/novel-promotion/prop_description_update.en.txt
Normal file
@@ -0,0 +1,31 @@
|
||||
You are a prop asset description editor.
|
||||
|
||||
Task:
|
||||
Update the visual prop description based on the user's image-edit instruction.
|
||||
|
||||
Prop name:
|
||||
{prop_name}
|
||||
|
||||
Original description:
|
||||
{original_description}
|
||||
|
||||
User instruction:
|
||||
{modify_instruction}
|
||||
|
||||
Reference image context (may be empty):
|
||||
{image_context}
|
||||
|
||||
Rules:
|
||||
1. Describe only the prop itself. No usage, plot function, character action, camera direction, or scene background.
|
||||
2. Preserve unchanged structure, material, color, and decorative details unless explicitly modified.
|
||||
3. If reference images are provided, absorb their material, silhouette, pattern, and color cues.
|
||||
4. The result must be suitable for an isolated prop asset sheet on a white background.
|
||||
5. Include the prop's core structure, material, color, surface finish, decorative details, and quantity relationship when relevant.
|
||||
6. Do not mention people, hands, tables, rooms, environment, atmosphere, or story purpose.
|
||||
7. Return one concise English visual description.
|
||||
|
||||
Output format:
|
||||
Return JSON only. ⚠️ JSON SAFETY: All quotation marks MUST be converted to corner brackets「」in JSON string values:
|
||||
{
|
||||
"prompt": "updated prop visual description"
|
||||
}
|
||||
30
lib/prompts/novel-promotion/prop_description_update.zh.txt
Normal file
30
lib/prompts/novel-promotion/prop_description_update.zh.txt
Normal file
@@ -0,0 +1,30 @@
|
||||
你是一个专业的道具资产描述更新专家。
|
||||
|
||||
【任务】
|
||||
根据用户对道具图片的修改,更新道具的视觉描述词。
|
||||
|
||||
【道具名称】
|
||||
{prop_name}
|
||||
|
||||
【原始道具描述】
|
||||
{original_description}
|
||||
|
||||
【用户修改指令】
|
||||
{modify_instruction}
|
||||
|
||||
{image_context}
|
||||
|
||||
【更新规则】
|
||||
1. 只描述道具本体的静态视觉信息,不写用途、剧情、角色动作、镜头、背景环境。
|
||||
2. 优先保留原描述里未被修改的结构、材质、颜色和装饰细节。
|
||||
3. 如果有参考图片,请吸收参考图中的材质、轮廓、纹样、配色等关键视觉特征。
|
||||
4. 输出必须适合白底居中的道具资产图生成。
|
||||
5. 必须明确道具的主体结构、材质、颜色、表面处理、装饰细节和数量关系。
|
||||
6. 禁止出现人物、手部、桌面、房间、场景、光影氛围、剧情用途等信息。
|
||||
7. 使用中文输出,长度 40-100 字。
|
||||
|
||||
【输出格式】
|
||||
只返回 JSON,禁止返回任何其他内容。⚠️ 所有引号(""''等)在 JSON 字符串值中必须替换为「」,严禁出现未转义的英文双引号 ":
|
||||
{
|
||||
"prompt": "更新后的道具视觉描述"
|
||||
}
|
||||
@@ -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 字符串值中出现英文双引号 "。原文中的所有引号(""''等)必须统一替换为「」。如字符串内确实需要英文双引号,必须转义为 \"
|
||||
|
||||
@@ -3,14 +3,15 @@ You are a key story prop extractor.
|
||||
Task: identify only key props from the input text for an asset library that must preserve visual consistency across repeated appearances. Be conservative. Return JSON only.
|
||||
|
||||
Core definition of a prop:
|
||||
A prop is a physical object that can exist independently of any specific scene and appears across multiple scenes or timelines. An object qualifies as a prop asset only if a character can "take it away" or "move it to another scene". Most stories have very few props, or even none at all.
|
||||
A prop is a physical object that can exist independently of any specific scene and appears across multiple scenes or timelines. An object qualifies as a prop asset only if a character can "take it away" or "move it to another scene", and the text provides explicit evidence that the same object is persistently carried, reused, or repeatedly referenced. Most stories have very few props, or even none at all.
|
||||
|
||||
Output format:
|
||||
{
|
||||
"props": [
|
||||
{
|
||||
"name": "prop name",
|
||||
"summary": "one-line objective prop description"
|
||||
"summary": "short human-readable prop summary",
|
||||
"description": "pure visual description for image generation"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,11 +19,15 @@ Output format:
|
||||
Key prop criteria:
|
||||
1. It must be a real physical object that actually appears in the story.
|
||||
2. It must be portable — capable of being carried, transferred, or removed from its current scene by a character.
|
||||
3. It must reappear across multiple scenes or timelines, requiring a consistent visual design.
|
||||
3. There must be explicit textual evidence that it reappears across multiple scenes or timelines and needs a consistent visual design. Do not infer future recurrence from common sense.
|
||||
4. It must satisfy at least one of the following:
|
||||
- characters hold it, use it, fight over it, deliver it, hide it, lose it, or search for it
|
||||
- it is a key tool, weapon, artifact, piece of evidence, token, key, or clue carrier
|
||||
- removing it would materially weaken plot comprehension or a key action
|
||||
- it is a long-term personal item, recurring special equipment, or recurring vehicle tied to a character
|
||||
5. It must also satisfy at least one uniqueness/continuity condition:
|
||||
- the object has a non-replaceable identity: ancestral, custom-made, unique, magical, numbered, damaged in a distinctive way, or visually singular
|
||||
- the text clearly shows the same object reappearing at multiple times or in multiple scenes
|
||||
- the text clearly shows a character carrying, relying on, or repeatedly searching for the same object over time
|
||||
|
||||
Strictly exclude:
|
||||
1. Ordinary background items, furniture, tableware, food, drinks, daily necessities, and decorations.
|
||||
@@ -32,6 +37,8 @@ Strictly exclude:
|
||||
5. Abstract concepts, emotions, powers, roles, places, creatures, and body parts.
|
||||
6. Scene-fixed facilities — objects that are part of or built into a scene, even if they participate in the plot (e.g. a hacked computer, a smashed window, a fireplace on fire). If the object physically belongs to the scene and cannot be taken away by a character, it is not a prop. These are "scene states" and should be handled by scene descriptions.
|
||||
7. Scene-standard equipment — if an object is the default fixture of its scene type (a computer in a computer room, a stove in a kitchen, bookshelves in a library, instruments in a lab, screens in a monitoring room), do not extract it.
|
||||
8. Ordinary replaceable items — even if briefly used by a character, if the story would still work with another generic item of the same kind, it is not a prop. Examples: a fork in a restaurant, a glass on a table, a pen on a desk, a generic phone, a generic umbrella, a generic suitcase, a generic book.
|
||||
9. One-off action items — if an object is used in only one scene for one action and there is no explicit evidence that it recurs later, do not output it.
|
||||
|
||||
Decision bias:
|
||||
1. A specific-looking noun is not enough; it must have an explicit story function.
|
||||
@@ -40,27 +47,37 @@ Decision bias:
|
||||
4. If you are unsure whether it deserves an asset entry, do not output it.
|
||||
5. Prefer under-extraction. Never output props just to increase the count.
|
||||
6. Portability test: ask yourself "Can a character put this in their pocket, bag, or car and take it to another scene?" If not, do not output it.
|
||||
7. Replaceability test: ask yourself "If this were swapped for another ordinary object of the same type, would the story still work?" If yes, do not output it.
|
||||
8. Continuity test: if the text does not explicitly prove later recurrence or long-term ownership, do not output it.
|
||||
9. Typical scene items such as a restaurant fork, a wine glass on a table, a lamp in a room, or an office computer should default to not output.
|
||||
|
||||
Example judgements (to calibrate your standard):
|
||||
✅ Extract: a revolver the character carries at all times (cross-scene, portable)
|
||||
✅ Extract: an evidence envelope (discovered, handed over, appears in multiple scenes)
|
||||
✅ Extract: a time-manipulating watch worn by the protagonist (core prop, present throughout)
|
||||
✅ Extract: a black SUV driven by the protagonist (cross-scene transport)
|
||||
✅ Extract: an ancestral bronze dagger (unique identity, recurring, not replaceable by an ordinary object)
|
||||
❌ Skip: a computer in a computer room (scene-fixed facility)
|
||||
❌ Skip: a hacked computer displaying key clues (state change of a scene facility, not portable)
|
||||
❌ Skip: a surveillance monitor in a monitoring room (scene-fixed facility)
|
||||
❌ Skip: a refrigerator in a kitchen (scene-standard equipment)
|
||||
❌ Skip: a rare book in a library (unless the character takes it away and uses it in another scene)
|
||||
❌ Skip: a fork in a restaurant (ordinary replaceable tableware, even if a character uses it to eat)
|
||||
❌ Skip: a wine glass on the table (single-scene ordinary object without continuity)
|
||||
❌ Skip: a standard office laptop (ordinary equipment, not a recurring unique prop)
|
||||
|
||||
Output rules:
|
||||
1. Only output `name` and `summary`.
|
||||
2. `name` and `summary` must both be non-empty.
|
||||
1. Only output `name`, `summary`, and `description`.
|
||||
2. `name`, `summary`, and `description` must all be non-empty.
|
||||
3. Do not repeat props that already exist in the prop library with the exact same name.
|
||||
4. Keep names stable and short.
|
||||
5. Keep summaries objective.
|
||||
6. Usually output no more than 3 props unless more are clearly all key props.
|
||||
7. If none exist, return {"props": []}. Returning an empty array is correct in most cases.
|
||||
8. Replace raw quotation marks inside JSON string values with corner brackets「」.
|
||||
5. `summary` is for humans only. Keep it short and objective. Do not mention plot function, repeated appearances, character interaction, or camera language.
|
||||
6. `description` is for image generation only. Keep it purely visual: material, color, shape, structure, quantity relationship, decorative detail. Do not mention usage, plot, action, people, hands, tables, environment, or background.
|
||||
7. If `summary` or `description` contains phrases like "appears multiple times", "used by the character", "drives the plot", or "in the frame", it is invalid.
|
||||
8. Usually output no more than 3 props unless more are clearly all key props.
|
||||
9. If none exist, return {"props": []}. Returning an empty array is correct in most cases.
|
||||
10. Replace raw quotation marks inside JSON string values with corner brackets「」.
|
||||
11. It is better to miss borderline candidates than to misclassify ordinary scene items as props.
|
||||
|
||||
Input:
|
||||
{input}
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
任务:从输入文本中只识别【关键道具】,用于建立需要长期保持外观一致的资产库。宁缺毋滥。只返回 JSON,不得包含任何额外解释或 markdown。
|
||||
|
||||
道具的核心定义:
|
||||
道具是可以脱离特定场景独立存在的、跨场景/跨时间线出现的实体物件。一个物件必须能被角色「带走」或「转移到另一个场景」,才有资格成为道具资产。大部分故事中道具数量非常少,甚至为零。
|
||||
道具是可以脱离特定场景独立存在的、跨场景/跨时间线出现的实体物件。一个物件必须能被角色「带走」或「转移到另一个场景」,并且在文本中有明确证据表明它会被持续持有、反复使用、反复提及,才有资格成为道具资产。大部分故事中道具数量非常少,甚至为零。
|
||||
|
||||
输出格式:
|
||||
{
|
||||
"props": [
|
||||
{
|
||||
"name": "道具名称",
|
||||
"summary": "一句话描述道具的外观/用途"
|
||||
"summary": "给人阅读的简短道具说明",
|
||||
"description": "用于生成图片的纯视觉描述"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,11 +19,15 @@
|
||||
关键道具判定标准:
|
||||
1. 必须是剧情中真实出现的实体物件。
|
||||
2. 必须是可移动的——能够被角色携带、转移、带离当前场景。
|
||||
3. 必须跨场景或跨时间线重复出现,且需要保持外观一致。
|
||||
3. 必须有明确文本证据表明它跨场景或跨时间线重复出现,且需要保持外观一致;禁止凭常识猜测它以后还会出现。
|
||||
4. 必须至少满足以下一种情况:
|
||||
- 被角色持有、使用、争夺、交付、隐藏、丢失、寻找
|
||||
- 是推进情节的关键工具、武器、法器、证物、信物、钥匙、线索载体
|
||||
- 去掉它会明显影响剧情理解或关键动作成立
|
||||
- 是角色长期携带或反复回收使用的专属物件、独特装备、特殊交通工具
|
||||
5. 必须同时满足以下至少一条“唯一性/持续性”条件,否则不输出:
|
||||
- 物件具有不可替代的独特身份,例如祖传、特制、唯一、带特殊能力、带特殊机关、带独特编号/纹样/损伤
|
||||
- 文本明确表明同一件物品在多个场景/多个时间点反复出现
|
||||
- 文本明确表明角色长期随身携带、持续依赖或反复寻找同一件物品
|
||||
|
||||
严格不提取:
|
||||
1. 普通背景陈设、家具、餐具、食物、饮料、日用品、装饰物。
|
||||
@@ -32,6 +37,8 @@
|
||||
5. 抽象概念、情绪、能力、身份、地点、生物、身体部位。
|
||||
6. 场景固有设施——物件是某个场景的组成部分或内置设备,即便它参与了剧情互动(如被黑客入侵的电脑、被砸碎的窗户、着火的壁炉),只要它在物理上依附于场景、无法被角色带走,就不是道具。这类属于"场景状态",由场景描述承载。
|
||||
7. 场景常规配置——如果一个物件是该类场景的标配(电脑房的电脑、厨房的灶台、图书馆的书架、实验室的仪器、监控室的屏幕),直接不提取。
|
||||
8. 普通可替换物件——即使它被角色短暂使用,只要换成同类另一件物品剧情仍成立,就不是道具。例如餐厅里的叉子、桌上的杯子、办公桌上的笔、本子、普通手机、普通雨伞、普通行李箱、普通书籍。
|
||||
9. 一次性动作依赖物件——如果它只在单个场景里承担一次动作功能,没有明确后续复现证据,不输出。
|
||||
|
||||
判断倾向:
|
||||
1. 仅因外观具体、名词明确,不足以成为关键道具;必须有明确剧情作用。
|
||||
@@ -40,27 +47,37 @@
|
||||
4. 如果不确定它是否值得进入资产库,直接不输出。
|
||||
5. 优先少报,禁止为了凑数量而输出。
|
||||
6. 可移动性测试:问自己"角色能把它装进口袋/背包/车里带到另一个场景吗?"如果不能,不输出。
|
||||
7. 可替换性测试:问自己"把它替换成同类另一件普通物品,剧情是否仍然成立?"如果答案是“成立”,不输出。
|
||||
8. 贯穿性测试:如果文本没有明确证据证明它会在后续再次出现或被长期持有,默认不输出。
|
||||
9. 对“餐厅里的叉子、桌上的酒杯、房间里的台灯、办公室里的电脑”这类典型场景内物件,一律默认不输出。
|
||||
|
||||
示例判断(帮助校准标准):
|
||||
✅ 应提取:角色随身携带的左轮手枪(跨场景出现、可移动)
|
||||
✅ 应提取:关键证物信封(被发现、传递、多场景出现)
|
||||
✅ 应提取:主角可操控时间的手表(核心道具,贯穿全剧)
|
||||
✅ 应提取:主角驾驶的黑色越野车(跨场景移动工具)
|
||||
✅ 应提取:祖传青铜短剑(独特身份,反复出现,无法被普通物件替代)
|
||||
❌ 不提取:电脑房里的电脑(场景固有设施)
|
||||
❌ 不提取:被黑客入侵、显示关键线索的电脑(场景设施的状态变化,不可移动)
|
||||
❌ 不提取:监控室的监控屏幕(场景固有设施)
|
||||
❌ 不提取:厨房的冰箱(场景常规配置)
|
||||
❌ 不提取:图书馆的某本古籍(除非角色将它取走带到其他场景使用)
|
||||
❌ 不提取:餐厅里的叉子(普通可替换餐具,即使角色拿它吃饭或短暂拿在手里)
|
||||
❌ 不提取:桌上的红酒杯(单场景普通物件,不具备贯穿性)
|
||||
❌ 不提取:办公室里的普通笔记本电脑(普通设备,场景配置)
|
||||
|
||||
输出要求:
|
||||
1. 只输出两个字段:name、summary。
|
||||
2. name 不能为空;summary 不能为空。
|
||||
1. 只输出三个字段:name、summary、description。
|
||||
2. name、summary、description 都不能为空。
|
||||
3. 如果道具库里已经有完全同名道具,不要重复输出。
|
||||
4. 名称尽量简洁稳定,例如"青铜匕首""录音笔""红绳手链"。
|
||||
5. summary 只写客观描述,不写剧情推断。
|
||||
6. 通常不超过 3 个;只有确实都是关键道具时才可更多。
|
||||
7. 如果没有合适道具,返回 {"props": []}。绝大多数情况下返回空数组是正确的。
|
||||
8. JSON 字符串值中的引号统一替换为「」。
|
||||
5. summary 只给人阅读,简短说明这是一个什么道具;禁止写剧情作用、使用过程、出现频次、角色互动、镜头描述。
|
||||
6. description 只写图片生成所需的静态视觉信息;只允许写材质、颜色、形状、结构、数量关系、装饰细节;禁止写用途、剧情、动作、人物、手部、桌面、环境、背景。
|
||||
7. 如果 summary 或 description 中出现"多次出现""被角色使用""推进剧情""在画面中"这类语义,视为错误,禁止输出。
|
||||
8. 通常不超过 3 个;只有确实都是关键道具时才可更多。
|
||||
9. 如果没有合适道具,返回 {"props": []}。绝大多数情况下返回空数组是正确的。
|
||||
10. JSON 字符串值中的引号统一替换为「」。
|
||||
11. 宁可漏掉边缘候选,也不要把场景里的普通物件误报为道具。
|
||||
|
||||
输入文本:
|
||||
{input}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"downloadSuccess": "Download Complete",
|
||||
"downloadFailed": "Download Failed",
|
||||
"downloadEmpty": "No image assets to download",
|
||||
"filteredEmptyHint": "Click \"New Asset\" to add assets",
|
||||
"newFolder": "New Folder",
|
||||
"editFolder": "Edit Folder",
|
||||
"deleteFolder": "Delete Folder",
|
||||
|
||||
@@ -55,8 +55,10 @@
|
||||
"title": "New Prop",
|
||||
"name": "Prop Name",
|
||||
"namePlaceholder": "Enter prop name",
|
||||
"summary": "Prop Description",
|
||||
"summaryPlaceholder": "Describe the prop..."
|
||||
"summary": "Summary",
|
||||
"summaryPlaceholder": "One-line human summary of the prop, without plot usage...",
|
||||
"description": "Image Description",
|
||||
"descriptionPlaceholder": "Describe only the prop itself: material, color, structure, and decoration..."
|
||||
},
|
||||
"artStyle": {
|
||||
"title": "Art Style"
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
"appearances": "appearances",
|
||||
"images": "images",
|
||||
"cancel": "Cancel",
|
||||
"confirmCopy": "Confirm Copy",
|
||||
"copyFromGlobal": "Copy from Asset Hub",
|
||||
"copySuccess": "Copy successful",
|
||||
"copyFailed": "Copy failed",
|
||||
"confirmCopy": "Confirm Import",
|
||||
"copyFromGlobal": "Import from Asset Hub",
|
||||
"copySuccess": "Import successful",
|
||||
"copyFailed": "Import failed",
|
||||
"preview": "Preview",
|
||||
"stop": "Stop"
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"characterCount": "{count} Characters",
|
||||
"updateFailed": "Update description failed",
|
||||
"addFailed": "Add character failed",
|
||||
"copyFromGlobal": "Copy from Asset Hub"
|
||||
"copyFromGlobal": "Import from Asset Hub"
|
||||
},
|
||||
"location": {
|
||||
"add": "Add Location",
|
||||
@@ -83,7 +83,9 @@
|
||||
"deleteFailed": "Delete failed: {error}",
|
||||
"name": "Prop Name",
|
||||
"summary": "Summary",
|
||||
"summaryPlaceholder": "Describe the prop",
|
||||
"summaryPlaceholder": "One-line human summary of the prop, without plot usage",
|
||||
"description": "Image Description",
|
||||
"descriptionPlaceholder": "Describe only the prop itself: material, color, structure, and decoration",
|
||||
"regenerateImage": "Regenerate",
|
||||
"addFailed": "Add prop failed"
|
||||
},
|
||||
@@ -171,8 +173,10 @@
|
||||
"scenePrompt": "Scene Description Prompt",
|
||||
"appearancePrompt": "Appearance Description Prompt",
|
||||
"smartModify": "Smart Modify",
|
||||
"modifyDescription": "AI Modify Description",
|
||||
"modifyPlaceholder": "e.g.: Change to night, add moonlight, add curtains...",
|
||||
"modifyPlaceholderCharacter": "e.g.: Change hair to blonde, height to 180cm, wear black suit...",
|
||||
"modifyPlaceholderProp": "e.g.: change to brushed silver, add carvings to the handle, remove ruby decoration...",
|
||||
"modifying": "Smart modifying...",
|
||||
"modifyFailed": "Modification failed",
|
||||
"editCharacter": "Edit Character",
|
||||
@@ -344,12 +348,15 @@
|
||||
"imageEdit": {
|
||||
"editCharacterImage": "Edit Character Image",
|
||||
"editLocationImage": "Edit Location Image",
|
||||
"editPropImage": "Edit Prop Image",
|
||||
"characterLabel": "Character: {name}",
|
||||
"locationLabel": "Location: {name}",
|
||||
"propLabel": "Prop: {name}",
|
||||
"editInstruction": "Edit Instruction",
|
||||
"subtitle": "Enter an edit instruction and optionally upload reference images",
|
||||
"characterPlaceholder": "Describe what you want to change, e.g.: Change hair to blonde, add glasses, change to casual clothes...",
|
||||
"locationPlaceholder": "Describe what you want to change, e.g.: Add more trees, change to night scene...",
|
||||
"propPlaceholder": "Describe what you want to change, e.g.: change to brushed silver metal, add carved patterns to the handle, remove gem decoration...",
|
||||
"storyboardPlaceholder": "Describe what you want to change, e.g.: Change background color, adjust character expression...",
|
||||
"noAssetHint": "No assets, click \"Add Asset\" to select",
|
||||
"referenceImages": "Reference Images",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"title": "Quick Start",
|
||||
"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",
|
||||
@@ -11,5 +12,15 @@
|
||||
"minutesAgo": "{n}m ago",
|
||||
"hoursAgo": "{n}h ago",
|
||||
"daysAgo": "{n}d ago"
|
||||
},
|
||||
"aiWrite": {
|
||||
"trigger": "AI Write",
|
||||
"modalTitle": "AI Writing Assistant",
|
||||
"modalSubtitle": "Enter your idea and let AI generate a complete story",
|
||||
"inputLabel": "Enter your creative idea",
|
||||
"placeholder": "Enter keywords, story outline, or a short idea...\n\ne.g.\n• ancient palace, revenge, mystery, female lead\n• Act 1: The heroine returns to the capital; Act 2: A chance encounter at the royal banquet",
|
||||
"hint": "💡 You can enter keywords, story outlines, or creative descriptions. AI will expand your input into a complete story",
|
||||
"startAiWrite": "Start AI Writing",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,4 +173,4 @@
|
||||
"confirm": "Continue and Clear",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +65,10 @@
|
||||
"screenplayConvert": "Screenplay conversion",
|
||||
"voiceAnalyze": "Voice line analysis",
|
||||
"analyzeGlobal": "Global analysis",
|
||||
"aiStoryExpand": "AI story expansion",
|
||||
"aiModifyAppearance": "Character description modify",
|
||||
"aiModifyLocation": "Location description modify",
|
||||
"aiModifyProp": "Prop description modify",
|
||||
"aiModifyShotPrompt": "Shot prompt modify",
|
||||
"analyzeShotVariants": "Shot variant analysis",
|
||||
"aiCreateCharacter": "Project character design",
|
||||
@@ -79,6 +81,7 @@
|
||||
"assetHubAiDesignLocation": "Asset hub location design",
|
||||
"assetHubAiModifyCharacter": "Asset hub character modify",
|
||||
"assetHubAiModifyLocation": "Asset hub location modify",
|
||||
"assetHubAiModifyProp": "Asset hub prop modify",
|
||||
"assetHubReferenceToCharacter": "Asset hub reference to character"
|
||||
},
|
||||
"stage": {
|
||||
@@ -104,6 +107,8 @@
|
||||
"scriptToStoryboardStep": "Execute script-to-storyboard step",
|
||||
"scriptToStoryboardPersist": "Persist script-to-storyboard output",
|
||||
"scriptToStoryboardPersistDone": "Storyboard and voice output persisted",
|
||||
"aiStoryExpandPrepare": "Prepare AI story expansion",
|
||||
"aiStoryExpandDone": "AI story expansion completed",
|
||||
"insertPanelGenerateText": "Generate inserted panel text",
|
||||
"insertPanelPersist": "Persist inserted panel",
|
||||
"pollingExternal": "Waiting for external service",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"downloadSuccess": "下载完成",
|
||||
"downloadFailed": "下载失败",
|
||||
"downloadEmpty": "当前没有可下载的图片资产",
|
||||
"filteredEmptyHint": "点击新建资产添加资产",
|
||||
"newFolder": "新建文件夹",
|
||||
"editFolder": "编辑文件夹",
|
||||
"deleteFolder": "删除文件夹",
|
||||
|
||||
@@ -55,8 +55,10 @@
|
||||
"title": "新建道具",
|
||||
"name": "道具名称",
|
||||
"namePlaceholder": "请输入道具名称",
|
||||
"summary": "道具描述",
|
||||
"summaryPlaceholder": "请输入道具描述..."
|
||||
"summary": "简要说明",
|
||||
"summaryPlaceholder": "一句话说明这是什么道具,不写剧情用途...",
|
||||
"description": "图片描述",
|
||||
"descriptionPlaceholder": "只写道具本体的材质、颜色、结构和装饰细节..."
|
||||
},
|
||||
"artStyle": {
|
||||
"title": "画面风格"
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
"appearances": "个形象",
|
||||
"images": "张图片",
|
||||
"cancel": "取消",
|
||||
"confirmCopy": "确认复制",
|
||||
"copyFromGlobal": "从资产中心复制",
|
||||
"copySuccess": "复制成功",
|
||||
"copyFailed": "复制失败",
|
||||
"confirmCopy": "确认导入",
|
||||
"copyFromGlobal": "从资产中心导入",
|
||||
"copySuccess": "导入成功",
|
||||
"copyFailed": "导入失败",
|
||||
"preview": "试听",
|
||||
"stop": "停止"
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"characterCount": "{count} 个角色",
|
||||
"updateFailed": "更新描述失败",
|
||||
"addFailed": "添加角色失败",
|
||||
"copyFromGlobal": "从资产中心复制"
|
||||
"copyFromGlobal": "从资产中心导入"
|
||||
},
|
||||
"location": {
|
||||
"add": "添加场景",
|
||||
@@ -82,8 +82,10 @@
|
||||
"deleteConfirm": "确定要删除这个道具吗?",
|
||||
"deleteFailed": "删除失败: {error}",
|
||||
"name": "道具名",
|
||||
"summary": "简要描述",
|
||||
"summaryPlaceholder": "描述这个道具",
|
||||
"summary": "简要说明",
|
||||
"summaryPlaceholder": "一句话说明这是什么道具,不写剧情用途",
|
||||
"description": "图片描述",
|
||||
"descriptionPlaceholder": "只写道具本体的材质、颜色、结构和装饰细节",
|
||||
"regenerateImage": "重新生成",
|
||||
"addFailed": "添加道具失败"
|
||||
},
|
||||
@@ -171,8 +173,10 @@
|
||||
"scenePrompt": "场景描述提示词",
|
||||
"appearancePrompt": "形象描述提示词",
|
||||
"smartModify": "智能修改",
|
||||
"modifyDescription": "AI修改描述",
|
||||
"modifyPlaceholder": "例如:改成夜晚,添加月光,增加窗帘...",
|
||||
"modifyPlaceholderCharacter": "例如:把头发改成金色、身高改为180cm、穿黑色西装...",
|
||||
"modifyPlaceholderProp": "例如:改成磨砂银质、手柄增加雕花、去掉红宝石装饰...",
|
||||
"modifying": "智能修改中...",
|
||||
"modifyFailed": "修改失败",
|
||||
"editCharacter": "编辑角色",
|
||||
@@ -344,12 +348,15 @@
|
||||
"imageEdit": {
|
||||
"editCharacterImage": "编辑人物图片",
|
||||
"editLocationImage": "编辑场景图片",
|
||||
"editPropImage": "编辑道具图片",
|
||||
"characterLabel": "人物: {name}",
|
||||
"locationLabel": "场景: {name}",
|
||||
"propLabel": "道具: {name}",
|
||||
"editInstruction": "修改指令",
|
||||
"subtitle": "输入修改指令,可选择上传参考图片",
|
||||
"characterPlaceholder": "描述你想要修改的内容,例如:把头发改成金色、添加眼镜、换成休闲装...",
|
||||
"locationPlaceholder": "描述你想要修改的内容,例如:添加更多树木、改成夜晚场景...",
|
||||
"propPlaceholder": "描述你想要修改的内容,例如:改成银色金属材质、刀柄增加雕纹、去掉宝石装饰...",
|
||||
"storyboardPlaceholder": "描述你想要修改的内容,例如:改变背景颜色、调整人物表情...",
|
||||
"noAssetHint": "暂无资产,点击\"添加资产\"选择",
|
||||
"referenceImages": "参考图片",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"title": "快速开始",
|
||||
"title": "从灵感到银幕",
|
||||
"subtitle": "描述你想要创作的故事,AI 为你智能生成影视短剧",
|
||||
"inputPlaceholder": "输入你的故事创意、小说片段或剧本大纲...",
|
||||
"defaultProjectName": "新项目 {timestamp}",
|
||||
"startCreation": "开始创作",
|
||||
"recentProjects": "最近项目",
|
||||
"viewAll": "查看全部项目",
|
||||
@@ -11,5 +12,15 @@
|
||||
"minutesAgo": "{n}分钟前",
|
||||
"hoursAgo": "{n}小时前",
|
||||
"daysAgo": "{n}天前"
|
||||
},
|
||||
"aiWrite": {
|
||||
"trigger": "AI 帮我写",
|
||||
"modalTitle": "AI 创作助手",
|
||||
"modalSubtitle": "输入你的创意,让 AI 帮你生成完整故事",
|
||||
"inputLabel": "输入你的创意内容",
|
||||
"placeholder": "输入关键词、故事大纲或简短创意...\n\n例如:\n• 古代宫廷 复仇 悬疑 女主角\n• 第一幕:女主回到京城,暗访旧宅;第二幕:宫廷宴会偶遇仇人之子",
|
||||
"hint": "💡 可以输入关键词、故事大纲、创意描述,AI 会根据你的输入扩展生成完整的故事内容",
|
||||
"startAiWrite": "开始 AI 创作",
|
||||
"cancel": "取消"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,4 +173,4 @@
|
||||
"confirm": "继续并清空",
|
||||
"cancel": "取消"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +65,10 @@
|
||||
"screenplayConvert": "剧本转换",
|
||||
"voiceAnalyze": "台词分析",
|
||||
"analyzeGlobal": "全局分析",
|
||||
"aiStoryExpand": "AI 故事扩写",
|
||||
"aiModifyAppearance": "角色描述修改",
|
||||
"aiModifyLocation": "场景描述修改",
|
||||
"aiModifyProp": "道具描述修改",
|
||||
"aiModifyShotPrompt": "镜头提示词修改",
|
||||
"analyzeShotVariants": "镜头变体分析",
|
||||
"aiCreateCharacter": "项目角色设计",
|
||||
@@ -79,6 +81,7 @@
|
||||
"assetHubAiDesignLocation": "资产库场景设计",
|
||||
"assetHubAiModifyCharacter": "资产库角色修改",
|
||||
"assetHubAiModifyLocation": "资产库场景修改",
|
||||
"assetHubAiModifyProp": "资产库道具修改",
|
||||
"assetHubReferenceToCharacter": "资产库参考图转角色"
|
||||
},
|
||||
"stage": {
|
||||
@@ -104,6 +107,8 @@
|
||||
"scriptToStoryboardStep": "执行分镜生成步骤",
|
||||
"scriptToStoryboardPersist": "保存分镜结果",
|
||||
"scriptToStoryboardPersistDone": "分镜与台词结果已保存",
|
||||
"aiStoryExpandPrepare": "准备故事扩写参数",
|
||||
"aiStoryExpandDone": "故事扩写已完成",
|
||||
"insertPanelGenerateText": "生成插入镜头文本",
|
||||
"insertPanelPersist": "保存插入镜头",
|
||||
"pollingExternal": "等待外部服务返回",
|
||||
|
||||
@@ -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": "配置模型,或在创建项目后于项目配置中自定义。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "waoowaoo",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "waoowaoo",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@ai-sdk/google": "^3.0.22",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "waoowaoo",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.1",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18.18.0",
|
||||
|
||||
11890
pnpm-lock.yaml
generated
11890
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `projects`
|
||||
DROP COLUMN `mode`;
|
||||
@@ -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())
|
||||
@@ -353,7 +354,6 @@ model Project {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
description String? @db.Text
|
||||
mode String @default("novel-promotion")
|
||||
userId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
@@ -900,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())
|
||||
@@ -347,7 +348,6 @@ model Project {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
description String?
|
||||
mode String @default("novel-promotion")
|
||||
userId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
@@ -737,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)
|
||||
|
||||
@@ -15,7 +15,8 @@ export const NORMALIZATION_HELPER_ALLOWLIST = new Set([
|
||||
const ACCEPTED_NORMALIZATION_MARKERS = [
|
||||
/\bnormalizeReferenceImagesForGeneration\s*\(/,
|
||||
/\bnormalizeToBase64ForGeneration\s*\(/,
|
||||
/\bgenerateLabeledImageToCos\s*\(/,
|
||||
/\bgenerateProjectLabeledImageToStorage\s*\(/,
|
||||
/\bgenerateCleanImageToStorage\s*\(/,
|
||||
]
|
||||
|
||||
function fail(title, details = []) {
|
||||
@@ -58,7 +59,7 @@ export function inspectImageReferenceNormalization(relPath, content) {
|
||||
if (!usesGenerationReferenceImages(content)) return []
|
||||
if (hasNormalizationMarker(content)) return []
|
||||
return [
|
||||
`${relPath} uses resolveImageSourceFromGeneration with referenceImages but does not reference normalizeReferenceImagesForGeneration/normalizeToBase64ForGeneration/generateLabeledImageToCos`,
|
||||
`${relPath} uses resolveImageSourceFromGeneration with referenceImages but does not reference normalizeReferenceImagesForGeneration/normalizeToBase64ForGeneration/generateProjectLabeledImageToStorage/generateCleanImageToStorage`,
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -4,20 +4,22 @@
|
||||
* 首页 - 创作中心
|
||||
* 用户登录后的主入口页面:快速创作 + 最近项目
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Navbar from '@/components/Navbar'
|
||||
import { AppIcon, IconGradientDefs } from '@/components/ui/icons'
|
||||
import { RatioSelector, StyleSelector } from '@/components/selectors/RatioStyleSelectors'
|
||||
import StoryInputComposer from '@/components/story-input/StoryInputComposer'
|
||||
import TypewriterHero from '@/components/home/TypewriterHero'
|
||||
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
|
||||
import { DEFAULT_STYLE_PRESET_VALUE, STYLE_PRESETS } from '@/lib/style-presets'
|
||||
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 {
|
||||
HOME_QUICK_START_MIN_ROWS,
|
||||
resolveTextareaTargetHeight,
|
||||
} from '@/lib/home/quick-start-textarea'
|
||||
import { formatDefaultProjectTimestamp } from '@/lib/projects/default-name'
|
||||
import { HOME_QUICK_START_MIN_ROWS } from '@/lib/ui/textarea-height'
|
||||
import AiWriteModal from '@/components/home/AiWriteModal'
|
||||
|
||||
interface ProjectStats {
|
||||
episodes: number
|
||||
@@ -49,46 +51,11 @@ export default function HomePage() {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [videoRatio, setVideoRatio] = useState('9:16')
|
||||
const [artStyle, setArtStyle] = useState('american-comic')
|
||||
const [stylePresetValue, setStylePresetValue] = useState<string>(DEFAULT_STYLE_PRESET_VALUE)
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const textareaMinHeightRef = useRef<number | null>(null)
|
||||
|
||||
// textarea 自适应高度(rAF 分帧动画)
|
||||
const autoResizeTextarea = useCallback(() => {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
const maxH = window.innerHeight * 0.5
|
||||
const oldH = el.offsetHeight
|
||||
const oldScrollTop = el.scrollTop
|
||||
if (textareaMinHeightRef.current === null && oldH > 0) {
|
||||
textareaMinHeightRef.current = oldH
|
||||
}
|
||||
const minH = textareaMinHeightRef.current ?? oldH
|
||||
|
||||
// 同步:测量真实高度(不改 overflow,避免 scrollTop 被重置)
|
||||
el.style.transition = 'none'
|
||||
el.style.height = 'auto'
|
||||
const scrollH = el.scrollHeight
|
||||
const targetH = resolveTextareaTargetHeight({
|
||||
minHeight: minH,
|
||||
maxHeight: maxH,
|
||||
scrollHeight: scrollH,
|
||||
})
|
||||
el.style.height = `${oldH}px`
|
||||
el.scrollTop = oldScrollTop
|
||||
|
||||
// 下一帧:开启 transition → 动画到目标高度
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = oldScrollTop
|
||||
el.style.transition = 'height 200ms ease-out'
|
||||
el.style.height = `${targetH}px`
|
||||
el.style.overflowY = scrollH > maxH ? 'auto' : 'hidden'
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
autoResizeTextarea()
|
||||
}, [inputValue, autoResizeTextarea])
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [aiWriteOpen, setAiWriteOpen] = useState(false)
|
||||
const [aiWriteLoading, setAiWriteLoading] = useState(false)
|
||||
|
||||
// 鉴权
|
||||
useEffect(() => {
|
||||
@@ -127,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,
|
||||
@@ -142,12 +112,32 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// AI 帮我写 — 直接生成文本并回填首页输入框
|
||||
const handleAiWriteStart = async (prompt: string) => {
|
||||
if (aiWriteLoading) return
|
||||
setAiWriteLoading(true)
|
||||
try {
|
||||
const result = await expandHomeStory({
|
||||
apiFetch,
|
||||
prompt,
|
||||
})
|
||||
|
||||
setInputValue(result.expandedText)
|
||||
setAiWriteOpen(false)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed'
|
||||
window.alert(message)
|
||||
} finally {
|
||||
setAiWriteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 比例选项(带推荐标签)
|
||||
const ratioOptions = useMemo(
|
||||
() => VIDEO_RATIOS.map((r) => ({ ...r, recommended: r.value === '9:16' })),
|
||||
@@ -159,7 +149,6 @@ export default function HomePage() {
|
||||
() => ART_STYLES.map((s) => ({ ...s, recommended: s.value === 'realistic' })),
|
||||
[]
|
||||
)
|
||||
|
||||
// 时间格式化
|
||||
const formatTimeAgo = (dateString: string): string => {
|
||||
const diffMs = Date.now() - new Date(dateString).getTime()
|
||||
@@ -204,82 +193,126 @@ export default function HomePage() {
|
||||
45% { transform: translate(-15px, -20px) scale(1.15); opacity: 0.7; }
|
||||
70% { transform: translate(10px, -10px) scale(1); opacity: 0.35; }
|
||||
}
|
||||
@keyframes bracket-breathe {
|
||||
0%, 70%, 100% { opacity: 0.2; }
|
||||
75%, 90% { opacity: 0.6; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<main className="flex flex-col items-center pt-[16vh] pb-12 px-4 max-w-3xl mx-auto w-full">
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="text-3xl font-bold text-[var(--glass-text-primary)] mb-2">
|
||||
✨ {t('title')}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--glass-text-tertiary)]">{t('subtitle')}</p>
|
||||
</div>
|
||||
<main className="flex flex-col items-center pt-[13vh] pb-12 px-4 max-w-5xl mx-auto w-full">
|
||||
|
||||
{/* 呼吸光晕 + 输入区域 */}
|
||||
<div className="w-full relative group">
|
||||
<div
|
||||
className="absolute -inset-10 rounded-[48px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 80% 60% at 30% 40%, rgba(6, 182, 212, 0.4), transparent 70%)',
|
||||
animation: 'breathe-drift-1 8s ease-in-out infinite',
|
||||
filter: 'blur(30px)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -inset-10 rounded-[48px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 70% 80% at 70% 60%, rgba(139, 92, 246, 0.35), transparent 70%)',
|
||||
animation: 'breathe-drift-2 10s ease-in-out infinite',
|
||||
filter: 'blur(35px)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -inset-12 rounded-[56px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 60% 50% at 50% 50%, rgba(59, 130, 246, 0.3), transparent 70%)',
|
||||
animation: 'breathe-drift-3 12s ease-in-out infinite',
|
||||
filter: 'blur(40px)',
|
||||
}}
|
||||
/>
|
||||
{/* ─── 取景器整体包裹:标题 + 输入框 ─── */}
|
||||
<div className="w-full relative p-5">
|
||||
{/* 四角校准线 */}
|
||||
<span className="absolute top-0 left-0 w-5 h-5 border-t border-l border-[var(--glass-text-primary)] pointer-events-none z-10" style={{ animation: 'bracket-breathe 8s ease-in-out infinite' }} />
|
||||
<span className="absolute top-0 right-0 w-5 h-5 border-t border-r border-[var(--glass-text-primary)] pointer-events-none z-10" style={{ animation: 'bracket-breathe 8s ease-in-out infinite' }} />
|
||||
<span className="absolute bottom-0 left-0 w-5 h-5 border-b border-l border-[var(--glass-text-primary)] pointer-events-none z-10" style={{ animation: 'bracket-breathe 8s ease-in-out infinite' }} />
|
||||
<span className="absolute bottom-0 right-0 w-5 h-5 border-b border-r border-[var(--glass-text-primary)] pointer-events-none z-10" style={{ animation: 'bracket-breathe 8s ease-in-out infinite' }} />
|
||||
|
||||
<div className="relative w-full glass-surface-elevated rounded-2xl">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
rows={HOME_QUICK_START_MIN_ROWS}
|
||||
className="w-full bg-transparent border-none outline-none text-[var(--glass-text-primary)] placeholder:text-[var(--glass-text-tertiary)] text-base resize-none p-5 pb-3 custom-scrollbar"
|
||||
{/* REC 录制指示灯 */}
|
||||
<span
|
||||
className="absolute top-2 right-7 flex items-center gap-1 z-10"
|
||||
style={{ animation: 'bracket-breathe 2s ease-in-out infinite' }}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500 shadow-[0_0_4px_rgba(239,68,68,0.7)]" />
|
||||
<span className="text-[8px] font-mono font-bold tracking-widest text-red-500/70">REC</span>
|
||||
</span>
|
||||
|
||||
{/* 标题区 */}
|
||||
<TypewriterHero title={t('title')} subtitle={t('subtitle')} />
|
||||
|
||||
{/* 呼吸光晕 + 输入区域 */}
|
||||
<div className="w-full relative group">
|
||||
<div
|
||||
className="absolute -inset-10 rounded-[48px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 80% 60% at 30% 40%, rgba(6, 182, 212, 0.4), transparent 70%)',
|
||||
animation: 'breathe-drift-1 8s ease-in-out infinite',
|
||||
filter: 'blur(30px)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -inset-10 rounded-[48px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 70% 80% at 70% 60%, rgba(139, 92, 246, 0.35), transparent 70%)',
|
||||
animation: 'breathe-drift-2 10s ease-in-out infinite',
|
||||
filter: 'blur(35px)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -inset-12 rounded-[56px] pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse 60% 50% at 50% 50%, rgba(59, 130, 246, 0.3), transparent 70%)',
|
||||
animation: 'breathe-drift-3 12s ease-in-out infinite',
|
||||
filter: 'blur(40px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 底部工具栏:比例 + 风格 + 创建按钮 */}
|
||||
<div className="flex items-end gap-3 px-5 pb-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-[160px] flex-shrink-0">
|
||||
<RatioSelector
|
||||
value={videoRatio}
|
||||
onChange={setVideoRatio}
|
||||
options={ratioOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[160px] flex-shrink-0">
|
||||
<StyleSelector
|
||||
value={artStyle}
|
||||
onChange={setArtStyle}
|
||||
options={styleOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={!inputValue.trim() || createLoading}
|
||||
className="glass-btn-base glass-btn-primary px-5 py-2.5 text-sm flex-shrink-0 disabled:opacity-50"
|
||||
>
|
||||
{createLoading ? tc('loading') : t('startCreation')}
|
||||
<AppIcon name="arrowRight" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<StoryInputComposer
|
||||
value={inputValue}
|
||||
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}
|
||||
artStyle={artStyle}
|
||||
onArtStyleChange={setArtStyle}
|
||||
styleOptions={styleOptions}
|
||||
stylePresetValue={stylePresetValue}
|
||||
onStylePresetChange={setStylePresetValue}
|
||||
stylePresetOptions={STYLE_PRESETS}
|
||||
primaryAction={(
|
||||
<button
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={!inputValue.trim() || createLoading}
|
||||
className="glass-btn-base glass-btn-primary h-10 flex-shrink-0 px-5 text-sm disabled:opacity-50"
|
||||
>
|
||||
{createLoading ? tc('loading') : t('startCreation')}
|
||||
<AppIcon name="arrowRight" className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
secondaryActions={(
|
||||
<button
|
||||
onClick={() => setAiWriteOpen(true)}
|
||||
disabled={createLoading}
|
||||
className="glass-btn-base flex h-10 flex-shrink-0 items-center gap-1.5 border border-[var(--glass-stroke-strong)] px-3 text-sm transition-all hover:border-[var(--glass-tone-info-fg)]/40"
|
||||
>
|
||||
<AppIcon name="sparkles" className="w-4 h-4 text-[#7c3aed]" />
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{t('aiWrite.trigger')}
|
||||
</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>
|
||||
{/* AI 帮我写模态框 */}
|
||||
<AiWriteModal
|
||||
open={aiWriteOpen}
|
||||
loading={aiWriteLoading}
|
||||
onClose={() => setAiWriteOpen(false)}
|
||||
onStart={(prompt) => void handleAiWriteStart(prompt)}
|
||||
t={(key: string) => t(`aiWrite.${key}`)}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* 最近项目 */}
|
||||
|
||||
@@ -262,8 +262,7 @@ export function ProviderAdvancedFields({
|
||||
|
||||
<div className="glass-surface-soft rounded-xl p-2">
|
||||
<div
|
||||
className="glass-provider-model-scroll h-[280px] overflow-y-auto pr-1"
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
className="app-scrollbar h-[280px] overflow-y-auto pr-1"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{currentModels.map((model, index) => (
|
||||
|
||||
@@ -136,7 +136,8 @@ export const PRESET_MODELS: PresetModel[] = [
|
||||
{ modelId: 'doubao-seedance-1-0-pro-fast-251015', name: 'Seedance 1.0 Pro Fast', type: 'video', provider: 'ark' },
|
||||
{ modelId: 'doubao-seedance-1-0-lite-i2v-250428', name: 'Seedance 1.0 Lite', type: 'video', provider: 'ark' },
|
||||
{ modelId: 'doubao-seedance-1-5-pro-251215', name: 'Seedance 1.5 Pro', type: 'video', provider: 'ark' },
|
||||
{ modelId: 'doubao-seedance-2-0-260128', name: 'Seedance 2.0(待上线)', type: 'video', provider: 'ark' },
|
||||
{ modelId: 'doubao-seedance-2-0-260128', name: 'Seedance 2.0', type: 'video', provider: 'ark' },
|
||||
{ modelId: 'doubao-seedance-2-0-fast-260128', name: 'Seedance 2.0 Fast', type: 'video', provider: 'ark' },
|
||||
{ modelId: 'doubao-seedance-1-0-pro-250528', name: 'Seedance 1.0 Pro', type: 'video', provider: 'ark' },
|
||||
// Google Veo
|
||||
{ modelId: 'veo-3.1-generate-preview', name: 'Veo 3.1', type: 'video', provider: 'google' },
|
||||
@@ -145,6 +146,7 @@ export const PRESET_MODELS: PresetModel[] = [
|
||||
{ modelId: 'veo-3.0-fast-generate-001', name: 'Veo 3.0 Fast', type: 'video', provider: 'google' },
|
||||
{ modelId: 'veo-2.0-generate-001', name: 'Veo 2.0', type: 'video', provider: 'google' },
|
||||
// 阿里云百炼图生视频模型
|
||||
{ modelId: 'wan2.7-i2v', name: 'Wan2.7 I2V', type: 'video', provider: 'bailian' },
|
||||
{ modelId: 'wan2.6-i2v-flash', name: 'Wan2.6 I2V Flash', type: 'video', provider: 'bailian' },
|
||||
{ modelId: 'wan2.6-i2v', name: 'Wan2.6 I2V', type: 'video', provider: 'bailian' },
|
||||
{ modelId: 'wan2.5-i2v-preview', name: 'Wan2.5 I2V Preview', type: 'video', provider: 'bailian' },
|
||||
@@ -184,9 +186,7 @@ export const PRESET_MODELS: PresetModel[] = [
|
||||
{ modelId: 'vidu2.0', name: 'Vidu 2.0', type: 'video', provider: 'vidu' },
|
||||
]
|
||||
|
||||
const PRESET_COMING_SOON_MODEL_KEYS = new Set<string>([
|
||||
encodeModelKey('ark', 'doubao-seedance-2-0-260128'),
|
||||
])
|
||||
const PRESET_COMING_SOON_MODEL_KEYS = new Set<string>([])
|
||||
|
||||
export function isPresetComingSoonModel(provider: string, modelId: string): boolean {
|
||||
return PRESET_COMING_SOON_MODEL_KEYS.has(encodeModelKey(provider, modelId))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -463,7 +463,7 @@ export default function AssetsStage({
|
||||
onRegenerateGroup={handleRegenerateLocationGroup}
|
||||
onUndo={handleUndoLocation}
|
||||
onImageClick={setPreviewImage}
|
||||
onImageEdit={(locId, imgIdx) => handleOpenLocationImageEdit(locId, imgIdx)}
|
||||
onImageEdit={(locId, imgIdx) => handleOpenLocationImageEdit(locId, imgIdx, 'location')}
|
||||
onCopyFromGlobal={handleCopyLocationFromGlobal}
|
||||
filterIds={episodeAssetIds?.locIds ?? null}
|
||||
/>
|
||||
@@ -488,7 +488,7 @@ export default function AssetsStage({
|
||||
void propAssetActions.revertRender({ id: propId }).catch(() => undefined)
|
||||
}}
|
||||
onImageClick={setPreviewImage}
|
||||
onImageEdit={() => undefined}
|
||||
onImageEdit={(propId, imgIdx) => handleOpenLocationImageEdit(propId, imgIdx, 'prop')}
|
||||
onCopyFromGlobal={handleCopyPropFromGlobal}
|
||||
filterIds={episodeAssetIds?.propIds ?? null}
|
||||
/>
|
||||
|
||||
@@ -8,11 +8,17 @@
|
||||
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'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { RatioSelector, StyleSelector } from '@/components/selectors/RatioStyleSelectors'
|
||||
import { DEFAULT_STYLE_PRESET_VALUE, STYLE_PRESETS } from '@/lib/style-presets'
|
||||
import { PROJECT_STORY_INPUT_MIN_ROWS } from '@/lib/ui/textarea-height'
|
||||
import { apiFetch } from '@/lib/api-fetch'
|
||||
import { expandHomeStory } from '@/lib/home/ai-story-expand'
|
||||
|
||||
/** 触发智能分集建议的字数阈值 */
|
||||
const LONG_TEXT_THRESHOLD = 1000
|
||||
@@ -58,6 +64,7 @@ export default function NovelInputStage({
|
||||
onArtStyleChange
|
||||
}: NovelInputStageProps) {
|
||||
const t = useTranslations('novelPromotion')
|
||||
const homeT = useTranslations('home')
|
||||
|
||||
// ── IME 组合输入处理 ──
|
||||
// 中文/日文/韩文输入法在组合(composing)期间会持续触发 onChange,
|
||||
@@ -66,6 +73,9 @@ export default function NovelInputStage({
|
||||
// 解决方案:组合期间仅更新本地 state,组合结束后再同步到父组件。
|
||||
const isComposingRef = useRef(false)
|
||||
const [localText, setLocalText] = useState(novelText)
|
||||
const [stylePresetValue, setStylePresetValue] = useState<string>(DEFAULT_STYLE_PRESET_VALUE)
|
||||
const [aiWriteOpen, setAiWriteOpen] = useState(false)
|
||||
const [aiWriteLoading, setAiWriteLoading] = useState(false)
|
||||
|
||||
// 当父组件的 novelText 变化(非本地编辑触发)时,同步到本地 state
|
||||
useEffect(() => {
|
||||
@@ -74,15 +84,6 @@ export default function NovelInputStage({
|
||||
}
|
||||
}, [novelText])
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value
|
||||
setLocalText(newValue)
|
||||
// 仅在非 IME 组合状态下才同步到父组件
|
||||
if (!isComposingRef.current) {
|
||||
onNovelTextChange(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
isComposingRef.current = true
|
||||
}
|
||||
@@ -106,23 +107,25 @@ export default function NovelInputStage({
|
||||
}
|
||||
}, [localText, onNext, onSmartSplit])
|
||||
|
||||
// 当前配置展示文案
|
||||
const ratioDisplayLabel = (VIDEO_RATIOS.find((option) => option.value === videoRatio) ?? VIDEO_RATIOS[0])?.label
|
||||
const artStyleDisplayLabel = (ART_STYLES.find((option) => option.value === artStyle) ?? ART_STYLES[0])?.label
|
||||
const handleAiWriteStart = useCallback(async (prompt: string) => {
|
||||
if (aiWriteLoading) return
|
||||
setAiWriteLoading(true)
|
||||
try {
|
||||
const result = await expandHomeStory({
|
||||
apiFetch,
|
||||
prompt,
|
||||
})
|
||||
|
||||
// 不同比例适合的素材类型文案映射(完整句子,用于 info 悬浮层)
|
||||
const ratioUsageTextMap: Record<string, string> = {
|
||||
'1:1': t('storyInput.ratioUsage.1_1'),
|
||||
'9:16': t('storyInput.ratioUsage.9_16'),
|
||||
'16:9': t('storyInput.ratioUsage.16_9'),
|
||||
'4:3': t('storyInput.ratioUsage.4_3'),
|
||||
'3:4': t('storyInput.ratioUsage.3_4'),
|
||||
'2:3': t('storyInput.ratioUsage.2_3'),
|
||||
'3:2': t('storyInput.ratioUsage.3_2'),
|
||||
'4:5': t('storyInput.ratioUsage.4_5'),
|
||||
'5:4': t('storyInput.ratioUsage.5_4'),
|
||||
'21:9': t('storyInput.ratioUsage.21_9'),
|
||||
}
|
||||
setLocalText(result.expandedText)
|
||||
onNovelTextChange(result.expandedText)
|
||||
setAiWriteOpen(false)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed'
|
||||
window.alert(message)
|
||||
} finally {
|
||||
setAiWriteLoading(false)
|
||||
}
|
||||
}, [aiWriteLoading, onNovelTextChange])
|
||||
|
||||
// 下拉中使用的简短标签(低信息密度)
|
||||
const ratioUsageTagMap: Record<string, string> = {
|
||||
@@ -138,13 +141,9 @@ export default function NovelInputStage({
|
||||
'21:9': t('storyInput.ratioUsageTag.21_9'),
|
||||
}
|
||||
|
||||
const getRatioUsageText = (ratio: string): string =>
|
||||
ratioUsageTextMap[ratio] ?? t('storyInput.videoRatioHint')
|
||||
|
||||
const getRatioUsageTag = (ratio: string): string =>
|
||||
ratioUsageTagMap[ratio] ?? ''
|
||||
|
||||
const ratioUsageText = getRatioUsageText(videoRatio)
|
||||
const stageSwitchingState = isSwitchingStage
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
@@ -168,81 +167,82 @@ export default function NovelInputStage({
|
||||
)}
|
||||
|
||||
{/* 主输入区域(含底部工具栏) */}
|
||||
<div className="glass-surface-elevated overflow-hidden relative z-10">
|
||||
<div className="p-6 pb-0">
|
||||
{/* 字数统计 */}
|
||||
<div className="flex items-center justify-end mb-3">
|
||||
<span className="glass-chip glass-chip-neutral text-xs">
|
||||
{t("storyInput.wordCount")} {localText.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 剧本输入框 */}
|
||||
<textarea
|
||||
value={localText}
|
||||
onChange={handleTextChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
placeholder={`请输入您的剧本或小说内容...\n\nAI 将根据您的文本智能分析:\n• 自动识别场景切换\n• 提取角色对话和动作\n• 生成分镜脚本\n\n例如:\n清晨,阳光透过窗帘洒进房间。小明揉着惺忪的睡眼从床上坐起,看了一眼床头的闹钟——已经八点了!他猛地跳下床,手忙脚乱地开始穿衣服...`}
|
||||
className="glass-textarea-base custom-scrollbar h-80 px-4 py-3 text-base resize-none placeholder:text-[var(--glass-text-tertiary)]"
|
||||
disabled={isSubmittingTask || isSwitchingStage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部工具栏:比例 + 风格 + 开始创作(内嵌在输入框卡片内) */}
|
||||
<div className="flex items-end gap-3 px-6 py-4">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-[160px] flex-shrink-0">
|
||||
<RatioSelector
|
||||
value={videoRatio}
|
||||
onChange={(value) => onVideoRatioChange?.(value)}
|
||||
options={VIDEO_RATIOS.map((option) => ({
|
||||
...option,
|
||||
recommended: option.value === '9:16'
|
||||
}))}
|
||||
getUsage={getRatioUsageTag}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[160px] flex-shrink-0">
|
||||
<StyleSelector
|
||||
value={artStyle}
|
||||
onChange={(value) => onArtStyleChange?.(value)}
|
||||
options={ART_STYLES.map((option) => ({
|
||||
...option,
|
||||
recommended: option.value === 'realistic'
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleStartClick}
|
||||
disabled={!hasContent || isSubmittingTask || isSwitchingStage}
|
||||
className="glass-btn-base glass-btn-primary px-5 py-2.5 text-sm flex-shrink-0 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSwitchingStage ? (
|
||||
<TaskStatusInline state={stageSwitchingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
<>
|
||||
<span>{t("smartImport.manualCreate.button")}</span>
|
||||
<AppIcon name="arrowRight" className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 配置提示 */}
|
||||
<div className="px-6 pb-4 space-y-1 text-center">
|
||||
<p className="text-xs text-[var(--glass-text-secondary)]">
|
||||
{t("storyInput.currentConfigSummary", {
|
||||
ratio: ratioDisplayLabel,
|
||||
style: artStyleDisplayLabel
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)]">
|
||||
{t("storyInput.moreConfig")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<StoryInputComposer
|
||||
value={localText}
|
||||
onValueChange={(value) => {
|
||||
setLocalText(value)
|
||||
if (!isComposingRef.current) {
|
||||
onNovelTextChange(value)
|
||||
}
|
||||
}}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
placeholder={`请输入您的剧本或小说内容...\n\nAI 将根据您的文本智能分析:\n• 自动识别场景切换\n• 提取角色对话和动作\n• 生成分镜脚本\n\n例如:\n清晨,阳光透过窗帘洒进房间。小明揉着惺忪的睡眼从床上坐起,看了一眼床头的闹钟——已经八点了!他猛地跳下床,手忙脚乱地开始穿衣服...`}
|
||||
minRows={PROJECT_STORY_INPUT_MIN_ROWS}
|
||||
maxHeightViewportRatio={0.5}
|
||||
disabled={isSubmittingTask || isSwitchingStage}
|
||||
videoRatio={videoRatio}
|
||||
onVideoRatioChange={(value) => onVideoRatioChange?.(value)}
|
||||
ratioOptions={VIDEO_RATIOS.map((option) => ({
|
||||
...option,
|
||||
recommended: option.value === '9:16'
|
||||
}))}
|
||||
getRatioUsage={getRatioUsageTag}
|
||||
artStyle={artStyle}
|
||||
onArtStyleChange={(value) => onArtStyleChange?.(value)}
|
||||
styleOptions={ART_STYLES.map((option) => ({
|
||||
...option,
|
||||
recommended: option.value === 'realistic'
|
||||
}))}
|
||||
stylePresetValue={stylePresetValue}
|
||||
onStylePresetChange={setStylePresetValue}
|
||||
stylePresetOptions={STYLE_PRESETS}
|
||||
textareaClassName="px-0 pt-0 pb-3 align-top"
|
||||
primaryAction={(
|
||||
<button
|
||||
onClick={handleStartClick}
|
||||
disabled={!hasContent || isSubmittingTask || isSwitchingStage}
|
||||
className="glass-btn-base glass-btn-primary h-10 flex-shrink-0 px-5 text-sm disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSwitchingStage ? (
|
||||
<TaskStatusInline state={stageSwitchingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
<>
|
||||
<span>{t("smartImport.manualCreate.button")}</span>
|
||||
<AppIcon name="arrowRight" className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
secondaryActions={(
|
||||
<button
|
||||
onClick={() => setAiWriteOpen(true)}
|
||||
disabled={isSubmittingTask || isSwitchingStage}
|
||||
className="glass-btn-base flex h-10 flex-shrink-0 items-center gap-1.5 border border-[var(--glass-stroke-strong)] px-3 text-sm transition-all hover:border-[var(--glass-tone-info-fg)]/40"
|
||||
>
|
||||
<AppIcon name="sparkles" className="w-4 h-4 text-[#7c3aed]" />
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{homeT('aiWrite.trigger')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<AiWriteModal
|
||||
open={aiWriteOpen}
|
||||
loading={aiWriteLoading}
|
||||
onClose={() => setAiWriteOpen(false)}
|
||||
onStart={(prompt) => void handleAiWriteStart(prompt)}
|
||||
t={(key: string) => homeT(`aiWrite.${key}`)}
|
||||
/>
|
||||
|
||||
{/* 资产库引导提示 */}
|
||||
<div className="glass-surface p-4">
|
||||
@@ -286,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
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function WorkspaceAssetLibraryModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar" data-asset-scroll-container="1">
|
||||
<div className="flex-1 overflow-y-auto p-6 app-scrollbar" data-asset-scroll-container="1">
|
||||
{assetsLoading && !hasCharacters && !hasLocations && (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-[var(--glass-text-tertiary)] animate-pulse">
|
||||
<TaskStatusInline state={assetsLoadingState} className="text-base [&>span]:text-base" />
|
||||
|
||||
@@ -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()
|
||||
@@ -129,8 +133,8 @@ export default function AddLocationModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-[var(--glass-overlay)] flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-[var(--glass-bg-surface)] rounded-xl shadow-xl max-w-2xl w-full max-h-[85vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="bg-[var(--glass-bg-surface)] rounded-xl shadow-xl max-w-2xl w-full max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<div className="p-6 overflow-y-auto app-scrollbar flex-1 min-h-0">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">
|
||||
|
||||
@@ -35,10 +35,12 @@ interface EditingPropState {
|
||||
propId: string
|
||||
propName: string
|
||||
summary: string
|
||||
description: string
|
||||
variantId?: string
|
||||
}
|
||||
|
||||
interface LocationImageEditModalState {
|
||||
assetType: 'location' | 'prop'
|
||||
locationName: string
|
||||
}
|
||||
|
||||
@@ -140,7 +142,7 @@ export default function AssetsStageModals({
|
||||
|
||||
{imageEditModal && (
|
||||
<ImageEditModal
|
||||
type="location"
|
||||
type={imageEditModal.assetType}
|
||||
name={imageEditModal.locationName}
|
||||
onClose={closeImageEditModal}
|
||||
onConfirm={handleLocationImageEdit}
|
||||
@@ -238,6 +240,7 @@ export default function AssetsStageModals({
|
||||
propId={editingProp.propId}
|
||||
propName={editingProp.propName}
|
||||
summary={editingProp.summary}
|
||||
description={editingProp.description}
|
||||
variantId={editingProp.variantId}
|
||||
projectId={projectId}
|
||||
onClose={closeEditingProp}
|
||||
|
||||
@@ -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
|
||||
@@ -451,6 +454,7 @@ export default function CharacterCard({
|
||||
mode="single"
|
||||
characterName={character.name}
|
||||
changeReason={appearance.changeReason}
|
||||
aspectClassName="aspect-[3/2]"
|
||||
currentImageUrl={currentImageUrl}
|
||||
selectedIndex={selectedIndex}
|
||||
hasMultipleImages={hasMultipleImages}
|
||||
|
||||
@@ -101,11 +101,11 @@ export default function CharacterProfileDialog({
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--glass-overlay)]" onClick={onClose}>
|
||||
<div
|
||||
className="bg-[var(--glass-bg-surface)] rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto m-4"
|
||||
className="bg-[var(--glass-bg-surface)] rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col m-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="sticky top-0 bg-[var(--glass-bg-surface)] border-b border-[var(--glass-stroke-base)] px-6 py-4 flex items-center justify-between">
|
||||
<div className="bg-[var(--glass-bg-surface)] border-b border-[var(--glass-stroke-base)] px-6 py-4 flex items-center justify-between shrink-0">
|
||||
<h2 className="text-xl font-semibold text-[var(--glass-text-primary)]">{t('characterProfile.editDialogTitle', { name: characterName })}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -116,7 +116,7 @@ export default function CharacterProfileDialog({
|
||||
</div>
|
||||
|
||||
{/* 表单内容 */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="p-6 space-y-4 overflow-y-auto app-scrollbar flex-1 min-h-0">
|
||||
{/* 角色层级 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--glass-text-secondary)] mb-2">{t('characterProfile.importanceLevel')}</label>
|
||||
@@ -261,7 +261,7 @@ export default function CharacterProfileDialog({
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="sticky bottom-0 bg-[var(--glass-bg-surface)] border-t border-[var(--glass-stroke-base)] px-6 py-4 flex gap-3 justify-end">
|
||||
<div className="bg-[var(--glass-bg-surface)] border-t border-[var(--glass-stroke-base)] px-6 py-4 flex gap-3 justify-end shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function CharacterSection({
|
||||
if (!element) return
|
||||
const scrollContainer = (element.closest('[data-asset-scroll-container="1"]') ||
|
||||
document.querySelector('[data-asset-scroll-container="1"]') ||
|
||||
element.closest('.custom-scrollbar')) as HTMLElement | null
|
||||
element.closest('.app-scrollbar')) as HTMLElement | null
|
||||
|
||||
if (scrollAnimationRef.current !== null) {
|
||||
window.cancelAnimationFrame(scrollAnimationRef.current)
|
||||
@@ -287,18 +287,18 @@ export default function CharacterSection({
|
||||
{t("character.assetCount", { count: sortedAppearances.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 从资产中心复制按钮 */}
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
{/* 从资产中心导入按钮 */}
|
||||
<button
|
||||
onClick={() => onCopyFromGlobal(character.id)}
|
||||
className="text-xs text-[var(--glass-tone-info-fg)] hover:text-[var(--glass-tone-info-fg)] flex items-center gap-1 px-2 py-1 rounded-lg hover:bg-[var(--glass-tone-info-bg)] transition-colors"
|
||||
>
|
||||
<AppIcon name="copy" className="w-4 h-4" />
|
||||
<AppIcon name="arrowDownCircle" className="w-4 h-4" />
|
||||
{t("character.copyFromGlobal")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteCharacter(character.id)}
|
||||
className="text-xs text-[var(--glass-tone-danger-fg)] hover:text-[var(--glass-tone-danger-fg)] flex items-center gap-1"
|
||||
className="text-xs text-[var(--glass-tone-danger-fg)] hover:text-[var(--glass-tone-danger-fg)] flex items-center gap-1 px-2 py-1 rounded-lg hover:bg-[var(--glass-tone-danger-bg)] transition-colors"
|
||||
>
|
||||
<AppIcon name="trash" className="w-4 h-4" />
|
||||
{t("character.delete")}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useState, useRef } from 'react'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
interface ImageEditModalProps {
|
||||
type: 'character' | 'location'
|
||||
type: 'character' | 'location' | 'prop'
|
||||
name: string
|
||||
onClose: () => void
|
||||
onConfirm: (modifyPrompt: string, extraImageUrls?: string[]) => void
|
||||
@@ -28,10 +28,16 @@ export default function ImageEditModal({
|
||||
const [editImages, setEditImages] = useState<string[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const title = type === 'character' ? t('imageEdit.editCharacterImage') : t('imageEdit.editLocationImage')
|
||||
const title = type === 'character'
|
||||
? t('imageEdit.editCharacterImage')
|
||||
: type === 'prop'
|
||||
? t('imageEdit.editPropImage')
|
||||
: t('imageEdit.editLocationImage')
|
||||
const subtitle = type === 'character'
|
||||
? t('imageEdit.characterLabel', { name })
|
||||
: t('imageEdit.locationLabel', { name })
|
||||
: type === 'prop'
|
||||
? t('imageEdit.propLabel', { name })
|
||||
: t('imageEdit.locationLabel', { name })
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!modifyPrompt.trim()) {
|
||||
@@ -88,14 +94,14 @@ export default function ImageEditModal({
|
||||
return (
|
||||
<div className="fixed inset-0 bg-[var(--glass-overlay)] z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="bg-[var(--glass-bg-surface)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto"
|
||||
className="bg-[var(--glass-bg-surface)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col"
|
||||
onPaste={handlePaste}
|
||||
>
|
||||
<div className="p-6 border-b">
|
||||
<div className="p-6 border-b shrink-0">
|
||||
<h3 className="text-lg font-bold text-[var(--glass-text-primary)]">{title}</h3>
|
||||
<p className="text-sm text-[var(--glass-text-tertiary)] mt-1">{subtitle} · {t('imageEdit.subtitle')}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="p-6 space-y-4 overflow-y-auto app-scrollbar flex-1 min-h-0">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--glass-text-secondary)] mb-2">{t('imageEdit.editInstruction')}</label>
|
||||
<textarea
|
||||
@@ -103,6 +109,8 @@ export default function ImageEditModal({
|
||||
onChange={(e) => setModifyPrompt(e.target.value)}
|
||||
placeholder={type === 'character'
|
||||
? t('imageEdit.characterPlaceholder')
|
||||
: type === 'prop'
|
||||
? t('imageEdit.propPlaceholder')
|
||||
: t('imageEdit.locationPlaceholder')
|
||||
}
|
||||
className="w-full h-24 px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)] resize-none"
|
||||
|
||||
@@ -20,6 +20,8 @@ import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
|
||||
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
|
||||
import { countGeneratedImageSlots, resolveDisplayImageSlots } from '@/lib/image-generation/slot-state'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { AI_EDIT_BUTTON_CLASS, AI_EDIT_ICON_CLASS } from '@/components/ui/ai-edit-style'
|
||||
import AISparklesIcon from '@/components/ui/icons/AISparklesIcon'
|
||||
import { canGenerateLocationBackedAsset } from './location-backed-asset'
|
||||
|
||||
interface LocationCardProps {
|
||||
@@ -37,7 +39,7 @@ interface LocationCardProps {
|
||||
activeTaskKeys?: Set<string>
|
||||
onClearTaskKey?: (key: string) => void
|
||||
projectId: string
|
||||
onConfirmSelection?: (locationId: string) => void
|
||||
onConfirmSelection?: (locationId: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
export default function LocationCard({
|
||||
@@ -170,7 +172,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
|
||||
@@ -179,6 +181,7 @@ export default function LocationCard({
|
||||
const hasPreviousVersion = location.images?.some(img => img.previousImageUrl) || false
|
||||
|
||||
const showSelectionMode = displaySlotCount > 1
|
||||
const singleImageAspectClassName = assetType === 'prop' ? 'aspect-[3/2]' : 'aspect-square'
|
||||
|
||||
// 选择模式:显示名字在上,三张图片在下
|
||||
if (showSelectionMode) {
|
||||
@@ -192,22 +195,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
|
||||
@@ -269,7 +274,9 @@ export default function LocationCard({
|
||||
onConfirmSelection={selectedIndex !== null && onConfirmSelection
|
||||
? () => {
|
||||
setIsConfirmingSelection(true)
|
||||
onConfirmSelection(location.id)
|
||||
void Promise.resolve(onConfirmSelection(location.id)).finally(() => {
|
||||
setIsConfirmingSelection(false)
|
||||
})
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
@@ -295,11 +302,10 @@ export default function LocationCard({
|
||||
{!isTaskRunning && currentImageUrl && onImageEdit && (
|
||||
<button
|
||||
onClick={() => onImageEdit(location.id, currentImageIndex)}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center transition-all shadow-sm"
|
||||
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }}
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center transition-all active:scale-95 ${AI_EDIT_BUTTON_CLASS}`}
|
||||
title={t('image.edit')}
|
||||
>
|
||||
<AppIcon name="edit" className="w-4 h-4 text-white" />
|
||||
<AISparklesIcon className={`w-4 h-4 ${AI_EDIT_ICON_CLASS}`} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -338,7 +344,7 @@ export default function LocationCard({
|
||||
className="flex-shrink-0 w-5 h-5 rounded hover:bg-[var(--glass-tone-info-bg)] flex items-center justify-center transition-colors"
|
||||
title={t('character.copyFromGlobal')}
|
||||
>
|
||||
<AppIcon name="copy" className="w-3.5 h-3.5 text-[var(--glass-tone-info-fg)]" />
|
||||
<AppIcon name="arrowDownCircle" className="w-3.5 h-3.5 text-[var(--glass-tone-info-fg)]" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -359,7 +365,7 @@ export default function LocationCard({
|
||||
)
|
||||
|
||||
const firstImage = location.images?.[0]
|
||||
const canGenerate = canGenerateLocationBackedAsset(location)
|
||||
const canGenerate = canGenerateLocationBackedAsset(location, assetType)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 glass-surface-elevated p-3">
|
||||
@@ -374,6 +380,7 @@ export default function LocationCard({
|
||||
<LocationImageList
|
||||
mode="single"
|
||||
locationName={location.name}
|
||||
aspectClassName={singleImageAspectClassName}
|
||||
currentImageUrl={currentImageUrl}
|
||||
selectedIndex={selectedIndex}
|
||||
hasMultipleImages={hasMultipleImages}
|
||||
|
||||
@@ -29,7 +29,7 @@ interface LocationSectionProps {
|
||||
// 🔥 V6.6 重构:重命名为 handleGenerateImage
|
||||
handleGenerateImage: (type: 'character' | 'location' | 'prop', id: string, appearanceId?: string, count?: number) => Promise<void>
|
||||
onSelectImage: (locationId: string, imageIndex: number | null) => void
|
||||
onConfirmSelection: (locationId: string) => void
|
||||
onConfirmSelection: (locationId: string) => Promise<void> | void
|
||||
onRegenerateSingle: (locationId: string, imageIndex: number) => Promise<void>
|
||||
onRegenerateGroup: (locationId: string, count?: number) => Promise<void>
|
||||
onUndo: (locationId: string) => void
|
||||
@@ -149,7 +149,7 @@ export default function LocationSection({
|
||||
activeTaskKeys={activeTaskKeys}
|
||||
onClearTaskKey={onClearTaskKey}
|
||||
projectId={projectId}
|
||||
onConfirmSelection={assetType === 'location' ? onConfirmSelection : undefined}
|
||||
onConfirmSelection={onConfirmSelection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ type CharacterCardGalleryProps =
|
||||
mode: 'single'
|
||||
characterName: string
|
||||
changeReason: string
|
||||
aspectClassName: string
|
||||
currentImageUrl: string | null | undefined
|
||||
selectedIndex: number | null
|
||||
hasMultipleImages: boolean
|
||||
@@ -105,14 +106,14 @@ export default function CharacterCardGallery(props: CharacterCardGalleryProps) {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden border-2 border-[var(--glass-stroke-base)] relative">
|
||||
<div className={`relative overflow-hidden rounded-lg border-2 border-[var(--glass-stroke-base)] ${props.aspectClassName}`}>
|
||||
{props.currentImageUrl ? (
|
||||
<div className="relative w-full">
|
||||
<div className="relative h-full w-full">
|
||||
<MediaImageWithLoading
|
||||
src={props.currentImageUrl}
|
||||
alt={`${props.characterName} - ${props.changeReason}`}
|
||||
containerClassName="w-full min-h-[120px]"
|
||||
className="w-full h-auto object-contain cursor-pointer hover:opacity-90 transition-opacity"
|
||||
containerClassName="h-full w-full"
|
||||
className="h-full w-full object-contain cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => props.onImageClick(props.currentImageUrl!)}
|
||||
/>
|
||||
{props.selectedIndex !== null && props.hasMultipleImages && (
|
||||
@@ -122,7 +123,7 @@ export default function CharacterCardGallery(props: CharacterCardGalleryProps) {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full bg-[var(--glass-bg-muted)] flex items-center justify-center">
|
||||
<div className="flex h-full w-full items-center justify-center bg-[var(--glass-bg-muted)]">
|
||||
{appearanceErrorDisplay && !props.isAppearanceTaskRunning ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
|
||||
<AppIcon name="alert" className="w-8 h-8 text-[var(--glass-tone-danger-fg)] mb-2" />
|
||||
|
||||
@@ -31,10 +31,12 @@ interface EditingProp {
|
||||
propId: string
|
||||
propName: string
|
||||
summary: string
|
||||
description: string
|
||||
variantId?: string
|
||||
}
|
||||
|
||||
interface ImageEditModal {
|
||||
assetType: 'location' | 'prop'
|
||||
locationId: string
|
||||
imageIndex: number
|
||||
locationName: string
|
||||
@@ -137,20 +139,24 @@ export function useAssetModals({
|
||||
}
|
||||
|
||||
const handleEditProp = (prop: Prop) => {
|
||||
const firstImage = prop.images?.[0]
|
||||
setEditingProp({
|
||||
propId: prop.id,
|
||||
propName: prop.name,
|
||||
summary: prop.summary || prop.images?.[0]?.description || '',
|
||||
variantId: prop.images?.[0]?.id,
|
||||
summary: prop.summary || '',
|
||||
description: firstImage?.description || prop.summary || '',
|
||||
variantId: firstImage?.id,
|
||||
})
|
||||
}
|
||||
|
||||
// 打开场景图片编辑弹窗
|
||||
const handleOpenLocationImageEdit = (locationId: string, imageIndex: number) => {
|
||||
const location = locations.find(l => l.id === locationId)
|
||||
const handleOpenLocationImageEdit = (locationId: string, imageIndex: number, assetType: 'location' | 'prop' = 'location') => {
|
||||
const assetsOfType = assetType === 'prop' ? props : locations
|
||||
const location = assetsOfType.find(l => l.id === locationId)
|
||||
if (!location) return
|
||||
|
||||
setImageEditModal({
|
||||
assetType,
|
||||
locationId,
|
||||
imageIndex,
|
||||
locationName: location.name
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useCallback } from 'react'
|
||||
import { logInfo as _ulogInfo } from '@/lib/logging/core'
|
||||
import { isAbortError } from '@/lib/error-utils'
|
||||
import {
|
||||
useAssetActions,
|
||||
useModifyProjectCharacterImage,
|
||||
useModifyProjectLocationImage,
|
||||
useUndoProjectCharacterImage,
|
||||
useUndoProjectLocationImage,
|
||||
useUpdateProjectAppearanceDescription,
|
||||
@@ -29,6 +29,7 @@ interface EditingLocationState {
|
||||
}
|
||||
|
||||
interface LocationImageEditState {
|
||||
assetType: 'location' | 'prop'
|
||||
locationId: string
|
||||
imageIndex: number
|
||||
locationName: string
|
||||
@@ -73,7 +74,8 @@ export function useAssetsImageEdit({
|
||||
closeCharacterImageEditModal,
|
||||
}: UseAssetsImageEditParams) {
|
||||
const modifyCharacterImage = useModifyProjectCharacterImage(projectId)
|
||||
const modifyLocationImage = useModifyProjectLocationImage(projectId)
|
||||
const locationAssetActions = useAssetActions({ scope: 'project', projectId, kind: 'location' })
|
||||
const propAssetActions = useAssetActions({ scope: 'project', projectId, kind: 'prop' })
|
||||
const undoCharacterImage = useUndoProjectCharacterImage(projectId)
|
||||
const undoLocationImage = useUndoProjectLocationImage(projectId)
|
||||
const updateAppearanceDescription = useUpdateProjectAppearanceDescription(projectId)
|
||||
@@ -111,29 +113,31 @@ export function useAssetsImageEdit({
|
||||
|
||||
const handleLocationImageEdit = useCallback(async (modifyPrompt: string, extraImageUrls?: string[]) => {
|
||||
if (!imageEditModal) return
|
||||
const { locationId, imageIndex, locationName } = imageEditModal
|
||||
const { assetType, locationId, imageIndex, locationName } = imageEditModal
|
||||
|
||||
closeImageEditModal()
|
||||
|
||||
_ulogInfo(`[场景编辑] 开始编辑 ${locationName}, locationId=${locationId}, imageIndex=${imageIndex}`)
|
||||
const assetLabel = assetType === 'prop' ? '道具' : '场景'
|
||||
const editAction = assetType === 'prop' ? propAssetActions : locationAssetActions
|
||||
|
||||
modifyLocationImage.mutate(
|
||||
{ locationId, imageIndex, modifyPrompt, extraImageUrls },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
const result = (data || {}) as { descriptionUpdated?: boolean }
|
||||
_ulogInfo(`[场景编辑] ✅ 完成: ${locationName}`)
|
||||
const descNote = result.descriptionUpdated ? t('stage.updateSuccess') : ''
|
||||
showToast(`${locationName} ${t('image.editSuccess')}${descNote}`, 'success')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
_ulogInfo(`[场景编辑] ❌ 失败: ${locationName}`, error)
|
||||
if (isAbortError(error)) return
|
||||
showToast(`${t('image.editFailed')}: ${getErrorMessage(error)}`, 'error')
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [closeImageEditModal, imageEditModal, modifyLocationImage, showToast, t])
|
||||
_ulogInfo(`[${assetLabel}编辑] 开始编辑 ${locationName}, locationId=${locationId}, imageIndex=${imageIndex}`)
|
||||
|
||||
void editAction.modifyRender({
|
||||
id: locationId,
|
||||
imageIndex,
|
||||
modifyPrompt,
|
||||
extraImageUrls,
|
||||
}).then((data) => {
|
||||
const result = (data || {}) as { descriptionUpdated?: boolean }
|
||||
_ulogInfo(`[${assetLabel}编辑] ✅ 完成: ${locationName}`)
|
||||
const descNote = result.descriptionUpdated ? t('stage.updateSuccess') : ''
|
||||
showToast(`${locationName} ${t('image.editSuccess')}${descNote}`, 'success')
|
||||
}).catch((error: unknown) => {
|
||||
_ulogInfo(`[${assetLabel}编辑] ❌ 失败: ${locationName}`, error)
|
||||
if (isAbortError(error)) return
|
||||
showToast(`${t('image.editFailed')}: ${getErrorMessage(error)}`, 'error')
|
||||
})
|
||||
}, [closeImageEditModal, imageEditModal, locationAssetActions, propAssetActions, showToast, t])
|
||||
|
||||
const handleCharacterImageEdit = useCallback(async (modifyPrompt: string, extraImageUrls?: string[]) => {
|
||||
if (!characterImageEditModal) return
|
||||
|
||||
@@ -58,7 +58,7 @@ export function useLocationActions({
|
||||
const regenerateGroup = useRegenerateLocationGroup(projectId)
|
||||
const deleteLocationMutation = useDeleteProjectLocation(projectId)
|
||||
const selectLocationImageMutation = useSelectProjectLocationImage(projectId)
|
||||
const confirmLocationSelectionMutation = useConfirmProjectLocationSelection(projectId)
|
||||
const confirmLocationSelectionMutation = useConfirmProjectLocationSelection(projectId, assetType)
|
||||
const updateLocationDescriptionMutation = useUpdateProjectLocationDescription(projectId)
|
||||
|
||||
// 删除场景
|
||||
@@ -96,9 +96,6 @@ export function useLocationActions({
|
||||
|
||||
// 确认选择并删除其他候选图片
|
||||
const handleConfirmLocationSelection = useCallback(async (locationId: string) => {
|
||||
if (assetType === 'prop') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await confirmLocationSelectionMutation.mutateAsync({ locationId })
|
||||
showToast?.(`✓ ${t('image.confirmSuccess')}`, 'success')
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Location, Prop } from '@/types/project'
|
||||
|
||||
export function canGenerateLocationBackedAsset(asset: Location | Prop): boolean {
|
||||
if (asset.summary && asset.summary.trim().length > 0) {
|
||||
export function canGenerateLocationBackedAsset(
|
||||
asset: Location | Prop,
|
||||
assetType: 'location' | 'prop',
|
||||
): boolean {
|
||||
if (assetType === 'location' && asset.summary && asset.summary.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ type LocationImageListProps =
|
||||
| {
|
||||
mode: 'single'
|
||||
locationName: string
|
||||
aspectClassName: string
|
||||
currentImageUrl: string | null | undefined
|
||||
selectedIndex: number | null
|
||||
hasMultipleImages: boolean
|
||||
@@ -161,14 +162,14 @@ export default function LocationImageList(props: LocationImageListProps) {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden border-2 border-[var(--glass-stroke-base)] relative">
|
||||
<div className={`relative overflow-hidden rounded-lg border-2 border-[var(--glass-stroke-base)] ${props.aspectClassName}`}>
|
||||
{props.currentImageUrl ? (
|
||||
<div className="relative w-full">
|
||||
<div className="relative h-full w-full">
|
||||
<MediaImageWithLoading
|
||||
src={props.currentImageUrl}
|
||||
alt={props.locationName}
|
||||
containerClassName="w-full min-h-[120px]"
|
||||
className="w-full h-auto object-contain cursor-pointer hover:opacity-90 transition-opacity"
|
||||
containerClassName="h-full w-full"
|
||||
className="h-full w-full object-contain cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => props.onImageClick(props.currentImageUrl!)}
|
||||
/>
|
||||
{props.selectedIndex !== null && props.hasMultipleImages && (
|
||||
@@ -178,7 +179,7 @@ export default function LocationImageList(props: LocationImageListProps) {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full bg-[var(--glass-bg-muted)] flex items-center justify-center">
|
||||
<div className="flex h-full w-full items-center justify-center bg-[var(--glass-bg-muted)]">
|
||||
{locationErrorDisplay && !props.isTaskRunning ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
|
||||
<AppIcon name="alert" className="w-8 h-8 text-[var(--glass-tone-danger-fg)] mb-2" />
|
||||
@@ -186,7 +187,7 @@ export default function LocationImageList(props: LocationImageListProps) {
|
||||
<div className="text-[var(--glass-tone-danger-fg)] text-xs max-w-full break-words">{locationErrorDisplay.message}</div>
|
||||
</div>
|
||||
) : (
|
||||
<AppIcon name="globe2" className="w-8 h-8 text-[var(--glass-text-tertiary)]" />
|
||||
<AppIcon name="image" className="w-8 h-8 text-[var(--glass-text-tertiary)]" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -462,7 +462,7 @@ export default function ScriptViewAssetsPanel({
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex-1 min-h-0 glass-surface-modal overflow-hidden p-4 pr-3">
|
||||
<div className="flex h-full flex-col gap-6 overflow-y-auto pr-1 custom-scrollbar">
|
||||
<div className="flex h-full flex-col gap-6 overflow-y-auto pr-1 app-scrollbar">
|
||||
{assetsLoading && characters.length === 0 && locations.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-[var(--glass-text-tertiary)] animate-pulse">
|
||||
<TaskStatusInline state={assetsLoadingState} />
|
||||
@@ -490,7 +490,7 @@ export default function ScriptViewAssetsPanel({
|
||||
{showAddChar && mounted && createPortal(
|
||||
<div ref={charEditorPopoverRef} className="fixed right-4 bottom-4 z-[80] glass-surface-modal w-[min(24rem,calc(100vw-2rem))] h-[min(560px,calc(100vh-2rem))] p-3 animate-fadeIn flex flex-col shadow-2xl">
|
||||
<div className="shrink-0 text-xs text-[var(--glass-text-tertiary)]">{tCommon('edit')} · {tScript('asset.activeCharacters')}</div>
|
||||
<div className="mt-3 flex-1 min-h-0 space-y-4 overflow-y-auto pr-1 custom-scrollbar">
|
||||
<div className="mt-3 flex-1 min-h-0 space-y-4 overflow-y-auto pr-1 app-scrollbar">
|
||||
{isAllClipsMode && (
|
||||
<div className="rounded-lg border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)]/40 p-2 text-[11px] text-[var(--glass-text-tertiary)]">
|
||||
当前为“全部片段”视图,文案要求仅在单片段视图可编辑
|
||||
@@ -646,7 +646,7 @@ export default function ScriptViewAssetsPanel({
|
||||
{showAddLoc && mounted && createPortal(
|
||||
<div ref={locEditorPopoverRef} className="fixed right-4 bottom-4 z-[80] glass-surface-modal w-[min(24rem,calc(100vw-2rem))] h-[min(560px,calc(100vh-2rem))] p-3 animate-fadeIn flex flex-col shadow-2xl">
|
||||
<div className="shrink-0 text-xs text-[var(--glass-text-tertiary)]">{tCommon('edit')} · {tScript('asset.activeLocations')}</div>
|
||||
<div className="mt-3 flex-1 min-h-0 overflow-y-auto pr-1 custom-scrollbar">
|
||||
<div className="mt-3 flex-1 min-h-0 overflow-y-auto pr-1 app-scrollbar">
|
||||
{isAllClipsMode && (
|
||||
<div className="mb-3 rounded-lg border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)]/40 p-2 text-[11px] text-[var(--glass-text-tertiary)]">
|
||||
当前为“全部片段”视图,场景文案要求仅在单片段视图可编辑
|
||||
@@ -775,7 +775,7 @@ export default function ScriptViewAssetsPanel({
|
||||
{showAddProp && mounted && createPortal(
|
||||
<div ref={propEditorPopoverRef} className="fixed right-4 bottom-4 z-[80] glass-surface-modal w-[min(24rem,calc(100vw-2rem))] h-[min(560px,calc(100vh-2rem))] p-3 animate-fadeIn flex flex-col shadow-2xl">
|
||||
<div className="shrink-0 text-xs text-[var(--glass-text-tertiary)]">{tCommon('edit')} · 道具</div>
|
||||
<div className="mt-3 flex-1 min-h-0 overflow-y-auto pr-1 custom-scrollbar">
|
||||
<div className="mt-3 flex-1 min-h-0 overflow-y-auto pr-1 app-scrollbar">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{props.map((prop) => {
|
||||
const isSelected = pendingPropIds.has(prop.id)
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function ScriptViewScriptPanel({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 glass-surface-elevated overflow-hidden flex flex-col relative w-full min-h-[300px]">
|
||||
<div className="lg:absolute lg:inset-0 overflow-y-auto p-6 space-y-4 custom-scrollbar">
|
||||
<div className="lg:absolute lg:inset-0 overflow-y-auto p-6 space-y-4 app-scrollbar">
|
||||
{clips.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-[var(--glass-text-tertiary)]">
|
||||
<AppIcon name="fileFold" className="h-10 w-10 mb-2" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -95,15 +95,15 @@ export default function ImageEditModal({
|
||||
return (
|
||||
<div className="fixed inset-0 bg-[var(--glass-overlay)] z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="bg-[var(--glass-bg-surface)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto"
|
||||
className="bg-[var(--glass-bg-surface)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col"
|
||||
onPaste={handlePaste}
|
||||
>
|
||||
<div className="p-6 border-b">
|
||||
<div className="p-6 border-b shrink-0">
|
||||
<h3 className="text-lg font-bold text-[var(--glass-text-primary)]">{t('imageEdit.title')}</h3>
|
||||
<p className="text-sm text-[var(--glass-text-tertiary)] mt-1">{t('imageEdit.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="p-6 space-y-4 overflow-y-auto app-scrollbar flex-1 min-h-0">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--glass-text-secondary)] mb-2">{t('prompts.aiInstruction')}</label>
|
||||
<textarea
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -26,16 +26,16 @@ export default function VideoPromptModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-[var(--glass-overlay)] flex items-center justify-center z-50" onClick={onCancel}>
|
||||
<div className="bg-[var(--glass-bg-surface)] rounded-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="bg-[var(--glass-bg-surface)] rounded-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 标题栏 */}
|
||||
<div className="sticky top-0 bg-[var(--glass-bg-surface)] border-b px-6 py-4 flex items-center justify-between">
|
||||
<div className="bg-[var(--glass-bg-surface)] border-b px-6 py-4 flex items-center justify-between shrink-0">
|
||||
<h3 className="text-lg font-bold">{t('promptModal.title', { number: panelIndex + 1 })}</h3>
|
||||
<button onClick={onCancel} className="text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]">
|
||||
<AppIcon name="close" className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="p-6 space-y-4 overflow-y-auto app-scrollbar flex-1 min-h-0">
|
||||
{/* 镜头信息 */}
|
||||
<div className="p-3 bg-[var(--glass-bg-muted)] rounded-lg text-sm space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ModelCapabilityDropdown } from '@/components/ui/config-modals/ModelCapa
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { readConfiguredAnalysisModel, shouldGuideToModelSetup } from '@/lib/workspace/model-setup'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
import { readApiErrorMessage } from '@/lib/api/read-error-message'
|
||||
|
||||
// 有效的stage值
|
||||
const VALID_STAGES = ['config', 'script', 'assets', 'text-storyboard', 'storyboard', 'videos', 'voice', 'editor'] as const
|
||||
@@ -209,8 +210,7 @@ export default function ProjectDetailPage() {
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || t('createFailed'))
|
||||
throw new Error(await readApiErrorMessage(res, t('createFailed')))
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
@@ -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) {
|
||||
@@ -89,8 +93,8 @@ export function AddLocationModal({ folderId, onClose, onSuccess }: AddLocationMo
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4">
|
||||
<div className="glass-surface-modal max-w-lg w-full max-h-[85vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="glass-surface-modal max-w-lg w-full max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<div className="p-6 overflow-y-auto app-scrollbar flex-1 min-h-0">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">
|
||||
|
||||
@@ -23,7 +23,7 @@ interface AssetGridProps {
|
||||
isDownloading?: boolean
|
||||
selectedFolderId: string | null
|
||||
onImageClick?: (url: string) => void
|
||||
onImageEdit?: (type: 'character' | 'location', id: string, name: string, imageIndex: number, appearanceIndex?: number) => void
|
||||
onImageEdit?: (type: 'character' | 'location' | 'prop', id: string, name: string, imageIndex: number, appearanceIndex?: number) => void
|
||||
onVoiceDesign?: (characterId: string, characterName: string) => void
|
||||
onCharacterEdit?: (character: unknown, appearance: unknown) => void
|
||||
onLocationEdit?: (location: unknown, imageIndex: number) => void
|
||||
@@ -282,6 +282,21 @@ export function AssetGrid({
|
||||
}
|
||||
|
||||
const isEmpty = characters.length === 0 && locations.length === 0 && props.length === 0 && voices.length === 0
|
||||
const visibleAssetCount = (() => {
|
||||
switch (filter) {
|
||||
case 'character':
|
||||
return characters.length
|
||||
case 'location':
|
||||
return locations.length
|
||||
case 'prop':
|
||||
return props.length
|
||||
case 'voice':
|
||||
return voices.length
|
||||
case 'all':
|
||||
default:
|
||||
return characters.length + locations.length + props.length + voices.length
|
||||
}
|
||||
})()
|
||||
|
||||
const tabs = [
|
||||
{ id: 'all', label: t('allAssets') },
|
||||
@@ -300,6 +315,8 @@ export function AssetGrid({
|
||||
options={tabs.map(tab => ({ value: tab.id, label: tab.label }))}
|
||||
value={filter}
|
||||
onChange={(val) => setFilter(val as 'all' | 'character' | 'location' | 'prop' | 'voice')}
|
||||
layout="compact"
|
||||
className="min-w-max"
|
||||
/>
|
||||
|
||||
{/* 右侧操作按钮 */}
|
||||
@@ -332,6 +349,20 @@ export function AssetGrid({
|
||||
</div>
|
||||
<p className="text-[var(--glass-text-secondary)] mb-2">{t('emptyState')}</p>
|
||||
<p className="text-sm text-[var(--glass-text-tertiary)]">{t('emptyStateHint')}</p>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<AddAssetDropdown
|
||||
onAddCharacter={onAddCharacter}
|
||||
onAddLocation={onAddLocation}
|
||||
onAddProp={onAddProp}
|
||||
onAddVoice={onAddVoice}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : visibleAssetCount === 0 ? (
|
||||
<div className="flex min-h-[320px] items-center justify-center">
|
||||
<p className="text-sm text-[var(--glass-text-tertiary)]">
|
||||
{t('filteredEmptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
|
||||
@@ -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')}>
|
||||
@@ -385,14 +391,14 @@ export function CharacterCard({ character, onImageClick, onImageEdit, onVoiceDes
|
||||
<input ref={voiceInputRef} type="file" accept="audio/*" onChange={handleUploadVoice} className="hidden" />
|
||||
|
||||
{/* 图片区域 */}
|
||||
<div className="relative bg-[var(--glass-bg-muted)] min-h-[100px]">
|
||||
<div className="relative aspect-[3/2] bg-[var(--glass-bg-muted)]">
|
||||
{displayImageUrl ? (
|
||||
<>
|
||||
<MediaImageWithLoading
|
||||
src={displayImageUrl}
|
||||
alt={character.name}
|
||||
containerClassName="w-full min-h-[120px]"
|
||||
className="w-full h-auto object-contain cursor-zoom-in"
|
||||
containerClassName="h-full w-full"
|
||||
className="h-full w-full object-contain cursor-zoom-in"
|
||||
onClick={() => onImageClick?.(displayImageUrl)}
|
||||
/>
|
||||
{/* 操作按钮 - 非生成时显示 */}
|
||||
@@ -416,7 +422,7 @@ export function CharacterCard({ character, onImageClick, onImageEdit, onVoiceDes
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-[var(--glass-text-tertiary)]">
|
||||
<div className="flex h-full flex-col items-center justify-center px-4 py-6 text-[var(--glass-text-tertiary)]">
|
||||
<AppIcon name="image" className="w-12 h-12 mb-3" />
|
||||
<ImageGenerationInlineCountButton
|
||||
prefix={<span>{tAssets('image.generateCountPrefix')}</span>}
|
||||
|
||||
@@ -172,8 +172,8 @@ export function CharacterEditModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4">
|
||||
<div className="glass-surface-modal max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="glass-surface-modal max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<div className="p-6 space-y-4 overflow-y-auto app-scrollbar flex-1 min-h-0">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
resolveDisplayImageSlots,
|
||||
} from '@/lib/image-generation/slot-state'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { AI_EDIT_BUTTON_CLASS, AI_EDIT_ICON_CLASS } from '@/components/ui/ai-edit-style'
|
||||
import AISparklesIcon from '@/components/ui/icons/AISparklesIcon'
|
||||
|
||||
interface LocationImage {
|
||||
id: string
|
||||
@@ -50,7 +52,7 @@ interface LocationCardProps {
|
||||
location: Location
|
||||
assetType?: 'location' | 'prop'
|
||||
onImageClick?: (url: string) => void
|
||||
onImageEdit?: (type: 'character' | 'location', id: string, name: string, imageIndex: number) => void
|
||||
onImageEdit?: (type: 'character' | 'location' | 'prop', id: string, name: string, imageIndex: number) => void
|
||||
onEdit?: (location: Location, imageIndex: number) => void
|
||||
}
|
||||
|
||||
@@ -94,10 +96,11 @@ 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
|
||||
const singleImageAspectClassName = assetType === 'prop' ? 'aspect-[3/2]' : 'aspect-square'
|
||||
const displayTaskPresentation = isTaskRunning
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
@@ -226,19 +229,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')}>
|
||||
@@ -375,14 +383,14 @@ export function LocationCard({ location, assetType = 'location', onImageClick, o
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
|
||||
{/* 图片区域 */}
|
||||
<div className="relative bg-[var(--glass-bg-muted)] min-h-[100px]">
|
||||
<div className={`relative bg-[var(--glass-bg-muted)] ${singleImageAspectClassName}`}>
|
||||
{displayImageUrl ? (
|
||||
<>
|
||||
<MediaImageWithLoading
|
||||
src={displayImageUrl}
|
||||
alt={location.name}
|
||||
containerClassName="w-full min-h-[120px]"
|
||||
className="w-full h-auto object-contain cursor-zoom-in"
|
||||
containerClassName="h-full w-full"
|
||||
className="h-full w-full object-contain cursor-zoom-in"
|
||||
onClick={() => onImageClick?.(displayImageUrl)}
|
||||
/>
|
||||
{/* 操作按钮 - 非生成时显示 */}
|
||||
@@ -391,8 +399,11 @@ export function LocationCard({ location, assetType = 'location', onImageClick, o
|
||||
<button onClick={() => fileInputRef.current?.click()} disabled={uploadImage.isPending} className="glass-btn-base glass-btn-secondary h-7 w-7 rounded-full">
|
||||
<AppIcon name="upload" className="w-4 h-4 text-[var(--glass-tone-success-fg)]" />
|
||||
</button>
|
||||
<button onClick={() => onImageEdit?.('location', location.id, location.name, currentImageIndex)} className="glass-btn-base glass-btn-tone-info h-7 w-7 rounded-full">
|
||||
<AppIcon name="edit" className="w-4 h-4" />
|
||||
<button
|
||||
onClick={() => onImageEdit?.(assetType === 'prop' ? 'prop' : 'location', location.id, location.name, currentImageIndex)}
|
||||
className={`h-7 w-7 rounded-full flex items-center justify-center transition-all active:scale-95 ${AI_EDIT_BUTTON_CLASS}`}
|
||||
>
|
||||
<AISparklesIcon className={`w-4 h-4 ${AI_EDIT_ICON_CLASS}`} />
|
||||
</button>
|
||||
<button onClick={() => handleGenerate()} className="glass-btn-base glass-btn-secondary h-7 w-7 rounded-full">
|
||||
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
|
||||
@@ -406,8 +417,8 @@ export function LocationCard({ location, assetType = 'location', onImageClick, o
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-[var(--glass-text-tertiary)]">
|
||||
<AppIcon name="globe2" className="w-12 h-12 mb-3" />
|
||||
<div className="flex h-full flex-col items-center justify-center px-4 py-6 text-[var(--glass-text-tertiary)]">
|
||||
<AppIcon name="image" className="w-12 h-12 mb-3" />
|
||||
<ImageGenerationInlineCountButton
|
||||
prefix={<span>{tAssets('image.generateCountPrefix')}</span>}
|
||||
suffix={<span>{tAssets('image.generateCountSuffix')}</span>}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user