Fix prop confirmation bug, add Wan 2.7 model, refine multiple UI details, improve prop generation quality and aspect ratio, remove text overlays from Asset Center created images, and optimize prop filtering logic
This commit is contained in:
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": "更新后的道具视觉描述"
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"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",
|
||||
@@ -80,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": {
|
||||
|
||||
@@ -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": "参考图片",
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"aiStoryExpand": "AI 故事扩写",
|
||||
"aiModifyAppearance": "角色描述修改",
|
||||
"aiModifyLocation": "场景描述修改",
|
||||
"aiModifyProp": "道具描述修改",
|
||||
"aiModifyShotPrompt": "镜头提示词修改",
|
||||
"analyzeShotVariants": "镜头变体分析",
|
||||
"aiCreateCharacter": "项目角色设计",
|
||||
@@ -80,6 +81,7 @@
|
||||
"assetHubAiDesignLocation": "资产库场景设计",
|
||||
"assetHubAiModifyCharacter": "资产库角色修改",
|
||||
"assetHubAiModifyLocation": "资产库场景修改",
|
||||
"assetHubAiModifyProp": "资产库道具修改",
|
||||
"assetHubReferenceToCharacter": "资产库参考图转角色"
|
||||
},
|
||||
"stage": {
|
||||
|
||||
@@ -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`,
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -146,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' },
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -133,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}
|
||||
|
||||
@@ -454,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({
|
||||
@@ -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) {
|
||||
@@ -271,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}
|
||||
/>
|
||||
@@ -297,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
|
||||
@@ -340,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
|
||||
@@ -361,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">
|
||||
@@ -376,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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -93,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
|
||||
|
||||
@@ -391,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)}
|
||||
/>
|
||||
{/* 操作按钮 - 非生成时显示 */}
|
||||
@@ -422,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
|
||||
}
|
||||
|
||||
@@ -98,6 +100,7 @@ export function LocationCard({ location, assetType = 'location', onImageClick, o
|
||||
})
|
||||
const displaySlotCount = displaySelectionImages.length
|
||||
const hasMultipleImages = generatedImageCount > 1
|
||||
const singleImageAspectClassName = assetType === 'prop' ? 'aspect-[3/2]' : 'aspect-square'
|
||||
const displayTaskPresentation = isTaskRunning
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
@@ -380,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)}
|
||||
/>
|
||||
{/* 操作按钮 - 非生成时显示 */}
|
||||
@@ -396,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)]" />
|
||||
@@ -411,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>}
|
||||
|
||||
@@ -163,8 +163,8 @@ export function LocationEditModal({
|
||||
|
||||
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)]">
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function AssetHubPage() {
|
||||
})
|
||||
const characterActions = useAssetActions({ scope: 'global', kind: 'character' })
|
||||
const locationActions = useAssetActions({ scope: 'global', kind: 'location' })
|
||||
const propActions = useAssetActions({ scope: 'global', kind: 'prop' })
|
||||
const refreshAssets = useRefreshAssets({ scope: 'global' })
|
||||
|
||||
const loading = foldersLoading || assetsLoading
|
||||
@@ -58,7 +59,7 @@ export default function AssetHubPage() {
|
||||
const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null)
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||
const [imageEditModal, setImageEditModal] = useState<{
|
||||
type: 'character' | 'location'
|
||||
type: 'character' | 'location' | 'prop'
|
||||
id: string
|
||||
name: string
|
||||
imageIndex: number
|
||||
@@ -101,6 +102,7 @@ export default function AssetHubPage() {
|
||||
propId: string
|
||||
propName: string
|
||||
summary: string
|
||||
description: string
|
||||
variantId?: string
|
||||
} | null>(null)
|
||||
|
||||
@@ -159,7 +161,7 @@ export default function AssetHubPage() {
|
||||
}
|
||||
|
||||
// 打开图片编辑弹窗
|
||||
const handleOpenImageEdit = (type: 'character' | 'location', id: string, name: string, imageIndex: number, appearanceIndex?: number) => {
|
||||
const handleOpenImageEdit = (type: 'character' | 'location' | 'prop', id: string, name: string, imageIndex: number, appearanceIndex?: number) => {
|
||||
setImageEditModal({ type, id, name, imageIndex, appearanceIndex })
|
||||
}
|
||||
|
||||
@@ -189,6 +191,15 @@ export default function AssetHubPage() {
|
||||
}).catch(() => {
|
||||
alert(t('editFailed'))
|
||||
})
|
||||
} else if (type === 'prop') {
|
||||
void propActions.modifyRender({
|
||||
id,
|
||||
imageIndex,
|
||||
modifyPrompt,
|
||||
extraImageUrls,
|
||||
}).catch(() => {
|
||||
alert(t('editFailed'))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,13 +301,14 @@ export default function AssetHubPage() {
|
||||
id: string
|
||||
name: string
|
||||
summary: string | null
|
||||
images: Array<{ id: string; imageIndex: number }>
|
||||
images: Array<{ id: string; imageIndex: number; description: string | null }>
|
||||
}
|
||||
const variant = typedProp.images.find((image) => image.imageIndex === imageIndex)
|
||||
setPropEditModal({
|
||||
propId: typedProp.id,
|
||||
propName: typedProp.name,
|
||||
summary: typedProp.summary || '',
|
||||
description: variant?.description || typedProp.summary || '',
|
||||
variantId: variant?.id,
|
||||
})
|
||||
}
|
||||
@@ -615,6 +627,7 @@ export default function AssetHubPage() {
|
||||
propId={propEditModal.propId}
|
||||
propName={propEditModal.propName}
|
||||
summary={propEditModal.summary}
|
||||
description={propEditModal.description}
|
||||
variantId={propEditModal.variantId}
|
||||
onClose={() => setPropEditModal(null)}
|
||||
onRefresh={refreshAssets}
|
||||
|
||||
58
src/app/api/asset-hub/ai-modify-prop/route.ts
Normal file
58
src/app/api/asset-hub/ai-modify-prop/route.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireUserAuth, isErrorResponse } from '@/lib/api-auth'
|
||||
import { apiHandler, ApiError } from '@/lib/api-errors'
|
||||
import { TASK_TYPE } from '@/lib/task/types'
|
||||
import { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const authResult = await requireUserAuth()
|
||||
if (isErrorResponse(authResult)) return authResult
|
||||
const { session } = authResult
|
||||
|
||||
const payload = await request.json().catch(() => ({}))
|
||||
const propId = typeof payload?.propId === 'string' ? payload.propId.trim() : ''
|
||||
const variantId = typeof payload?.variantId === 'string' ? payload.variantId.trim() : ''
|
||||
const currentDescription = typeof payload?.currentDescription === 'string' ? payload.currentDescription.trim() : ''
|
||||
const modifyInstruction = typeof payload?.modifyInstruction === 'string' ? payload.modifyInstruction.trim() : ''
|
||||
|
||||
if (!propId || !currentDescription || !modifyInstruction) {
|
||||
throw new ApiError('INVALID_PARAMS')
|
||||
}
|
||||
|
||||
const prop = await prisma.globalLocation.findFirst({
|
||||
where: {
|
||||
id: propId,
|
||||
userId: session.user.id,
|
||||
assetKind: 'prop',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
})
|
||||
if (!prop) {
|
||||
throw new ApiError('NOT_FOUND')
|
||||
}
|
||||
|
||||
const asyncTaskResponse = await maybeSubmitLLMTask({
|
||||
request,
|
||||
userId: session.user.id,
|
||||
projectId: 'global-asset-hub',
|
||||
type: TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP,
|
||||
targetType: 'GlobalLocation',
|
||||
targetId: variantId || propId,
|
||||
routePath: '/api/asset-hub/ai-modify-prop',
|
||||
body: {
|
||||
propId,
|
||||
propName: prop.name,
|
||||
variantId: variantId || undefined,
|
||||
currentDescription,
|
||||
modifyInstruction,
|
||||
},
|
||||
dedupeKey: `asset_hub_ai_modify_prop:${propId}:${variantId || 'default'}`,
|
||||
})
|
||||
if (asyncTaskResponse) return asyncTaskResponse
|
||||
|
||||
throw new ApiError('INVALID_PARAMS')
|
||||
})
|
||||
@@ -1,11 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { requireUserAuth, isErrorResponse } from '@/lib/api-auth'
|
||||
import { apiHandler, ApiError } from '@/lib/api-errors'
|
||||
import { updateAssetRenderLabel } from '@/lib/assets/services/asset-label'
|
||||
|
||||
/**
|
||||
* POST /api/asset-hub/update-asset-label
|
||||
* 更新资产中心图片上的黑边标识符(修改名字后调用)
|
||||
* 资产中心不再支持图片黑边标识更新
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const authResult = await requireUserAuth()
|
||||
@@ -21,15 +20,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
void appearanceIndex
|
||||
|
||||
if (type === 'character' || type === 'location') {
|
||||
await updateAssetRenderLabel({
|
||||
scope: 'global',
|
||||
kind: type,
|
||||
assetId: id,
|
||||
newName,
|
||||
})
|
||||
return NextResponse.json({ success: true })
|
||||
if (type !== 'character' && type !== 'location') {
|
||||
throw new ApiError('INVALID_PARAMS')
|
||||
}
|
||||
|
||||
throw new ApiError('INVALID_PARAMS')
|
||||
throw new ApiError('INVALID_PARAMS', {
|
||||
code: 'GLOBAL_ASSET_LABEL_UPDATES_DISABLED',
|
||||
message: 'Global asset images no longer support label updates',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { uploadObject, generateUniqueKey } from '@/lib/storage'
|
||||
import sharp from 'sharp'
|
||||
import { initializeFonts, createLabelSVG } from '@/lib/fonts'
|
||||
import { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'
|
||||
import { requireUserAuth, isErrorResponse } from '@/lib/api-auth'
|
||||
import { apiHandler, ApiError } from '@/lib/api-errors'
|
||||
@@ -45,7 +44,6 @@ interface AssetHubUploadDb {
|
||||
* 上传用户自定义图片作为角色或场景资产
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await initializeFonts()
|
||||
const db = prisma as unknown as AssetHubUploadDb
|
||||
|
||||
// 🔐 统一权限验证
|
||||
@@ -59,9 +57,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const id = formData.get('id') as string
|
||||
const appearanceIndex = formData.get('appearanceIndex') as string | null
|
||||
const imageIndex = formData.get('imageIndex') as string | null
|
||||
const labelText = formData.get('labelText') as string
|
||||
const labelTextValue = formData.get('labelText')
|
||||
const labelText = typeof labelTextValue === 'string' ? labelTextValue : ''
|
||||
|
||||
if (!file || !type || !id || !labelText) {
|
||||
if (!file || !type || !id || (type === 'location' && !labelText.trim())) {
|
||||
throw new ApiError('INVALID_PARAMS')
|
||||
}
|
||||
|
||||
@@ -69,18 +68,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
|
||||
const meta = await sharp(buffer).metadata()
|
||||
const w = meta.width || 2160
|
||||
const h = meta.height || 2160
|
||||
const fontSize = Math.floor(h * 0.04)
|
||||
const pad = Math.floor(fontSize * 0.5)
|
||||
const barH = fontSize + pad * 2
|
||||
|
||||
const svg = await createLabelSVG(w, barH, fontSize, pad, labelText)
|
||||
|
||||
const processed = await sharp(buffer)
|
||||
.extend({ top: barH, bottom: 0, left: 0, right: 0, background: { r: 0, g: 0, b: 0, alpha: 1 } })
|
||||
.composite([{ input: svg, top: 0, left: 0 }])
|
||||
.jpeg({ quality: 90, mozjpeg: true })
|
||||
.toBuffer()
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireProjectAuth, isErrorResponse } from '@/lib/api-auth'
|
||||
import { apiHandler, ApiError } from '@/lib/api-errors'
|
||||
import { TASK_TYPE } from '@/lib/task/types'
|
||||
import { maybeSubmitLLMTask } from '@/lib/llm-observe/route-task'
|
||||
|
||||
export const POST = apiHandler(async (
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) => {
|
||||
const { projectId } = await context.params
|
||||
const authResult = await requireProjectAuth(projectId)
|
||||
if (isErrorResponse(authResult)) return authResult
|
||||
const { session } = authResult
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const propId = typeof body?.propId === 'string' ? body.propId.trim() : ''
|
||||
const variantId = typeof body?.variantId === 'string' ? body.variantId.trim() : ''
|
||||
const currentDescription = typeof body?.currentDescription === 'string' ? body.currentDescription.trim() : ''
|
||||
const modifyInstruction = typeof body?.modifyInstruction === 'string' ? body.modifyInstruction.trim() : ''
|
||||
|
||||
if (!propId || !currentDescription || !modifyInstruction) {
|
||||
throw new ApiError('INVALID_PARAMS')
|
||||
}
|
||||
|
||||
const novelProject = await prisma.novelPromotionProject.findUnique({
|
||||
where: { projectId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!novelProject) {
|
||||
throw new ApiError('NOT_FOUND')
|
||||
}
|
||||
|
||||
const prop = await prisma.novelPromotionLocation.findFirst({
|
||||
where: {
|
||||
id: propId,
|
||||
novelPromotionProjectId: novelProject.id,
|
||||
assetKind: 'prop',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
})
|
||||
if (!prop) {
|
||||
throw new ApiError('NOT_FOUND')
|
||||
}
|
||||
|
||||
const asyncTaskResponse = await maybeSubmitLLMTask({
|
||||
request,
|
||||
userId: session.user.id,
|
||||
projectId,
|
||||
type: TASK_TYPE.AI_MODIFY_PROP,
|
||||
targetType: 'NovelPromotionLocation',
|
||||
targetId: variantId || propId,
|
||||
routePath: `/api/novel-promotion/${projectId}/ai-modify-prop`,
|
||||
body: {
|
||||
propId,
|
||||
propName: prop.name,
|
||||
variantId: variantId || undefined,
|
||||
currentDescription,
|
||||
modifyInstruction,
|
||||
},
|
||||
dedupeKey: `ai_modify_prop:${propId}:${variantId || 'default'}`,
|
||||
})
|
||||
if (asyncTaskResponse) return asyncTaskResponse
|
||||
|
||||
throw new ApiError('INVALID_PARAMS')
|
||||
})
|
||||
@@ -86,7 +86,7 @@ export default function AiWriteModal({
|
||||
value={promptText}
|
||||
onChange={(e) => setPromptText(e.target.value)}
|
||||
placeholder={t('placeholder')}
|
||||
className="glass-textarea-base custom-scrollbar h-36 px-4 py-3 text-sm resize-none placeholder:text-[var(--glass-text-tertiary)]"
|
||||
className="glass-textarea-base app-scrollbar h-36 px-4 py-3 text-sm resize-none placeholder:text-[var(--glass-text-tertiary)]"
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -144,7 +144,7 @@ export function RatioSelector({
|
||||
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="glass-surface-modal z-[9999] p-3 overflow-y-auto custom-scrollbar"
|
||||
className="glass-surface-modal z-[9999] p-3 overflow-y-auto app-scrollbar"
|
||||
style={panelStyle}
|
||||
>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
|
||||
138
src/components/shared/assets/AiModifyDescriptionField.tsx
Normal file
138
src/components/shared/assets/AiModifyDescriptionField.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import type { TaskPresentationState } from '@/lib/task/presentation'
|
||||
import GlassModalShell from '@/components/ui/primitives/GlassModalShell'
|
||||
|
||||
interface AiModifyDescriptionFieldProps {
|
||||
label: string
|
||||
description: string
|
||||
onDescriptionChange: (value: string) => void
|
||||
descriptionPlaceholder: string
|
||||
descriptionHeightClassName?: string
|
||||
aiInstruction: string
|
||||
onAiInstructionChange: (value: string) => void
|
||||
aiInstructionPlaceholder: string
|
||||
onAiModify: () => Promise<boolean> | boolean
|
||||
isAiModifying: boolean
|
||||
aiModifyingState: TaskPresentationState | null
|
||||
actionLabel: string
|
||||
cancelLabel: string
|
||||
}
|
||||
|
||||
export function AiModifyDescriptionField({
|
||||
label,
|
||||
description,
|
||||
onDescriptionChange,
|
||||
descriptionPlaceholder,
|
||||
descriptionHeightClassName = 'h-48',
|
||||
aiInstruction,
|
||||
onAiInstructionChange,
|
||||
aiInstructionPlaceholder,
|
||||
onAiModify,
|
||||
isAiModifying,
|
||||
aiModifyingState,
|
||||
actionLabel,
|
||||
cancelLabel,
|
||||
}: AiModifyDescriptionFieldProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isAiModifying) return
|
||||
setIsModalOpen(false)
|
||||
}, [isAiModifying])
|
||||
|
||||
const handleConfirmModify = useCallback(async () => {
|
||||
const didModify = await Promise.resolve(onAiModify())
|
||||
if (didModify) {
|
||||
setIsModalOpen(false)
|
||||
}
|
||||
}, [onAiModify])
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative overflow-hidden rounded-2xl border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] transition-[border-color,box-shadow,background-color] hover:border-[var(--glass-stroke-strong)] focus-within:border-[var(--glass-stroke-focus)] focus-within:bg-[var(--glass-bg-surface-strong)] focus-within:shadow-[0_0_0_3px_var(--glass-focus-ring)]">
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(event) => onDescriptionChange(event.target.value)}
|
||||
className={`app-scrollbar w-full resize-none border-0 bg-transparent px-4 py-3 pb-16 text-sm leading-6 text-[var(--glass-text-primary)] outline-none placeholder:text-[var(--glass-text-tertiary)] ${descriptionHeightClassName}`}
|
||||
placeholder={descriptionPlaceholder}
|
||||
disabled={isAiModifying}
|
||||
/>
|
||||
<div className="pointer-events-none absolute bottom-4 right-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
disabled={isAiModifying}
|
||||
className="glass-btn-base pointer-events-auto flex h-10 flex-shrink-0 items-center gap-1.5 border border-[var(--glass-stroke-strong)] bg-[var(--glass-bg-surface)] px-3 text-sm transition-all hover:border-[var(--glass-tone-info-fg)]/40 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isAiModifying ? (
|
||||
<TaskStatusInline state={aiModifyingState} className="text-[var(--glass-tone-info-fg)] [&>span]:text-[var(--glass-tone-info-fg)] [&_svg]:text-[var(--glass-tone-info-fg)]" />
|
||||
) : (
|
||||
<>
|
||||
<AppIcon name="sparkles" className="h-4 w-4 text-[#7c3aed]" />
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{actionLabel}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<GlassModalShell
|
||||
open={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
title={actionLabel}
|
||||
description={label}
|
||||
size="sm"
|
||||
footer={(
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseModal}
|
||||
disabled={isAiModifying}
|
||||
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleConfirmModify()}
|
||||
disabled={isAiModifying || !aiInstruction.trim()}
|
||||
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg disabled:cursor-not-allowed disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isAiModifying ? (
|
||||
<TaskStatusInline state={aiModifyingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
actionLabel
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
value={aiInstruction}
|
||||
onChange={(event) => onAiInstructionChange(event.target.value)}
|
||||
placeholder={aiInstructionPlaceholder}
|
||||
className="glass-textarea-base app-scrollbar h-32 w-full resize-none px-4 py-3 text-sm"
|
||||
disabled={isAiModifying}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</GlassModalShell>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useUpdateProjectCharacterIntroduction,
|
||||
useUpdateProjectCharacterName,
|
||||
} from '@/lib/query/hooks'
|
||||
import { AiModifyDescriptionField } from './AiModifyDescriptionField'
|
||||
|
||||
export interface CharacterEditModalProps {
|
||||
mode: 'asset-hub' | 'project'
|
||||
@@ -151,7 +152,7 @@ export function CharacterEditModal({
|
||||
}
|
||||
|
||||
const handleAiModify = async () => {
|
||||
if (!aiModifyInstruction.trim()) return
|
||||
if (!aiModifyInstruction.trim()) return false
|
||||
|
||||
try {
|
||||
setIsAiModifying(true)
|
||||
@@ -167,8 +168,9 @@ export function CharacterEditModal({
|
||||
setEditingDescription(data.modifiedDescription)
|
||||
onUpdate?.(data.modifiedDescription)
|
||||
setAiModifyInstruction('')
|
||||
return true
|
||||
}
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
if (!appearanceId) throw new Error('Missing appearanceId')
|
||||
@@ -182,11 +184,14 @@ export function CharacterEditModal({
|
||||
setEditingDescription(data.modifiedDescription)
|
||||
onUpdate?.(data.modifiedDescription)
|
||||
setAiModifyInstruction('')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error: unknown) {
|
||||
if (shouldShowError(error)) {
|
||||
alert(`${t('modal.modifyFailed')}: ${getErrorMessage(error, t('errors.failed'))}`)
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
setIsAiModifying(false)
|
||||
}
|
||||
@@ -313,58 +318,21 @@ export function CharacterEditModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 glass-surface-soft p-4 rounded-lg border border-[var(--glass-stroke-base)]">
|
||||
<label className="block text-sm font-medium text-[var(--glass-tone-info-fg)] flex items-center gap-2">
|
||||
<AppIcon name="bolt" className="w-4 h-4" />
|
||||
{t('modal.smartModify')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={aiModifyInstruction}
|
||||
onChange={(e) => setAiModifyInstruction(e.target.value)}
|
||||
placeholder={t('modal.modifyPlaceholderCharacter')}
|
||||
className="glass-input-base flex-1 px-3 py-2"
|
||||
disabled={isAiModifying}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleAiModify()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAiModify}
|
||||
disabled={isAiModifying || !aiModifyInstruction.trim()}
|
||||
className="glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
{isAiModifying ? (
|
||||
<TaskStatusInline state={aiModifyingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
<>
|
||||
<AppIcon name="bolt" className="w-4 h-4" />
|
||||
{t('modal.smartModify')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="glass-field-hint">
|
||||
{t('modal.aiTipSub')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
{t('modal.appearancePrompt')}
|
||||
</label>
|
||||
<textarea
|
||||
value={editingDescription}
|
||||
onChange={(e) => setEditingDescription(e.target.value)}
|
||||
className="glass-textarea-base w-full h-64 px-3 py-2 resize-none"
|
||||
placeholder={t('modal.descPlaceholder')}
|
||||
disabled={isAiModifying}
|
||||
/>
|
||||
</div>
|
||||
<AiModifyDescriptionField
|
||||
label={t('modal.appearancePrompt')}
|
||||
description={editingDescription}
|
||||
onDescriptionChange={setEditingDescription}
|
||||
descriptionPlaceholder={t('modal.descPlaceholder')}
|
||||
descriptionHeightClassName="h-64"
|
||||
aiInstruction={aiModifyInstruction}
|
||||
onAiInstructionChange={setAiModifyInstruction}
|
||||
aiInstructionPlaceholder={t('modal.modifyPlaceholderCharacter')}
|
||||
onAiModify={handleAiModify}
|
||||
isAiModifying={isAiModifying}
|
||||
aiModifyingState={aiModifyingState}
|
||||
actionLabel={t('modal.modifyDescription')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end p-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)] rounded-b-lg flex-shrink-0">
|
||||
|
||||
@@ -8,6 +8,13 @@ import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { apiFetch } from '@/lib/api-fetch'
|
||||
import type {
|
||||
AssetSummary,
|
||||
CharacterAssetSummary,
|
||||
LocationAssetSummary,
|
||||
PropAssetSummary,
|
||||
VoiceAssetSummary,
|
||||
} from '@/lib/assets/contracts'
|
||||
|
||||
interface GlobalAssetPickerProps {
|
||||
isOpen: boolean
|
||||
@@ -17,67 +24,30 @@ interface GlobalAssetPickerProps {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
interface GlobalCharacterAppearance {
|
||||
id: string
|
||||
imageUrl: string | null
|
||||
imageUrls: string[]
|
||||
selectedIndex: number | null
|
||||
}
|
||||
|
||||
interface GlobalCharacter {
|
||||
id: string
|
||||
name: string
|
||||
folderId: string | null
|
||||
customVoiceUrl: string | null
|
||||
appearances: GlobalCharacterAppearance[]
|
||||
}
|
||||
|
||||
interface GlobalLocationImage {
|
||||
id: string
|
||||
imageIndex: number
|
||||
imageUrl: string | null
|
||||
isSelected: boolean
|
||||
}
|
||||
|
||||
interface GlobalLocation {
|
||||
id: string
|
||||
name: string
|
||||
summary: string | null
|
||||
folderId: string | null
|
||||
images: GlobalLocationImage[]
|
||||
}
|
||||
|
||||
type GlobalProp = GlobalLocation
|
||||
|
||||
interface GlobalVoice {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
folderId: string | null
|
||||
customVoiceUrl: string | null
|
||||
voiceId: string | null
|
||||
voiceType: string
|
||||
voicePrompt: string | null
|
||||
gender: string | null
|
||||
language: string
|
||||
}
|
||||
|
||||
/** 从 appearances 中提取预览图 URL */
|
||||
function getCharacterPreview(char: GlobalCharacter): string | null {
|
||||
const first = char.appearances?.[0]
|
||||
if (!first) return null
|
||||
// 优先使用 selectedIndex 指向的图
|
||||
if (first.selectedIndex != null && first.imageUrls?.[first.selectedIndex]) {
|
||||
return first.imageUrls[first.selectedIndex]
|
||||
}
|
||||
return first.imageUrl || first.imageUrls?.[0] || null
|
||||
function getCharacterPreview(char: CharacterAssetSummary): string | null {
|
||||
const primaryVariant = char.variants.find((variant) => variant.index === 0) || char.variants[0]
|
||||
if (!primaryVariant) return null
|
||||
const selectedRenderIndex = primaryVariant.selectionState.selectedRenderIndex
|
||||
const selectedRender = selectedRenderIndex !== null
|
||||
? primaryVariant.renders.find((render) => render.index === selectedRenderIndex)
|
||||
: null
|
||||
return selectedRender?.imageUrl
|
||||
|| primaryVariant.renders.find((render) => render.isSelected)?.imageUrl
|
||||
|| primaryVariant.renders[0]?.imageUrl
|
||||
|| null
|
||||
}
|
||||
|
||||
/** 从 images 中提取预览图 URL */
|
||||
function getLocationPreview(loc: GlobalLocation): string | null {
|
||||
const selected = loc.images?.find(img => img.isSelected)
|
||||
if (selected?.imageUrl) return selected.imageUrl
|
||||
return loc.images?.[0]?.imageUrl || null
|
||||
/** 从 variants/renders 中提取预览图 URL */
|
||||
function getVisualAssetPreview(asset: LocationAssetSummary | PropAssetSummary): string | null {
|
||||
const selectedVariant = asset.selectedVariantId
|
||||
? asset.variants.find((variant) => variant.id === asset.selectedVariantId)
|
||||
: null
|
||||
const targetVariant = selectedVariant || asset.variants[0]
|
||||
if (!targetVariant) return null
|
||||
return targetVariant.renders.find((render) => render.isSelected)?.imageUrl
|
||||
|| targetVariant.renders[0]?.imageUrl
|
||||
|| null
|
||||
}
|
||||
|
||||
// 内联 SVG 图标组件
|
||||
@@ -122,7 +92,7 @@ export default function GlobalAssetPicker({
|
||||
const res = await apiFetch('/api/assets?scope=global&kind=character')
|
||||
if (!res.ok) throw new Error('Failed to fetch characters')
|
||||
const data = await res.json()
|
||||
return data.assets as GlobalCharacter[]
|
||||
return data.assets as CharacterAssetSummary[]
|
||||
},
|
||||
enabled: type === 'character',
|
||||
})
|
||||
@@ -132,7 +102,7 @@ export default function GlobalAssetPicker({
|
||||
const res = await apiFetch('/api/assets?scope=global&kind=location')
|
||||
if (!res.ok) throw new Error('Failed to fetch locations')
|
||||
const data = await res.json()
|
||||
return data.assets as GlobalLocation[]
|
||||
return data.assets as LocationAssetSummary[]
|
||||
},
|
||||
enabled: type === 'location',
|
||||
})
|
||||
@@ -142,7 +112,7 @@ export default function GlobalAssetPicker({
|
||||
const res = await apiFetch('/api/assets?scope=global&kind=prop')
|
||||
if (!res.ok) throw new Error('Failed to fetch props')
|
||||
const data = await res.json()
|
||||
return data.assets as GlobalProp[]
|
||||
return data.assets as PropAssetSummary[]
|
||||
},
|
||||
enabled: type === 'prop',
|
||||
})
|
||||
@@ -152,15 +122,15 @@ export default function GlobalAssetPicker({
|
||||
const res = await apiFetch('/api/assets?scope=global&kind=voice')
|
||||
if (!res.ok) throw new Error('Failed to fetch voices')
|
||||
const data = await res.json()
|
||||
return data.assets as GlobalVoice[]
|
||||
return data.assets as VoiceAssetSummary[]
|
||||
},
|
||||
enabled: type === 'voice',
|
||||
})
|
||||
|
||||
const characters = (charactersQuery.data || []) as GlobalCharacter[]
|
||||
const locations = (locationsQuery.data || []) as GlobalLocation[]
|
||||
const props = (propsQuery.data || []) as GlobalProp[]
|
||||
const voices = (voicesQuery.data || []) as GlobalVoice[]
|
||||
const characters = (charactersQuery.data || []) as CharacterAssetSummary[]
|
||||
const locations = (locationsQuery.data || []) as LocationAssetSummary[]
|
||||
const props = (propsQuery.data || []) as PropAssetSummary[]
|
||||
const voices = (voicesQuery.data || []) as VoiceAssetSummary[]
|
||||
const isLoading = type === 'character'
|
||||
? charactersQuery.isFetching
|
||||
: type === 'location'
|
||||
@@ -249,7 +219,7 @@ export default function GlobalAssetPicker({
|
||||
|
||||
const filteredVoices = voices.filter(v =>
|
||||
v.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(v.description && v.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
(v.voiceMeta.description && v.voiceMeta.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
)
|
||||
|
||||
// 播放/暂停音频预览
|
||||
@@ -302,9 +272,9 @@ export default function GlobalAssetPicker({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50">
|
||||
<div className="glass-surface-modal w-[600px] max-h-[80vh] flex flex-col">
|
||||
<div className="glass-surface-modal w-[600px] max-h-[80vh] flex flex-col">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--glass-stroke-base)]">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-[var(--glass-text-primary)]">
|
||||
{type === 'character' ? t('selectCharacter') : type === 'location' ? t('selectLocation') : type === 'prop' ? t('selectProp') : t('selectVoice')}
|
||||
</h2>
|
||||
@@ -314,7 +284,7 @@ export default function GlobalAssetPicker({
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="px-6 py-3 border-b border-[var(--glass-stroke-base)]">
|
||||
<div className="px-6 pb-3">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--glass-text-tertiary)]" />
|
||||
<input
|
||||
@@ -369,13 +339,13 @@ export default function GlobalAssetPicker({
|
||||
)}
|
||||
|
||||
{/* 预览图 */}
|
||||
<div className="aspect-square rounded-lg overflow-hidden bg-[var(--glass-bg-muted)] mb-2 relative">
|
||||
<div className="aspect-[3/2] rounded-lg overflow-hidden bg-[var(--glass-bg-muted)] mb-2 relative">
|
||||
{charPreview ? (
|
||||
<MediaImageWithLoading
|
||||
src={charPreview}
|
||||
alt={char.name}
|
||||
containerClassName="w-full h-full"
|
||||
className="w-full h-full object-cover cursor-zoom-in"
|
||||
className="w-full h-full object-contain cursor-zoom-in"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setPreviewImage(charPreview)
|
||||
@@ -383,7 +353,7 @@ export default function GlobalAssetPicker({
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-[var(--glass-text-tertiary)]">
|
||||
<UserIcon className="w-12 h-12" />
|
||||
<PhotoIcon className="w-12 h-12" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -391,17 +361,13 @@ export default function GlobalAssetPicker({
|
||||
{/* 名称 */}
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-sm text-[var(--glass-text-primary)] truncate">{char.name}</p>
|
||||
<p className="text-xs text-[var(--glass-text-secondary)] mt-1">
|
||||
{char.appearances?.length || 0} {t('appearances')}
|
||||
{char.customVoiceUrl && ' · Voice'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : type === 'location' ? (
|
||||
filteredLocations.map((loc) => {
|
||||
const locPreview = getLocationPreview(loc)
|
||||
const locPreview = getVisualAssetPreview(loc)
|
||||
return (
|
||||
<div
|
||||
key={loc.id}
|
||||
@@ -440,7 +406,7 @@ export default function GlobalAssetPicker({
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-sm text-[var(--glass-text-primary)] truncate">{loc.name}</p>
|
||||
<p className="text-xs text-[var(--glass-text-secondary)] mt-1">
|
||||
{loc.images?.length || 0} {t('images')}
|
||||
{loc.variants.length} {t('images')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -448,7 +414,7 @@ export default function GlobalAssetPicker({
|
||||
})
|
||||
) : type === 'prop' ? (
|
||||
filteredProps.map((prop) => {
|
||||
const propPreview = getLocationPreview(prop)
|
||||
const propPreview = getVisualAssetPreview(prop)
|
||||
return (
|
||||
<div
|
||||
key={prop.id}
|
||||
@@ -482,7 +448,7 @@ export default function GlobalAssetPicker({
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-sm text-[var(--glass-text-primary)] truncate">{prop.name}</p>
|
||||
<p className="text-xs text-[var(--glass-text-secondary)] mt-1">
|
||||
{prop.images?.length || 0} {t('images')}
|
||||
{prop.variants.length} {t('images')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -491,8 +457,9 @@ export default function GlobalAssetPicker({
|
||||
) : (
|
||||
// 音色列表渲染 - 与资产中心 VoiceCard 风格统一
|
||||
filteredVoices.map((voice) => {
|
||||
const genderIcon = voice.gender === 'male' ? 'M' : voice.gender === 'female' ? 'F' : ''
|
||||
const isVoicePlaying = previewAudio === voice.customVoiceUrl && isPlayingAudio
|
||||
const genderIcon = voice.voiceMeta.gender === 'male' ? 'M' : voice.voiceMeta.gender === 'female' ? 'F' : ''
|
||||
const previewVoiceUrl = voice.voiceMeta.customVoiceUrl
|
||||
const isVoicePlaying = previewAudio === previewVoiceUrl && isPlayingAudio
|
||||
return (
|
||||
<div
|
||||
key={voice.id}
|
||||
@@ -523,9 +490,9 @@ export default function GlobalAssetPicker({
|
||||
)}
|
||||
|
||||
{/* 试听按钮 - 圆形,与 VoiceCard 统一 */}
|
||||
{voice.customVoiceUrl && (
|
||||
{previewVoiceUrl && (
|
||||
<button
|
||||
onClick={(e) => handlePlayAudio(voice.customVoiceUrl!, e)}
|
||||
onClick={(e) => handlePlayAudio(previewVoiceUrl, e)}
|
||||
className={`absolute bottom-2 right-2 w-10 h-10 rounded-full glass-btn-base flex items-center justify-center transition-all ${isVoicePlaying
|
||||
? 'glass-btn-tone-info animate-pulse'
|
||||
: 'glass-btn-secondary text-[var(--glass-tone-info-fg)]'
|
||||
@@ -543,11 +510,11 @@ export default function GlobalAssetPicker({
|
||||
{/* 信息区域 */}
|
||||
<div className="p-3">
|
||||
<h3 className="font-medium text-[var(--glass-text-primary)] text-sm truncate">{voice.name}</h3>
|
||||
{voice.description && (
|
||||
<p className="mt-1 text-xs text-[var(--glass-text-secondary)] line-clamp-2">{voice.description}</p>
|
||||
{voice.voiceMeta.description && (
|
||||
<p className="mt-1 text-xs text-[var(--glass-text-secondary)] line-clamp-2">{voice.voiceMeta.description}</p>
|
||||
)}
|
||||
{voice.voicePrompt && !voice.description && (
|
||||
<p className="mt-1 text-xs text-[var(--glass-text-tertiary)] line-clamp-2 italic">{voice.voicePrompt}</p>
|
||||
{voice.voiceMeta.voicePrompt && !voice.voiceMeta.description && (
|
||||
<p className="mt-1 text-xs text-[var(--glass-text-tertiary)] line-clamp-2 italic">{voice.voiceMeta.voicePrompt}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useUpdateProjectLocationName,
|
||||
} from '@/lib/query/hooks'
|
||||
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
|
||||
import { AiModifyDescriptionField } from './AiModifyDescriptionField'
|
||||
|
||||
export interface LocationEditModalProps {
|
||||
mode: 'asset-hub' | 'project'
|
||||
@@ -129,7 +130,7 @@ export function LocationEditModal({
|
||||
}
|
||||
|
||||
const handleAiModify = async () => {
|
||||
if (!aiModifyInstruction.trim()) return
|
||||
if (!aiModifyInstruction.trim()) return false
|
||||
|
||||
try {
|
||||
setIsAiModifying(true)
|
||||
@@ -146,8 +147,9 @@ export function LocationEditModal({
|
||||
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
|
||||
onUpdate?.(data.modifiedDescription)
|
||||
setAiModifyInstruction('')
|
||||
return true
|
||||
}
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const data = await aiModifyProject.mutateAsync({
|
||||
@@ -162,11 +164,14 @@ export function LocationEditModal({
|
||||
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
|
||||
onUpdate?.(nextDescription)
|
||||
setAiModifyInstruction('')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error: unknown) {
|
||||
if (shouldShowError(error)) {
|
||||
alert(`${t('modal.modifyFailed')}: ${getErrorMessage(error, t('errors.failed'))}`)
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
setIsAiModifying(false)
|
||||
}
|
||||
@@ -262,58 +267,20 @@ export function LocationEditModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 glass-surface-soft p-4 rounded-lg border border-[var(--glass-stroke-base)]">
|
||||
<label className="block text-sm font-medium text-[var(--glass-tone-info-fg)] flex items-center gap-2">
|
||||
<AppIcon name="bolt" className="w-4 h-4" />
|
||||
{t('modal.smartModify')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={aiModifyInstruction}
|
||||
onChange={(e) => setAiModifyInstruction(e.target.value)}
|
||||
placeholder={t('modal.modifyPlaceholder')}
|
||||
className="glass-input-base flex-1 px-3 py-2"
|
||||
disabled={isAiModifying}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleAiModify()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAiModify}
|
||||
disabled={isAiModifying || !aiModifyInstruction.trim()}
|
||||
className="glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
{isAiModifying ? (
|
||||
<TaskStatusInline state={aiModifyingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
<>
|
||||
<AppIcon name="bolt" className="w-4 h-4" />
|
||||
{t('modal.smartModify')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="glass-field-hint">
|
||||
{t('modal.aiLocationTip')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
{t('location.description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={editingDescription}
|
||||
onChange={(e) => setEditingDescription(e.target.value)}
|
||||
className="glass-textarea-base w-full h-48 px-3 py-2 resize-none"
|
||||
placeholder={t('modal.descPlaceholder')}
|
||||
disabled={isAiModifying}
|
||||
/>
|
||||
</div>
|
||||
<AiModifyDescriptionField
|
||||
label={t('location.description')}
|
||||
description={editingDescription}
|
||||
onDescriptionChange={setEditingDescription}
|
||||
descriptionPlaceholder={t('modal.descPlaceholder')}
|
||||
aiInstruction={aiModifyInstruction}
|
||||
onAiInstructionChange={setAiModifyInstruction}
|
||||
aiInstructionPlaceholder={t('modal.modifyPlaceholder')}
|
||||
onAiModify={handleAiModify}
|
||||
isAiModifying={isAiModifying}
|
||||
aiModifyingState={aiModifyingState}
|
||||
actionLabel={t('modal.modifyDescription')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end p-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)] rounded-b-lg flex-shrink-0">
|
||||
|
||||
@@ -34,6 +34,7 @@ export function PropCreationModal({
|
||||
const { count, setCount } = useImageGenerationCount('location')
|
||||
const [name, setName] = useState('')
|
||||
const [summary, setSummary] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [artStyle, setArtStyle] = useState('american-comic')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const submittingState = isSubmitting
|
||||
@@ -56,12 +57,13 @@ export function PropCreationModal({
|
||||
}, [isSubmitting, onClose])
|
||||
|
||||
const handleSubmit = async (generateAfterCreate: boolean) => {
|
||||
if (!name.trim() || !summary.trim()) return
|
||||
if (!name.trim() || !summary.trim() || !description.trim()) return
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
const result = await actions.create({
|
||||
name: name.trim(),
|
||||
summary: summary.trim(),
|
||||
description: description.trim(),
|
||||
folderId,
|
||||
artStyle,
|
||||
}) as { assetId?: string }
|
||||
@@ -112,9 +114,9 @@ export function PropCreationModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
{t('prop.summary')} <span className="text-[var(--glass-tone-danger-fg)]">*</span>
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
{t('prop.summary')} <span className="text-[var(--glass-tone-danger-fg)]">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={summary}
|
||||
@@ -123,6 +125,18 @@ export function PropCreationModal({
|
||||
className="glass-textarea-base w-full h-36 px-3 py-2 text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
{t('prop.description')} <span className="text-[var(--glass-tone-danger-fg)]">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder={t('prop.descriptionPlaceholder')}
|
||||
className="glass-textarea-base w-full h-36 px-3 py-2 text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -136,7 +150,7 @@ export function PropCreationModal({
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSubmit(false)}
|
||||
disabled={isSubmitting || !name.trim() || !summary.trim()}
|
||||
disabled={isSubmitting || !name.trim() || !summary.trim() || !description.trim()}
|
||||
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed text-sm flex items-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
@@ -152,7 +166,7 @@ export function PropCreationModal({
|
||||
options={getImageGenerationCountOptions('location')}
|
||||
onValueChange={setCount}
|
||||
onClick={() => void handleSubmit(true)}
|
||||
actionDisabled={!name.trim() || !summary.trim()}
|
||||
actionDisabled={!name.trim() || !summary.trim() || !description.trim()}
|
||||
selectDisabled={isSubmitting}
|
||||
ariaLabel={t('common.selectGenerateCount')}
|
||||
className="glass-btn-base glass-btn-primary flex items-center justify-center gap-1 rounded-lg px-4 py-2 text-sm disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
|
||||
@@ -3,15 +3,22 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { shouldShowError } from '@/lib/error-utils'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { useAssetActions } from '@/lib/query/hooks'
|
||||
import {
|
||||
useAiModifyProjectPropDescription,
|
||||
useAiModifyPropDescription,
|
||||
useAssetActions,
|
||||
} from '@/lib/query/hooks'
|
||||
import { AiModifyDescriptionField } from './AiModifyDescriptionField'
|
||||
|
||||
export interface PropEditModalProps {
|
||||
mode: 'asset-hub' | 'project'
|
||||
propId: string
|
||||
propName: string
|
||||
summary: string
|
||||
description: string
|
||||
variantId?: string
|
||||
projectId?: string
|
||||
onClose: () => void
|
||||
@@ -23,6 +30,7 @@ export function PropEditModal({
|
||||
propId,
|
||||
propName,
|
||||
summary,
|
||||
description,
|
||||
variantId,
|
||||
projectId,
|
||||
onClose,
|
||||
@@ -36,7 +44,18 @@ export function PropEditModal({
|
||||
})
|
||||
const [editingName, setEditingName] = useState(propName)
|
||||
const [editingSummary, setEditingSummary] = useState(summary)
|
||||
const [editingDescription, setEditingDescription] = useState(description)
|
||||
const [aiModifyInstruction, setAiModifyInstruction] = useState('')
|
||||
const [isAiModifying, setIsAiModifying] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const aiModifyingState = isAiModifying
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'modify',
|
||||
resource: 'image',
|
||||
hasOutput: true,
|
||||
})
|
||||
: null
|
||||
const savingState = isSaving
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
@@ -45,6 +64,13 @@ export function PropEditModal({
|
||||
hasOutput: false,
|
||||
})
|
||||
: null
|
||||
const aiModifyAssetHub = useAiModifyPropDescription()
|
||||
const aiModifyProject = useAiModifyProjectPropDescription(projectId ?? '')
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string) => {
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
return fallback
|
||||
}
|
||||
|
||||
const persist = async () => {
|
||||
await actions.update(propId, {
|
||||
@@ -53,14 +79,49 @@ export function PropEditModal({
|
||||
})
|
||||
if (variantId) {
|
||||
await actions.updateVariant(propId, variantId, {
|
||||
description: editingSummary.trim(),
|
||||
description: editingDescription.trim(),
|
||||
})
|
||||
}
|
||||
onRefresh?.()
|
||||
}
|
||||
|
||||
const handleAiModify = async () => {
|
||||
if (!aiModifyInstruction.trim()) return false
|
||||
|
||||
try {
|
||||
setIsAiModifying(true)
|
||||
const data = mode === 'asset-hub'
|
||||
? await aiModifyAssetHub.mutateAsync({
|
||||
propId,
|
||||
variantId,
|
||||
currentDescription: editingDescription,
|
||||
modifyInstruction: aiModifyInstruction,
|
||||
})
|
||||
: await aiModifyProject.mutateAsync({
|
||||
propId,
|
||||
variantId,
|
||||
currentDescription: editingDescription,
|
||||
modifyInstruction: aiModifyInstruction,
|
||||
})
|
||||
|
||||
if (data?.modifiedDescription) {
|
||||
setEditingDescription(data.modifiedDescription)
|
||||
setAiModifyInstruction('')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error: unknown) {
|
||||
if (shouldShowError(error)) {
|
||||
alert(`${t('modal.modifyFailed')}: ${getErrorMessage(error, t('errors.failed'))}`)
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
setIsAiModifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveOnly = async () => {
|
||||
if (!editingName.trim() || !editingSummary.trim()) return
|
||||
if (!editingName.trim() || !editingSummary.trim() || !editingDescription.trim()) return
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await persist()
|
||||
@@ -71,7 +132,7 @@ export function PropEditModal({
|
||||
}
|
||||
|
||||
const handleSaveAndGenerate = async () => {
|
||||
if (!editingName.trim() || !editingSummary.trim()) return
|
||||
if (!editingName.trim() || !editingSummary.trim() || !editingDescription.trim()) return
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await persist()
|
||||
@@ -118,10 +179,25 @@ export function PropEditModal({
|
||||
<textarea
|
||||
value={editingSummary}
|
||||
onChange={(event) => setEditingSummary(event.target.value)}
|
||||
className="glass-textarea-base w-full h-48 px-3 py-2 resize-none"
|
||||
className="glass-textarea-base h-28 w-full px-3 py-2 resize-none"
|
||||
placeholder={t('prop.summaryPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AiModifyDescriptionField
|
||||
label={t('prop.description')}
|
||||
description={editingDescription}
|
||||
onDescriptionChange={setEditingDescription}
|
||||
descriptionPlaceholder={t('prop.descriptionPlaceholder')}
|
||||
aiInstruction={aiModifyInstruction}
|
||||
onAiInstructionChange={setAiModifyInstruction}
|
||||
aiInstructionPlaceholder={t('modal.modifyPlaceholderProp')}
|
||||
onAiModify={handleAiModify}
|
||||
isAiModifying={isAiModifying}
|
||||
aiModifyingState={aiModifyingState}
|
||||
actionLabel={t('modal.modifyDescription')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end p-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)] rounded-b-lg flex-shrink-0">
|
||||
@@ -134,7 +210,7 @@ export function PropEditModal({
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSaveOnly()}
|
||||
disabled={isSaving || !editingName.trim() || !editingSummary.trim()}
|
||||
disabled={isSaving || !editingName.trim() || !editingSummary.trim() || !editingDescription.trim()}
|
||||
className="glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
@@ -145,7 +221,7 @@ export function PropEditModal({
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSaveAndGenerate()}
|
||||
disabled={isSaving || !editingName.trim() || !editingSummary.trim()}
|
||||
disabled={isSaving || !editingName.trim() || !editingSummary.trim() || !editingDescription.trim()}
|
||||
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('modal.saveAndGenerate')}
|
||||
|
||||
@@ -125,7 +125,7 @@ export default function StoryInputComposer({
|
||||
placeholder={placeholder}
|
||||
rows={minRows}
|
||||
disabled={disabled}
|
||||
className={`w-full resize-none border-none bg-transparent text-base text-[var(--glass-text-primary)] outline-none placeholder:text-[var(--glass-text-tertiary)] custom-scrollbar ${textareaClassName ?? 'p-5 pb-3'}`}
|
||||
className={`w-full resize-none border-none bg-transparent text-base text-[var(--glass-text-primary)] outline-none placeholder:text-[var(--glass-text-tertiary)] app-scrollbar ${textareaClassName ?? 'p-5 pb-3'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ export function EpisodeSelector({
|
||||
|
||||
{isOpen && (
|
||||
<div className="glass-surface-modal absolute left-0 top-full mt-2 w-72 origin-top-left p-2 animate-fadeIn">
|
||||
<div className="max-h-[300px] overflow-y-auto custom-scrollbar space-y-1">
|
||||
<div className="max-h-[300px] overflow-y-auto app-scrollbar space-y-1">
|
||||
{episodes.map(ep => {
|
||||
const statusColor = ep.status?.visual === 'ready'
|
||||
? 'bg-[var(--glass-tone-success-fg)]'
|
||||
|
||||
@@ -364,7 +364,7 @@ export function SettingsModal({
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[12px] text-[var(--glass-text-tertiary)] mb-6">{t('subtitle')}</p>
|
||||
<div className="space-y-5 flex-1 min-h-0 overflow-y-auto custom-scrollbar">
|
||||
<div className="space-y-5 flex-1 min-h-0 overflow-y-auto app-scrollbar">
|
||||
<div className="glass-surface-soft p-5 sm:p-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-[var(--glass-text-tertiary)]">{t('visualSettings')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
@@ -286,7 +286,7 @@ export function ModelCapabilityDropdown({
|
||||
style={panelStyle}
|
||||
>
|
||||
{/* Model list */}
|
||||
<div className="px-2 pb-2 min-h-0 flex-1 overflow-y-auto custom-scrollbar">
|
||||
<div className="px-2 pb-2 min-h-0 flex-1 overflow-y-auto app-scrollbar">
|
||||
{(() => {
|
||||
// Group models by provider
|
||||
const grouped = new Map<string, ModelCapabilityOption[]>()
|
||||
@@ -340,7 +340,7 @@ export function ModelCapabilityDropdown({
|
||||
<div className="text-[10px] font-bold text-[#8e8e93] uppercase tracking-wider mb-2.5">
|
||||
{t('paramConfig')}
|
||||
</div>
|
||||
<div className="max-h-[156px] overflow-y-auto custom-scrollbar pr-1">
|
||||
<div className="max-h-[156px] overflow-y-auto app-scrollbar pr-1">
|
||||
<div className="space-y-3">
|
||||
{visibleCapabilityFields.map((def) => {
|
||||
const currentVal = capabilityOverrides[def.field] !== undefined
|
||||
|
||||
@@ -94,7 +94,7 @@ export function WorldContextModal({ isOpen, onClose, text, onChange }: WorldCont
|
||||
value={text}
|
||||
onChange={(event) => handleTextChange(event.target.value)}
|
||||
placeholder={t('placeholder')}
|
||||
className="glass-textarea-base flex-1 text-base resize-none leading-relaxed placeholder:text-[var(--glass-text-tertiary)]/70 custom-scrollbar p-4"
|
||||
className="glass-textarea-base app-scrollbar flex-1 text-base resize-none leading-relaxed placeholder:text-[var(--glass-text-tertiary)]/70 p-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export function RatioSelector({ value, onChange, options }: RatioSelectorProps)
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto custom-scrollbar"
|
||||
className="glass-surface-modal absolute z-50 mt-1 left-0 right-0 p-3 max-h-60 overflow-y-auto app-scrollbar"
|
||||
style={{ minWidth: '300px' }}
|
||||
>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
|
||||
@@ -380,7 +380,7 @@ export function ModelDropdownV4(props: ModelDropdownTestProps) {
|
||||
{/* Top: Models */}
|
||||
<div className="px-3 pt-3 pb-2 bg-[var(--glass-bg-base)]">
|
||||
<div className="text-[12px] font-bold text-[var(--glass-text-secondary)] mb-2 px-1">选择模型</div>
|
||||
<div className="overflow-y-auto max-h-[160px] custom-scrollbar space-y-1 pr-1">
|
||||
<div className="overflow-y-auto max-h-[160px] app-scrollbar space-y-1 pr-1">
|
||||
{props.models.map(m => {
|
||||
const active = m.value === props.value
|
||||
return (
|
||||
|
||||
@@ -134,7 +134,7 @@ export function SelectVariantCard({
|
||||
className="glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-xl shadow-xl border border-[var(--glass-stroke-base)] py-1"
|
||||
style={panelStyle}
|
||||
>
|
||||
<div className="overflow-y-auto custom-scrollbar px-1 py-1 max-h-full">
|
||||
<div className="overflow-y-auto app-scrollbar px-1 py-1 max-h-full">
|
||||
{options.map((opt) => {
|
||||
const isSelected = value === opt.value
|
||||
return (
|
||||
@@ -279,7 +279,7 @@ export function SelectVariantMinimal({
|
||||
className="glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-xl shadow-[0_8px_32px_rgba(0,0,0,0.12)] border border-[var(--glass-stroke-subtle)] py-1 bg-gradient-to-b from-[var(--glass-bg-surface-strong)] to-[var(--glass-bg-surface)] backdrop-blur-md"
|
||||
style={panelStyle}
|
||||
>
|
||||
<div className="overflow-y-auto custom-scrollbar px-1 py-1 max-h-full">
|
||||
<div className="overflow-y-auto app-scrollbar px-1 py-1 max-h-full">
|
||||
{options.map((opt) => {
|
||||
const isSelected = value === opt.value
|
||||
return (
|
||||
@@ -411,7 +411,7 @@ export function SelectVariantGhost({
|
||||
className="glass-surface-modal z-[9999] overflow-hidden flex flex-col rounded-xl shadow-lg border border-[var(--glass-stroke-subtle)] py-1"
|
||||
style={panelStyle}
|
||||
>
|
||||
<div className="overflow-y-auto custom-scrollbar p-1 max-h-full space-y-0.5">
|
||||
<div className="overflow-y-auto app-scrollbar p-1 max-h-full space-y-0.5">
|
||||
{options.map((opt) => {
|
||||
const isSelected = value === opt.value
|
||||
return (
|
||||
|
||||
27
src/lib/assets/prop-description.ts
Normal file
27
src/lib/assets/prop-description.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { removePropPromptSuffix } from '@/lib/constants'
|
||||
|
||||
function normalizeText(value: string | null | undefined): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
export function normalizePropVisualDescription(description: string | null | undefined): string {
|
||||
return removePropPromptSuffix(normalizeText(description))
|
||||
}
|
||||
|
||||
export function resolvePropVisualDescription(input: {
|
||||
name: string
|
||||
summary?: string | null
|
||||
description?: string | null
|
||||
}): string {
|
||||
const explicitDescription = normalizePropVisualDescription(input.description)
|
||||
if (explicitDescription) {
|
||||
return explicitDescription
|
||||
}
|
||||
|
||||
const summaryDescription = normalizePropVisualDescription(input.summary)
|
||||
if (summaryDescription) {
|
||||
return summaryDescription
|
||||
}
|
||||
|
||||
return normalizeText(input.name)
|
||||
}
|
||||
@@ -11,11 +11,11 @@ import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
|
||||
import { ensureGlobalLocationImageSlots, ensureProjectLocationImageSlots } from '@/lib/image-generation/location-slots'
|
||||
import { hasCharacterAppearanceOutput, hasGlobalCharacterAppearanceOutput, hasGlobalCharacterOutput, hasGlobalLocationImageOutput, hasGlobalLocationOutput, hasLocationImageOutput } from '@/lib/task/has-output'
|
||||
import { sanitizeImageInputsForTaskPayload } from '@/lib/media/outbound-image'
|
||||
import { PRIMARY_APPEARANCE_INDEX, isArtStyleValue, removeLocationPromptSuffix, type ArtStyleValue } from '@/lib/constants'
|
||||
import { PRIMARY_APPEARANCE_INDEX, isArtStyleValue, removeLocationPromptSuffix, removePropPromptSuffix, type ArtStyleValue } from '@/lib/constants'
|
||||
import { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'
|
||||
import { deleteObject } from '@/lib/storage'
|
||||
import { resolveStorageKeyFromMediaValue } from '@/lib/media/service'
|
||||
import { updateCharacterAppearanceLabels, updateLocationImageLabels } from '@/lib/image-label'
|
||||
import { createProjectCharacterLabeledCopies, createProjectLocationLabeledCopies } from '@/lib/image-label'
|
||||
import type { AssetKind, AssetScope } from '@/lib/assets/contracts'
|
||||
import {
|
||||
normalizeLocationAvailableSlots,
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
deleteProjectLocationBackedAsset,
|
||||
type LocationBackedAssetKind,
|
||||
} from '@/lib/assets/services/location-backed-assets'
|
||||
import { resolvePropVisualDescription } from '@/lib/assets/prop-description'
|
||||
import { confirmProjectLocationBackedSelection } from '@/lib/assets/services/project-location-backed-selection'
|
||||
|
||||
type AssetWriteAccess = {
|
||||
scope: AssetScope
|
||||
@@ -162,7 +164,16 @@ async function submitGlobalAssetGenerateTask(input: AssetGenerateInput) {
|
||||
if (normalizedKind === 'location' && toNumber(input.body.imageIndex) === null) {
|
||||
const location = await prisma.globalLocation.findFirst({
|
||||
where: { id: input.assetId, userId: input.access.userId },
|
||||
select: { name: true, summary: true },
|
||||
select: {
|
||||
name: true,
|
||||
summary: true,
|
||||
assetKind: true,
|
||||
images: {
|
||||
orderBy: { imageIndex: 'asc' },
|
||||
take: 1,
|
||||
select: { description: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!location) {
|
||||
throw new ApiError('NOT_FOUND')
|
||||
@@ -170,13 +181,19 @@ async function submitGlobalAssetGenerateTask(input: AssetGenerateInput) {
|
||||
await ensureGlobalLocationImageSlots({
|
||||
locationId: input.assetId,
|
||||
count,
|
||||
fallbackDescription: location.summary || location.name,
|
||||
fallbackDescription: location.assetKind === 'prop'
|
||||
? resolvePropVisualDescription({
|
||||
name: location.name,
|
||||
summary: location.summary,
|
||||
description: location.images[0]?.description ?? null,
|
||||
})
|
||||
: location.summary || location.name,
|
||||
})
|
||||
}
|
||||
|
||||
const payloadBase: Record<string, unknown> = normalizedKind === 'character'
|
||||
? { ...input.body, id: input.assetId, type: normalizedKind, appearanceIndex, artStyle, count }
|
||||
: { ...input.body, id: input.assetId, type: normalizedKind, artStyle, count }
|
||||
? { ...input.body, id: input.assetId, type: input.kind, appearanceIndex, artStyle, count }
|
||||
: { ...input.body, id: input.assetId, type: input.kind, artStyle, count }
|
||||
const targetType = normalizedKind === 'character' ? 'GlobalCharacter' : 'GlobalLocation'
|
||||
const hasOutputAtStart = normalizedKind === 'character'
|
||||
? await hasGlobalCharacterOutput({
|
||||
@@ -232,7 +249,16 @@ async function submitProjectAssetGenerateTask(input: AssetGenerateInput) {
|
||||
if (normalizedKind === 'location' && imageIndex === null) {
|
||||
const location = await prisma.novelPromotionLocation.findUnique({
|
||||
where: { id: input.assetId },
|
||||
select: { name: true, summary: true },
|
||||
select: {
|
||||
name: true,
|
||||
summary: true,
|
||||
assetKind: true,
|
||||
images: {
|
||||
orderBy: { imageIndex: 'asc' },
|
||||
take: 1,
|
||||
select: { description: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!location) {
|
||||
throw new ApiError('NOT_FOUND')
|
||||
@@ -240,7 +266,13 @@ async function submitProjectAssetGenerateTask(input: AssetGenerateInput) {
|
||||
await ensureProjectLocationImageSlots({
|
||||
locationId: input.assetId,
|
||||
count,
|
||||
fallbackDescription: location.summary || location.name,
|
||||
fallbackDescription: location.assetKind === 'prop'
|
||||
? resolvePropVisualDescription({
|
||||
name: location.name,
|
||||
summary: location.summary,
|
||||
description: location.images[0]?.description ?? null,
|
||||
})
|
||||
: location.summary || location.name,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -266,8 +298,8 @@ async function submitProjectAssetGenerateTask(input: AssetGenerateInput) {
|
||||
? projectModelConfig.characterModel
|
||||
: projectModelConfig.locationModel
|
||||
const payloadBase = artStyle
|
||||
? { ...input.body, type: normalizedKind, id: input.assetId, artStyle, count }
|
||||
: { ...input.body, type: normalizedKind, id: input.assetId, count }
|
||||
? { ...input.body, type: input.kind, id: input.assetId, artStyle, count }
|
||||
: { ...input.body, type: input.kind, id: input.assetId, count }
|
||||
|
||||
let billingPayload: Record<string, unknown>
|
||||
try {
|
||||
@@ -374,7 +406,7 @@ async function submitGlobalAssetModifyTask(input: AssetModifyInput) {
|
||||
const payload = {
|
||||
...input.body,
|
||||
id: input.assetId,
|
||||
type: normalizedKind,
|
||||
type: input.kind,
|
||||
extraImageUrls: extraImageAudit.normalized,
|
||||
meta: {
|
||||
...toObject(input.body.meta),
|
||||
@@ -444,7 +476,7 @@ async function submitProjectAssetModifyTask(input: AssetModifyInput) {
|
||||
}
|
||||
const payload = {
|
||||
...input.body,
|
||||
type: normalizedKind,
|
||||
type: input.kind,
|
||||
characterId: normalizedKind === 'character' ? input.assetId : undefined,
|
||||
locationId: normalizedKind === 'location' ? input.assetId : undefined,
|
||||
extraImageUrls: extraImageAudit.normalized,
|
||||
@@ -607,12 +639,17 @@ async function selectProjectAssetRender(input: AssetSelectInput) {
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
const confirm = input.body.confirm === true
|
||||
if (confirm) {
|
||||
return confirmProjectLocationBackedSelection(input.assetId)
|
||||
}
|
||||
const selectedIndex = toNumber(input.body.selectedIndex ?? input.body.imageIndex)
|
||||
const location = await prisma.novelPromotionLocation.findUnique({
|
||||
where: { id: input.assetId },
|
||||
include: { images: { orderBy: { imageIndex: 'asc' } } },
|
||||
})
|
||||
if (!location) throw new ApiError('NOT_FOUND')
|
||||
|
||||
if (selectedIndex !== null) {
|
||||
const targetImage = location.images.find((image) => image.imageIndex === selectedIndex)
|
||||
if (!targetImage || !targetImage.imageUrl) {
|
||||
@@ -786,7 +823,7 @@ async function copyCharacterFromGlobal(input: AssetCopyInput) {
|
||||
if (projectCharacter.appearances.length > 0) {
|
||||
await prisma.characterAppearance.deleteMany({ where: { characterId: input.targetId } })
|
||||
}
|
||||
const updatedLabels = await updateCharacterAppearanceLabels(
|
||||
const labeledCopies = await createProjectCharacterLabeledCopies(
|
||||
globalCharacter.appearances.map((appearance) => ({
|
||||
imageUrl: appearance.imageUrl,
|
||||
imageUrls: appearance.imageUrls || encodeImageUrls([]),
|
||||
@@ -796,7 +833,7 @@ async function copyCharacterFromGlobal(input: AssetCopyInput) {
|
||||
)
|
||||
for (let index = 0; index < globalCharacter.appearances.length; index += 1) {
|
||||
const appearance = globalCharacter.appearances[index]
|
||||
const labelUpdate = updatedLabels[index]
|
||||
const labeledCopy = labeledCopies[index]
|
||||
const originalImageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'globalCharacterAppearance.imageUrls')
|
||||
await prisma.characterAppearance.create({
|
||||
data: {
|
||||
@@ -805,8 +842,8 @@ async function copyCharacterFromGlobal(input: AssetCopyInput) {
|
||||
changeReason: appearance.changeReason,
|
||||
description: appearance.description,
|
||||
descriptions: appearance.descriptions,
|
||||
imageUrl: labelUpdate?.imageUrl || appearance.imageUrl,
|
||||
imageUrls: labelUpdate?.imageUrls || encodeImageUrls(originalImageUrls),
|
||||
imageUrl: labeledCopy?.imageUrl || appearance.imageUrl,
|
||||
imageUrls: labeledCopy?.imageUrls || encodeImageUrls(originalImageUrls),
|
||||
previousImageUrls: encodeImageUrls([]),
|
||||
selectedIndex: appearance.selectedIndex,
|
||||
},
|
||||
@@ -840,21 +877,21 @@ async function copyLocationFromGlobal(input: AssetCopyInput) {
|
||||
if (projectLocation.images.length > 0) {
|
||||
await prisma.locationImage.deleteMany({ where: { locationId: input.targetId } })
|
||||
}
|
||||
const updatedLabels = await updateLocationImageLabels(
|
||||
const labeledCopies = await createProjectLocationLabeledCopies(
|
||||
globalLocation.images.map((image) => ({ imageUrl: image.imageUrl })),
|
||||
projectLocation.name,
|
||||
)
|
||||
const copiedImages: Array<{ id: string; imageIndex: number; imageUrl: string | null }> = []
|
||||
for (let index = 0; index < globalLocation.images.length; index += 1) {
|
||||
const image = globalLocation.images[index]
|
||||
const labelUpdate = updatedLabels[index]
|
||||
const labeledCopy = labeledCopies[index]
|
||||
const created = await prisma.locationImage.create({
|
||||
data: {
|
||||
locationId: input.targetId,
|
||||
imageIndex: image.imageIndex,
|
||||
description: image.description,
|
||||
availableSlots: image.availableSlots,
|
||||
imageUrl: labelUpdate?.imageUrl || image.imageUrl,
|
||||
imageUrl: labeledCopy?.imageUrl || image.imageUrl,
|
||||
isSelected: image.isSelected,
|
||||
},
|
||||
})
|
||||
@@ -1048,7 +1085,7 @@ async function updateGlobalAssetVariant(input: AssetVariantUpdateInput) {
|
||||
if (input.kind === 'prop') {
|
||||
const trimmedDescription = normalizeString(input.body.description)
|
||||
if (!trimmedDescription) throw new ApiError('INVALID_PARAMS')
|
||||
const cleanDescription = removeLocationPromptSuffix(trimmedDescription)
|
||||
const cleanDescription = removePropPromptSuffix(trimmedDescription)
|
||||
const image = await prisma.globalLocationImage.update({
|
||||
where: { id: input.variantId },
|
||||
data: { description: cleanDescription },
|
||||
@@ -1084,6 +1121,16 @@ async function updateProjectAssetVariant(input: AssetVariantUpdateInput) {
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
if (input.kind === 'prop') {
|
||||
const trimmedDescription = normalizeString(input.body.description)
|
||||
if (!trimmedDescription) throw new ApiError('INVALID_PARAMS')
|
||||
const cleanDescription = removePropPromptSuffix(trimmedDescription)
|
||||
const image = await prisma.locationImage.update({
|
||||
where: { id: input.variantId },
|
||||
data: { description: cleanDescription },
|
||||
})
|
||||
return { success: true, image }
|
||||
}
|
||||
const trimmedDescription = normalizeString(input.body.description)
|
||||
if (!trimmedDescription) throw new ApiError('INVALID_PARAMS')
|
||||
const cleanDescription = removeLocationPromptSuffix(trimmedDescription)
|
||||
@@ -1096,11 +1143,14 @@ async function updateProjectAssetVariant(input: AssetVariantUpdateInput) {
|
||||
|
||||
export async function createAsset(input: AssetCreateInput) {
|
||||
const name = normalizeString(input.body.name)
|
||||
const kind = requireLocationBackedKind(input.kind)
|
||||
const summary = normalizeString(input.body.summary || input.body.description)
|
||||
if (!name || !summary) {
|
||||
const description = kind === 'prop'
|
||||
? normalizeString(input.body.description)
|
||||
: summary
|
||||
if (!name || !summary || !description) {
|
||||
throw new ApiError('INVALID_PARAMS')
|
||||
}
|
||||
const kind = requireLocationBackedKind(input.kind)
|
||||
|
||||
if (input.access.scope === 'global') {
|
||||
const created = await createGlobalLocationBackedAsset({
|
||||
@@ -1108,6 +1158,7 @@ export async function createAsset(input: AssetCreateInput) {
|
||||
folderId: normalizeString(input.body.folderId) || null,
|
||||
name,
|
||||
summary,
|
||||
initialDescription: description,
|
||||
artStyle: normalizeString(input.body.artStyle) || null,
|
||||
kind,
|
||||
})
|
||||
@@ -1125,6 +1176,7 @@ export async function createAsset(input: AssetCreateInput) {
|
||||
novelPromotionProjectId: project.id,
|
||||
name,
|
||||
summary,
|
||||
initialDescription: description,
|
||||
kind,
|
||||
})
|
||||
return { success: true, assetId: created.id }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ApiError } from '@/lib/api-errors'
|
||||
import { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'
|
||||
import { updateImageLabel } from '@/lib/image-label'
|
||||
import type { AssetKind, AssetScope } from '@/lib/assets/contracts'
|
||||
@@ -24,77 +25,14 @@ export function renderLabelText(input: {
|
||||
|
||||
export async function updateAssetRenderLabel(input: UpdateAssetRenderLabelInput) {
|
||||
if (input.scope === 'global') {
|
||||
return updateGlobalAssetRenderLabel(input)
|
||||
throw new ApiError('INVALID_PARAMS', {
|
||||
code: 'GLOBAL_ASSET_LABEL_UPDATES_DISABLED',
|
||||
message: 'Global asset images no longer support label updates',
|
||||
})
|
||||
}
|
||||
return updateProjectAssetRenderLabel(input)
|
||||
}
|
||||
|
||||
async function updateGlobalAssetRenderLabel(input: UpdateAssetRenderLabelInput) {
|
||||
if (input.kind === 'character') {
|
||||
const character = await prisma.globalCharacter.findUnique({
|
||||
where: { id: input.assetId },
|
||||
include: { appearances: true },
|
||||
})
|
||||
if (!character) {
|
||||
throw new Error('Global character not found')
|
||||
}
|
||||
|
||||
await Promise.all(character.appearances.map(async (appearance) => {
|
||||
const newImageUrls = await Promise.all(
|
||||
decodeImageUrlsFromDb(appearance.imageUrls, 'globalCharacterAppearance.imageUrls').map(async (imageUrl) => updateImageLabel(
|
||||
imageUrl,
|
||||
renderLabelText({
|
||||
kind: 'character',
|
||||
assetName: input.newName,
|
||||
variantLabel: appearance.changeReason,
|
||||
}),
|
||||
{
|
||||
generateNewKey: true,
|
||||
keyPrefix: 'asset-label-rename',
|
||||
},
|
||||
)),
|
||||
)
|
||||
const firstImageUrl = newImageUrls[0] ?? null
|
||||
await prisma.globalCharacterAppearance.update({
|
||||
where: { id: appearance.id },
|
||||
data: {
|
||||
imageUrls: encodeImageUrls(newImageUrls),
|
||||
imageUrl: firstImageUrl,
|
||||
},
|
||||
})
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
const location = await prisma.globalLocation.findUnique({
|
||||
where: { id: input.assetId },
|
||||
include: { images: true },
|
||||
})
|
||||
if (!location) {
|
||||
throw new Error('Global location not found')
|
||||
}
|
||||
await Promise.all(location.images.map(async (image) => {
|
||||
if (!image.imageUrl) {
|
||||
return
|
||||
}
|
||||
const nextImageUrl = await updateImageLabel(
|
||||
image.imageUrl,
|
||||
renderLabelText({
|
||||
kind: input.kind === 'prop' ? 'prop' : 'location',
|
||||
assetName: input.newName,
|
||||
}),
|
||||
{
|
||||
generateNewKey: true,
|
||||
keyPrefix: 'asset-label-rename',
|
||||
},
|
||||
)
|
||||
await prisma.globalLocationImage.update({
|
||||
where: { id: image.id },
|
||||
data: { imageUrl: nextImageUrl },
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
async function updateProjectAssetRenderLabel(input: UpdateAssetRenderLabelInput) {
|
||||
if (!input.projectId) {
|
||||
throw new Error('projectId is required for project assets')
|
||||
|
||||
@@ -191,6 +191,7 @@ export async function createProjectLocationBackedAsset(input: {
|
||||
novelPromotionProjectId: string
|
||||
name: string
|
||||
summary: string
|
||||
initialDescription?: string
|
||||
kind: LocationBackedAssetKind
|
||||
}): Promise<{ id: string }> {
|
||||
const id = randomUUID()
|
||||
@@ -219,8 +220,8 @@ export async function createProjectLocationBackedAsset(input: {
|
||||
`)
|
||||
await seedProjectLocationBackedImageSlots({
|
||||
locationId: id,
|
||||
fallbackDescription: input.summary,
|
||||
descriptions: [input.summary],
|
||||
fallbackDescription: input.initialDescription ?? input.summary,
|
||||
descriptions: [input.initialDescription ?? input.summary],
|
||||
availableSlots: [],
|
||||
})
|
||||
return { id }
|
||||
@@ -231,6 +232,7 @@ export async function createGlobalLocationBackedAsset(input: {
|
||||
folderId?: string | null
|
||||
name: string
|
||||
summary: string
|
||||
initialDescription?: string
|
||||
artStyle?: string | null
|
||||
kind: LocationBackedAssetKind
|
||||
}): Promise<{ id: string }> {
|
||||
@@ -260,8 +262,8 @@ export async function createGlobalLocationBackedAsset(input: {
|
||||
`)
|
||||
await seedGlobalLocationBackedImageSlots({
|
||||
locationId: id,
|
||||
fallbackDescription: input.summary,
|
||||
descriptions: [input.summary],
|
||||
fallbackDescription: input.initialDescription ?? input.summary,
|
||||
descriptions: [input.initialDescription ?? input.summary],
|
||||
availableSlots: [],
|
||||
})
|
||||
return { id }
|
||||
|
||||
75
src/lib/assets/services/project-location-backed-selection.ts
Normal file
75
src/lib/assets/services/project-location-backed-selection.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { ApiError } from '@/lib/api-errors'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { deleteObject } from '@/lib/storage'
|
||||
import { resolveStorageKeyFromMediaValue } from '@/lib/media/service'
|
||||
|
||||
export async function confirmProjectLocationBackedSelection(assetId: string): Promise<{ success: true }> {
|
||||
const location = await prisma.novelPromotionLocation.findUnique({
|
||||
where: { id: assetId },
|
||||
include: { images: { orderBy: { imageIndex: 'asc' } } },
|
||||
})
|
||||
if (!location) {
|
||||
throw new ApiError('NOT_FOUND')
|
||||
}
|
||||
|
||||
const selectedImage = location.selectedImageId
|
||||
? location.images.find((image) => image.id === location.selectedImageId)
|
||||
: location.images.find((image) => image.isSelected)
|
||||
|
||||
if (location.images.length <= 1) {
|
||||
const onlyImage = location.images[0] ?? null
|
||||
if (onlyImage) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.locationImage.update({
|
||||
where: { id: onlyImage.id },
|
||||
data: {
|
||||
imageIndex: 0,
|
||||
isSelected: true,
|
||||
},
|
||||
})
|
||||
await tx.novelPromotionLocation.update({
|
||||
where: { id: assetId },
|
||||
data: { selectedImageId: onlyImage.id },
|
||||
})
|
||||
})
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
if (!selectedImage || !selectedImage.imageUrl) {
|
||||
throw new ApiError('INVALID_PARAMS')
|
||||
}
|
||||
|
||||
const imagesToDelete = location.images.filter((image) => image.id !== selectedImage.id)
|
||||
for (const image of imagesToDelete) {
|
||||
if (!image.imageUrl) continue
|
||||
const storageKey = await resolveStorageKeyFromMediaValue(image.imageUrl)
|
||||
if (!storageKey) continue
|
||||
try {
|
||||
await deleteObject(storageKey)
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.locationImage.deleteMany({
|
||||
where: {
|
||||
locationId: assetId,
|
||||
id: { not: selectedImage.id },
|
||||
},
|
||||
})
|
||||
await tx.locationImage.update({
|
||||
where: { id: selectedImage.id },
|
||||
data: {
|
||||
imageIndex: 0,
|
||||
isSelected: true,
|
||||
},
|
||||
})
|
||||
await tx.novelPromotionLocation.update({
|
||||
where: { id: assetId },
|
||||
data: { selectedImageId: selectedImage.id },
|
||||
})
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
@@ -39,6 +39,7 @@ const BILLABLE_TASK_TYPES = new Set<TaskType>([
|
||||
TASK_TYPE.AI_STORY_EXPAND,
|
||||
TASK_TYPE.AI_MODIFY_APPEARANCE,
|
||||
TASK_TYPE.AI_MODIFY_LOCATION,
|
||||
TASK_TYPE.AI_MODIFY_PROP,
|
||||
TASK_TYPE.AI_MODIFY_SHOT_PROMPT,
|
||||
TASK_TYPE.ANALYZE_SHOT_VARIANTS,
|
||||
TASK_TYPE.AI_CREATE_CHARACTER,
|
||||
@@ -51,6 +52,7 @@ const BILLABLE_TASK_TYPES = new Set<TaskType>([
|
||||
TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION,
|
||||
TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER,
|
||||
TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION,
|
||||
TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP,
|
||||
TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER,
|
||||
])
|
||||
|
||||
@@ -284,6 +286,7 @@ export function buildDefaultTaskBillingInfo(taskType: TaskType, payload: AnyPayl
|
||||
case TASK_TYPE.AI_STORY_EXPAND:
|
||||
case TASK_TYPE.AI_MODIFY_APPEARANCE:
|
||||
case TASK_TYPE.AI_MODIFY_LOCATION:
|
||||
case TASK_TYPE.AI_MODIFY_PROP:
|
||||
case TASK_TYPE.AI_MODIFY_SHOT_PROMPT:
|
||||
case TASK_TYPE.ANALYZE_SHOT_VARIANTS:
|
||||
case TASK_TYPE.AI_CREATE_CHARACTER:
|
||||
@@ -296,6 +299,7 @@ export function buildDefaultTaskBillingInfo(taskType: TaskType, payload: AnyPayl
|
||||
case TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION:
|
||||
case TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER:
|
||||
case TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION:
|
||||
case TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP:
|
||||
case TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER:
|
||||
return buildTextTaskInfo(taskType, payload)
|
||||
case TASK_TYPE.PANEL_VARIANT:
|
||||
|
||||
@@ -191,15 +191,23 @@ export function getArtStylePrompt(
|
||||
// 角色形象生成的系统后缀(始终添加到提示词末尾,不显示给用户)- 左侧面部特写+右侧三视图
|
||||
export const CHARACTER_PROMPT_SUFFIX = '角色设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是角色的正面特写(如果是人类则展示完整正脸,如果是动物/生物则展示最具辨识度的正面形态);【右侧区域】占约2/3宽度,是角色三视图横向排列(从左到右依次为:正面全身、侧面全身、背面全身),三视图高度一致。纯白色背景,无其他元素。'
|
||||
|
||||
// 道具图片生成的系统后缀(固定白底三视图资产图)
|
||||
export const PROP_PROMPT_SUFFIX = '道具设定图,画面分为左右两个区域:【左侧区域】占约1/3宽度,是道具主体的主视图特写;【右侧区域】占约2/3宽度,是同一道具的三视图横向排列(从左到右依次为:正面、侧面、背面),三视图高度一致。纯白色背景,主体居中完整展示,无人物、无手部、无桌面陈设、无环境背景、无其他元素。'
|
||||
|
||||
// 场景图片生成的系统后缀(已禁用四视图,直接生成单张场景图)
|
||||
export const LOCATION_PROMPT_SUFFIX = ''
|
||||
|
||||
// 角色图片生成比例(16:9横版,左侧面部特写+右侧全身)
|
||||
export const CHARACTER_IMAGE_RATIO = '16:9'
|
||||
// 角色资产图生成比例(当前角色设定图实际使用 3:2)
|
||||
export const CHARACTER_ASSET_IMAGE_RATIO = '3:2'
|
||||
// 历史保留:旧注释中曾写 16:9,但当前资产图生成统一以 CHARACTER_ASSET_IMAGE_RATIO 为准
|
||||
export const CHARACTER_IMAGE_RATIO = CHARACTER_ASSET_IMAGE_RATIO
|
||||
// 角色图片尺寸(用于Seedream API)
|
||||
export const CHARACTER_IMAGE_SIZE = '3840x2160' // 16:9 横版
|
||||
// 角色图片尺寸(用于Banana API)
|
||||
export const CHARACTER_IMAGE_BANANA_RATIO = '3:2'
|
||||
export const CHARACTER_IMAGE_BANANA_RATIO = CHARACTER_ASSET_IMAGE_RATIO
|
||||
|
||||
// 道具图片生成比例(与角色资产图保持一致)
|
||||
export const PROP_IMAGE_RATIO = CHARACTER_ASSET_IMAGE_RATIO
|
||||
|
||||
// 场景图片生成比例(1:1 正方形单张场景)
|
||||
export const LOCATION_IMAGE_RATIO = '1:1'
|
||||
@@ -221,6 +229,17 @@ export function addCharacterPromptSuffix(prompt: string): string {
|
||||
return `${cleanPrompt}${cleanPrompt ? ',' : ''}${CHARACTER_PROMPT_SUFFIX}`
|
||||
}
|
||||
|
||||
export function removePropPromptSuffix(prompt: string): string {
|
||||
if (!prompt) return ''
|
||||
return prompt.replace(PROP_PROMPT_SUFFIX, '').replace(/,$/, '').trim()
|
||||
}
|
||||
|
||||
export function addPropPromptSuffix(prompt: string): string {
|
||||
if (!prompt) return PROP_PROMPT_SUFFIX
|
||||
const cleanPrompt = removePropPromptSuffix(prompt)
|
||||
return `${cleanPrompt}${cleanPrompt ? ',' : ''}${PROP_PROMPT_SUFFIX}`
|
||||
}
|
||||
|
||||
// 从提示词中移除场景系统后缀(用于显示给用户)
|
||||
export function removeLocationPromptSuffix(prompt: string): string {
|
||||
if (!prompt) return ''
|
||||
|
||||
@@ -1,171 +1,157 @@
|
||||
import { logError as _ulogError } from '@/lib/logging/core'
|
||||
/**
|
||||
* 图片黑边标签处理工具
|
||||
* 用于给图片添加/更新顶部的黑边文字标签
|
||||
*/
|
||||
|
||||
import sharp from 'sharp'
|
||||
import { uploadObject, getSignedUrl, generateUniqueKey, toFetchableUrl } from '@/lib/storage'
|
||||
import { decodeImageUrlsFromDb, encodeImageUrls } from '@/lib/contracts/image-urls-contract'
|
||||
import { resolveStorageKeyFromMediaValue } from '@/lib/media/service'
|
||||
import { initializeFonts, createLabelSVG } from '@/lib/fonts'
|
||||
|
||||
/**
|
||||
* 更新图片的黑边标签(裁剪旧标签 + 添加新标签)
|
||||
*
|
||||
* @param imageUrl - 原始图片 URL 或 COS key
|
||||
* @param newLabelText - 新的标签文本
|
||||
* @param options - 可选配置
|
||||
* @returns 更新后的 COS key
|
||||
*/
|
||||
async function downloadImageBuffer(imageUrl: string): Promise<Buffer> {
|
||||
const storageKey = await resolveStorageKeyFromMediaValue(imageUrl)
|
||||
if (!storageKey) {
|
||||
throw new Error(`无法归一化媒体 key: ${imageUrl}`)
|
||||
}
|
||||
|
||||
const signedUrl = getSignedUrl(storageKey, 3600)
|
||||
const response = await fetch(toFetchableUrl(signedUrl))
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${response.status}`)
|
||||
}
|
||||
|
||||
return Buffer.from(await response.arrayBuffer())
|
||||
}
|
||||
|
||||
async function createLabeledImageBuffer(sourceBuffer: Buffer, labelText: string): Promise<Buffer> {
|
||||
await initializeFonts()
|
||||
|
||||
const meta = await sharp(sourceBuffer).metadata()
|
||||
const width = meta.width || 2160
|
||||
const height = meta.height || 2160
|
||||
const fontSize = Math.floor(height * 0.04)
|
||||
const pad = Math.floor(fontSize * 0.5)
|
||||
const barHeight = fontSize + pad * 2
|
||||
const svg = await createLabelSVG(width, barHeight, fontSize, pad, labelText)
|
||||
|
||||
return await sharp(sourceBuffer)
|
||||
.extend({
|
||||
top: barHeight,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 1 },
|
||||
})
|
||||
.composite([{ input: svg, top: 0, left: 0 }])
|
||||
.jpeg({ quality: 90, mozjpeg: true })
|
||||
.toBuffer()
|
||||
}
|
||||
|
||||
export async function updateImageLabel(
|
||||
imageUrl: string,
|
||||
newLabelText: string,
|
||||
options?: {
|
||||
/** 是否生成新的 key(默认覆盖原 key) */
|
||||
generateNewKey?: boolean
|
||||
/** 新 key 的前缀(仅当 generateNewKey=true 时有效) */
|
||||
keyPrefix?: string
|
||||
}
|
||||
imageUrl: string,
|
||||
newLabelText: string,
|
||||
options?: {
|
||||
generateNewKey?: boolean
|
||||
keyPrefix?: string
|
||||
},
|
||||
): Promise<string> {
|
||||
await initializeFonts()
|
||||
const originalKey = await resolveStorageKeyFromMediaValue(imageUrl)
|
||||
if (!originalKey) {
|
||||
throw new Error(`无法归一化媒体 key: ${imageUrl}`)
|
||||
}
|
||||
|
||||
const originalKey = await resolveStorageKeyFromMediaValue(imageUrl)
|
||||
if (!originalKey) {
|
||||
throw new Error(`无法归一化媒体 key: ${imageUrl}`)
|
||||
}
|
||||
const signedUrl = getSignedUrl(originalKey, 3600)
|
||||
const buffer = await downloadImageBuffer(imageUrl)
|
||||
const meta = await sharp(buffer).metadata()
|
||||
const width = meta.width || 2160
|
||||
const height = meta.height || 2160
|
||||
const fontSize = Math.floor(height * 0.04)
|
||||
const pad = Math.floor(fontSize * 0.5)
|
||||
const barHeight = fontSize + pad * 2
|
||||
const croppedBuffer = await sharp(buffer)
|
||||
.extract({ left: 0, top: barHeight, width, height: height - barHeight })
|
||||
.toBuffer()
|
||||
const processed = await createLabeledImageBuffer(croppedBuffer, newLabelText)
|
||||
|
||||
// 下载图片
|
||||
const response = await fetch(toFetchableUrl(signedUrl))
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${response.status}`)
|
||||
}
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
const finalKey = options?.generateNewKey
|
||||
? generateUniqueKey(options.keyPrefix || 'labeled-image', 'jpg')
|
||||
: originalKey
|
||||
|
||||
// 获取图片元数据
|
||||
const meta = await sharp(buffer).metadata()
|
||||
const w = meta.width || 2160
|
||||
const h = meta.height || 2160
|
||||
|
||||
// 计算标签条高度(与生成时一致:高度的 4%)
|
||||
const fontSize = Math.floor(h * 0.04)
|
||||
const pad = Math.floor(fontSize * 0.5)
|
||||
const barH = fontSize + pad * 2
|
||||
|
||||
// 裁剪掉顶部的旧标签条
|
||||
const croppedBuffer = await sharp(buffer)
|
||||
.extract({ left: 0, top: barH, width: w, height: h - barH })
|
||||
.toBuffer()
|
||||
|
||||
// 创建新的 SVG 标签条
|
||||
const svg = await createLabelSVG(w, barH, fontSize, pad, newLabelText)
|
||||
|
||||
// 添加新标签条到图片顶部
|
||||
const processed = await sharp(croppedBuffer)
|
||||
.extend({ top: barH, bottom: 0, left: 0, right: 0, background: { r: 0, g: 0, b: 0, alpha: 1 } })
|
||||
.composite([{ input: svg, top: 0, left: 0 }])
|
||||
.jpeg({ quality: 90, mozjpeg: true })
|
||||
.toBuffer()
|
||||
|
||||
// 决定使用原始 key 还是生成新 key
|
||||
const finalKey = options?.generateNewKey
|
||||
? generateUniqueKey(options.keyPrefix || 'labeled-image', 'jpg')
|
||||
: originalKey
|
||||
|
||||
await uploadObject(processed, finalKey)
|
||||
return finalKey
|
||||
await uploadObject(processed, finalKey)
|
||||
return finalKey
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新角色形象的标签
|
||||
* 用于从资产中心复制角色到项目时更新标签
|
||||
*/
|
||||
export async function updateCharacterAppearanceLabels(
|
||||
appearances: Array<{
|
||||
imageUrl: string | null
|
||||
imageUrls: string
|
||||
changeReason: string
|
||||
}>,
|
||||
characterName: string
|
||||
export async function createProjectCharacterLabeledCopies(
|
||||
appearances: Array<{
|
||||
imageUrl: string | null
|
||||
imageUrls: string
|
||||
changeReason: string
|
||||
}>,
|
||||
characterName: string,
|
||||
): Promise<Array<{ imageUrl: string | null; imageUrls: string }>> {
|
||||
const results: Array<{ imageUrl: string | null; imageUrls: string }> = []
|
||||
const results: Array<{ imageUrl: string | null; imageUrls: string }> = []
|
||||
|
||||
for (const appearance of appearances) {
|
||||
try {
|
||||
// 获取图片 URLs
|
||||
let imageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'appearance.imageUrls')
|
||||
if (imageUrls.length === 0 && appearance.imageUrl) {
|
||||
imageUrls = [appearance.imageUrl]
|
||||
}
|
||||
for (const appearance of appearances) {
|
||||
try {
|
||||
let imageUrls = decodeImageUrlsFromDb(appearance.imageUrls, 'appearance.imageUrls')
|
||||
if (imageUrls.length === 0 && appearance.imageUrl) {
|
||||
imageUrls = [appearance.imageUrl]
|
||||
}
|
||||
|
||||
if (imageUrls.length === 0) {
|
||||
results.push({ imageUrl: null, imageUrls: encodeImageUrls([]) })
|
||||
continue
|
||||
}
|
||||
if (imageUrls.length === 0) {
|
||||
results.push({ imageUrl: null, imageUrls: encodeImageUrls([]) })
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新每张图片的标签
|
||||
const newLabelText = `${characterName} - ${appearance.changeReason}`
|
||||
const newImageUrls: string[] = await Promise.all(
|
||||
imageUrls.map(async (url) => {
|
||||
if (!url) return ''
|
||||
try {
|
||||
// 生成新的 key,避免覆盖资产中心的原图
|
||||
return await updateImageLabel(url, newLabelText, {
|
||||
generateNewKey: true,
|
||||
keyPrefix: `project-char-copy`
|
||||
})
|
||||
} catch (e) {
|
||||
_ulogError(`Failed to update label for image:`, e)
|
||||
return url // 失败时保留原 URL
|
||||
}
|
||||
})
|
||||
)
|
||||
const labelText = `${characterName} - ${appearance.changeReason}`
|
||||
const labeledImageUrls = await Promise.all(
|
||||
imageUrls.map(async (imageUrl) => {
|
||||
if (!imageUrl) return ''
|
||||
try {
|
||||
const sourceBuffer = await downloadImageBuffer(imageUrl)
|
||||
const processed = await createLabeledImageBuffer(sourceBuffer, labelText)
|
||||
const newKey = generateUniqueKey('project-char-copy', 'jpg')
|
||||
await uploadObject(processed, newKey)
|
||||
return newKey
|
||||
} catch (error) {
|
||||
_ulogError('Failed to create project character labeled copy:', error)
|
||||
return imageUrl
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const firstUrl = newImageUrls.find((u) => !!u) || null
|
||||
results.push({
|
||||
imageUrl: firstUrl,
|
||||
imageUrls: encodeImageUrls(newImageUrls)
|
||||
})
|
||||
} catch (e) {
|
||||
_ulogError('Failed to update appearance labels:', e)
|
||||
results.push({ imageUrl: appearance.imageUrl, imageUrls: appearance.imageUrls })
|
||||
}
|
||||
results.push({
|
||||
imageUrl: labeledImageUrls.find((url) => !!url) || null,
|
||||
imageUrls: encodeImageUrls(labeledImageUrls),
|
||||
})
|
||||
} catch (error) {
|
||||
_ulogError('Failed to copy project character images:', error)
|
||||
results.push({ imageUrl: appearance.imageUrl, imageUrls: appearance.imageUrls })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新场景图片的标签
|
||||
* 用于从资产中心复制场景到项目时更新标签
|
||||
*/
|
||||
export async function updateLocationImageLabels(
|
||||
images: Array<{
|
||||
imageUrl: string | null
|
||||
}>,
|
||||
locationName: string
|
||||
export async function createProjectLocationLabeledCopies(
|
||||
images: Array<{ imageUrl: string | null }>,
|
||||
locationName: string,
|
||||
): Promise<Array<{ imageUrl: string | null }>> {
|
||||
const results: Array<{ imageUrl: string | null }> = []
|
||||
const results: Array<{ imageUrl: string | null }> = []
|
||||
|
||||
for (const image of images) {
|
||||
if (!image.imageUrl) {
|
||||
results.push({ imageUrl: null })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// 生成新的 key,避免覆盖资产中心的原图
|
||||
const newImageUrl = await updateImageLabel(image.imageUrl, locationName, {
|
||||
generateNewKey: true,
|
||||
keyPrefix: `project-loc-copy`
|
||||
})
|
||||
results.push({ imageUrl: newImageUrl })
|
||||
} catch (e) {
|
||||
_ulogError('Failed to update location image label:', e)
|
||||
results.push({ imageUrl: image.imageUrl })
|
||||
}
|
||||
for (const image of images) {
|
||||
if (!image.imageUrl) {
|
||||
results.push({ imageUrl: null })
|
||||
continue
|
||||
}
|
||||
|
||||
return results
|
||||
try {
|
||||
const sourceBuffer = await downloadImageBuffer(image.imageUrl)
|
||||
const processed = await createLabeledImageBuffer(sourceBuffer, locationName)
|
||||
const newKey = generateUniqueKey('project-location-copy', 'jpg')
|
||||
await uploadObject(processed, newKey)
|
||||
results.push({ imageUrl: newKey })
|
||||
} catch (error) {
|
||||
_ulogError('Failed to create project location labeled copy:', error)
|
||||
results.push({ imageUrl: image.imageUrl })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ const POLICY_BY_TASK_TYPE: Partial<Record<TaskType, LLMTaskPolicy>> = {
|
||||
[TASK_TYPE.AI_STORY_EXPAND]: LLM_STANDARD_POLICY,
|
||||
[TASK_TYPE.AI_MODIFY_APPEARANCE]: LLM_STANDARD_POLICY,
|
||||
[TASK_TYPE.AI_MODIFY_LOCATION]: LLM_STANDARD_POLICY,
|
||||
[TASK_TYPE.AI_MODIFY_PROP]: LLM_STANDARD_POLICY,
|
||||
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: LLM_STANDARD_POLICY,
|
||||
[TASK_TYPE.ANALYZE_SHOT_VARIANTS]: LLM_STANDARD_POLICY,
|
||||
[TASK_TYPE.AI_CREATE_CHARACTER]: LLM_STANDARD_POLICY,
|
||||
@@ -63,6 +64,7 @@ const POLICY_BY_TASK_TYPE: Partial<Record<TaskType, LLMTaskPolicy>> = {
|
||||
[TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION]: LLM_STANDARD_POLICY,
|
||||
[TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER]: LLM_STANDARD_POLICY,
|
||||
[TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION]: LLM_STANDARD_POLICY,
|
||||
[TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP]: LLM_STANDARD_POLICY,
|
||||
[TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER]: LLM_STANDARD_POLICY,
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,10 @@ export const PROMPT_CATALOG: Record<PromptId, PromptCatalogEntry> = {
|
||||
pathStem: 'novel-promotion/location_regenerate',
|
||||
variableKeys: ['location_name', 'current_descriptions'],
|
||||
},
|
||||
[PROMPT_IDS.NP_PROP_DESCRIPTION_UPDATE]: {
|
||||
pathStem: 'novel-promotion/prop_description_update',
|
||||
variableKeys: ['prop_name', 'original_description', 'modify_instruction', 'image_context'],
|
||||
},
|
||||
[PROMPT_IDS.NP_SCREENPLAY_CONVERSION]: {
|
||||
pathStem: 'novel-promotion/screenplay_conversion',
|
||||
variableKeys: ['clip_content', 'locations_lib_name', 'characters_lib_name', 'props_lib_name', 'characters_introduction', 'clip_id'],
|
||||
|
||||
@@ -22,6 +22,7 @@ export const PROMPT_IDS = {
|
||||
NP_LOCATION_DESCRIPTION_UPDATE: 'np_location_description_update',
|
||||
NP_LOCATION_MODIFY: 'np_location_modify',
|
||||
NP_LOCATION_REGENERATE: 'np_location_regenerate',
|
||||
NP_PROP_DESCRIPTION_UPDATE: 'np_prop_description_update',
|
||||
NP_SCREENPLAY_CONVERSION: 'np_screenplay_conversion',
|
||||
NP_SELECT_PROP: 'np_select_prop',
|
||||
NP_SELECT_LOCATION: 'np_select_location',
|
||||
|
||||
5
src/lib/prop-image-prompt.ts
Normal file
5
src/lib/prop-image-prompt.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function buildPropImagePromptCore(params: {
|
||||
description: string
|
||||
}): string {
|
||||
return params.description.trim()
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const BAILIAN_CATALOG: Readonly<Record<OfficialModelModality, readonly string[]>
|
||||
],
|
||||
image: [],
|
||||
video: [
|
||||
'wan2.7-i2v',
|
||||
'wan2.6-i2v-flash',
|
||||
'wan2.6-i2v',
|
||||
'wan2.5-i2v-preview',
|
||||
|
||||
@@ -26,10 +26,14 @@ function assertRegistered(modelId: string): void {
|
||||
|
||||
const BAILIAN_VIDEO_ENDPOINT = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis'
|
||||
const BAILIAN_KF2V_ENDPOINT = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/image2video/video-synthesis'
|
||||
const BAILIAN_KF2V_MODELS = new Set([
|
||||
const BAILIAN_FIRST_LAST_FRAME_ONLY_MODELS = new Set([
|
||||
'wan2.2-kf2v-flash',
|
||||
'wanx2.1-kf2v-plus',
|
||||
])
|
||||
const BAILIAN_FIRST_LAST_FRAME_CAPABLE_MODELS = new Set([
|
||||
...BAILIAN_FIRST_LAST_FRAME_ONLY_MODELS,
|
||||
'wan2.7-i2v',
|
||||
])
|
||||
|
||||
interface BailianVideoSubmitResponse {
|
||||
request_id?: string
|
||||
@@ -71,8 +75,12 @@ function readOptionalPositiveInteger(value: unknown, fieldName: string): number
|
||||
return value
|
||||
}
|
||||
|
||||
function isKf2vModel(modelId: string): boolean {
|
||||
return BAILIAN_KF2V_MODELS.has(modelId)
|
||||
function supportsFirstLastFrame(modelId: string): boolean {
|
||||
return BAILIAN_FIRST_LAST_FRAME_CAPABLE_MODELS.has(modelId)
|
||||
}
|
||||
|
||||
function isFirstLastFrameOnlyModel(modelId: string): boolean {
|
||||
return BAILIAN_FIRST_LAST_FRAME_ONLY_MODELS.has(modelId)
|
||||
}
|
||||
|
||||
function assertNoUnsupportedOptions(options: BailianGenerateRequestOptions): void {
|
||||
@@ -110,12 +118,12 @@ function buildSubmitRequest(params: BailianVideoGenerateParams): {
|
||||
}
|
||||
|
||||
const firstFrameUrl = toFetchableUrl(imageUrl)
|
||||
const kf2v = isKf2vModel(modelId)
|
||||
const lastFrameImageUrl = readTrimmedString(params.options.lastFrameImageUrl)
|
||||
if (kf2v && !lastFrameImageUrl) {
|
||||
const firstLastFrame = !!lastFrameImageUrl
|
||||
if (isFirstLastFrameOnlyModel(modelId) && !firstLastFrame) {
|
||||
throw new Error('BAILIAN_VIDEO_LAST_FRAME_IMAGE_URL_REQUIRED')
|
||||
}
|
||||
if (!kf2v && lastFrameImageUrl) {
|
||||
if (firstLastFrame && !supportsFirstLastFrame(modelId)) {
|
||||
throw new Error(`BAILIAN_VIDEO_LAST_FRAME_UNSUPPORTED_FOR_MODEL: ${modelId}`)
|
||||
}
|
||||
|
||||
@@ -128,7 +136,7 @@ function buildSubmitRequest(params: BailianVideoGenerateParams): {
|
||||
|
||||
const submitBody: BailianVideoSubmitBody = {
|
||||
model: modelId,
|
||||
input: kf2v
|
||||
input: firstLastFrame
|
||||
? {
|
||||
first_frame_url: firstFrameUrl,
|
||||
last_frame_url: toFetchableUrl(lastFrameImageUrl),
|
||||
@@ -162,7 +170,7 @@ function buildSubmitRequest(params: BailianVideoGenerateParams): {
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: kf2v ? BAILIAN_KF2V_ENDPOINT : BAILIAN_VIDEO_ENDPOINT,
|
||||
endpoint: firstLastFrame ? BAILIAN_KF2V_ENDPOINT : BAILIAN_VIDEO_ENDPOINT,
|
||||
body: submitBody,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export {
|
||||
useUpdateLocationSummary,
|
||||
useAiModifyCharacterDescription,
|
||||
useAiModifyLocationDescription,
|
||||
useAiModifyPropDescription,
|
||||
useDesignAssetHubVoice,
|
||||
useSaveDesignedAssetHubVoice,
|
||||
useUploadAssetHubVoice,
|
||||
@@ -98,6 +99,7 @@ export {
|
||||
useUpdateProjectCharacterIntroduction,
|
||||
useAiModifyProjectAppearanceDescription,
|
||||
useAiModifyProjectLocationDescription,
|
||||
useAiModifyProjectPropDescription,
|
||||
useAiCreateProjectLocation,
|
||||
useCreateProjectLocation,
|
||||
useAiCreateProjectCharacter,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { apiFetch } from '@/lib/api-fetch'
|
||||
import { resolveTaskResponse } from '@/lib/task/client'
|
||||
import { queryKeys } from '@/lib/query/keys'
|
||||
import { useTaskTargetStateMap } from '@/lib/query/hooks/useTaskTargetStateMap'
|
||||
import {
|
||||
@@ -398,8 +399,9 @@ export function useAssetActions(input: AssetActionScopeInput) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to modify asset render')
|
||||
}
|
||||
const result = await resolveTaskResponse(response)
|
||||
invalidateScopeQueries(queryClient, input)
|
||||
return response.json()
|
||||
return result
|
||||
}
|
||||
|
||||
const copyFromGlobal = async (payload: { targetId: string; globalAssetId: string }) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { resolveTaskResponse } from '@/lib/task/client'
|
||||
import { apiFetch } from '@/lib/api-fetch'
|
||||
import {
|
||||
requestJsonWithError,
|
||||
requestTaskResponseWithError,
|
||||
@@ -17,7 +16,7 @@ export function useUpdateCharacterName() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ characterId, name }: { characterId: string; name: string }) => {
|
||||
const res = await requestJsonWithError(`/api/assets/${characterId}`, {
|
||||
return await requestJsonWithError(`/api/assets/${characterId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -26,23 +25,6 @@ export function useUpdateCharacterName() {
|
||||
name,
|
||||
}),
|
||||
}, 'Failed to update character name')
|
||||
|
||||
// 等待图片标签更新完成,确保 onSuccess invalidate 后前端能立即看到新标签
|
||||
try {
|
||||
await apiFetch(`/api/assets/${characterId}/update-label`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scope: 'global',
|
||||
kind: 'character',
|
||||
newName: name,
|
||||
}),
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('更新图片标签失败:', e)
|
||||
}
|
||||
|
||||
return res
|
||||
},
|
||||
onSuccess: invalidateCharacters,
|
||||
})
|
||||
@@ -54,7 +36,7 @@ export function useUpdateLocationName() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ locationId, name }: { locationId: string; name: string }) => {
|
||||
const res = await requestJsonWithError(`/api/assets/${locationId}`, {
|
||||
return await requestJsonWithError(`/api/assets/${locationId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -63,23 +45,6 @@ export function useUpdateLocationName() {
|
||||
name,
|
||||
}),
|
||||
}, 'Failed to update location name')
|
||||
|
||||
// 等待图片标签更新完成,确保 onSuccess invalidate 后前端能立即看到新标签
|
||||
try {
|
||||
await apiFetch(`/api/assets/${locationId}/update-label`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
scope: 'global',
|
||||
kind: 'location',
|
||||
newName: name,
|
||||
}),
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('更新图片标签失败:', e)
|
||||
}
|
||||
|
||||
return res
|
||||
},
|
||||
onSuccess: invalidateLocations,
|
||||
})
|
||||
@@ -219,3 +184,35 @@ export function useAiModifyLocationDescription() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAiModifyPropDescription() {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
propId,
|
||||
variantId,
|
||||
currentDescription,
|
||||
modifyInstruction,
|
||||
}: {
|
||||
propId: string
|
||||
variantId?: string
|
||||
currentDescription: string
|
||||
modifyInstruction: string
|
||||
}) => {
|
||||
const response = await requestTaskResponseWithError(
|
||||
'/api/asset-hub/ai-modify-prop',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
propId,
|
||||
variantId,
|
||||
currentDescription,
|
||||
modifyInstruction,
|
||||
}),
|
||||
},
|
||||
'Failed to modify prop description',
|
||||
)
|
||||
return resolveTaskResponse<{ modifiedDescription?: string }>(response)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
import {
|
||||
invalidateQueryTemplates,
|
||||
requestJsonWithError,
|
||||
requestTaskResponseWithError,
|
||||
} from './mutation-shared'
|
||||
import { resolveTaskResponse } from '@/lib/task/client'
|
||||
|
||||
export function useModifyProjectCharacterImage(projectId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -26,7 +28,7 @@ export function useModifyProjectCharacterImage(projectId: string) {
|
||||
modifyPrompt: string
|
||||
extraImageUrls?: string[]
|
||||
}) => {
|
||||
return await requestJsonWithError(`/api/assets/${params.characterId}/modify-render`, {
|
||||
const response = await requestTaskResponseWithError(`/api/assets/${params.characterId}/modify-render`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -36,6 +38,7 @@ export function useModifyProjectCharacterImage(projectId: string) {
|
||||
...params,
|
||||
}),
|
||||
}, 'Failed to modify image')
|
||||
return await resolveTaskResponse(response)
|
||||
},
|
||||
onMutate: ({ appearanceId }) => {
|
||||
upsertTaskTargetOverlay(queryClient, {
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
useUpdateLocationSummary,
|
||||
useAiModifyCharacterDescription,
|
||||
useAiModifyLocationDescription,
|
||||
useAiModifyPropDescription,
|
||||
useUploadAssetHubTempMedia,
|
||||
useAiDesignCharacter,
|
||||
useExtractAssetHubReferenceCharacterDescription,
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
import {
|
||||
invalidateQueryTemplates,
|
||||
requestJsonWithError,
|
||||
requestTaskResponseWithError,
|
||||
} from './mutation-shared'
|
||||
import { resolveTaskResponse } from '@/lib/task/client'
|
||||
|
||||
interface SelectProjectLocationImageContext {
|
||||
previousAssets: ProjectAssetsData | undefined
|
||||
@@ -187,7 +189,7 @@ export function useModifyProjectLocationImage(projectId: string) {
|
||||
modifyPrompt: string
|
||||
extraImageUrls?: string[]
|
||||
}) => {
|
||||
return await requestJsonWithError(`/api/assets/${params.locationId}/modify-render`, {
|
||||
const response = await requestTaskResponseWithError(`/api/assets/${params.locationId}/modify-render`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -197,6 +199,7 @@ export function useModifyProjectLocationImage(projectId: string) {
|
||||
...params,
|
||||
}),
|
||||
}, 'Failed to modify image')
|
||||
return await resolveTaskResponse(response)
|
||||
},
|
||||
onMutate: ({ locationId }) => {
|
||||
upsertTaskTargetOverlay(queryClient, {
|
||||
|
||||
@@ -208,6 +208,38 @@ export function useAiModifyProjectLocationDescription(projectId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useAiModifyProjectPropDescription(projectId: string) {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
propId,
|
||||
variantId,
|
||||
currentDescription,
|
||||
modifyInstruction,
|
||||
}: {
|
||||
propId: string
|
||||
variantId?: string
|
||||
currentDescription: string
|
||||
modifyInstruction: string
|
||||
}) => {
|
||||
const response = await requestTaskResponseWithError(
|
||||
`/api/novel-promotion/${projectId}/ai-modify-prop`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
propId,
|
||||
variantId,
|
||||
currentDescription,
|
||||
modifyInstruction,
|
||||
}),
|
||||
},
|
||||
'Failed to modify prop description',
|
||||
)
|
||||
return resolveTaskResponse<{ modifiedDescription?: string }>(response)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 设计项目场景描述
|
||||
*/
|
||||
@@ -263,18 +295,26 @@ export function useCreateProjectLocation(projectId: string) {
|
||||
* AI 设计项目角色文案
|
||||
*/
|
||||
|
||||
export function useConfirmProjectLocationSelection(projectId: string) {
|
||||
export function useConfirmProjectLocationSelection(
|
||||
projectId: string,
|
||||
kind: 'location' | 'prop' = 'location',
|
||||
) {
|
||||
const queryClient = useQueryClient()
|
||||
const invalidateProjectAssets = () =>
|
||||
invalidateQueryTemplates(queryClient, [queryKeys.projectAssets.all(projectId)])
|
||||
return useMutation({
|
||||
mutationFn: async ({ locationId }: { locationId: string }) =>
|
||||
await requestJsonWithError(
|
||||
`/api/novel-promotion/${projectId}/location/confirm-selection`,
|
||||
`/api/assets/${locationId}/select-render`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locationId }),
|
||||
body: JSON.stringify({
|
||||
scope: 'project',
|
||||
kind,
|
||||
projectId,
|
||||
confirm: true,
|
||||
}),
|
||||
},
|
||||
'确认选择失败',
|
||||
),
|
||||
|
||||
@@ -47,6 +47,7 @@ const TASK_INTENT_BY_TYPE: Record<TaskType, TaskIntent> = {
|
||||
[TASK_TYPE.AI_STORY_EXPAND]: 'generate',
|
||||
[TASK_TYPE.AI_MODIFY_APPEARANCE]: 'modify',
|
||||
[TASK_TYPE.AI_MODIFY_LOCATION]: 'modify',
|
||||
[TASK_TYPE.AI_MODIFY_PROP]: 'modify',
|
||||
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: 'modify',
|
||||
[TASK_TYPE.ANALYZE_SHOT_VARIANTS]: 'analyze',
|
||||
[TASK_TYPE.AI_CREATE_CHARACTER]: 'generate',
|
||||
@@ -59,6 +60,7 @@ const TASK_INTENT_BY_TYPE: Record<TaskType, TaskIntent> = {
|
||||
[TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION]: 'generate',
|
||||
[TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER]: 'modify',
|
||||
[TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION]: 'modify',
|
||||
[TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP]: 'modify',
|
||||
[TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER]: 'process',
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const TASK_TYPE_LABELS: Record<string, string> = {
|
||||
[TASK_TYPE.AI_STORY_EXPAND]: 'progress.taskType.aiStoryExpand',
|
||||
[TASK_TYPE.AI_MODIFY_APPEARANCE]: 'progress.taskType.aiModifyAppearance',
|
||||
[TASK_TYPE.AI_MODIFY_LOCATION]: 'progress.taskType.aiModifyLocation',
|
||||
[TASK_TYPE.AI_MODIFY_PROP]: 'progress.taskType.aiModifyProp',
|
||||
[TASK_TYPE.AI_MODIFY_SHOT_PROMPT]: 'progress.taskType.aiModifyShotPrompt',
|
||||
[TASK_TYPE.ANALYZE_SHOT_VARIANTS]: 'progress.taskType.analyzeShotVariants',
|
||||
[TASK_TYPE.AI_CREATE_CHARACTER]: 'progress.taskType.aiCreateCharacter',
|
||||
@@ -38,6 +39,7 @@ const TASK_TYPE_LABELS: Record<string, string> = {
|
||||
[TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION]: 'progress.taskType.assetHubAiDesignLocation',
|
||||
[TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER]: 'progress.taskType.assetHubAiModifyCharacter',
|
||||
[TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION]: 'progress.taskType.assetHubAiModifyLocation',
|
||||
[TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP]: 'progress.taskType.assetHubAiModifyProp',
|
||||
[TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER]: 'progress.taskType.assetHubReferenceToCharacter',
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export const TASK_TYPE = {
|
||||
AI_STORY_EXPAND: 'ai_story_expand',
|
||||
AI_MODIFY_APPEARANCE: 'ai_modify_appearance',
|
||||
AI_MODIFY_LOCATION: 'ai_modify_location',
|
||||
AI_MODIFY_PROP: 'ai_modify_prop',
|
||||
AI_MODIFY_SHOT_PROMPT: 'ai_modify_shot_prompt',
|
||||
ANALYZE_SHOT_VARIANTS: 'analyze_shot_variants',
|
||||
AI_CREATE_CHARACTER: 'ai_create_character',
|
||||
@@ -75,6 +76,7 @@ export const TASK_TYPE = {
|
||||
ASSET_HUB_AI_DESIGN_LOCATION: 'asset_hub_ai_design_location',
|
||||
ASSET_HUB_AI_MODIFY_CHARACTER: 'asset_hub_ai_modify_character',
|
||||
ASSET_HUB_AI_MODIFY_LOCATION: 'asset_hub_ai_modify_location',
|
||||
ASSET_HUB_AI_MODIFY_PROP: 'asset_hub_ai_modify_prop',
|
||||
ASSET_HUB_REFERENCE_TO_CHARACTER: 'asset_hub_reference_to_character',
|
||||
} as const
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from './analyze-global-parse'
|
||||
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
|
||||
import { normalizeLocationAvailableSlots } from '@/lib/location-available-slots'
|
||||
import { resolvePropVisualDescription } from '@/lib/assets/prop-description'
|
||||
|
||||
export type AnalyzeGlobalStats = {
|
||||
totalChunks: number
|
||||
@@ -198,7 +199,12 @@ export async function persistAnalyzeGlobalChunk(params: {
|
||||
for (const prop of params.propsData.props || []) {
|
||||
const name = readText(prop.name).trim()
|
||||
const summary = readText(prop.summary).trim()
|
||||
if (!name || !summary) {
|
||||
const description = resolvePropVisualDescription({
|
||||
name,
|
||||
summary,
|
||||
description: readText(prop.description).trim(),
|
||||
})
|
||||
if (!name || !summary || !description) {
|
||||
params.stats.skippedProps += 1
|
||||
continue
|
||||
}
|
||||
@@ -220,8 +226,8 @@ export async function persistAnalyzeGlobalChunk(params: {
|
||||
})
|
||||
await seedProjectLocationBackedImageSlots({
|
||||
locationId: created.id,
|
||||
descriptions: [summary],
|
||||
fallbackDescription: summary,
|
||||
descriptions: [description],
|
||||
fallbackDescription: description,
|
||||
availableSlots: [],
|
||||
})
|
||||
params.existingPropNames.push(name)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
|
||||
import { resolveAnalysisModel } from './resolve-analysis-model'
|
||||
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
|
||||
import { normalizeLocationAvailableSlots } from '@/lib/location-available-slots'
|
||||
import { resolvePropVisualDescription } from '@/lib/assets/prop-description'
|
||||
|
||||
function readAssetKind(value: Record<string, unknown>): string {
|
||||
return typeof value.assetKind === 'string' ? value.assetKind : 'location'
|
||||
@@ -337,7 +338,12 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
|
||||
for (const item of parsedProps) {
|
||||
const name = readText(item.name).trim()
|
||||
const summary = readText(item.summary).trim()
|
||||
if (!name || !summary) continue
|
||||
const description = resolvePropVisualDescription({
|
||||
name,
|
||||
summary,
|
||||
description: readText(item.description).trim(),
|
||||
})
|
||||
if (!name || !summary || !description) continue
|
||||
|
||||
const normalizedName = name.toLowerCase()
|
||||
if (existingPropNameSet.has(normalizedName)) continue
|
||||
@@ -353,8 +359,8 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
|
||||
})
|
||||
await seedProjectLocationBackedImageSlots({
|
||||
locationId: created.id,
|
||||
descriptions: [summary],
|
||||
fallbackDescription: summary,
|
||||
descriptions: [description],
|
||||
fallbackDescription: description,
|
||||
availableSlots: [],
|
||||
})
|
||||
existingPropNameSet.add(normalizedName)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { executeAiTextStep } from '@/lib/ai-runtime'
|
||||
import { getUserModelConfig } from '@/lib/config-service'
|
||||
import { removeCharacterPromptSuffix, removeLocationPromptSuffix } from '@/lib/constants'
|
||||
import { removeCharacterPromptSuffix, removeLocationPromptSuffix, removePropPromptSuffix } from '@/lib/constants'
|
||||
import { withInternalLLMStreamCallbacks } from '@/lib/llm-observe/internal-stream-context'
|
||||
import { reportTaskProgress } from '@/lib/workers/shared'
|
||||
import { assertTaskActive } from '@/lib/workers/utils'
|
||||
@@ -44,11 +44,12 @@ export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
|
||||
|
||||
const isCharacter = job.data.type === TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER
|
||||
const isLocation = job.data.type === TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION
|
||||
if (!isCharacter && !isLocation) {
|
||||
const isProp = job.data.type === TASK_TYPE.ASSET_HUB_AI_MODIFY_PROP
|
||||
if (!isCharacter && !isLocation && !isProp) {
|
||||
throw new Error(`Unsupported task type: ${job.data.type}`)
|
||||
}
|
||||
|
||||
const targetIdField = isCharacter ? 'characterId' : 'locationId'
|
||||
const targetIdField = isCharacter ? 'characterId' : isProp ? 'propId' : 'locationId'
|
||||
const targetId = readRequiredString(payload[targetIdField], targetIdField)
|
||||
const modifyInstruction = readRequiredString(payload.modifyInstruction, 'modifyInstruction')
|
||||
const currentDescriptionRaw = readRequiredString(payload.currentDescription, 'currentDescription')
|
||||
@@ -62,6 +63,17 @@ export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
|
||||
user_input: modifyInstruction,
|
||||
},
|
||||
})
|
||||
: isProp
|
||||
? buildPrompt({
|
||||
promptId: PROMPT_IDS.NP_PROP_DESCRIPTION_UPDATE,
|
||||
locale: job.data.locale,
|
||||
variables: {
|
||||
prop_name: readRequiredString(payload.propName || '道具', 'propName'),
|
||||
original_description: removePropPromptSuffix(currentDescriptionRaw),
|
||||
modify_instruction: modifyInstruction,
|
||||
image_context: '',
|
||||
},
|
||||
})
|
||||
: buildPrompt({
|
||||
promptId: PROMPT_IDS.NP_LOCATION_MODIFY,
|
||||
locale: job.data.locale,
|
||||
@@ -79,7 +91,12 @@ export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
|
||||
})
|
||||
await assertTaskActive(job, 'asset_hub_ai_modify_prepare')
|
||||
|
||||
const streamContext = createWorkerLLMStreamContext(job, isCharacter ? 'asset_hub_ai_modify_character' : 'asset_hub_ai_modify_location')
|
||||
const streamContextKey = isCharacter
|
||||
? 'asset_hub_ai_modify_character'
|
||||
: isProp
|
||||
? 'asset_hub_ai_modify_prop'
|
||||
: 'asset_hub_ai_modify_location'
|
||||
const streamContext = createWorkerLLMStreamContext(job, streamContextKey)
|
||||
const streamCallbacks = createWorkerLLMStreamCallbacks(job, streamContext)
|
||||
|
||||
const completion = await withInternalLLMStreamCallbacks(
|
||||
@@ -91,10 +108,10 @@ export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
|
||||
messages: [{ role: 'user', content: finalPrompt }],
|
||||
temperature: 0.7,
|
||||
projectId: 'asset-hub',
|
||||
action: isCharacter ? 'ai_modify_character' : 'ai_modify_location',
|
||||
action: isCharacter ? 'ai_modify_character' : isProp ? 'ai_modify_prop' : 'ai_modify_location',
|
||||
meta: {
|
||||
stepId: isCharacter ? 'asset_hub_ai_modify_character' : 'asset_hub_ai_modify_location',
|
||||
stepTitle: isCharacter ? '角色描述修改' : '场景描述修改',
|
||||
stepId: streamContextKey,
|
||||
stepTitle: isCharacter ? '角色描述修改' : isProp ? '道具描述修改' : '场景描述修改',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
},
|
||||
@@ -110,7 +127,7 @@ export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
|
||||
stageLabel: '资产修改结果已生成',
|
||||
displayMode: 'detail',
|
||||
meta: {
|
||||
targetType: isCharacter ? 'character' : 'location',
|
||||
targetType: isCharacter ? 'character' : isProp ? 'prop' : 'location',
|
||||
targetId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { type Job } from 'bullmq'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { addCharacterPromptSuffix, addLocationPromptSuffix, getArtStylePrompt } from '@/lib/constants'
|
||||
import { CHARACTER_ASSET_IMAGE_RATIO, LOCATION_IMAGE_RATIO, PROP_IMAGE_RATIO, addCharacterPromptSuffix, addLocationPromptSuffix, addPropPromptSuffix, getArtStylePrompt } from '@/lib/constants'
|
||||
import { type TaskJobData } from '@/lib/task/types'
|
||||
import { encodeImageUrls } from '@/lib/contracts/image-urls-contract'
|
||||
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
|
||||
import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'
|
||||
import { buildLocationImagePromptCore } from '@/lib/location-image-prompt'
|
||||
import { buildPropImagePromptCore } from '@/lib/prop-image-prompt'
|
||||
import {
|
||||
assertTaskActive,
|
||||
getUserModels,
|
||||
} from '../utils'
|
||||
import {
|
||||
AnyObj,
|
||||
generateLabeledImageToCos,
|
||||
generateCleanImageToStorage,
|
||||
parseJsonStringArray,
|
||||
} from './image-task-handler-shared'
|
||||
|
||||
@@ -93,19 +94,18 @@ export async function handleAssetHubImageTask(job: Job<TaskJobData>) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const raw = base[i] || base[0]
|
||||
const prompt = artStyle ? `${addCharacterPromptSuffix(raw)},${artStyle}` : addCharacterPromptSuffix(raw)
|
||||
const cosKey = await generateLabeledImageToCos({
|
||||
const imageKey = await generateCleanImageToStorage({
|
||||
job,
|
||||
userId,
|
||||
modelId,
|
||||
prompt,
|
||||
label: `${character.name} - ${appearance.changeReason || '形象'}`,
|
||||
targetId: `${appearance.id}-${i}`,
|
||||
keyPrefix: 'global-character',
|
||||
options: {
|
||||
aspectRatio: '3:2',
|
||||
aspectRatio: CHARACTER_ASSET_IMAGE_RATIO,
|
||||
},
|
||||
})
|
||||
imageUrls.push(cosKey)
|
||||
imageUrls.push(imageKey)
|
||||
}
|
||||
|
||||
await assertTaskActive(job, 'persist_global_character_image')
|
||||
@@ -121,7 +121,7 @@ export async function handleAssetHubImageTask(job: Job<TaskJobData>) {
|
||||
return { type: payload.type, appearanceId: appearance.id, imageCount: imageUrls.length }
|
||||
}
|
||||
|
||||
if (payload.type === 'location') {
|
||||
if (payload.type === 'location' || payload.type === 'prop') {
|
||||
const locationId = typeof payload.id === 'string' ? payload.id : null
|
||||
if (!locationId) throw new Error('Global location id missing')
|
||||
|
||||
@@ -142,30 +142,37 @@ export async function handleAssetHubImageTask(job: Job<TaskJobData>) {
|
||||
|
||||
for (const image of targetImages) {
|
||||
if (!image.description) continue
|
||||
const promptCore = buildLocationImagePromptCore({
|
||||
description: image.description,
|
||||
availableSlotsRaw: image.availableSlots,
|
||||
locale: job.data.locale === 'en' ? 'en' : 'zh',
|
||||
})
|
||||
const prompt = artStyle ? `${addLocationPromptSuffix(promptCore)},${artStyle}` : addLocationPromptSuffix(promptCore)
|
||||
const promptCore = payload.type === 'prop'
|
||||
? buildPropImagePromptCore({
|
||||
description: image.description,
|
||||
})
|
||||
: buildLocationImagePromptCore({
|
||||
description: image.description,
|
||||
availableSlotsRaw: image.availableSlots,
|
||||
locale: job.data.locale === 'en' ? 'en' : 'zh',
|
||||
})
|
||||
const promptWithSuffix = payload.type === 'prop'
|
||||
? addPropPromptSuffix(promptCore)
|
||||
: addLocationPromptSuffix(promptCore)
|
||||
const prompt = artStyle ? `${promptWithSuffix},${artStyle}` : promptWithSuffix
|
||||
const aspectRatio = payload.type === 'prop' ? PROP_IMAGE_RATIO : LOCATION_IMAGE_RATIO
|
||||
|
||||
const cosKey = await generateLabeledImageToCos({
|
||||
const imageKey = await generateCleanImageToStorage({
|
||||
job,
|
||||
userId,
|
||||
modelId,
|
||||
prompt,
|
||||
label: location.name,
|
||||
targetId: image.id,
|
||||
keyPrefix: 'global-location',
|
||||
options: {
|
||||
aspectRatio: '1:1',
|
||||
aspectRatio,
|
||||
},
|
||||
})
|
||||
|
||||
await assertTaskActive(job, 'persist_global_location_image')
|
||||
await db.globalLocationImage.update({
|
||||
where: { id: image.id },
|
||||
data: { imageUrl: cosKey },
|
||||
data: { imageUrl: imageKey },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { type Job } from 'bullmq'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { LOCATION_IMAGE_RATIO, PROP_IMAGE_RATIO } from '@/lib/constants'
|
||||
import { type TaskJobData } from '@/lib/task/types'
|
||||
import {
|
||||
assertTaskActive,
|
||||
getUserModels,
|
||||
resolveImageSourceFromGeneration,
|
||||
stripLabelBar,
|
||||
toSignedUrlIfCos,
|
||||
uploadImageSourceToCos,
|
||||
withLabelBar,
|
||||
} from '../utils'
|
||||
import {
|
||||
normalizeReferenceImagesForGeneration,
|
||||
@@ -124,9 +123,8 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const requiredReference = await stripLabelBar(currentUrl)
|
||||
const normalizedExtras = await normalizeReferenceImagesForGeneration(extraReferenceInputs)
|
||||
const referenceImages = Array.from(new Set([requiredReference, ...normalizedExtras]))
|
||||
const referenceImages = Array.from(new Set([currentUrl, ...normalizedExtras]))
|
||||
const currentDescription = readIndexedDescription({
|
||||
descriptions: appearance.descriptions,
|
||||
fallbackDescription: appearance.description,
|
||||
@@ -145,12 +143,10 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
|
||||
},
|
||||
})
|
||||
|
||||
const label = `${character.name} - ${appearance.changeReason || '形象'}`
|
||||
const labeled = await withLabelBar(source, label)
|
||||
const cosKey = await uploadImageSourceToCos(labeled, 'global-character-modify', appearance.id)
|
||||
const imageKey = await uploadImageSourceToCos(source, 'global-character-modify', appearance.id)
|
||||
|
||||
while (imageUrls.length <= targetImageIndex) imageUrls.push('')
|
||||
imageUrls[targetImageIndex] = cosKey
|
||||
imageUrls[targetImageIndex] = imageKey
|
||||
|
||||
const selectedIndex = appearance.selectedIndex
|
||||
const shouldUpdateMain = selectedIndex === targetImageIndex || selectedIndex === null || imageUrls.length === 1
|
||||
@@ -187,15 +183,15 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
|
||||
previousDescription: appearance.description || null,
|
||||
previousDescriptions: appearance.descriptions ?? null,
|
||||
imageUrls: encodeImageUrls(imageUrls),
|
||||
imageUrl: shouldUpdateMain ? cosKey : appearance.imageUrl,
|
||||
imageUrl: shouldUpdateMain ? imageKey : appearance.imageUrl,
|
||||
...(descriptionFields || {}),
|
||||
},
|
||||
})
|
||||
|
||||
return { type: payload.type, appearanceId: appearance.id, imageUrl: cosKey }
|
||||
return { type: payload.type, appearanceId: appearance.id, imageUrl: imageKey }
|
||||
}
|
||||
|
||||
if (payload.type === 'location') {
|
||||
if (payload.type === 'location' || payload.type === 'prop') {
|
||||
const location = await db.globalLocation.findFirst({
|
||||
where: { id: payload.id, userId },
|
||||
include: { images: true },
|
||||
@@ -217,24 +213,26 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const requiredReference = await stripLabelBar(currentUrl)
|
||||
const normalizedExtras = await normalizeReferenceImagesForGeneration(extraReferenceInputs)
|
||||
const referenceImages = Array.from(new Set([requiredReference, ...normalizedExtras]))
|
||||
const referenceImages = Array.from(new Set([currentUrl, ...normalizedExtras]))
|
||||
|
||||
const prompt = `请根据以下指令修改场景图片,保持整体风格一致:\n${modifyInstruction}`
|
||||
const isProp = payload.type === 'prop'
|
||||
const prompt = isProp
|
||||
? `请根据以下指令修改道具图片,保持道具主体、结构和关键材质一致:\n${modifyInstruction}`
|
||||
: `请根据以下指令修改场景图片,保持整体风格一致:\n${modifyInstruction}`
|
||||
const aspectRatio = isProp ? PROP_IMAGE_RATIO : LOCATION_IMAGE_RATIO
|
||||
const source = await resolveImageSourceFromGeneration(job, {
|
||||
userId,
|
||||
modelId: editModel,
|
||||
prompt,
|
||||
options: {
|
||||
referenceImages,
|
||||
aspectRatio: '1:1',
|
||||
aspectRatio,
|
||||
...(resolution ? { resolution } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
const labeled = await withLabelBar(source, location.name)
|
||||
const cosKey = await uploadImageSourceToCos(labeled, 'global-location-modify', locationImage.id)
|
||||
const imageKey = await uploadImageSourceToCos(source, isProp ? 'global-prop-modify' : 'global-location-modify', locationImage.id)
|
||||
|
||||
let extractedDescription: {
|
||||
prompt: string
|
||||
@@ -246,24 +244,25 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
|
||||
userId,
|
||||
model: userModels.analysisModel,
|
||||
locale: job.data.locale,
|
||||
type: 'location',
|
||||
type: isProp ? 'prop' : 'location',
|
||||
currentDescription: locationImage.description,
|
||||
modifyInstruction,
|
||||
referenceImages: normalizedExtras,
|
||||
locationName: location.name,
|
||||
propName: isProp ? location.name : undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn({ message: '资产库场景描述同步失败', details: { error: String(err) } })
|
||||
logger.warn({ message: isProp ? '资产库道具描述同步失败' : '资产库场景描述同步失败', details: { error: String(err) } })
|
||||
}
|
||||
}
|
||||
|
||||
await assertTaskActive(job, 'persist_global_location_modify')
|
||||
await assertTaskActive(job, isProp ? 'persist_global_prop_modify' : 'persist_global_location_modify')
|
||||
await db.globalLocationImage.update({
|
||||
where: { id: locationImage.id },
|
||||
data: {
|
||||
previousImageUrl: locationImage.imageUrl,
|
||||
previousDescription: locationImage.description || null,
|
||||
imageUrl: cosKey,
|
||||
imageUrl: imageKey,
|
||||
...(extractedDescription ? {
|
||||
description: extractedDescription.prompt,
|
||||
availableSlots: stringifyLocationAvailableSlots(extractedDescription.availableSlots),
|
||||
@@ -271,7 +270,7 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
|
||||
},
|
||||
})
|
||||
|
||||
return { type: payload.type, locationImageId: locationImage.id, imageUrl: cosKey }
|
||||
return { type: payload.type, locationImageId: locationImage.id, imageUrl: imageKey }
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported asset-hub modify type: ${String(payload.type)}`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Job } from 'bullmq'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { addCharacterPromptSuffix, getArtStylePrompt, isArtStyleValue, PRIMARY_APPEARANCE_INDEX, type ArtStyleValue } from '@/lib/constants'
|
||||
import { CHARACTER_ASSET_IMAGE_RATIO, addCharacterPromptSuffix, getArtStylePrompt, isArtStyleValue, PRIMARY_APPEARANCE_INDEX, type ArtStyleValue } from '@/lib/constants'
|
||||
import { type TaskJobData } from '@/lib/task/types'
|
||||
import { encodeImageUrls } from '@/lib/contracts/image-urls-contract'
|
||||
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { normalizeReferenceImagesForGeneration } from '@/lib/media/outbound-image'
|
||||
import {
|
||||
AnyObj,
|
||||
generateLabeledImageToCos,
|
||||
generateProjectLabeledImageToStorage,
|
||||
parseImageUrls,
|
||||
parseJsonStringArray,
|
||||
pickFirstString,
|
||||
@@ -152,7 +152,7 @@ export async function handleCharacterImageTask(job: Job<TaskJobData>) {
|
||||
index,
|
||||
})
|
||||
|
||||
const cosKey = await generateLabeledImageToCos({
|
||||
const imageKey = await generateProjectLabeledImageToStorage({
|
||||
job,
|
||||
userId,
|
||||
modelId,
|
||||
@@ -162,14 +162,14 @@ export async function handleCharacterImageTask(job: Job<TaskJobData>) {
|
||||
keyPrefix: 'character',
|
||||
options: {
|
||||
referenceImages: primaryReferenceImages.length > 0 ? primaryReferenceImages : undefined,
|
||||
aspectRatio: '3:2',
|
||||
aspectRatio: CHARACTER_ASSET_IMAGE_RATIO,
|
||||
},
|
||||
})
|
||||
|
||||
while (nextImageUrls.length <= index) {
|
||||
nextImageUrls.push('')
|
||||
}
|
||||
nextImageUrls[index] = cosKey
|
||||
nextImageUrls[index] = imageKey
|
||||
}
|
||||
|
||||
const selectedIndex = appearance.selectedIndex
|
||||
|
||||
@@ -95,7 +95,51 @@ export function pickFirstString(...values: unknown[]) {
|
||||
return null
|
||||
}
|
||||
|
||||
export async function generateLabeledImageToCos(params: {
|
||||
async function generateImageToStorage(params: {
|
||||
job: Job<TaskJobData>
|
||||
userId: string
|
||||
modelId: string
|
||||
prompt: string
|
||||
targetId: string
|
||||
keyPrefix: string
|
||||
options?: {
|
||||
referenceImages?: string[]
|
||||
aspectRatio?: string
|
||||
size?: string
|
||||
}
|
||||
label?: string
|
||||
}) {
|
||||
const source = await resolveImageSourceFromGeneration(params.job, {
|
||||
userId: params.userId,
|
||||
modelId: params.modelId,
|
||||
prompt: params.prompt,
|
||||
options: params.options,
|
||||
})
|
||||
|
||||
const uploadSource = params.label
|
||||
? await withLabelBar(source, params.label)
|
||||
: source
|
||||
const cosKey = await uploadImageSourceToCos(uploadSource, params.keyPrefix, params.targetId)
|
||||
return cosKey
|
||||
}
|
||||
|
||||
export async function generateCleanImageToStorage(params: {
|
||||
job: Job<TaskJobData>
|
||||
userId: string
|
||||
modelId: string
|
||||
prompt: string
|
||||
targetId: string
|
||||
keyPrefix: string
|
||||
options?: {
|
||||
referenceImages?: string[]
|
||||
aspectRatio?: string
|
||||
size?: string
|
||||
}
|
||||
}) {
|
||||
return await generateImageToStorage(params)
|
||||
}
|
||||
|
||||
export async function generateProjectLabeledImageToStorage(params: {
|
||||
job: Job<TaskJobData>
|
||||
userId: string
|
||||
modelId: string
|
||||
@@ -109,16 +153,7 @@ export async function generateLabeledImageToCos(params: {
|
||||
size?: string
|
||||
}
|
||||
}) {
|
||||
const source = await resolveImageSourceFromGeneration(params.job, {
|
||||
userId: params.userId,
|
||||
modelId: params.modelId,
|
||||
prompt: params.prompt,
|
||||
options: params.options,
|
||||
})
|
||||
|
||||
const labeled = await withLabelBar(source, params.label)
|
||||
const cosKey = await uploadImageSourceToCos(labeled, params.keyPrefix, params.targetId)
|
||||
return cosKey
|
||||
return await generateImageToStorage(params)
|
||||
}
|
||||
|
||||
export async function resolveNovelData(projectId: string) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type Job } from 'bullmq'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { LOCATION_IMAGE_RATIO, PROP_IMAGE_RATIO } from '@/lib/constants'
|
||||
import { type TaskJobData } from '@/lib/task/types'
|
||||
import { encodeImageUrls } from '@/lib/contracts/image-urls-contract'
|
||||
import {
|
||||
@@ -168,7 +169,7 @@ export async function handleModifyAssetImageTask(job: Job<TaskJobData>) {
|
||||
return { type, appearanceId: appearance.id, imageIndex, imageUrl: cosKey }
|
||||
}
|
||||
|
||||
if (type === 'location') {
|
||||
if (type === 'location' || type === 'prop') {
|
||||
const locationImageId = pickFirstString(payload.locationImageId, payload.targetId, job.data.targetId)
|
||||
let locationImage: LocationImageRecord | null = locationImageId
|
||||
? await prisma.locationImage.findUnique({
|
||||
@@ -204,21 +205,25 @@ export async function handleModifyAssetImageTask(job: Job<TaskJobData>) {
|
||||
const normalizedExtras = await normalizeReferenceImagesForGeneration(extraReferenceInputs)
|
||||
const referenceImages = Array.from(new Set([requiredReference, ...normalizedExtras]))
|
||||
|
||||
const prompt = `请根据以下指令修改场景图片,保持整体风格一致:\n${modifyInstruction}`
|
||||
const isProp = type === 'prop'
|
||||
const prompt = isProp
|
||||
? `请根据以下指令修改道具图片,保持道具主体、结构和关键材质一致:\n${modifyInstruction}`
|
||||
: `请根据以下指令修改场景图片,保持整体风格一致:\n${modifyInstruction}`
|
||||
const aspectRatio = isProp ? PROP_IMAGE_RATIO : LOCATION_IMAGE_RATIO
|
||||
const source = await resolveImageSourceFromGeneration(job, {
|
||||
userId: job.data.userId,
|
||||
modelId: editModel,
|
||||
prompt,
|
||||
options: {
|
||||
referenceImages,
|
||||
aspectRatio: '1:1',
|
||||
aspectRatio,
|
||||
...(resolution ? { resolution } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
const label = locationImage.location?.name || '场景'
|
||||
const label = locationImage.location?.name || (isProp ? '道具' : '场景')
|
||||
const labeled = await withLabelBar(source, label)
|
||||
const cosKey = await uploadImageSourceToCos(labeled, 'location-modify', locationImage.id)
|
||||
const cosKey = await uploadImageSourceToCos(labeled, isProp ? 'prop-modify' : 'location-modify', locationImage.id)
|
||||
|
||||
let extractedDescription: {
|
||||
prompt: string
|
||||
@@ -233,20 +238,21 @@ export async function handleModifyAssetImageTask(job: Job<TaskJobData>) {
|
||||
userId: job.data.userId,
|
||||
model: analysisModel,
|
||||
locale: job.data.locale,
|
||||
type: 'location',
|
||||
type: isProp ? 'prop' : 'location',
|
||||
currentDescription: locationImage.description,
|
||||
modifyInstruction,
|
||||
referenceImages: normalizedExtras,
|
||||
locationName: locationImage.location?.name || '场景',
|
||||
propName: isProp ? (locationImage.location?.name || '道具') : undefined,
|
||||
projectId: job.data.projectId,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ message: '项目场景描述同步失败,不影响改图结果', details: { error: String(err) } })
|
||||
logger.warn({ message: isProp ? '项目道具描述同步失败,不影响改图结果' : '项目场景描述同步失败,不影响改图结果', details: { error: String(err) } })
|
||||
}
|
||||
}
|
||||
|
||||
await assertTaskActive(job, 'persist_location_modify')
|
||||
await assertTaskActive(job, isProp ? 'persist_prop_modify' : 'persist_location_modify')
|
||||
await prisma.locationImage.update({
|
||||
where: { id: locationImage.id },
|
||||
data: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Job } from 'bullmq'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { addLocationPromptSuffix, getArtStylePrompt, isArtStyleValue, type ArtStyleValue } from '@/lib/constants'
|
||||
import { LOCATION_IMAGE_RATIO, PROP_IMAGE_RATIO, addLocationPromptSuffix, addPropPromptSuffix, getArtStylePrompt, isArtStyleValue, type ArtStyleValue } from '@/lib/constants'
|
||||
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
|
||||
import { type TaskJobData } from '@/lib/task/types'
|
||||
import { reportTaskProgress } from '../shared'
|
||||
@@ -10,10 +10,11 @@ import {
|
||||
} from '../utils'
|
||||
import {
|
||||
AnyObj,
|
||||
generateLabeledImageToCos,
|
||||
generateProjectLabeledImageToStorage,
|
||||
pickFirstString,
|
||||
} from './image-task-handler-shared'
|
||||
import { buildLocationImagePromptCore } from '@/lib/location-image-prompt'
|
||||
import { buildPropImagePromptCore } from '@/lib/prop-image-prompt'
|
||||
|
||||
function resolvePayloadArtStyle(payload: AnyObj): ArtStyleValue | undefined {
|
||||
if (!Object.prototype.hasOwnProperty.call(payload, 'artStyle')) return undefined
|
||||
@@ -67,6 +68,7 @@ export async function handleLocationImageTask(job: Job<TaskJobData>) {
|
||||
|
||||
const payloadArtStyle = resolvePayloadArtStyle(payload)
|
||||
const artStyle = getArtStylePrompt(payloadArtStyle ?? models.artStyle, job.data.locale)
|
||||
const assetType = payload.type === 'prop' ? 'prop' : 'location'
|
||||
|
||||
// targetId may be locationId (group) or locationImageId (single)
|
||||
const maybeLocationImage = await db.locationImage.findUnique({
|
||||
@@ -141,19 +143,27 @@ export async function handleLocationImageTask(job: Job<TaskJobData>) {
|
||||
const name = locationNameMap[item.locationId] || item.location?.name || '场景'
|
||||
const promptBody = item.description || ''
|
||||
if (!promptBody) continue
|
||||
const promptCore = buildLocationImagePromptCore({
|
||||
description: promptBody,
|
||||
availableSlotsRaw: item.availableSlots,
|
||||
locale: job.data.locale === 'en' ? 'en' : 'zh',
|
||||
})
|
||||
const promptCore = assetType === 'prop'
|
||||
? buildPropImagePromptCore({
|
||||
description: promptBody,
|
||||
})
|
||||
: buildLocationImagePromptCore({
|
||||
description: promptBody,
|
||||
availableSlotsRaw: item.availableSlots,
|
||||
locale: job.data.locale === 'en' ? 'en' : 'zh',
|
||||
})
|
||||
|
||||
const prompt = artStyle ? `${addLocationPromptSuffix(promptCore)},${artStyle}` : addLocationPromptSuffix(promptCore)
|
||||
const promptWithSuffix = assetType === 'prop'
|
||||
? addPropPromptSuffix(promptCore)
|
||||
: addLocationPromptSuffix(promptCore)
|
||||
const prompt = artStyle ? `${promptWithSuffix},${artStyle}` : promptWithSuffix
|
||||
const aspectRatio = assetType === 'prop' ? PROP_IMAGE_RATIO : LOCATION_IMAGE_RATIO
|
||||
await reportTaskProgress(job, 20 + Math.floor((i / Math.max(locationImages.length, 1)) * 55), {
|
||||
stage: 'generate_location_image',
|
||||
imageId: item.id,
|
||||
})
|
||||
|
||||
const cosKey = await generateLabeledImageToCos({
|
||||
const imageKey = await generateProjectLabeledImageToStorage({
|
||||
job,
|
||||
userId,
|
||||
modelId,
|
||||
@@ -162,14 +172,14 @@ export async function handleLocationImageTask(job: Job<TaskJobData>) {
|
||||
targetId: item.id,
|
||||
keyPrefix: 'location',
|
||||
options: {
|
||||
aspectRatio: '1:1',
|
||||
aspectRatio,
|
||||
},
|
||||
})
|
||||
|
||||
await assertTaskActive(job, 'persist_location_image')
|
||||
await db.locationImage.update({
|
||||
where: { id: item.id },
|
||||
data: { imageUrl: cosKey },
|
||||
data: { imageUrl: imageKey },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { executeAiTextStep, executeAiVisionStep } from '@/lib/ai-runtime'
|
||||
import { removeCharacterPromptSuffix, removeLocationPromptSuffix } from '@/lib/constants'
|
||||
import { removeCharacterPromptSuffix, removeLocationPromptSuffix, removePropPromptSuffix } from '@/lib/constants'
|
||||
import { safeParseJsonObject } from '@/lib/json-repair'
|
||||
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
|
||||
import type { PromptLocale } from '@/lib/prompt-i18n/types'
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
normalizeLocationAvailableSlots,
|
||||
} from '@/lib/location-available-slots'
|
||||
|
||||
export type SyncedAssetType = 'character' | 'location'
|
||||
export type SyncedAssetType = 'character' | 'location' | 'prop'
|
||||
|
||||
function trimText(value: string | null | undefined): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
@@ -23,6 +23,9 @@ function buildImageContext(type: SyncedAssetType, hasReferenceImages: boolean):
|
||||
if (type === 'character') {
|
||||
return '【参考图片】\n请仔细分析参考图片中的服装款式、颜色、材质、配饰等关键视觉特征,并将这些特征融入更新后的描述中。'
|
||||
}
|
||||
if (type === 'prop') {
|
||||
return '【参考图片】\n请仔细分析参考图片中的材质、轮廓、比例、装饰细节、配色与表面处理,并将这些特征融入更新后的描述中。'
|
||||
}
|
||||
return '【参考图片】\n请仔细分析参考图片中的建筑风格、装饰元素、光线氛围、色调等关键视觉特征,并将这些特征融入更新后的描述中。'
|
||||
}
|
||||
|
||||
@@ -52,6 +55,7 @@ export async function generateModifiedAssetDescription(params: {
|
||||
modifyInstruction: string
|
||||
referenceImages?: string[]
|
||||
locationName?: string
|
||||
propName?: string
|
||||
projectId?: string
|
||||
}): Promise<{
|
||||
prompt: string
|
||||
@@ -68,6 +72,17 @@ export async function generateModifiedAssetDescription(params: {
|
||||
image_context: buildImageContext('character', hasReferenceImages),
|
||||
},
|
||||
})
|
||||
: params.type === 'prop'
|
||||
? buildPrompt({
|
||||
promptId: PROMPT_IDS.NP_PROP_DESCRIPTION_UPDATE,
|
||||
locale: params.locale,
|
||||
variables: {
|
||||
prop_name: trimText(params.propName) || '道具',
|
||||
original_description: removePropPromptSuffix(params.currentDescription),
|
||||
modify_instruction: params.modifyInstruction,
|
||||
image_context: buildImageContext('prop', hasReferenceImages),
|
||||
},
|
||||
})
|
||||
: buildPrompt({
|
||||
promptId: PROMPT_IDS.NP_LOCATION_DESCRIPTION_UPDATE,
|
||||
locale: params.locale,
|
||||
@@ -99,12 +114,16 @@ export async function generateModifiedAssetDescription(params: {
|
||||
...(params.projectId ? { projectId: params.projectId } : {}),
|
||||
action: params.type === 'character'
|
||||
? 'sync_character_description_after_image_modify'
|
||||
: 'sync_location_description_after_image_modify',
|
||||
: params.type === 'prop'
|
||||
? 'sync_prop_description_after_image_modify'
|
||||
: 'sync_location_description_after_image_modify',
|
||||
meta: {
|
||||
stepId: params.type === 'character'
|
||||
? 'sync_character_description_after_image_modify'
|
||||
: 'sync_location_description_after_image_modify',
|
||||
stepTitle: params.type === 'character' ? '同步角色描述' : '同步场景描述',
|
||||
: params.type === 'prop'
|
||||
? 'sync_prop_description_after_image_modify'
|
||||
: 'sync_location_description_after_image_modify',
|
||||
stepTitle: params.type === 'character' ? '同步角色描述' : params.type === 'prop' ? '同步道具描述' : '同步场景描述',
|
||||
stepIndex: 1,
|
||||
stepTotal: 1,
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from './reference-to-character-helpers'
|
||||
const POLL_MAX_ATTEMPTS = 60
|
||||
const POLL_INTERVAL_MS = 2000
|
||||
async function generateLabeledImage(params: {
|
||||
async function generateReferenceImage(params: {
|
||||
job: Job<TaskJobData>
|
||||
imageIndex: number
|
||||
userId: string
|
||||
@@ -36,7 +36,7 @@ async function generateLabeledImage(params: {
|
||||
referenceImages?: string[]
|
||||
falApiKey?: string | null
|
||||
keyPrefix: string
|
||||
labelText: string
|
||||
labelText?: string
|
||||
}): Promise<string | null> {
|
||||
const {
|
||||
job,
|
||||
@@ -91,25 +91,30 @@ async function generateLabeledImage(params: {
|
||||
logPrefix: `[reference-to-character:${imageIndex + 1}]`,
|
||||
})
|
||||
const buffer = Buffer.from(await imgRes.arrayBuffer())
|
||||
const meta = await sharp(buffer).metadata()
|
||||
const width = meta.width || 2160
|
||||
const height = meta.height || 2160
|
||||
const fontSize = Math.floor(height * 0.04)
|
||||
const pad = Math.floor(fontSize * 0.5)
|
||||
const barHeight = fontSize + pad * 2
|
||||
|
||||
const svg = await createLabelSVG(width, barHeight, fontSize, pad, labelText)
|
||||
const processed = await sharp(buffer)
|
||||
.extend({
|
||||
top: barHeight,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 1 },
|
||||
})
|
||||
.composite([{ input: svg, top: 0, left: 0 }])
|
||||
.jpeg({ quality: 90, mozjpeg: true })
|
||||
.toBuffer()
|
||||
const processed = labelText
|
||||
? await (async () => {
|
||||
const meta = await sharp(buffer).metadata()
|
||||
const width = meta.width || 2160
|
||||
const height = meta.height || 2160
|
||||
const fontSize = Math.floor(height * 0.04)
|
||||
const pad = Math.floor(fontSize * 0.5)
|
||||
const barHeight = fontSize + pad * 2
|
||||
const svg = await createLabelSVG(width, barHeight, fontSize, pad, labelText)
|
||||
return await sharp(buffer)
|
||||
.extend({
|
||||
top: barHeight,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 1 },
|
||||
})
|
||||
.composite([{ input: svg, top: 0, left: 0 }])
|
||||
.jpeg({ quality: 90, mozjpeg: true })
|
||||
.toBuffer()
|
||||
})()
|
||||
: await sharp(buffer)
|
||||
.jpeg({ quality: 90, mozjpeg: true })
|
||||
.toBuffer()
|
||||
|
||||
const key = generateUniqueKey(`${keyPrefix}-${Date.now()}-${imageIndex}`, 'jpg')
|
||||
return await uploadObject(processed, key)
|
||||
@@ -149,8 +154,9 @@ export async function handleReferenceToCharacterTask(job: Job<TaskJobData>) {
|
||||
displayMode: 'detail',
|
||||
})
|
||||
await assertTaskActive(job, 'reference_to_character_prepare')
|
||||
|
||||
await initializeFonts()
|
||||
if (isProject) {
|
||||
await initializeFonts()
|
||||
}
|
||||
|
||||
const userConfig = await getUserModelConfig(job.data.userId)
|
||||
const imageModel = readString(userConfig.characterModel)
|
||||
@@ -214,7 +220,7 @@ export async function handleReferenceToCharacterTask(job: Job<TaskJobData>) {
|
||||
})
|
||||
|
||||
const imageResults = await Promise.all(Array.from({ length: count }, (_value, index) => index).map(async (index) =>
|
||||
await generateLabeledImage({
|
||||
await generateReferenceImage({
|
||||
job,
|
||||
imageIndex: index,
|
||||
userId: job.data.userId,
|
||||
@@ -223,7 +229,7 @@ export async function handleReferenceToCharacterTask(job: Job<TaskJobData>) {
|
||||
referenceImages: useReferenceImages ? allReferenceImages : undefined,
|
||||
falApiKey,
|
||||
keyPrefix,
|
||||
labelText: characterName,
|
||||
...(isProject ? { labelText: characterName } : {}),
|
||||
}),
|
||||
))
|
||||
|
||||
|
||||
65
src/lib/workers/handlers/shot-ai-prompt-prop.ts
Normal file
65
src/lib/workers/handlers/shot-ai-prompt-prop.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { Job } from 'bullmq'
|
||||
import { removePropPromptSuffix } from '@/lib/constants'
|
||||
import { reportTaskProgress } from '@/lib/workers/shared'
|
||||
import { assertTaskActive } from '@/lib/workers/utils'
|
||||
import type { TaskJobData } from '@/lib/task/types'
|
||||
import { resolveAnalysisModel } from './shot-ai-persist'
|
||||
import { runShotPromptCompletion } from './shot-ai-prompt-runtime'
|
||||
import { parseJsonObject, readRequiredString, type AnyObj } from './shot-ai-prompt-utils'
|
||||
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
|
||||
|
||||
export async function handleModifyPropTask(job: Job<TaskJobData>, payload: AnyObj) {
|
||||
const propId = readRequiredString(payload.propId, 'propId')
|
||||
const variantId = typeof payload.variantId === 'string' ? payload.variantId.trim() : ''
|
||||
const propName = typeof payload.propName === 'string' && payload.propName.trim() ? payload.propName.trim() : '道具'
|
||||
const currentDescription = readRequiredString(payload.currentDescription, 'currentDescription')
|
||||
const modifyInstruction = readRequiredString(payload.modifyInstruction, 'modifyInstruction')
|
||||
const novelData = await resolveAnalysisModel(job.data.projectId, job.data.userId)
|
||||
|
||||
const finalPrompt = buildPrompt({
|
||||
promptId: PROMPT_IDS.NP_PROP_DESCRIPTION_UPDATE,
|
||||
locale: job.data.locale,
|
||||
variables: {
|
||||
prop_name: propName,
|
||||
original_description: removePropPromptSuffix(currentDescription),
|
||||
modify_instruction: modifyInstruction,
|
||||
image_context: '',
|
||||
},
|
||||
})
|
||||
|
||||
await reportTaskProgress(job, 22, {
|
||||
stage: 'ai_modify_prop_prepare',
|
||||
stageLabel: '准备道具描述修改参数',
|
||||
displayMode: 'detail',
|
||||
})
|
||||
await assertTaskActive(job, 'ai_modify_prop_prepare')
|
||||
|
||||
const responseText = await runShotPromptCompletion({
|
||||
job,
|
||||
model: novelData.analysisModel,
|
||||
prompt: finalPrompt,
|
||||
action: 'ai_modify_prop',
|
||||
streamContextKey: 'ai_modify_prop',
|
||||
streamStepId: 'ai_modify_prop',
|
||||
streamStepTitle: '道具描述修改',
|
||||
})
|
||||
await assertTaskActive(job, 'ai_modify_prop_parse')
|
||||
|
||||
const parsed = parseJsonObject(responseText)
|
||||
const prompt = readRequiredString(parsed.prompt, 'prompt')
|
||||
const modifiedDescription = removePropPromptSuffix(prompt)
|
||||
|
||||
await reportTaskProgress(job, 96, {
|
||||
stage: 'ai_modify_prop_done',
|
||||
stageLabel: '道具描述修改完成',
|
||||
displayMode: 'detail',
|
||||
meta: { propId, variantId: variantId || null },
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
modifiedDescription,
|
||||
originalPrompt: finalPrompt,
|
||||
rawResponse: responseText,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export type { AnyObj } from './shot-ai-prompt-utils'
|
||||
export { handleModifyAppearanceTask } from './shot-ai-prompt-appearance'
|
||||
export { handleModifyLocationTask } from './shot-ai-prompt-location'
|
||||
export { handleModifyPropTask } from './shot-ai-prompt-prop'
|
||||
export { handleModifyShotPromptTask } from './shot-ai-prompt-shot'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
import {
|
||||
handleModifyAppearanceTask,
|
||||
handleModifyLocationTask,
|
||||
handleModifyPropTask,
|
||||
handleModifyShotPromptTask,
|
||||
type AnyObj,
|
||||
} from './shot-ai-prompt'
|
||||
@@ -15,6 +16,8 @@ export async function handleShotAITask(job: Job<TaskJobData>) {
|
||||
return await handleModifyAppearanceTask(job, payload)
|
||||
case TASK_TYPE.AI_MODIFY_LOCATION:
|
||||
return await handleModifyLocationTask(job, payload)
|
||||
case TASK_TYPE.AI_MODIFY_PROP:
|
||||
return await handleModifyPropTask(job, payload)
|
||||
case TASK_TYPE.AI_MODIFY_SHOT_PROMPT:
|
||||
return await handleModifyShotPromptTask(job, payload)
|
||||
case TASK_TYPE.ANALYZE_SHOT_VARIANTS:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user