diff --git a/data/skills/art_prompts/chinese_sweet_romance/images/2b1cb9c6-54f9-4e19-bf15-b546c80147bc.jpg b/data/skills/art_prompts/chinese_sweet_romance/images/2b1cb9c6-54f9-4e19-bf15-b546c80147bc.jpg new file mode 100644 index 0000000..1f5ad89 Binary files /dev/null and b/data/skills/art_prompts/chinese_sweet_romance/images/2b1cb9c6-54f9-4e19-bf15-b546c80147bc.jpg differ diff --git a/data/skills/art_prompts/chinese_sweet_romance/images/b5bd58ee-c304-477f-9c18-b40f814a5901.jpg b/data/skills/art_prompts/chinese_sweet_romance/images/b5bd58ee-c304-477f-9c18-b40f814a5901.jpg new file mode 100644 index 0000000..9ec69a9 Binary files /dev/null and b/data/skills/art_prompts/chinese_sweet_romance/images/b5bd58ee-c304-477f-9c18-b40f814a5901.jpg differ diff --git a/data/skills/art_prompts/chinese_sweet_romance/images/e4b20248-54f1-4e63-ab2d-cb72cd1886b8.jpg b/data/skills/art_prompts/chinese_sweet_romance/images/e4b20248-54f1-4e63-ab2d-cb72cd1886b8.jpg new file mode 100644 index 0000000..1f5ad89 Binary files /dev/null and b/data/skills/art_prompts/chinese_sweet_romance/images/e4b20248-54f1-4e63-ab2d-cb72cd1886b8.jpg differ diff --git a/data/skills/art_prompts/chinese_sweet_romance/images/f0f89ce4-b197-4bd6-9794-3d14d1c3ab1e.jpg b/data/skills/art_prompts/chinese_sweet_romance/images/f0f89ce4-b197-4bd6-9794-3d14d1c3ab1e.jpg new file mode 100644 index 0000000..9ec69a9 Binary files /dev/null and b/data/skills/art_prompts/chinese_sweet_romance/images/f0f89ce4-b197-4bd6-9794-3d14d1c3ab1e.jpg differ diff --git a/data/skills/production_agent_decision.md b/data/skills/production_agent_decision.md index 055f210..2640cb4 100644 --- a/data/skills/production_agent_decision.md +++ b/data/skills/production_agent_decision.md @@ -19,16 +19,16 @@ ## 制作流水线 -五个阶段**必须按顺序执行**: +六个阶段**必须按顺序执行**: ``` -阶段1: 衍生资产分析 → 阶段2: 衍生资产生成(可选) → 阶段3: 导演规划 → 阶段4: 构建分镜表 → 阶段5: 生成分镜 +阶段1: 衍生资产分析 → 阶段2: 衍生资产生成(可选) → 阶段3: 导演规划 → 阶段4: 构建分镜表 → 阶段5: 分镜面板写入 → 阶段6: 分镜图生成 ``` ### 全局约束 -- **资产约束**:阶段3、4、5 只能使用资产库中已存在的资产(含阶段1已写入的衍生资产) -- **异步操作**:阶段2的图片生成、阶段5的分镜图片生成均为异步操作,派发后告知用户等待即可 +- **资产约束**:阶段3、4、5、6 只能使用资产库中已存在的资产(含阶段1已写入的衍生资产) +- **异步操作**:阶段2的图片生成、阶段6的分镜图片生成均为异步操作,派发后告知用户等待即可 - **审核规则**:仅阶段3(导演规划)和阶段4(构建分镜表)需要审核,执行完毕后自动派发监督层 --- @@ -72,7 +72,7 @@ | 前置条件 | 阶段1完成且用户确认生成 | | 审核 | 不需要 | -**决策层行为:** 将用户确认的资产清单(或子集)派发给执行层。返回确认后,告知用户图片生成中,直接进入阶段3。 +**决策层行为:** 将用户确认的资产清单(或子集)派发给执行层。返回确认后,告知用户图片生成中,询问用户是否进入阶段3。 --- @@ -104,27 +104,40 @@ --- -### 阶段5:构建分镜面板 +### 阶段5:分镜面板写入 | 项 | 说明 | |----|------| -| 派发 | 执行层调将分镜表拆分生成分镜面板 | -| 输出 | 生成的分镜面板 | +| 派发 | 执行层按分镜表写入分镜面板 XML | +| 输出 | 分镜面板写入完成确认 | | 前置条件 | 阶段4完成且用户确认 | | 审核 | 不需要 | +**决策层行为:** +向执行层派发阶段5分镜面板写入任务,收到确认后进入阶段6。 -### 阶段6:生成分镜 +**阶段特有约束:** +- 必须严格依据阶段4分镜表逐行写入,行数与时长保持一致 +- 分组累计时长不得超过 15 秒 + +--- + +### 阶段6:分镜图生成 | 项 | 说明 | |----|------| -| 派发 | 执行层调用图片生成接口生成分镜图片 | -| 输出 | 生成的分镜图片 | -| 前置条件 | 阶段5完成且用户确认 | +| 派发 | 执行层读取分镜面板并调用图片生成接口 | +| 输出 | 分镜图片生成任务启动(异步) | +| 前置条件 | 阶段5完成 | | 审核 | 不需要 | -**阶段特有约束:** `generate_storyboard_images({ ids: [真实分镜ID列表] })` 中的 ids参数 必须指向分镜面板中实际存在的分镜Id。 -**决策层行为:** 执行层返回确认后,告知用户图片生成已启动,流程结束。 +**决策层行为:** +向执行层派发阶段6分镜图生成任务,收到确认后告知用户任务已启动并结束流程。 + +**阶段特有约束:** +- 仅可使用分镜面板中的真实分镜 ID 发起生成 +- 图片内容需与分镜描述一致 + --- ## 调度与派发规范 diff --git a/data/skills/production_agent_execution.md b/data/skills/production_agent_execution.md index 8631606..06b78de 100644 --- a/data/skills/production_agent_execution.md +++ b/data/skills/production_agent_execution.md @@ -18,7 +18,8 @@ | 资产图片、生成资产、generate assets | [二、衍生资产图片生成](#二衍生资产图片生成) | | 导演规划、拍摄计划、director plan | [三、导演规划](#三导演规划) | | 构建分镜表、分镜面板、storyboard table | [四、构建分镜表](#四构建分镜表) | -| 生成分镜、分镜图片、storyboard gen | [五、生成分镜图片](#五生成分镜图片) | +| 分镜面板写入、写入分镜面板、storyboard panel | [五、分镜面板写入](#五分镜面板写入) | +| 生成分镜、分镜图片、storyboard gen | [六、分镜图生成](#六分镜图生成) | --- @@ -51,11 +52,11 @@ ### `add_deriveAsset` 入参要求 ```ts add_deriveAsset({ - assetsId: number, // 关联的资产ID - id: number | null, // 衍生资产ID,新增填 null - name: string, // 衍生资产名称 - desc: string, // 衍生资产描述 - type: "role" | "tool" | "scene" | "clip", // 衍生资产类型 + assetsId: number, // 关联的资产ID + id: number | null, // 衍生资产ID,新增填 null + name: string, // 衍生资产名称 + desc: string, // 衍生资产描述 + type: "role" | "tool" | "scene" | "clip", // 衍生资产类型 }) ``` @@ -63,12 +64,12 @@ add_deriveAsset({ - `assetsId`:父资产在工作区中的 ID - `id`:新增时必须为 `null`;更新已有衍生资产时填写已有衍生资产 ID - `name`:2~6 字,体现视觉外观变化 -- `desc`:`[与默认态的差异] · [视觉特征] · [出现场景/触发条件]`,1~100 字 +- `desc`:`[与默认态的差异] · [视觉特征] ,1~100 字 - `type`: - - 角色资产填 `role` - - 道具资产填 `tool` - - 场景资产填 `scene` - - 镜头/片段类资产填 `clip` + - 角色资产填 `role` + - 道具资产填 `tool` + - 场景资产填 `scene` + - 镜头/片段类资产填 `clip` @@ -162,6 +163,7 @@ add_deriveAsset({ 约束: - 色调具体到色温范围或色彩倾向描述 - 光影以「段落-光影方向」表格呈现,每段落指定光影基调方向 +- 色温、光源角度、冷暖色调分配等具体技法参数以风格技法参考(`director_planning`)为准 - **构图须说明叙事理由**,参考以下情绪-构图映射(按需选用): - 对称构图 → 秩序 / 压迫 / 庄重 - 三分法偏侧留白 → 孤独 / 期待 / 未知 @@ -208,6 +210,7 @@ add_deriveAsset({ 约束: - 配乐按段落统一规划(不逐场),同段落内场景切换靠环境音变化过渡 +- 乐器选择、组合策略等具体技法以风格技法参考(`director_planning`)为准 - 环境音具体到可感知声源("蝉鸣 / 溪水 / 市井叫卖 / 雨滴檐角"),每场标注 1~2 个核心环境音 - 标注运用沉默手法的关键瞬间(关键情感瞬间优先考虑去掉配乐,只留环境音) - 全片配乐覆盖率建议不超过 70%,留白段落与配乐段落形成呼吸感 @@ -257,6 +260,26 @@ add_deriveAsset({ 粒度:一个独立画面 = 一条分镜,约每 50~100 字剧本对应 1~2 条分镜。过渡/转场如有明确描写也单独拆分。 +### 定场与镜头合并规则(防冗余) + +**定场镜头**:每个新场景/段落的定场最多 1~2 个镜头完成,禁止拆成 3 个以上碎片。 +- 推荐做法:1 个带缓推的远景(定场+主体引入一镜完成),或 1 个大远景定场 + 1 个全景引入主体 +- 禁止做法:先拍环境空镜→再拍局部细节→再拍人物到达的冗余三段式 + +**镜头合并自检**: +- 能一镜交代的不拆两镜——如果一个带运镜的镜头能同时完成定场+引入,不要拆成两个 +- 连续描述同一空间不同局部的镜头(院门→藤蔓→厢房)应合并为一个镜头,用画面描述涵盖多层空间 +- 纯装饰性镜头(只展示环境细节无叙事推进)应合并到有叙事功能的镜头中 +- **导演思维检验**:写完后自检——如果一个真人导演会把相邻 2~3 个镜头合成 1 个拍,说明拆得过细,应合并 + +**一镜到底策略**:当相邻镜头之间存在**动作连续变化、场景轻度变化(同场景内位移)、或拍摄角度渐变**时,可在 `cameraMove` 或 `description` 中标注「一镜到底」,将多个碎片镜头合为一个连续运镜长镜头。 +- **适用场景**:角色行走穿越空间、跟随动作从A点到B点、环绕角色展示环境、定场缓推到主体特写等 +- **标注方式**:在 `cameraMove` 中写明运镜路径(如"一镜到底:缓推远景→跟移至院内→落幅全景"),在 `description` 中描述起幅和落幅的画面内容 +- **时长放宽**:一镜到底镜头因信息量持续更新,可突破单镜 6s 上限,但不超过 12s +- **风险提示**:一镜到底会提高画面生成的抽卡难度(连续性要求高),仅在叙事流畅性收益明显大于碎切时使用,不滥用 + +**黄金 6 秒规则**:无台词镜头累计超过 6s 未出现新信息(台词/动作/主体变化),观众注意力断裂。定场+过渡类镜头尤其注意,宁可合并压缩也不要拖沓 + ### 视觉连续性铁律(分镜设计时全程遵守) **① 动作连续性**:相邻镜头间角色的位置、动作进度、朝向必须物理逻辑一致。上一镜手伸到半空→下一镜必须从半空状态接续,不能突然收回。 @@ -312,11 +335,11 @@ add_deriveAsset({ **emotion**(情绪):画面传达的情绪基调(2~10 字),用具象可感描述。如"冷傲轻蔑""痛苦绝望""紧张压迫"。禁止"开心""难过"等空泛词。 -**lighting**(光影氛围):画面光影与氛围描述(5~40 字),须包含**光源方向 + 色调倾向 + 明暗关系**。如"右侧45°冷白光,面部明暗对半,背景深沉""底部暖黄光上打,眼窝沉入暗影"。禁止只写"柔光""暗调"。 +**lighting**(光影氛围):画面光影与氛围描述(5~40 字),须包含**光源方向 + 色调倾向 + 明暗关系**。如"右侧冷白光斜射,面部明暗对半,背景深沉""底部暖黄光上打,眼窝沉入暗影"。禁止只写"柔光""暗调"。具体光源角度、色调阶段分配以风格技法参考为准 **scene**:该分镜所处的场景名称,与剧本中的场景对应 -**associateAssetsNames**:画面中**可见的**资产名称列表,便于直观确认关联内容 +**associateAssetsNames**:画面中**可见的**资产名称列表(包括仅局部出现的角色/物件),便于直观确认关联内容 **duration**:基础参考——特写/表情 2~3s · 对话近景 3~5s · 全身亮相 3~5s · 动作 2~4s · 远景/空镜/过渡 3~5s · 复杂场景 5~8s。**单镜不超过 8s**,超过须拆分。 @@ -338,7 +361,10 @@ add_deriveAsset({ **sound**:环境音/音效描述,按「环境音层 + 动作音层」分层。如"远处风声呼啸 + 剑鸣声"。无音效填 `无音效` -**associateAssetsIds**:画面中**可见的**资产的 ID(从 assets 数据中获取的实际 `id` 字段值),不编造不存在的 ID +**associateAssetsIds**:画面中**可见的**资产的 ID(从 assets 数据中获取的实际 `id` 字段值),不编造不存在的 ID。 +- **角色出现即引用**:画面中出现的所有角色,无论是主体还是仅局部可见(如背影、手部、虚化剪影等),只要在画面内可被辨识,都必须引用其对应的资产 ID +- **场景资产必选**:每条分镜必须引用其所处场景对应的场景资产 ID(type 为 scene 的资产);若该场景存在匹配当前画面状态的衍生场景资产,则选用衍生场景资产 ID,否则选用主场景资产 ID。缺少场景资产 ID 视为字段不完整 +- 父子资产选择规则:按剧情画面所需状态选择资产 ID——若该镜头需要某主资产的衍生状态,**只选衍生资产 ID**;仅当不存在匹配的衍生状态时,才选择主资产 ID;同一父资产在同一分镜中禁止主/衍生同时出现 ### 转场规则 @@ -360,9 +386,9 @@ add_deriveAsset({ | 序号 | 画面描述 | 场景 | 关联资产名称 | 时长 | 景别 | 运镜 | 角色动作 | 情绪 | 光影氛围 | 台词 | 音效 | 关联资产ID | |----|-------------|------|----------|------|------|------|------|------|------|-------|-------|----------| -| 1 | 苏晚卿冷笑,居高临下看着跪地的凌玄,大殿柱影深沉 | 大殿 | [苏晚卿] | 4 | 近景 | 静止 | 嘴角缓缓上扬→微仰下巴→眼神下压注视(开篇) | 冷傲轻蔑 | 顶光直射面部,眼窝明暗对半,背景大殿沉入暗部 | 苏晚卿:还有你当宝贝的青云令 | 空旷殿堂回声 | [101] | -| 2 | 凌玄跪地猛喷鲜血,身体前倾欲坠,血雾弥漫 | 大殿 | [凌玄] | 3 | 中景 | 缓慢推至近景 | 胸口剧颤→猛然喷出鲜血→身体前倾摇晃(承接上镜:跪地状态) | 痛苦绝望 | 左侧冷光勾边,血雾被逆光映成暗红,背景压暗 | 无台词 | 喷血声 + 沉闷跪地声 | [100] | -| 3 | 青云令灵纹一寸寸暗淡,玉面浮现细微裂痕 | 大殿 | [青云令] | 3 | 大特写 | 静止 | 灵纹光芒由亮渐灭→裂痕自中心蔓延(承接上镜:喷血后切物件) | 紧张压迫 | 微弱自发光从内部渗出渐灭,周围完全暗沉 | 无台词 | 细微玉石碎裂声 | [202] | +| 1 | 苏晚卿冷笑,居高临下看着跪地的凌玄,大殿柱影深沉 | 大殿 | [苏晚卿, 凌玄, 大殿] | 4 | 近景 | 静止 | 嘴角缓缓上扬→微仰下巴→眼神下压注视(开篇) | 冷傲轻蔑 | 顶光直射面部,眼窝明暗对半,背景大殿沉入暗部 | 苏晚卿:还有你当宝贝的青云令 | 空旷殿堂回声 | [101, 100, 300] | +| 2 | 凌玄跪地猛喷鲜血,身体前倾欲坠,血雾弥漫 | 大殿 | [凌玄, 大殿] | 3 | 中景 | 缓慢推至近景 | 胸口剧颤→猛然喷出鲜血→身体前倾摇晃(承接上镜:跪地状态) | 痛苦绝望 | 左侧冷光勾边,血雾被逆光映成暗红,背景压暗 | 无台词 | 喷血声 + 沉闷跪地声 | [100, 300] | +| 3 | 青云令灵纹一寸寸暗淡,玉面浮现细微裂痕 | 大殿 | [青云令, 大殿] | 3 | 大特写 | 静止 | 灵纹光芒由亮渐灭→裂痕自中心蔓延(承接上镜:喷血后切物件) | 紧张压迫 | 微弱自发光从内部渗出渐灭,周围完全暗沉 | 无台词 | 细微玉石碎裂声 | [202, 300] | ### 约束 @@ -373,13 +399,20 @@ add_deriveAsset({ - **台词原文锁定**:剧本中所有台词必须原文照搬进 `lines` 字段,禁止改写、省略或意译,如有台词未出现在分镜中视为严重错误 - 分镜顺序与剧本叙事顺序一致 - 所有字段完整填写,`associateAssetsIds` 使用资产的实际 ID(非数组索引),必须与工作区现有资产匹配 +- **按剧情选资产(衍生优先)**:同一父资产在单条分镜中,若剧情对应衍生状态则仅填写该衍生资产 ID;仅当无匹配衍生状态时才填写主资产 ID,禁止两者并填 +- **场景资产必须引用**:每条分镜的 `associateAssetsIds` 必须包含该分镜 `scene` 字段对应的场景资产 ID(从 assets 中匹配 type 为 scene 的资产);若存在匹配的衍生场景资产则选用衍生 ID,否则选用主场景资产 ID。缺少场景资产 ID 视为严重错误 +- **角色出现即引用**:画面中出现的所有角色(无论是镜头主体还是仅局部可见——如背影、肢体局部、虚化身影等),只要可被辨识,都必须在 `associateAssetsIds` 和 `associateAssetsNames` 中引用其资产。遗漏画面中可见角色的资产 ID 视为严重错误 - 剧本中出现但资产列表不存在的角色/物件仍需在分镜中描述,但不在 `associateAssetsIds` 中编造 ID - **台词-时长强关联**:含台词的分镜,需根据角色当前情绪状态选取对应语速(愤怒~4字/秒、正常~3字/秒、悲伤~2字/秒、低语/虚弱~2字/秒),`duration` ≥ 台词字数 ÷ 语速(向上取整)+ 1s 情绪余量;宁可多留余量,不可台词超时 - **视觉连续性逐行校验**:每写一行分镜前,回顾上一行的动作终态、景别、角色朝向,确保当前行与之衔接合理,符合「视觉连续性铁律」7条规则 +- **定场精简**:每个新场景定场最多 1~2 镜,禁止 3 镜以上的碎片化定场;能一镜完成定场+引入的不拆两镜 +- **镜头合并自检**:完成全部分镜后,逐段检查是否有可合并的相邻镜头(同空间局部描述、纯装饰镜头、信息重复镜头),合并后重新编号 +- **黄金 6 秒**:无台词镜头不超过 6s,定场/过渡类镜头尤其注意 +- **光影风格一致**:光影描述须与风格技法参考(`director_storyboard_table`)的光影规范保持一致 --- -## 五、生成分镜面板与生成分镜图片 +## 五、分镜面板写入 ### 工具 @@ -387,17 +420,277 @@ add_deriveAsset({ |------|------| | 读取剧本 | `get_flowData("script")` | | 读取分镜表 | `get_flowData("stoaryTable")` | + +### 执行流程 + +1. 获取 `script` 、`stoaryTable`,并加载下方「分镜提示词 · 通用基础技法」与风格专属技法(激活 `director_storyboard`)作为提示词生成的全部参考依据,冲突时以风格专属技法为准 +2. 确定分组与时长规则:同组内分镜 `duration` 累计时长不得超过 15 秒,且每条 `duration` 必须严格使用 `stoaryTable` 对应行时长 +3. **人物空间位置预分析**:正式写入前,先通读全部分镜表,梳理同一人物在不同分镜中出现的画面位置与朝向,建立「人物-位置」连续性基准(如:角色A全片画面偏左、面朝右;角色B画面偏右、面朝左),后续每条 prompt 中涉及该人物时须保持一致 +4. **图像资产标注与正文绑定**:为每条分镜的 prompt 生成图像资产标注前缀,按 `associateAssetsIds` 的引用顺序,依次标注 `@图N 为xx{类型}`;**提示词正文中所有涉及该角色/场景/道具的位置,必须使用对应的 `@图N` 替代其名称**,建立参考图与画面描述的直接绑定(详见下方「prompt 图像资产标注规则」) +5. 严格按 `stoaryTable` 的分镜数据行逐行写入分镜面板(排除表头与分隔行), +6. 写入完成后,仅返回一句确认:`已完成分镜面板写入` + +### 分镜提示词 · 通用基础技法 + +> 以下为分镜提示词生成的**通用基础规范**,适用于所有视觉风格。风格锚定词、情绪映射、光影词库、场景质感、美学禁止项等**风格相关内容**由风格专属技法(`director_storyboard`)定义。 + +#### 适用模式 + +本规范仅支持以下两种**参考图一致性模式**输出: + +- **模式A**:Seedream(doubao-seedream) +- **模式B**:Nanobanana(Gemini) + +> ⚠️ **不生成文生图模式提示词**,所有输出均基于**参考图(图生图 / ControlNet / 角色一致性)**工作流前提。 + +#### 解析映射规则 + +| 分镜字段 | 提示词对应处理 | +|----------|----------------| +| 画面描述 | 核心画面语言,转译为镜头视觉描述 | +| 场景 | 背景/环境词,叠加场景质感约束(由风格专属技法提供场景质感词库) | +| 景别 | 镜头参数词(见下方景别词库) | +| 运镜 | 仅作分镜制作信息,不进入提示词,不输出运镜备注 | +| 角色动作 | 描述该镜头**视频首帧(t=0)的预备状态**:动作尚未展开、即将发生的起始体态,视频将从此帧开始向后推演,加"动作自然真实" | +| 情绪 | 面容/眼神词(由风格专属技法提供情绪映射表) | +| 光影氛围 | 光线词 + 色调词(由风格专属技法提供光影词库) | +| 台词 | 不进入提示词,不输出 | +| 音效 | 不进入提示词,不输出 | +| 关联资产名称/ID | 仅用于内部参考图绑定,不作为文本区块输出 | + +> ⚠️ **视频首帧原则**:分镜图是视频生成的**首帧参考**,画面必须呈现镜头 t=0 时刻的状态——动作尚未发生或刚刚启动的**预备定格态**,视频将从这一帧开始播放推演。 +> +> **核心逻辑**:首帧 → 视频推演 → 动作完成。提示词描述的是"推演起点",而非"推演终点"。 +> +> - ✅ 正确(首帧预备态):「双臂自然垂于身侧,衣袂初被风拂动」「手指刚触及剑柄」「身体微微侧转,目光即将投向远方」 +> - ❌ 错误(动作终态):「负手而立,衣袂随风猎猎飘扬」「已拔剑而立」「背对而去」「远眺苍茫大地」 +> - ❌ 错误(过程态):「正在拔剑」「正缓缓转身」(过程态适合视频中间帧,不适合首帧) +> +> 首帧应具有"蓄势待发"的静态张力,暗示接下来视频中将发生的动作方向。 + +#### 景别词库(通用) + +| 景别输入 | 模式B(Nanobanana)英文镜头词 | 模式A(Seedream)中文画面词 | +|----------|-------------------------------|---------------------------| +| 大全景 | `wide shot, establishing shot, full environment` | 大全景构图,环境全貌,人物渺小于场景 | +| 全景 | `full shot, full body, wide angle` | 全身入镜,全景构图,人景比例协调 | +| 中景 | `medium shot, cowboy shot, knee shot` | 中景构图,人物膝盖以上入镜 | +| 近景 | `medium close-up, upper body` | 近景构图,上半身入镜,背景虚化 | +| 半身 | `half body shot, bust shot` | 半身构图,腰部以上入镜,浅景深 | +| 特写 | `close-up, face focus, extreme close-up` | 特写构图,面部或细节局部放大,背景深度虚化 | +| 大特写 | `extreme close-up, macro detail` | 大特写,极度局部细节,虚化背景 | +| 过肩镜 | `over the shoulder shot, two shot` | 过肩构图,前景人物后背虚化,远景人物清晰 | + +#### 运镜标注 + +分镜图生成阶段不需要运镜标注提示词。运镜字段仅用于分镜生产管理信息,不参与提示词生成,不作为输出区块。 + +#### 输出格式规范 + +每条分镜**只输出一种模式的提示词正文**(二选一),不允许同条分镜同时输出模式A与模式B。 + +**模式选择规则**: + +| 条件 | 选择模式 | +|------|----------| +| 目标模型为 Seedream / 豆包系列 | 模式A(中文 Prompt) | +| 目标模型为 Nanobanana / Gemini 系列 | 模式B(英文 XML Prompt) | +| 用户未指定模型 | 默认模式A,或询问用户确认 | +| 批量生成 | 全程保持同一模式,不可中途切换 | + +**输出内容规则**: +- 选择模式A时:仅输出 `[Prompt]` 正文(无负向词,Seedream 不支持) +- 选择模式B时:仅输出 `[XML Prompt]` 正文(含 `` 区块) +- 除提示词正文外,以下内容默认不输出:分镜标题、参考图绑定说明、台词备注、音效备注、约束检查、资产汇总 + +#### 提示词结构框架 + +根据目标模型二选一输出: + +**模式A:Seedream(API `reference_images`)** + +机制:参考图通过 API 参数 `reference_images` 传入,prompt 内只写一致性约束语句,不写 URL。 + +Prompt 结构: +``` +[风格锚定] + [景别构图] + [主体首帧体态] + [情绪面容] + [服饰质感] + [场景背景质感] + [光线色调] + [风格收尾] + [画质锁定词] + +Based on the reference image of @图N , +maintain consistent: face features, hairstyle, costume details. +Generate a new scene: [本镜画面描述,使用@图N 替代角色/场景名称]. +Keep character appearance identical to reference. +``` + +> `[风格锚定]`、`[服饰质感]`、`[场景背景质感]`、`[风格收尾]`、`[画质锁定词]` 的具体内容由**风格专属技法**定义。 + +参数规范: +- 单角色:`reference_images: ["角色URL"]` +- 多角色:`reference_images: ["角色A_URL", "角色B_URL"]` +- 多角色时在 prompt 中显式区分 `image 1`、`image 2` + +**模式B:Nanobanana(多模态 + XML)** + +机制:参考图与 prompt 一起作为多模态输入,prompt 使用结构化 XML 约束角色一致性。 + +Prompt 结构(固定框架): +```xml + +You are a cinematographer and storyboard artist. +Maintain strict visual continuity across all shots. + + + +Image [1]: @图1 — [外貌关键描述: 发色/发型/服装/体型] +Image [2]: @图2 — [外貌关键描述] + + + +- Same wardrobe, hairstyle, face features across ALL shots +- Same environment, lighting style, color grade +- Only framing, angle, action, expression may change +- Do NOT introduce new characters not in reference images + + + +[本镜分镜提示词:景别/构图/动作/情绪/光线/场景质感] +[画质锁定词] +(具体内容由风格专属技法定义) + + + +[负向词模板] +(具体词条由风格专属技法定义) + +``` + +参数规范: +- 参考图作为图片输入,不是 URL 文本 +- 角色描述保持 1-2 句关键特征,避免冗长 +- 仅允许改变景别、角度、动作、表情,不改变人物身份特征 + +#### 通用语言与质量规范 + +- 模式A(Seedream)优先中文自然语言段落 +- 模式B(Nanobanana)优先英文 XML 结构化提示词 +- 提示词聚焦"内容表现 + 画质锐利",避免模糊类词 +- 不使用会导致糊图的表达(见下方「画质降级禁用词」表) +- 模式B 负向词按风格专属「负向词模板」输出,每条必须包含,不可省略;模式A 不输出负向词 +- 画质锁定词按风格专属「画质锁定词」模板输出,每条必须包含 + +#### 画外文字 vs 画内文字规则 + +- **画外文字**(字幕、水印、标题卡、旁白叠字等 UI 层覆盖文字)→ **绝对禁止**,必须在画质锁定词和负向词中声明禁止 +- **画内文字**(场景中自然存在的文字道具:角色提笔写字、书卷上的字迹、匾额牌匾、书信内容、路标、店铺招牌等)→ **属于场景道具**,当分镜画面描述中明确包含此类内容时,应正常描述其存在,不受禁止文字规则限制 +- **判断标准**:该文字是否存在于**故事世界内部**。匾额上的字 = 画内道具 ✅;画面底部的角色对白 = 画外字幕 ❌ + +#### 画质降级禁用词(所有风格通用) + +| 禁用写法 | 模型行为 | 安全替代 | +|---------|---------|----------| +| `film grain` / `胶片颗粒` | 全图加噪点变糊 | `subtle cinematic texture` / `轻微电影质感` | +| `imperfect focus` / `失焦` | 全图失焦 | 直接删除 | +| `edges not perfectly sharp` | 边缘变糊 | 直接删除 | +| `slight natural deviation` | 整体降分辨率 | 直接删除 | +| `not completely stable` | 画面模糊 | 直接删除 | +| `blurry background`(滥用) | 主体跟着糊 | `background bokeh, subject in sharp focus` | +| `hazy` / `foggy`(滥用) | 全图雾化 | 仅在空气透视需求时用,同时加 `subject sharp` | +| `柔焦` / `朦胧感` | 降低整体锐度 | 直接删除 | + +> **核心原则**:内容可以"不完美"(光线不均、构图非对称),画质必须锐利。 + +#### 批量处理规范 + +用户输入多行分镜表时: + +1. **逐行顺序处理**,不跳行、不合并 +2. 每条分镜仅输出目标模式的提示词正文(Prompt 或 XML Prompt) +3. 若同一场景连续多镜,**场景质感词可复用**,但情绪/光线/景别/动作必须**按行独立处理** +4. 关联资产名称相同的镜次,**一致性标注词必须一致** +5. 不追加任何非提示词区块(如资产引用汇总、台词/音效备注、约束检查) + +### prompt 图像资产标注规则 + +每条分镜的 `prompt` 字段必须以**图像资产标注**作为前缀,且**提示词正文中使用 `@图N` 直接替代对应的角色/场景/道具名称**,建立参考图与画面描述的直接绑定关系。标注按 `associateAssetsIds` 中资产的引用顺序,从 `@图1` 开始依次编号。 + +**格式**:`@图1 为{资产名称}{资产类型} @图2 为{资产名称}{资产类型} ... , 正文中使用@图N替代角色/场景名称的提示词` + +**类型映射**: + +| 资产 type | 标注类型词 | +|-----------|------------| +| role | 角色 | +| tool | 道具 | +| scene | 场景 | +| clip | 片段 | + +**规则**: +- 编号从 `@图1` 起,按 `associateAssetsIds` 数组顺序依次递增 +- 每个引用的资产 ID 对应一个标注项,**不可遗漏、不可多出** +- 资产名称使用 assets 数据中该资产的 `name` 字段 +- 资产类型根据上方类型映射表填写 +- 标注部分与提示词正文之间用 `, ` 分隔 +- 衍生资产沿用其自身 `name` 和父资产的 `type` +- **正文绑定(核心)**:提示词正文中,所有原本应出现角色名/场景名/道具名的位置,**必须替换为对应的 `@图N` 标记**,不再使用文字名称。这样参考图与画面中的视觉主体形成直接指向关系,避免资产名称与角色名称不一致导致的歧义(如衍生资产名"幕离红斗篷"与角色名"戚映竹"无法对应的问题) +- 同一 `@图N` 在正文中可多次出现(如角色在前景和反射面中同时可见时) + +**示例**(假设 `associateAssetsIds="[101, 100, 300]"` 对应苏晚卿(role)、凌玄(role)、大殿(scene)): + +❌ 错误(正文使用文字名称,与前缀标注脱节): +``` +@图1 为苏晚卿角色 @图2 为凌玄角色 @图3 为大殿场景, 苏晚卿冷笑,居高临下看着跪地的凌玄,大殿柱影深沉…… +``` + +✅ 正确(正文使用 @图N 直接绑定参考图): +``` +@图1 为苏晚卿角色 @图2 为凌玄角色 @图3 为大殿场景, @图1 冷笑,居高临下看着跪地的@图2,@图3 柱影深沉…… +``` + +### prompt 人物位置连贯性规则 + +生成每条 prompt 时,须遵守以下跨分镜人物位置一致性约束: + +- **画面位置锁定**:同一角色在同一场景内的多条分镜中,其画面左右位置(画面左侧 / 中央 / 右侧)须保持固定,不得无叙事理由地跳侧 +- **朝向守恒**:对话/对峙场景遵循 180° 视轴线——角色A面朝右则全场景保持面朝右,角色B面朝左则全场景保持面朝左;prompt 中须通过方位词(facing left / 面朝左、on the left side of frame / 画面左侧等)显式标注 +- **前后景层次一致**:若角色A在分镜N中处于前景、角色B处于中景,则同场景后续分镜中二者前后关系不应无理由反转 +- **位置变化须有动作衔接**:角色画面位置确需变化时(如角色走动、转身),前序分镜的 prompt 中须包含对应位移/转身动作描写,不可凭空跳位 +- **跨场景可重置**:切换到全新场景时允许重新分配画面位置,但新场景内部仍须保持一致 +- **反射面视觉关系**:当画面中存在反射介质(镜面、水面、光滑金属、窗玻璃、相机镜头等)时,须注意以下规则: + - **镜像翻转**:反射面中角色的左右朝向与实体相反(实体面朝右→镜像面朝左),prompt 中须显式标注反射体与实体的朝向关系(如"@图1 面朝右,水面倒影中@图1 面朝左") + - **反射面不改变位置基准**:角色的画面位置以实体为准,反射面中的映像不视为角色位置变化 + - **反射面内容与实体一致**:反射面中可见的角色服饰、发型、表情等必须与同帧实体一致,不可出现偏差 + - **反射面景深与清晰度**:根据反射面距离和材质,反射图像可适当降低清晰度(如水面波纹导致的模糊),但须在 prompt 中标注(如"水面倒影微微扭曲") + - **识别触发**:当分镜画面描述或场景资产中包含镜面、水面、湖面、溪流、玻璃、金属反光、相机/摄像等反射性元素时,自动触发本规则 + +### 约束 + +- 前置条件:分镜表已构建完成且用户已确认 +- 你必须使用XML格式写入工作区分镜面板: +- 分组总时长约束:每个 `group` 的累计时长不得超过 15 秒 +- 行数一致性约束:分镜面板 `items` 数量必须与 `stoaryTable` 的分镜数据行数量完全一致(不包含表头与分隔行) +- 时长一致性约束:分镜面板 `duration` 必须与 `stoaryTable` 对应行时长完全一致 +- **人物位置连贯性**:每条 prompt 须通过上述「人物位置连贯性规则」校验,同场景内同一人物的画面位置与朝向描述前后一致 +- **图像资产标注必填**:每条 prompt 必须以图像资产标注前缀开头,标注数量与 `associateAssetsIds` 数量一致、顺序一致;缺少标注或顺序不匹配视为格式错误 +- 阶段边界:本阶段禁止调用 `generate_storyboard_images` + +--- + +## 六、分镜图生成 + +### 工具 + +| 操作 | 调用 | +|------|------| | 读取分镜面板 | `get_flowData("storyboard")` | | 生成图片 | `generate_storyboard_images({ ids: [分镜ID列表] })` | ### 执行流程 -1. 获取 `script` 、`stoaryTable` -2. 使用XML格式写入工作区分镜面板: -3. 生成分镜面板后,先获取 `storyboard`数据 再调用 `generate_storyboard_images({ ids: [真实分镜ID列表] })` 生成分镜图片(异步,发起即返回) +1. 获取 `storyboard` +2. 提取真实分镜 ID 列表 +3. 调用 `generate_storyboard_images({ ids: [真实分镜ID列表] })` 生成分镜图片(异步,发起即返回) ### 约束 -- 前置条件:分镜表已构建完成且用户已确认 +- 前置条件:分镜面板已写入完成 - 图片必须与分镜描述匹配 -- 你必须使用XML格式写入工作区分镜面板: \ No newline at end of file +- 仅使用 `storyboard` 中的真实分镜 ID,禁止编造或复用无效 ID \ No newline at end of file diff --git a/src/agents/productionAgent/index.ts b/src/agents/productionAgent/index.ts index 14d1158..281b789 100644 --- a/src/agents/productionAgent/index.ts +++ b/src/agents/productionAgent/index.ts @@ -135,7 +135,7 @@ function createSubAgent(parentCtx: AgentContext) { "你必须使用如下XML格式写入工作区:\n```", "拍摄计划:内容", "分镜表:内容", - "分镜面板:", + "分镜面板:", "```", ].join("\n"); const projectData = await u.db("o_project").where("id", resTool.data.projectId).first(); diff --git a/src/app.ts b/src/app.ts index 280cb98..cffa3c5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,6 +12,7 @@ import fs from "fs"; import u from "@/utils"; import jwt from "jsonwebtoken"; import socketInit from "@/socket/index"; +import path from "path"; const app = express(); const server = http.createServer(app); @@ -29,14 +30,28 @@ export default async function startServe(randomPort: Boolean = false) { app.use(express.json({ limit: "100mb" })); app.use(express.urlencoded({ extended: true, limit: "100mb" })); - // oss 静态资源 - const rootDir = u.getPath("oss"); - if (!fs.existsSync(rootDir)) { - fs.mkdirSync(rootDir, { recursive: true }); + const ossDir = u.getPath("oss"); + if (!fs.existsSync(ossDir)) { + fs.mkdirSync(ossDir, { recursive: true }); } - console.log("文件目录:", rootDir); - app.use(express.static(rootDir)); + console.log("文件目录:", ossDir); + app.use("/oss", express.static(ossDir)); + // skills 静态资源 + + const skillsDir = u.getPath("skills"); + if (!fs.existsSync(skillsDir)) { + fs.mkdirSync(skillsDir, { recursive: true }); + } + console.log("文件目录:", skillsDir); + // 只允许图片文件访问 + app.use( + "/skills", + (req, res, next) => { + /\.(jpe?g|png|gif|webp|svg|ico|bmp)$/i.test(req.path) ? next() : res.status(403).end(); + }, + express.static(skillsDir), + ); // data/web 静态网站 const webDir = u.getPath("web"); diff --git a/src/env.ts b/src/env.ts index e733138..d1af1b4 100644 --- a/src/env.ts +++ b/src/env.ts @@ -3,8 +3,8 @@ import path from "path"; // 默认环境变量(当 env 文件不存在时自动创建) const defaultEnvValues: Record = { - dev: `NODE_ENV=dev\nPORT=10588\nOSSURL=http://127.0.0.1:10588/`, - prod: `NODE_ENV=prod\nPORT=10588\nOSSURL=http://127.0.0.1:10588/`, + dev: `NODE_ENV=dev\nPORT=10588\nOSSURL=http://127.0.0.1:10588/oss/`, + prod: `NODE_ENV=prod\nPORT=10588\nOSSURL=http://127.0.0.1:10588/oss/`, }; // 判断是否为打包后的 Electron 环境 diff --git a/src/lib/initDB.ts b/src/lib/initDB.ts index aaf01c9..7bd081c 100644 --- a/src/lib/initDB.ts +++ b/src/lib/initDB.ts @@ -488,6 +488,8 @@ description: 专注于从剧本内容中提取所使用的资产(角色、场 table.text("state"); table.integer("trackId"); table.text("reason"); + table.text("videoPrompt"); + table.integer("shouldGenerateImage"); // 0 否 1 是 table.integer("projectId"); table.integer("flowId"); //工作流id table.integer("index"); @@ -539,6 +541,7 @@ description: 专注于从剧本内容中提取所使用的资产(角色、场 table.text("reason"); table.text("prompt"); table.integer("selectVideoId"); + table.integer("duration"); table.primary(["id"]); table.unique(["id"]); }, diff --git a/src/router.ts b/src/router.ts index c7cd0b2..8469a27 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,4 +1,4 @@ -// @routes-hash 3d1d48934f908135efd71196a7205556 +// @routes-hash 6b77c26005a9993d80cda7ab95d26702 import { Express } from "express"; import route1 from "./routes/agents/clearMemory"; @@ -70,66 +70,67 @@ import route66 from "./routes/production/storyboard/previewImage"; import route67 from "./routes/production/storyboard/removeFrame"; import route68 from "./routes/production/storyboard/updateStoryboardUrl"; import route69 from "./routes/production/workbench/addTrack"; -import route70 from "./routes/production/workbench/delVideo"; -import route71 from "./routes/production/workbench/generateVideo"; -import route72 from "./routes/production/workbench/generateVideoPrompt"; -import route73 from "./routes/production/workbench/getGenerateData"; -import route74 from "./routes/production/workbench/getVideoList"; -import route75 from "./routes/production/workbench/getVideoModelDetail"; -import route76 from "./routes/production/workbench/selectVideo"; -import route77 from "./routes/project/addProject"; -import route78 from "./routes/project/addVisual"; -import route79 from "./routes/project/addVisualManual"; -import route80 from "./routes/project/deleteVisualManual"; -import route81 from "./routes/project/delProject"; -import route82 from "./routes/project/editProject"; -import route83 from "./routes/project/editVisualManual"; -import route84 from "./routes/project/getProject"; -import route85 from "./routes/project/getVisualManual"; -import route86 from "./routes/project/visualManual"; -import route87 from "./routes/script/addScript"; -import route88 from "./routes/script/delScript"; -import route89 from "./routes/script/exportScript"; -import route90 from "./routes/script/extractAssets"; -import route91 from "./routes/script/getScrptApi"; -import route92 from "./routes/script/pollScriptAssets"; -import route93 from "./routes/script/updateScript"; -import route94 from "./routes/scriptAgent/getPlanData"; -import route95 from "./routes/scriptAgent/setPlanData"; -import route96 from "./routes/scriptAgent/updateData"; -import route97 from "./routes/setting/about/checkUpdate"; -import route98 from "./routes/setting/about/downloadApp"; -import route99 from "./routes/setting/agentDeploy/agentSetKey"; -import route100 from "./routes/setting/agentDeploy/deployAgentModel"; -import route101 from "./routes/setting/agentDeploy/getAgentDeploy"; -import route102 from "./routes/setting/dbConfig/clearData"; -import route103 from "./routes/setting/dev/getSwitchAiDevTool"; -import route104 from "./routes/setting/dev/updateSwitchAiDevTool"; -import route105 from "./routes/setting/fileManagement/openFolder"; -import route106 from "./routes/setting/getTextModel"; -import route107 from "./routes/setting/loginConfig/getUser"; -import route108 from "./routes/setting/loginConfig/updateUserPwd"; -import route109 from "./routes/setting/memoryConfig/delAllMemory"; -import route110 from "./routes/setting/memoryConfig/getMemory"; -import route111 from "./routes/setting/memoryConfig/sureMemory"; -import route112 from "./routes/setting/promptManage/getPrompt"; -import route113 from "./routes/setting/promptManage/updatePrompt"; -import route114 from "./routes/setting/skillManagement/getSkillContent"; -import route115 from "./routes/setting/skillManagement/getSkillList"; -import route116 from "./routes/setting/skillManagement/saveSkillContent"; -import route117 from "./routes/setting/vendorConfig/addVendor"; -import route118 from "./routes/setting/vendorConfig/deleteVendor"; -import route119 from "./routes/setting/vendorConfig/enableEnglishVendor"; -import route120 from "./routes/setting/vendorConfig/getCodeByLink"; -import route121 from "./routes/setting/vendorConfig/getVendorList"; -import route122 from "./routes/setting/vendorConfig/modelTest"; -import route123 from "./routes/setting/vendorConfig/updateCode"; -import route124 from "./routes/setting/vendorConfig/updateVendor"; -import route125 from "./routes/task/getProject"; -import route126 from "./routes/task/getTaskApi"; -import route127 from "./routes/task/getTaskCategories"; -import route128 from "./routes/task/taskDetails"; -import route129 from "./routes/test/test"; +import route70 from "./routes/production/workbench/deleteTrack"; +import route71 from "./routes/production/workbench/delVideo"; +import route72 from "./routes/production/workbench/generateVideo"; +import route73 from "./routes/production/workbench/generateVideoPrompt"; +import route74 from "./routes/production/workbench/getGenerateData"; +import route75 from "./routes/production/workbench/getVideoList"; +import route76 from "./routes/production/workbench/getVideoModelDetail"; +import route77 from "./routes/production/workbench/selectVideo"; +import route78 from "./routes/project/addProject"; +import route79 from "./routes/project/addVisual"; +import route80 from "./routes/project/addVisualManual"; +import route81 from "./routes/project/deleteVisualManual"; +import route82 from "./routes/project/delProject"; +import route83 from "./routes/project/editProject"; +import route84 from "./routes/project/editVisualManual"; +import route85 from "./routes/project/getProject"; +import route86 from "./routes/project/getVisualManual"; +import route87 from "./routes/project/visualManual"; +import route88 from "./routes/script/addScript"; +import route89 from "./routes/script/delScript"; +import route90 from "./routes/script/exportScript"; +import route91 from "./routes/script/extractAssets"; +import route92 from "./routes/script/getScrptApi"; +import route93 from "./routes/script/pollScriptAssets"; +import route94 from "./routes/script/updateScript"; +import route95 from "./routes/scriptAgent/getPlanData"; +import route96 from "./routes/scriptAgent/setPlanData"; +import route97 from "./routes/scriptAgent/updateData"; +import route98 from "./routes/setting/about/checkUpdate"; +import route99 from "./routes/setting/about/downloadApp"; +import route100 from "./routes/setting/agentDeploy/agentSetKey"; +import route101 from "./routes/setting/agentDeploy/deployAgentModel"; +import route102 from "./routes/setting/agentDeploy/getAgentDeploy"; +import route103 from "./routes/setting/dbConfig/clearData"; +import route104 from "./routes/setting/dev/getSwitchAiDevTool"; +import route105 from "./routes/setting/dev/updateSwitchAiDevTool"; +import route106 from "./routes/setting/fileManagement/openFolder"; +import route107 from "./routes/setting/getTextModel"; +import route108 from "./routes/setting/loginConfig/getUser"; +import route109 from "./routes/setting/loginConfig/updateUserPwd"; +import route110 from "./routes/setting/memoryConfig/delAllMemory"; +import route111 from "./routes/setting/memoryConfig/getMemory"; +import route112 from "./routes/setting/memoryConfig/sureMemory"; +import route113 from "./routes/setting/promptManage/getPrompt"; +import route114 from "./routes/setting/promptManage/updatePrompt"; +import route115 from "./routes/setting/skillManagement/getSkillContent"; +import route116 from "./routes/setting/skillManagement/getSkillList"; +import route117 from "./routes/setting/skillManagement/saveSkillContent"; +import route118 from "./routes/setting/vendorConfig/addVendor"; +import route119 from "./routes/setting/vendorConfig/deleteVendor"; +import route120 from "./routes/setting/vendorConfig/enableEnglishVendor"; +import route121 from "./routes/setting/vendorConfig/getCodeByLink"; +import route122 from "./routes/setting/vendorConfig/getVendorList"; +import route123 from "./routes/setting/vendorConfig/modelTest"; +import route124 from "./routes/setting/vendorConfig/updateCode"; +import route125 from "./routes/setting/vendorConfig/updateVendor"; +import route126 from "./routes/task/getProject"; +import route127 from "./routes/task/getTaskApi"; +import route128 from "./routes/task/getTaskCategories"; +import route129 from "./routes/task/taskDetails"; +import route130 from "./routes/test/test"; export default async (app: Express) => { app.use("/api/agents/clearMemory", route1); @@ -201,64 +202,65 @@ export default async (app: Express) => { app.use("/api/production/storyboard/removeFrame", route67); app.use("/api/production/storyboard/updateStoryboardUrl", route68); app.use("/api/production/workbench/addTrack", route69); - app.use("/api/production/workbench/delVideo", route70); - app.use("/api/production/workbench/generateVideo", route71); - app.use("/api/production/workbench/generateVideoPrompt", route72); - app.use("/api/production/workbench/getGenerateData", route73); - app.use("/api/production/workbench/getVideoList", route74); - app.use("/api/production/workbench/getVideoModelDetail", route75); - app.use("/api/production/workbench/selectVideo", route76); - app.use("/api/project/addProject", route77); - app.use("/api/project/addVisual", route78); - app.use("/api/project/addVisualManual", route79); - app.use("/api/project/deleteVisualManual", route80); - app.use("/api/project/delProject", route81); - app.use("/api/project/editProject", route82); - app.use("/api/project/editVisualManual", route83); - app.use("/api/project/getProject", route84); - app.use("/api/project/getVisualManual", route85); - app.use("/api/project/visualManual", route86); - app.use("/api/script/addScript", route87); - app.use("/api/script/delScript", route88); - app.use("/api/script/exportScript", route89); - app.use("/api/script/extractAssets", route90); - app.use("/api/script/getScrptApi", route91); - app.use("/api/script/pollScriptAssets", route92); - app.use("/api/script/updateScript", route93); - app.use("/api/scriptAgent/getPlanData", route94); - app.use("/api/scriptAgent/setPlanData", route95); - app.use("/api/scriptAgent/updateData", route96); - app.use("/api/setting/about/checkUpdate", route97); - app.use("/api/setting/about/downloadApp", route98); - app.use("/api/setting/agentDeploy/agentSetKey", route99); - app.use("/api/setting/agentDeploy/deployAgentModel", route100); - app.use("/api/setting/agentDeploy/getAgentDeploy", route101); - app.use("/api/setting/dbConfig/clearData", route102); - app.use("/api/setting/dev/getSwitchAiDevTool", route103); - app.use("/api/setting/dev/updateSwitchAiDevTool", route104); - app.use("/api/setting/fileManagement/openFolder", route105); - app.use("/api/setting/getTextModel", route106); - app.use("/api/setting/loginConfig/getUser", route107); - app.use("/api/setting/loginConfig/updateUserPwd", route108); - app.use("/api/setting/memoryConfig/delAllMemory", route109); - app.use("/api/setting/memoryConfig/getMemory", route110); - app.use("/api/setting/memoryConfig/sureMemory", route111); - app.use("/api/setting/promptManage/getPrompt", route112); - app.use("/api/setting/promptManage/updatePrompt", route113); - app.use("/api/setting/skillManagement/getSkillContent", route114); - app.use("/api/setting/skillManagement/getSkillList", route115); - app.use("/api/setting/skillManagement/saveSkillContent", route116); - app.use("/api/setting/vendorConfig/addVendor", route117); - app.use("/api/setting/vendorConfig/deleteVendor", route118); - app.use("/api/setting/vendorConfig/enableEnglishVendor", route119); - app.use("/api/setting/vendorConfig/getCodeByLink", route120); - app.use("/api/setting/vendorConfig/getVendorList", route121); - app.use("/api/setting/vendorConfig/modelTest", route122); - app.use("/api/setting/vendorConfig/updateCode", route123); - app.use("/api/setting/vendorConfig/updateVendor", route124); - app.use("/api/task/getProject", route125); - app.use("/api/task/getTaskApi", route126); - app.use("/api/task/getTaskCategories", route127); - app.use("/api/task/taskDetails", route128); - app.use("/api/test/test", route129); + app.use("/api/production/workbench/deleteTrack", route70); + app.use("/api/production/workbench/delVideo", route71); + app.use("/api/production/workbench/generateVideo", route72); + app.use("/api/production/workbench/generateVideoPrompt", route73); + app.use("/api/production/workbench/getGenerateData", route74); + app.use("/api/production/workbench/getVideoList", route75); + app.use("/api/production/workbench/getVideoModelDetail", route76); + app.use("/api/production/workbench/selectVideo", route77); + app.use("/api/project/addProject", route78); + app.use("/api/project/addVisual", route79); + app.use("/api/project/addVisualManual", route80); + app.use("/api/project/deleteVisualManual", route81); + app.use("/api/project/delProject", route82); + app.use("/api/project/editProject", route83); + app.use("/api/project/editVisualManual", route84); + app.use("/api/project/getProject", route85); + app.use("/api/project/getVisualManual", route86); + app.use("/api/project/visualManual", route87); + app.use("/api/script/addScript", route88); + app.use("/api/script/delScript", route89); + app.use("/api/script/exportScript", route90); + app.use("/api/script/extractAssets", route91); + app.use("/api/script/getScrptApi", route92); + app.use("/api/script/pollScriptAssets", route93); + app.use("/api/script/updateScript", route94); + app.use("/api/scriptAgent/getPlanData", route95); + app.use("/api/scriptAgent/setPlanData", route96); + app.use("/api/scriptAgent/updateData", route97); + app.use("/api/setting/about/checkUpdate", route98); + app.use("/api/setting/about/downloadApp", route99); + app.use("/api/setting/agentDeploy/agentSetKey", route100); + app.use("/api/setting/agentDeploy/deployAgentModel", route101); + app.use("/api/setting/agentDeploy/getAgentDeploy", route102); + app.use("/api/setting/dbConfig/clearData", route103); + app.use("/api/setting/dev/getSwitchAiDevTool", route104); + app.use("/api/setting/dev/updateSwitchAiDevTool", route105); + app.use("/api/setting/fileManagement/openFolder", route106); + app.use("/api/setting/getTextModel", route107); + app.use("/api/setting/loginConfig/getUser", route108); + app.use("/api/setting/loginConfig/updateUserPwd", route109); + app.use("/api/setting/memoryConfig/delAllMemory", route110); + app.use("/api/setting/memoryConfig/getMemory", route111); + app.use("/api/setting/memoryConfig/sureMemory", route112); + app.use("/api/setting/promptManage/getPrompt", route113); + app.use("/api/setting/promptManage/updatePrompt", route114); + app.use("/api/setting/skillManagement/getSkillContent", route115); + app.use("/api/setting/skillManagement/getSkillList", route116); + app.use("/api/setting/skillManagement/saveSkillContent", route117); + app.use("/api/setting/vendorConfig/addVendor", route118); + app.use("/api/setting/vendorConfig/deleteVendor", route119); + app.use("/api/setting/vendorConfig/enableEnglishVendor", route120); + app.use("/api/setting/vendorConfig/getCodeByLink", route121); + app.use("/api/setting/vendorConfig/getVendorList", route122); + app.use("/api/setting/vendorConfig/modelTest", route123); + app.use("/api/setting/vendorConfig/updateCode", route124); + app.use("/api/setting/vendorConfig/updateVendor", route125); + app.use("/api/task/getProject", route126); + app.use("/api/task/getTaskApi", route127); + app.use("/api/task/getTaskCategories", route128); + app.use("/api/task/taskDetails", route129); + app.use("/api/test/test", route130); } diff --git a/src/routes/assetsGenerate/batchPolishAssetsPrompt.ts b/src/routes/assetsGenerate/batchPolishAssetsPrompt.ts index 72c9c07..79a375a 100644 --- a/src/routes/assetsGenerate/batchPolishAssetsPrompt.ts +++ b/src/routes/assetsGenerate/batchPolishAssetsPrompt.ts @@ -83,21 +83,47 @@ export default router.post( }); }); const result: ResultItem[] = Object.values(itemMap); - - const typeConfig: Record = { - role: { promptKey: "role-polish", itemType: "characters", label: "角色标准四视图", nameLabel: "角色", visualManual: "art_character" }, - scene: { promptKey: "scene-polish", itemType: "scenes", label: "场景图", nameLabel: "场景", visualManual: "art_scene" }, - tool: { promptKey: "tool-polish", itemType: "props", label: "道具图", nameLabel: "道具", visualManual: "art_prop" }, - }; // 批量更新所有 item 状态为生成中 const assetsIds = items.map((item: { assetsId: number }) => item.assetsId); await u.db("o_assets").whereIn("id", assetsIds).update({ promptState: "生成中" }); + //查询所有资产,用于判断每个资产是否是衍生资产 + const assetsDataList = await u.db("o_assets").whereIn("id", assetsIds).select("id", "assetsId"); + if (!assetsDataList || assetsDataList.length === 0) return res.status(500).send(error("资产不存在")); + const assetsDataMap = new Map(assetsDataList.map((a: any) => [a.id, a])); + + const getTypeConfig = ( + isDerivative: boolean, + ): Record => ({ + role: { + promptKey: "role-polish", + itemType: "characters", + label: "角色标准四视图", + nameLabel: "角色", + visualManual: isDerivative ? "art_character_derivative" : "art_character", + }, + scene: { + promptKey: "scene-polish", + itemType: "scenes", + label: "场景图", + nameLabel: "场景", + visualManual: isDerivative ? "art_scene_derivative" : "art_scene", + }, + tool: { + promptKey: "tool-polish", + itemType: "props", + label: "道具图", + nameLabel: "道具", + visualManual: isDerivative ? "art_prop_derivative" : "art_prop", + }, + }); // 后台异步并发生成,不阻塞响应 const limit = pLimit(concurrentCount ?? 1); - const tasks = items.map((item: { assetsId: number; type: string; name: string; describe: string }) => limit(async () => { + const assetData = assetsDataMap.get(item.assetsId); + if (!assetData) return; + const typeConfig = getTypeConfig(!!assetData.assetsId); const config = typeConfig[item.type]; if (!config) return; //获取到视觉手册 diff --git a/src/routes/assetsGenerate/polishAssetsPrompt.ts b/src/routes/assetsGenerate/polishAssetsPrompt.ts index 438af1e..ee96a7d 100644 --- a/src/routes/assetsGenerate/polishAssetsPrompt.ts +++ b/src/routes/assetsGenerate/polishAssetsPrompt.ts @@ -76,11 +76,31 @@ export default router.post( }); const result: ResultItem[] = Object.values(itemMap); - + //查询资产是否是衍生资产 + const assetsData = await u.db("o_assets").where("id", assetsId).select("assetsId").first(); + if (!assetsData) return { code: 500, message: "资产不存在" }; const typeConfig: Record = { - role: { promptKey: "role-polish", itemType: "characters", label: "角色标准四视图", nameLabel: "角色", visualManual: "art_character" }, - scene: { promptKey: "scene-polish", itemType: "scenes", label: "场景图", nameLabel: "场景", visualManual: "art_scene" }, - tool: { promptKey: "tool-polish", itemType: "props", label: "道具图", nameLabel: "道具", visualManual: "art_prop" }, + role: { + promptKey: "role-polish", + itemType: "characters", + label: "角色标准四视图", + nameLabel: "角色", + visualManual: assetsData.assetsId ? "art_character_derivative" : "art_character", + }, + scene: { + promptKey: "scene-polish", + itemType: "scenes", + label: "场景图", + nameLabel: "场景", + visualManual: assetsData.assetsId ? "art_scene_derivative" : "art_scene", + }, + tool: { + promptKey: "tool-polish", + itemType: "props", + label: "道具图", + nameLabel: "道具", + visualManual: assetsData.assetsId ? "art_prop_derivative" : "art_prop", + }, }; const config = typeConfig[type]; diff --git a/src/routes/production/storyboard/addStoryboard.ts b/src/routes/production/storyboard/addStoryboard.ts index fe94d37..cf707ff 100644 --- a/src/routes/production/storyboard/addStoryboard.ts +++ b/src/routes/production/storyboard/addStoryboard.ts @@ -18,12 +18,14 @@ export default router.post( prompt: z.string(), duration: z.number(), state: z.string(), + videoPrompt: z.string(), + shouldGenerateImage: z.number(), src: z.string().nullable(), scriptId: z.number(), projectId: z.number(), }), async (req, res) => { - const { prompt, duration, state, src, scriptId, projectId } = req.body; + const { prompt, duration, state, src, scriptId, projectId, videoPrompt, shouldGenerateImage } = req.body; const [trackId] = await u.db("o_videoTrack").insert({ scriptId: scriptId, @@ -35,6 +37,8 @@ export default router.post( state, filePath: new URL(src).pathname, trackId, + videoPrompt, + shouldGenerateImage, scriptId: scriptId, projectId: projectId, }); diff --git a/src/routes/production/storyboard/batchAddStoryboardInfo.ts b/src/routes/production/storyboard/batchAddStoryboardInfo.ts index cd8e80b..32aa8e6 100644 --- a/src/routes/production/storyboard/batchAddStoryboardInfo.ts +++ b/src/routes/production/storyboard/batchAddStoryboardInfo.ts @@ -4,14 +4,6 @@ import { z } from "zod"; import { error, success } from "@/lib/responseFormat"; import { validateFields } from "@/middleware/middleware"; const router = express.Router(); -interface Storyboard { - id: number; - track: string; - src: string | null; - associateAssetsIds: number[]; - duration: number; - state: string; -} export default router.post( "/", validateFields({ @@ -22,6 +14,8 @@ export default router.post( track: z.string(), state: z.string(), src: z.string().nullable(), + videoPrompt: z.string(), + shouldGenerateImage: z.number(), associateAssetsIds: z.array(z.number()), }), ), @@ -38,6 +32,9 @@ export default router.post( state: item.state, scriptId, projectId, + track:item.track, + videoPrompt:item.videoPrompt, + shouldGenerateImage:item.shouldGenerateImage, createTime: Date.now(), }); if (item.associateAssetsIds?.length) { @@ -50,33 +47,47 @@ export default router.post( } item.id = id; } + const lastStoryboard = await u.db("o_storyboard").where("scriptId", scriptId); + if (!lastStoryboard || !lastStoryboard.length) return res.status(400).send(error("未查到分镜数据")); //根据track分组 const storyboardGroupByTrack: Record = {}; - data.forEach((item: any) => { + lastStoryboard.forEach((item: any) => { if (!storyboardGroupByTrack[item.track]) { storyboardGroupByTrack[item.track] = []; } storyboardGroupByTrack[item.track].push(item.id); }); - //循环 + //循环:先查询数据库中是否已存在相同track名称的trackId,有则复用,没有则新建 for (const track in storyboardGroupByTrack) { - const [trackId] = await u.db("o_videoTrack").insert({ - scriptId, - projectId, - }); const storyboardIds = storyboardGroupByTrack[track] ?? []; + + // 计算该track下所有分镜的duration总和 + const trackDuration = lastStoryboard + .filter((item: any) => item.track == track) + .reduce((sum: number, item: any) => sum + Number(item.duration), 0); + + // 查找该scriptId下是否已有相同track名称且已分配trackId的分镜记录 + const existingStoryboard = await u.db("o_storyboard").where({ scriptId, track }).whereNotNull("trackId").first(); + + let trackId: number; + if (existingStoryboard?.trackId) { + // 已存在相同track名称的trackId,直接复用,并更新duration + trackId = existingStoryboard.trackId; + await u.db("o_videoTrack").where("id", trackId).update({ duration: trackDuration }); + } else { + // 不存在,新建videoTrack + const [newTrackId] = await u.db("o_videoTrack").insert({ + scriptId, + projectId, + duration: trackDuration, + }); + trackId = newTrackId; + } + await u.db("o_storyboard").whereIn("id", storyboardIds).update({ trackId }); } - const lastStoryboard = await u - .db("o_storyboard") - .where("scriptId", scriptId) - .select("id", "trackId", "prompt", "duration", "state", "scriptId", "reason", "filePath"); - if (!lastStoryboard || !lastStoryboard.length) return res.status(400).send(error("未查到分镜数据")); - batchGenerateVideoPrompts( - data.map((i: any) => i.id), - projectId, - ); + const storyboardData = await Promise.all( lastStoryboard.map(async (i) => { return { @@ -95,58 +106,3 @@ export default router.post( return res.status(200).send(success(storyboardData)); }, ); - -async function batchGenerateVideoPrompts(storyboardIds: number[], projectId: number) { - const lastStoryboard = await u.db("o_storyboard").whereIn("id", storyboardIds).select("id", "trackId", "prompt"); - const allTrackIds = lastStoryboard.map((i) => i.trackId); - const storyboardPromptRecord: Record = {}; - lastStoryboard.forEach((i) => { - if (i.trackId) { - if (!storyboardPromptRecord[i.trackId]) { - storyboardPromptRecord[i.trackId] = []; - } - storyboardPromptRecord[i.trackId].push(i.prompt!); - } - }); - const projectSetting = await u.db("o_project").where("id", projectId).select("artStyle").first(); - const systemPrompt = u.getArtPrompt(projectSetting?.artStyle!, "art_storyboard_video"); - await u - .db("o_videoTrack") - .whereIn("id", allTrackIds as number[]) - .update({ - state: "生成中", - }); - for (const trackId in storyboardPromptRecord) { - const storboardPrompts = storyboardPromptRecord[trackId]; - try { - const { text } = await u.Ai.Text("universalAi").invoke({ - messages: [ - { - role: "system", - content: systemPrompt, - }, - { - role: "user", - content: `请根据我所提供的 ${storboardPrompts.length} 条分镜内容,为我生成一条视频提示词,请直接输出提示词内容,不做任何解释说明。 - 分镜内容如下: - ${storboardPrompts.map((i, index) => `${index + 1}.${i}`).join("\n")}`, - }, - ], - }); - await u.db("o_videoTrack").where("id", trackId).update({ - state: "已完成", - prompt: text, - }); - console.log("%c Line:116 🍎 text", "background:#42b983", text); - } catch (e) { - console.error("生成视频提示词失败", e); - await u - .db("o_videoTrack") - .where("id", trackId) - .update({ - state: "生成失败", - reason: u.error(e).message, - }); - } - } -} diff --git a/src/routes/production/storyboard/batchGenerateImage.ts b/src/routes/production/storyboard/batchGenerateImage.ts index 398fe31..a47ef8c 100644 --- a/src/routes/production/storyboard/batchGenerateImage.ts +++ b/src/routes/production/storyboard/batchGenerateImage.ts @@ -43,6 +43,8 @@ export default router.post( .leftJoin("o_assets2Storyboard", "o_assets.id", "o_assets2Storyboard.assetId") .whereIn("o_assets2Storyboard.storyboardId", finalStoryboardIds) .select("o_assets2Storyboard.storyboardId", "o_assets.imageId"); + console.log("%c Line:42 🥪 assetData", "background:#ea7e5c", assetData); + const assetRecord: Record = {}; assetData.forEach((item: any) => { if (!assetRecord[item.storyboardId]) { @@ -50,9 +52,7 @@ export default router.post( } assetRecord[item.storyboardId].push(item.imageId); }); - await u.db("o_storyboard").whereIn("id", finalStoryboardIds).update({ - state: "生成中", - }); + res.status(200).send( success( storyboardData.map((i) => ({ @@ -111,25 +111,30 @@ export default router.post( }, ); async function getAssetsImageBase64(imageIds: number[]) { - if (imageIds.length === 0) return []; - const imagePaths = await u - .db("o_assets") - .leftJoin("o_image", "o_assets.imageId", "o_image.id") - .whereIn("o_assets.id", imageIds) - .select("o_assets.id", "o_image.filePath"); - if (!imagePaths.length) return []; + if (!imageIds.length) return []; + + const imagePaths = await u.db("o_image").whereIn("o_image.id", imageIds).select("o_image.id", "o_image.filePath"); + + // 建立 id 到 filePath 的映射 + const id2Path = new Map(); + for (const row of imagePaths) { + id2Path.set(row.id, row.filePath); + } + + // 保证输出顺序与 imageIds 一致 const imageUrls = await Promise.all( - imagePaths.map(async (i) => { - if (i.filePath) { + imageIds.map(async (id) => { + const filePath = id2Path.get(id); + if (filePath) { try { - return await urlToBase64(await u.oss.getFileUrl(i.filePath)); + return await urlToBase64(await u.oss.getFileUrl(filePath)); } catch { return null; } - } else { - return null; } + return null; }), ); + // 保留顺序,并且过滤掉无效项 return imageUrls.filter(Boolean) as string[]; } diff --git a/src/routes/production/workbench/delVideo.ts b/src/routes/production/workbench/delVideo.ts index dd440cc..4b87855 100644 --- a/src/routes/production/workbench/delVideo.ts +++ b/src/routes/production/workbench/delVideo.ts @@ -8,12 +8,11 @@ const router = express.Router(); export default router.post( "/", validateFields({ - videoId: z.number(), + id: z.number(), }), async (req, res) => { - const { videoId } = req.body; - await u.db("o_video").where("id", videoId).delete(); - await u.db("o_videoConfig").where("videoId", videoId).update({ videoId: null, updateTime: Date.now() }); + const { id } = req.body; + await u.db("o_video").where("id", id).delete(); res.status(200).send(success({ message: "视频删除成功" })); }, ); diff --git a/src/routes/production/workbench/deleteTrack.ts b/src/routes/production/workbench/deleteTrack.ts new file mode 100644 index 0000000..5541175 --- /dev/null +++ b/src/routes/production/workbench/deleteTrack.ts @@ -0,0 +1,19 @@ +import express from "express"; +import u from "@/utils"; +import { z } from "zod"; +import { success } from "@/lib/responseFormat"; +import { validateFields } from "@/middleware/middleware"; +const router = express.Router(); + +export default router.post( + "/", + validateFields({ + id: z.number(), + }), + async (req, res) => { + const { id } = req.body; + await u.db("o_videoTrack").where("id", id).delete(); + await u.db("o_storyboard").where("trackId", id).delete(); + res.status(200).send(success({ message: "视频段删除成功" })); + }, +); diff --git a/src/routes/production/workbench/getGenerateData.ts b/src/routes/production/workbench/getGenerateData.ts index b69f2d2..0a18465 100644 --- a/src/routes/production/workbench/getGenerateData.ts +++ b/src/routes/production/workbench/getGenerateData.ts @@ -23,6 +23,7 @@ interface TrackItem { prompt: string; state: "未生成" | "生成中" | "已完成" | "生成失败"; reason?: string; + duration?: number; selectVideoId?: number; medias: TrackMedia[]; videoList: VideoItem[]; @@ -53,6 +54,7 @@ export default router.post( const item = trackData.find((t) => t.id === trackId); trackList.push({ id: trackId, + duration: item?.duration ?? 0, prompt: item?.prompt || "", state: (item?.state as "未生成" | "生成中" | "已完成" | "生成失败") ?? "未生成", reason: item?.reason ?? "", diff --git a/src/routes/project/addVisualManual.ts b/src/routes/project/addVisualManual.ts index 434ed67..e8550fd 100644 --- a/src/routes/project/addVisualManual.ts +++ b/src/routes/project/addVisualManual.ts @@ -76,11 +76,11 @@ export default router.post( } fs.writeFileSync(filePath, item.data, "utf-8"); } - const ossImagesDir = u.getPath(["oss", stylePath]); + const imagesDir = path.join(mainPath, "images"); let existingFiles: string[] = []; try { - const allFiles = fs.readdirSync(ossImagesDir); + const allFiles = fs.readdirSync(imagesDir); existingFiles = allFiles.filter((f) => /\.(png|jpe?g|gif|webp|svg)$/i.test(f)); } catch {} @@ -88,12 +88,22 @@ export default router.post( for (const file of existingFiles) { if (!retainedFileNames.has(file)) { - await u.oss.deleteFile(`${stylePath}/${file}`); + const filePath = path.join(imagesDir, file); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } } + if (!fs.existsSync(imagesDir)) { + fs.mkdirSync(imagesDir, { recursive: true }); + } + for (const item of images) { - if (!item.startsWith("http")) await u.oss.writeFile(`${stylePath}/${u.uuid()}.jpg`, item); + if (!item.startsWith("http")) { + const fileName = `${u.uuid()}.jpg`; + const targetPath = path.join(imagesDir, fileName); + const buffer = Buffer.from(item.replace(/^data:[^;]+;base64,/, ""), "base64"); + fs.writeFileSync(targetPath, buffer); + } } res.status(200).send(success()); diff --git a/src/routes/project/deleteVisualManual.ts b/src/routes/project/deleteVisualManual.ts index 5d35333..b0a1244 100644 --- a/src/routes/project/deleteVisualManual.ts +++ b/src/routes/project/deleteVisualManual.ts @@ -33,14 +33,6 @@ export default router.post( } catch (e) { console.error("[删除视觉手册] 删除失败:", artPromptsDir, e); } - - // 2. 删除 oss 下的同名文件夹(存放图片),独立于 art_prompts 目录 - try { - await u.oss.deleteDirectory(name); - } catch (e) { - console.warn("[删除视觉手册] oss 目录删除失败:", name, e); - } - res.status(200).send(success({ message: "删除成功" })); } catch (err) { res.status(500).send(error(u.error(err).message || "删除失败")); diff --git a/src/routes/project/editVisualManual.ts b/src/routes/project/editVisualManual.ts index 77c7ea3..69997b0 100644 --- a/src/routes/project/editVisualManual.ts +++ b/src/routes/project/editVisualManual.ts @@ -77,11 +77,11 @@ export default router.post( const content = item.value === "README" ? `${name}\n${item.data}` : item.data; fs.writeFileSync(filePath, content, "utf-8"); } - const ossImagesDir = u.getPath(["oss", stylePath]); + const imagesDir = path.join(mainPath, "images"); let existingFiles: string[] = []; try { - const allFiles = fs.readdirSync(ossImagesDir); + const allFiles = fs.readdirSync(imagesDir); existingFiles = allFiles.filter((f) => /\.(png|jpe?g|gif|webp|svg)$/i.test(f)); } catch {} @@ -89,12 +89,22 @@ export default router.post( for (const file of existingFiles) { if (!retainedFileNames.has(file)) { - await u.oss.deleteFile(`${stylePath}/${file}`); + const filePath = path.join(imagesDir, file); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } } + if (!fs.existsSync(imagesDir)) { + fs.mkdirSync(imagesDir, { recursive: true }); + } + for (const item of images) { - if (!item.startsWith("http")) await u.oss.writeFile(`${stylePath}/${u.uuid()}.jpg`, item); + if (!item.startsWith("http")) { + const fileName = `${u.uuid()}.jpg`; + const targetPath = path.join(imagesDir, fileName); + const buffer = Buffer.from(item.replace(/^data:[^;]+;base64,/, ""), "base64"); + fs.writeFileSync(targetPath, buffer); + } } res.status(200).send(success()); diff --git a/src/routes/project/getVisualManual.ts b/src/routes/project/getVisualManual.ts index e227418..b997b96 100644 --- a/src/routes/project/getVisualManual.ts +++ b/src/routes/project/getVisualManual.ts @@ -33,11 +33,11 @@ function readMd(filePath: string): string { // 获取 images 文件夹下所有图片文件路径列表 async function readAllImages(imagesDir: string) { try { - const ossPath = u.getPath(["oss", imagesDir]); + const ossPath = u.getPath(path.join("skills", "art_prompts", imagesDir, "images")); const files = fs.readdirSync(ossPath); - const images = files.filter((f) => /\.(png|jpe?g|gif|webp|svg)$/i.test(f)).map((f) => path.join(imagesDir, f)); + const images = files.filter((f) => /\.(png|jpe?g|gif|webp|svg)$/i.test(f)).map((f) => path.join("art_prompts", imagesDir, "images", f)); if (images.length) { - return Promise.all(images.map(async (i) => await u.oss.getFileUrl(i))); + return Promise.all(images.map(async (i) => await u.oss.getFileUrl(i, "skills"))); } else { return []; } @@ -60,7 +60,6 @@ export default router.post("/", async (req, res) => { const result = await Promise.all( styleDirs.map(async (styleName) => { const styleDir = path.join(artPromptsDir, styleName); - const images = await readAllImages(styleName); const readmePath = path.join(styleDir, "README.md"); const readmeContent = fs.readFileSync(readmePath, "utf-8"); diff --git a/src/utils/oss.ts b/src/utils/oss.ts index e3e9f0c..8ae4927 100644 --- a/src/utils/oss.ts +++ b/src/utils/oss.ts @@ -45,12 +45,13 @@ class OSS { * @param userRelPath 用户传入的相对文件路径(使用 / 作为分隔符) * @returns 文件的 http 链接(本地服务地址) */ - async getFileUrl(userRelPath: string): Promise { + async getFileUrl(userRelPath: string, prefix?: string): Promise { + if (!prefix) prefix = "oss"; await this.ensureInit(); const safePath = normalizeUserPath(userRelPath); // URL 始终使用 /,所以这里需要将系统分隔符转回 / - let url = process.env.OSSURL || `http://127.0.0.1:10588/`; - if (isEletron()) url = `http://localhost:${process.env.PORT}/`; + let url = `${process.env.OSSURL}${prefix}/` || `http://127.0.0.1:10588/${prefix}/`; + if (isEletron()) url = `http://localhost:${process.env.PORT}/${prefix}/`; return `${url}${safePath.split(path.sep).join("/")}`; } @@ -146,10 +147,7 @@ class OSS { await fs.mkdir(path.dirname(absPath), { recursive: true }); // 如果 data 是 string,则视为 base64 编码,先解码再写入 // 自动去除可能存在的 Data URL 前缀(如 "data:image/png;base64,") - const buffer = - typeof data === "string" - ? Buffer.from(data.replace(/^data:[^;]+;base64,/, ""), "base64") - : data; + const buffer = typeof data === "string" ? Buffer.from(data.replace(/^data:[^;]+;base64,/, ""), "base64") : data; await fs.writeFile(absPath, buffer); }