diff --git a/docs/script/sql/README.md b/docs/script/sql/README.md index e00e7489..5f4fd16a 100644 --- a/docs/script/sql/README.md +++ b/docs/script/sql/README.md @@ -8,7 +8,7 @@ mysql -uroot -p < ruoyi-ai.sql ``` -主 SQL 已合并下表中的全部更新,无需再次执行 `update` 目录中的脚本。[snail_job_mysql.sql](snail_job_mysql.sql) 仅用于单独初始化调度库,执行主 SQL 后无需再执行它。 +主 SQL 已合并下表列出的基础更新,以及原始想法、技能绑定、剧本打磨字段、模型优先级和 PC 登录超时结构。新安装仍需执行下面“后续功能更新”中注明的补充脚本。[snail_job_mysql.sql](snail_job_mysql.sql) 仅用于单独初始化调度库,执行主 SQL 后无需再执行它。 全量初始化包含 `DROP TABLE` 和调度库的 `DROP DATABASE`,已有数据库升级应使用对应的增量脚本。 @@ -43,3 +43,18 @@ mysql -uroot -p < ruoyi-ai.sql | [2026-09-01-sys-url.sql](update/2026-09-01-sys-url.sql) | `sys_url` 表、5 个菜单权限和 2 条公开链接 | 后续增加更新脚本时,同步修改主 SQL 中对应的表结构或初始化数据,并更新本清单及主 SQL 文件头。 + +## 后续功能更新 + +以下脚本统一位于 `update/`。已有数据库按日期升级,保留原始输入、已批准媒体和提供商密钥;不要执行全量初始化。 + +| 脚本 | 内容与新安装要求 | +| --- | --- | +| [知识库检索节点](update/2026-09-11-knowledge-retrieval-node.sql) | 补充节点注册,新安装也需执行 | +| [短剧视觉资产](update/2026-09-30-short-drama-visual-asset.sql) | 补充异步视觉资产表,新安装也需执行 | +| [剧本打磨与 Seedance 2.5](update/2026-09-30-script-refinement-seedance-25.sql) | 打磨字段已并入全量结构;新安装执行以补充视频模型记录 | +| [技能绑定](update/2026-10-01-short-drama-skill-bindings.sql) | 项目审美与导演绑定,幂等;全量结构已包含 | +| [模型优先级](update/2026-10-02-chat-model-sort-order.sql) | 幂等增加优先级列,保留已自定义优先级;全量结构已包含 | +| [PC 登录空闲时间](update/2026-10-02-pc-login-idle-timeout.sql) | 仅调整原 30 分钟配置;全量默认已更新,已签发令牌需重新登录 | +| [文字模型默认优先级](update/2026-10-03-short-drama-flash-default.sql) | 先应用模型优先级迁移,再调整已有 Flash / Seed 模型排序,不新增模型或密钥 | +| [原始创作输入](update/2026-10-03-short-drama-original-idea.sql) | 幂等增加独立原文列,不用生成概要回填;全量结构已包含 | diff --git a/docs/script/sql/ruoyi-ai.sql b/docs/script/sql/ruoyi-ai.sql index b2d256f9..91bc16fc 100644 --- a/docs/script/sql/ruoyi-ai.sql +++ b/docs/script/sql/ruoyi-ai.sql @@ -95,6 +95,7 @@ DROP TABLE IF EXISTS `chat_model`; CREATE TABLE `chat_model` ( `id` bigint NOT NULL COMMENT '主键', `category` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模型分类', + `sort_order` int NOT NULL DEFAULT 100 COMMENT '默认模型优先级,升序', `model_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模型名称', `provider_code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模型供应商', `model_describe` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '模型描述', @@ -115,20 +116,20 @@ CREATE TABLE `chat_model` ( -- ---------------------------- -- Records of chat_model -- ---------------------------- -INSERT INTO `chat_model` VALUES (2000585866022060033, 'chat', 'deepseek-v4-flash', 'deepseek', 'DeepSeek V4 Flash', NULL, 'Y', 'https://api.deepseek.com', '', 103, 1, '2025-12-15 23:16:54', 1, '2026-08-30 00:00:00', '简单快速编程任务', 0); -INSERT INTO `chat_model` VALUES (2090000000000000001, 'chat', 'deepseek-v4-pro', 'deepseek', 'DeepSeek V4 Pro', NULL, 'Y', 'https://api.deepseek.com', '', 103, 1, '2026-08-30 00:00:00', 1, '2026-08-30 00:00:00', '复杂编程、规划与审查', 0); -INSERT INTO `chat_model` VALUES (2090000000000000002, 'chat', 'deepseek-v4-flash-vision-exp', 'deepseek', 'DeepSeek V4 Flash Vision (experimental)', NULL, 'Y', 'https://api.deepseek.com', '', 103, 1, '2026-08-30 00:00:00', 1, '2026-08-30 00:00:00', '带图编程任务的视觉证据提取', 0); -INSERT INTO `chat_model` VALUES (2007528268536287233, 'vector', 'embedding-3', 'zhipu', 'embedding-3', 2048, 'N', 'https://open.bigmodel.cn', 'sk_xx', 103, 1, '2026-01-04 03:03:32', 1, '2026-03-15 19:18:51', '向量模型', 0); -INSERT INTO `chat_model` VALUES (2045071617578237953, 'rerank', 'rerank', 'zhipu', 'rerank', NULL, 'N', 'https://open.bigmodel.cn', 'sk_xx', 103, 1, '2026-04-17 17:27:24', 1, '2026-04-20 15:21:48', '重排序模型', 0); -INSERT INTO `chat_model` VALUES (2000585866022060003, 'chat', 'deepseek-ai/deepseek-v4-flash', 'atlas', 'deepseek-v4-flash', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2025-12-15 23:16:54', 1, '2026-03-15 19:18:48', '对话模型', 0); -INSERT INTO `chat_model` VALUES (2050000000000000001, 'video', 'bytedance/seedance-2.0/text-to-video', 'atlas', 'Seedance 2.0 文生视频(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-17 18:39:20', 1, '2026-06-17 18:39:20', 'Atlas Cloud 视频模型 - Seedance 2.0', 0); -INSERT INTO `chat_model` VALUES (2060622000000000001, 'image', 'openai/gpt-image-2/text-to-image', 'atlas', 'GPT-IMAGE-2 文生图', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 14:45:54', 1, '2026-06-22 14:45:54', 'Atlas Cloud 图片模型 - 文生图,支持三视图、角色设定、场景图', 0); -INSERT INTO `chat_model` VALUES (2060622000000000002, 'image', 'openai/gpt-image-2/edit', 'atlas', 'GPT-IMAGE-2 图生图编辑', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 14:45:54', 1, '2026-06-22 14:45:54', 'Atlas Cloud 图片模型 - 图生图/编辑,支持基于参考图修改风格、纹理等', 0); -INSERT INTO `chat_model` VALUES (2060622000000000003, 'video', 'bytedance/seedance-2.0/image-to-video', 'atlas', 'Seedance 2.0 图生视频(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 17:24:07', 1, '2026-06-22 17:24:07', 'Atlas Cloud 视频模型 - Seedance 2.0 图生视频,接收参考图+提示词生成视频', 0); -INSERT INTO `chat_model` VALUES (2060622000000000004, 'video', 'bytedance/seedance-2.0/reference-to-video', 'atlas', 'Seedance 2.0 多参考图生视频(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 20:24:44', 1, '2026-06-22 20:24:44', 'Atlas Cloud 视频模型 - Seedance 2.0 多参考图生视频,接收多张参考图+带@imageN标记的提示词生成视频', 0); -INSERT INTO `chat_model` VALUES (2070700000000000002, 'chat', 'dify-chat', 'dify', 'Dify Chat App', NULL, 'Y', 'https://api.dify.ai/v1', '', 103, 1, '2026-07-14 11:03:44', 1, '2026-07-14 11:03:44', 'Dify 聊天应用;真实 App API Key 在后台模型管理中填写,model_name 可按应用名修改', 0); -INSERT INTO `chat_model` VALUES (2070700000000000004, 'chat', '替换为你的COZE_BOT_ID', 'coze', 'Coze Bot', NULL, 'Y', 'https://api.coze.cn', '替换为你的COZE_PAT', 103, 1, '2026-07-14 11:03:38', 1, '2026-07-14 11:03:38', 'Coze 聊天 Bot;model_name 为 Coze Bot ID,api_key 为 PAT 或 OAuth access token', 0); -INSERT INTO `chat_model` VALUES (2070700000000000010, 'audio', 'bytedance/seed-audio-1.0', 'atlas', 'Seed Audio 1.0 语音生成(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-07-16 18:00:00', 1, '2026-07-16 18:00:00', 'Atlas Cloud 语音模型 - 支持多角色对白配音,references 指定 speaker 音色,text 中用 @audioN 引用', 0); +INSERT INTO `chat_model` VALUES (2000585866022060033, 'chat', 100, 'deepseek-v4-flash', 'deepseek', 'DeepSeek V4 Flash', NULL, 'Y', 'https://api.deepseek.com', '', 103, 1, '2025-12-15 23:16:54', 1, '2026-08-30 00:00:00', '简单快速编程任务', 0); +INSERT INTO `chat_model` VALUES (2090000000000000001, 'chat', 100, 'deepseek-v4-pro', 'deepseek', 'DeepSeek V4 Pro', NULL, 'Y', 'https://api.deepseek.com', '', 103, 1, '2026-08-30 00:00:00', 1, '2026-08-30 00:00:00', '复杂编程、规划与审查', 0); +INSERT INTO `chat_model` VALUES (2090000000000000002, 'chat', 100, 'deepseek-v4-flash-vision-exp', 'deepseek', 'DeepSeek V4 Flash Vision (experimental)', NULL, 'Y', 'https://api.deepseek.com', '', 103, 1, '2026-08-30 00:00:00', 1, '2026-08-30 00:00:00', '带图编程任务的视觉证据提取', 0); +INSERT INTO `chat_model` VALUES (2007528268536287233, 'vector', 100, 'embedding-3', 'zhipu', 'embedding-3', 2048, 'N', 'https://open.bigmodel.cn', 'sk_xx', 103, 1, '2026-01-04 03:03:32', 1, '2026-03-15 19:18:51', '向量模型', 0); +INSERT INTO `chat_model` VALUES (2045071617578237953, 'rerank', 100, 'rerank', 'zhipu', 'rerank', NULL, 'N', 'https://open.bigmodel.cn', 'sk_xx', 103, 1, '2026-04-17 17:27:24', 1, '2026-04-20 15:21:48', '重排序模型', 0); +INSERT INTO `chat_model` VALUES (2000585866022060003, 'chat', 100, 'deepseek-ai/deepseek-v4-flash', 'atlas', 'deepseek-v4-flash', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2025-12-15 23:16:54', 1, '2026-03-15 19:18:48', '对话模型', 0); +INSERT INTO `chat_model` VALUES (2050000000000000001, 'video', 100, 'bytedance/seedance-2.0/text-to-video', 'atlas', 'Seedance 2.0 文生视频(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-17 18:39:20', 1, '2026-06-17 18:39:20', 'Atlas Cloud 视频模型 - Seedance 2.0', 0); +INSERT INTO `chat_model` VALUES (2060622000000000001, 'image', 100, 'openai/gpt-image-2/text-to-image', 'atlas', 'GPT-IMAGE-2 文生图', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 14:45:54', 1, '2026-06-22 14:45:54', 'Atlas Cloud 图片模型 - 文生图,支持三视图、角色设定、场景图', 0); +INSERT INTO `chat_model` VALUES (2060622000000000002, 'image', 100, 'openai/gpt-image-2/edit', 'atlas', 'GPT-IMAGE-2 图生图编辑', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 14:45:54', 1, '2026-06-22 14:45:54', 'Atlas Cloud 图片模型 - 图生图/编辑,支持基于参考图修改风格、纹理等', 0); +INSERT INTO `chat_model` VALUES (2060622000000000003, 'video', 100, 'bytedance/seedance-2.0/image-to-video', 'atlas', 'Seedance 2.0 图生视频(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 17:24:07', 1, '2026-06-22 17:24:07', 'Atlas Cloud 视频模型 - Seedance 2.0 图生视频,接收参考图+提示词生成视频', 0); +INSERT INTO `chat_model` VALUES (2060622000000000004, 'video', 100, 'bytedance/seedance-2.0/reference-to-video', 'atlas', 'Seedance 2.0 多参考图生视频(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-06-22 20:24:44', 1, '2026-06-22 20:24:44', 'Atlas Cloud 视频模型 - Seedance 2.0 多参考图生视频,接收多张参考图+带@imageN标记的提示词生成视频', 0); +INSERT INTO `chat_model` VALUES (2070700000000000002, 'chat', 100, 'dify-chat', 'dify', 'Dify Chat App', NULL, 'Y', 'https://api.dify.ai/v1', '', 103, 1, '2026-07-14 11:03:44', 1, '2026-07-14 11:03:44', 'Dify 聊天应用;真实 App API Key 在后台模型管理中填写,model_name 可按应用名修改', 0); +INSERT INTO `chat_model` VALUES (2070700000000000004, 'chat', 100, '替换为你的COZE_BOT_ID', 'coze', 'Coze Bot', NULL, 'Y', 'https://api.coze.cn', '替换为你的COZE_PAT', 103, 1, '2026-07-14 11:03:38', 1, '2026-07-14 11:03:38', 'Coze 聊天 Bot;model_name 为 Coze Bot ID,api_key 为 PAT 或 OAuth access token', 0); +INSERT INTO `chat_model` VALUES (2070700000000000010, 'audio', 100, 'bytedance/seed-audio-1.0', 'atlas', 'Seed Audio 1.0 语音生成(字节跳动)', NULL, 'Y', 'https://api.atlascloud.ai/v1', 'sk_xx', 103, 1, '2026-07-16 18:00:00', 1, '2026-07-16 18:00:00', 'Atlas Cloud 语音模型 - 支持多角色对白配音,references 指定 speaker 音色,text 中用 @audioN 引用', 0); -- ---------------------------- -- Table structure for chat_provider @@ -1490,6 +1491,9 @@ CREATE TABLE `short_drama_project` ( `user_id` bigint NOT NULL COMMENT '用户ID', `project_name` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '项目名称', `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '项目描述', + `original_idea` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '原始创作提示词', + `aesthetic_skill_name` varchar(64) NULL COMMENT '所选审美技能', + `director_skill_name` varchar(64) NULL COMMENT '所选导演技能', `status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'draft' COMMENT '状态:draft/active/archived', `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', `create_by` bigint NULL DEFAULT NULL COMMENT '创建者', @@ -1516,7 +1520,7 @@ CREATE TABLE `short_drama_project` ( -- ---------------------------- -- Records of short_drama_project -- ---------------------------- -INSERT INTO `short_drama_project` VALUES (2077007721974484992, 1, '短剧项目', '50岁失意中年小明意外穿越到1985年,利用未来知识创业,在时代洪流中改写命运,最终留下并影响一代人。', 'draft', -1, -1, '2026-07-14 20:30:05', -1, '2026-07-14 20:51:14', 0, 'realistic', NULL, 'done', NULL, 100, 'fade', 0.300, '9:16', 32.267, NULL, '2026-07-14 20:51:14'); +INSERT INTO `short_drama_project` (`id`, `user_id`, `project_name`, `description`, `status`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `art_style`, `composed_video_oss_id`, `compose_status`, `compose_job_id`, `compose_progress`, `compose_transition_type`, `compose_transition_duration_seconds`, `compose_aspect_ratio`, `composed_video_duration_seconds`, `compose_error_message`, `composed_at`) VALUES (2077007721974484992, 1, '短剧项目', '50岁失意中年小明意外穿越到1985年,利用未来知识创业,在时代洪流中改写命运,最终留下并影响一代人。', 'draft', -1, -1, '2026-07-14 20:30:05', -1, '2026-07-14 20:51:14', 0, 'realistic', NULL, 'done', NULL, 100, 'fade', 0.300, '9:16', 32.267, NULL, '2026-07-14 20:51:14'); -- ---------------------------- -- Table structure for short_drama_script @@ -1530,6 +1534,10 @@ CREATE TABLE `short_drama_script` ( `outline_text` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '大纲文本', `tone` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '风格/基调', `source_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'manual' COMMENT '来源:manual/ai', + `creation_mode` varchar(32) DEFAULT 'standard' COMMENT '剧本创作模式', + `source_materials` longtext COMMENT '创作资料', + `worldbuilding` longtext COMMENT '世界观设定', + `revision_notes` longtext COMMENT '打磨要求', `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', `create_by` bigint NULL DEFAULT NULL COMMENT '创建者', `create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', @@ -1544,7 +1552,7 @@ CREATE TABLE `short_drama_script` ( -- ---------------------------- -- Records of short_drama_script -- ---------------------------- -INSERT INTO `short_drama_script` VALUES (2077007722058371072, 2077007721974484992, '重启1985', '1. 内景 现代办公室 夜\n\n办公室昏暗,只有电脑屏幕亮着。50岁的小明穿着皱巴巴的衬衫,盯着屏幕上的辞退邮件。他拿起手机,看到妻子发来的离婚协议截图。手抖着放下手机,碰倒桌上的电水壶,水洒在插座上。火花闪过,小明倒在椅子上。\n\n2. 外景 小巷 日 1985年\n\n小明醒来,发现自己躺在地上,身穿80年代蓝色工装。他摸着脸——没有皱纹。旁边老式自行车铃响,一个年轻人喊:“小明,上班迟到了!”小明看着路口的二八自行车,墙上的标语“时间就是金钱”,愣住。他低头看手腕上的电子表:1985年6月。他深吸一口气,眼神从迷茫变成坚定:“再来一次。”\n\n3. 外景 街道办门口 日\n\n小明拿着一叠手写方案,站在街道办门口。门卫拦住他。小明:“同志,我有商业计划,能帮工厂接全国订单!”门卫:“胡闹!”推他出去。小明捡起散落的纸,抬头看到路对面一个穿中山装的中年男人(老陈)正在看他的方案。老陈:“你这上面写的‘电商’是啥?”小明眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”\n\n4. 内景 废弃仓库 日\n\n老陈和小明对面坐着。桌上摆着两盒烟。老陈:“我信你一次,但怎么弄?”小明在纸上画:“用电话线连电脑,搞个库存信息库。我写程序。”老陈:“钱呢?”小明:“我先拉商户,您出设备。”老陈犹豫后点头。小明看着仓库墙上的“工业学大庆”,低声:“这次一定要成。”\n\n5. 内景 工厂办公室 日\n\n办公桌上电话响起。老陈接起,脸色变白。挂断后对小明:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看账本:“我们走的是信息服务,不是倒卖。”老陈:“没人认可这个。”小明:“明天我来说。”\n\n6. 内景 小明的出租屋 夜\n\n团队成员(两个年轻人)拍桌:“不干了,风险太大!”老陈沉默。小明拿出一块黑板,画出时间轴:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头:“你哪来的这些?”小明:“梦里学的。”\n\n7. 外景 百货大楼门口 日\n\n工商人员和小明、老陈站在门口。小明拿出一份表格:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看,递给旁边一个戴眼镜的中年人(百货公司经理)。经理看了后低声:“这方法能帮我们清库存?”小明:“三天见效。”经理点头。工商人员收起表格:“先观察。”\n\n8. 内景 厂庆礼堂 夜\n\n工人坐满长凳。老陈上台:“感谢小明的信息平台,咱们工厂活了。”台下鼓掌。小明站在侧台,看着灯光下兴奋的面孔。他手摸到口袋里的现代身份证——早已发黄。他默默撕碎。主持人:“请小明同志讲话!”小明上前,拿着话筒停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。\n\n9. 外景 工厂大门 晨\n\n阳光照在新挂牌上:“华夏信息服务中心”。小明推着自行车出来,老陈追上:“下一步?”小明:“跑遍全国,建数据库。”老陈笑:“疯了。”小明跨上车:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟。', '1.现代办公室夜,小明被老板辞退,回家发现妻子要离婚,绝望中碰倒电水壶触电。2.1985年小巷外景清晨,小明醒来发现变成25岁身体,确认穿越,决定利用记忆创业。3.街道办外景日,小明推销‘互联网购物’概念,被当成疯子赶出,遇到工厂副厂长老陈愿意听。4.废弃仓库内景,小明说服老陈入股,用记忆设计简易电商系统,团队组建。5.工厂办公室内景,初获订单,却遭国营百货公司经理举报,面临查封危机。6.小作家中内景夜,团队内讧,老陈动摇,小明用未来管理经验分析市场,决定转型做信息中介。7.百货大楼外景日,小明带团队利用数据库帮商家库存匹配,化解危机,赢取首个大客户。8.厂庆礼堂内景夜,小明站在台上,回想现代生活,决定留下,带领工人转型。9.同场景,小明讲话,镜头拉远,工厂转型成功。', '现实与励志', 'llm', -1, -1, '2026-07-14 20:30:05', -1, '2026-07-14 20:30:05', 0); +INSERT INTO `short_drama_script` (`id`, `project_id`, `script_name`, `script_text`, `outline_text`, `tone`, `source_type`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`) VALUES (2077007722058371072, 2077007721974484992, '重启1985', '1. 内景 现代办公室 夜\n\n办公室昏暗,只有电脑屏幕亮着。50岁的小明穿着皱巴巴的衬衫,盯着屏幕上的辞退邮件。他拿起手机,看到妻子发来的离婚协议截图。手抖着放下手机,碰倒桌上的电水壶,水洒在插座上。火花闪过,小明倒在椅子上。\n\n2. 外景 小巷 日 1985年\n\n小明醒来,发现自己躺在地上,身穿80年代蓝色工装。他摸着脸——没有皱纹。旁边老式自行车铃响,一个年轻人喊:“小明,上班迟到了!”小明看着路口的二八自行车,墙上的标语“时间就是金钱”,愣住。他低头看手腕上的电子表:1985年6月。他深吸一口气,眼神从迷茫变成坚定:“再来一次。”\n\n3. 外景 街道办门口 日\n\n小明拿着一叠手写方案,站在街道办门口。门卫拦住他。小明:“同志,我有商业计划,能帮工厂接全国订单!”门卫:“胡闹!”推他出去。小明捡起散落的纸,抬头看到路对面一个穿中山装的中年男人(老陈)正在看他的方案。老陈:“你这上面写的‘电商’是啥?”小明眼睛一亮:“陈厂长,我能让您仓库里的积压货三天卖掉。”\n\n4. 内景 废弃仓库 日\n\n老陈和小明对面坐着。桌上摆着两盒烟。老陈:“我信你一次,但怎么弄?”小明在纸上画:“用电话线连电脑,搞个库存信息库。我写程序。”老陈:“钱呢?”小明:“我先拉商户,您出设备。”老陈犹豫后点头。小明看着仓库墙上的“工业学大庆”,低声:“这次一定要成。”\n\n5. 内景 工厂办公室 日\n\n办公桌上电话响起。老陈接起,脸色变白。挂断后对小明:“国营百货举报我们投机倒把,工商明天来查。”小明快速翻看账本:“我们走的是信息服务,不是倒卖。”老陈:“没人认可这个。”小明:“明天我来说。”\n\n6. 内景 小明的出租屋 夜\n\n团队成员(两个年轻人)拍桌:“不干了,风险太大!”老陈沉默。小明拿出一块黑板,画出时间轴:“1985年,电脑刚进中国,我们的优势是信息差。不做商品买卖,做平台——帮工厂和供销社匹配库存。”他指着黑板:“明天工商来,我就说这是‘技术咨询’。”老陈抬头:“你哪来的这些?”小明:“梦里学的。”\n\n7. 外景 百货大楼门口 日\n\n工商人员和小明、老陈站在门口。小明拿出一份表格:“这是通过我们信息服务成交的订单,全部有记录,不碰资金,只收咨询费。”工商人员翻看,递给旁边一个戴眼镜的中年人(百货公司经理)。经理看了后低声:“这方法能帮我们清库存?”小明:“三天见效。”经理点头。工商人员收起表格:“先观察。”\n\n8. 内景 厂庆礼堂 夜\n\n工人坐满长凳。老陈上台:“感谢小明的信息平台,咱们工厂活了。”台下鼓掌。小明站在侧台,看着灯光下兴奋的面孔。他手摸到口袋里的现代身份证——早已发黄。他默默撕碎。主持人:“请小明同志讲话!”小明上前,拿着话筒停顿三秒:“各位,未来三十年,互联网会改变一切。今天,我们从这里开始。”台下掌声雷动。\n\n9. 外景 工厂大门 晨\n\n阳光照在新挂牌上:“华夏信息服务中心”。小明推着自行车出来,老陈追上:“下一步?”小明:“跑遍全国,建数据库。”老陈笑:“疯了。”小明跨上车:“1985年,疯的人才能赢。”他蹬车远去,街道两旁的工厂烟囱开始冒烟。', '1.现代办公室夜,小明被老板辞退,回家发现妻子要离婚,绝望中碰倒电水壶触电。2.1985年小巷外景清晨,小明醒来发现变成25岁身体,确认穿越,决定利用记忆创业。3.街道办外景日,小明推销‘互联网购物’概念,被当成疯子赶出,遇到工厂副厂长老陈愿意听。4.废弃仓库内景,小明说服老陈入股,用记忆设计简易电商系统,团队组建。5.工厂办公室内景,初获订单,却遭国营百货公司经理举报,面临查封危机。6.小作家中内景夜,团队内讧,老陈动摇,小明用未来管理经验分析市场,决定转型做信息中介。7.百货大楼外景日,小明带团队利用数据库帮商家库存匹配,化解危机,赢取首个大客户。8.厂庆礼堂内景夜,小明站在台上,回想现代生活,决定留下,带领工人转型。9.同场景,小明讲话,镜头拉远,工厂转型成功。', '现实与励志', 'llm', -1, -1, '2026-07-14 20:30:05', -1, '2026-07-14 20:30:05', 0); -- ---------------------------- -- Table structure for short_drama_storyboard @@ -2253,7 +2261,7 @@ CREATE TABLE `sys_client` ( `client_secret` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '客户端秘钥', `grant_type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '授权类型', `device_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '设备类型', - `active_timeout` int NULL DEFAULT 1800 COMMENT 'token活跃超时时间', + `active_timeout` int NULL DEFAULT 43200 COMMENT 'token活跃超时时间', `timeout` int NULL DEFAULT 604800 COMMENT 'token固定超时', `status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '状态(0正常 1停用)', `del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)', @@ -2268,9 +2276,9 @@ CREATE TABLE `sys_client` ( -- ---------------------------- -- Records of sys_client -- ---------------------------- -INSERT INTO `sys_client` VALUES (1, 'e5cd7e4891bf95d1d19206ce24a7b32e', 'pc', 'pc123', 'password,social', 'pc', 1800, 604800, '0', '0', 103, 1, '2026-02-03 05:14:53', 1, '2026-02-03 05:14:53'); +INSERT INTO `sys_client` VALUES (1, 'e5cd7e4891bf95d1d19206ce24a7b32e', 'pc', 'pc123', 'password,social', 'pc', 43200, 604800, '0', '0', 103, 1, '2026-02-03 05:14:53', 1, '2026-02-03 05:14:53'); INSERT INTO `sys_client` VALUES (2, '428a8310cd442757ae699df5d894f051', 'app', 'app123', 'password,sms,social', 'android', 1800, 604800, '0', '0', 103, 1, '2026-02-03 05:14:53', 1, '2026-02-03 05:14:53'); -INSERT INTO `sys_client` VALUES (2033738530356912129, '0d4c873ff6146ecd7f38e2e45526ab1b', 'web', 'web123', 'sms,email,password', 'pc', 1800, 604800, '0', '0', 103, 1, '2026-03-17 10:53:45', 1, '2026-03-17 10:59:16'); +INSERT INTO `sys_client` VALUES (2033738530356912129, '0d4c873ff6146ecd7f38e2e45526ab1b', 'web', 'web123', 'sms,email,password', 'pc', 43200, 604800, '0', '0', 103, 1, '2026-03-17 10:53:45', 1, '2026-03-17 10:59:16'); -- ---------------------------- -- Table structure for sys_config diff --git a/docs/script/sql/update/2026-09-30-script-refinement-seedance-25.sql b/docs/script/sql/update/2026-09-30-script-refinement-seedance-25.sql new file mode 100644 index 00000000..88e5dbf8 --- /dev/null +++ b/docs/script/sql/update/2026-09-30-script-refinement-seedance-25.sql @@ -0,0 +1,19 @@ +-- Run after the base ruoyi-ai.sql. Preserves existing scripts and provider keys. +DROP PROCEDURE IF EXISTS migrate_drama_preparation; +DELIMITER $$ +CREATE PROCEDURE migrate_drama_preparation() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='short_drama_script' AND column_name='creation_mode') THEN + ALTER TABLE short_drama_script ADD creation_mode varchar(32) DEFAULT 'standard', ADD source_materials longtext, ADD worldbuilding longtext, ADD revision_notes longtext; + END IF; +END$$ +DELIMITER ; +CALL migrate_drama_preparation(); +DROP PROCEDURE migrate_drama_preparation; + +INSERT INTO chat_model (id,category,model_name,provider_code,model_describe,model_show,api_host,api_key,create_dept,create_by,create_time,update_by,update_time,remark,tenant_id) +SELECT 2106002500000000001,'video','bytedance/seedance-2.5/reference-to-video','atlas','Seedance 2.5 多模态参考生视频(字节跳动)','Y', + COALESCE((SELECT api_host FROM (SELECT api_host FROM chat_model WHERE provider_code='atlas' AND category='video' AND tenant_id=0 ORDER BY id DESC LIMIT 1) h),'https://api.atlascloud.ai/api/v1'), + COALESCE((SELECT api_key FROM (SELECT api_key FROM chat_model WHERE provider_code='atlas' AND category='video' AND tenant_id=0 ORDER BY id DESC LIMIT 1) k),''), + 103,1,NOW(),1,NOW(),'Atlas Seedance 2.5 reference-to-video; 4-30s, up to 30 images and 10 audio references',0 +WHERE NOT EXISTS (SELECT 1 FROM chat_model WHERE model_name='bytedance/seedance-2.5/reference-to-video' AND tenant_id=0); diff --git a/docs/script/sql/update/2026-10-01-short-drama-skill-bindings.sql b/docs/script/sql/update/2026-10-01-short-drama-skill-bindings.sql new file mode 100644 index 00000000..c94315ae --- /dev/null +++ b/docs/script/sql/update/2026-10-01-short-drama-skill-bindings.sql @@ -0,0 +1,13 @@ +-- MySQL idempotent migration; run in the configured application database. +SET @skill_aesthetic_sql = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE short_drama_project ADD COLUMN aesthetic_skill_name VARCHAR(64) NULL COMMENT ''Selected aesthetic skill catalog name''', + 'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'short_drama_project' AND column_name = 'aesthetic_skill_name'); +PREPARE skill_aesthetic_stmt FROM @skill_aesthetic_sql; +EXECUTE skill_aesthetic_stmt; +DEALLOCATE PREPARE skill_aesthetic_stmt; +SET @skill_director_sql = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE short_drama_project ADD COLUMN director_skill_name VARCHAR(64) NULL COMMENT ''Selected director skill catalog name''', + 'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'short_drama_project' AND column_name = 'director_skill_name'); +PREPARE skill_director_stmt FROM @skill_director_sql; +EXECUTE skill_director_stmt; +DEALLOCATE PREPARE skill_director_stmt; diff --git a/docs/script/sql/update/2026-10-02-chat-model-sort-order.sql b/docs/script/sql/update/2026-10-02-chat-model-sort-order.sql new file mode 100644 index 00000000..d2e28ff3 --- /dev/null +++ b/docs/script/sql/update/2026-10-02-chat-model-sort-order.sql @@ -0,0 +1,8 @@ +-- Idempotent schema upgrade; preserve credentials and explicitly customized priorities. +SET @sort_order_sql = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE chat_model ADD COLUMN sort_order INT NOT NULL DEFAULT 100 COMMENT ''默认模型优先级,升序'' AFTER category', + 'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'chat_model' AND column_name = 'sort_order'); +PREPARE sort_order_stmt FROM @sort_order_sql; +EXECUTE sort_order_stmt; +DEALLOCATE PREPARE sort_order_stmt; +UPDATE chat_model SET sort_order = 0 WHERE provider_code = 'atlas' AND sort_order = 100 AND model_name IN ('bytedance/doubao-seed-2.1-pro-260628', 'openai/gpt-image-2.5-flare/text-to-image', 'bytedance/seedance-2.0-mini/reference-to-video'); diff --git a/docs/script/sql/update/2026-10-02-pc-login-idle-timeout.sql b/docs/script/sql/update/2026-10-02-pc-login-idle-timeout.sql new file mode 100644 index 00000000..e1729d7f --- /dev/null +++ b/docs/script/sql/update/2026-10-02-pc-login-idle-timeout.sql @@ -0,0 +1,7 @@ +-- PC 管理后台和短剧工作台:无操作 12 小时后登录失效(单位:秒)。 +-- 仅迁移原 30 分钟策略,保留已自定义的客户端设置。 +UPDATE sys_client +SET active_timeout = 43200 +WHERE device_type = 'pc' AND active_timeout = 1800; +-- 已部署环境通过 /system/client 更新相同字段以同步清理客户端缓存。 +-- 已签发的令牌需重新登录后使用新策略。 diff --git a/docs/script/sql/update/2026-10-03-short-drama-flash-default.sql b/docs/script/sql/update/2026-10-03-short-drama-flash-default.sql new file mode 100644 index 00000000..c5f30494 --- /dev/null +++ b/docs/script/sql/update/2026-10-03-short-drama-flash-default.sql @@ -0,0 +1,6 @@ +-- Existing Atlas keys/hosts remain intact. Default priority is maintained through model management. +-- Apply after 2026-10-02-chat-model-sort-order.sql. +UPDATE chat_model SET sort_order = 10 +WHERE provider_code = 'atlas' AND category = 'chat' AND model_name = 'bytedance/doubao-seed-2.1-pro-260628' AND sort_order = 0; +UPDATE chat_model SET sort_order = 0 +WHERE provider_code = 'atlas' AND category = 'chat' AND model_name = 'deepseek-ai/deepseek-v4.1-flash'; diff --git a/docs/script/sql/update/2026-10-03-short-drama-original-idea.sql b/docs/script/sql/update/2026-10-03-short-drama-original-idea.sql new file mode 100644 index 00000000..e40ab52b --- /dev/null +++ b/docs/script/sql/update/2026-10-03-short-drama-original-idea.sql @@ -0,0 +1,8 @@ +-- Store the exact creation input separately from generated project descriptions. +-- Existing rows stay NULL unless their original request can be recovered. +SET @original_idea_sql = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE short_drama_project ADD COLUMN original_idea LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''Original story creation input'' AFTER description', + 'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'short_drama_project' AND column_name = 'original_idea'); +PREPARE original_idea_stmt FROM @original_idea_sql; +EXECUTE original_idea_stmt; +DEALLOCATE PREPARE original_idea_stmt; diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index df3f9731..8251c72d 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -415,4 +415,4 @@ short-drama: max-redirects: ${SHORT_DRAMA_DOWNLOAD_MAX_REDIRECTS:5} # 生产环境建议配置为视频供应商或 OSS/CDN 域名,多个值用逗号分隔。 allowed-hosts: ${SHORT_DRAMA_DOWNLOAD_ALLOWED_HOSTS:} - fake-ip-allowed-hosts: ${SHORT_DRAMA_DOWNLOAD_FAKE_IP_ALLOWED_HOSTS:atlas-media.oss-us-west-1.aliyuncs.com} + fake-ip-allowed-hosts: ${SHORT_DRAMA_DOWNLOAD_FAKE_IP_ALLOWED_HOSTS:atlas-media.oss-us-west-1.aliyuncs.com,ark-acg-ap-southeast-1.tos-ap-southeast-1.volces.com} diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelBo.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelBo.java index 8ac4579f..ebac9149 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelBo.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelBo.java @@ -35,6 +35,9 @@ public class ChatModelBo extends BaseEntity { @NotBlank(message = "模型分类不能为空", groups = { AddGroup.class, EditGroup.class }) private String category; + /** 默认模型优先级,数值越小越优先;同值按ID排序。 */ + private Integer sortOrder; + /** * 模型名称 */ diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelSelectQuery.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelSelectQuery.java index 95f92004..45d0d6fd 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelSelectQuery.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/bo/chat/ChatModelSelectQuery.java @@ -8,4 +8,7 @@ public class ChatModelSelectQuery { /** Optional model category; defaults to chat at the controller boundary. */ private String category; + + /** Optional exact provider filter. */ + private String providerCode; } diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/vo/chat/ChatModelSelectVo.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/vo/chat/ChatModelSelectVo.java index 3f28c250..fcfd31b7 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/vo/chat/ChatModelSelectVo.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/vo/chat/ChatModelSelectVo.java @@ -29,9 +29,14 @@ public class ChatModelSelectVo implements Serializable { /** Provider identifier used for display. */ private String providerCode; + private String category; + private Integer sortOrder; + public static ChatModelSelectVo from(ChatModelVo source) { ChatModelSelectVo target = new ChatModelSelectVo(); target.setId(source.getId()); + target.setCategory(source.getCategory()); + target.setSortOrder(source.getSortOrder()); target.setModelName(source.getModelName()); target.setModelDescribe(source.getModelDescribe()); target.setProviderCode(source.getProviderCode()); diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/vo/chat/ChatModelVo.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/vo/chat/ChatModelVo.java index d7d0c183..1062e944 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/vo/chat/ChatModelVo.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/domain/vo/chat/ChatModelVo.java @@ -39,6 +39,9 @@ public class ChatModelVo implements Serializable { @ExcelProperty(value = "模型分类") private String category; + /** 默认模型优先级,数值越小越优先;同值按ID排序。 */ + private Integer sortOrder; + /** * 模型名称 */ diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/chat/ChatModel.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/chat/ChatModel.java index 2a0ec2c1..bd1427a4 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/chat/ChatModel.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/chat/ChatModel.java @@ -34,6 +34,9 @@ public class ChatModel extends TenantEntity { */ private String category; + /** 默认模型优先级,数值越小越优先;同值按ID排序。 */ + private Integer sortOrder; + /** * 模型名称 */ diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/video/VideoContext.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/video/VideoContext.java index c602d4b7..c79b2392 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/video/VideoContext.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/video/VideoContext.java @@ -25,6 +25,9 @@ public class VideoContext { private String quality; + /** Provider output resolution; distinct from aspect ratio and quality. */ + private String resolution; + /** 参考图 URL(图生视频模式,单图) */ private String imageUrl; diff --git a/ruoyi-common/ruoyi-common-satoken/src/main/java/org/ruoyi/common/satoken/handler/SaTokenExceptionHandler.java b/ruoyi-common/ruoyi-common-satoken/src/main/java/org/ruoyi/common/satoken/handler/SaTokenExceptionHandler.java index f746f3c4..a1e8a8dc 100644 --- a/ruoyi-common/ruoyi-common-satoken/src/main/java/org/ruoyi/common/satoken/handler/SaTokenExceptionHandler.java +++ b/ruoyi-common/ruoyi-common-satoken/src/main/java/org/ruoyi/common/satoken/handler/SaTokenExceptionHandler.java @@ -50,9 +50,9 @@ public class SaTokenExceptionHandler { */ @ExceptionHandler(NotLoginException.class) public R handleNotLoginException(NotLoginException e, HttpServletRequest request) { - log.error("authorization_failed category=NOT_LOGIN method={} path={} exceptionType={}", + log.debug("authorization_failed category=NOT_LOGIN method={} path={} exceptionType={}", request.getMethod(), safePath(request), e.getClass().getName()); - return R.fail(HttpStatus.HTTP_UNAUTHORIZED, "认证失败,无法访问系统资源"); + return R.fail(HttpStatus.HTTP_UNAUTHORIZED, "请先登录"); } private static String safePath(HttpServletRequest request) { diff --git a/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/core/SseEmitterManager.java b/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/core/SseEmitterManager.java index 61de9d61..133c2f46 100644 --- a/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/core/SseEmitterManager.java +++ b/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/core/SseEmitterManager.java @@ -111,25 +111,36 @@ public class SseEmitterManager { * @param eventDto SSE事件对象 */ public void sendEvent(String sessionId, SseEventDto eventDto) { + if (!sendLocalEvent(sessionId, eventDto)) { + log.warn("sse_delivery routeType=SESSION status=SKIPPED reason=NO_ACTIVE_CONNECTION activeSessionCount={}", + SESSION_EMITTERS.size()); + } + } + + /** Deliver locally in order; a terminal event is flushed before its connection is removed. */ + public boolean sendLocalEvent(String sessionId, SseEventDto eventDto) { if (sessionId == null || eventDto == null) { - return; + return false; } SseEmitter emitter = SESSION_EMITTERS.get(sessionId); if (emitter == null) { - log.warn("sse_delivery routeType=SESSION status=SKIPPED reason=NO_ACTIVE_CONNECTION activeSessionCount={}", - SESSION_EMITTERS.size()); - return; + return false; } try { log.debug("sse_delivery routeType=SESSION status=SENDING eventType={} payloadChars={}", safeEventType(eventDto), eventPayloadLength(eventDto)); - emitter.send(SseEmitter.event() - .name(eventDto.getEvent()) - .data(JSONUtil.toJsonStr(eventDto))); + synchronized (emitter) { + if (SESSION_EMITTERS.get(sessionId) != emitter) return false; + emitter.send(SseEmitter.event() + .name(eventDto.getEvent()) + .data(JSONUtil.toJsonStr(eventDto))); + if (Boolean.TRUE.equals(eventDto.getDone())) removeSessionEmitter(sessionId, emitter); + } } catch (Exception e) { log.error("sse_delivery routeType=SESSION status=FAILED errorType={}", errorType(e)); removeSessionEmitter(sessionId, emitter); } + return true; } private void removeSessionEmitter(String sessionId, SseEmitter emitter) { diff --git a/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/utils/SseMessageUtils.java b/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/utils/SseMessageUtils.java index 59483c45..1bc3b46f 100644 --- a/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/utils/SseMessageUtils.java +++ b/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/utils/SseMessageUtils.java @@ -126,6 +126,8 @@ public class SseMessageUtils { if (!isEnable() || sessionId == null) { return; } + // The producer normally owns this emitter. Do not race a Redis delivery against disconnect. + if (MANAGER.sendLocalEvent(sessionId, eventDto)) return; SseMessageDto dto = new SseMessageDto(); dto.setSessionId(sessionId); dto.setEventDto(eventDto); @@ -210,6 +212,13 @@ public class SseMessageUtils { sendEvent(sessionId, SseEventDto.error(SAFE_ERROR_MESSAGE)); } + /** Final error only: unlike recoverable error events, this flushes and closes the stream. */ + public static void sendErrorAndComplete(String sessionId, String error) { + SseEventDto terminal = SseEventDto.error(SAFE_ERROR_MESSAGE); + terminal.setDone(true); + sendEvent(sessionId, terminal); + } + /** * 是否开启 */ diff --git a/ruoyi-common/ruoyi-common-sse/src/test/java/org/ruoyi/common/sse/core/SseTerminalDeliveryTest.java b/ruoyi-common/ruoyi-common-sse/src/test/java/org/ruoyi/common/sse/core/SseTerminalDeliveryTest.java new file mode 100644 index 00000000..79209932 --- /dev/null +++ b/ruoyi-common/ruoyi-common-sse/src/test/java/org/ruoyi/common/sse/core/SseTerminalDeliveryTest.java @@ -0,0 +1,51 @@ +package org.ruoyi.common.sse.core; + +import cn.hutool.extra.spring.SpringUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.ruoyi.common.sse.dto.SseEventDto; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@Tag("dev") +class SseTerminalDeliveryTest { + private SseEmitterManager manager; + private Map emitters; + @BeforeEach @SuppressWarnings("unchecked") void setup() { + var beans = new DefaultListableBeanFactory(); + beans.registerSingleton("scheduler", mock(ScheduledExecutorService.class)); + new SpringUtil().postProcessBeanFactory(beans); + manager = new SseEmitterManager(); + emitters = (Map) ReflectionTestUtils.getField(SseEmitterManager.class, "SESSION_EMITTERS"); + emitters.clear(); + } + + @Test void doneIsSentBeforeRemovalAndCompletion() throws Exception { assertTerminal(SseEventDto.done(), false); } + @Test void redisTerminalErrorIsSentBeforeRemovalAndCompletion() throws Exception { + var error = SseEventDto.error("safe failure"); error.setDone(true); assertTerminal(error, true); + } + + private void assertTerminal(SseEventDto terminal, boolean viaRedisListener) throws Exception { + var emitter = mock(SseEmitter.class); emitters.put("s", emitter); + doAnswer(call -> { assertSame(emitter, emitters.get("s")); return null; }).when(emitter).send(any(SseEmitter.SseEventBuilder.class)); + doAnswer(call -> { assertFalse(emitters.containsKey("s")); return null; }).when(emitter).complete(); + if (viaRedisListener) manager.sendEvent("s", terminal); else assertTrue(manager.sendLocalEvent("s", terminal)); + var ordered = inOrder(emitter); ordered.verify(emitter).send(any(SseEmitter.SseEventBuilder.class)); ordered.verify(emitter).complete(); + assertFalse(manager.sendLocalEvent("s", terminal)); + } + + @Test void normalContentAndRecoverableErrorKeepTheConnectionOpen() throws Exception { + var emitter = mock(SseEmitter.class); emitters.put("s", emitter); + assertTrue(manager.sendLocalEvent("s", SseEventDto.content("正文"))); + assertTrue(manager.sendLocalEvent("s", SseEventDto.error("recoverable"))); + assertSame(emitter, emitters.get("s")); verify(emitter, never()).complete(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ShortDramaScriptAgent.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ShortDramaScriptAgent.java deleted file mode 100644 index 7601feeb..00000000 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/ShortDramaScriptAgent.java +++ /dev/null @@ -1,51 +0,0 @@ -package org.ruoyi.agent; - -import dev.langchain4j.service.SystemMessage; -import dev.langchain4j.service.UserMessage; -import dev.langchain4j.service.V; -import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptResult; - -/** - * 短剧剧本打磨 Agent —— 使用 langchain4j AiServices 结构化输出 - *

- * 框架自动生成 JSON Schema 并强制 LLM 返回符合结构的数据,无需手工 parse。 - * - * @author ageerle - */ -public interface ShortDramaScriptAgent { - - @SystemMessage(""" - 你是顶级短剧编剧和创意总监。请根据用户的一个创意想法,创作完整的短剧剧本。 - - 【核心原则 - 最高优先级】 - 1. 剧本必须完整、有张力、有画面感 - 2. 角色要有鲜明性格,不是工具人 - 3. 情节紧凑,每句台词都推动剧情 - 4. 场景描写具体,让分镜师能直接画出画面 - 5. 对话自然有力,避免废话 - - 【剧本格式】 - 使用标准剧本格式,包含以下元素: - 1. 场景头(Scene Heading):内景/外景+地点+时间,如"内景 客厅 清晨" - 2. 场景描述(Scene Description):简洁描述场景环境、布局、关键道具 - 3. 动作描述(Action):描述角色的动作、表情、行为,连续段落形式 - 4. 对话(Dialogue):角色名: 台词内容 - 5. 画外音(Voiceover):旁白、独白、回忆中的声音 - - 【剧本长度要求】 - - scriptText:1000-3000字,含完整的开场、冲突发展、高潮、结尾 - - outlineText:400-800字,概述完整故事线 - - 【角色塑造要求】 - - 每个角色要有明确的性格标签(如:霸道总裁、温柔女医、腹黑谋士) - - 角色之间要有清晰的关系和冲突 - - 对话要符合角色性格 - - 【情节要求】 - - 必须有清晰的冲突和反转 - - 情绪节奏要有起伏(紧张→舒缓→爆发) - - 结尾要有记忆点(反转/留白/情感升华) - """) - @UserMessage("用户期望项目名:{{projectName}}\n用户创意:{{idea}}") - ShortDramaScriptResult polish(@V("projectName") String projectName, @V("idea") String idea); -} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/constant/ShortDramaImageConstants.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/constant/ShortDramaImageConstants.java index 619b7bef..b49fa695 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/constant/ShortDramaImageConstants.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/constant/ShortDramaImageConstants.java @@ -44,9 +44,10 @@ public final class ShortDramaImageConstants { /** 项目视觉风格 → 生图 prompt 后缀映射,确保同项目所有图片风格一致 */ public static final java.util.Map ART_STYLE_PROMPTS = java.util.Map.of( "american-comic", "美式漫画风格,粗线条,高饱和度色彩,强烈光影对比", + "chinese-3d", "国风电影级三维动画,Chinese donghua cinematic 3D animation,统一的角色三维建模和辨识度,东方人物面部结构与自然身体比例,PBR衣料、木石金属材质和细腻服饰纹理,立体体积光与可解释的环境灯光,克制的次表面散射,自然表情和电影镜头景深,国风建筑与服饰遵循当前故事年代,色彩清透且光色统一;不使用真人摄影实拍、2D赛璐璐平涂、黑色漫画线稿、塑料玩偶或油亮蜡像肤质", "chinese-comic", "现代国漫动画风格,Chinese donghua 2D comic style,赛璐璐平涂上色,干净锐利的黑色线稿,平面化光影无真实景深,动漫人物比例(略放大双眼、修长身形),皮肤平滑无毛孔无写实肤质,国风服饰剪裁与材质细节清晰,色彩饱满通透,画面精致干净;禁止真人写实、摄影实拍、3D渲染、CGI、厚涂油画、写实皮肤纹理、景深虚化", "japanese-anime", "现代日系动漫风格,赛璐璐上色,清晰干净的线条,视觉小说CG感,高质量2D风格", - "realistic", "真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感" + "realistic", "真人影视写实,自然人物比例与未磨皮肤质,真实衣料与环境材质,可解释的光源和丰富中间调,色彩与维护程度服从故事,不使用美颜滤镜、塑料CG或电商棚拍质感" ); /** 默认视觉风格 */ diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java index 685fd4b3..199c1013 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/chat/ChatModelController.java @@ -67,6 +67,7 @@ public class ChatModelController extends BaseController { } else { internalQuery.setCategory(query.getCategory()); } + if (query != null) internalQuery.setProviderCode(query.getProviderCode()); return R.ok(chatModelService.queryAvailableList(internalQuery).stream() .map(ChatModelSelectVo::from) .toList()); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaCharacterVoiceController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaCharacterVoiceController.java new file mode 100644 index 00000000..bb0dd262 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaCharacterVoiceController.java @@ -0,0 +1,41 @@ +package org.ruoyi.controller.shortdrama; + +import lombok.RequiredArgsConstructor; +import org.ruoyi.common.core.domain.R; +import org.ruoyi.common.satoken.utils.LoginHelper; +import org.ruoyi.service.shortdrama.impl.ShortDramaCharacterVoiceService; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.nio.file.Files; +import java.util.Map; + +@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}") +public class ShortDramaCharacterVoiceController { + private final ShortDramaCharacterVoiceService service; + @GetMapping("/characters/{characterId}/voice") + public R get(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId) throws Exception {return R.ok(service.get(projectId,characterId,LoginHelper.getUserId()));} + @PutMapping("/characters/{characterId}/voice") + public R settings(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@RequestBody ShortDramaCharacterVoiceService.Settings request) throws Exception {return R.ok(service.settings(projectId,characterId,LoginHelper.getUserId(),request));} + @PostMapping("/characters/{characterId}/voice/generate") + public R generate(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@RequestBody ShortDramaCharacterVoiceService.Generate request) throws Exception {return R.ok(service.generate(projectId,characterId,LoginHelper.getUserId(),request));} + @GetMapping("/characters/{characterId}/voice/jobs/{id}") + public R poll(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@PathVariable("id") String id) throws Exception {return R.ok(service.poll(projectId,characterId,LoginHelper.getUserId(),id));} + @PostMapping("/characters/{characterId}/voice/upload") + public R upload(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@RequestPart("file") MultipartFile file) throws Exception {return R.ok(service.upload(projectId,characterId,LoginHelper.getUserId(),file));} + @PostMapping("/characters/{characterId}/voice/select") + public R select(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@RequestBody ShortDramaCharacterVoiceService.Selection request) throws Exception {return R.ok(service.select(projectId,characterId,LoginHelper.getUserId(),request));} + @GetMapping("/characters/{characterId}/voice/samples/{id}") + public ResponseEntity audio(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@PathVariable("id") String id) throws Exception { + return ResponseEntity.ok().contentType(MediaType.valueOf("audio/mpeg")).cacheControl(CacheControl.noStore()).body(Files.readAllBytes(service.audio(projectId,characterId,LoginHelper.getUserId(),id))); + } + @GetMapping("/voices/storyboards/{storyboardId}") + public R preview(@PathVariable("projectId") Long projectId,@PathVariable("storyboardId") Long storyboardId,@RequestParam(value="model",required=false) String model) throws Exception { + var plan=service.preview(projectId,storyboardId,LoginHelper.getUserId(),model); + return R.ok(Map.of("bindings",plan.bindings(),"direction",plan.direction(),"issues",plan.issues(),"explicit",plan.explicit(),"references",plan.fingerprints(),"speakerIds",plan.speakerIds())); + } + @PutMapping("/voices/storyboards/{storyboardId}") + public R speakers(@PathVariable("projectId") Long projectId,@PathVariable("storyboardId") Long storyboardId,@RequestBody ShortDramaCharacterVoiceService.SpeakersRequest request) throws Exception { + return R.ok(Map.of("continuityJson",service.speakers(projectId,storyboardId,LoginHelper.getUserId(),request).getContinuityJson())); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaController.java index 8e6e2a65..724b1667 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaController.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaController.java @@ -4,6 +4,8 @@ import cn.dev33.satoken.annotation.SaCheckPermission; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Max; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; @@ -18,8 +20,10 @@ import org.ruoyi.domain.bo.shortdrama.ShortDramaAudioBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaLocationBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptRevisionBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaStoryboardBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaIdeaBo; +import org.ruoyi.domain.bo.shortdrama.ShortDramaImageRevisionBo; import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterAppearanceVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaComposeVideoVo; @@ -31,8 +35,10 @@ import org.ruoyi.domain.vo.shortdrama.ShortDramaScriptVo; import org.ruoyi.domain.vo.shortdrama.ShortDramaStoryboardVo; import org.ruoyi.service.shortdrama.IShortDramaService; import org.ruoyi.service.shortdrama.IShortDramaVideoComposeService; +import org.ruoyi.service.shortdrama.impl.ShortDramaTiming; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -57,6 +63,11 @@ import java.util.List; @RequestMapping("/short-drama") public class ShortDramaController { + @ExceptionHandler(ShortDramaTiming.ReviewException.class) + public R handleTimingReview(ShortDramaTiming.ReviewException error) { + return R.fail(error.getMessage()); + } + private final IShortDramaService shortDramaService; private final IShortDramaVideoComposeService videoComposeService; @@ -131,10 +142,30 @@ public class ShortDramaController { return R.ok(shortDramaService.saveStoryboard(bo, LoginHelper.getUserId())); } + @PostMapping("/storyboard/add") + public R addStoryboard(@RequestParam Long projectId, @RequestParam Long scriptId, + @RequestParam(required = false) Long afterId) { + return R.ok(shortDramaService.addStoryboard(projectId, scriptId, afterId, LoginHelper.getUserId())); + } + + @DeleteMapping("/storyboard/{storyboardId}") + public R deleteStoryboard(@PathVariable Long storyboardId) { + shortDramaService.deleteStoryboard(storyboardId, LoginHelper.getUserId()); + return R.ok(); + } + @PostMapping("/storyboard/{storyboardId}/generate-video") public R generateVideo(@NotNull @PathVariable Long storyboardId, - @NotBlank @RequestParam String model) { - return R.ok(shortDramaService.generateVideo(storyboardId, model, LoginHelper.getUserId())); + @NotBlank @RequestParam String model, + @RequestParam(required = false) String requestId, + @RequestParam(defaultValue = "false") boolean regenerate) { + return R.ok(shortDramaService.generateVideo(storyboardId, model, LoginHelper.getUserId(), requestId, regenerate)); + } + + @GetMapping("/storyboard/{storyboardId}/video-submissions/{requestId}") + public R videoSubmission( + @NotNull @PathVariable Long storyboardId, @PathVariable String requestId) { + return R.ok(shortDramaService.videoSubmission(storyboardId, requestId, LoginHelper.getUserId())); } @GetMapping("/storyboard/{storyboardId}/video-result") @@ -145,8 +176,10 @@ public class ShortDramaController { @PostMapping("/{projectId}/generate-all-videos") public R> generateAllVideos(@NotNull @PathVariable Long projectId, - @NotBlank @RequestParam String model) { - return R.ok(shortDramaService.generateAllVideos(projectId, model, LoginHelper.getUserId())); + @NotBlank @RequestParam String model, + @RequestParam(required = false) Integer sceneStart, + @RequestParam(required = false) Integer sceneCount) { + return R.ok(shortDramaService.generateAllVideos(projectId, model, LoginHelper.getUserId(), sceneStart, sceneCount)); } @PostMapping("/{projectId}/compose-video") @@ -180,10 +213,11 @@ public class ShortDramaController { // ==================== 阶段式流水线端点 ==================== - /** Phase 1: 剧本打磨 */ + /** 按修改意见重新生成固定格式剧本 */ @PostMapping("/{projectId}/polish-script") - public R polishScript(@NotNull @PathVariable Long projectId, @RequestParam(required = false) String model) { - return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId(), model)); + public R polishScript(@NotNull @PathVariable Long projectId, + @Valid @RequestBody ShortDramaScriptRevisionBo revision) { + return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId(), revision.getInstruction())); } // ==================== 资产分析 ==================== @@ -196,27 +230,73 @@ public class ShortDramaController { } // ==================== 分镜流水线 ==================== + @PostMapping("/{projectId}/analyze-assets/stream") + public SseEmitter analyzeAssetsStream(@NotNull @PathVariable Long projectId, + @NotNull @RequestParam Long scriptId, @RequestParam(required = false) String model, + @RequestParam(required = false) String requestId) { + return shortDramaService.analyzeAssetsStream(projectId, scriptId, LoginHelper.getUserId(), model, requestId); + } + + @GetMapping("/{projectId}/analyze-assets/status") + public R> assetAnalysisStatus(@NotNull @PathVariable Long projectId, + @NotNull @RequestParam Long scriptId, @RequestParam(required = false) String requestId) { + return R.ok(shortDramaService.assetAnalysisStatus(projectId, scriptId, requestId, LoginHelper.getUserId())); + } + /** Phase 3-6: 分镜规划+摄影规则+表演指导+分镜细化 */ @PostMapping("/{projectId}/plan-storyboard") public R> planStoryboard(@NotNull @PathVariable Long projectId, @NotNull @RequestParam Long scriptId, - @RequestParam(required = false) String model) { - return R.ok(shortDramaService.planStoryboard(projectId, scriptId, model, LoginHelper.getUserId())); + @RequestParam(required = false) String model, + @Min(1) @Max(15) @RequestParam(defaultValue = "1") Integer minimumShotSeconds) { + return R.ok(shortDramaService.planStoryboard(projectId, scriptId, model, LoginHelper.getUserId(), minimumShotSeconds)); } /** Phase 3-6: SSE 流式生成分镜,持续推送规划和细化进度 */ - @PostMapping("/{projectId}/plan-storyboard/review") + @PostMapping({"/{projectId}/plan-storyboard/review", "/{projectId}/import-reviewed-plan"}) public R importReviewedPlan(@PathVariable Long projectId, @Valid @RequestBody org.ruoyi.domain.bo.shortdrama.ShortDramaReviewedPlanBo review) { - return R.ok(shortDramaService.importReviewedPlan(projectId,review,LoginHelper.getUserId())); + try { + return R.ok(shortDramaService.importReviewedPlan(projectId,review,LoginHelper.getUserId())); + } catch (IllegalArgumentException | IllegalStateException rejected) { + // The global handler intentionally hides even ServiceException messages. This normal, + // owner-checked endpoint returns only known domain validation feedback, without a stack/cause. + String message = org.ruoyi.service.shortdrama.impl.ShortDramaReviewedImportMessages.failureMessage(rejected); + if (message == null) throw rejected; + return R.fail(message); + } + } + + /** Read-only recovery proof; it never submits a model request or promotes an unchecked draft. */ + @GetMapping("/{projectId}/checkpoint-status") + public R> storyboardCheckpointStatus(@NotNull @PathVariable Long projectId, + @NotNull @RequestParam Long scriptId, @RequestParam(required = false) String model, + @Min(1) @Max(15) @RequestParam(defaultValue = "1") Integer minimumShotSeconds) { + return R.ok(shortDramaService.storyboardCheckpointStatus(projectId, scriptId, model, LoginHelper.getUserId(), minimumShotSeconds)); + } + + /** Unreviewed native output only; viewing it does not validate or import any panel. */ + @GetMapping("/{projectId}/plan-storyboard/candidates") + public R> storyboardCandidates(@NotNull @PathVariable Long projectId, + @NotNull @RequestParam Long scriptId, @RequestParam(required = false) String model, + @Min(1) @Max(15) @RequestParam(defaultValue = "4") Integer minimumShotSeconds) { + return R.ok(shortDramaService.storyboardCandidates(projectId, scriptId, model, LoginHelper.getUserId(), minimumShotSeconds)); + } + + @GetMapping("/{projectId}/plan-storyboard/status") + public R> storyboardPlanningStatus(@NotNull @PathVariable Long projectId, + @NotNull @RequestParam Long scriptId, @RequestParam(required = false) String requestId) { + return R.ok(shortDramaService.storyboardPlanningStatus(projectId, scriptId, requestId, LoginHelper.getUserId())); } @PostMapping("/{projectId}/plan-storyboard/stream") public SseEmitter planStoryboardStream(@NotNull @PathVariable Long projectId, @NotNull @RequestParam Long scriptId, - @RequestParam(required = false) String model) { - return shortDramaService.planStoryboardStream(projectId, scriptId, model, LoginHelper.getUserId()); + @RequestParam(required = false) String model, + @Min(1) @Max(15) @RequestParam(defaultValue = "1") Integer minimumShotSeconds, + @RequestParam(required = false) String requestId) { + return shortDramaService.planStoryboardStream(projectId, scriptId, model, LoginHelper.getUserId(), minimumShotSeconds, requestId); } /** Phase 4: 重新生成摄影规则 */ @@ -328,15 +408,19 @@ public class ShortDramaController { @PostMapping("/location/{locationId}/generate-image") public R generateLocationImage(@NotNull @PathVariable Long locationId, @NotBlank @RequestParam String model, - @RequestParam(required = false) String referenceImageUrl) { - return R.ok(shortDramaService.generateLocationImage(locationId, model, referenceImageUrl, LoginHelper.getUserId())); + @RequestParam(required = false) String referenceImageUrl, + @Valid @RequestBody(required = false) org.ruoyi.domain.bo.shortdrama.ShortDramaLocationImageRevisionBo revision) { + return R.ok(shortDramaService.generateLocationImage(locationId, model, referenceImageUrl, + revision == null ? null : revision.getRevisionRequirements(), LoginHelper.getUserId())); } @PostMapping("/location/{locationId}/regenerate") public R regenerateLocationImage(@NotNull @PathVariable Long locationId, @NotBlank @RequestParam String model, - @RequestParam(required = false) String referenceImageUrl) { - return R.ok(shortDramaService.regenerateLocationImage(locationId, model, referenceImageUrl, LoginHelper.getUserId())); + @RequestParam(required = false) String referenceImageUrl, + @Valid @RequestBody(required = false) org.ruoyi.domain.bo.shortdrama.ShortDramaLocationImageRevisionBo revision) { + return R.ok(shortDramaService.regenerateLocationImage(locationId, model, referenceImageUrl, + revision == null ? null : revision.getRevisionRequirements(), LoginHelper.getUserId())); } @PostMapping("/location/{locationId}/select-image") @@ -400,8 +484,12 @@ public class ShortDramaController { public R startImage(@NotBlank @RequestParam String assetType, @NotNull @RequestParam Long assetId, @NotBlank @RequestParam String model, - @RequestParam(required = false) String referenceImageUrl) { - return R.ok(shortDramaService.startImageGeneration(assetType, assetId, model, referenceImageUrl, LoginHelper.getUserId())); + @RequestParam(required = false) String referenceImageUrl, + @Valid @RequestBody(required = false) ShortDramaImageRevisionBo revision) { + return R.ok(shortDramaService.startImageGeneration(assetType, assetId, model, referenceImageUrl, + revision == null ? null : revision.getReferencePurpose(), + revision == null ? null : revision.getRevisionRequirements(), + revision == null ? null : revision.getStyleReferenceImageUrls(), LoginHelper.getUserId())); } /** 轮询确认形象图片并保存 */ diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaMaterialController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaMaterialController.java index 412fe408..df5a9e80 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaMaterialController.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaMaterialController.java @@ -87,7 +87,7 @@ public class ShortDramaMaterialController { ObjectNode image = replace >= 0 ? ((ObjectNode)list.get(replace)).deepCopy() : JSON.createObjectNode(); image.remove("items"); image.put("url", "/short-drama/"+projectId+"/materials/file/"+name); - image.put("filename", Objects.toString(file.getOriginalFilename(), "真实素材")); + image.put("filename", Objects.toString(file.getOriginalFilename(), "素材")); image.put("type", type); if (replace >= 0) list.set(replace, image); else list.add(image); } @@ -155,8 +155,8 @@ public class ShortDramaMaterialController { owner(projectId);var shot=boards.selectById(shotId); if(shot==null || !projectId.equals(shot.getProjectId()) || "generating".equals(shot.getVideoStatus()))throw new IllegalArgumentException("镜头不存在或正在生成"); var continuity=(ObjectNode)JSON.readTree(Objects.toString(shot.getContinuityJson(),"{}")); - if(!"direct_insert".equals(continuity.path("source_media").path("mode").asText()))throw new IllegalArgumentException("仅支持真实素材镜头"); - var images=items(continuity);if(images.isEmpty())throw new IllegalArgumentException("请先上传真实素材"); + if(!"direct_insert".equals(continuity.path("source_media").path("mode").asText()))throw new IllegalArgumentException("仅支持素材镜头"); + var images=items(continuity);if(images.isEmpty())throw new IllegalArgumentException("请先上传素材"); Path dir=directory(projectId);String job=UUID.randomUUID().toString(); List command=new ArrayList<>(List.of("ffmpeg","-v","error","-y"));List filters=new ArrayList<>();StringBuilder concat=new StringBuilder(); double duration=shot.getDurationSeconds(), part=duration/images.size(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaMusicController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaMusicController.java new file mode 100644 index 00000000..8e9d4db0 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaMusicController.java @@ -0,0 +1,15 @@ +package org.ruoyi.controller.shortdrama; +import lombok.RequiredArgsConstructor; +import org.ruoyi.common.core.domain.R; +import org.ruoyi.common.satoken.utils.LoginHelper; +import org.ruoyi.service.shortdrama.impl.ShortDramaMusicService; +import org.springframework.web.bind.annotation.*; +@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}/music") +public class ShortDramaMusicController { + private final ShortDramaMusicService service; + @GetMapping public R list(@PathVariable Long projectId) throws Exception {return R.ok(service.list(projectId,LoginHelper.getUserId()));} + @PostMapping public R generate(@PathVariable Long projectId,@RequestBody ShortDramaMusicService.MusicRequest request) throws Exception {return R.ok(service.generate(projectId,LoginHelper.getUserId(),request));} + @GetMapping("/{id}") public R poll(@PathVariable Long projectId,@PathVariable String id) throws Exception {return R.ok(service.poll(projectId,LoginHelper.getUserId(),id));} + @PostMapping("/{id}/use") public R use(@PathVariable Long projectId,@PathVariable String id,@RequestBody ShortDramaMusicService.Usage usage) throws Exception {return R.ok(service.use(projectId,LoginHelper.getUserId(),id,usage));} + @PostMapping("/write") public R write(@PathVariable Long projectId,@RequestBody ShortDramaMusicService.BriefRequest request) {return R.ok(service.write(projectId,LoginHelper.getUserId(),request));} +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaSkillController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaSkillController.java new file mode 100644 index 00000000..27deff98 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaSkillController.java @@ -0,0 +1,23 @@ +package org.ruoyi.controller.shortdrama; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import lombok.RequiredArgsConstructor; +import org.ruoyi.common.core.domain.R; +import org.ruoyi.service.shortdrama.impl.ShortDramaSkillCatalog; +import org.ruoyi.service.shortdrama.impl.ShortDramaSkillMarket; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/skills") +public class ShortDramaSkillController { + private final ShortDramaSkillCatalog catalog; + private final ShortDramaSkillMarket market; + @GetMapping("/market") public R> market() { return R.ok(market.list()); } + @GetMapping public R> list(@RequestParam(required=false) String type, + @RequestParam(defaultValue="false") boolean includeDisabled) { return R.ok(catalog.list(type, includeDisabled)); } + @GetMapping("/{name}") public R detail(@PathVariable String name) { return R.ok(catalog.detail(name)); } + @PostMapping @SaCheckPermission("shortDrama:skill:manage") + public R create(@RequestBody ShortDramaSkillCatalog.Save body) { return R.ok(catalog.save(body.name(), body, true)); } + @PutMapping("/{name}") @SaCheckPermission("shortDrama:skill:manage") + public R update(@PathVariable String name, @RequestBody ShortDramaSkillCatalog.Save body) { return R.ok(catalog.save(name, body, false)); } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaVisualAssetController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaVisualAssetController.java index 30b8d312..f448b6a9 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaVisualAssetController.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaVisualAssetController.java @@ -11,12 +11,20 @@ public class ShortDramaVisualAssetController { private final ShortDramaVisualAssetService service; public record ReviseFrame(String prompt,String model) { } @PostMapping("/{assetId}/revise") public R> revise(@PathVariable Long projectId,@PathVariable Long assetId,@RequestBody ReviseFrame body) {return R.ok(service.reviseFrame(projectId,assetId,body.prompt(),body.model(),LoginHelper.getUserId()));} + public record StartFrame(String imageUrl) { } + @PostMapping("/{storyboardId}/start-frame") public R> saveStartFrame(@PathVariable Long projectId,@PathVariable Long storyboardId,@RequestBody StartFrame body) {return R.ok(service.saveManualStartFrame(projectId,storyboardId,body.imageUrl(),LoginHelper.getUserId()));} + @DeleteMapping("/{storyboardId}/start-frame") public R> removeStartFrame(@PathVariable Long projectId,@PathVariable Long storyboardId) {return R.ok(service.removeManualStartFrame(projectId,storyboardId,LoginHelper.getUserId()));} public record Prop(String title,String prompt,java.util.List shotNumbers,String imageUrl) { } @PostMapping("/prop") public R> prop(@PathVariable Long projectId,@RequestBody Prop body) {return R.ok(service.saveProp(projectId,body.title(),body.prompt(),body.shotNumbers(),body.imageUrl(),LoginHelper.getUserId()));} + public record PropUpdate(String prompt,String referenceImageUrl) { } + @PutMapping("/prop/{assetId}") public R> updateProp(@PathVariable Long projectId,@PathVariable Long assetId,@RequestBody PropUpdate body) {return R.ok(service.updateProp(projectId,assetId,body.prompt(),body.referenceImageUrl(),LoginHelper.getUserId()));} + @PostMapping("/prop/{assetId}/regenerate") public R> regenerateProp(@PathVariable Long projectId,@PathVariable Long assetId,@RequestParam String model) {return R.ok(service.regenerateProp(projectId,assetId,model,LoginHelper.getUserId()));} + @PostMapping("/prop/{assetId}/undo") public R> undoProp(@PathVariable Long projectId,@PathVariable Long assetId) {return R.ok(service.undoProp(projectId,assetId,LoginHelper.getUserId()));} @PostMapping("/generate-opening") public R> opening(@PathVariable Long projectId,@RequestParam String model) {return R.ok(service.generateOpening(projectId,model,LoginHelper.getUserId()));} @PostMapping("/generate-range") public R> range(@PathVariable Long projectId,@RequestParam String model,@RequestParam int start,@RequestParam int end) {return R.ok(service.generateRange(projectId,model,LoginHelper.getUserId(),start,end));} @GetMapping public R> status(@PathVariable Long projectId) {return R.ok(service.status(projectId,LoginHelper.getUserId()));} @PostMapping("/plan") public R> plan(@PathVariable Long projectId,@RequestParam(required=false) String model) {return R.ok(service.plan(projectId,model,LoginHelper.getUserId()));} @PostMapping("/recover") public R> recover(@PathVariable Long projectId) {return R.ok(service.recover(projectId,LoginHelper.getUserId()));} + @PostMapping("/generate-props") public R> missingProps(@PathVariable Long projectId,@RequestParam String model) {return R.ok(service.generateMissingProps(projectId,model,LoginHelper.getUserId()));} @PostMapping("/generate") public R> generate(@PathVariable Long projectId,@RequestParam String model) {return R.ok(service.generate(projectId,model,LoginHelper.getUserId()));} } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaVoiceErrors.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaVoiceErrors.java new file mode 100644 index 00000000..b57cc853 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaVoiceErrors.java @@ -0,0 +1,14 @@ +package org.ruoyi.controller.shortdrama; + +import org.ruoyi.common.core.domain.R; +import org.ruoyi.service.shortdrama.impl.ShortDramaVoiceException; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.bind.annotation.*; + +@RestControllerAdvice(assignableTypes={ShortDramaCharacterVoiceController.class,ShortDramaController.class}) +@Order(Ordered.HIGHEST_PRECEDENCE) +public class ShortDramaVoiceErrors { + @ExceptionHandler(ShortDramaVoiceException.class) + public R validation(ShortDramaVoiceException error) {return R.fail(error.getMessage());} +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaIdeaBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaIdeaBo.java index b99d91a0..8dc7134a 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaIdeaBo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaIdeaBo.java @@ -15,9 +15,13 @@ public class ShortDramaIdeaBo { private String projectName; private String artStyle; + private String aestheticSkillName; + private String directorSkillName; private String aspectRatio; - /** Stop after script so the author can review before asset/shot planning. */ - private Boolean scriptOnly = false; + /** 兼容旧请求字段;创建接口始终只生成剧本,该值不再控制后续阶段。 */ + @Deprecated + private Boolean scriptOnly = true; + } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaImageRevisionBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaImageRevisionBo.java new file mode 100644 index 00000000..95389f3d --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaImageRevisionBo.java @@ -0,0 +1,19 @@ +package org.ruoyi.domain.bo.shortdrama; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** Optional request-only edit intent; omission preserves approved identity/location continuity. */ +@Data +public class ShortDramaImageRevisionBo { + @Pattern(regexp = "identity|identity_revision|location|location_revision") + private String referencePurpose; + + @Size(max = 4000) + private String revisionRequirements; + + /** Separate look references; the primary reference still owns identity or geometry. */ + @Size(max = 3) + private java.util.List<@Pattern(regexp = "https?://.+") String> styleReferenceImageUrls; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaLocationImageRevisionBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaLocationImageRevisionBo.java new file mode 100644 index 00000000..b34b96b1 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaLocationImageRevisionBo.java @@ -0,0 +1,11 @@ +package org.ruoyi.domain.bo.shortdrama; + +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** Request-only image changes. Never persisted into the frozen location description. */ +@Data +public class ShortDramaLocationImageRevisionBo { + @Size(max = 4000) + private String revisionRequirements; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaProjectBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaProjectBo.java index ce73bec3..918f8ba8 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaProjectBo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaProjectBo.java @@ -19,9 +19,13 @@ public class ShortDramaProjectBo extends BaseEntity { private String description; + private String originalIdea; + private String status; private String artStyle; + private String aestheticSkillName; + private String directorSkillName; private String composeAspectRatio; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaReviewedPlanBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaReviewedPlanBo.java index 7eed97c2..12609948 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaReviewedPlanBo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaReviewedPlanBo.java @@ -10,5 +10,10 @@ public class ShortDramaReviewedPlanBo { @NotNull private Long scriptId; @NotBlank private String expectedScriptText; private String model; + @NotNull @Min(1) @Max(15) private Integer minimumShotSeconds = 1; + /** Absent means a full reviewed plan; present imports only explicitly reviewed scenes. */ + @Size(min=1, max=100) private List<@NotNull @Min(1) Integer> sceneNumbers; + /** Returned by checkpoint-status. Required for partial imports, ignores temporary image URLs. */ + @Pattern(regexp="[a-f0-9]{64}") private String expectedAssetSignature; @NotEmpty @Size(max=500) private List panels; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaRevisionBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaRevisionBo.java index 3391a1a6..ad37fb12 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaRevisionBo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaRevisionBo.java @@ -12,6 +12,9 @@ public class ShortDramaRevisionBo { @NotBlank private String expectedScriptText; @NotBlank private String scriptText; private String outlineText; + private String tone; + private String worldbuilding; + private String revisionNotes; @NotEmpty private List storyboards; private List characters; private List locations; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptBo.java index c4252441..18d53489 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptBo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptBo.java @@ -24,4 +24,12 @@ public class ShortDramaScriptBo extends BaseEntity { private String tone; private String sourceType; + + private String creationMode; + + private String sourceMaterials; + + private String worldbuilding; + + private String revisionNotes; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptRevisionBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptRevisionBo.java new file mode 100644 index 00000000..39bdf905 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaScriptRevisionBo.java @@ -0,0 +1,13 @@ +package org.ruoyi.domain.bo.shortdrama; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class ShortDramaScriptRevisionBo { + + @NotBlank(message = "修改意见不能为空") + @Size(max = 10000, message = "修改意见不能超过10000字") + private String instruction; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaProject.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaProject.java index 529c3cdd..aa714373 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaProject.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaProject.java @@ -27,9 +27,14 @@ public class ShortDramaProject extends BaseEntity { private String description; + /** Original user input, independent of generated summaries and script revisions. */ + private String originalIdea; + private String status; private String artStyle; + private String aestheticSkillName; + private String directorSkillName; private Long composedVideoOssId; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaScript.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaScript.java index 7a5b0e60..8b2b7509 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaScript.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaScript.java @@ -30,4 +30,12 @@ public class ShortDramaScript extends BaseEntity { private String tone; private String sourceType; + + private String creationMode; + + private String sourceMaterials; + + private String worldbuilding; + + private String revisionNotes; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaProjectVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaProjectVo.java index a5cde322..07f62706 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaProjectVo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaProjectVo.java @@ -24,9 +24,13 @@ public class ShortDramaProjectVo implements Serializable { private String description; + private String originalIdea; + private String status; private String artStyle; + private String aestheticSkillName; + private String directorSkillName; private Long composedVideoOssId; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaScriptVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaScriptVo.java index a26e6067..f6b34463 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaScriptVo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/shortdrama/ShortDramaScriptVo.java @@ -32,4 +32,12 @@ public class ShortDramaScriptVo implements Serializable { private Date createTime; private Date updateTime; + + private String creationMode; + + private String sourceMaterials; + + private String worldbuilding; + + private String revisionNotes; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/AtlasAudioGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/AtlasAudioGenerationServiceImpl.java index bab5ca4e..63333569 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/AtlasAudioGenerationServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/AtlasAudioGenerationServiceImpl.java @@ -1,7 +1,6 @@ package org.ruoyi.service.audio.provider; import cn.hutool.core.util.StrUtil; -import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,13 +19,11 @@ import org.ruoyi.service.media.AtlasPredictionService; import org.springframework.stereotype.Component; import java.io.IOException; -import java.util.List; -import java.util.Map; import java.util.concurrent.TimeUnit; /** - * Atlas Cloud 音频生成(bytedance/seed-audio-1.0)。异步:提交 /model/generateAudio 返回 predictionId, - * 轮询 /model/prediction/{id} 拿音频 URL。支持 references(speaker 音色 + 参考音频)做多角色对白配音。 + * Atlas Cloud TTS:Gemini 单音色与 Seed Audio references 使用各自的请求字段。 + * 提交返回 predictionId,轮询 /model/prediction/{id} 获取音频;Gemini 付费 POST 不自动重试。 */ @Slf4j @Component("atlasAudio") @@ -41,37 +38,17 @@ public class AtlasAudioGenerationServiceImpl extends AbstractAudioGenerationServ .writeTimeout(30, TimeUnit.SECONDS) .build(); + private final OkHttpClient geminiHttpClient = okHttpClient.newBuilder() + .retryOnConnectionFailure(false) + .followRedirects(false) + .followSslRedirects(false) + .build(); + @Override protected MediaGenerationResponse doGenerateSpeech(AudioContext audioContext) { ChatModelVo model = audioContext.getChatModelVo(); - ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode(); - payload.put("model", model.getModelName()); - payload.put("text", audioContext.getInput()); - String format = StrUtil.blankToDefault(audioContext.getResponseFormat(), "mp3"); - payload.put("format", format); - - // 参考资源:多角色音色 + 参考音频 - List> refs = audioContext.getReferences(); - if (refs != null && !refs.isEmpty()) { - ArrayNode arr = payload.putArray("references"); - for (Map ref : refs) { - ObjectNode r = arr.addObject(); - if (StrUtil.isNotBlank(ref.get("speaker"))) r.put("speaker", ref.get("speaker")); - if (StrUtil.isNotBlank(ref.get("audioUrl"))) r.put("audio_url", ref.get("audioUrl")); - if (StrUtil.isNotBlank(ref.get("audioData"))) r.put("audio_data", ref.get("audioData")); - if (StrUtil.isNotBlank(ref.get("imageData"))) r.put("image_data", ref.get("imageData")); - } - } else if (StrUtil.isNotBlank(audioContext.getVoice())) { - // 没有显式 references 但指定了 voice 音色名:作为单一 speaker - ArrayNode arr = payload.putArray("references"); - ObjectNode r = arr.addObject(); - r.put("speaker", audioContext.getVoice()); - } - - if (audioContext.getSampleRate() != null) payload.put("sample_rate", audioContext.getSampleRate()); - if (audioContext.getPitchRate() != null) payload.put("pitch_rate", audioContext.getPitchRate()); - if (audioContext.getSpeechRate() != null) payload.put("speech_rate", audioContext.getSpeechRate()); - if (audioContext.getLoudnessRate() != null) payload.put("loudness_rate", audioContext.getLoudnessRate()); + boolean gemini = AtlasSpeechPayloadBuilder.isGeminiTts(model.getModelName()); + ObjectNode payload = AtlasSpeechPayloadBuilder.build(audioContext); Request request = new Request.Builder() .url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/generateAudio")) @@ -79,15 +56,26 @@ public class AtlasAudioGenerationServiceImpl extends AbstractAudioGenerationServ .addHeader("Content-Type", "application/json") .post(RequestBody.create(payload.toString(), AtlasMediaSupport.JSON)) .build(); - try (Response response = okHttpClient.newCall(request).execute()) { + try (Response response = (gemini ? geminiHttpClient : okHttpClient).newCall(request).execute()) { ResponseBody body = response.body(); String responseText = body == null ? "" : body.string(); if (!response.isSuccessful()) { throw new IllegalArgumentException("Atlas Cloud 音频生成任务创建失败: " + response.code() + " - " + responseText); } - return atlasPredictionService.toResponse(responseText, "audio"); + if (!gemini) return atlasPredictionService.toResponse(responseText, "audio"); + // Public documentation also shows a root-level prediction object. + var root = AtlasMediaSupport.OBJECT_MAPPER.readTree(responseText); + String normalized = root != null && root.isObject() && !root.has("data") && root.has("id") + ? AtlasMediaSupport.OBJECT_MAPPER.createObjectNode().set("data", root).toString() : responseText; + MediaGenerationResponse result = atlasPredictionService.toResponse(normalized, "audio"); + if (result == null || (StrUtil.isBlank(result.getId()) && StrUtil.isBlank(result.getUrl()))) { + throw new IllegalStateException("Atlas 音频提交结果未知:未返回任务ID或音频URL,请先核对上游任务,禁止重复提交"); + } + result.setRawResponse(responseText); + return result; } catch (IOException e) { - throw new RuntimeException("Atlas Cloud 音频生成任务创建失败: " + e.getMessage(), e); + if (!gemini) throw new RuntimeException("Atlas Cloud 音频生成任务创建失败: " + e.getMessage(), e); + throw new RuntimeException("Atlas 音频提交结果未知,请先核对上游任务,禁止重复提交: " + e.getMessage(), e); } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/AtlasSpeechPayloadBuilder.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/AtlasSpeechPayloadBuilder.java new file mode 100644 index 00000000..2fd77caa --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/audio/provider/AtlasSpeechPayloadBuilder.java @@ -0,0 +1,103 @@ +package org.ruoyi.service.audio.provider; + +import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.ruoyi.common.chat.entity.audio.AudioContext; +import org.ruoyi.service.media.AtlasMediaSupport; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** Model-specific Atlas speech contracts; Gemini is not a Seed Audio reference request. */ +public final class AtlasSpeechPayloadBuilder { + public static final String GEMINI_MODEL = "google/gemini-3.1-flash-tts"; + public static final String DEFAULT_GEMINI_VOICE = "Charon"; + public static final String DRAMA_INSTRUCTIONS = "使用自然、有力的中文男声,保持同一人的声线;" + + "根据原句表达情绪,语速适合短剧表演,不过度播报,不增加台词或旁白。"; + private static final Set GEMINI_VOICES = Set.of( + "Zephyr", "Puck", "Charon", "Kore", "Fenrir", "Leda", "Orus", "Aoede", "Callirrhoe", "Autonoe", + "Enceladus", "Iapetus", "Umbriel", "Algieba", "Despina", "Erinome", "Algenib", "Rasalgethi", + "Laomedeia", "Achernar", "Alnilam", "Schedar", "Gacrux", "Pulcherrima", "Achird", "Zubenelgenubi", + "Vindemiatrix", "Sadachbia", "Sadaltager", "Sulafat"); + private static final Pattern AUDIO_LABEL = Pattern.compile("(?i)@audio\\d+\\b\\s*[::]?[\\t ]*"); + + private AtlasSpeechPayloadBuilder() { } + + public static boolean isGeminiTts(String model) { + return GEMINI_MODEL.equals(model); + } + + public static ObjectNode build(AudioContext context) { + String model = context.getChatModelVo().getModelName(); + ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode(); + payload.put("model", model); + if (isGeminiTts(model)) { + String voice = StrUtil.blankToDefault(context.getVoice(), DEFAULT_GEMINI_VOICE); + if (!GEMINI_VOICES.contains(voice)) { + throw new IllegalArgumentException("Gemini TTS 音色必须是官方 prebuilt voice,不能使用 Seed Audio 的 speaker 名称"); + } + String spoken = spokenText(context.getInput(), List.of()); + if (StrUtil.isBlank(spoken)) throw new IllegalArgumentException("语音正文不能为空"); + String direction = StrUtil.blankToDefault(context.getInstructions(), DRAMA_INSTRUCTIONS); + // Atlas exposes one text field. Gemini accepts natural-language performance instructions in it. + payload.put("text", "单人配音任务。仅朗读下面“正文”中的原句,不朗读任务说明、表演指令、角色名或标签。\n" + + "表演指令:" + direction + "\n正文:\n" + spoken); + payload.put("voice", voice); + return payload; + } + + payload.put("text", context.getInput()); + payload.put("format", StrUtil.blankToDefault(context.getResponseFormat(), "mp3")); + List> refs = context.getReferences(); + if (refs != null && !refs.isEmpty()) { + if (refs.size() > 3) throw new IllegalArgumentException("Seed Audio最多3条声音参考或1张图像参考"); + ArrayNode arr = payload.putArray("references"); + boolean image = false; + for (Map ref : refs) { + long sources = List.of("speaker", "audioUrl", "audioData", "imageUrl", "imageData").stream() + .filter(key -> StrUtil.isNotBlank(ref.get(key))).count(); + if (sources != 1) throw new IllegalArgumentException("每条 Seed Audio 参考必须且只能包含一种来源"); + image |= StrUtil.isNotBlank(ref.get("imageUrl")) || StrUtil.isNotBlank(ref.get("imageData")); + ObjectNode r = arr.addObject(); + if (StrUtil.isNotBlank(ref.get("speaker"))) r.put("speaker", ref.get("speaker")); + if (StrUtil.isNotBlank(ref.get("audioUrl"))) r.put("audio_url", ref.get("audioUrl")); + if (StrUtil.isNotBlank(ref.get("audioData"))) r.put("audio_data", ref.get("audioData")); + if (StrUtil.isNotBlank(ref.get("imageData"))) r.put("image_data", ref.get("imageData")); + if (StrUtil.isNotBlank(ref.get("imageUrl"))) r.put("image_url", ref.get("imageUrl")); + } + if (image && refs.size() != 1) throw new IllegalArgumentException("Seed Audio图像参考最多1张,不能与声音参考混用"); + } else if (StrUtil.isNotBlank(context.getVoice())) { + payload.putArray("references").addObject().put("speaker", context.getVoice()); + } + if (context.getSampleRate() != null) payload.put("sample_rate", context.getSampleRate()); + if (context.getPitchRate() != null) payload.put("pitch_rate", context.getPitchRate()); + if (context.getSpeechRate() != null) payload.put("speech_rate", context.getSpeechRate()); + if (context.getLoudnessRate() != null) payload.put("loudness_rate", context.getLoudnessRate()); + return payload; + } + + /** Remove production speaker labels, never arbitrary text preceding a colon inside a spoken sentence. */ + public static String spokenText(String input, Collection speakerNames) { + if (input == null) return ""; + String text = AUDIO_LABEL.matcher(input).replaceAll(""); + for (String alias : List.of("旁白", "画外音", "Narrator", "Narration")) { + text = removeSpeakerPrefix(text, alias); + } + text = text.replaceAll("(?im)^[\\t ]*Speaker[\\t ]*\\d+[\\t ]*[::][\\t ]*", ""); + if (speakerNames != null) { + for (String name : speakerNames) { + if (StrUtil.isNotBlank(name)) text = removeSpeakerPrefix(text, name); + } + } + return text.trim(); + } + + private static String removeSpeakerPrefix(String text, String name) { + return text.replaceAll("(?m)^[\\t ]*" + Pattern.quote(name) + + "[\\t ]*(?:[((][^))\\r\\n]*[))][\\t ]*)?[::][\\t ]*", ""); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/AbstractChatService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/AbstractChatService.java index 7048e2e8..5bb085ef 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/AbstractChatService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/AbstractChatService.java @@ -42,6 +42,11 @@ public interface AbstractChatService { .build(); } + /** Optional request parameters; native providers keep their existing adapter behavior. */ + default ChatModel buildChatModel(ChatModelVo chatModelVo, ChatRequest request) { + return buildChatModel(chatModelVo); + } + /** * 获取服务提供商名称 */ diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatModelServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatModelServiceImpl.java index 0c47bce2..5946ecc0 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatModelServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatModelServiceImpl.java @@ -110,10 +110,10 @@ public class ChatModelServiceImpl implements IChatModelService { private LambdaQueryWrapper buildQueryWrapper(ChatModelBo bo) { LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); - lqw.orderByAsc(ChatModel::getId); + lqw.orderByAsc(ChatModel::getSortOrder, ChatModel::getId); lqw.eq(StringUtils.isNotBlank(bo.getCategory()), ChatModel::getCategory, bo.getCategory()); lqw.like(StringUtils.isNotBlank(bo.getModelName()), ChatModel::getModelName, bo.getModelName()); - lqw.like(StringUtils.isNotBlank(bo.getProviderCode()), ChatModel::getProviderCode, bo.getProviderCode()); + lqw.eq(StringUtils.isNotBlank(bo.getProviderCode()), ChatModel::getProviderCode, bo.getProviderCode()); lqw.eq(StringUtils.isNotBlank(bo.getModelDescribe()), ChatModel::getModelDescribe, bo.getModelDescribe()); lqw.eq(StringUtils.isNotBlank(bo.getModelShow()), ChatModel::getModelShow, bo.getModelShow()); lqw.eq(StringUtils.isNotBlank(bo.getApiHost()), ChatModel::getApiHost, bo.getApiHost()); @@ -181,6 +181,7 @@ public class ChatModelServiceImpl implements IChatModelService { } target.setId(source.getId()); target.setCategory(source.getCategory()); + target.setSortOrder(source.getSortOrder()); target.setModelName(source.getModelName()); target.setProviderCode(source.getProviderCode()); target.setModelDescribe(source.getModelDescribe()); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java index 4869bef7..1388fcbf 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java @@ -262,8 +262,8 @@ public class ChatServiceFacade implements IChatService { llmSpan.detach(); } finishTraceRun(traceRun, TraceConstants.STATUS_ERROR, e); - SseMessageUtils.sendError(String.valueOf(chatRequest.getSessionId()), SAFE_CHAT_ERROR_MESSAGE); - SseMessageUtils.completeConnection(String.valueOf(chatRequest.getSessionId())); + saveModelChatFailure(chatRequest, ""); + SseMessageUtils.sendErrorAndComplete(String.valueOf(chatRequest.getSessionId()), SAFE_CHAT_ERROR_MESSAGE); log.error("chat_operation operation=MODEL_CHAT status=FAILED errorType={}", errorType(e)); } return chatRequest.getEmitter(); @@ -807,20 +807,24 @@ public class ChatServiceFacade implements IChatService { return new StreamingChatResponseHandler() { private final StringBuilder messageBuffer = new StringBuilder(); + private final AtomicBoolean terminal = new AtomicBoolean(); @Override public void onPartialResponse(String partialResponse) { + if (terminal.get()) return; messageBuffer.append(partialResponse); SseMessageUtils.sendContent(sessionId, partialResponse); } @Override public void onPartialThinking(PartialThinking partialThinking) { + if (terminal.get()) return; SseMessageUtils.sendReasoning(sessionId, partialThinking.text()); } @Override public void onCompleteResponse(ChatResponse completeResponse) { + if (!terminal.compareAndSet(false, true)) return; try { String fullMessage = messageBuffer.toString(); if (StringUtils.isNotBlank(fullMessage)) { @@ -832,7 +836,7 @@ public class ChatServiceFacade implements IChatService { chatRequest.getModel() ); } else { - log.warn("chat_stream status=EMPTY_RESPONSE"); + throw new IllegalStateException("Model completed without text content"); } if (llmSpan != null) { llmSpan.finishSuccess(RagTracePayloadBuilder.streamOutputSummary(fullMessage.length())); @@ -844,30 +848,43 @@ public class ChatServiceFacade implements IChatService { llmSpan.finishError(e); } finishTraceRun(traceRun, TraceConstants.STATUS_ERROR, e); - SseMessageUtils.sendError(sessionId, SAFE_CHAT_ERROR_MESSAGE); + saveModelChatFailure(chatRequest, messageBuffer.toString()); + SseMessageUtils.sendErrorAndComplete(sessionId, SAFE_CHAT_ERROR_MESSAGE); log.error("chat_stream operation=COMPLETE status=FAILED errorType={}", errorType(e)); } finally { if (llmSpan != null) { llmSpan.detach(); } - SseMessageUtils.completeConnection(sessionId); } } @Override public void onError(Throwable error) { - if (llmSpan != null) { - llmSpan.finishError(error); - llmSpan.detach(); + if (!terminal.compareAndSet(false, true)) return; + try { + if (llmSpan != null) llmSpan.finishError(error); + finishTraceRun(traceRun, TraceConstants.STATUS_ERROR, error); + saveModelChatFailure(chatRequest, messageBuffer.toString()); + } finally { + if (llmSpan != null) llmSpan.detach(); + SseMessageUtils.sendErrorAndComplete(sessionId, SAFE_CHAT_ERROR_MESSAGE); } - finishTraceRun(traceRun, TraceConstants.STATUS_ERROR, error); - SseMessageUtils.sendError(sessionId, SAFE_CHAT_ERROR_MESSAGE); - SseMessageUtils.completeConnection(sessionId); log.error("chat_stream operation=MODEL_STREAM status=FAILED errorType={}", errorType(error)); } }; } + private void saveModelChatFailure(ChatRequest request, String partialText) { + try { + String saved = StringUtils.isBlank(partialText) ? SAFE_CHAT_ERROR_MESSAGE + : partialText + "\n\n[响应未完成] " + SAFE_CHAT_ERROR_MESSAGE; + chatMessageService.saveChatMessage(request.getUserId(), request.getSessionId(), saved, + RoleType.ASSISTANT.getName(), request.getModel()); + } catch (Exception failure) { + log.error("chat_stream operation=SAVE_ERROR status=FAILED errorType={}", errorType(failure)); + } + } + /** * 创建组合响应处理器 - 同时发送到 SSE 和外部 handler * diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/AtlaServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/AtlaServiceImpl.java index 7d4a62f0..20e76fa6 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/AtlaServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/AtlaServiceImpl.java @@ -2,6 +2,8 @@ package org.ruoyi.service.chat.impl.provider; import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.openai.OpenAiStreamingChatModel; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -10,9 +12,11 @@ import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; import org.ruoyi.enums.ChatModeType; import org.ruoyi.observability.MyChatModelListener; import org.ruoyi.service.chat.AbstractChatService; +import org.ruoyi.service.chat.impl.provider.atlas.AtlasDeadlineHttpClientBuilder; import org.springframework.stereotype.Service; import java.util.List; +import java.time.Duration; /** * Atlas Cloud服务调用 @@ -28,14 +32,38 @@ public class AtlaServiceImpl implements AbstractChatService { @Override public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { return OpenAiStreamingChatModel.builder() + .httpClientBuilder(new AtlasDeadlineHttpClientBuilder()) .baseUrl(chatModelVo.getApiHost()) .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) + .timeout(Duration.ofMinutes(6)) + .maxTokens(16384) + .reasoningEffort(chatRequest.getReasoningEffort()) + .customParameters(SeedThinkingParameters.forRequest(chatModelVo.getModelName(), chatRequest)) .listeners(List.of(new MyChatModelListener())) .returnThinking(chatRequest.getEnableThinking()) .build(); } + @Override + public ChatModel buildChatModel(ChatModelVo chatModelVo) { + return buildChatModel(chatModelVo, new ChatRequest()); + } + + @Override + public ChatModel buildChatModel(ChatModelVo chatModelVo, ChatRequest request) { + return OpenAiChatModel.builder() + .baseUrl(chatModelVo.getApiHost()) + .apiKey(chatModelVo.getApiKey()) + .modelName(chatModelVo.getModelName()) + .timeout(Duration.ofMinutes(20)) + .maxRetries(0) + .maxTokens(16384) + .reasoningEffort(request.getReasoningEffort()) + .customParameters(SeedThinkingParameters.forRequest(chatModelVo.getModelName(), request)) + .build(); + } + @Override public String getProviderName() { return ChatModeType.ATLAS.getCode(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DeepseekServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DeepseekServiceImpl.java index 17bfcc38..401e0fb9 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DeepseekServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/DeepseekServiceImpl.java @@ -2,6 +2,8 @@ package org.ruoyi.service.chat.impl.provider; import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.openai.OpenAiStreamingChatModel; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -29,6 +31,23 @@ import java.util.Map; @RequiredArgsConstructor public class DeepseekServiceImpl implements AbstractChatService { + @Override + public ChatModel buildChatModel(ChatModelVo model) { + return buildChatModel(model, new ChatRequest()); + } + + @Override + public ChatModel buildChatModel(ChatModelVo model, ChatRequest request) { + boolean thinking = Boolean.TRUE.equals(request.getEnableThinking()); + var builder = OpenAiChatModel.builder().baseUrl(model.getApiHost()).apiKey(model.getApiKey()) + .modelName(model.getModelName()).listeners(List.of(new MyChatModelListener())) + .customParameters(thinkingParameters(thinking)).returnThinking(thinking) + .sendThinking(thinking || Boolean.TRUE.equals(request.getReplayThinking())) + .timeout(Duration.ofMinutes(3)).maxRetries(0); + if (thinking) builder.reasoningEffort("high"); + return builder.build(); + } + @Override public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) { boolean thinkingEnabled = Boolean.TRUE.equals(chatRequest.getEnableThinking()); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java index 0b06614e..aa706c8d 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/OpenAIServiceImpl.java @@ -2,6 +2,8 @@ package org.ruoyi.service.chat.impl.provider; import dev.langchain4j.model.chat.StreamingChatModel; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.openai.OpenAiStreamingChatModel; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -28,12 +30,24 @@ import java.util.List; @RequiredArgsConstructor public class OpenAIServiceImpl implements AbstractChatService { + @Override + public ChatModel buildChatModel(ChatModelVo config, ChatRequest request) { + return OpenAiChatModel.builder() + .baseUrl(config.getApiHost()).apiKey(config.getApiKey()).modelName(config.getModelName()) + .timeout(Duration.ofSeconds(120)) + .reasoningEffort(request.getReasoningEffort()) + .customParameters(SeedThinkingParameters.forRequest(config.getModelName(), request)) + .build(); + } + @Override public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo,ChatRequest chatRequest) { return OpenAiStreamingChatModel.builder() .baseUrl(chatModelVo.getApiHost()) .apiKey(chatModelVo.getApiKey()) .modelName(chatModelVo.getModelName()) + .reasoningEffort(chatRequest.getReasoningEffort()) + .customParameters(SeedThinkingParameters.forRequest(chatModelVo.getModelName(), chatRequest)) .timeout(Duration.ofMinutes(30)) .listeners(List.of(new MyChatModelListener())) .returnThinking(chatRequest.getEnableThinking()) diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/SeedThinkingParameters.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/SeedThinkingParameters.java new file mode 100644 index 00000000..e996202e --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/SeedThinkingParameters.java @@ -0,0 +1,14 @@ +package org.ruoyi.service.chat.impl.provider; + +import java.util.Map; +import org.ruoyi.common.chat.domain.dto.request.ChatRequest; + +/** Ark's explicit upstream thinking switch is independent of SDK returnThinking. */ +final class SeedThinkingParameters { + private SeedThinkingParameters() {} + static Map forRequest(String model, ChatRequest request) { + boolean seed21 = model != null && (model.endsWith("doubao-seed-2.1-pro-260628") || model.endsWith("doubao-seed-2-1-pro-260628")); + return seed21 && "none".equals(request.getReasoningEffort()) + ? Map.of("thinking", Map.of("type", "disabled")) : Map.of(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/atlas/AtlasDeadlineHttpClientBuilder.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/atlas/AtlasDeadlineHttpClientBuilder.java new file mode 100644 index 00000000..af98a213 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/atlas/AtlasDeadlineHttpClientBuilder.java @@ -0,0 +1,145 @@ +package org.ruoyi.service.chat.impl.provider.atlas; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.langchain4j.exception.HttpException; +import dev.langchain4j.http.client.HttpClient; +import dev.langchain4j.http.client.HttpClientBuilder; +import dev.langchain4j.http.client.HttpRequest; +import dev.langchain4j.http.client.SuccessfulHttpResponse; +import dev.langchain4j.http.client.jdk.JdkHttpClient; +import dev.langchain4j.http.client.sse.ServerSentEvent; +import dev.langchain4j.http.client.sse.ServerSentEventContext; +import dev.langchain4j.http.client.sse.ServerSentEventListener; +import dev.langchain4j.http.client.sse.ServerSentEventParser; + +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** Bounds an opened Atlas SSE body as well as the wait for response headers. No request retries. */ +public final class AtlasDeadlineHttpClientBuilder implements HttpClientBuilder { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final ScheduledExecutorService DEADLINES = Executors.newScheduledThreadPool(2, task -> { + Thread thread = new Thread(task, "atlas-stream-deadline"); thread.setDaemon(true); return thread; + }); + private final Duration firstContentTimeout; + private final Duration totalTimeout; + private Duration connectTimeout = Duration.ofSeconds(30); + private Duration readTimeout = Duration.ofMinutes(6); + + // Seed 2.1 Pro 的长总纲实测首正文约 11 分钟、完整响应约 15 分钟。 + public AtlasDeadlineHttpClientBuilder() { this(Duration.ofMinutes(15), Duration.ofMinutes(30)); } + + public AtlasDeadlineHttpClientBuilder(Duration firstContentTimeout, Duration totalTimeout) { + if (firstContentTimeout == null || totalTimeout == null || firstContentTimeout.isNegative() + || firstContentTimeout.isZero() || totalTimeout.compareTo(firstContentTimeout) < 0) + throw new IllegalArgumentException("Invalid Atlas stream deadlines"); + this.firstContentTimeout = firstContentTimeout; + this.totalTimeout = totalTimeout; + } + + @Override public Duration connectTimeout() { return connectTimeout; } + @Override public AtlasDeadlineHttpClientBuilder connectTimeout(Duration timeout) { connectTimeout = timeout; return this; } + @Override public Duration readTimeout() { return readTimeout; } + @Override public AtlasDeadlineHttpClientBuilder readTimeout(Duration timeout) { readTimeout = timeout; return this; } + + @Override public HttpClient build() { + Duration connection = connectTimeout == null || connectTimeout.compareTo(Duration.ofSeconds(30)) > 0 + ? Duration.ofSeconds(30) : connectTimeout; + var delegate = java.net.http.HttpClient.newBuilder().connectTimeout(connection).build(); + var synchronous = JdkHttpClient.builder().connectTimeout(connection).readTimeout(readTimeout).build(); + return new HttpClient() { + @Override public SuccessfulHttpResponse execute(HttpRequest request) { return synchronous.execute(request); } + + @Override public void execute(HttpRequest request, ServerSentEventParser parser, ServerSentEventListener listener) { + var call = new StreamCall(listener); + call.firstDeadline = DEADLINES.schedule(() -> { + if (!call.hasContent.get()) call.fail(new TimeoutException("Atlas 首段正文等待超过配置时限(" + firstContentTimeout.toSeconds() + "秒)")); + }, firstContentTimeout.toMillis(), TimeUnit.MILLISECONDS); + call.totalDeadline = DEADLINES.schedule(() -> call.fail(new TimeoutException("Atlas 流式请求超过配置时限(" + totalTimeout.toSeconds() + "秒)")), + totalTimeout.toMillis(), TimeUnit.MILLISECONDS); + try { + if (!request.formDataFiles().isEmpty() || !request.formDataFields().isEmpty()) + throw new IllegalArgumentException("Atlas chat streaming requires a JSON request"); + var builder = java.net.http.HttpRequest.newBuilder(URI.create(request.url())).timeout(firstContentTimeout); + request.headers().forEach((name, values) -> values.forEach(value -> builder.header(name, value))); + builder.method(request.method().name(), request.body() == null + ? java.net.http.HttpRequest.BodyPublishers.noBody() + : java.net.http.HttpRequest.BodyPublishers.ofString(request.body())); + CompletableFuture> future = delegate.sendAsync(builder.build(), HttpResponse.BodyHandlers.ofInputStream()); + call.future.set(future); + if (call.terminal.get()) future.cancel(true); + future.whenCompleteAsync((response, error) -> { + if (error != null) { call.fail(error); return; } + call.body.set(response.body()); + if (call.terminal.get()) { call.closeBody(); return; } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + call.fail(new HttpException(response.statusCode(), "Atlas HTTP " + response.statusCode())); return; + } + try (InputStream body = response.body()) { + listener.onOpen(SuccessfulHttpResponse.builder().statusCode(response.statusCode()) + .headers(response.headers().map()).build()); + parser.parse(body, new ServerSentEventListener() { + @Override public void onEvent(ServerSentEvent event, ServerSentEventContext context) { + if (call.terminal.get()) return; + if (hasTextContent(event.data())) { + call.hasContent.set(true); call.firstDeadline.cancel(false); + } + listener.onEvent(event, context); + if ("[DONE]".equals(event.data())) call.complete(); + } + @Override public void onError(Throwable error) { call.fail(error); } + }); + call.complete(); + } catch (Exception failure) { call.fail(failure); } + }); + } catch (Exception failure) { call.fail(failure); } + } + }; + } + + static boolean hasTextContent(String data) { + try { + for (var choice : JSON.readTree(data).path("choices")) { + var content = choice.path("delta").path("content"); + if (content.isTextual() && !content.asText().isBlank()) return true; + } + } catch (Exception ignored) { } + return false; // Transport keepalives, role and reasoning deltas are not screenplay text. + } + + private static final class StreamCall { + final ServerSentEventListener listener; + final AtomicBoolean terminal = new AtomicBoolean(); + final AtomicBoolean hasContent = new AtomicBoolean(); + final AtomicReference> future = new AtomicReference<>(); + final AtomicReference body = new AtomicReference<>(); + volatile ScheduledFuture firstDeadline; + volatile ScheduledFuture totalDeadline; + StreamCall(ServerSentEventListener listener) { this.listener = listener; } + void fail(Throwable failure) { + if (!terminal.compareAndSet(false, true)) return; + cancelDeadlines(); + var pending = future.get(); if (pending != null) pending.cancel(true); + closeBody(); + listener.onError(failure); + } + void complete() { + if (!terminal.compareAndSet(false, true)) return; + cancelDeadlines(); + try { listener.onClose(); } finally { closeBody(); } + } + void cancelDeadlines() { + if (firstDeadline != null) firstDeadline.cancel(false); + if (totalDeadline != null) totalDeadline.cancel(false); + } + void closeBody() { + var stream = body.getAndSet(null); + if (stream != null) try { stream.close(); } catch (Exception ignored) { } + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java index 3d2daf42..f907dddc 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java @@ -17,6 +17,7 @@ import org.ruoyi.common.chat.entity.media.MediaGenerationResponse; import org.ruoyi.enums.ChatModeType; import org.ruoyi.service.image.AbstractImageGenerationService; import org.ruoyi.service.media.AtlasMediaSupport; +import org.ruoyi.service.media.AtlasPredictionService; import org.springframework.stereotype.Component; import java.io.IOException; @@ -26,6 +27,8 @@ import java.util.concurrent.TimeUnit; @Slf4j @Component("atlasImage") public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationService { + private final AtlasPredictionService predictions; + public AtlasImageGenerationServiceImpl(AtlasPredictionService predictions) { this.predictions = predictions; } /** 比例 → 像素映射(AtlasCloud 要求 width*height,min 768 max 1360) */ private static final Map SIZE_MAP = Map.of( @@ -42,6 +45,13 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ "16:9", "1536x1024", "9:16", "1024x1536" ); + private static final String SEEDREAM_47_TEXT_TO_IMAGE = "bytedance/seedream-v4.7/text-to-image"; + /** Atlas's documented 2K presets; the exact T2I variant produces one image, without references. */ + private static final Map SEEDREAM_47_SIZE_MAP = Map.of( + "1:1", "2048*2048", "4:3", "2304*1728", "3:4", "1728*2304", + "16:9", "2848*1600", "9:16", "1600*2848", "3:2", "2496*1664", + "2:3", "1664*2496", "21:9", "3136*1344" + ); private final OkHttpClient okHttpClient = new OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) @@ -49,8 +59,36 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ .writeTimeout(30, TimeUnit.SECONDS) .build(); + @Override + public String generateImage(ImageContext context) { + if (!isSeedream47TextToImage(context.getChatModelVo().getModelName())) return super.generateImage(context); + // The official 4.7 schema is asynchronous. A synchronous caller submits exactly once, + // then queries that prediction; it does not rely on an undocumented sync-mode flag. + var started = startImageGeneration(context); + return awaitSeedream47Image(context.getChatModelVo(), started.getId()); + } + + String awaitSeedream47Image(ChatModelVo model, String predictionId) { + long deadline = System.nanoTime() + TimeUnit.MINUTES.toNanos(5); + try { + while (System.nanoTime() < deadline) { + var result = predictions.retrieve(model, predictionId); + if (result != null && ("completed".equals(result.getStatus()) || "succeeded".equals(result.getStatus())) && StrUtil.isNotBlank(result.getUrl())) return result.getUrl(); + if (result != null && "failed".equals(result.getStatus())) throw new IllegalStateException("Seedream 4.7图片任务失败,predictionId=" + predictionId); + Thread.sleep(1500); + } + throw new IllegalStateException("Seedream 4.7图片任务尚未完成,请查询原任务而勿重新提交:predictionId=" + predictionId); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); throw new IllegalStateException("图片等待被中断,请查询原任务:predictionId=" + predictionId, e); + } catch (RuntimeException e) { + throw new IllegalStateException("Seedream 4.7原任务查询未完成,predictionId=" + predictionId + ";" + e.getMessage(), e); + } + } + @Override protected String doGenerateImage(ChatModelVo chatModelVo, String prompt, String size, Integer seed, String image) { + if (isSeedream47TextToImage(chatModelVo.getModelName())) return generateImage(ImageContext.builder() + .chatModelVo(chatModelVo).prompt(prompt).size(size).seed(seed).image(image).build()); ObjectNode payload = buildPayload(chatModelVo, prompt, size, seed, image); payload.put("enable_sync_mode", true); @@ -101,18 +139,26 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ return ""; } - private ObjectNode buildPayload(ChatModelVo chatModelVo, String prompt, String size, Integer seed, String image) { + static ObjectNode buildPayload(ChatModelVo chatModelVo, String prompt, String size, Integer seed, String image) { String modelName = chatModelVo.getModelName(); boolean gptImageModel = isGptImageModel(modelName); + boolean seedream47T2i = isSeedream47TextToImage(modelName); + if (seedream47T2i && StrUtil.isNotBlank(image)) { + throw seedream47ReferenceError(); + } ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode(); payload.put("model", modelName); payload.put("prompt", prompt); if (StrUtil.isNotBlank(size)) { payload.put("size", resolveSize(modelName, size)); } - if (seed != null && !gptImageModel) { + if (seed != null && !gptImageModel && !seedream47T2i) { payload.put("seed", seed); } + if (seedream47T2i) { + payload.put("prompt_expansion_mode", "standard"); + payload.put("enable_base64_output", false); + } if (gptImageModel) { payload.put("enable_base64_output", false); payload.put("output_format", "jpeg"); @@ -140,10 +186,22 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ return StrUtil.isNotBlank(modelName) && modelName.startsWith("openai/gpt-image-"); } + static boolean isSeedream47TextToImage(String modelName) { + return SEEDREAM_47_TEXT_TO_IMAGE.equals(modelName); + } + + private static IllegalArgumentException seedream47ReferenceError() { + return new IllegalArgumentException("Seedream 4.7 文生图型号不接收参考图;请使用无参考图的新资产候选流程。身份绑定或多参考首帧需要另行选择支持参考图的编辑型号,不会自动切换模型。"); + } + /** 根据 AtlasCloud 模型将比例转换为该模型支持的像素格式。 */ static String resolveSize(String modelName, String size) { if (StrUtil.isBlank(size)) return null; String normalizedSize = size.trim(); + if (isSeedream47TextToImage(modelName)) { + String mapped = SEEDREAM_47_SIZE_MAP.get(normalizedSize); + return mapped != null ? mapped : resolveSeedream47PixelSize(normalizedSize); + } if (isGptImageModel(modelName)) { if (modelName.startsWith("openai/gpt-image-2/") || modelName.startsWith("openai/gpt-image-2.5-")) { if ("16:9".equals(normalizedSize)) return "1536x864"; @@ -159,6 +217,22 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ return SIZE_MAP.getOrDefault(normalizedSize, "1024*1024"); } + private static String resolveSeedream47PixelSize(String size) { + String[] dimensions = size.toLowerCase(java.util.Locale.ROOT).replace('x', '*').split("\\*", -1); + try { + if (dimensions.length != 2) throw new NumberFormatException(); + int width = Integer.parseInt(dimensions[0].trim()), height = Integer.parseInt(dimensions[1].trim()); + long pixels = (long) width * height; + if (width <= 0 || height <= 0 || pixels < 921600L || pixels > 16777216L + || (long) width > (long) height * 16L || (long) height > (long) width * 16L) { + throw new NumberFormatException(); + } + return width + "*" + height; + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Seedream 4.7 尺寸须为有效比例或WIDTH*HEIGHT:总像素921600–16777216,长宽比例在1:16至16:1之间"); + } + } + private static String resolveGptImagePixelSize(String size) { String[] dimensions = size.toLowerCase().replace('*', 'x').split("x"); if (dimensions.length != 2) return "1024x1024"; @@ -181,7 +255,7 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ String image = imageContext.getImage(); ObjectNode payload = buildPayload(chatModelVo, prompt, size, seed, image); - payload.put("enable_sync_mode", false); + if (!isSeedream47TextToImage(chatModelVo.getModelName())) payload.put("enable_sync_mode", false); applyReferenceImages(payload, imageContext); Request request = new Request.Builder() @@ -220,6 +294,7 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ static void applyReferenceImages(ObjectNode payload, ImageContext context) { var references = context.getReferenceImages(); if (references == null || references.isEmpty()) return; + if (isSeedream47TextToImage(context.getChatModelVo().getModelName())) throw seedream47ReferenceError(); int limit = context.getChatModelVo().getModelName().startsWith("openai/gpt-image-2.5-") ? 16 : 10; if (references.size() > limit) throw new IllegalArgumentException("当前模型最多绑定" + limit + "张参考图,请减少不必要道具"); if (!isGptImageModel(context.getChatModelVo().getModelName())) { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java index ef86e2aa..91916cbd 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java @@ -50,10 +50,10 @@ public class AtlasPredictionService { .build(); } if (!response.isSuccessful()) { - if (response.code() == 400 || response.code() == 422) { - MediaGenerationResponse terminal = terminalFailure(responseText, predictionId, mediaType(model.getCategory())); - if (terminal != null) return terminal; - } + // Music validation can be wrapped as HTTP 500 by the provider. A matching + // prediction id plus explicit failed status is terminal; unrelated errors are not. + MediaGenerationResponse terminal = terminalFailure(responseText, predictionId, mediaType(model.getCategory())); + if (terminal != null) return terminal; throw new IllegalArgumentException("Atlas Cloud生成结果查询失败: " + response.code() + " - " + responseText); } return toResponse(responseText, mediaType(model.getCategory())); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaService.java index f6bdf0e2..97c7496b 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/IShortDramaService.java @@ -42,8 +42,16 @@ public interface IShortDramaService { ShortDramaStoryboardVo saveStoryboard(ShortDramaStoryboardBo bo, Long userId); + ShortDramaStoryboardVo addStoryboard(Long projectId, Long scriptId, Long afterId, Long userId); + + void deleteStoryboard(Long storyboardId, Long userId); + ShortDramaStoryboardVo generateVideo(Long storyboardId, String videoModel, Long userId); + ShortDramaStoryboardVo generateVideo(Long storyboardId, String videoModel, Long userId, String requestId, boolean regenerate); + + org.ruoyi.service.shortdrama.impl.ShortDramaVideoSubmissionStore.Submission videoSubmission(Long storyboardId, String requestId, Long userId); + ShortDramaStoryboardVo retrieveVideo(Long storyboardId, String videoModel, Long userId); /** 代理流式输出视频,绕过 OSS 强制下载 header */ @@ -51,19 +59,31 @@ public interface IShortDramaService { List generateAllVideos(Long projectId, String videoModel, Long userId); - ShortDramaDetailVo analyzeAssets(Long projectId, Long scriptId, Long userId, String model); + List generateAllVideos(Long projectId, String videoModel, Long userId, Integer sceneStart, Integer sceneCount); - /** Phase 1: 剧本打磨,重新生成更丰富的剧本内容 */ - ShortDramaDetailVo polishScript(Long projectId, Long userId, String model); + ShortDramaDetailVo analyzeAssets(Long projectId, Long scriptId, Long userId, String model); + SseEmitter analyzeAssetsStream(Long projectId, Long scriptId, Long userId, String model, String requestId); + java.util.Map assetAnalysisStatus(Long projectId, Long scriptId, String requestId, Long userId); + + /** 按修改意见重新生成固定格式剧本 */ + ShortDramaDetailVo polishScript(Long projectId, Long userId, String instruction); /** Phase 3-6: 分镜规划+摄影规则+表演指导+分镜细化 */ List planStoryboard(Long projectId, Long scriptId, String model, Long userId); + List planStoryboard(Long projectId, Long scriptId, String model, Long userId, Integer minimumShotSeconds); /** Phase 3-6: 流式生成分镜并推送实时进度 */ SseEmitter planStoryboardStream(Long projectId, Long scriptId, String model, Long userId); + SseEmitter planStoryboardStream(Long projectId, Long scriptId, String model, Long userId, Integer minimumShotSeconds); + SseEmitter planStoryboardStream(Long projectId, Long scriptId, String model, Long userId, Integer minimumShotSeconds, String requestId); + java.util.Map storyboardPlanningStatus(Long projectId, Long scriptId, String requestId, Long userId); + java.util.Map storyboardCandidates(Long projectId, Long scriptId, String model, Long userId, Integer minimumShotSeconds); int importReviewedPlan(Long projectId, org.ruoyi.domain.bo.shortdrama.ShortDramaReviewedPlanBo review, Long userId); + java.util.Map storyboardCheckpointStatus(Long projectId, Long scriptId, String model, Long userId); + java.util.Map storyboardCheckpointStatus(Long projectId, Long scriptId, String model, Long userId, Integer minimumShotSeconds); + /** Phase 4: 重新生成摄影规则 */ List generatePhotographyRules(Long projectId, Long scriptId, Long userId); @@ -77,6 +97,7 @@ public interface IShortDramaService { ShortDramaCharacterVo generateCharacterImage(Long characterId, String imageModel, String referenceImageUrl, Long userId); ShortDramaLocationVo generateLocationImage(Long locationId, String imageModel, String referenceImageUrl, Long userId); + ShortDramaLocationVo generateLocationImage(Long locationId, String imageModel, String referenceImageUrl, String revisionRequirements, Long userId); Boolean deleteCharacter(Long characterId, Long userId); @@ -97,6 +118,7 @@ public interface IShortDramaService { ShortDramaCharacterAppearanceVo undoAppearanceImage(Long appearanceId, Long userId); ShortDramaLocationVo regenerateLocationImage(Long locationId, String imageModel, String referenceImageUrl, Long userId); + ShortDramaLocationVo regenerateLocationImage(Long locationId, String imageModel, String referenceImageUrl, String revisionRequirements, Long userId); ShortDramaLocationVo selectLocationImage(Long locationId, Integer index, Long userId); @@ -107,6 +129,14 @@ public interface IShortDramaService { /** 异步启动图片生成,返回 prediction 信息供前端轮询 */ MediaGenerationResponse startImageGeneration(String assetType, Long assetId, String model, String referenceImageUrl, Long userId); + /** Explicit identity revision produces an unselected candidate and retains approved continuity. */ + MediaGenerationResponse startImageGeneration(String assetType, Long assetId, String model, String referenceImageUrl, + String referencePurpose, String revisionRequirements, Long userId); + + MediaGenerationResponse startImageGeneration(String assetType, Long assetId, String model, String referenceImageUrl, + String referencePurpose, String revisionRequirements, + java.util.List styleReferenceImageUrls, Long userId); + /** 上传到图片模型供应商,返回仅用于当前生成会话的临时参考图 URL。 */ String uploadReferenceImage(MultipartFile file, String model, Long userId); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaAppearanceResolver.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaAppearanceResolver.java new file mode 100644 index 00000000..8faf05fc --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaAppearanceResolver.java @@ -0,0 +1,33 @@ +package org.ruoyi.service.shortdrama.impl; + +import java.util.List; +import java.util.Objects; +import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacterAppearance; + +/** Resolve explicit costume bindings identically for keyframes and video references. */ +final class ShortDramaAppearanceResolver { + private ShortDramaAppearanceResolver() { } + static ShortDramaCharacterAppearance resolve(List variants, String binding) { + if (variants == null || variants.isEmpty()) return null; + String key = binding == null ? "" : binding.strip(); + if (key.isEmpty() || key.equals("初始形象")) return variants.get(0); + for (var item : variants) if (key.equals(item.getChangeReason())) return item; + var display = java.util.regex.Pattern.compile("^\\[\\s*(\\d+)\\s*]\\s*(.*)$").matcher(key); + if (display.matches()) { + int index = Integer.parseInt(display.group(1)); + String name = display.group(2).strip(); + var indexed = variants.stream().filter(item -> Objects.equals(item.getAppearanceIndex(), index)).toList(); + if (indexed.size() == 1 && (name.isEmpty() || name.equals(indexed.get(0).getChangeReason()))) return indexed.get(0); + throw new IllegalArgumentException("角色形象编号和名称不匹配:" + key); + } + String numeric = key.replaceAll("^\\[\\s*|\\s*\\]$", ""); + if (numeric.matches("\\d+")) { + int index = Integer.parseInt(numeric); + for (var item : variants) if (Objects.equals(item.getAppearanceIndex(), index)) return item; + } + var matches = variants.stream().filter(item -> item.getChangeReason() != null + && item.getChangeReason().contains(key)).toList(); + if (matches.size() == 1) return matches.get(0); + throw new IllegalArgumentException("角色形象绑定无法唯一匹配:" + key + ",请使用已存在形象名称或编号"); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaAssetAesthetics.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaAssetAesthetics.java new file mode 100644 index 00000000..7d6bc3eb --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaAssetAesthetics.java @@ -0,0 +1,39 @@ +package org.ruoyi.service.shortdrama.impl; + +import cn.hutool.crypto.digest.DigestUtil; + +/** Shared aesthetic direction with separate identity, space, prop and actual-shot responsibilities. */ +final class ShortDramaAssetAesthetics { + private ShortDramaAssetAesthetics() {} + + private static final class Bundled { + static final String TEXT = ShortDramaDirectorSkills.read("asset-aesthetics"); + static final String VERSION = "asset-aesthetics-v1-" + DigestUtil.sha256Hex(TEXT).substring(0, 12); + } + + static String version() { return Bundled.VERSION; } + + private static String scope(String instruction) { + return "\n[asset-aesthetics:" + version() + "]\n" + + "【本次审美规范适用范围】\n" + instruction + + "审美增强服从用户画风、当前年代、身份、年龄、发展阶段及已批准连续性;只把已有内容的造型、材质与构图做清楚,不新增人物、装备、剧情成果、皮肤记号或豪华规模。\n"; + } + + static String characterReference() { + return scope("人物身份参照:清楚展示本张既定身份的脸形、年龄、发式和服饰裁片、层次、扣结、材质;多个视角是同一身份,不是多名演员。非人形主体按自身几何与材质审美,不套用人脸、服装或人体比例。"); + } + + static String locationReference() { + return scope("场景空间参照:使用本张地点规模、阶段、时点和明确陈设建立可读的空间层次、建筑构造与材质。保持本张无人物参照用途,不套用人物定妆图布局,不扩大贫困或建设初期地点,也不从总世界观加入未来物件。"); + } + + static String propReference(String artStyle, String worldbuilding) { + return scope("物品形制参照:单件已有道具,中性背景,结构、用途、接合方式、材质和尺度感清楚;只展示该物既有形制,不额外增加人物、另一种道具、装饰或可执行武器制造细节。") + + ShortDramaAssetWorldPrompt.style(artStyle) + ShortDramaAssetWorldPrompt.context(worldbuilding) + + ShortDramaAssetWorldPrompt.currentStateRules(); + } + + static String shotFrame() { + return scope("实际镜头首帧:身份图锁身份、已批准外观;空间图锁几何,当前镜头决定机位、景别、光色和0秒状态。只用一幅镜头构图,不套用多视角定妆图。严格保持起始动作、原文、登记演员与匿名群演人数、CG比例、持物、出入口及局部构图指令,不能为了好看提前演结果或复制演员。"); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaAssetGeneration.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaAssetGeneration.java new file mode 100644 index 00000000..7cb470e7 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaAssetGeneration.java @@ -0,0 +1,114 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import org.ruoyi.service.media.AtlasMediaSupport; +import java.util.*; +import java.util.function.BiConsumer; + +/** One structured completion. Previews are never authoritative database assets. */ +final class ShortDramaAssetGeneration { + record Result(List characters, List locations, List props) { + int size() { return characters.size() + locations.size() + props.size(); } + } + static String prompt(String script, String tone, String direction) { + return ShortDramaDirectorSkills.load("visual-world", "character-art-direction") + + ShortDramaAssetAesthetics.characterReference() + ShortDramaAssetAesthetics.locationReference() + + direction + "\n剧本风格:" + Objects.toString(tone, "") + "\n" + """ + 你是短剧资产制作负责人。一次完成角色、场景、道具提取及它们的出图描述,仅输出一个JSON对象。 + 只提取正文中实际出现、需要保持一致的资产,不补写剧情、分镜、摄影规则、运镜或视频指令。 + 同名角色和同一地点只列一次,场景描述不能带人物。不要把云、风、光线当成道具。 + 角色visualDescription须写清时代身份、年龄体态、五官发型、服装层次及颜色材质,便于独立出图。 + 场景descriptions写空间结构、环境、材料和光色;availableSlots是可用站位名。 + 道具description写形制、尺寸比例、材料、颜色、连接结构和识别特征,不写持有人或摄影指导。 + 组合器物保留完整主体,剧本明确提及且需要单独保持一致的部件也单列,名称不能与主体混淆。 + 逐项核对正文中的实体器物,不因已在主体描述中提到就省略独立道具。 + 具有操作、固定、承重或连接作用的绳索、绑带、扣件也是道具,不能归入普通衣饰而漏掉。 + 所有名称、描述使用中文,描述简洁具体,不复制整段剧本。没有道具时props为空数组。 + 字段格式如下(值按剧本填写,不把示例当实际资产): + {"characters":[{"name":"角色名","aliases":"","introduction":"身份与作用","roleLevel":"S", + "gender":"男","ageRange":"成年","personalityTags":"","costumeTier":2,"visualDescription":"独立角色出图描述"}], + "locations":[{"name":"场景名","summary":"场景用途","hasCrowd":false,"crowdDescription":"", + "availableSlots":["站位名"],"descriptions":["独立空场景出图描述"]}], + "props":[{"name":"道具名","description":"独立道具出图描述"}]} + 剧本正文: + """ + script; + } + static Result parse(String raw) { + if (raw == null) throw new IllegalStateException("资产分析没有返回正文"); + int start = raw.indexOf('{'), end = raw.lastIndexOf('}'); + if (start < 0 || end < start) throw new IllegalStateException("资产分析没有返回有效JSON,原资产保留"); + try { + JsonNode root = AtlasMediaSupport.OBJECT_MAPPER.readTree(raw.substring(start, end + 1)); + var characters = list(root, "characters", true, 30); + var locations = list(root, "locations", true, 40); + var props = list(root, "props", false, 30); + for (var item : characters) text(item, "visualDescription", 12000); + for (var item : locations) { + JsonNode descriptions = item.path("descriptions"); + if (!descriptions.isArray() || descriptions.isEmpty()) throw new IllegalStateException("场景缺少出图描述"); + for (var description : descriptions) + if (!description.isTextual() || description.asText().isBlank() || description.asText().length() > 12000) + throw new IllegalStateException("场景出图描述无效"); + } + for (var item : props) text(item, "description", 12000); + return new Result(characters, locations, props); + } catch (java.io.IOException e) { throw new IllegalStateException("资产清单JSON不完整,原资产保留", e); } + } + private static List list(JsonNode root, String field, boolean required, int maximum) { + JsonNode array = root.path(field); + if (!array.isArray() || (required && array.isEmpty()) || array.size() > maximum) + throw new IllegalStateException(field + "清单无效,原资产保留"); + var unique = new LinkedHashMap(); + for (var item : array) { + String name = text(item, "name", 80); + // Validate every original item before de-duplicating, including repeated names. + if ("characters".equals(field)) text(item, "visualDescription", 12000); + if ("props".equals(field)) text(item, "description", 12000); + unique.putIfAbsent(name.toLowerCase(Locale.ROOT), item); + } + return List.copyOf(unique.values()); + } + private static String text(JsonNode item, String field, int maximum) { + JsonNode value = item.path(field); + if (!value.isTextual() || value.asText().isBlank() || value.asText().length() > maximum) + throw new IllegalStateException("资产字段" + field + "缺少有效内容"); + return value.asText().trim(); + } + /** Scan each incoming character once; nested arrays and quoted braces do not end an asset. */ + static final class Preview { + private final BiConsumer sink; + private final StringBuilder buffer = new StringBuilder(), string = new StringBuilder(); + private int objects, arrays, start = -1, category; + private boolean quoted, escape; + private String lastString = ""; + Preview(BiConsumer sink) { this.sink = sink; } + synchronized void accept(String chunk) { + if (buffer.length() + chunk.length() > 500_000) return; + for (char c : chunk.toCharArray()) { + int index = buffer.length(); buffer.append(c); + if (quoted) { + if (escape) { escape = false; string.append(c); } + else if (c == '\\') { escape = true; string.append(c); } + else if (c == '"') { quoted = false; lastString = string.toString(); } + else string.append(c); + continue; + } + if (c == '"') { quoted = true; string.setLength(0); } + else if (c == '[') { + if (objects == 1 && arrays == 0) + category = switch (lastString) { case "characters" -> 1; case "locations" -> 2; case "props" -> 3; default -> 0; }; + arrays++; + } else if (c == ']') { arrays--; if (arrays == 0) category = 0; } + else if (c == '{') { if (objects == 1 && arrays == 1 && category != 0) start = index; objects++; } + else if (c == '}') { + objects--; + if (objects == 1 && arrays == 1 && category != 0 && start >= 0) { + try { sink.accept(category, AtlasMediaSupport.OBJECT_MAPPER.readTree(buffer.substring(start, index + 1))); } + catch (java.io.IOException ignored) { /* Full response validation decides success. */ } + start = -1; + } + } + } + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaAssetWorldPrompt.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaAssetWorldPrompt.java new file mode 100644 index 00000000..72db7a71 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaAssetWorldPrompt.java @@ -0,0 +1,50 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.ruoyi.constant.ShortDramaImageConstants; + +/** World rules constrain an asset's era/materials; they are not a list of things to add to its image. */ +final class ShortDramaAssetWorldPrompt { + private ShortDramaAssetWorldPrompt() {} + + static String context(String worldbuilding) { + if (worldbuilding == null || worldbuilding.isBlank()) return ""; + return "\n【当前项目世界观:年代、材料与发展阶段上下文】\n" + worldbuilding + + "\n【上下文使用边界】以上提供年代、材料可获得性、资源规模与阶段因果约束,不是本图物件清单。" + + "只画下面本张资产描述确定的当前时刻;世界观中的后续发展、未来装备、其他年代人物与计划成果不自动进入本图。" + + "本张描述决定当前可见物件与阶段,未写已建成、已成熟或已引进的内容保持未发生,不从总纲补入。\n"; + } + + static String style(String artStyle) { + String style = artStyle == null ? ShortDramaImageConstants.DEFAULT_ART_STYLE : artStyle; + return ShortDramaImageConstants.artStylePrompt(style) + ("chinese-3d".equals(style) + ? "\n【可见三维动画造型】明确可辨的风格化CG雕塑造型,轮廓与体块有设计,清晰体面、受控色面和材质层次,动画电影的三维布景与角色渲染;" + + "保持描述中的身份比例与年龄,不靠夸大五官改脸。发束成组设计,布料细节服从CG造型,皮肤使用受控色阶与克制散射;" + + "不用仿真人数字人的摄影皮肤、逐根写真发丝或真人棚拍来假扮三维动画,也不变成二维平涂、塑料玩偶。\n" + : ""); + } + + static String currentStateRules() { + return "\n【本张年代与当前状态】服装、建筑和物件按本张所处年代及明确剧情依据实现;世界观若包含多个年代,不混用其他时点的服装和材料。" + + "前现代时点且没有明确的已引进依据时,不添现代塑料薄膜、塑料育苗盘、工业玻璃棚或西式翻领衬衫。" + + "贫困或建设初期的空间保持描述中的小规模与未完成状态,不为了电影感增加繁华都市、巨大水面、豪华铺装或已竣工的壮阔城防。" + + "作物种类、生长阶段、叶片数量和密度遵从本张描述;苗床、幼苗与初次试种不能画成成熟稻穗、满田丰收或提前长成的作物。" + + "默认已批准参考的身份与衣物仍保持;发现其与时代约束冲突须明确修订,不以年代规则擅自换脸换衣。\n"; + } + + static String location(String description, String artStyle, String worldbuilding) { + return location(null, description, artStyle, worldbuilding); + } + + static String location(String name, String description, String artStyle, String worldbuilding) { + String timeAndPlace = name == null || name.isBlank() ? "" : "【本张场景名称与时点】\n" + name + + "\n名称中的年代、季候与昼夜是当前图的时间约束,描述中未重复时也保留;不把深夜画成白昼窗景,不从名称猜测额外建筑或陈设。\n"; + return ShortDramaDirectorSkills.media("visual-world") + + ShortDramaAssetAesthetics.locationReference() + + "【用途:当前阶段的无人物空间参照图】\n" + style(artStyle) + context(worldbuilding) + + timeAndPlace + + "【本张场景实际描述】\n" + (description == null ? "" : description) + + "\n按上述场景实际规模建立完整空间构图,入口、窗、工作区和主要陈设清楚;不靠扩大城镇、河湖或建筑尺度制造宏大感。" + + ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + "。牌匾、城名、招牌与可读文字不凭空创造,需精确文字留给后期。" + + currentStateRules(); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaBackgroundExtras.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaBackgroundExtras.java new file mode 100644 index 00000000..0f7322b6 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaBackgroundExtras.java @@ -0,0 +1,112 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacter; +import org.ruoyi.service.shortdrama.impl.ShortDramaServiceImpl.StoryboardPanelData; +import java.util.*; +import java.util.regex.Pattern; + +/** Anonymous extras are a first-frame staging description, never an asset identity or speaker replacement. */ +final class ShortDramaBackgroundExtras { + private ShortDramaBackgroundExtras() {} + private static final Pattern COUNT = Pattern.compile("^0(?:秒|s)\\s*[::]\\s*可见人数\\s*[=::]\\s*(\\d+)(?:\\s*[-–—~~至]\\s*(\\d+))?\\s*[;;]"); + // Role/category vocabulary, rather than a list of identities from any particular drama. + // A generic occupation alias does not uniquely identify its registered foreground representative. + private static final String CATEGORY = "军队|敌军|守军|援军|骑兵|步兵|乱兵|溃兵|散兵|兵卒|军士|士兵|军群|群众|百姓|村民|乡亲|路人|行人|人群|众人|同伴|差役|衙役|工匠|宾客|队伍|护卫|兵士|乡勇|徒弟|学徒|学生|工人|农民|农户|商人|顾客|乘客|医护人员|护士|医生|警员|警察|记者|店员|伙计|侍卫|侍从|仆人|仆役|富户|宫女|老人|老汉|少年|少女|儿童"; + private static final Pattern GROUP = Pattern.compile(CATEGORY); + private static final Pattern GENERIC_NAME = Pattern.compile("^(?:(?:前景|后景|年轻|年老|老|小|普通|匿名|无名|一名|领队的|排首的|带头的|男|女))*(?:" + CATEGORY + ")$"); + static boolean genericRole(String name) { return name != null && GENERIC_NAME.matcher(name.trim()).matches(); } + static String readText(com.fasterxml.jackson.databind.JsonNode continuity) { + var value = continuity.path("background_extras"); + if (!value.isMissingNode() && !value.isNull() && !value.isTextual()) + throw new IllegalArgumentException("background_extras必须为文字字段"); + return value.asText(""); + } + record FirstFrame(int minimum, int maximum, String text) { boolean visible() { return maximum > 0; } } + static FirstFrame parse(String text) { + if (text == null || text.isBlank()) return new FirstFrame(0, 0, ""); + if (text.length() > 4000) throw new IllegalArgumentException("background_extras不能超过4000字符"); + var count = COUNT.matcher(text.trim()); + if (!count.find()) throw new IllegalArgumentException("background_extras须以0秒:可见人数=整数或范围;开头"); + int min, max; + try { min = Integer.parseInt(count.group(1)); max = count.group(2) == null ? min : Integer.parseInt(count.group(2)); } + catch (NumberFormatException e) { throw new IllegalArgumentException("匿名群演首帧人数超出可用范围", e); } + if (min < 0 || max < min || max > 1_000_000) throw new IllegalArgumentException("匿名群演首帧人数或范围无效"); + if (Pattern.compile("(?i)character[_-]?id|appearance[_-]?id|asset[_-]?id|角色\\s*ID|@image").matcher(text).find()) + throw new IllegalArgumentException("匿名群演不能使用角色ID或具名角色参考图"); + // An explicit zero count describes an empty first-frame extras roster. + // There is no visible person whose costume or staging needs validation. + if (max == 0) return new FirstFrame(0, 0, text.trim()); + for (String field : List.of("身份服饰", "位置", "姿态动作")) { + if (fieldValue(text, field).isBlank()) throw new IllegalArgumentException("background_extras缺少" + field); + } + if (!text.contains("匿名") && !text.contains("无名")) throw new IllegalArgumentException("background_extras必须明确匿名群演,不创建命名角色"); + return new FirstFrame(min, max, text.trim()); + } + static String frameGuidance(String text) { + FirstFrame extras = parse(text); + if (!extras.visible()) return "0秒匿名群演可见人数=0,不提前画入之后才入场的人物。"; + return extras.text() + "\n以上只定义0秒静态人数、位置和姿态;不提前执行后续入场、攀登完成或冲锋结果。匿名群演保持不同脸型与服饰细节,不能复用任何具名角色的身份参考。"; + } + static void validateScene(String scene, List registered, List panels) { + String anonymousSource = Objects.toString(scene, "").replaceAll("「[^」]*」", ""); + for (var c : registered) { + if (c.getName() != null) { + // Remove an explicit representative, but preserve category plurals such as “徒弟们”. + anonymousSource = removeRepresentative(anonymousSource, c.getName()); + } + for (String alias : Objects.toString(c.getAliases(), "").split("[,,、;;\\n]")) + if (uniqueAlias(alias)) anonymousSource = anonymousSource.replace(alias.trim(), ""); + } + boolean sourceHasGroup = GROUP.matcher(anonymousSource).find(); + for (var panel : panels) { + String text = panel.getBackgroundExtras(); + if (!parse(text).visible()) continue; + if (!sourceHasGroup) throw new ShortDramaSceneCheckpoint.BindingMismatch("本场原文未安排匿名群演,不能借background_extras增加人物"); + String identity = fieldValue(text, "身份服饰"); + String staging = fieldValue(text, "位置") + ";" + fieldValue(text, "姿态动作"); + Set visibleNamed = new HashSet<>(panel.getPresentCharacters() == null ? List.of() : panel.getPresentCharacters()); + for (var c : registered) { + if (c.getName() != null && !genericRole(c.getName())) checkIdentityReference(c.getName(), c.getName(), identity, staging, visibleNamed); + for (String alias : Objects.toString(c.getAliases(), "").split("[,,、;;\\n]")) + if (uniqueAlias(alias)) checkIdentityReference(c.getName(), alias.trim(), identity, staging, visibleNamed); + } + } + } + private static void checkIdentityReference(String canonical, String name, String identity, String staging, Set visibleNamed) { + if (identity.contains(name)) throw new ShortDramaSceneCheckpoint.BindingMismatch("background_extras不能隐藏具名角色或CG:" + canonical + "(" + name + ")"); + // Position/anti-cloning instructions may refer to an already visible registered foreground + // actor. They cannot turn an absent/later actor or CG into a zero-second physical landmark. + if (staging.contains(name) && !visibleNamed.contains(canonical)) + throw new ShortDramaSceneCheckpoint.BindingMismatch("background_extras定位参照未在0秒具名名册中:" + canonical + "(" + name + ")"); + } + private static String fieldValue(String text, String field) { + var value = Pattern.compile(Pattern.quote(field) + "\\s*[=::]([^;;\\n]*)").matcher(text); + return value.find() ? value.group(1).trim() : ""; + } + private static boolean uniqueAlias(String alias) { + String value = alias.trim(); + return value.length() >= 2 && !value.equals("无") && !genericRole(value); + } + private static String removeRepresentative(String source, String name) { + var matcher = Pattern.compile(Pattern.quote(name)).matcher(source); + StringBuffer result = new StringBuffer(); + while (matcher.find()) { + String before = source.substring(Math.max(0, matcher.start() - 30), matcher.start()); + String after = source.substring(matcher.end(), Math.min(source.length(), matcher.end() + 3)); + boolean plural = genericRole(name) && (after.matches("^(?:们|群|队).*") + || before.matches("(?s).*(?:[二两三四五六七八九十百千万]+|[2-9]|[1-9][0-9]+)[名个位户][^。;;\\n]{0,18}") + || before.matches("(?s).*(?:其余|其他|一群|众多|数名|几名|多名|全体|一些|一队)[^。;;\\n]{0,18}")); + matcher.appendReplacement(result, plural ? java.util.regex.Matcher.quoteReplacement(name) : ""); + } + matcher.appendTail(result); return result.toString(); + } + static String promptRules() { + return "\n【登记身份与匿名群演:最高优先级】characters、present_characters、performance_beats.name只允许当前原文明确安排的精确登记角色名,包含明确可见的必要CG;全剧角色库与上一场名册不是本场出演许可。" + + "原对白画外声保留原说话人/source_text与声源,不为画外声强画实体;同伴、乱兵、溃兵、百姓等未登记匿名群演绝不能自造群像角色名、别名、ID或借用某个头目的身份。" + + "匿名群演在description、起止状态中写可见动作,且用单个background_extras文字字段明确0秒人数、身份服饰、位置和静态姿态;具名/登记演员名单与匿名群演分开,不要求每个可见人头都成为角色资产。" + + "字段格式:0秒:可见人数=6-8;身份服饰=匿名乱兵,粗布军装、不同脸型;位置=梯架两侧;姿态动作=躬身扶梯,尚未登顶。" + + "无匿名群演时字段为空;后续才入场时0秒人数写0,后续动作放video_prompt和story_action。身份服饰段禁止把具名、声源或CG藏入匿名身份;位置/姿态可引用present_characters中0秒已可见的登记演员作为定位,引用不产生新的演员。差役、乡勇、徒弟等职业泛称可同时用于登记前景代表与其余匿名同类,泛称不是每个人的唯一姓名。\n" + + "【单镜地点精确绑定】每镜location只能逐字选择一条真实登记场景名,不自造、缩写、加后缀或把两个名字用及/和/斜杠合并。" + + "原文多地点标题只是本场预算说明,逐镜按当前主体所在空间选择对应资产;城头与城外郊野分别绑定各自地点,必要时拆镜并做桥接,同一原文预算场编号保持。\n"; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaCharacterArtPrompt.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaCharacterArtPrompt.java new file mode 100644 index 00000000..ab4c40dd --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaCharacterArtPrompt.java @@ -0,0 +1,109 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.ruoyi.constant.ShortDramaImageConstants; + +/** Shared casting-reference rules for synchronous, asynchronous and edit image requests. */ +final class ShortDramaCharacterArtPrompt { + // Only an explicit statement about the subject's anatomy selects this branch; decorative animal motifs do not. + private static final java.util.regex.Pattern NON_HUMAN_SUBJECT = java.util.regex.Pattern.compile( + "(?:^|[。;;,,::\\n])\\s*(?:(?:主体|本体|该角色|其)(?:明确)?(?:为|是)?\\s*)?" + + "(?:(?:无|没有|不具备|不具有|不含)(?:常规)?(?:人类|人形)(?:的)?(?:五官|躯体|身体)" + + "|(?:外观|形态|主体)(?:明确)?(?:为|是|呈)?非人形|非人形(?:虚拟)?(?:主体|角色|实体))"); + + private ShortDramaCharacterArtPrompt() {} + + static String reference(String description, String artStyle) { + return reference(description, artStyle, null, null); + } + + /** An explicit revision uses the supplied image for identity, not for the rejected wardrobe/material. */ + static String reference(String description, String artStyle, String referencePurpose, String revisionRequirements) { + return reference(description, artStyle, referencePurpose, revisionRequirements, null); + } + + static String reference(String description, String artStyle, String referencePurpose, String revisionRequirements, String worldbuilding) { + boolean revision = isRevision(referencePurpose, revisionRequirements); + String style = artStyle == null ? ShortDramaImageConstants.DEFAULT_ART_STYLE : artStyle; + String subjectDescription = description == null ? "" : description; + // Portraits are independent casting masters, not a small inset in a full-body sheet. + boolean portraitReference = subjectDescription.matches("(?s).*(?:单幅|单张)(?:头肩|面部|脸部|胸像)(?:面部)?(?:母版|定妆|肖像|特写).*" ); + boolean singleReference = subjectDescription.matches("(?s).*(?:单幅(?:角色|人物|定妆|全身)|单张(?:角色|人物|定妆|全身)|单人\\s*3/4\\s*全身|(?:不要|不做|不用)三视图).*" ); + if (NON_HUMAN_SUBJECT.matcher(subjectDescription).find()) { + return nonHumanReference(subjectDescription, style, worldbuilding) + revisionRules(revision, revisionRequirements, true); + } + String material = switch (style) { + case "custom-skill" -> "【风格约束】人物造型、肤质、毛发、衣物材质和光色全部服从选中审美技能正文,不从旧基础画风强加真人写真、二维平涂或国风三维预设;身份、年龄和服装裁片仍服从本张实际描述。\n"; + case "realistic" -> "【写实身份参照材质】自然未磨皮的成人肤质,细微毛孔、正常细小汗毛和年龄痕迹,眼球湿润但不过度放大,发际线与发束自然;衣料织理、接缝、厚薄和受力褶皱可辨。维护程度及磨损位置以角色描述为准,不统一加旧、加脏或新增皮肤记号。柔和中性光保留体积和肌肤中间调,避免美颜滤镜、塑料皮肤和电商假发感。\n"; + case "chinese-3d" -> "【三维动画身份参照材质】采用国风动画电影的风格化CG雕塑造型,面部有清晰的体面、轮廓和受控色阶,一看就是三维动画角色;自然东方面部结构与身体比例,CG发束和毛发、PBR布料织理与接缝,发束成组且有设计,衣纹服从建模体块,厚薄和受力褶皱可辨;受控次表面散射与柔和中性体积光塑造动画皮肤,保留立体明暗和材质层次,不出现摄影毛孔或真人实拍肤质,不使用仿真人数字人写真、逐根摄影发丝或真人棚拍,不用二维线稿或平涂表达材质,不做塑料玩偶或油亮蜡像。年龄、服装裁片、配饰及已有标志以角色描述为准,不自动美化或增加污损。\n"; + default -> "【风格约束】严格采用项目指定画风,脸部、毛发和衣料细节用该画风的线条与上色表达;不混入真人肤质或摄影景深。\n"; + }; + return ShortDramaDirectorSkills.media("visual-world", "character-art-direction") + + ShortDramaAssetAesthetics.characterReference() + + "【用途:身份参照图,不是剧情首帧】\n" + + ShortDramaAssetWorldPrompt.style(style) + "\n" + material + + ShortDramaAssetWorldPrompt.context(worldbuilding) + + "【本张角色当前形象描述】\n" + + (portraitReference ? "【本张构图优先】单幅头肩面部母版:只画一位人物,从完整头发帽饰至肩胸;脸部为视觉中心,双眼、眉毛、鼻唇边缘与发际线清楚,头脸占比服从本张描述。通用完整服装规则仅用于全身定妆,本张不附全身小人、不拼接三视图、不加文字标签。" + : singleReference ? "单幅身份定妆图:一个完整全身人物,自然静态3/4姿势,完整头发帽饰与双脚在画内,中性简洁背景,清晰脸形、服装裁片及原描述中的既有配饰;不做多视角拼图,不复制成多个演员,不加文字标签。" : ShortDramaImageConstants.CHARACTER_PROMPT_PREFIX) + + subjectDescription + + (portraitReference || singleReference ? "。本张只画一个当前身份,不增加第二视角;脸部、年龄、发际线、衣物和左右标志按本轮描述与批准约束落实。" : ShortDramaImageConstants.CHARACTER_PROMPT_SUFFIX) + + (portraitReference || singleReference ? "" : revision + ? "本候选的脸部特写与三个全身视角必须同一张脸、同一发际线;各视角采用本轮修订后的同一服装裁片、配饰和左右侧标志。主角身份不自动增加颜值或服装华丽度。\n" + : "脸部特写与三个全身视角必须同一张脸、同一发际线、同一服装裁片、同一配饰与左右侧标志。主角身份不自动增加颜值或服装华丽度。") + + ("chinese-3d".equals(style) + ? "\n【材质优先级】项目国风三维动画画风优先于描述或参考图里的‘写实肤质、细微毛孔、真人写真’等材质表述;这些仅可提供脸部比例和年龄线索,须重建为有体积的三维动画皮肤、CG发束与PBR布料,不复制摄影皮肤纹理。\n" + : "") + + ShortDramaAssetWorldPrompt.currentStateRules() + + revisionRules(revision, revisionRequirements, false); + } + + static boolean isRevision(String referencePurpose, String revisionRequirements) { + String purpose = referencePurpose == null || referencePurpose.isBlank() ? "identity" : referencePurpose.trim(); + if (!"identity".equals(purpose) && !"identity_revision".equals(purpose)) { + throw new IllegalArgumentException("不支持的参考用途: " + purpose); + } + boolean revision = "identity_revision".equals(purpose); + if (revision && (revisionRequirements == null || revisionRequirements.isBlank())) { + throw new IllegalArgumentException("修订候选必须填写明确的修订要求"); + } + if (!revision && revisionRequirements != null && !revisionRequirements.isBlank()) { + throw new IllegalArgumentException("修订要求须配合 referencePurpose=identity_revision 使用"); + } + if (revisionRequirements != null && revisionRequirements.length() > 4000) { + throw new IllegalArgumentException("修订要求不能超过4000字符"); + } + return revision; + } + + private static String revisionRules(boolean revision, String requirements, boolean nonHuman) { + if (!revision) return ""; + String identity = nonHuman + ? "参考图只绑定主体的核心身份与未指定修改的形态辨识点;材质与明确指定的结构修正按本轮要求执行,不添加人体结构。" + : "参考图只绑定脸部身份:脸型、眉眼鼻口比例、年龄、发际线及未指定修改的辨识点;不绑定参考图的摄影皮肤材质或被本轮明确纠正的服装。"; + String roundNeck = !nonHuman && requirements.contains("圆领") + ? "\n【圆领可见几何】本轮指定圆领时,正面领口为绕颈闭合的圆弧,左右领缘不交叉,前胸没有斜向重叠的V形交领线;用正面及侧面视角清楚展示闭合圆领的领缘和接缝。" + : ""; + return "\n【用途:身份绑定的修订候选,尚未批准连续性】\n" + identity + + "只修改本轮明确列出的材质、服装或结构,未指定的身份特征、配饰及左右标志仍保持。世界规范中的默认服装锁适用于已批准参考;本轮指定修订项以以下要求为准,不沿用被拒参考的旧裁片。新候选各视角保持同一修订结果。\n" + + "【本轮明确修订要求】\n" + requirements.trim() + roundNeck + + "\n此图仅作为待审候选,须人工审阅选择后才能成为后续镜头的批准连续性参考。"; + } + + private static String nonHumanReference(String description, String style, String worldbuilding) { + // Human casting/makeup rules do not apply to a subject explicitly declared to have no human anatomy. + String stylePrompt = switch (style) { + case "chinese-3d" -> "国风电影级三维动画,Chinese donghua cinematic 3D animation;CG造型和PBR材质,克制的次表面散射、透明度和反射以描述为准,柔和中性体积光保留立体边缘与内部层次,不使用真人摄影实拍、二维赛璐璐平涂或黑色漫画线稿。"; + case "realistic" -> "影视写实的非人形主体材质参照,真实形体与表面质感,可解释的柔和中性光源和丰富中间调;材质、反射、透明度及维护状态服从描述,不增加人体结构。"; + case "chinese-comic" -> "现代国漫动画风格,Chinese donghua 2D comic style,赛璐璐平涂上色,干净锐利的黑色线稿、平面化光影与清晰形态边缘;不使用真人摄影、3D渲染或景深虚化。"; + default -> ShortDramaImageConstants.artStylePrompt(style); + }; + return ShortDramaDirectorSkills.media("visual-world") + + ShortDramaAssetAesthetics.characterReference() + + "【用途:非人形主体形态参照图,不是剧情首帧】\n" + + "世界规范中涉及人体身份与妆造的通用条款仅适用于具备相应结构的主体;本图采用以下专用形态参照规范。\n" + + stylePrompt + "\n" + ShortDramaAssetWorldPrompt.context(worldbuilding) + + "单一主体的中性背景多角度形态参照:主视图展示完整形态,其余视图展示同一主体的正向、侧向、背向或其他有辨识价值的角度,附一处关键材质或几何特征近景。所有角度保持同一结构、相对尺度、颜色、材质和已有视觉锚;不自行增添人体结构或拟人化表情。\n" + + "【主体原始描述】" + description + "\n" + + "完整保留描述中的轮廓、棱角、悬浮状态、透明度、内部纹理、发光强弱与相对尺度。纯白或浅灰中性背景,无文字标签,不添加其他角色、道具、环境或剧情表演,也不用另一人物作尺寸对照。"; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaCharacterVoiceService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaCharacterVoiceService.java new file mode 100644 index 00000000..8b22c2fb --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaCharacterVoiceService.java @@ -0,0 +1,258 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import okhttp3.*; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.entity.audio.AudioContext; +import org.ruoyi.common.chat.service.chat.IChatModelService; +import org.ruoyi.domain.entity.shortdrama.*; +import org.ruoyi.mapper.shortdrama.*; +import org.ruoyi.service.audio.provider.AtlasSpeechPayloadBuilder; +import org.ruoyi.service.media.AtlasMediaSupport; +import org.ruoyi.service.media.AtlasPredictionService; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** Versioned role-owned samples. No invented provider voiceId, no automatic replacement of a selected voice. */ +@Service @RequiredArgsConstructor +public class ShortDramaCharacterVoiceService { + public static final String MODEL = "bytedance/seed-audio-1.0"; + private final ShortDramaSoundService sounds; + private final ShortDramaCharacterMapper characters; + private final ShortDramaCharacterAppearanceMapper appearances; + private final ShortDramaStoryboardMapper storyboards; + private final IChatModelService models; + private final AtlasPredictionService predictions; + private final Map roleLocks = new java.util.concurrent.ConcurrentHashMap<>(); + private Object lock(Long project,Long character) {return roleLocks.computeIfAbsent(project+":"+character,key -> new Object());} + private final OkHttpClient http = new OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(180, TimeUnit.SECONDS).retryOnConnectionFailure(false).followRedirects(false).followSslRedirects(false).build(); + + public record Settings(String description, String sampleText) {} + public record Sample(String id, String label, String model, String predictionId, String source, double duration, long createdAt) {} + public record Profile(String characterId, String characterName, String description, String sampleText, + String selectedSampleId, int version, List samples) {} + public record Generate(String requestId, String model, String description, String sampleText, String referenceSampleId) {} + public record Job(String id, Generate request, String predictionId, String status, String error, String outputUrl, long createdAt) {} + public record View(Profile profile, List jobs) {} + public record Selection(String sampleId) {} + public record SpeakersRequest(List characterIds) {} + public record Binding(String characterId, String characterName, String sampleId, int version, double duration, int audioIndex) {} + public record Plan(List bindings, List references, String direction, List issues, boolean explicit, List speakerIds) { + public List> fingerprints() {return references.stream().map(ShortDramaSoundService.Reference::fingerprint).toList();} + } + private Path directory(Long project, Long character) {return Path.of("data", "short-drama-sounds", project.toString(), "character-voices", character.toString()).toAbsolutePath();} + private Path profileFile(Long project, Long character) {return directory(project, character).resolve("profile.json");} + private Path jobFile(Long project, Long character, String id) {return directory(project, character).resolve(uuid(id) + ".json");} + private Path sampleFile(Long project, Long character, String id) {return directory(project, character).resolve(uuid(id) + ".mp3");} + private static String uuid(String id) { + try {String canonical = UUID.fromString(id).toString(); if (!canonical.equalsIgnoreCase(id)) throw new ShortDramaVoiceException("样音任务ID必须是完整UUID"); return canonical;} + catch (Exception e) {throw new ShortDramaVoiceException("样音任务ID必须是完整UUID");} + } + private ShortDramaCharacter owner(Long project, Long character, Long user) { + sounds.owner(project, user);var actor = characters.selectById(character); + if (actor == null || !project.equals(actor.getProjectId())) throw new ShortDramaVoiceException("角色不属于当前项目"); + return actor; + } + private List cast(Long project) {return characters.selectList(new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId,project).orderByAsc(ShortDramaCharacter::getId));} + private Profile read(Long project, ShortDramaCharacter actor) throws Exception { + Path file = profileFile(project, actor.getId()); + if (Files.exists(file)) return AtlasMediaSupport.OBJECT_MAPPER.readValue(file.toFile(), Profile.class); + String legacy = appearances.selectList(new LambdaQueryWrapper().eq(ShortDramaCharacterAppearance::getCharacterId,actor.getId())) + .stream().map(ShortDramaCharacterAppearance::getVoice).filter(v -> v != null && !v.isBlank()).findFirst().orElse(""); + return new Profile(actor.getId().toString(), actor.getName(), legacy.isBlank() ? String.join(",", Objects.toString(actor.getGender(),""), Objects.toString(actor.getAgeRange(),"")) : legacy, + "你先听我把话说完。", null, 0, List.of()); + } + private void write(Path file, Object value) throws Exception { + Files.createDirectories(file.getParent());Path temporary = Files.createTempFile(file.getParent(), "voice-", ".tmp"); + try {AtlasMediaSupport.OBJECT_MAPPER.writeValue(temporary.toFile(), value); + try {Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);} + catch (AtomicMoveNotSupportedException e) {Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING);} + } finally {Files.deleteIfExists(temporary);} + } + public View get(Long project, Long character, Long user) throws Exception {synchronized(lock(project,character)){return getLocked(project,character,user);}} + private View getLocked(Long project, Long character, Long user) throws Exception { + var actor = owner(project,character,user);List jobs = new ArrayList<>();Path dir=directory(project,character); + if (Files.isDirectory(dir)) try (var files=Files.list(dir)) { + for (Path file : files.filter(p -> p.getFileName().toString().matches("[a-f0-9-]{36}\\.json")).toList()) { + var job=AtlasMediaSupport.OBJECT_MAPPER.readValue(file.toFile(),Job.class); + if ("submitting".equals(job.status()) && System.currentTimeMillis()-job.createdAt()>210000) + job=new Job(job.id(),job.request(),job.predictionId(),"submission_unknown","提交结果未知,请核对上游记录;未自动重试",job.outputUrl(),job.createdAt()); + jobs.add(job); + } + } + jobs.sort(Comparator.comparingLong(Job::createdAt).reversed());return new View(read(project,actor),List.copyOf(jobs)); + } + static void validate(Settings settings) { + if (settings==null || settings.description()==null || settings.description().isBlank() || settings.description().length()>1500 + || settings.sampleText()==null || settings.sampleText().isBlank() || settings.sampleText().length()>80) + throw new ShortDramaVoiceException("请填写声音描述(最多1500字)与简短样音正文(最多80字)"); + } + public Profile settings(Long project,Long character,Long user,Settings settings) throws Exception {synchronized(lock(project,character)){return settingsLocked(project,character,user,settings);}} + private Profile settingsLocked(Long project,Long character,Long user,Settings settings) throws Exception { + validate(settings);var old=read(project,owner(project,character,user));var updated=new Profile(old.characterId(),old.characterName(),settings.description().trim(),settings.sampleText().trim(),old.selectedSampleId(),old.version(),old.samples()); + write(profileFile(project,character),updated);return updated; + } + private ChatModelVo model(String name) { + var model=models.selectModelByName(name); + if (model==null || !MODEL.equals(name) || !"audio".equals(model.getCategory()) || !"atlas".equalsIgnoreCase(model.getProviderCode())) + throw new ShortDramaVoiceException("请在模型配置中启用 Atlas Seed Audio 1.0"); + return model; + } + static String prompt(Generate request) { + return "生成单人中文角色样音。只朗读正文,不能读说明、角色名或标签,不增加对白,不加音乐和其他说话人。声音设定:" + + request.description() + (request.referenceSampleId()!=null && !request.referenceSampleId().isBlank() ? "。保持 @audio1 的同一人音色,情绪按声音设定表演。" : "。") + + "\n正文:" + request.sampleText(); + } + public Job generate(Long project,Long character,Long user,Generate request) throws Exception {synchronized(lock(project,character)){return generateLocked(project,character,user,request);}} + private Job generateLocked(Long project,Long character,Long user,Generate request) throws Exception { + var actor=owner(project,character,user);if(request==null)throw new ShortDramaVoiceException("缺少请求");uuid(request.requestId());validate(new Settings(request.description(),request.sampleText()));var model=model(request.model()); + Path file=jobFile(project,character,request.requestId()); + if (Files.exists(file)) {var existing=AtlasMediaSupport.OBJECT_MAPPER.readValue(file.toFile(),Job.class);if(!existing.request().equals(request))throw new ShortDramaVoiceException("任务ID已用于不同样音,请创建新任务");return existing;} + List> references=List.of(); + if(request.referenceSampleId()!=null && !request.referenceSampleId().isBlank()) { + sample(read(project,actor),request.referenceSampleId()); + references=List.of(Map.of("audioData",Base64.getEncoder().encodeToString(Files.readAllBytes(sampleFile(project,character,request.referenceSampleId()))))); + } + var payload=AtlasSpeechPayloadBuilder.build(AudioContext.builder().chatModelVo(model).input(prompt(request)).references(references).responseFormat("mp3").sampleRate(24000).build()); + var initial=new Job(request.requestId(),request,null,"submitting","",null,System.currentTimeMillis()); + // A durable claim precedes the only paid POST; reuse of a UUID never resubmits. + Files.createDirectories(file.getParent());Files.writeString(file,AtlasMediaSupport.OBJECT_MAPPER.writeValueAsString(initial),StandardOpenOption.CREATE_NEW); + var post=new Request.Builder().url(AtlasMediaSupport.endpoint(model.getApiHost(),"/model/generateAudio")) + .header("Authorization","Bearer "+model.getApiKey()).post(RequestBody.create(payload.toString(),AtlasMediaSupport.JSON)).build(); + Job result; + try(Response response=http.newCall(post).execute()) { + String raw=response.body()==null?"":response.body().string(); + if(!response.isSuccessful()) result=new Job(initial.id(),request,null,response.code()>=500?"submission_unknown":"failed","样音提交返回HTTP "+response.code()+",请检查上游任务、模型权限与余额",null,initial.createdAt()); + else { + var root=AtlasMediaSupport.OBJECT_MAPPER.readTree(raw);if(!root.has("data") && root.has("id"))raw=AtlasMediaSupport.OBJECT_MAPPER.createObjectNode().set("data",root).toString(); + var prediction=predictions.toResponse(raw,"audio"); + if(prediction.getId()==null || prediction.getId().isBlank())throw new IllegalStateException("未返回任务编号"); + result=new Job(initial.id(),request,prediction.getId(),"processing","",prediction.getUrl(),initial.createdAt()); + } + } catch(Exception e) {result=new Job(initial.id(),request,null,"submission_unknown","提交结果未知,请核对 Atlas 记录;未自动重试",null,initial.createdAt());} + write(file,result);return result; + } + public Job poll(Long project,Long character,Long user,String id) throws Exception {synchronized(lock(project,character)){return pollLocked(project,character,user,id);}} + private Job pollLocked(Long project,Long character,Long user,String id) throws Exception { + var actor=owner(project,character,user);Path file=jobFile(project,character,id);var old=AtlasMediaSupport.OBJECT_MAPPER.readValue(file.toFile(),Job.class); + if(!Set.of("processing","saving").contains(old.status()))return old; + String url=old.outputUrl(); + if(!"saving".equals(old.status())) { + var result=predictions.retrieve(model(old.request().model()),old.predictionId());String status=Objects.toString(result.getStatus(),""); + if(Set.of("failed","cancelled","canceled").contains(status)) { + var root=AtlasMediaSupport.OBJECT_MAPPER.readTree(result.getRawResponse());var detail=root.path("data").path("error"); + String message=detail.isTextual()?detail.asText():detail.path("message").asText("上游样音生成失败,请检查 Atlas 任务记录"); + if(message.contains("concurrency"))message="上游配音并发额度已满,请等其他样音完成后创建新任务;本任务未自动重交"; + var failed=new Job(id,old.request(),old.predictionId(),"failed",message.substring(0,Math.min(500,message.length())),null,old.createdAt());write(file,failed);return failed; + } + if(!Set.of("completed","succeeded").contains(status))return old; + url=result.getUrl();if(url==null || url.isBlank()){var failed=new Job(id,old.request(),old.predictionId(),"failed","任务完成但未返回音频",null,old.createdAt());write(file,failed);return failed;} + } + var saving=new Job(id,old.request(),old.predictionId(),"saving","样音已生成,正在保存",url,old.createdAt());write(file,saving); + try { + var profile=read(project,actor); + if(profile.samples().stream().noneMatch(s -> s.id().equals(id))) { + Path input=Files.createTempFile(directory(project,character),"audio-",".bin"); + try {download(url,input);double duration=normalize(input,sampleFile(project,character,id)); + add(project,character,profile,new Sample(id,actor.getName()+" · 样音 "+(profile.samples().size()+1),MODEL,old.predictionId(),"generated",duration,System.currentTimeMillis())); + } finally {Files.deleteIfExists(input);} + } + var completed=new Job(id,old.request(),old.predictionId(),"completed","",url,old.createdAt());write(file,completed);return completed; + } catch(IllegalArgumentException e) {var failed=new Job(id,old.request(),old.predictionId(),"failed",e.getMessage(),url,old.createdAt());write(file,failed);return failed;} + catch(Exception e) { + String detail=Objects.toString(e.getMessage(),e.getClass().getSimpleName()); + var retry=new Job(id,old.request(),old.predictionId(),"saving","样音已生成,保存暂未完成;刷新可重试保存:"+detail.substring(0,Math.min(240,detail.length())),url,old.createdAt()); + write(file,retry);return retry; + } + } + private void download(String url,Path input) throws Exception { + var uri=java.net.URI.create(url);if(!"https".equals(uri.getScheme()) || uri.getHost()==null || uri.getUserInfo()!=null)throw new ShortDramaVoiceException("上游样音地址无效"); + try(var response=http.newCall(new Request.Builder().url(url).build()).execute()) { + if(!response.isSuccessful() || response.body()==null)throw new IllegalStateException("上游样音下载HTTP "+response.code()); + try(var stream=response.body().byteStream();var output=Files.newOutputStream(input)){byte[] buffer=new byte[8192];int n;long size=0;while((n=stream.read(buffer))!=-1){size+=n;if(size>10*1024*1024)throw new ShortDramaVoiceException("样音超过10MB");output.write(buffer,0,n);}} + } + } + private double normalize(Path input,Path target) throws Exception { + Path probe=target.resolveSibling(target.getFileName()+".probe"); + ShortDramaSoundService.run(List.of("ffprobe","-v","error","-show_entries","format=duration","-of","default=nw=1:nk=1",input.toString()),probe); + double duration=Double.parseDouble(Files.readString(probe).trim()); + if(!Double.isFinite(duration) || duration<2 || duration>30.1)throw new ShortDramaVoiceException("样音需要2至30秒,请用更短正文或上传短片段;原输出已保留"); + ShortDramaSoundService.run(List.of("ffmpeg","-v","error","-y","-i",input.toString(),"-vn","-ar","24000","-ac","1","-c:a","libmp3lame",target.toString()),target.resolveSibling(target.getFileName()+".log")); + ShortDramaSoundService.run(List.of("ffprobe","-v","error","-show_entries","format=duration","-of","default=nw=1:nk=1",target.toString()),probe); + return Double.parseDouble(Files.readString(probe).trim()); + } + private void add(Long project,Long character,Profile old,Sample sample) throws Exception { + List samples=new ArrayList<>(old.samples());samples.add(sample); + write(profileFile(project,character),new Profile(old.characterId(),old.characterName(),old.description(),old.sampleText(),old.selectedSampleId(),old.version(),List.copyOf(samples))); + } + public Sample upload(Long project,Long character,Long user,MultipartFile file) throws Exception {synchronized(lock(project,character)){return uploadLocked(project,character,user,file);}} + private Sample uploadLocked(Long project,Long character,Long user,MultipartFile file) throws Exception { + var old=read(project,owner(project,character,user));if(file.isEmpty() || file.getSize()>10*1024*1024)throw new ShortDramaVoiceException("请上传小于10MB的样音"); + Files.createDirectories(directory(project,character));String id=UUID.randomUUID().toString();Path input=Files.createTempFile(directory(project,character),"upload-",".bin"); + try {file.transferTo(input);var sample=new Sample(id,Objects.toString(file.getOriginalFilename(),"上传样音"),null,null,"uploaded",normalize(input,sampleFile(project,character,id)),System.currentTimeMillis());add(project,character,old,sample);return sample;} + finally {Files.deleteIfExists(input);} + } + private Sample sample(Profile profile,String id) {return profile.samples().stream().filter(s -> s.id().equals(id)).findFirst().orElseThrow(() -> new ShortDramaVoiceException("样音不属于当前角色或尚未保存"));} + public Profile select(Long project,Long character,Long user,Selection selection) throws Exception {synchronized(lock(project,character)){return selectLocked(project,character,user,selection);}} + private Profile selectLocked(Long project,Long character,Long user,Selection selection) throws Exception { + var old=read(project,owner(project,character,user));String id=selection.sampleId();if(id!=null && !id.isBlank()){sample(old,id);if(!Files.isRegularFile(sampleFile(project,character,id)))throw new ShortDramaVoiceException("样音文件丢失,请重新保存");}else id=null; + var selected=new Profile(old.characterId(),old.characterName(),old.description(),old.sampleText(),id,old.version()+(Objects.equals(id,old.selectedSampleId())?0:1),old.samples());write(profileFile(project,character),selected);return selected; + } + public Path audio(Long project,Long character,Long user,String id) throws Exception {sample(read(project,owner(project,character,user)),id);return sampleFile(project,character,id);} + public record Speech(String text,List> references) {} + public Speech speech(ShortDramaAudio audio) { + if(!"dialogue".equals(audio.getAudioType()) || audio.getLinkedStoryboardId()==null)return new Speech(audio.getText(),List.of()); + var shot=storyboards.selectById(audio.getLinkedStoryboardId());if(shot==null || !audio.getProjectId().equals(shot.getProjectId()))throw new ShortDramaVoiceException("关联对白分镜不属于当前项目"); + var plan=plan(shot,null,true);List> refs=new ArrayList<>();StringBuilder mapping=new StringBuilder(); + try { + for(var binding:plan.bindings()) { + refs.add(Map.of("audioData",Base64.getEncoder().encodeToString(Files.readAllBytes(sampleFile(audio.getProjectId(),Long.valueOf(binding.characterId()),binding.sampleId()))))); + mapping.append(binding.characterName()).append("使用 @audio").append(refs.size()).append(" 的音色;"); + } + }catch(Exception e){throw new IllegalStateException("对白角色样音无法读取",e);} + if(refs.size()>3)throw new ShortDramaVoiceException("Seed Audio最多3个说话人,请按发言段落拆分配音"); + String text=refs.isEmpty()?audio.getText():"角色配音任务。只说原对白,不朗读说明和角色标签,不增加台词或背景音乐。"+mapping+"\n原对白:\n"+audio.getText(); + return new Speech(text,List.copyOf(refs)); + } + private ShortDramaStoryboard shot(Long project,Long storyboard,Long user) {sounds.owner(project,user);var shot=storyboards.selectById(storyboard);if(shot==null || !project.equals(shot.getProjectId()))throw new ShortDramaVoiceException("分镜不属于当前项目");return shot;} + public synchronized ShortDramaStoryboard speakers(Long project,Long storyboard,Long user,SpeakersRequest request) throws Exception { + var shot=shot(project,storyboard,user);String previous=shot.getContinuityJson();ObjectNode continuity=(ObjectNode)AtlasMediaSupport.OBJECT_MAPPER.readTree(Objects.toString(previous,"{}")); + if(continuity==null)continuity=AtlasMediaSupport.OBJECT_MAPPER.createObjectNode(); + if(request.characterIds()==null)continuity.remove("voice_speakers");else continuity.set("voice_speakers",AtlasMediaSupport.OBJECT_MAPPER.valueToTree(request.characterIds().stream().distinct().toList())); + shot.setContinuityJson(continuity.toString());ShortDramaVoiceContract.resolve(shot,cast(project)); + // Only this field changes, and a concurrent edit cannot be overwritten. + var condition=new LambdaUpdateWrapper().eq(ShortDramaStoryboard::getId,storyboard) + .eq(previous!=null,ShortDramaStoryboard::getContinuityJson,previous).isNull(previous==null,ShortDramaStoryboard::getContinuityJson).set(ShortDramaStoryboard::getContinuityJson,shot.getContinuityJson()); + if(storyboards.update(null,condition)!=1)throw new IllegalStateException("分镜已被更新,请刷新后重试");return shot; + } + public Plan preview(Long project,Long storyboard,Long user,String model) throws Exception {return plan(shot(project,storyboard,user),model,false);} + public Plan plan(ShortDramaStoryboard shot,String model,boolean required) { + try { + var actors=cast(shot.getProjectId());var resolved=ShortDramaVoiceContract.resolve(shot,actors); + boolean configured=actors.stream().anyMatch(c -> Files.exists(profileFile(shot.getProjectId(),c.getId()))); + List issues=new ArrayList<>();if(configured)issues.addAll(resolved.issues()); + List bindings=new ArrayList<>();List refs=new ArrayList<>(); + for(var actor:resolved.actors()) { + var profile=read(shot.getProjectId(),actor); + if(profile.selectedSampleId()==null) {if(configured)issues.add(actor.getName()+"尚未选用样音,请先在角色卡绑定");continue;} + var sample=sample(profile,profile.selectedSampleId());Path file=sampleFile(shot.getProjectId(),actor.getId(),sample.id()); + if(!Files.isRegularFile(file)){issues.add(actor.getName()+"已选样音文件丢失");continue;} + refs.add(ShortDramaSoundService.Reference.of("voice:"+actor.getId()+":"+sample.id(),actor.getName(),file,sample.duration())); + bindings.add(new Binding(actor.getId().toString(),actor.getName(),sample.id(),profile.version(),sample.duration(),refs.size())); + } + refs.addAll(sounds.referenceFiles(shot.getProjectId(),shot.getSceneNo())); + if(model!=null && !model.isBlank())try {ShortDramaVoiceContract.validateReferences(model,refs.stream().map(ShortDramaSoundService.Reference::duration).toList());}catch(IllegalArgumentException e){issues.add(e.getMessage());} + if(required && !issues.isEmpty())throw new ShortDramaVoiceException("镜"+shot.getSceneNo()+"声音绑定未就绪:"+String.join(";",issues)); + String direction=bindings.isEmpty()?"":"\n【本镜角色声音绑定】\n"+bindings.stream().map(b -> b.characterName()+"(角色ID "+b.characterId()+")使用 @音频"+b.audioIndex()+" 的音色").reduce((a,b)->a+"\n"+b).orElse("") + +"\n这些样音只用于同一角色音色参考。按原对白与声源顺序表演,画外对白仍由对应角色发声;不要朗读样音正文或绑定说明,不给静默听者增加台词。"; + return new Plan(List.copyOf(bindings),List.copyOf(refs),direction,List.copyOf(issues),resolved.explicit(),resolved.actors().stream().map(c -> c.getId().toString()).toList()); + } catch(RuntimeException e){throw e;}catch(Exception e){throw new IllegalStateException("角色声音档案无法读取,请修复后再生成视频",e);} + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaDirectorSkills.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaDirectorSkills.java index 9fe86645..8b3686f8 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaDirectorSkills.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaDirectorSkills.java @@ -8,13 +8,44 @@ import java.util.concurrent.ConcurrentHashMap; /** Versioned, bundled production skills. Loaded by default, independent of tool calling. */ public final class ShortDramaDirectorSkills { private ShortDramaDirectorSkills() {} - public static final String VERSION = "director-emotion-v2"; + public static final String VERSION = "director-video-prompt-v11"; + public static final String PACING_VERSION = "content-pacing-advisory-v2"; + public static final String PLANNING_VERSION = "semantic-segment-advisory-v12"; private static final Map CACHE = new ConcurrentHashMap<>(); public static String load(String... names) { StringBuilder result = new StringBuilder("【默认导演业务规范:在保持输出格式和用户意图的前提下优先遵循】\n"); for (String name : names) result.append(CACHE.computeIfAbsent(name, ShortDramaDirectorSkills::read)).append("\n"); return result.toString(); } + /** Only remove an exact duplicate in the selected-skill section. Edited bindings and references stay intact. */ + static String compactSelected(String direction, String... loadedNames) { + for (String name : loadedNames) { + int marker = direction.indexOf("[selected-skill:" + name + "@"); + if (marker < 0) continue; + String rule = CACHE.computeIfAbsent(name, ShortDramaDirectorSkills::read); + String body = rule.substring(rule.indexOf('\n') + 1).strip(); + int start = direction.indexOf(body, marker); + int next = direction.indexOf("[selected-skill:", marker + 1); + if (start >= 0 && (next < 0 || start < next)) + direction = direction.substring(0, start) + "[正文与本次默认技能完全一致,复用已加载正文;选中版本与附属参考仍生效]" + direction.substring(start + body.length()); + } + return direction; + } + /** + * Media models receive the concrete asset/shot instruction plus versioned + * bindings. Full skill handbooks belong to planning and review prompts; when + * repeated inside every image request they obscure the actual subject. + */ + public static String media(String... names) { + StringBuilder result = new StringBuilder("【媒体执行规则版本】\n"); + for (String name : names) { + String loaded = CACHE.computeIfAbsent(name, ShortDramaDirectorSkills::read); + int lineEnd = loaded.indexOf('\n'); + result.append(lineEnd < 0 ? loaded : loaded.substring(0, lineEnd)).append('\n'); + } + return result.append("技能正文已在规划和审阅阶段应用;本次以当前资产或镜头的具体描述为可见内容。" + + "人物身份与数量、时点、已批准参考及用户明确修订优先,不从技能手册增添人物、道具、年代或剧情。\n").toString(); + } static String read(String name) { if (!name.matches("[a-z-]+")) throw new IllegalArgumentException("Invalid skill name"); // Common-pool threads may not inherit Spring Boot's nested-jar context loader. @@ -23,7 +54,9 @@ public final class ShortDramaDirectorSkills { String text = new String(in.readAllBytes(), StandardCharsets.UTF_8); if (text.startsWith("---")) text = text.substring(text.indexOf("---",3)+3).strip(); if (text.isBlank()) throw new IllegalStateException("Empty director skill: "+name); - return "[skill:"+name+"@"+VERSION+"]\n"+text; + String version = "video-prompt".equals(name) ? VERSION + "-" + PACING_VERSION + : "cinematic-storyboard".equals(name) ? VERSION + "-" + PLANNING_VERSION : VERSION; + return "[skill:"+name+"@"+version+"]\n"+text; } catch (Exception e) { throw new IllegalStateException("Cannot load director skill: "+name,e); } } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaImagePromptEvidence.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaImagePromptEvidence.java new file mode 100644 index 00000000..92109a74 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaImagePromptEvidence.java @@ -0,0 +1,104 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.ruoyi.service.media.AtlasMediaSupport; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.UUID; +import java.util.regex.Pattern; + +/** Local production evidence, never a provider credential dump or a paid-submission retry mechanism. */ +final class ShortDramaImagePromptEvidence { + record Evidence(String attemptId, String assetType, Long assetId, Long projectId, Long userId, + String model, String referencePurpose, String revisionRequirements, + String finalPrompt, String promptSha256, String referenceImageSha256, + long createdAt, String predictionId, String providerStatus) { + boolean revision() { return "identity_revision".equals(referencePurpose) || "location_revision".equals(referencePurpose); } + Evidence submitted(String prediction, String status) { + return new Evidence(attemptId, assetType, assetId, projectId, userId, model, referencePurpose, + revisionRequirements, finalPrompt, promptSha256, referenceImageSha256, createdAt, prediction, status); + } + } + + private static final Pattern URL = Pattern.compile("(?i)(?:https?://|data:image/)[^\\s<>\"']+"); + private static final Pattern SECRET = Pattern.compile( + "(?i)((?:authorization|api[_-]?key|access[_-]?token|client[_-]?secret|password)\\s*[:=]\\s*)(?:Bearer\\s+)?[^\\s,;\"']+"); + private static final Pattern BEARER = Pattern.compile("(?i)\\bBearer\\s+[A-Za-z0-9._~+/-]+=*"); + private final Path root; + + ShortDramaImagePromptEvidence() { this(Path.of("data", "short-drama-image", "prompt-evidence")); } + ShortDramaImagePromptEvidence(Path root) { this.root = root.toAbsolutePath().normalize(); } + + Evidence prepare(String assetType, Long assetId, Long projectId, Long userId, String model, + String purpose, String requirements, String prompt, String referenceImage) { + Evidence evidence = new Evidence(UUID.randomUUID().toString(), assetType, assetId, projectId, userId, + model, purpose, redact(requirements), redact(prompt), sha256(prompt), + referenceImage == null ? null : sha256(referenceImage), System.currentTimeMillis(), null, "prepared"); + write(attemptPath(evidence.attemptId()), evidence, false); + return evidence; + } + + void submitted(Evidence evidence, String prediction, String status) { + Evidence submitted = evidence.submitted(prediction, status); + // Persist the prediction lookup first so a completed paid task retains its exact edit intent. + if (prediction != null && !prediction.isBlank()) { + write(predictionPath(evidence.model(), prediction), submitted, true); + } + write(attemptPath(evidence.attemptId()), submitted, true); + } + + Evidence forPrediction(String assetType, Long assetId, Long userId, String model, String prediction) { + Path file = predictionPath(model, prediction); + if (!Files.exists(file)) return null; // Compatibility with tasks submitted before evidence support. + try { + Evidence evidence = AtlasMediaSupport.OBJECT_MAPPER.readValue(file.toFile(), Evidence.class); + if (!assetType.equals(evidence.assetType()) || !assetId.equals(evidence.assetId()) || !userId.equals(evidence.userId())) { + throw new IllegalArgumentException("图片任务与当前资产不匹配,未修改已批准图片"); + } + return evidence; + } catch (IOException ex) { + throw new IllegalStateException("图片提示词证据无法读取,请核对原任务;未替换已批准图片", ex); + } + } + + static String redact(String text) { + if (text == null) return null; + String result = URL.matcher(text).replaceAll("[URL redacted]"); + result = SECRET.matcher(result).replaceAll("$1[redacted]"); + return BEARER.matcher(result).replaceAll("Bearer [redacted]"); + } + + static String sha256(String text) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(text.getBytes(StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException ex) { throw new IllegalStateException(ex); } + } + + private Path attemptPath(String attempt) { return root.resolve("attempts").resolve(attempt + ".json"); } + private Path predictionPath(String model, String prediction) { + return root.resolve("predictions").resolve(sha256(model + "\n" + prediction) + ".json"); + } + + private void write(Path file, Evidence evidence, boolean replace) { + Path temporary = null; + try { + Files.createDirectories(file.getParent()); + if (!replace) { + Files.writeString(file, AtlasMediaSupport.OBJECT_MAPPER.writeValueAsString(evidence), StandardOpenOption.CREATE_NEW); + return; + } + temporary = Files.createTempFile(file.getParent(), "image-prompt-", ".tmp"); + AtlasMediaSupport.OBJECT_MAPPER.writeValue(temporary.toFile(), evidence); + try { Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException ex) { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); } + } catch (IOException ex) { + throw new IllegalStateException(replace + ? "图片任务可能已受理,证据保存失败,请核对任务ID,勿重新付费提交" + : "图片提示词证据无法保存,未提交生成任务", ex); + } finally { if (temporary != null) try { Files.deleteIfExists(temporary); } catch (IOException ignored) { } } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaImageStyleReferences.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaImageStyleReferences.java new file mode 100644 index 00000000..470b8707 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaImageStyleReferences.java @@ -0,0 +1,26 @@ +package org.ruoyi.service.shortdrama.impl; + +import java.util.ArrayList; +import java.util.List; + +/** Identity/geometry and visual style have distinct roles in the provider's ordered images. */ +final class ShortDramaImageStyleReferences { + static List ordered(String primary, List styles) { + if (styles == null || styles.isEmpty()) return primary == null ? List.of() : List.of(primary); + if (styles.size() > 3) throw new IllegalArgumentException("最多绑定3张风格参考图"); + if (primary == null || primary.isBlank()) throw new IllegalArgumentException("风格参考须同时提供身份或空间参考图"); + var result = new ArrayList(); result.add(primary); + for (String style : styles) { + if (style == null || style.isBlank()) throw new IllegalArgumentException("风格参考图不能为空"); + if (!result.contains(style)) result.add(style); + } + if (result.size() == 1) throw new IllegalArgumentException("风格参考须与身份或空间参考图区分"); + return List.copyOf(result); + } + + static String direction(boolean location, int count) { + return "\n【有序参考图用途】第1张仅用于" + (location ? "本场景的门墙、出入口和方位" : "本角色的身份、年龄、服装与配色") + + ";第2至" + count + "张仅用于影视画面中的光色、明暗层次、表面质感与摄影呈现。" + + "将后者的视觉处理用到第1张的内容,不复制后者的人脸、衣装、建筑、道具或构图,输出一个连续单幅。"; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaLocationArtPrompt.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaLocationArtPrompt.java new file mode 100644 index 00000000..d2c9cbd1 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaLocationArtPrompt.java @@ -0,0 +1,31 @@ +package org.ruoyi.service.shortdrama.impl; + +/** Per-request location corrections constrain an image candidate, never the saved world or geometry. */ +final class ShortDramaLocationArtPrompt { + private ShortDramaLocationArtPrompt() {} + + static boolean isRevision(String referencePurpose, String requirements) { + String purpose = referencePurpose == null || referencePurpose.isBlank() ? "location" : referencePurpose.trim(); + // identity was the shared DTO's historical default for all image requests. + if (!"location".equals(purpose) && !"identity".equals(purpose) && !"location_revision".equals(purpose)) + throw new IllegalArgumentException("地点图参考用途须为location或location_revision,不能使用角色身份修订用途"); + if (requirements != null && requirements.length() > 4000) throw new IllegalArgumentException("修订要求不能超过4000字符"); + boolean revision = requirements != null && !requirements.isBlank(); + if ("location_revision".equals(purpose) && !revision) throw new IllegalArgumentException("地点修订候选必须填写明确的修订要求"); + return revision; + } + + static String reference(String name, String description, String artStyle, String worldbuilding, String requirements) { + String base = ShortDramaAssetWorldPrompt.location(name, description, artStyle, worldbuilding); + if (!isRevision(null, requirements)) return base; + return base + "\n【用途:当前地点的局部修订候选,尚未批准连续性】\n" + + "本轮只作用于图片,不改写已冻结的场景名称、场景文案、世界观或镜头连续性。" + + "有参考图时只绑定其建筑几何、空间尺度、入口与窗的位置、道路走向、陈设布局及未指定修改的材质;" + + "无参考图时按上述已冻结地点文案重建同一空间。不得搬动建筑、改造道路、改变空间规模或增加人物。\n" + + "【局部要求优先级】本轮明确列出的时间、季候、光色或物件清理要求优先于参考图和冻结文案中的旧时间、旧光色及被指定删除的错误物件。" + + "参考图的夕阳、夜色、天气与错误物件不锁定本轮结果;不能为了保留参考而恢复已明确要求移除的物件。" + + "未列出的几何、结构、身份与可见内容继续遵守已冻结地点,不能从世界观补入未来装备或新的剧情成果。\n" + + "【本轮明确修订要求】\n" + requirements.trim() + + "\n修订结果仅追加为待审候选,须人工审阅并明确选择后才成为后续镜头的批准参考,不自动替换已有批准图片。"; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaMusicService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaMusicService.java new file mode 100644 index 00000000..123ced21 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaMusicService.java @@ -0,0 +1,119 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import okhttp3.*; +import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo; +import org.ruoyi.common.chat.service.chat.IChatModelService; +import org.ruoyi.domain.entity.shortdrama.ShortDramaScript; +import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard; +import org.ruoyi.factory.ChatServiceFactory; +import org.ruoyi.mapper.shortdrama.ShortDramaScriptMapper; +import org.ruoyi.mapper.shortdrama.ShortDramaStoryboardMapper; +import org.ruoyi.service.media.AtlasMediaSupport; +import org.ruoyi.service.media.AtlasPredictionService; +import org.springframework.stereotype.Service; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** Project-owned, recoverable music tasks. TTS and music deliberately use separate contracts. */ +@Service @RequiredArgsConstructor +public class ShortDramaMusicService { + private final ShortDramaSoundService sounds; + private final IChatModelService models; + private final AtlasPredictionService predictions; + private final ChatServiceFactory chats; + private final ShortDramaScriptMapper scripts; + private final ShortDramaStoryboardMapper storyboards; + private final OkHttpClient http = new OkHttpClient.Builder().connectTimeout(30,TimeUnit.SECONDS).readTimeout(180,TimeUnit.SECONDS).build(); + public record MusicRequest(String requestId,String model,String purpose,String title,String prompt,String style,Integer duration,String vocalGender,String negativeTags) {} + public record Variant(int index,String url,String soundId,double duration) {} + public record Job(String id,String predictionId,MusicRequest request,String status,String error,List variants,long createdAt) {} + public record BriefRequest(String model,String purpose,String direction) {} + public record Brief(String title,String style,String prompt) {} + public record Usage(int variant,List shots,double volume,double offset,boolean enabled) {} + private Path directory(Long project) {return Path.of("data","short-drama-sounds",project.toString(),"music-jobs").toAbsolutePath();} + private Path file(Long project,String id) {UUID.fromString(id);return directory(project).resolve(id+".json");} + private void save(Long project,Job job) throws Exception { + Files.createDirectories(directory(project));Path temporary=Files.createTempFile(directory(project),"job-",".tmp"); + try {AtlasMediaSupport.OBJECT_MAPPER.writeValue(temporary.toFile(),job);try {Files.move(temporary,file(project,job.id()),StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException e){Files.move(temporary,file(project,job.id()),StandardCopyOption.REPLACE_EXISTING);}} + finally {Files.deleteIfExists(temporary);} + } + private Job read(Long project,String id) throws Exception {return AtlasMediaSupport.OBJECT_MAPPER.readValue(file(project,id).toFile(),Job.class);} + public List list(Long project,Long user) throws Exception { + sounds.owner(project,user);if(!Files.isDirectory(directory(project)))return List.of(); + try(var files=Files.list(directory(project))){List jobs=new ArrayList<>();for(Path path:files.filter(p->p.toString().endsWith(".json")).toList()){var job=AtlasMediaSupport.OBJECT_MAPPER.readValue(path.toFile(),Job.class);if("submitting".equals(job.status()) && System.currentTimeMillis()-job.createdAt()>210000)job=new Job(job.id(),job.predictionId(),job.request(),"submission_unknown","提交尚未取得任务编号,请先核对 Atlas 记录;未自动重试",job.variants(),job.createdAt());jobs.add(job);}jobs.sort(Comparator.comparingLong(Job::createdAt).reversed());return jobs;} + } + private ChatModelVo musicModel(String name) { + var model=models.selectModelByName(name); + if(model==null || !"audio".equals(model.getCategory()) || !"atlas".equalsIgnoreCase(model.getProviderCode()) || !"suno/chirp-v6".equals(name))throw new IllegalArgumentException("请选择已配置的 Atlas Suno Chirp V6 音乐模型"); + return model; + } + static Map payload(MusicRequest r) { + if(r==null || !Set.of("bgm","theme","ending").contains(Objects.toString(r.purpose(),"")))throw new IllegalArgumentException("请选择 BGM、主题曲或片尾曲"); + if(r.duration()==null || r.duration()<10 || r.duration()>360)throw new IllegalArgumentException("目标时长为10至360秒"); + if(r.title()==null || r.title().isBlank() || r.title().length()>50 || r.style()==null || r.style().isBlank() || r.style().length()>1000)throw new IllegalArgumentException("请填写标题(最多50字符)与音乐风格(最多1000字符)"); + if(r.prompt()!=null && r.prompt().length()>3000)throw new IllegalArgumentException("歌词或音乐描述最多3000字符"); + if(!"bgm".equals(r.purpose()) && (r.prompt()==null || r.prompt().isBlank()))throw new IllegalArgumentException("歌曲需要先填写歌词"); + if(r.negativeTags()!=null && r.negativeTags().length()>1000)throw new IllegalArgumentException("排除风格最多1000字符"); + var p=new LinkedHashMap();p.put("model",r.model());p.put("custom",true);p.put("instrumental","bgm".equals(r.purpose()));p.put("title",r.title().trim());p.put("prompt",Objects.toString(r.prompt(),""));p.put("style",r.style().trim());p.put("duration",r.duration());p.put("variety","normal"); + if(!"bgm".equals(r.purpose())) {if(!Set.of("Male","Female").contains(Objects.toString(r.vocalGender(),"")))throw new IllegalArgumentException("请选择男声或女声");p.put("vocal_gender",r.vocalGender());p.put("auto_lyrics",false);} + if(r.negativeTags()!=null && !r.negativeTags().isBlank())p.put("negative_tags",r.negativeTags());return p; + } + public synchronized Job generate(Long project,Long user,MusicRequest request) throws Exception { + sounds.owner(project,user);UUID.fromString(request.requestId());var payload=payload(request);var model=musicModel(request.model()); + // Reusing the same client request id never submits another paid prediction, even after a timeout. + if(Files.exists(file(project,request.requestId()))) {Job old=read(project,request.requestId());if(!old.request().equals(request))throw new IllegalArgumentException("任务ID已用于其他音乐,请创建新任务");return old;} + var initial=new Job(request.requestId(),null,request,"submitting","",List.of(),System.currentTimeMillis());save(project,initial); + Request post=new Request.Builder().url(AtlasMediaSupport.endpoint(model.getApiHost(),"/model/generateAudio")).header("Authorization","Bearer "+model.getApiKey()).post(RequestBody.create(AtlasMediaSupport.OBJECT_MAPPER.writeValueAsString(payload),AtlasMediaSupport.JSON)).build(); + try(Response response=http.newCall(post).execute()) { + String body=response.body()==null?"":response.body().string(); + if(!response.isSuccessful()){var failed=new Job(initial.id(),null,request,response.code()>=500?"submission_unknown":"failed","音乐提交返回 HTTP "+response.code()+",请检查模型权限和账户余额",List.of(),initial.createdAt());save(project,failed);return failed;} + var data=AtlasMediaSupport.OBJECT_MAPPER.readTree(body).path("data");String prediction=data.path("id").asText(""); + if(prediction.isBlank())throw new IllegalStateException("服务未返回任务ID"); + var submitted=new Job(initial.id(),prediction,request,"processing","",List.of(),initial.createdAt());save(project,submitted);return submitted; + }catch(Exception e){var unknown=new Job(initial.id(),null,request,"submission_unknown","提交结果未知;未自动重试,请先核对 Atlas 任务记录",List.of(),initial.createdAt());save(project,unknown);return unknown;} + } + static List outputs(JsonNode data) { + List urls=new ArrayList<>();for(JsonNode item:data.path("outputs")){String url=item.isTextual()?item.asText():item.path("audio_url").asText(item.path("url").asText(""));if(url.startsWith("https://") && !urls.contains(url))urls.add(url);}return urls; + } + public synchronized Job poll(Long project,Long user,String id) throws Exception { + sounds.owner(project,user);var job=read(project,id);if(!Set.of("processing","saving").contains(job.status()))return job; + var result=predictions.retrieve(musicModel(job.request().model()),job.predictionId());var data=AtlasMediaSupport.OBJECT_MAPPER.readTree(result.getRawResponse()).path("data");String status=Objects.toString(result.getStatus(),""); + if(Set.of("failed","canceled","cancelled").contains(status)){String message=data.path("error").asText("音乐生成失败,请查看 Atlas 任务记录");if(message.contains("tags") && message.contains("1000"))message="音乐风格超过上游1000字符限制,请缩短后创建新任务";else message=message.substring(0,Math.min(500,message.length()));var failed=new Job(id,job.predictionId(),job.request(),"failed",message,job.variants(),job.createdAt());save(project,failed);return failed;} + if(!Set.of("completed","succeeded").contains(status))return job; + var urls=outputs(data);if(urls.isEmpty()){var failed=new Job(id,job.predictionId(),job.request(),"failed","服务已结束但未返回音轨",List.of(),job.createdAt());save(project,failed);return failed;} + List variants=new ArrayList<>();for(int i=0;i=job.variants().size())throw new IllegalArgumentException("请选择已完成的音乐版本"); + var numbers=storyboards.selectList(new LambdaQueryWrapper().eq(ShortDramaStoryboard::getProjectId,project)).stream().map(ShortDramaStoryboard::getSceneNo).toList(); + if(usage.enabled() && (usage.shots()==null || usage.shots().isEmpty() || !numbers.containsAll(usage.shots())))throw new IllegalArgumentException("请选择本项目中实际存在的镜号"); + // Applying one variant removes the other variants of this job from the mix. + var selected=sounds.musicUsage(project,user,job.variants().get(usage.variant()).soundId(),usage.shots(),usage.volume(),usage.offset(),usage.enabled()); + if(usage.enabled())for(var v:job.variants())if(v.index()!=usage.variant())sounds.musicUsage(project,user,v.soundId(),List.of(),0,0,false); + return selected; + } + public Brief write(Long project,Long user,BriefRequest request) { + sounds.owner(project,user);var model=models.selectModelByName(request.model());if(model==null || !"chat".equals(model.getCategory()))throw new IllegalArgumentException("请选择写作模型"); + if(!Set.of("bgm","theme","ending").contains(Objects.toString(request.purpose(),"")) || request.direction()!=null && request.direction().length()>2000)throw new IllegalArgumentException("音乐用途或创作要求无效"); + var script=scripts.selectOne(new LambdaQueryWrapper().eq(ShortDramaScript::getProjectId,project).orderByDesc(ShortDramaScript::getId).last("limit 1"));if(script==null)throw new IllegalArgumentException("请先保存剧本"); + String context=Objects.toString(script.getScriptText(),"");if(context.length()>16000)context=context.substring(0,16000); + String prompt="你是短剧音乐创作。只输出JSON对象,字段title中文曲名、style英文编曲与演唱风格、prompt。用途="+request.purpose()+"。BGM必须纯器乐,prompt写音乐发展不写歌词;歌曲prompt只能是原创中文歌词和[Verse]、[Chorus]等段落标签,不得插入音乐发展说明或制作解说,最多3000字符。编曲与演唱要求全部写style,最多800字符。遵守当前剧本事实和情感弧线,不虚构已取得的胜利,不照抄小说或现有歌曲,不使用人名口号和产品广告。采用克制具体的意象,适合片尾回味。标题最多50字符。要求:"+Objects.toString(request.direction(),"")+"\n剧本:\n"+context; + try {var writer="atlas".equalsIgnoreCase(model.getProviderCode()) ? dev.langchain4j.model.openai.OpenAiChatModel.builder().baseUrl(model.getApiHost()).apiKey(model.getApiKey()).modelName(model.getModelName()).timeout(java.time.Duration.ofMinutes(6)).maxRetries(0).maxTokens(8192).build() : chats.getOriginalService(ShortDramaServiceImpl.shortDramaProviderCode(model.getProviderCode(),model.getModelName())).buildChatModel(model);String raw=writer.chat(prompt);int start=raw.indexOf('{'),end=raw.lastIndexOf('}');if(start<0 || end<=start)throw new IllegalArgumentException("格式无效");return normalizeBrief(AtlasMediaSupport.OBJECT_MAPPER.readValue(raw.substring(start,end+1),Brief.class),request.purpose()); + }catch(Exception e){throw new IllegalStateException("音乐文案生成失败,请检查写作模型或手动填写",e);} + } + static Brief normalizeBrief(Brief brief,String purpose) { + if(brief.title()==null || brief.title().isBlank() || brief.style()==null || brief.style().isBlank() || brief.prompt()==null)throw new IllegalArgumentException("音乐文案不完整"); + String lyrics=brief.prompt().trim();if(!"bgm".equals(purpose)){var tags=java.util.regex.Pattern.compile("(?i)\\[(?:intro|verse|chorus|bridge|outro|hook|pre-chorus)\\b[^\\]]*\\]").matcher(lyrics);if(tags.find())lyrics=lyrics.substring(tags.start());if(lyrics.isBlank())throw new IllegalArgumentException("歌词为空");} + if(lyrics.length()>3000)throw new IllegalArgumentException("歌词或描述超过3000字符,请缩短文案"); + String style=brief.style().trim();if(style.length()>1000){style=style.substring(0,1000);int end=Math.max(style.lastIndexOf('\n'),style.lastIndexOf(','));if(end>700)style=style.substring(0,end);} + return new Brief(brief.title().substring(0,Math.min(50,brief.title().length())).trim(),style,lyrics); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaPanelStreamParser.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaPanelStreamParser.java new file mode 100644 index 00000000..0e89c5fc --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaPanelStreamParser.java @@ -0,0 +1,45 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import org.ruoyi.service.media.AtlasMediaSupport; +import java.util.function.Consumer; + +/** Preview complete panels from legacy arrays or a scene_plan/panels envelope; never approves partial data. */ +final class ShortDramaPanelStreamParser { + private final Consumer sink; + private final StringBuilder object = new StringBuilder(); + private int arrayDepth, objectDepth, captureParentDepth; + private boolean string, escaped, capturing; + ShortDramaPanelStreamParser(Consumer sink) { this.sink = sink; } + void accept(String chunk) { + for (int i = 0; i < chunk.length(); i++) { + char c = chunk.charAt(i); + if (capturing) object.append(c); + if (string) { + if (escaped) escaped = false; + else if (c == '\\') escaped = true; + else if (c == '"') string = false; + continue; + } + if (c == '"') { string = true; continue; } + if (c == '[') arrayDepth++; + else if (c == ']') arrayDepth--; + else if (c == '{') { + if (!capturing && arrayDepth == 1 && objectDepth <= 1) { + capturing = true; captureParentDepth = objectDepth; object.setLength(0); object.append(c); + } + objectDepth++; + } else if (c == '}') { + objectDepth--; + if (capturing && objectDepth == captureParentDepth) { + capturing = false; + try { + JsonNode node = AtlasMediaSupport.OBJECT_MAPPER.readTree(object.toString()); + if (node.isObject() && (node.has("panel_number") || node.has("panelNumber"))) sink.accept(node); + } catch (java.io.IOException ignored) { /* malformed objects await full batch validation */ } + object.setLength(0); + } + } + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaPlanResponse.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaPlanResponse.java new file mode 100644 index 00000000..1789bf3b --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaPlanResponse.java @@ -0,0 +1,20 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.ruoyi.service.media.AtlasMediaSupport; +import java.io.IOException; +import java.util.List; + +/** A scene plan guides generation; it is not a post-generation shot-count limit. */ +final class ShortDramaPlanResponse { + private ShortDramaPlanResponse() {} + + static List parse(String json) throws IOException { + var mapper = AtlasMediaSupport.OBJECT_MAPPER; + var root = mapper.readTree(json); + if (root == null) return null; + var panels = root.isArray() ? root : root.path("panels"); + if (!panels.isArray()) return null; + return mapper.readerForListOf(ShortDramaServiceImpl.StoryboardPanelData.class) + .without(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(panels); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaPlanningProgress.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaPlanningProgress.java new file mode 100644 index 00000000..4963c043 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaPlanningProgress.java @@ -0,0 +1,132 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import org.ruoyi.common.redis.utils.RedisUtils; +import org.ruoyi.service.media.AtlasMediaSupport; +import java.time.Duration; +import java.util.*; +import java.util.function.Consumer; + +/** Read-only progress, separate from the authoritative submission/terminal receipt. No reasoning text. */ +final class ShortDramaPlanningProgress { + record Call(String id, String phase, String label, String state, long startedAt, long firstContentAt, + long lastActivityAt, long endedAt, int promptChars, long contentChars, long thinkingChars) {} + record Card(String key, int scene, int ordinal, String stage, JsonNode panel) {} + record Snapshot(String requestId, String phase, String message, long startedAt, long updatedAt, + List calls, List cards) {} + static String key(Long project, Long script, String request) { + return "short-drama:planning-progress:v1:" + project + ":" + script + ":" + request; + } + static Snapshot read(Long project, Long script, String request) { + String json = RedisUtils.getCacheObject(key(project, script, request)); + if (json == null) return null; + try { return AtlasMediaSupport.OBJECT_MAPPER.readValue(json, Snapshot.class); } + catch (java.io.IOException e) { return null; } // Progress corruption cannot alter a receipt or trigger resubmission. + } + static Consumer persistence(Long project, Long script, String request, Consumer push) { + // The Redis key is captured in the submitter's namespace; provider callbacks do not inherit tenant context. + final String cacheKey = key(project, script, request); + final String tenant = org.ruoyi.common.tenant.helper.TenantHelper.getTenantId(); + return snapshot -> { + try { persistInTenant(tenant, () -> { + RedisUtils.setCacheObject(cacheKey, org.ruoyi.common.json.utils.JsonUtils.toJsonString(snapshot), Duration.ofDays(7)); + }); } + catch (Exception ignored) { /* Observability must not turn successful generation into a failure. */ } + push.accept(snapshot); + }; + } + static void persistInTenant(String tenant, Runnable write) { + String previous = org.ruoyi.common.tenant.helper.TenantHelper.getDynamic(); + try { org.ruoyi.common.tenant.helper.TenantHelper.dynamic(tenant, write); } + finally { + // dynamic() clears instead of restoring an outer worker scope. Progress must not + // move the subsequent scene cache, database work or terminal receipt to another tenant. + if (previous != null && !previous.isBlank()) + org.ruoyi.common.tenant.helper.TenantHelper.setDynamic(previous); + } + } + static final class Tracker { + private final String requestId; + private final long startedAt; + private final Consumer sink; + private final Map calls = new LinkedHashMap<>(); + private final Map cards = new LinkedHashMap<>(); + private final Map globalNumbers = new HashMap<>(); + private String phase = "storyboard_plan", message = "正在规划镜头"; + private long publishedAt; + private boolean terminal; + Tracker(String requestId, long startedAt, Consumer sink) { + this.requestId = requestId; this.startedAt = startedAt; this.sink = sink; + } + synchronized void phase(String value, String text) { + if (terminal) return; + phase = value; message = text; publish(true); + } + synchronized String startCall(String callPhase, String label, int promptChars) { + String id = UUID.randomUUID().toString(); long now = System.currentTimeMillis(); + if (terminal) return id; + calls.put(id, new Call(id, callPhase, label, "running", now, 0, now, 0, promptChars, 0, 0)); + publish(true); return id; + } + synchronized void activity(String id, String text, boolean thinking) { + Call call = calls.get(id); if (terminal || call == null || !"running".equals(call.state()) || text == null || text.isEmpty()) return; + long now = System.currentTimeMillis(); + calls.put(id, new Call(id, call.phase(), call.label(), call.state(), call.startedAt(), + !thinking && call.firstContentAt() == 0 ? now : call.firstContentAt(), now, 0, call.promptChars(), + call.contentChars() + (thinking ? 0 : text.length()), call.thinkingChars() + (thinking ? text.length() : 0))); + publish(false); + } + synchronized Call finishCall(String id, boolean success) { + Call call = calls.get(id); if (call == null || terminal || !"running".equals(call.state())) return call; + Call finished = new Call(id, call.phase(), call.label(), success ? "done" : "error", call.startedAt(), call.firstContentAt(), + call.lastActivityAt(), System.currentTimeMillis(), call.promptChars(), call.contentChars(), call.thinkingChars()); + calls.put(id, finished); publish(true); return finished; + } + synchronized void card(int scene, int ordinal, String stage, JsonNode panel) { + if (terminal || panel == null || !panel.isObject()) return; + String key = scene + ":" + ordinal; + Card old = cards.get(key); + if (old != null && rank(old.stage()) > rank(stage)) return; + var preview = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode(); + for (String field : List.of("scene_title", "sceneTitle", "segment_goal", "segmentGoal", "description", "location", "video_prompt", "videoPrompt")) { + JsonNode value = panel.get(field); + if (value != null && value.isTextual()) preview.put(field, value.asText().substring(0, Math.min(2400, value.asText().length()))); + } + JsonNode design = panel.has("shot_design") ? panel.get("shot_design") : panel.get("shotDesign"); + if (panel.path("duration").canConvertToInt() && panel.path("duration").asInt() > 0) + preview.put("duration", panel.path("duration").asInt()); + if (design != null && design.isObject()) { + var view = preview.putObject("shot_design"); + for (String field : List.of("focus", "viewer_gain", "framing", "movement", "cut_in", "cut_out")) + if (design.path(field).isTextual()) view.put(field, design.path(field).asText()); + } + cards.put(key, new Card(key, scene, ordinal, stage, preview)); publish(true); + } + synchronized void clearDrafts(int scene) { cards.values().removeIf(card -> card.scene() == scene && "draft".equals(card.stage())); publish(true); } + synchronized void assetCard(int category, int ordinal, String stage, JsonNode asset) { + if (terminal || asset == null || !asset.isObject() || asset.path("name").asText().isBlank()) return; + String key = "asset:" + category + ":" + ordinal; + Card old = cards.get(key); + if (old != null && rank(old.stage()) > rank(stage)) return; + var preview = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode(); + for (String field : List.of("name", "description", "visualDescription", "summary", "introduction")) { + String value = asset.path(field).asText(""); + if (!value.isBlank()) preview.put(field, value.substring(0, Math.min(2400, value.length()))); + } + cards.put(key, new Card(key, category, ordinal, stage, preview)); publish(true); + } + synchronized void bindGlobal(int global, int scene, int ordinal) { globalNumbers.put(global, new int[]{scene, ordinal}); } + synchronized void ready(int global, JsonNode panel) { int[] address = globalNumbers.get(global); if (address != null) card(address[0], address[1], "ready", panel); } + synchronized void heartbeat() { if (!terminal) publish(true); } + synchronized void terminal(String state) { if (terminal) return; phase = state; terminal = true; publish(true); } + synchronized Snapshot snapshot() { + return new Snapshot(requestId, phase, message, startedAt, System.currentTimeMillis(), List.copyOf(calls.values()), + cards.values().stream().sorted(Comparator.comparingInt(Card::scene).thenComparingInt(Card::ordinal)).toList()); + } + private void publish(boolean force) { + long now = System.currentTimeMillis(); if (!force && now - publishedAt < 1000) return; + publishedAt = now; sink.accept(snapshot()); + } + private int rank(String stage) { return "ready".equals(stage) ? 2 : "planned".equals(stage) ? 1 : 0; } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaPlanningStatus.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaPlanningStatus.java new file mode 100644 index 00000000..e9e1a1b9 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaPlanningStatus.java @@ -0,0 +1,85 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.ruoyi.common.redis.utils.RedisUtils; +import org.ruoyi.service.media.AtlasMediaSupport; +import java.time.Duration; +import java.util.*; + +/** Submission receipt independent of an SSE connection. An absent/old receipt never proves upstream failure. */ +final class ShortDramaPlanningStatus { + static final String VERSION = "planning-status-v1"; + record Status(Long projectId, Long scriptId, String requestId, String scriptHash, String model, + int minimumShotSeconds, String runtimeId, String state, long submittedAt, long updatedAt, + Integer panelCount, String error, List storyboardIds, String storyboardHash) { + boolean terminal() { return "done".equals(state) || "error".equals(state); } + } + private final String runtimeId = UUID.randomUUID().toString(); + private final ShortDramaSceneCheckpoint.Cache cache; + private final String namespace; + ShortDramaPlanningStatus() { + this(VERSION); + } + ShortDramaPlanningStatus(String namespace) { + this(new ShortDramaSceneCheckpoint.Cache() { + public String get(String key) { return RedisUtils.getCacheObject(key); } + public void put(String key, String value) { RedisUtils.setCacheObject(key, value, Duration.ofDays(7)); } + }, namespace); + } + ShortDramaPlanningStatus(ShortDramaSceneCheckpoint.Cache cache) { this(cache, VERSION); } + ShortDramaPlanningStatus(ShortDramaSceneCheckpoint.Cache cache, String namespace) { this.cache = cache; this.namespace = namespace; } + private String prefix(Long project, Long script) { return "short-drama:" + namespace + ":" + project + ":" + script + ":"; } + Status read(Long project, Long script, String requestId) { + String json = cache.get(prefix(project, script) + (requestId == null ? "latest" : "request:" + requestId)); + try { return json == null ? null : AtlasMediaSupport.OBJECT_MAPPER.readValue(json, Status.class); } + catch (java.io.IOException e) { throw new IllegalStateException("分镜请求状态损坏,请核对原请求,勿重复提交", e); } + } + boolean currentRuntime(Status status) { return runtimeId.equals(status.runtimeId()); } + synchronized Status queued(Long project, Long script, String requestId, String scriptHash, String model, int minimum) { + Status existing = read(project, script, requestId); + if (existing != null) throw new IllegalStateException("分镜requestId已有回执,不得覆盖并重复提交"); + long now = System.currentTimeMillis(); + Status status = new Status(project, script, requestId, scriptHash, model, minimum, runtimeId, "queued", now, now, null, "", null, ""); + write(status, true); return status; + } + synchronized Status update(Status previous, String state, Integer count, String error) { + if (!Set.of("queued", "running", "done", "error").contains(state)) throw new IllegalArgumentException("无效的分镜请求状态"); + // Callbacks retain the original queued receipt. Always consult the persisted request so a + // late phase/heartbeat cannot undo a terminal result (or overwrite a newer latest request). + Status current = read(previous.projectId(), previous.scriptId(), previous.requestId()); + if (current == null) current = previous; + if (current.terminal() || ("running".equals(current.state()) && "queued".equals(state))) return current; + Status status = new Status(current.projectId(), current.scriptId(), current.requestId(), current.scriptHash(), + current.model(), current.minimumShotSeconds(), current.runtimeId(), state, current.submittedAt(), + System.currentTimeMillis(), count, ShortDramaSceneCandidateDiagnostics.limit(ShortDramaSceneCandidateDiagnostics.redact(error), 2048), + current.storyboardIds(), current.storyboardHash()); + write(status, false); return status; + } + synchronized Status complete(Status previous, List storyboardIds, String storyboardHash) { + if (storyboardIds == null || storyboardIds.isEmpty() || storyboardIds.stream().anyMatch(Objects::isNull)) + throw new IllegalArgumentException("完成回执必须绑定分镜 ID"); + if (storyboardHash == null || !storyboardHash.matches("[0-9a-f]{64}")) + throw new IllegalArgumentException("完成回执必须绑定分镜内容哈希"); + Status current = read(previous.projectId(), previous.scriptId(), previous.requestId()); + if (current == null) current = previous; + if (current.terminal()) return current; + List ids = List.copyOf(storyboardIds); + Status status = new Status(current.projectId(), current.scriptId(), current.requestId(), current.scriptHash(), + current.model(), current.minimumShotSeconds(), current.runtimeId(), "done", current.submittedAt(), + System.currentTimeMillis(), ids.size(), "", ids, storyboardHash); + write(status, false); return status; + } + private void write(Status status, boolean newSubmission) { + try { + String json = AtlasMediaSupport.OBJECT_MAPPER.writeValueAsString(status); + cache.put(prefix(status.projectId(), status.scriptId()) + "request:" + status.requestId(), json); + Status latest = read(status.projectId(), status.scriptId(), null); + if (newSubmission || latest == null || latest.requestId().equals(status.requestId())) + cache.put(prefix(status.projectId(), status.scriptId()) + "latest", json); + } catch (java.io.IOException e) { throw new IllegalStateException("分镜排队状态保存失败,未提交后台任务", e); } + } + static String checkedRequestId(String value) { + if (value == null || value.isBlank()) return null; + if (!value.matches("[A-Za-z0-9_-]{1,128}")) throw new IllegalArgumentException("requestId须为1至128位字母、数字、下划线或连字符"); + return value; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaReviewedImportMessages.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaReviewedImportMessages.java new file mode 100644 index 00000000..e6c55f1f --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaReviewedImportMessages.java @@ -0,0 +1,19 @@ +package org.ruoyi.service.shortdrama.impl; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** Narrow, owner-checked import feedback; never relax the global exception disclosure policy. */ +public final class ShortDramaReviewedImportMessages { + private ShortDramaReviewedImportMessages() { } + private static final Pattern DOMAIN = Pattern.compile("^(?:项目不存在或无权限|剧本|规划|审阅|导入|缺少第|请等待当前分镜|已有分镜|当前资产|每镜|本场|镜头|角色|场景|background_extras|匿名群演|遗漏或乱序原文对白|对白语速|时间预算|minimumShotSeconds|未找到模型配置|无可用聊天模型).*"); + + /** null means an unexpected/infrastructure error, which must retain the global safe response. */ + public static String failureMessage(RuntimeException failure) { + if (!(failure instanceof IllegalArgumentException) && !(failure instanceof IllegalStateException)) return null; + String message = Objects.toString(failure.getMessage(), "").replaceAll("[\\p{Cntrl}]+", " ").trim(); + if (!(failure instanceof ShortDramaSceneCheckpoint.BindingMismatch) && !DOMAIN.matcher(message).matches()) return null; + return ShortDramaSceneCandidateDiagnostics.limit("审阅规划导入未通过:" + + ShortDramaSceneCandidateDiagnostics.redact(message), 2048); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaRevisionService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaRevisionService.java index 4d079303..3dc3322d 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaRevisionService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaRevisionService.java @@ -36,7 +36,8 @@ public class ShortDramaRevisionService { if (script == null || !projectId.equals(script.getProjectId())) throw new IllegalArgumentException("剧本不属于当前项目"); if (!Objects.equals(script.getScriptText(), revision.getExpectedScriptText())) throw new IllegalStateException("剧本已发生变化,请重新导出并合并修订"); var existing = boards.selectList(new LambdaQueryWrapper().eq(ShortDramaStoryboard::getProjectId, projectId)); - if (existing.stream().anyMatch(s -> "generating".equals(s.getVideoStatus()))) throw new IllegalStateException("请等待运行中的视频任务完成再修订"); + if (existing.stream().anyMatch(s -> Set.of("submitting", "generating", "submission_unknown") + .contains(Objects.toString(s.getVideoStatus(), "")))) throw new IllegalStateException("请等待运行中的视频任务完成再修订"); var ids = existing.stream().map(ShortDramaStoryboard::getId).collect(Collectors.toSet()); var incoming = revision.getStoryboards(); if (incoming == null || incoming.size() != ids.size() || !ids.equals(incoming.stream().map(s -> s.getId()).collect(Collectors.toSet()))) throw new IllegalArgumentException("修订稿必须完整覆盖当前项目的镜头,不能增删或重复镜号"); @@ -47,6 +48,8 @@ public class ShortDramaRevisionService { if (s.getUpdateTime() == null || !Objects.equals(s.getUpdateTime(), current.getUpdateTime())) throw new IllegalStateException("镜头" + s.getSceneNo() + "已发生变化,请重新导出再修订"); if (s.getSourceText() == null || s.getSourceText().isBlank() || s.getVideoPrompt() == null || s.getVideoPrompt().isBlank()) throw new IllegalArgumentException("原文及视频提示词不能为空"); ShortDramaTiming.validate(s.getSceneNo(), s.getDurationSeconds(), s.getSourceText(), s.getContinuityJson()); + if (!Objects.equals(current.getVideoPrompt(), s.getVideoPrompt()) || !Objects.equals(current.getSourceText(), s.getSourceText())) + ShortDramaVideoPromptReview.validate(s.getSceneNo(), s.getVideoPrompt(), s.getDurationSeconds(), s.getSourceText()); } if (revision.getCharacters() != null) for (var c : revision.getCharacters()) { var current = characters.selectById(c.getId()); @@ -67,17 +70,22 @@ public class ShortDramaRevisionService { .eq(ShortDramaVisualAsset::getProjectId,projectId).eq(ShortDramaVisualAsset::getStoryboardId,s.getId()) .set(ShortDramaVisualAsset::getStatus,"obsolete")); } - boolean videoChanged=changedFrames.contains(s.getId()) || !Objects.equals(current.getVideoPrompt(),s.getVideoPrompt()) - || !Objects.equals(current.getSourceText(),s.getSourceText()) || !Objects.equals(current.getDurationSeconds(),s.getDurationSeconds()); - if(videoChanged) { - boards.update(null,new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() - .eq(ShortDramaStoryboard::getId,s.getId()).set(ShortDramaStoryboard::getVideoStatus,"pending") - .set(ShortDramaStoryboard::getVideoId,null).set(ShortDramaStoryboard::getVideoUrl,null).set(ShortDramaStoryboard::getLastFrameUrl,null)); - } - s.setUpdateTime(null); boards.updateById(MapstructUtils.convert(s, ShortDramaStoryboard.class)); + // A reviewed text revision does not replace paid media. Copy from the + // current row rather than accepting media fields from the revision BO. + // Only an explicit regenerate request may replace the existing video. + s.setUpdateTime(null); + var revised = MapstructUtils.convert(s, ShortDramaStoryboard.class); + revised.setVideoStatus(current.getVideoStatus()); + revised.setVideoId(current.getVideoId()); + revised.setVideoUrl(current.getVideoUrl()); + revised.setLastFrameUrl(current.getLastFrameUrl()); + boards.updateById(revised); } script.setScriptText(revision.getScriptText()); if (revision.getOutlineText() != null) script.setOutlineText(revision.getOutlineText()); + if (revision.getTone() != null) script.setTone(revision.getTone()); + if (revision.getWorldbuilding() != null) script.setWorldbuilding(revision.getWorldbuilding()); + if (revision.getRevisionNotes() != null) script.setRevisionNotes(revision.getRevisionNotes()); scripts.updateById(script); visualService.synchronizeExistingFrames(projectId, changedFrames); project.setStatus("storyboard_ready"); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSceneBudget.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSceneBudget.java new file mode 100644 index 00000000..258937f0 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSceneBudget.java @@ -0,0 +1,165 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import org.ruoyi.service.media.AtlasMediaSupport; +import org.ruoyi.service.shortdrama.impl.ShortDramaServiceImpl.StoryboardPanelData; + +import java.util.*; +import java.util.regex.Pattern; + +/** Timing repair keeps actual speech capacity while allowing concurrent actions and motivated shot merges. */ +final class ShortDramaSceneBudget { + private ShortDramaSceneBudget() {} + private static final Pattern BUDGET = Pattern.compile("预计\\s*(\\d+(?:\\.\\d+)?)\\s*秒"); + + static String scaffold(String scene) { + return scaffold(scene, 1); + } + static String scaffold(String scene, int minimum) { + var match = BUDGET.matcher(scene.lines().findFirst().orElse("")); + if (!match.find()) return ""; + int seconds = (int)Math.round(Double.parseDouble(match.group(1))); + if (seconds < 1) return ""; + return "\n【本场时长参考】剧本预计" + seconds + + "秒,仅作制作参考。按原对白、连续动作与叙事节奏安排自然分段,允许估算差异;不强制镜数、最短整数秒或总和,不靠删对白、增加事件或空等凑预算。\n"; + } + + static String repairPrompt(String scene, List panels, String error) { + return repairPrompt(scene, panels, error, 1); + } + static String repairPrompt(String scene, List panels, String error, int minimum) { + ArrayNode compact = JsonNodeFactory.instance.arrayNode(); + for (int i = 0; i < panels.size(); i++) { + var p = panels.get(i); var n = compact.addObject(); n.put("from_panel", i + 1); + n.put("source_text", Objects.toString(p.getSourceText(), "")); n.put("duration", p.getDuration()); + n.put("location", Objects.toString(p.getLocation(), "")); + n.put("start_state", Objects.toString(p.getStartState(), "")); n.put("end_state", Objects.toString(p.getEndState(), "")); + if (p.getBackgroundExtras() != null) n.put("background_extras", p.getBackgroundExtras()); + if (p.getTiming() != null) n.set("timing", p.getTiming()); + if (p.getShotDesign() != null) n.set("shot_design", p.getShotDesign()); + } + return ShortDramaDirectorSkills.load("emotional-dialogue", "cinematic-storyboard", "director-blocking", "video-prompt") + scaffold(scene, minimum) + + "只修本场可拍节拍表,返回JSON数组。每项必须含:from_panel(原镜序号,拆镜可重复;合并同空间连续镜头时改用from_panels整数数组)、source_text、description、start_state、end_state、performance_beats、duration(明确分配的整数秒)、timing。\n" + + "原镜含shot_design时修订项也必须含完整shot_design;拆镜或合镜后重写实际焦点与切点,连续镜state_in复制前镜state_out、cut_in复制前镜cut_out,不复制同一个动作从头重复。\n" + + "shot_design须明确包含focus、viewer_gain、framing、axis、movement、motivation、transition、cut_in、cut_out;连续组首项另含非空state_in,每项含state_delta。状态是对象,不得只返回拍法而遗漏状态。首态锚点覆盖下方原规划全组已有属性,初态据原文明确填写;state_delta仅列本项真实末态变化,不新增键。\n" + + "本场每个JSON项就是一次视频生成,段内允许有动机的摄影切点。先按原事件的因果与真实容量安排连贯表演,再决定自然分段;不要逐句、逐反应列项,也不按总秒数除以段长硬算镜数。合并项重写segment_goal、segment_result、bridge_in、bridge_out、beat_type、narrative_cause、character_goal、next_hook,结尾直接收束。\n" + + "先逐句抄回原话并注明原发言人、画内/画外/心念等原声源,再安排节拍。保留字句、先后和声源,不把别人的台词交给另一角色,不重复整句来凑镜头。长句可在自然句界跨镜拆开。\n" + + "source_text保留段落换行;对白逐句独占一行,统一为角色名:「原句」。动作、字幕与文书文字必须另起行,不得跟在对白冒号后;叙述性冒号后换行,不能把动作文字误当发音延长镜头。\n" + + "timing完整填写spoken_text(只含本镜发音)、speech_rate(2至5字/秒)、action_seconds、pause_seconds、action_note;较长非对白时段补pacing_note,说明逐段具体事件与叙事必要性,不为达到总分钟数虚填动作/停顿。" + + "duration填写正整数制作估算,不强制最短整数秒或固定段长。结合对白、并行动作与必要反应自然安排,不因公式估算差异强制拆镜;不要删改原对白或增添事件来凑数。\n" + + "说话时转头、指向物件、听者回应眼神属于并行动作,不额外累计action_seconds;只有必须停止说话的交接、落笔等动作才独占。" + + "倾听对方正在说话不再记pause_seconds,停顿按真实表演需要逐项决定,静默镜speech_rate仍填4,不能0。\n" + + "超总预算时先把同一空间同一因果的对白与同时进行动作合并;同一段可让不同人物依次说话,口型与各自声源对应,不同时抢声。" + + "from_panels不能合并不同场景或同角色不同服装形象;合并后start_state承接首项、end_state落实最后项,并为每名发言/倾听者保留可见反应。\n" + + "输出前保留全部原话及先后关系,核对真实表演节奏;总时长可以随内容调整,不要求凑齐剧本估时。\n" + + "世界观与全角色库不能增加本场未安排的演员、精灵或投影;background_extras保持匿名群演原有身份/服饰,不升级为角色ID;若拆镜改变0秒人数/位置,按该镜start_state明确重写该字段。地点精确绑定不合并新名字。\n" + + "background_extras无人时写空字符串;有人时严格写成“0秒:可见人数=6-8;身份服饰=匿名守卒,粗布军服;位置=门板内侧;姿态动作=肩抵门板”,人数使用整数或整数范围,不写“无”“若干”或数组。\n" + + "不伸长无事件的静默来填预算,允许上述预算容差,动作与反应必须推进原事件。\n" + + "原文:\n" + scene + "\n失败原因:" + error + "\n当前节拍:\n" + compact; + } + + static List applyRepair(List originals, String response) { + return applyRepair(originals, response, 1); + } + static List applyRepair(List originals, String response, int minimumShotSeconds) { + var mapper = AtlasMediaSupport.OBJECT_MAPPER; + try { + JsonNode edits = mapper.readTree(response.strip().replaceFirst("^```(?:json)?\\s*", "").replaceFirst("\\s*```$", "")); + if (!edits.isArray() || edits.isEmpty()) throw new IllegalArgumentException("时间表须为非空数组"); + List repaired = new ArrayList<>(); + for (JsonNode edit : edits) { + List indices = new ArrayList<>(); + if (edit.has("from_panels")) { + if (!edit.path("from_panels").isArray() || edit.path("from_panels").isEmpty()) throw new IllegalArgumentException("合并原镜序号须为非空数组"); + for (JsonNode index : edit.path("from_panels")) { + if (!index.isIntegralNumber()) throw new IllegalArgumentException("合并原镜序号须为整数"); + indices.add(index.asInt()); + } + if (new HashSet<>(indices).size() != indices.size()) throw new IllegalArgumentException("合并原镜序号不能重复"); + } else { + if (!edit.path("from_panel").isIntegralNumber()) throw new IllegalArgumentException("时间表原镜序号须为整数"); + indices.add(edit.path("from_panel").asInt()); + } + if (indices.stream().anyMatch(n -> n < 1 || n > originals.size())) throw new IllegalArgumentException("时间表原镜序号无效"); + var panel = mapper.convertValue(mapper.valueToTree(originals.get(indices.get(0) - 1)), StoryboardPanelData.class); + mergeReferences(panel, indices.stream().map(n -> originals.get(n - 1)).toList()); + if (!edit.hasNonNull("source_text") || !edit.path("timing").isObject()) throw new IllegalArgumentException("时间表缺少原话或时间预算"); + panel.setSourceText(edit.path("source_text").asText()); panel.setTiming(edit.path("timing")); + panel.setDescription(edit.path("description").asText(panel.getDescription())); + if (edit.hasNonNull("segment_goal")) panel.setSegmentGoal(edit.path("segment_goal").asText()); + if (edit.hasNonNull("segment_result")) panel.setSegmentResult(edit.path("segment_result").asText()); + if (edit.hasNonNull("bridge_in")) panel.setBridgeIn(edit.path("bridge_in").asText()); + if (edit.hasNonNull("bridge_out")) panel.setBridgeOut(edit.path("bridge_out").asText()); + if (edit.hasNonNull("beat_type")) panel.setBeatType(edit.path("beat_type").asText()); + if (edit.hasNonNull("narrative_cause")) panel.setNarrativeCause(edit.path("narrative_cause").asText()); + if (edit.hasNonNull("character_goal")) panel.setCharacterGoal(edit.path("character_goal").asText()); + if (edit.hasNonNull("next_hook")) panel.setNextHook(edit.path("next_hook").asText()); + panel.setStartState(edit.path("start_state").asText(panel.getStartState())); + panel.setEndState(edit.path("end_state").asText(panel.getEndState())); + if (panel.getShotDesign() != null && !panel.getShotDesign().isNull() && !edit.path("shot_design").isObject()) + throw new IllegalArgumentException("专业镜头时间修订须保留shot_design并重新核对跨镜接点"); + if (edit.path("shot_design").isObject()) { + var design = edit.path("shot_design").deepCopy(); + retainKnownStates(design, indices.stream().map(n -> originals.get(n - 1)).toList()); + panel.setShotDesign(design); + } + if (edit.has("background_extras")) { + if (!edit.path("background_extras").isTextual()) throw new IllegalArgumentException("background_extras须为0秒匿名群演文字"); + ShortDramaBackgroundExtras.parse(edit.path("background_extras").asText()); + panel.setBackgroundExtras(edit.path("background_extras").asText()); + } + panel.setStoryAction(panel.getDescription()); panel.setStoryResult(panel.getEndState()); + panel.setContinuityAction("从" + panel.getStartState() + "连续执行至" + panel.getEndState()); + if (edit.path("performance_beats").isArray()) panel.setPerformanceBeats(edit.path("performance_beats")); + var continuity = JsonNodeFactory.instance.objectNode(); continuity.set("timing", panel.getTiming()); + int minimum = Math.max(minimumShotSeconds, (int)Math.ceil(ShortDramaTiming.requiredSeconds(panel.getSourceText(), continuity.toString()))); + int duration = minimum; + if (edit.has("duration")) { + if (!edit.path("duration").isIntegralNumber()) throw new IllegalArgumentException("duration须为整数秒"); + duration = edit.path("duration").asInt(); + } + if (duration <= 0) throw new IllegalArgumentException("duration须为正整数秒"); + panel.setDuration(duration); panel.setPanelNumber(repaired.size() + 1); panel.setSegmentNumber(repaired.size() + 1); + repaired.add(panel); + } + return repaired; + } catch (Exception e) { throw new IllegalArgumentException("时间表修订无效:" + e.getMessage(), e); } + } + + private static void mergeReferences(StoryboardPanelData panel, List originals) { + Map refs = new LinkedHashMap<>(); + Set present = new LinkedHashSet<>(); + for (var original : originals) { + if (!Objects.equals(panel.getLocation(), original.getLocation())) throw new IllegalArgumentException("不同场景不能合并为一个镜头"); + for (var ref : original.getCharacters() == null ? List.of() : original.getCharacters()) { + var before = refs.putIfAbsent(ref.getName(), ref); + if (before != null && !Objects.equals(before.getAppearance(), ref.getAppearance())) throw new IllegalArgumentException("同角色不同服装形象不能合并"); + } + if (original.getPresentCharacters() != null) present.addAll(original.getPresentCharacters()); + } + panel.setCharacters(new ArrayList<>(refs.values())); panel.setPresentCharacters(new ArrayList<>(present)); + } + + /** Missing contract fields retain only explicitly recorded source states; unknown initial values stay unknown. */ + private static void retainKnownStates(JsonNode revised, List originals) { + if (!(revised instanceof com.fasterxml.jackson.databind.node.ObjectNode design)) return; + var first = originals.get(0).getShotDesign(); + if (!design.has("state_in") && first != null && first.path("state_in").isObject()) + design.set("state_in", first.path("state_in").deepCopy()); + if (design.has("state_delta") || design.has("state_out")) return; + var delta = design.putObject("state_delta"); + for (var original : originals) { + var recorded = original.getShotDesign(); + if (recorded == null) continue; + if (recorded.path("state_delta").isObject()) + recorded.path("state_delta").fields().forEachRemaining(entry -> delta.set(entry.getKey(), entry.getValue().deepCopy())); + else if (recorded.path("state_out").isObject()) + recorded.path("state_out").fields().forEachRemaining(entry -> { + if (!entry.getValue().equals(design.path("state_in").path(entry.getKey()))) + delta.set(entry.getKey(), entry.getValue().deepCopy()); + }); + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSceneCandidateDiagnostics.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSceneCandidateDiagnostics.java new file mode 100644 index 00000000..e79bf4a7 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSceneCandidateDiagnostics.java @@ -0,0 +1,84 @@ +package org.ruoyi.service.shortdrama.impl; + +import cn.hutool.crypto.digest.DigestUtil; +import org.ruoyi.common.redis.utils.RedisUtils; +import org.ruoyi.service.media.AtlasMediaSupport; + +import java.time.Duration; +import java.util.*; +import java.util.function.LongSupplier; + +/** Raw native responses for diagnosis only. This store is never read by checkpoint/import recovery. */ +final class ShortDramaSceneCandidateDiagnostics { + static final String VERSION = "scene-candidate-v1"; + static final int MAX_RESPONSE_CHARS = 65_536, MAX_ERROR_CHARS = 2_048, MAX_CANDIDATES = 20; + static final long RETENTION_MILLIS = Duration.ofDays(7).toMillis(); + record Candidate(String candidateId, ShortDramaSceneCheckpoint.Identity identity, String requestId, + int attempt, String mode, String state, boolean validated, long receivedAt, long checkedAt, + String responseHash, int originalResponseChars, boolean responseTruncated, boolean credentialsRedacted, + String modelResponse, String validationStatus, String validationError) {} + private final ShortDramaSceneCheckpoint.Cache cache; + private final LongSupplier clock; + ShortDramaSceneCandidateDiagnostics() { + this(new ShortDramaSceneCheckpoint.Cache() { + public String get(String key) { return RedisUtils.getCacheObject(key); } + public void put(String key, String value) { RedisUtils.setCacheObject(key, value, Duration.ofDays(7)); } + }, System::currentTimeMillis); + } + ShortDramaSceneCandidateDiagnostics(ShortDramaSceneCheckpoint.Cache cache, LongSupplier clock) { + this.cache = cache; this.clock = clock; + } + private String key(ShortDramaSceneCheckpoint.Identity identity) { + return "short-drama:scene-candidates:" + VERSION + ":" + identity.prefix(); + } + List read(ShortDramaSceneCheckpoint.Identity identity) { + try { + String json = cache.get(key(identity)); + if (json == null) return List.of(); + List saved = AtlasMediaSupport.OBJECT_MAPPER.readValue(json, + AtlasMediaSupport.OBJECT_MAPPER.getTypeFactory().constructCollectionType(List.class, Candidate.class)); + long now = clock.getAsLong(); + return saved.stream().filter(c -> Objects.equals(c.identity(), identity) && c.receivedAt() <= now + && now - c.receivedAt() < RETENTION_MILLIS).toList(); + } catch (java.io.IOException e) { return List.of(); } + } + synchronized String recordResponse(ShortDramaSceneCheckpoint.Identity identity, String requestId, int attempt, + String mode, String response) { + String raw = Objects.toString(response, ""), safe = redact(raw), id = UUID.randomUUID().toString(); + var entries = new ArrayList<>(read(identity)); + entries.add(new Candidate(id, identity, requestId, attempt, mode, "unreviewed_candidate", false, + clock.getAsLong(), 0, DigestUtil.sha256Hex(raw), raw.length(), safe.length() > MAX_RESPONSE_CHARS, + !raw.equals(safe), limit(safe, MAX_RESPONSE_CHARS), "pending", "")); + write(identity, entries); return id; + } + synchronized void checked(ShortDramaSceneCheckpoint.Identity identity, String candidateId, String error) { + var entries = new ArrayList<>(read(identity)); + for (int i = 0; i < entries.size(); i++) { + Candidate c = entries.get(i); + if (!Objects.equals(c.candidateId(), candidateId)) continue; + String safeError = limit(redact(Objects.toString(error, "")), MAX_ERROR_CHARS); + entries.set(i, new Candidate(c.candidateId(), c.identity(), c.requestId(), c.attempt(), c.mode(), + c.state(), false, c.receivedAt(), clock.getAsLong(), c.responseHash(), c.originalResponseChars(), + c.responseTruncated(), c.credentialsRedacted() || !Objects.toString(error, "").equals(redact(Objects.toString(error, ""))), + c.modelResponse(), error == null ? "automatic_checks_passed_unreviewed" : "automatic_checks_failed", safeError)); + write(identity, entries); return; + } + } + private void write(ShortDramaSceneCheckpoint.Identity identity, List entries) { + if (entries.size() > MAX_CANDIDATES) entries = entries.subList(entries.size() - MAX_CANDIDATES, entries.size()); + try { cache.put(key(identity), AtlasMediaSupport.OBJECT_MAPPER.writeValueAsString(entries)); } + catch (java.io.IOException e) { throw new IllegalStateException("未审阅候选诊断序列化失败", e); } + } + static String redact(String text) { + return Objects.toString(text, "") + .replaceAll("(?i)\\bBearer\\s+[^\\s\"',}]+", "Bearer [REDACTED]") + .replaceAll("(?i)\\bsk-[A-Za-z0-9_-]{8,}", "[REDACTED]") + .replaceAll("(?i)((?:api[_-]?key|access[_-]?token|authorization|password|secret)\\s*[\"']?\\s*[:=]\\s*[\"']?)[^\\s\"',}]+", "$1[REDACTED]"); + } + static String limit(String value, int max) { + if (value.length() <= max) return value; + int end = max; + if (end > 0 && Character.isHighSurrogate(value.charAt(end - 1))) end--; + return value.substring(0, end); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSceneCheckpoint.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSceneCheckpoint.java new file mode 100644 index 00000000..97039f23 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSceneCheckpoint.java @@ -0,0 +1,209 @@ +package org.ruoyi.service.shortdrama.impl; + +import cn.hutool.crypto.digest.DigestUtil; +import org.ruoyi.common.mybatis.core.domain.BaseEntity; +import org.ruoyi.common.redis.utils.RedisUtils; +import org.ruoyi.domain.entity.shortdrama.*; +import org.ruoyi.service.media.AtlasMediaSupport; +import org.ruoyi.service.shortdrama.impl.ShortDramaServiceImpl.StoryboardPanelData; + +import java.time.Duration; +import java.util.*; +import java.util.regex.Pattern; + +/** Per-scene provenance: unrelated assets and transient image results never invalidate a validated scene. */ +final class ShortDramaSceneCheckpoint { + static final String VERSION = "scene-proof-v1"; + record Identity(Long projectId, Long scriptId, int sceneNumber, String sceneHash, String contextHash, String modelHash, int minimumShotSeconds) { + String prefix() { return "short-drama:scene-checkpoint:" + VERSION + ":" + projectId + ":" + scriptId + + ":" + sceneNumber + ":" + modelHash + ":" + sceneHash + ":" + contextHash + ":"; } + } + record Binding(String kind, Long id, String name, String signature) {} + record Receipt(Identity identity, List bindings, boolean validated, String origin, + long savedAt, List panels) {} + record Assets(List characters, List appearances, + List locations, String selectedSkillVersion) { + Assets(List characters, List appearances, List locations) { + this(characters, appearances, locations, ""); + } + String signature() { + List all = new ArrayList<>(); all.addAll(characters); all.addAll(appearances); all.addAll(locations); + String original = ShortDramaServiceImpl.planningAssetSignature(all); + return selectedSkillVersion == null || selectedSkillVersion.isBlank() ? original : DigestUtil.sha256Hex(original + selectedSkillVersion); + } + List referenced(List panels) { + Map bindings = new TreeMap<>(); + for (StoryboardPanelData panel : panels) { + ShortDramaBackgroundExtras.parse(panel.getBackgroundExtras()); + if (panel.getLocation() == null || panel.getLocation().isBlank()) throw new BindingMismatch("镜头未绑定已登记场景"); + ShortDramaLocation location = unique(locations.stream().filter(a -> Objects.equals(a.getName(), panel.getLocation())).toList(), "场景", panel.getLocation()); + add(bindings, "location", location.getId(), location.getName(), location); + // A listening actor or an offscreen speaker is also a dependency, even without a visual ref. + Set visualNames = new HashSet<>(); + for (var ref : panel.getCharacters() == null ? List.of() : panel.getCharacters()) { + visualNames.add(ref.getName()); + ShortDramaCharacter character = unique(characters.stream().filter(a -> Objects.equals(a.getName(), ref.getName())).toList(), "角色", ref.getName()); + add(bindings, "character", character.getId(), character.getName(), character); + var choices = appearances.stream().filter(a -> Objects.equals(a.getCharacterId(), character.getId())).toList(); + if (choices.isEmpty()) continue; + String requested = Objects.toString(ref.getAppearance(), "").trim(); + List matched; + List named = choices.stream().filter(a -> Objects.equals(a.getChangeReason(), requested)).toList(); + if (!requested.isBlank() && !named.isEmpty()) { + // Literal stage names can start with a year or age. Ambiguous names must not fall back to an index. + matched = named; + } else if (!requested.isBlank()) { + var index = Pattern.compile("^(?:([0-9]+)|\\[([0-9]+)\\].*)$").matcher(requested); + if (index.matches()) { + int number; + try { number = Integer.parseInt(index.group(1) == null ? index.group(2) : index.group(1)); } + catch (NumberFormatException e) { throw new BindingMismatch("角色形象索引超出范围: " + character.getName() + ":" + requested); } + matched = choices.stream().filter(a -> Objects.equals(a.getAppearanceIndex(), number)).toList(); + } else matched = uniquelyDescribedAppearance(choices, requested); + } else { + matched = choices.size() == 1 ? choices : choices.stream().filter(a -> Objects.equals(a.getAppearanceIndex(), 0)).toList(); + } + ShortDramaCharacterAppearance appearance = unique(matched, "角色形象", character.getName() + ":" + requested); + add(bindings, "appearance", appearance.getId(), character.getName(), appearance); + } + Set otherNames = new HashSet<>(panel.getPresentCharacters() == null ? List.of() : panel.getPresentCharacters()); + String source = Objects.toString(panel.getSourceText(), ""); + for (var character : characters) { + if (character.getName() != null && Pattern.compile(Pattern.quote(character.getName()) + + "(?:[((][^))]*[))])?\\s*[::]\\s*「").matcher(source).find()) otherNames.add(character.getName()); + } + otherNames.removeAll(visualNames); + for (String name : otherNames) { + ShortDramaCharacter character = unique(characters.stream().filter(a -> Objects.equals(a.getName(), name)).toList(), "角色", name); + add(bindings, "character", character.getId(), character.getName(), character); + } + } + return List.copyOf(bindings.values()); + } + void validateSceneCast(String scene, String previous, List panels) { + ShortDramaBackgroundExtras.validateScene(scene, characters, panels); + Set named = sourceNames(scene, previous); + // Anonymous groups are staged separately. An empty source role set is never permission + // to substitute a registered leader or carry an unrelated actor from the preceding scene. + for (var p : panels) { + Set actual = new HashSet<>(p.getPresentCharacters() == null ? List.of() : p.getPresentCharacters()); + if (p.getCharacters() != null) for (var ref : p.getCharacters()) actual.add(ref.getName()); + actual.removeAll(named); + if (!actual.isEmpty()) throw new BindingMismatch("本场原文未安排出镜角色(镜头" + p.getPanelNumber() + "):" + + actual.stream().map(n -> Objects.toString(n, "<空名称>")).sorted().collect(java.util.stream.Collectors.joining("、")) + + ";世界观/全剧角色库不得用于擅加本场演员或CG,请按本场原文重排"); + } + } + Set sourceNames(String scene, String previous) { + String visibleText = scene.replaceAll("「[^」]*」", ""); + Set named = new HashSet<>(); + for (var c : characters) if (mentioned(visibleText, c)) named.add(c.getName()); + // Unnamed pronouns may inherit the preceding scene; no global world/library actor is implied. + if (named.isEmpty() && visibleText.matches("(?s).*[他她它其二人众人].*")) + for (var c : characters) if (mentioned(previous, c)) named.add(c.getName()); + return named; + } + private static boolean mentioned(String text, ShortDramaCharacter character) { + if (character.getName() == null) return false; + if (text.contains(character.getName())) return true; + // Analysis stores explicit script aliases as comma-separated text. Never infer an actor from its global description. + String aliases = Objects.toString(character.getAliases(), ""); + for (String value : aliases.split("[,,、;;\\n]")) { + String alias = value.trim(); + if (alias.length() >= 2 && !alias.equals("无") && text.contains(alias)) return true; + } + return false; + } + private static T unique(List values, String kind, String name) { + if (values.size() != 1) throw new BindingMismatch(kind + "绑定无法唯一核对: " + name); + return values.get(0); + } + private static List uniquelyDescribedAppearance( + List choices, String requested) { + // Models sometimes echo a rich wardrobe description instead of the exact stage label. + // Accept it only when substantial literal clauses point to one saved appearance by a + // clear margin. Shared face traits alone must never choose between two life stages. + List fragments = Arrays.stream(requested.split("[::,,;;。\\n()()、]")) + .map(value -> value.replaceAll("\\s+", "").trim()) + .filter(value -> value.length() >= 4) + .distinct().toList(); + if (fragments.isEmpty()) return List.of(); + record Scored(ShortDramaCharacterAppearance value, int score) {} + List scored = choices.stream().map(value -> { + String haystack = (Objects.toString(value.getChangeReason(), "") + "\n" + + Objects.toString(value.getDescription(), "")).replaceAll("\\s+", ""); + int score = fragments.stream().filter(haystack::contains).mapToInt(String::length).sum(); + return new Scored(value, score); + }).sorted(java.util.Comparator.comparingInt(Scored::score).reversed()).toList(); + if (scored.isEmpty() || scored.get(0).score() < 24) return List.of(); + int runnerUp = scored.size() > 1 ? scored.get(1).score() : 0; + if (scored.get(0).score() < runnerUp + 12) return List.of(); + return List.of(scored.get(0).value()); + } + private static void add(Map result, String kind, Long id, String name, BaseEntity asset) { + if (id == null) throw new BindingMismatch("引用资产缺少已保存ID"); + result.put(kind + ":" + id, new Binding(kind, id, name, ShortDramaServiceImpl.planningAssetSignature(List.of(asset)))); + } + } + static final class BindingMismatch extends IllegalArgumentException { BindingMismatch(String message) { super(message); } } + interface Cache { + String get(String key); + void put(String key, String value); + } + private final Cache cache; + ShortDramaSceneCheckpoint() { + this(new Cache() { + public String get(String key) { return RedisUtils.getCacheObject(key); } + public void put(String key, String value) { RedisUtils.setCacheObject(key, value, Duration.ofDays(7)); } + }); + } + ShortDramaSceneCheckpoint(Cache cache) { this.cache = cache; } + + static Identity identity(Long projectId, ShortDramaScript script, int number, String scene, String previous, + String style, String aspect, String model) { + return identity(projectId, script, number, scene, previous, style, aspect, model, 1); + } + static Identity identity(Long projectId, ShortDramaScript script, int number, String scene, String previous, + String style, String aspect, String model, int minimumShotSeconds) { + String context = String.join("\n", Objects.toString(script.getWorldbuilding(), ""), Objects.toString(script.getOutlineText(), ""), + Objects.toString(previous, ""), Objects.toString(style, ""), Objects.toString(aspect, ""), ShortDramaDirectorSkills.VERSION, ShortDramaDirectorSkills.PACING_VERSION, ShortDramaDirectorSkills.PLANNING_VERSION, "minimumShotSeconds=" + minimumShotSeconds); + return new Identity(projectId, script.getId(), number, DigestUtil.sha256Hex(scene), DigestUtil.sha256Hex(context), DigestUtil.sha256Hex(model), minimumShotSeconds); + } + + Receipt compatible(Identity identity, Assets assets, boolean validated) { + List candidates = new ArrayList<>(); + for (Receipt receipt : read(identity)) { + try { + Identity saved = receipt.identity(); + if (!Objects.equals(saved.projectId(), identity.projectId()) || !Objects.equals(saved.scriptId(), identity.scriptId()) + || saved.sceneNumber() != identity.sceneNumber() || saved.minimumShotSeconds() != identity.minimumShotSeconds() || !Objects.equals(saved.sceneHash(), identity.sceneHash()) || !Objects.equals(saved.contextHash(), identity.contextHash()) + || !Objects.equals(saved.modelHash(), identity.modelHash()) || receipt.validated() != validated + || receipt.panels() == null || receipt.panels().isEmpty()) continue; + if (Objects.equals(receipt.bindings(), assets.referenced(receipt.panels()))) candidates.add(receipt); + } catch (Exception ignored) { /* Unproven/corrupt content is never silently treated as reusable. */ } + } + return candidates.stream().max(Comparator.comparingLong(Receipt::savedAt)).orElse(null); + } + + private List read(Identity identity) { + try { + String value = cache.get(identity.prefix() + "receipts"); + if (value == null) return List.of(); + return AtlasMediaSupport.OBJECT_MAPPER.readValue(value, + AtlasMediaSupport.OBJECT_MAPPER.getTypeFactory().constructCollectionType(List.class, Receipt.class)); + } catch (java.io.IOException ignored) { return List.of(); } + } + + void save(Identity identity, Assets assets, List panels, boolean validated, String origin) { + List bindings = assets.referenced(panels); + Receipt receipt = new Receipt(identity, bindings, validated, origin, System.currentTimeMillis(), panels); + try { + List receipts = new ArrayList<>(read(identity)); + receipts.removeIf(r -> r.validated() == validated && Objects.equals(r.bindings(), bindings)); + receipts.add(receipt); + // One address per scene avoids global scans and tenant-prefix ambiguity. Script generation is already locked. + if (receipts.size() > 20) receipts = new ArrayList<>(receipts.subList(receipts.size() - 20, receipts.size())); + cache.put(identity.prefix() + "receipts", AtlasMediaSupport.OBJECT_MAPPER.writeValueAsString(receipts)); + } catch (java.io.IOException ex) { throw new IllegalStateException("分场检查点序列化失败,未标记可复用", ex); } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaScriptPreparation.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaScriptPreparation.java new file mode 100644 index 00000000..40c0fd74 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaScriptPreparation.java @@ -0,0 +1,14 @@ +package org.ruoyi.service.shortdrama.impl; + +import cn.hutool.core.util.StrUtil; +import org.ruoyi.domain.entity.shortdrama.ShortDramaScript; + +/** Project-specific evidence and world rules, kept separate from screenplay action. */ +public final class ShortDramaScriptPreparation { + private ShortDramaScriptPreparation() {} + + public static String worldContext(ShortDramaScript script) { + return (StrUtil.isBlank(script.getTone()) ? "" : "\n【剧本审阅中维护的风格与基调】\n" + script.getTone() + "\n按当前剧本维护的方向处理角色、场景、材质、光色和表演;具体已批准素材的身份与时点继续保留。\n") + + (StrUtil.isBlank(script.getWorldbuilding()) ? "" : "\n【当前项目世界观,人物与场景必须遵守】\n" + script.getWorldbuilding()); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaScriptProgress.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaScriptProgress.java new file mode 100644 index 00000000..f45f0193 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaScriptProgress.java @@ -0,0 +1,40 @@ +package org.ruoyi.service.shortdrama.impl; + +import java.util.function.Consumer; + +/** Observable output milestones, never provider reasoning text. */ +final class ShortDramaScriptProgress { + record Snapshot(String state, long startedAt, long updatedAt, long lastActivityAt, + long firstContentMs, long firstScriptMs, long elapsedMs, + int promptChars, int contentChars, int scriptChars, int thinkingChars) {} + private final long startedAt = System.currentTimeMillis(); + private final Consumer sink; + private long lastActivityAt, firstContentMs = -1, firstScriptMs = -1, publishedAt; + private int promptChars, contentChars, scriptChars, thinkingChars; + private String state = "waiting"; + + ShortDramaScriptProgress(Consumer sink) { this.sink = sink; } + synchronized void start(int chars) { promptChars = chars; publish(true); } + synchronized void thinking(int chars) { thinkingChars += chars; activity(); publish(false); } + synchronized void content(int chars) { + contentChars += chars; activity(); + boolean first = firstContentMs < 0; + if (first) firstContentMs = lastActivityAt - startedAt; + state = "writing"; publish(first); + } + synchronized void script(int chars) { + scriptChars += chars; + boolean first = firstScriptMs < 0; + if (first) firstScriptMs = System.currentTimeMillis() - startedAt; + publish(first); + } + synchronized void state(String value) { state = value; publish(true); } + private void activity() { lastActivityAt = System.currentTimeMillis(); } + private void publish(boolean force) { + long now = System.currentTimeMillis(); + if (!force && now - publishedAt < 250) return; + publishedAt = now; + sink.accept(new Snapshot(state, startedAt, now, lastActivityAt, firstContentMs, firstScriptMs, + now - startedAt, promptChars, contentChars, scriptChars, thinkingChars)); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImpl.java index 99aca90a..ab5bd197 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaServiceImpl.java @@ -1,10 +1,13 @@ package org.ruoyi.service.shortdrama.impl; +import org.ruoyi.service.media.AtlasMediaSupport; + import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; @@ -18,6 +21,7 @@ import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.chat.response.PartialThinking; import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; import org.ruoyi.common.chat.domain.dto.request.ChatRequest; @@ -33,6 +37,7 @@ import org.ruoyi.common.chat.service.chat.IChatModelService; import org.ruoyi.common.core.utils.MapstructUtils; import org.ruoyi.common.core.utils.StringUtils; import org.ruoyi.common.json.utils.JsonUtils; +import org.ruoyi.service.media.AtlasMediaSupport; import org.ruoyi.constant.ShortDramaImageConstants; import org.ruoyi.domain.bo.shortdrama.ShortDramaIdeaBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo; @@ -79,9 +84,13 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo import java.io.IOException; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; @@ -106,6 +115,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { private final ShortDramaAudioMapper audioMapper; private final ShortDramaVisualAssetService visualAssets; private final ShortDramaSoundService sounds; + private final ShortDramaCharacterVoiceService characterVoices; + private final ShortDramaSkillCatalog skillCatalog; private final IChatModelService chatModelService; private final ChatServiceFactory chatServiceFactory; private final VideoServiceFactory videoServiceFactory; @@ -116,6 +127,16 @@ public class ShortDramaServiceImpl implements IShortDramaService { private final org.ruoyi.common.core.service.OssService ossService; private final java.util.Map activeEmitters = new ConcurrentHashMap<>(); private final java.util.Map storyboardGenerationStates = new ConcurrentHashMap<>(); + private final java.util.Map planningSkillSnapshots = new ConcurrentHashMap<>(); + private final java.util.concurrent.Executor storyboardPlanningExecutor = java.util.concurrent.ForkJoinPool.commonPool(); + private final ShortDramaPlanningStatus planningStatuses = new ShortDramaPlanningStatus(); + private final ShortDramaPlanningStatus assetStatuses = new ShortDramaPlanningStatus("asset-status-v1"); + private final java.util.Map assetGenerationStates = new ConcurrentHashMap<>(); + private final java.util.concurrent.ConcurrentHashMap planningProgress = new java.util.concurrent.ConcurrentHashMap<>(); + private final ShortDramaSceneCandidateDiagnostics sceneCandidates = new ShortDramaSceneCandidateDiagnostics(); + private final ShortDramaVideoSubmissionStore videoSubmissions = new ShortDramaVideoSubmissionStore(); + private final ShortDramaImagePromptEvidence imagePromptEvidence = new ShortDramaImagePromptEvidence(); + private final ShortDramaSceneCheckpoint sceneCheckpoints = new ShortDramaSceneCheckpoint(); /** * 代理流式输出视频,绕过 OSS 强制下载 header。 @@ -178,26 +199,18 @@ public class ShortDramaServiceImpl implements IShortDramaService { return detailVo; } - // ==================== 核心:六阶段流水线 ==================== + // ==================== 创意生成:仅保存剧本,后续阶段由用户显式触发 ==================== @Override public ShortDramaDetailVo createFromIdea(ShortDramaIdeaBo bo, Long userId) { ChatModelVo modelVo = validateAndGetModel(bo.getModel()); AbstractChatService chatService = getChatService(modelVo); - ChatModel chatModel = chatService.buildChatModel(modelVo); + ChatModel chatModel = chatService.buildChatModel(modelVo, ShortDramaWritingRequest.forText(modelVo.getModelName(), sanitizeIdeaInput(bo.getIdea()))); - // Phase 1: 剧本打磨 + // Phase 1: 固定格式剧本生成 ShortDramaScriptResult polishResult = executePhase1_ScriptPolish(chatModel, bo); ShortDramaProject project = buildAndInsertProject(userId, polishResult, bo); - ShortDramaScript script = buildAndInsertScript(project.getId(), polishResult); - - if (Boolean.TRUE.equals(bo.getScriptOnly())) return getDetail(project.getId(), userId); - - // Phase 2: 资产分析(角色+场景提取) - executePhase2_AssetAnalysis(chatModel, project.getId(), script); - - // Phase 3-6: 分镜流水线 - List panels = executeStoryboardPipeline(chatModel, script, project.getId()); + ShortDramaScript script = buildAndInsertScript(project.getId(), polishResult, bo); return getDetail(project.getId(), userId); } @@ -211,49 +224,34 @@ public class ShortDramaServiceImpl implements IShortDramaService { emitter.onTimeout(() -> closeEmitter(emitter)); emitter.onError(error -> closeEmitter(emitter)); - CompletableFuture.runAsync(() -> { + final String tenant = org.ruoyi.common.tenant.helper.TenantHelper.getTenantId(); + var progress = new ShortDramaScriptProgress(snapshot -> sendEmitterEvent(emitter, + SseEmitter.event().name("progress").data(org.ruoyi.common.json.utils.JsonUtils.toJsonString(snapshot)))); + CompletableFuture.runAsync(() -> org.ruoyi.common.tenant.helper.TenantHelper.dynamic(tenant, () -> { Long projectId = null; try { ChatModelVo modelVo = validateAndGetModel(bo.getModel()); AbstractChatService chatService = getChatService(modelVo); - ChatModel chatModel = chatService.buildChatModel(modelVo); - StreamingChatModel streamingModel = chatService.buildStreamingChatModel(modelVo, new ChatRequest()); + ChatRequest streamRequest = ShortDramaWritingRequest.forText(modelVo.getModelName(), sanitizeIdeaInput(bo.getIdea())); + StreamingChatModel streamingModel = chatService.buildStreamingChatModel(modelVo, streamRequest); // Phase 1: 单次流式调用(JSON 元信息 → 分隔符 → 剧本正文逐字推送) - emit(emitter, "polish", "running", "正在生成故事大纲..."); - ShortDramaScriptResult polishResult = executePhase1_Streaming(streamingModel, bo, emitter); + emit(emitter, "polish", "running", "正在生成固定格式剧本..."); + ShortDramaScriptResult polishResult = executePhase1_Streaming(streamingModel, bo, emitter, progress); + progress.state("saving"); ShortDramaProject project = buildAndInsertProject(userId, polishResult, bo); projectId = project.getId(); - ShortDramaScript script = buildAndInsertScript(projectId, polishResult); - emit(emitter, "polish", "done", "剧本打磨完成"); + ShortDramaScript script = buildAndInsertScript(projectId, polishResult, bo); + progress.state("done"); + emit(emitter, "polish", "done", "剧本生成完成"); - if (Boolean.TRUE.equals(bo.getScriptOnly())) { - sendEmitterEvent(emitter, SseEmitter.event().name("complete") - .data("{\"projectId\":\"" + projectId + "\"}")); - completeEmitter(emitter); - return; - } - - // Phase 2: 资产分析(角色 + 场景 并发,流式输出) - emit(emitter, "assets", "running", "正在分析角色和场景..."); - StreamingChatModel assetsStreamModel = chatService.buildStreamingChatModel(modelVo, new ChatRequest()); - executePhase2_AssetAnalysis(chatModel, assetsStreamModel, projectId, script, emitter); - long charCount = characterMapper.selectCount(new LambdaQueryWrapper() - .eq(ShortDramaCharacter::getProjectId, projectId)); - long locCount = locationMapper.selectCount(new LambdaQueryWrapper() - .eq(ShortDramaLocation::getProjectId, projectId)); - emit(emitter, "assets", "done", "提取了 " + charCount + " 个角色、" + locCount + " 个场景"); - - // Phase 3-6: 分镜流水线(各子阶段内部创建 StreamingChatModel 实现流式输出) - List panels = executeStoryboardPipeline(chatModel, script, projectId, emitter); - emit(emitter, "storyboard", "done", "分镜完成,共 " + panels.size() + " 个镜头"); - - // 完成 + // 创建接口始终止于剧本。旧客户端的 scriptOnly=false 也不能提前分析资产或生成分镜。 sendEmitterEvent(emitter, SseEmitter.event() .name("complete") .data("{\"projectId\":\"" + projectId + "\"}")); completeEmitter(emitter); } catch (Exception e) { + progress.state("error"); log.error("流式创建短剧失败", e); String errorData = projectId != null ? "{\"message\":\"" + escapeJson(e.getMessage()) + "\",\"projectId\":\"" + projectId + "\"}" @@ -261,7 +259,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { sendEmitterEvent(emitter, SseEmitter.event().name("error").data(errorData)); completeEmitterWithError(emitter, e); } - }); + })); return emitter; } @@ -299,11 +297,13 @@ public class ShortDramaServiceImpl implements IShortDramaService { private void emit(SseEmitter emitter, String phase, String status, String message) { String data = "{\"phase\":\"" + phase + "\",\"status\":\"" + status + "\",\"message\":\"" + escapeJson(message) + "\"}"; + var progress = emitter == null ? null : planningProgress.get(emitter); + if (progress != null && phase.startsWith("storyboard_") && "running".equals(status)) progress.phase(phase, message); sendEmitterEvent(emitter, SseEmitter.event().name("phase").data(data)); } private void emitStream(SseEmitter emitter, String phase, String text) { - String data = "{\"phase\":\"" + phase + "\",\"text\":\"" + escapeJson(text) + "\"}"; + String data = JsonUtils.toJsonString(Map.of("phase", phase, "text", text)); sendEmitterEvent(emitter, SseEmitter.event().name("stream").data(data)); } @@ -330,14 +330,18 @@ public class ShortDramaServiceImpl implements IShortDramaService { project.setUserId(userId); project.setProjectName(firstNotBlank(bo.getProjectName(), polishResult.getProjectName(), "短剧项目")); project.setDescription(firstNotBlank(polishResult.getDescription(), "")); - project.setArtStyle(firstNotBlank(bo.getArtStyle(), ShortDramaImageConstants.DEFAULT_ART_STYLE)); + project.setOriginalIdea(bo.getIdea()); + project.setArtStyle(firstNotBlank(bo.getArtStyle(), "script-tone")); + project.setAestheticSkillName(bo.getAestheticSkillName()); + project.setDirectorSkillName(bo.getDirectorSkillName()); + validateSkillBindings(project); project.setComposeAspectRatio(normalizeAspectRatio(bo.getAspectRatio())); project.setStatus("draft"); projectMapper.insert(project); return project; } - private ShortDramaScript buildAndInsertScript(Long projectId, ShortDramaScriptResult polishResult) { + private ShortDramaScript buildAndInsertScript(Long projectId, ShortDramaScriptResult polishResult, ShortDramaIdeaBo bo) { ShortDramaScript script = new ShortDramaScript(); script.setId(IdUtil.getSnowflakeNextId()); script.setProjectId(projectId); @@ -350,54 +354,25 @@ public class ShortDramaServiceImpl implements IShortDramaService { return script; } - private List executeStoryboardPipeline(ChatModel chatModel, ShortDramaScript script, Long projectId) { - return executeStoryboardPipeline(chatModel, script, projectId, null); - } - - private List executeStoryboardPipeline(ChatModel chatModel, ShortDramaScript script, Long projectId, SseEmitter emitter) { - // Phase 3: 分镜规划(流式输出由 executePhase3_StoryboardPlan 内部处理) - if (emitter != null) emit(emitter, "storyboard_plan", "running", "正在规划分镜镜头..."); - List panels = executePhase3_StoryboardPlan(chatModel, script, projectId, emitter); - // Phase 3 的成功/失败由子方法内部 emit(因为流式输出的 done/error 在 streamingChat 回调中处理) - if (emitter != null && !panels.isEmpty()) emit(emitter, "storyboard_plan", "done", "分镜规划完成,共 " + panels.size() + " 个镜头"); - - if (!panels.isEmpty()) { - List planPanels = panels; - - // 摄影规则与表演指导使用确定性本地规则生成,避免为整组镜头额外发起两次大模型请求。 - if (emitter != null) emit(emitter, "photography", "running", "正在生成摄影规则..."); - List photographyRules = buildLocalPhotographyRules(planPanels); - if (emitter != null) emit(emitter, "photography", "done", "摄影规则生成完成"); - - if (emitter != null) emit(emitter, "acting", "running", "正在生成表演指导..."); - List actingDirections = buildLocalActingDirections(planPanels); - if (emitter != null) emit(emitter, "acting", "done", "表演指导生成完成"); - - // 分镜细化保留一次模型调用,统一生成景别、运镜和时长匹配的视频/图片提示词。 - if (emitter != null) emit(emitter, "storyboard_detail", "running", "正在细化分镜详情..."); - executePhase6_StoryboardDetail(chatModel, planPanels, projectId, emitter); - normalizeContinuityChain(planPanels); - panels = mergePanelsWithRules(planPanels, photographyRules, actingDirections); - } - persistStoryboards(projectId, script.getId(), panels, script); - return panels; - } - @Override - public ShortDramaDetailVo polishScript(Long projectId, Long userId, String model) { + public ShortDramaDetailVo polishScript(Long projectId, Long userId, String instruction) { ShortDramaProject project = validateProjectOwner(projectId, userId); ShortDramaScript script = scriptMapper.selectOne(new LambdaQueryWrapper() .eq(ShortDramaScript::getProjectId, projectId) .orderByDesc(ShortDramaScript::getId).last("limit 1")); + if (StrUtil.isBlank(instruction)) throw new IllegalArgumentException("修改意见不能为空"); String idea = script != null ? firstNotBlank(script.getScriptText(), script.getOutlineText(), project.getDescription()) : project.getDescription(); - ChatModelVo modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel(); + ChatModelVo modelVo = findChatModel(); AbstractChatService chatService = getChatService(modelVo); - ChatModel chatModel = chatService.buildChatModel(modelVo); + ChatModel chatModel = chatService.buildChatModel(modelVo, ShortDramaWritingRequest.forText(modelVo.getModelName(), null)); ShortDramaIdeaBo bo = new ShortDramaIdeaBo(); bo.setIdea(idea); bo.setProjectName(project.getProjectName()); - ShortDramaScriptResult result = executePhase1_ScriptPolish(chatModel, bo); + bo.setArtStyle(project.getArtStyle()); + bo.setAestheticSkillName(project.getAestheticSkillName()); + bo.setDirectorSkillName(project.getDirectorSkillName()); + ShortDramaScriptResult result = executePhase1_ScriptPolish(chatModel, bo, instruction.trim(), null); if (script == null) { script = new ShortDramaScript(); @@ -414,6 +389,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { script.setOutlineText(firstNotBlank(result.getOutlineText(), script.getOutlineText())); script.setScriptText(firstNotBlank(result.getScriptText(), script.getScriptText())); script.setTone(firstNotBlank(result.getTone(), script.getTone())); + script.setRevisionNotes(instruction.trim()); if (script.getId() != null && scriptMapper.selectById(script.getId()) != null) { scriptMapper.updateById(script); } else { @@ -428,6 +404,20 @@ public class ShortDramaServiceImpl implements IShortDramaService { public Long saveProject(ShortDramaProjectBo bo, Long userId) { ShortDramaProject entity = MapstructUtils.convert(bo, ShortDramaProject.class); entity.setUserId(userId); + if (entity.getId() != null) { + var previous = validateProjectOwner(entity.getId(), userId); + if (bo.getOriginalIdea() == null) entity.setOriginalIdea(previous.getOriginalIdea()); + if (bo.getAestheticSkillName() == null) entity.setAestheticSkillName(previous.getAestheticSkillName()); + if (bo.getDirectorSkillName() == null) entity.setDirectorSkillName(previous.getDirectorSkillName()); + if (bo.getArtStyle() == null) entity.setArtStyle(previous.getArtStyle()); + if (bo.getAestheticSkillName() != null && !Objects.equals(entity.getAestheticSkillName(), previous.getAestheticSkillName()) && StrUtil.isNotBlank(entity.getAestheticSkillName())) { + var selected = skillCatalog.requireEnabled(entity.getAestheticSkillName(), "aesthetic"); + if (StrUtil.isNotBlank(selected.artStyle())) entity.setArtStyle(selected.artStyle()); + } + if (bo.getDirectorSkillName() != null && !Objects.equals(entity.getDirectorSkillName(), previous.getDirectorSkillName()) && StrUtil.isNotBlank(entity.getDirectorSkillName())) + skillCatalog.requireEnabled(entity.getDirectorSkillName(), "director"); + } + else validateSkillBindings(entity); if (entity.getId() == null) { entity.setStatus(StrUtil.blankToDefault(entity.getStatus(), "draft")); projectMapper.insert(entity); @@ -446,13 +436,22 @@ public class ShortDramaServiceImpl implements IShortDramaService { ShortDramaScript existing = bo.getId() == null ? null : scriptMapper.selectById(bo.getId()); if (existing != null && !bo.getProjectId().equals(existing.getProjectId())) throw new IllegalArgumentException("剧本不属于当前项目"); if (existing == null || !java.util.Objects.equals(existing.getScriptText(), bo.getScriptText()) - || !java.util.Objects.equals(existing.getOutlineText(), bo.getOutlineText())) { + || !java.util.Objects.equals(existing.getOutlineText(), bo.getOutlineText()) + || (bo.getTone() != null && !java.util.Objects.equals(existing.getTone(), bo.getTone())) + || (bo.getWorldbuilding() != null && !java.util.Objects.equals(existing.getWorldbuilding(), bo.getWorldbuilding()))) { project.setStatus("script_changed"); projectMapper.updateById(project); videoComposeService.invalidateComposition(project.getId()); } ShortDramaScript entity = MapstructUtils.convert(bo, ShortDramaScript.class); + entity.setScriptText(normalizePlainScriptText(entity.getScriptText())); entity.setSourceType(StrUtil.blankToDefault(entity.getSourceType(), "manual")); + if (existing != null) { + if (bo.getCreationMode() == null) entity.setCreationMode(existing.getCreationMode()); + if (bo.getSourceMaterials() == null) entity.setSourceMaterials(existing.getSourceMaterials()); + if (bo.getWorldbuilding() == null) entity.setWorldbuilding(existing.getWorldbuilding()); + if (bo.getRevisionNotes() == null) entity.setRevisionNotes(existing.getRevisionNotes()); + } if (entity.getId() == null) { scriptMapper.insert(entity); } else { @@ -465,6 +464,10 @@ public class ShortDramaServiceImpl implements IShortDramaService { @Override public List generateStoryboards(Long projectId, Long scriptId, String model, Long userId) { + return generateStoryboards(projectId, scriptId, model, userId, 1); + } + + private List generateStoryboards(Long projectId, Long scriptId, String model, Long userId, int minimumShotSeconds) { if (!beginStoryboardGeneration(scriptId)) { throw new IllegalStateException("该剧本正在生成分镜,请勿重复提交"); } @@ -478,19 +481,18 @@ public class ShortDramaServiceImpl implements IShortDramaService { ChatModelVo modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel(); AbstractChatService chatService = getChatService(modelVo); - ChatModel chatModel = chatService.buildChatModel(modelVo); + ChatModel chatModel = chatService.buildChatModel(modelVo, ShortDramaWritingRequest.forText(modelVo.getModelName(), null)); - List panels = executePhase3_StoryboardPlan(chatModel, null, script, projectId, null, modelVo.getModelName()); + List panels = executePhase3_StoryboardPlan(chatModel, null, script, projectId, null, modelVo.getModelName(), minimumShotSeconds); + boolean sourceScenes = !panels.isEmpty(); if (panels.isEmpty()) { panels = fallbackPanels(script); } if (!panels.isEmpty()) { - List photographyRules = buildLocalPhotographyRules(panels); - List actingDirections = buildLocalActingDirections(panels); executePhase6_StoryboardDetail(chatModel, null, panels, projectId, null, modelVo.getModelName()); - normalizeContinuityChain(panels); - panels = mergePanelsWithRules(panels, photographyRules, actingDirections); + normalizeContinuityChain(panels, sourceScenes); } + validateCompletedSceneBudgets(script, panels, minimumShotSeconds); return persistStoryboards(projectId, scriptId, panels, script); } finally { endStoryboardGeneration(scriptId); @@ -502,9 +504,15 @@ public class ShortDramaServiceImpl implements IShortDramaService { return generateStoryboards(projectId, scriptId, model, userId); } + @Override + public List planStoryboard(Long projectId, Long scriptId, String model, Long userId, Integer minimumShotSeconds) { + return generateStoryboards(projectId, scriptId, model, userId, checkedMinimumShotSeconds(minimumShotSeconds)); + } + @Override public int importReviewedPlan(Long projectId, org.ruoyi.domain.bo.shortdrama.ShortDramaReviewedPlanBo review, Long userId) { validateProjectOwner(projectId,userId); + int minimum = checkedMinimumShotSeconds(review.getMinimumShotSeconds()); Long scriptId=review.getScriptId(); if(!beginStoryboardGeneration(scriptId))throw new IllegalStateException("请等待当前分镜生成结束"); try { @@ -515,12 +523,24 @@ public class ShortDramaServiceImpl implements IShortDramaService { if(!existing.isEmpty())throw new IllegalStateException("已有分镜请使用分镜修订入口;规划导入仅适用于尚未落库的项目"); List scenes=splitScriptScenes(script.getScriptText()); var panels=review.getPanels(); - validateReviewedPlan(scenes,panels); + List selected = reviewedSceneNumbers(scenes, panels, review.getSceneNumbers()); + var assets = planningAssets(projectId); + if (review.getSceneNumbers() != null && !java.util.Objects.equals(assets.signature(), review.getExpectedAssetSignature())) + throw new IllegalStateException("当前资产签名已变化或未确认,请重新审阅所选场次并读取checkpoint-status"); + // Validate every selected scene before writing any checkpoint. A partial import is explicit approval, never migration. + for (int number : selected) { + var group = panels.stream().filter(p -> java.util.Objects.equals(p.getSceneNumber(), number)).toList(); + validateScenePlan(scenes.get(number - 1), group, minimum); + assets.validateSceneCast(scenes.get(number - 1), previousSceneBridge(scenes, number - 1), group); + assets.referenced(group); + } var model=StrUtil.isNotBlank(review.getModel())?validateAndGetModel(review.getModel()):findChatModel(); - String checkpoint=storyboardCheckpointKey(script,projectId)+":parallel-v3:"+ShortDramaDirectorSkills.VERSION+":"+cn.hutool.crypto.digest.DigestUtil.sha256Hex(model.getModelName()); - for(int i=0;ijava.util.Objects.equals(p.getSceneNumber(),sceneNo)).toList(); + sceneCheckpoints.save(sceneIdentity(projectId, script, scenes, sceneNo - 1, style, aspect, model.getModelName(), minimum), + assets, group, true, "reviewed_import"); RedisUtils.setCacheObject(checkpoint+":scene:"+sceneNo,JsonUtils.toJsonString(group),java.time.Duration.ofDays(7)); } return panels.size(); @@ -528,18 +548,222 @@ public class ShortDramaServiceImpl implements IShortDramaService { } static void validateReviewedPlan(List scenes,List panels) { + for (int sceneNo : reviewedSceneNumbers(scenes, panels, null)) { + var group = panels.stream().filter(p -> java.util.Objects.equals(p.getSceneNumber(), sceneNo)).toList(); + validateScenePlan(scenes.get(sceneNo - 1), group); + } + } + + static List reviewedSceneNumbers(List scenes, List panels, List selected) { if(panels==null || panels.isEmpty() || panels.size()>500)throw new IllegalArgumentException("规划镜头数量无效"); if(panels.stream().anyMatch(p->p.getSceneNumber()==null || p.getSceneNumber()<1 || p.getSceneNumber()>scenes.size()))throw new IllegalArgumentException("规划场次不属于当前剧本"); - for(int i=0;i numbers = selected == null ? java.util.stream.IntStream.rangeClosed(1, scenes.size()).boxed().toList() : new ArrayList<>(selected); + if (numbers.isEmpty() || numbers.stream().anyMatch(n -> n == null || n < 1 || n > scenes.size()) + || new java.util.HashSet<>(numbers).size() != numbers.size()) throw new IllegalArgumentException("审阅场次须唯一且属于当前剧本"); + if (panels.stream().anyMatch(p -> !numbers.contains(p.getSceneNumber()))) throw new IllegalArgumentException("导入镜头包含未明确批准的场次"); + for(int sceneNo : numbers) { var group=panels.stream().filter(p->java.util.Objects.equals(p.getSceneNumber(),sceneNo)).toList(); if(group.isEmpty())throw new IllegalArgumentException("缺少第"+sceneNo+"场规划"); - validateScenePlan(scenes.get(i),group); } + return numbers; + } + + @Override + public java.util.Map storyboardCheckpointStatus(Long projectId, Long scriptId, String model, Long userId) { + return storyboardCheckpointStatus(projectId, scriptId, model, userId, 1); + } + + @Override + public java.util.Map storyboardCheckpointStatus(Long projectId, Long scriptId, String model, Long userId, Integer minimumShotSeconds) { + int minimum = checkedMinimumShotSeconds(minimumShotSeconds); + validateProjectOwner(projectId, userId); + var script = scriptMapper.selectById(scriptId); + if (script == null || !projectId.equals(script.getProjectId())) throw new IllegalArgumentException("剧本不属于当前项目"); + var modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel(); + String text = firstNotBlank(script.getScriptText(), script.getOutlineText(), ""); + List scenes = splitScriptScenes(text); + var assets = planningAssets(projectId); + String style = artStyleSuffix(projectId), aspect = projectAspectRatio(projectId); + String legacy = planningCheckpointKey(script, projectId, modelVo.getModelName(), minimum); + List> entries = new ArrayList<>(); + for (int i = 0; i < scenes.size(); i++) { + var identity = sceneIdentity(projectId, script, scenes, i, style, aspect, modelVo.getModelName(), minimum); + var receipt = sceneCheckpoints.compatible(identity, assets, true); + List plan = receipt == null ? null : receipt.panels(); + String origin = receipt == null ? "none" : receipt.origin(); + if (plan == null) { + plan = parsePanelList(RedisUtils.getCacheObject(legacy + ":scene:" + (i + 1))); + if (plan != null && !plan.isEmpty()) origin = "legacy_exact_fingerprint"; + } + boolean valid = false; + try { if (plan != null && !plan.isEmpty()) { validateScenePlan(scenes.get(i), plan, minimum); assets.referenced(plan); assets.validateSceneCast(scenes.get(i), previousSceneBridge(scenes, i), plan); valid = true; } } + catch (RuntimeException ignored) { /* Only current valid proof is advertised as reusable. */ } + var draft = sceneCheckpoints.compatible(identity, assets, false); + var entry = new LinkedHashMap(); + entry.put("sceneNumber", i + 1); entry.put("sceneHash", identity.sceneHash()); entry.put("contextHash", identity.contextHash()); + entry.put("recoverableValidated", valid); entry.put("recoverableDraft", draft != null); + entry.put("state", valid ? "validated" : draft != null ? "draft_needs_validation" : "missing_or_incompatible"); + entry.put("proofSource", valid ? origin : "none"); + entry.put("panelCount", valid ? plan.size() : 0); entry.put("totalSeconds", valid ? plan.stream().mapToInt(StoryboardPanelData::getDuration).sum() : 0); + if (valid) entry.put("bindings", assets.referenced(plan)); + entries.add(entry); + } + var result = new LinkedHashMap(); + result.put("schemaVersion", ShortDramaSceneCheckpoint.VERSION); result.put("projectId", projectId); result.put("scriptId", scriptId); + result.put("scriptHash", cn.hutool.crypto.digest.DigestUtil.sha256Hex(text)); result.put("model", modelVo.getModelName()); + result.put("currentAssetSignature", assets.signature()); result.put("sceneCount", scenes.size()); result.put("scenes", entries); + result.put("minimumShotSeconds", minimum); + result.put("validatedSceneCount", entries.stream().filter(e -> Boolean.TRUE.equals(e.get("recoverableValidated"))).count()); + result.put("observedAt", java.time.Instant.now().toString()); + return result; + } + + @Override + public java.util.Map storyboardCandidates(Long projectId, Long scriptId, String model, Long userId, Integer minimumShotSeconds) { + int minimum = checkedMinimumShotSeconds(minimumShotSeconds == null ? 4 : minimumShotSeconds); + validateProjectOwner(projectId, userId); + var script = scriptMapper.selectById(scriptId); + if (script == null || !projectId.equals(script.getProjectId())) throw new IllegalArgumentException("剧本不属于当前项目"); + var modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel(); + String text = firstNotBlank(script.getScriptText(), script.getOutlineText(), ""); + List scenes = splitScriptScenes(text); + String style = artStyleSuffix(projectId), aspect = projectAspectRatio(projectId); + List> entries = new ArrayList<>(); + for (int i = 0; i < scenes.size(); i++) { + var identity = sceneIdentity(projectId, script, scenes, i, style, aspect, modelVo.getModelName(), minimum); + var entry = new LinkedHashMap(); + entry.put("sceneNumber", i + 1); entry.put("identity", identity); entry.put("candidates", sceneCandidates.read(identity)); + entries.add(entry); + } + var result = new LinkedHashMap(); + result.put("schemaVersion", ShortDramaSceneCandidateDiagnostics.VERSION); result.put("projectId", projectId); result.put("scriptId", scriptId); + result.put("model", modelVo.getModelName()); result.put("scriptHash", cn.hutool.crypto.digest.DigestUtil.sha256Hex(text)); + result.put("minimumShotSeconds", minimum); result.put("state", "unreviewed_candidates_only"); result.put("validated", false); + result.put("retentionDays", 7); result.put("maxResponseChars", ShortDramaSceneCandidateDiagnostics.MAX_RESPONSE_CHARS); + result.put("maxCandidatesPerScene", ShortDramaSceneCandidateDiagnostics.MAX_CANDIDATES); result.put("scenes", entries); + result.put("observedAt", java.time.Instant.now().toString()); + return result; + } + + @Override + public java.util.Map storyboardPlanningStatus(Long projectId, Long scriptId, String requestId, Long userId) { + validateProjectOwner(projectId, userId); + var script = scriptMapper.selectById(scriptId); + if (script == null || !projectId.equals(script.getProjectId())) throw new IllegalArgumentException("剧本不属于当前项目"); + var status = planningStatuses.read(projectId, scriptId, ShortDramaPlanningStatus.checkedRequestId(requestId)); + var result = planningStatusData(status, projectId, scriptId); + if (status != null && "done".equals(status.state()) && StrUtil.isNotBlank(status.storyboardHash())) { + var current = storyboardMapper.selectVoList(new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getScriptId, scriptId) + .orderByAsc(ShortDramaStoryboard::getSceneNo)); + result.put("currentStoryboardIds", current.stream().map(ShortDramaStoryboardVo::getId).toList()); + result.put("currentStoryboardHash", storyboardReceiptHash(current)); + } + result.put("currentScriptHash", cn.hutool.crypto.digest.DigestUtil.sha256Hex(firstNotBlank(script.getScriptText(), script.getOutlineText(), ""))); + result.put("observedAt", java.time.Instant.now().toString()); + return result; + } + + private java.util.Map planningStatusData(ShortDramaPlanningStatus.Status status, Long projectId, Long scriptId) { + var result = new LinkedHashMap(); + result.put("schemaVersion", ShortDramaPlanningStatus.VERSION); result.put("projectId", projectId); result.put("scriptId", scriptId); + result.put("requestId", status == null ? null : status.requestId()); + result.put("state", status == null ? (storyboardGenerationStates.containsKey(scriptId) ? "running" : "not_observed") + : !status.terminal() && !planningStatuses.currentRuntime(status) ? "submission_unknown" : status.state()); + result.put("terminal", status != null && status.terminal()); + result.put("activeLock", storyboardGenerationStates.containsKey(scriptId)); + result.put("retrySafe", false); // Neither a missing receipt nor a dropped SSE proves that a paid request failed. + if (status != null) { + result.put("scriptHash", status.scriptHash()); result.put("model", status.model()); result.put("minimumShotSeconds", status.minimumShotSeconds()); + result.put("submittedAt", status.submittedAt()); result.put("updatedAt", status.updatedAt()); + result.put("panelCount", status.panelCount()); result.put("error", status.error()); + result.put("storyboardIds", status.storyboardIds()); result.put("storyboardHash", status.storyboardHash()); + var progress = ShortDramaPlanningProgress.read(projectId, scriptId, status.requestId()); + if (progress != null) result.put("progress", progress); + } + return result; + } + + private static String storyboardReceiptHash(List storyboards) { + var payload = new ArrayList>(); + storyboards.stream() + .sorted(java.util.Comparator.comparing(ShortDramaStoryboardVo::getSceneNo) + .thenComparing(ShortDramaStoryboardVo::getId)) + .forEach(row -> { + var value = new LinkedHashMap(); + value.put("id", row.getId()); value.put("projectId", row.getProjectId()); value.put("scriptId", row.getScriptId()); + value.put("sceneNo", row.getSceneNo()); value.put("sceneTitle", row.getSceneTitle()); value.put("sceneText", row.getSceneText()); + value.put("sceneType", row.getSceneType()); value.put("shotType", row.getShotType()); value.put("cameraMove", row.getCameraMove()); + value.put("charactersJson", row.getCharactersJson()); value.put("locationName", row.getLocationName()); + value.put("photographyRules", row.getPhotographyRules()); value.put("actingNotes", row.getActingNotes()); + value.put("continuityJson", row.getContinuityJson()); value.put("sourceText", row.getSourceText()); + value.put("imagePrompt", row.getImagePrompt()); value.put("durationSeconds", row.getDurationSeconds()); + value.put("videoPrompt", row.getVideoPrompt()); value.put("videoUrl", row.getVideoUrl()); value.put("videoId", row.getVideoId()); + value.put("videoStatus", row.getVideoStatus()); value.put("lastFrameUrl", row.getLastFrameUrl()); + payload.add(value); + }); + try { + return cn.hutool.crypto.digest.DigestUtil.sha256Hex(org.ruoyi.service.media.AtlasMediaSupport.OBJECT_MAPPER.writeValueAsString(payload)); + } catch (java.io.IOException error) { + throw new IllegalStateException("分镜完成回执哈希生成失败", error); + } + } + + private ShortDramaSceneCheckpoint.Assets planningAssets(Long projectId) { + var characters = characterMapper.selectList(new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId, projectId)); + var appearances = characters.isEmpty() ? List.of() : characterAppearanceMapper.selectList( + new LambdaQueryWrapper().in(ShortDramaCharacterAppearance::getCharacterId, characters.stream().map(ShortDramaCharacter::getId).toList())); + var locations = locationMapper.selectList(new LambdaQueryWrapper().eq(ShortDramaLocation::getProjectId, projectId)); + var project = projectMapper.selectById(projectId); + String version = project != null && (StrUtil.isNotBlank(project.getAestheticSkillName()) || StrUtil.isNotBlank(project.getDirectorSkillName())) + ? skillCatalog.projectVersion(project) : ""; + return new ShortDramaSceneCheckpoint.Assets(characters, appearances, locations, version); + } + + private static ShortDramaSceneCheckpoint.Identity sceneIdentity(Long projectId, ShortDramaScript script, List scenes, + int index, String style, String aspect, String model, int minimum) { + return ShortDramaSceneCheckpoint.identity(projectId, script, index + 1, scenes.get(index), previousSceneBridge(scenes, index), style, aspect, model, minimum); + } + + private static String previousSceneBridge(List scenes, int index) { + String previous = index == 0 ? "" : scenes.get(index - 1); + return previous.substring(Math.max(0, previous.length() - 1000)); + } + + private static int checkedMinimumShotSeconds(Integer minimum) { + int value = minimum == null ? 1 : minimum; + if (value < 1 || value > 15) throw new IllegalArgumentException("minimumShotSeconds须为1至15的整数"); + return value; + } + + private String planningCheckpointKey(ShortDramaScript script, Long projectId, String model, int minimum) { + var project = projectMapper.selectById(projectId); + String selectedVersion = project != null && (StrUtil.isNotBlank(project.getAestheticSkillName()) || StrUtil.isNotBlank(project.getDirectorSkillName())) + ? ":skills:" + skillCatalog.projectVersion(project) : ""; + return storyboardCheckpointKey(script, projectId) + ":parallel-v3:" + ShortDramaDirectorSkills.VERSION + ":" + ShortDramaDirectorSkills.PACING_VERSION + ":" + ShortDramaDirectorSkills.PLANNING_VERSION + ":" + + cn.hutool.crypto.digest.DigestUtil.sha256Hex(model) + (minimum == 1 ? "" : ":min:" + minimum) + selectedVersion; } @Override public SseEmitter planStoryboardStream(Long projectId, Long scriptId, String model, Long userId) { + return planStoryboardStream(projectId, scriptId, model, userId, 1); + } + + @Override + public SseEmitter planStoryboardStream(Long projectId, Long scriptId, String model, Long userId, Integer minimumShotSeconds) { + return planStoryboardStream(projectId, scriptId, model, userId, minimumShotSeconds, null); + } + + @Override + public SseEmitter planStoryboardStream(Long projectId, Long scriptId, String model, Long userId, Integer minimumShotSeconds, String requestId) { + int minimum = checkedMinimumShotSeconds(minimumShotSeconds); + validateProjectOwner(projectId, userId); + ShortDramaScript script = scriptMapper.selectById(scriptId); + if (script == null || !projectId.equals(script.getProjectId())) throw new IllegalArgumentException("剧本不属于当前项目"); + ChatModelVo modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel(); + String requested = ShortDramaPlanningStatus.checkedRequestId(requestId); + String scriptHash = cn.hutool.crypto.digest.DigestUtil.sha256Hex(firstNotBlank(script.getScriptText(), script.getOutlineText(), "")); + String tenant = org.ruoyi.common.tenant.helper.TenantHelper.getTenantId(); SseEmitter emitter = new SseEmitter(7_200_000L); AtomicBoolean emitterActive = new AtomicBoolean(true); activeEmitters.put(emitter, emitterActive); @@ -547,73 +771,115 @@ public class ShortDramaServiceImpl implements IShortDramaService { emitter.onTimeout(() -> closeEmitter(emitter)); emitter.onError(error -> closeEmitter(emitter)); - String tenant = org.ruoyi.common.tenant.helper.TenantHelper.getTenantId(); - CompletableFuture.runAsync(() -> org.ruoyi.common.tenant.helper.TenantHelper.dynamic(tenant, () -> { - if (!beginStoryboardGeneration(scriptId)) { - sendEmitterEvent(emitter, SseEmitter.event().name("error") - .data("{\"message\":\"该剧本正在生成分镜,请勿重复提交\"}")); - completeEmitter(emitter); - return; - } - try { - validateProjectOwner(projectId, userId); - ShortDramaScript script = scriptMapper.selectById(scriptId); - if (script == null || !projectId.equals(script.getProjectId())) { - throw new IllegalArgumentException("剧本不存在"); + if (requested != null) { + var existing = planningStatuses.read(projectId, scriptId, requested); + if (existing != null) { + if (!scriptHash.equals(existing.scriptHash()) || !modelVo.getModelName().equals(existing.model()) || minimum != existing.minimumShotSeconds()) { + completeEmitter(emitter); + throw new IllegalArgumentException("requestId已用于不同的剧本、模型或镜头时长参数"); } - - videoComposeService.invalidateComposition(projectId); - - ChatModelVo modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel(); - AbstractChatService chatService = getChatService(modelVo); - ChatModel chatModel = chatService.buildChatModel(modelVo); - StreamingChatModel streamingModel = chatService.buildStreamingChatModel(modelVo, new ChatRequest()); - - log.info("开始流式生成分镜: projectId={}, scriptId={}, model={}", projectId, scriptId, modelVo.getModelName()); - emit(emitter, "storyboard_plan", "running", "正在规划分镜镜头,模型开始输出后会实时显示..."); - List panels = executePhase3_StoryboardPlan(chatModel, streamingModel, script, projectId, emitter, modelVo.getModelName()); - if (panels.isEmpty()) { - throw new IllegalStateException("分镜规划失败,模型未返回有效镜头"); - } - emit(emitter, "storyboard_plan", "done", "分镜规划完成,共 " + panels.size() + " 个镜头"); - - emit(emitter, "photography", "running", "正在生成摄影规则..."); - List photographyRules = buildLocalPhotographyRules(panels); - emit(emitter, "photography", "done", "摄影规则生成完成"); - - emit(emitter, "acting", "running", "正在生成表演指导..."); - List actingDirections = buildLocalActingDirections(panels); - emit(emitter, "acting", "done", "表演指导生成完成"); - - emit(emitter, "storyboard_detail", "running", "正在细化镜头提示词和时长..."); - executePhase6_StoryboardDetail(chatModel, streamingModel, panels, projectId, emitter, modelVo.getModelName()); - emit(emitter, "storyboard_detail", "done", "全部镜头细化完成"); - normalizeContinuityChain(panels); - panels = mergePanelsWithRules(panels, photographyRules, actingDirections); - List storyboards = persistStoryboards(projectId, scriptId, panels, script); - - // Keep validated content-addressed checkpoints for seven days; a successful run is reusable. - log.info("流式生成分镜完成: projectId={}, count={}", projectId, storyboards.size()); - sendEmitterEvent(emitter, SseEmitter.event().name("complete") - .data("{\"projectId\":\"" + projectId + "\",\"count\":" + storyboards.size() + "}")); - completeEmitter(emitter); - } catch (Exception e) { - log.error("流式生成分镜失败: projectId={}, scriptId={}", projectId, scriptId, e); - sendEmitterEvent(emitter, SseEmitter.event().name("error") - .data("{\"message\":\"" + escapeJson(e.getMessage()) + "\"}")); - completeEmitterWithError(emitter, e); - } finally { - endStoryboardGeneration(scriptId); + sendEmitterEvent(emitter, SseEmitter.event().name("submission").data(planningStatusData(existing, projectId, scriptId))); + completeEmitter(emitter); return emitter; // Replay a receipt; never replay the model request. } - })); + } + if (!beginStoryboardGeneration(scriptId)) { + var busy = storyboardPlanningStatus(projectId, scriptId, null, userId); + busy.put("message", "该剧本正在生成分镜,请查询原请求状态,勿重复提交"); + sendEmitterEvent(emitter, SseEmitter.event().name("error").data(busy)); + completeEmitter(emitter); return emitter; + } + final ShortDramaPlanningStatus.Status queued; + try { + var previous = planningStatuses.read(projectId, scriptId, null); + if (previous != null && !previous.terminal()) { + if (planningStatuses.currentRuntime(previous)) + throw new IllegalStateException("原分镜请求尚未确认终态,请先查询requestId=" + previous.requestId() + ",勿重复提交"); + planningStatuses.update(previous, "error", null, + "服务进程已重启,原规划任务不在当前运行实例中;允许新requestId复用内容检查点继续规划"); + } + queued = planningStatuses.queued(projectId, scriptId, requested == null ? java.util.UUID.randomUUID().toString() : requested, + scriptHash, modelVo.getModelName(), minimum); + } catch (RuntimeException e) { endStoryboardGeneration(scriptId); completeEmitter(emitter); throw e; } + var progress = new ShortDramaPlanningProgress.Tracker(queued.requestId(), queued.submittedAt(), + ShortDramaPlanningProgress.persistence(projectId, scriptId, queued.requestId(), snapshot -> + sendEmitterEvent(emitter, SseEmitter.event().name("progress").data(JsonUtils.toJsonString(snapshot))))); + planningProgress.put(emitter, progress); + progress.phase("storyboard_plan", "分镜请求已登记,等待规划"); + // Queue ACK is buffered by SseEmitter before Spring installs its response handler. + sendEmitterEvent(emitter, SseEmitter.event().name("submission").data(planningStatusData(queued, projectId, scriptId))); + emit(emitter, "storyboard_plan", "queued", "分镜请求已登记排队,请用requestId查询,勿重复提交"); + try { + CompletableFuture.runAsync(() -> { + try { + org.ruoyi.common.tenant.helper.TenantHelper.dynamic(tenant, () -> { + planningStatuses.update(queued, "running", null, null); + videoComposeService.invalidateComposition(projectId); + + AbstractChatService chatService = getChatService(modelVo); + ChatRequest planningStreamRequest = ShortDramaWritingRequest.forText(modelVo.getModelName(), firstNotBlank(script.getScriptText(), script.getOutlineText(), "")); + ChatModel chatModel = chatService.buildChatModel(modelVo, planningStreamRequest); + StreamingChatModel streamingModel = chatService.buildStreamingChatModel(modelVo, planningStreamRequest); + + log.info("开始流式生成分镜: projectId={}, scriptId={}, model={}", projectId, scriptId, modelVo.getModelName()); + emit(emitter, "storyboard_plan", "running", "正在规划分镜镜头,模型开始输出后会实时显示..."); + List panels = executePhase3_StoryboardPlan(chatModel, streamingModel, script, projectId, emitter, modelVo.getModelName(), minimum, queued.requestId()); + if (panels.isEmpty()) { + throw new IllegalStateException("分镜规划失败,模型未返回有效镜头"); + } + emit(emitter, "storyboard_plan", "done", "分镜规划完成,共 " + panels.size() + " 个镜头"); + + emit(emitter, "storyboard_detail", "running", "正在细化镜头提示词和时长..."); + executePhase6_StoryboardDetail(chatModel, streamingModel, panels, projectId, emitter, modelVo.getModelName()); + emit(emitter, "storyboard_detail", "done", "全部镜头细化完成"); + normalizeContinuityChain(panels, true); // Space/time montage cuts must not renumber budget scenes. + validateCompletedSceneBudgets(script, panels, minimum); + progress.phase("persist", "完整镜头已校验,正在保存分镜"); + List storyboards = persistStoryboards(projectId, scriptId, panels, script); + + // Keep validated content-addressed checkpoints for seven days; a successful run is reusable. + log.info("流式生成分镜完成: projectId={}, count={}", projectId, storyboards.size()); + String storyboardHash = storyboardReceiptHash(storyboards); + var completed = planningStatuses.complete(queued, storyboards.stream().map(ShortDramaStoryboardVo::getId).toList(), storyboardHash); + progress.terminal("done"); + sendEmitterEvent(emitter, SseEmitter.event().name("complete") + .data(planningStatusData(completed, projectId, scriptId))); + completeEmitter(emitter); + }); + } catch (Exception e) { + log.error("流式生成分镜失败: projectId={}, scriptId={}", projectId, scriptId, e); + // dynamic(...) has already restored the worker's tenant here. Persist the terminal + // receipt in the submission's namespace, not the executor thread's default tenant. + org.ruoyi.common.tenant.helper.TenantHelper.dynamic(tenant, + () -> { planningStatuses.update(queued, "error", null, e.getMessage()); }); + progress.terminal("error"); + sendEmitterEvent(emitter, SseEmitter.event().name("error") + .data("{\"requestId\":\"" + queued.requestId() + "\",\"message\":\"" + escapeJson(ShortDramaSceneCandidateDiagnostics.redact(e.getMessage())) + "\"}")); + completeEmitterWithError(emitter, e); + } finally { + planningProgress.remove(emitter); + endStoryboardGeneration(scriptId); + } + }, storyboardPlanningExecutor); + } catch (RuntimeException e) { + planningStatuses.update(queued, "error", null, "后台任务未接受排队请求: " + e.getMessage()); + progress.terminal("error"); planningProgress.remove(emitter); + endStoryboardGeneration(scriptId); completeEmitterWithError(emitter, e); + } return emitter; } - private boolean beginStoryboardGeneration(Long scriptId) { - return storyboardGenerationStates.putIfAbsent(scriptId, new AtomicBoolean(true)) == null; + private synchronized boolean beginStoryboardGeneration(Long scriptId) { + if (assetGenerationStates.containsKey(scriptId)) throw new IllegalStateException("该剧本正在分析资产,请完成后再生成分镜"); + if (storyboardGenerationStates.putIfAbsent(scriptId, new AtomicBoolean(true)) != null) return false; + try { + var script = scriptMapper.selectById(scriptId); + planningSkillSnapshots.put(scriptId, skillCatalog.snapshot(script == null ? null : projectMapper.selectById(script.getProjectId()))); + return true; + } catch (RuntimeException error) { planningSkillSnapshots.remove(scriptId); storyboardGenerationStates.remove(scriptId); throw error; } } private void endStoryboardGeneration(Long scriptId) { + planningSkillSnapshots.remove(scriptId); storyboardGenerationStates.remove(scriptId); } @@ -629,7 +895,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { } List panels = toPanelDataList(existing); ChatModelVo modelVo = findChatModel(); - ChatModel chatModel = getChatService(modelVo).buildChatModel(modelVo); + ChatModel chatModel = getChatService(modelVo).buildChatModel(modelVo, ShortDramaWritingRequest.forText(modelVo.getModelName(), null)); List photographyRules = executePhase4_PhotographyRules(chatModel, panels, projectId); mergePhotographyRules(panels, photographyRules); for (int i = 0; i < existing.size() && i < panels.size(); i++) { @@ -653,7 +919,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { } List panels = toPanelDataList(existing); ChatModelVo modelVo = findChatModel(); - ChatModel chatModel = getChatService(modelVo).buildChatModel(modelVo); + ChatModel chatModel = getChatService(modelVo).buildChatModel(modelVo, ShortDramaWritingRequest.forText(modelVo.getModelName(), null)); List actingDirections = executePhase5_ActingDirections(chatModel, panels, projectId); mergeActingDirections(panels, actingDirections); for (int i = 0; i < existing.size() && i < panels.size(); i++) { @@ -667,9 +933,73 @@ public class ShortDramaServiceImpl implements IShortDramaService { // ==================== 分镜 CRUD / 视频 ==================== + private void lockStoryboardStructure(Long projectId, Long scriptId, Long userId) { + var project = projectMapper.selectOne(new LambdaQueryWrapper() + .eq(ShortDramaProject::getId, projectId).last("FOR UPDATE")); + if (project == null || !userId.equals(project.getUserId())) throw new IllegalArgumentException("项目不存在或无权限"); + var script = scriptMapper.selectById(scriptId); + if (script == null || !projectId.equals(script.getProjectId())) throw new IllegalArgumentException("剧本不属于当前项目"); + if (storyboardGenerationStates.containsKey(scriptId)) throw new IllegalStateException("分镜正在生成,请完成后再增删"); + if (java.util.Set.of("pending", "queued", "processing", "running", "composing").contains(java.util.Objects.toString(project.getComposeStatus(), ""))) + throw new IllegalStateException("成片正在合成,请完成后再增删分镜"); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ShortDramaStoryboardVo addStoryboard(Long projectId, Long scriptId, Long afterId, Long userId) { + lockStoryboardStructure(projectId, scriptId, userId); + var shots = storyboardMapper.selectList(new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getScriptId, scriptId).orderByAsc(ShortDramaStoryboard::getSceneNo)); + int position = shots.size(); + if (afterId != null) { + position = -1; + for (int i = 0; i < shots.size(); i++) if (afterId.equals(shots.get(i).getId())) position = i + 1; + if (position < 0) throw new IllegalArgumentException("插入位置的分镜不存在"); + } + Map numbering = new java.util.HashMap<>(); + for (int i = 0; i < shots.size(); i++) numbering.put(shots.get(i).getSceneNo(), i < position ? i + 1 : i + 2); + visualAssets.reindexStoryboardProps(projectId, numbering, null); + for (int i = shots.size() - 1; i >= position; i--) { + storyboardMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaStoryboard::getId, shots.get(i).getId()).set(ShortDramaStoryboard::getSceneNo, i + 2)); + } + var shot = new ShortDramaStoryboard(); + shot.setProjectId(projectId); shot.setScriptId(scriptId); shot.setSceneNo(position + 1); + shot.setSceneTitle("新增镜头"); shot.setVideoStatus("pending"); shot.setDurationSeconds(5); + // Start with an independent draft; never duplicate a neighbour's media or task references. + shot.setContinuityJson("{}"); shot.setVideoPrompt(""); + storyboardMapper.insert(shot); + videoComposeService.invalidateComposition(projectId); + return MapstructUtils.convert(shot, ShortDramaStoryboardVo.class); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteStoryboard(Long storyboardId, Long userId) { + var shot = storyboardMapper.selectById(storyboardId); + if (shot == null) throw new IllegalArgumentException("分镜不存在"); + lockStoryboardStructure(shot.getProjectId(), shot.getScriptId(), userId); + if (videoSubmissionInFlight(shot)) throw new IllegalStateException("此镜头视频仍在生成或等待确认,请查询完成后再删除"); + var before = storyboardMapper.selectList(new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getScriptId, shot.getScriptId()).orderByAsc(ShortDramaStoryboard::getSceneNo)); + Map numbering = new java.util.HashMap<>(); + int next = 1; + for (var existing : before) numbering.put(existing.getSceneNo(), storyboardId.equals(existing.getId()) ? 0 : next++); + visualAssets.reindexStoryboardProps(shot.getProjectId(), numbering, storyboardId); + storyboardMapper.deleteById(storyboardId); + var remaining = storyboardMapper.selectList(new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getScriptId, shot.getScriptId()).orderByAsc(ShortDramaStoryboard::getSceneNo)); + for (int i = 0; i < remaining.size(); i++) { + if (remaining.get(i).getSceneNo() != i + 1) storyboardMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaStoryboard::getId, remaining.get(i).getId()).set(ShortDramaStoryboard::getSceneNo, i + 1)); + } + videoComposeService.invalidateComposition(shot.getProjectId()); + } + @Override public ShortDramaStoryboardVo saveStoryboard(ShortDramaStoryboardBo bo, Long userId) { ShortDramaStoryboard entity = MapstructUtils.convert(bo, ShortDramaStoryboard.class); + ShortDramaStoryboard previous = null; ShortDramaProject project = projectMapper.selectById(entity.getProjectId()); if (project == null || !userId.equals(project.getUserId())) { throw new IllegalArgumentException("项目不存在或无权限"); @@ -679,17 +1009,26 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (existing == null || !entity.getProjectId().equals(existing.getProjectId())) { throw new IllegalArgumentException("分镜不存在或不属于当前项目"); } + previous = existing; } if (entity.getDurationSeconds() == null || entity.getDurationSeconds() <= 0) { entity.setDurationSeconds(defaultDurationForSceneType(entity.getSceneType())); } - ShortDramaTiming.validate(entity.getSceneNo(), entity.getDurationSeconds(), entity.getSourceText(), entity.getContinuityJson()); + ShortDramaVideoDuration.seconds(entity.getContinuityJson()); + // A manually reviewed new panel must meet the same director-content gate as model planning. + // Blank drafts and edits to legacy panels remain editable; paid generation has its own gate. + if (previous == null && StrUtil.isNotBlank(entity.getVideoPrompt()) + && !ShortDramaVisualAssetService.directInsert(entity.getContinuityJson())) { + var issues = ShortDramaVideoPromptReview.issues(entity.getVideoPrompt(), ShortDramaVideoDuration.reviewSeconds(entity.getContinuityJson()), entity.getSourceText()); + if (!issues.isEmpty()) throw new IllegalArgumentException("新分镜导演稿未通过:" + String.join(";", issues)); + } if (entity.getId() == null) { entity.setVideoStatus("pending"); storyboardMapper.insert(entity); } else { storyboardMapper.updateById(entity); entity = storyboardMapper.selectById(entity.getId()); + visualAssets.preserveFrameForVideoSettings(previous, entity); } videoComposeService.invalidateComposition(entity.getProjectId()); return MapstructUtils.convert(entity, ShortDramaStoryboardVo.class); @@ -700,30 +1039,55 @@ public class ShortDramaServiceImpl implements IShortDramaService { return generateVideo(storyboardId, videoModel, userId, null); } + @Override + public ShortDramaStoryboardVo generateVideo(Long storyboardId, String videoModel, Long userId, String requestId, boolean regenerate) { + return generateVideo(storyboardId, videoModel, userId, null, requestId, regenerate); + } + /** * 生成单镜视频。 * @param lastFrameUrl 上一镜末帧 URL(仅同场景相邻镜头传入,用于首帧承接);跨场景或首镜传 null */ public ShortDramaStoryboardVo generateVideo(Long storyboardId, String videoModel, Long userId, String lastFrameUrl) { + return generateVideo(storyboardId, videoModel, userId, lastFrameUrl, null, false); + } + + ShortDramaStoryboardVo generateVideo(Long storyboardId, String videoModel, Long userId, String lastFrameUrl, String requestId, boolean regenerate) { + if (regenerate && StrUtil.isBlank(requestId)) throw new IllegalArgumentException("显式重生成须提供新的 requestId UUID"); + String id = ShortDramaVideoSubmissionStore.requestId(requestId); + synchronized (videoSubmissions.lock(storyboardId)) { + return generateVideoLocked(storyboardId, videoModel, userId, lastFrameUrl, id, regenerate); + } + } + + private ShortDramaStoryboardVo generateVideoLocked(Long storyboardId, String videoModel, Long userId, String lastFrameUrl, String requestId, boolean regenerate) { ShortDramaStoryboard storyboard = storyboardMapper.selectById(storyboardId); if (storyboard == null) throw new IllegalArgumentException("分镜不存在"); ShortDramaProject project = projectMapper.selectById(storyboard.getProjectId()); if (project == null || !userId.equals(project.getUserId())) throw new IllegalArgumentException("项目不存在或无权限"); - if (ShortDramaVisualAssetService.directInsert(storyboard.getContinuityJson())) throw new IllegalStateException("本镜使用真实素材,不能通过AI视频模型重绘;请在剪辑阶段使用已绑定原图"); + var previousRequest = videoSubmissions.read(storyboardId, requestId); + if (previousRequest == null && (("done".equals(storyboard.getVideoStatus()) && StrUtil.isNotBlank(storyboard.getVideoUrl()) && !regenerate) + || videoSubmissionInFlight(storyboard))) return MapstructUtils.convert(storyboard, ShortDramaStoryboardVo.class); + if (previousRequest == null && "done".equals(storyboard.getVideoStatus()) && !regenerate) { + if (StrUtil.isNotBlank(storyboard.getVideoId())) return retrieveVideo(storyboardId, videoModel, userId); + throw new IllegalStateException("原视频标记已完成但缺少文件与任务编号;请先核对原任务,再显式重生成"); + } + if (ShortDramaVisualAssetService.directInsert(storyboard.getContinuityJson())) throw new IllegalStateException("本镜使用素材,不能通过AI视频模型重绘;请在剪辑阶段使用已绑定原图"); if ("script_changed".equals(project.getStatus())) throw new IllegalStateException("剧本已修改,请重新分析资产和生成分镜后再生成视频"); if (storyboardGenerationStates.containsKey(storyboard.getScriptId())) throw new IllegalStateException("分镜正在更新,请完成审阅后再生成视频"); ChatModelVo modelVo = chatModelService.selectModelByName(videoModel); if (modelVo == null) throw new IllegalArgumentException("未找到视频模型配置: " + videoModel); - ShortDramaTiming.validate(storyboard.getSceneNo(), storyboard.getDurationSeconds(), storyboard.getSourceText(), storyboard.getContinuityJson()); + Integer videoSeconds = ShortDramaVideoDuration.seconds(storyboard.getContinuityJson()); // 收集所有参考图(角色 + 场景 + 末帧承接) List referenceImages = findStoryboardReferenceImages(storyboard, lastFrameUrl); + var voicePlan = characterVoices.plan(storyboard, null, true); // 根据参考图数量自动切换模型 - if (referenceImages != null && !referenceImages.isEmpty()) { + if (referenceImages != null && !referenceImages.isEmpty() || !voicePlan.references().isEmpty()) { String targetModel; - if (referenceImages.size() >= 2 || videoModel.endsWith("/reference-to-video")) { + if ((referenceImages != null && referenceImages.size() >= 2) || videoModel.endsWith("/reference-to-video") || !voicePlan.references().isEmpty()) { targetModel = videoModel.replace("/text-to-video", "/reference-to-video") .replace("/image-to-video", "/reference-to-video"); } else { @@ -734,7 +1098,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { ChatModelVo switched = chatModelService.selectModelByName(targetModel); if (switched != null) { modelVo = switched; - log.info("参考图({}张) → 切换模型: {}", referenceImages.size(), targetModel); + log.info("参考图({}张) → 切换模型: {}", referenceImages == null ? 0 : referenceImages.size(), targetModel); } else { throw new IllegalArgumentException("当前镜头需要参考图,但对应模型未配置:"+targetModel+"。请选择已配置的多参考图生视频模型,不能降级为文生视频。"); } @@ -742,42 +1106,76 @@ public class ShortDramaServiceImpl implements IShortDramaService { } String enrichedPrompt = buildEnrichedVideoPrompt(storyboard, referenceImages, lastFrameUrl); + ShortDramaVoiceContract.validateReferences(modelVo.getModelName(),voicePlan.references().stream().map(ShortDramaSoundService.Reference::duration).toList()); + if (!voicePlan.references().isEmpty() && modelVo.getModelName().startsWith("bytedance/seedance-2.0") && (referenceImages==null || referenceImages.isEmpty())) + throw new IllegalArgumentException("Seedance 2.0声音参考需要至少一张角色或场景参考图;首帧仍为可选"); + enrichedPrompt += voicePlan.direction(); VideoContext ctx = VideoContext.builder() .chatModelVo(modelVo) .prompt(enrichedPrompt) .size(projectAspectRatio(storyboard.getProjectId())) - .seconds(storyboard.getDurationSeconds()) + .seconds(videoSeconds) + .resolution(JsonUtils.parseObject(StrUtil.blankToDefault(storyboard.getContinuityJson(), "{}"), JsonNode.class).path("video_resolution").asText("720p")) .referenceImages(referenceImages) - .referenceAudios(sounds.references(storyboard.getProjectId(),storyboard.getSceneNo(),modelVo)) .generateAudio(Boolean.TRUE) .returnLastFrame(Boolean.TRUE) .lastFrameUrl(lastFrameUrl) .build(); - String generationToken = "local:" + UUID.randomUUID(); - storyboardMapper.update(null, new LambdaUpdateWrapper() + var parameters = new LinkedHashMap(); + parameters.put("requestedModel", videoModel); parameters.put("actualModel", modelVo.getModelName()); + parameters.put("provider", modelVo.getProviderCode()); parameters.put("prompt", enrichedPrompt); + parameters.put("seconds", ctx.getSeconds()); parameters.put("size", ctx.getSize()); parameters.put("resolution", ctx.getResolution()); + parameters.put("images", ctx.getReferenceImages()); parameters.put("audios", voicePlan.fingerprints()); + if (!voicePlan.bindings().isEmpty()) parameters.put("characterVoices", voicePlan.bindings()); + parameters.put("lastFrame", lastFrameUrl); parameters.put("generateAudio", ctx.getGenerateAudio()); + parameters.put("returnLastFrame", ctx.getReturnLastFrame()); parameters.put("regenerate", regenerate); + String parametersHash = ShortDramaVideoSubmissionStore.hash(parameters); + ShortDramaVideoSubmissionStore.match(previousRequest, parametersHash); + if (previousRequest != null) return MapstructUtils.convert(storyboard, ShortDramaStoryboardVo.class); + // Drafts remain editable. Only a paid submission requires the production review. + ShortDramaVideoPromptReview.validate(storyboard.getSceneNo(), storyboard.getVideoPrompt(), ShortDramaVideoDuration.reviewSeconds(storyboard.getContinuityJson()), storyboard.getSourceText()); + ctx.setReferenceAudios(sounds.uploadReferences(voicePlan.references(),modelVo)); + String generationToken = "local:" + requestId; + long now = System.currentTimeMillis(); + var receipt = new ShortDramaVideoSubmissionStore.Submission(requestId, storyboardId, project.getId(), userId, + parametersHash, videoModel, modelVo.getModelName(), modelVo.getProviderCode(), null, null, null, "submitting", now, now, + "", ShortDramaVideoSubmissionStore.VideoSnapshot.of(storyboard)); + if (!videoSubmissions.claim(receipt)) { + ShortDramaVideoSubmissionStore.match(videoSubmissions.read(storyboardId, requestId), parametersHash); + return MapstructUtils.convert(storyboardMapper.selectById(storyboardId), ShortDramaStoryboardVo.class); + } + if (!voicePlan.bindings().isEmpty()) videoSubmissions.voiceSnapshot(storyboardId,requestId, + Map.of("bindings",voicePlan.bindings(),"references",voicePlan.fingerprints(),"referenceAudios",ctx.getReferenceAudios(),"direction",voicePlan.direction())); + // Compare both observed fields: separate request UUIDs cannot submit concurrently. + var claim = new LambdaUpdateWrapper() .eq(ShortDramaStoryboard::getId, storyboardId) - .set(ShortDramaStoryboard::getVideoUrl, null) + .eq(storyboard.getVideoStatus() != null, ShortDramaStoryboard::getVideoStatus, storyboard.getVideoStatus()) + .isNull(storyboard.getVideoStatus() == null, ShortDramaStoryboard::getVideoStatus) + .eq(storyboard.getVideoId() != null, ShortDramaStoryboard::getVideoId, storyboard.getVideoId()) + .isNull(storyboard.getVideoId() == null, ShortDramaStoryboard::getVideoId) + .eq(storyboard.getUpdateTime() != null, ShortDramaStoryboard::getUpdateTime, storyboard.getUpdateTime()) .set(ShortDramaStoryboard::getVideoId, generationToken) - .set(ShortDramaStoryboard::getVideoStatus, "generating")); - videoComposeService.invalidateComposition(project.getId()); + .set(ShortDramaStoryboard::getVideoStatus, "generating"); + if (storyboardMapper.update(null, claim) == 0) { + videoSubmissions.save(receipt.update(null, "not_submitted", "分镜已由其他请求更新;本请求未提交")); + return MapstructUtils.convert(storyboardMapper.selectById(storyboardId), ShortDramaStoryboardVo.class); + } MediaGenerationResponse response; try { response = videoServiceFactory.getOriginalService(modelVo.getProviderCode()).generateVideo(ctx); } catch (RuntimeException ex) { - int failed = storyboardMapper.update(null, new LambdaUpdateWrapper() + videoSubmissions.save(receipt.update(null, "submission_unknown", "提交结果未知;请核对上游任务记录,未自动重试")); + storyboardMapper.update(null, new LambdaUpdateWrapper() .eq(ShortDramaStoryboard::getId, storyboardId) .eq(ShortDramaStoryboard::getVideoId, generationToken) - .set(ShortDramaStoryboard::getVideoId, null) - .set(ShortDramaStoryboard::getVideoStatus, "failed")); - if (failed > 0) { - videoComposeService.invalidateComposition(project.getId()); - } - throw ex; + .set(ShortDramaStoryboard::getVideoStatus, "submission_unknown")); + log.warn("镜头{}视频提交结果未知,requestId={},未重试: {}", storyboardId, requestId, ex.getMessage()); + return MapstructUtils.convert(storyboardMapper.selectById(storyboardId), ShortDramaStoryboardVo.class); } String videoUrl = null; - String videoId = null; + String videoId = response != null && StrUtil.isNotBlank(response.getId()) ? response.getId() : generationToken; String videoStatus; String lastFrame = null; if (response != null && StrUtil.isNotBlank(response.getUrl())) { @@ -785,36 +1183,57 @@ public class ShortDramaServiceImpl implements IShortDramaService { lastFrame = response.getLastFrameUrl(); videoStatus = "done"; } else if (response != null && StrUtil.isNotBlank(response.getId())) { - videoId = response.getId(); lastFrame = response.getLastFrameUrl(); - videoStatus = "generating"; - } else if (response != null && "processing".equals(response.getStatus())) { - videoId = response.getId(); - lastFrame = response.getLastFrameUrl(); - videoStatus = "generating"; - } else { + videoStatus = videoTerminalFailure(response.getStatus()) ? "failed" : "generating"; + } else if (response != null && videoTerminalFailure(response.getStatus())) { videoStatus = "failed"; + } else { + videoStatus = "submission_unknown"; } + // Preserve the prediction ID even for a synchronous URL response. + videoSubmissions.save(receipt.result(response == null ? null : response.getId(), videoUrl, lastFrame, videoStatus, + "submission_unknown".equals(videoStatus) ? "服务未返回可确认任务;请核对上游记录,未自动重试" : "")); + boolean accepted = "done".equals(videoStatus) || "generating".equals(videoStatus); + if (("done".equals(videoStatus) || "failed".equals(videoStatus)) && (response == null || StrUtil.isBlank(response.getId()))) videoId = null; int completed = storyboardMapper.update(null, new LambdaUpdateWrapper() .eq(ShortDramaStoryboard::getId, storyboardId) .eq(ShortDramaStoryboard::getVideoId, generationToken) - .set(ShortDramaStoryboard::getVideoUrl, videoUrl) + // During an uncertain regeneration the old finished file remains available. + .set(accepted, ShortDramaStoryboard::getVideoUrl, videoUrl) .set(ShortDramaStoryboard::getVideoId, videoId) .set(ShortDramaStoryboard::getVideoStatus, videoStatus) - .set(StrUtil.isNotBlank(lastFrame), ShortDramaStoryboard::getLastFrameUrl, lastFrame)); - if (completed > 0) { + .set(accepted, ShortDramaStoryboard::getLastFrameUrl, lastFrame)); + if (completed > 0 && accepted) { videoComposeService.invalidateComposition(project.getId()); } return MapstructUtils.convert(storyboardMapper.selectById(storyboardId), ShortDramaStoryboardVo.class); } + private static boolean videoSubmissionInFlight(ShortDramaStoryboard shot) { + return "submission_unknown".equals(shot.getVideoStatus()) || "generating".equals(shot.getVideoStatus()) + || (shot.getVideoId() != null && shot.getVideoId().startsWith("local:")); + } + + private static boolean videoTerminalFailure(String state) { + return java.util.Set.of("failed", "canceled", "cancelled", "rejected").contains(java.util.Objects.toString(state, "")); + } + + @Override + public ShortDramaVideoSubmissionStore.Submission videoSubmission(Long storyboardId, String requestId, Long userId) { + ShortDramaStoryboard storyboard = storyboardMapper.selectById(storyboardId); + if (storyboard == null) throw new IllegalArgumentException("分镜不存在"); + validateProjectOwner(storyboard.getProjectId(), userId); + return videoSubmissions.read(storyboardId, requestId); + } + /** 收集分镜关联的所有参考图:角色形象图(按出场顺序)+ 场景图 + 可选末帧承接 */ private List findStoryboardReferenceImages(ShortDramaStoryboard storyboard, String lastFrameUrl) { List images = new ArrayList<>(); - String frame = visualAssets.readyFrame(storyboard.getId()); + String frame = videoStartFrame(storyboard); // The reviewed frame already resolves identity, set and props. Do not let the // original design sheets compete with it and recreate objects or camera angles. - if (StrUtil.isNotBlank(frame)) return List.of(frame); + if (StrUtil.isNotBlank(frame) && !multiShotSegment(storyboard)) return List.of(frame); + if (StrUtil.isNotBlank(frame)) images.add(frame); List chars = parseCharacterRefs(storyboard.getCharactersJson()); if (chars != null) { for (CharacterRef ref : chars) { @@ -824,7 +1243,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { } } } - if (StrUtil.isNotBlank(storyboard.getLocationName())) { + if (videoReferenceEnabled(storyboard, "video_reference_include_location") && StrUtil.isNotBlank(storyboard.getLocationName())) { String img = findLocationImageUrl(storyboard.getProjectId(), storyboard.getLocationName()); if (StrUtil.isNotBlank(img) && !images.contains(img)) { images.add(img); @@ -838,6 +1257,26 @@ public class ShortDramaServiceImpl implements IShortDramaService { return images.isEmpty() ? null : images; } + /** Explicit opt-in: one continuous performance segment may contain several editorial shots. */ + private String videoStartFrame(ShortDramaStoryboard storyboard) { + return videoReferenceEnabled(storyboard, "video_use_start_frame") ? visualAssets.readyFrame(storyboard.getId()) : null; + } + + private static boolean videoReferenceEnabled(ShortDramaStoryboard storyboard, String key) { + try { + return JsonUtils.parseObject(StrUtil.blankToDefault(storyboard.getContinuityJson(), "{}"), JsonNode.class) + .path(key).asBoolean(true); + } catch (Exception e) { throw new IllegalArgumentException("视频参考设置无效", e); } + } + + private static boolean multiShotSegment(ShortDramaStoryboard storyboard) { + try { + return "multi_shot".equals(JsonUtils.parseObject( + StrUtil.blankToDefault(storyboard.getContinuityJson(), "{}"), JsonNode.class) + .path("video_reference_mode").asText()); + } catch (Exception ignored) { return false; } + } + /** 构建增强提示词:融合镜头语言、摄影规则、角色信息、表演指导、场景描述 */ private String buildEnrichedVideoPrompt(ShortDramaStoryboard storyboard) { return buildEnrichedVideoPrompt(storyboard, null, null); @@ -850,19 +1289,45 @@ public class ShortDramaServiceImpl implements IShortDramaService { /** 构建增强提示词(含参考图 + 末帧首帧承接) */ private String buildEnrichedVideoPrompt(ShortDramaStoryboard storyboard, java.util.List refImages, String lastFrameUrl) { + var selectedProject = projectMapper.selectById(storyboard.getProjectId()); + String selectedDirection = skillCatalog.selectedVisual(selectedProject, "aesthetic") + skillCatalog.selectedVisual(selectedProject, "director"); boolean hasRefImages = refImages != null && !refImages.isEmpty(); + if (multiShotSegment(storyboard)) { + String frame = videoStartFrame(storyboard); + StringBuilder segment = new StringBuilder(ShortDramaDirectorSkills.load("video-prompt")); + segment.append(selectedDirection).append("\n[连续表演段参考绑定]\n"); + if (StrUtil.isNotBlank(frame) && hasRefImages && refImages.contains(frame)) { + segment.append("@image").append(refImages.indexOf(frame) + 1) + .append("是本段0秒起始关键帧;后续按明确的剪辑镜头切换机位,不将起始构图强制保持到结尾。\n"); + } else { + segment.append("本段未绑定起始帧;以角色、场景和道具参考建立第一帧,再按导演稿完成连续表演与镜头切换。\n"); + } + for (CharacterRef ref : parseCharacterRefs(storyboard.getCharactersJson())) { + int index = findRefImageIndex(refImages, storyboard.getProjectId(), ref.getName(), ref.getAppearance()); + if (index < 0) throw new IllegalArgumentException("连续多镜段缺少角色参考:" + ref.getName()); + segment.append(ref.getName()).append("身份与服装对应@image").append(index + 1) + .append(";该图仅提供身份,人物何时入画以导演稿为准,不复制多视图或背景。\n"); + } + int location = findLocationRefImageIndex(refImages, storyboard.getProjectId(), storyboard.getLocationName()); + if (location >= 0) segment.append("场景空间对应@image").append(location + 1) + .append(";同场换景别保持物件位置、光源和摄影轴线。\n"); + segment.append(visualAssets.readyPropDirection(storyboard, refImages)); + segment.append(ShortDramaVideoDuration.direction(storyboard.getContinuityJson())) + .append(storyboard.getVideoPrompt()).append("\n[原文声源]\n").append(storyboard.getSourceText()); + return segment.toString(); + } try { var c=JsonUtils.parseObject(storyboard.getContinuityJson(),JsonNode.class); - if(hasRefImages && refImages.size()==1 && StrUtil.isNotBlank(visualAssets.readyFrame(storyboard.getId())))return - "本镜唯一画面参考是image 1。严格保持该构图、人物、服装、左右关系、道具与背景,连续单一机位,不切镜、不镜像、不添加参考图中没有的物件。\n" - + "起点:"+c.path("start_state").asText()+"\n"+storyboard.getVideoPrompt() - + "\n终点:"+c.path("end_state").asText()+"\n只执行当前镜头,不展示后续镜头,不配乐。"; + if(hasRefImages && refImages.size()==1 && StrUtil.isNotBlank(videoStartFrame(storyboard)))return + selectedDirection + ShortDramaVideoPromptReview.anchoredPrompt(storyboard.getVideoPrompt(), ShortDramaVideoDuration.reviewSeconds(storyboard.getContinuityJson()), + storyboard.getSourceText(), c.path("start_state").asText(), c.path("end_state").asText()); } catch(Exception ignored) { } StringBuilder sb = new StringBuilder(); - sb.append(ShortDramaDirectorSkills.load("emotional-dialogue", "director-blocking", "real-material", "video-continuity")); + sb.append(ShortDramaDirectorSkills.load("emotional-dialogue", "director-blocking", "real-material", "video-continuity", "video-prompt")); + sb.append(selectedDirection); // 1. 镜头语言标签 - String frame = visualAssets.readyFrame(storyboard.getId()); + String frame = videoStartFrame(storyboard); if (StrUtil.isNotBlank(frame) && hasRefImages && refImages.contains(frame)) { sb.append("[本镜关键帧] @image").append(refImages.indexOf(frame)+1) .append("是已生成的本镜构图和起始状态;锁定人物身份、道具外观和站位,从该状态展开本镜动作。其他参考图只用于身份和空间,不复制多视图。\n"); @@ -1003,12 +1468,12 @@ public class ShortDramaServiceImpl implements IShortDramaService { } // 9. 目标时长与节奏 - int durationSeconds = storyboard.getDurationSeconds() != null ? storyboard.getDurationSeconds() : 6; - sb.append("[时长] ").append(durationSeconds).append("秒,按已审阅的对白、独占动作及停顿预算自然表演;固定机位和安静倾听均可保留,不添加新动作填秒数"); + sb.append(visualAssets.readyPropDirection(storyboard, refImages)); + sb.append(ShortDramaVideoDuration.direction(storyboard.getContinuityJson())); sb.append("\n"); // 10. 视觉风格 - String artStyle = artStyleSuffix(storyboard.getProjectId()); + String artStyle = ShortDramaImageConstants.artStylePrompt(skillCatalog.effectiveArtStyle(selectedProject)); if (StrUtil.isNotBlank(artStyle)) { sb.append("\n[风格] ").append(artStyle); } @@ -1037,6 +1502,13 @@ public class ShortDramaServiceImpl implements IShortDramaService { String shotType = firstNotBlank(storyboard.getShotType(), ""); boolean closeShot = shotType.contains("特写") || shotType.contains("近景") || shotType.toLowerCase(Locale.ROOT).contains("close"); + boolean visibleExtras; + try { + JsonNode continuity = org.ruoyi.service.media.AtlasMediaSupport.OBJECT_MAPPER.readTree(firstNotBlank(storyboard.getContinuityJson(), "{}")); + visibleExtras = ShortDramaBackgroundExtras.parse(ShortDramaBackgroundExtras.readText(continuity)).visible(); + } catch (java.io.IOException e) { + throw new IllegalArgumentException("人物隔离的连续性字段无效", e); + } sb.append("[人物身份隔离] "); if (characterCount == 0) { @@ -1046,11 +1518,11 @@ public class ShortDramaServiceImpl implements IShortDramaService { String characterName = chars.get(0).getName(); sb.append("唯一具名角色为").append(characterName).append(";") .append(hasRefImages ? "该角色参考图只绑定此人,绝不能扩散给背景人物;" : "角色身份只属于此人;"); - if (closeShot) { + if (closeShot && !visibleExtras) { sb.append("这是单人近景/特写,画面只允许一张可辨认人脸。其他人物必须留在画外,") .append("不得出现背景人头、倒影脸、反射脸、画像脸、脸谱幻影或多重曝光;"); } else { - sb.append("若剧情必须出现无名群众,群众不得复用").append(characterName) + sb.append(visibleExtras ? "本镜已明确的0秒匿名群演必须按background_extras保留,群众不得复用" : "若剧情必须出现无名群众,群众不得复用").append(characterName) .append("的五官,后景人物应弱化面部并保持彼此差异;"); } sb.append("禁止克隆、分身、镜像脸和同脸不同装。"); @@ -1127,29 +1599,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { } private ShortDramaCharacterAppearance matchAppearance(List appearances, String appearance) { - if (appearances == null || appearances.isEmpty()) return null; - if (StrUtil.isBlank(appearance) || "初始形象".equals(appearance)) { - return appearances.get(0); - } - // 1. changeReason 精确匹配 - for (ShortDramaCharacterAppearance ap : appearances) { - if (appearance.equals(ap.getChangeReason())) return ap; - } - // 2. changeReason 包含匹配 - for (ShortDramaCharacterAppearance ap : appearances) { - if (StrUtil.isNotBlank(ap.getChangeReason()) && ap.getChangeReason().contains(appearance)) return ap; - } - // 3. description 包含匹配 - for (ShortDramaCharacterAppearance ap : appearances) { - if (StrUtil.isNotBlank(ap.getDescription()) && ap.getDescription().contains(appearance)) return ap; - } - // 4. appearanceIndex 数字匹配 - try { - int idx = Integer.parseInt(appearance); - if (idx >= 0 && idx < appearances.size()) return appearances.get(idx); - } catch (NumberFormatException ignored) {} - // 5. 回退主形象 - return appearances.get(0); + return ShortDramaAppearanceResolver.resolve(appearances, appearance); } private String pickAppearanceImage(ShortDramaCharacterAppearance appearance) { @@ -1237,21 +1687,53 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (project == null || !userId.equals(project.getUserId())) { throw new IllegalArgumentException("项目不存在或无权限"); } - if (StrUtil.isBlank(storyboard.getVideoId()) || storyboard.getVideoId().startsWith("local:")) { + if ("done".equals(storyboard.getVideoStatus()) && StrUtil.isNotBlank(storyboard.getVideoUrl())) { return MapstructUtils.convert(storyboard, ShortDramaStoryboardVo.class); } - String predictionId = storyboard.getVideoId(); - ChatModelVo modelVo = chatModelService.selectModelByName(videoModel); + String observedId = storyboard.getVideoId(); + var receipt = videoSubmissions.forPrediction(storyboardId, observedId); + if (receipt != null && "done".equals(receipt.status()) && StrUtil.isNotBlank(receipt.videoUrl())) { + storyboardMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaStoryboard::getId, storyboardId).eq(ShortDramaStoryboard::getVideoId, observedId) + .set(ShortDramaStoryboard::getVideoId, receipt.predictionId()) + .set(ShortDramaStoryboard::getVideoUrl, receipt.videoUrl()) + .set(ShortDramaStoryboard::getLastFrameUrl, receipt.lastFrameUrl()) + .set(ShortDramaStoryboard::getVideoStatus, "done")); + videoComposeService.invalidateComposition(project.getId()); + return MapstructUtils.convert(storyboardMapper.selectById(storyboardId), ShortDramaStoryboardVo.class); + } + String predictionId = receipt != null && StrUtil.isNotBlank(receipt.predictionId()) ? receipt.predictionId() : observedId; + if (StrUtil.isBlank(predictionId) || predictionId.startsWith("local:")) { + if (receipt != null && ("submission_unknown".equals(receipt.status()) || + ("submitting".equals(receipt.status()) && System.currentTimeMillis() - receipt.createdAt() > TimeUnit.MINUTES.toMillis(10)))) { + videoSubmissions.save(receipt.update(null, "submission_unknown", "尚未取得上游任务编号;请核对上游记录,禁止重交")); + storyboardMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaStoryboard::getId, storyboardId).eq(ShortDramaStoryboard::getVideoId, observedId) + .set(ShortDramaStoryboard::getVideoStatus, "submission_unknown")); + return MapstructUtils.convert(storyboardMapper.selectById(storyboardId), ShortDramaStoryboardVo.class); + } + return MapstructUtils.convert(storyboard, ShortDramaStoryboardVo.class); + } + // Never reconstruct the queried provider/model from a client dropdown after a switch. + String actualModel = receipt != null ? receipt.actualModel() : videoModel; + ChatModelVo modelVo = chatModelService.selectModelByName(actualModel); if (modelVo == null) { - throw new IllegalArgumentException("未找到视频模型配置: " + videoModel); + throw new IllegalArgumentException("未找到原视频任务模型配置: " + actualModel); } VideoContext ctx = VideoContext.builder() .chatModelVo(modelVo) .prompt("retrieve") .videoId(predictionId) .build(); - MediaGenerationResponse response = videoServiceFactory.getOriginalService(modelVo.getProviderCode()) - .retrieveVideo(ctx); + MediaGenerationResponse response; + try { + response = videoServiceFactory.getOriginalService(receipt != null ? receipt.providerCode() : modelVo.getProviderCode()) + .retrieveVideo(ctx); + } catch (RuntimeException ex) { + if (receipt != null) videoSubmissions.save(receipt.update(predictionId, receipt.status(), "任务查询暂时失败;保留原任务编号,未重交")); + log.warn("镜头{}视频查询暂时失败,保留predictionId={}: {}", storyboardId, predictionId, ex.getMessage()); + return MapstructUtils.convert(storyboard, ShortDramaStoryboardVo.class); + } String videoUrl = null; String videoStatus = null; String lastFrame = null; @@ -1269,16 +1751,20 @@ public class ShortDramaServiceImpl implements IShortDramaService { } else { log.warn("视频已完成但无法提取URL, predictionId={}, raw={}", predictionId, response.getRawResponse() != null ? response.getRawResponse().substring(0, Math.min(300, response.getRawResponse().length())) : "null"); - videoStatus = "failed"; + videoStatus = "submission_unknown"; } - } else if (response != null && "failed".equals(response.getStatus())) { + } else if (response != null && videoTerminalFailure(response.getStatus())) { videoStatus = "failed"; } + if (videoStatus == null && observedId != null && observedId.startsWith("local:")) videoStatus = "generating"; if (videoStatus != null) { + if (receipt != null) videoSubmissions.save(receipt.result(predictionId, videoUrl, lastFrame, videoStatus, + "submission_unknown".equals(videoStatus) ? "上游报告完成但未返回媒体;保留任务编号,请核对记录" : "")); int updated = storyboardMapper.update(null, new LambdaUpdateWrapper() .eq(ShortDramaStoryboard::getId, storyboardId) - .eq(ShortDramaStoryboard::getVideoId, predictionId) - .set(ShortDramaStoryboard::getVideoUrl, videoUrl) + .eq(ShortDramaStoryboard::getVideoId, observedId) + .set(ShortDramaStoryboard::getVideoId, predictionId) + .set("done".equals(videoStatus), ShortDramaStoryboard::getVideoUrl, videoUrl) .set(ShortDramaStoryboard::getVideoStatus, videoStatus) .set(StrUtil.isNotBlank(lastFrame), ShortDramaStoryboard::getLastFrameUrl, lastFrame)); if (updated > 0) { @@ -1290,6 +1776,13 @@ public class ShortDramaServiceImpl implements IShortDramaService { @Override public List generateAllVideos(Long projectId, String videoModel, Long userId) { + return generateAllVideos(projectId, videoModel, userId, null, null); + } + + @Override + public List generateAllVideos(Long projectId, String videoModel, Long userId, Integer sceneStart, Integer sceneCount) { + if (sceneStart != null && sceneStart < 1) throw new IllegalArgumentException("sceneStart 必须为实际镜号,至少1"); + if (sceneCount != null && (sceneCount < 1 || sceneCount > 100)) throw new IllegalArgumentException("sceneCount 须为1至100"); ShortDramaProject project = projectMapper.selectById(projectId); if (project == null || !userId.equals(project.getUserId())) { throw new IllegalArgumentException("项目不存在或无权限"); @@ -1299,8 +1792,32 @@ public class ShortDramaServiceImpl implements IShortDramaService { .eq(ShortDramaStoryboard::getProjectId, projectId) .orderByAsc(ShortDramaStoryboard::getSceneNo)); - for (ShortDramaStoryboard shot : storyboards) ShortDramaTiming.validate(shot.getSceneNo(), shot.getDurationSeconds(), shot.getSourceText(), shot.getContinuityJson()); - List> groups = groupContinuousScenes(storyboards); + var selected = selectVideoRange(storyboards, sceneStart, sceneCount); + for (ShortDramaStoryboard shot : selected) { + if (("done".equals(shot.getVideoStatus()) && StrUtil.isNotBlank(shot.getVideoUrl())) || videoSubmissionInFlight(shot)) continue; + ShortDramaVideoDuration.seconds(shot.getContinuityJson()); + ShortDramaVideoPromptReview.validate(shot.getSceneNo(), shot.getVideoPrompt(), ShortDramaVideoDuration.reviewSeconds(shot.getContinuityJson()), shot.getSourceText()); + } + var selectedIds = selected.stream().map(ShortDramaStoryboard::getId).collect(java.util.stream.Collectors.toSet()); + List groups = new ArrayList<>(); + for (var fullGroup : groupContinuousScenes(storyboards)) { + var shots = fullGroup.stream().filter(shot -> selectedIds.contains(shot.getId())).toList(); + if (shots.isEmpty()) continue; + int offset = fullGroup.indexOf(shots.get(0)); + String initialFrame = null; + ShortDramaStoryboard first = shots.get(0); + if (offset > 0 && !videoSubmissionInFlight(first) && !("done".equals(first.getVideoStatus()) && StrUtil.isNotBlank(first.getVideoUrl()))) { + ShortDramaStoryboard prior = fullGroup.get(offset - 1); + // A ranged batch may query the predecessor, but must never submit outside its selected range. + var previous = retrieveVideo(prior.getId(), videoModel, userId); + if (previous != null && "generating".equals(previous.getVideoStatus()) && previous.getVideoId() != null && !previous.getVideoId().startsWith("local:")) + previous = ensureVideoDone(prior.getId(), videoModel, userId); + if (previous == null || !"done".equals(previous.getVideoStatus()) || StrUtil.isBlank(previous.getLastFrameUrl())) + throw new IllegalStateException("本批起点需要镜头" + prior.getSceneNo() + "的已完成末帧;请先完成上一镜,未提交本批"); + initialFrame = previous.getLastFrameUrl(); + } + groups.add(new VideoGenerationGroup(shots, initialFrame)); + } // 跨场景组并发,组上限 4 int parallel = Math.min(4, Math.max(1, groups.size())); @@ -1308,8 +1825,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { r -> { Thread t = new Thread(r, "short-drama-video-gen"); t.setDaemon(true); return t; }); try { List>> futures = new ArrayList<>(); - for (List group : groups) { - futures.add(pool.submit(() -> generateGroupSerial(group, videoModel, userId))); + for (VideoGenerationGroup group : groups) { + futures.add(pool.submit(() -> generateGroupSerial(group.shots(), videoModel, userId, group.initialFrame()))); } // 按 sceneNo 顺序汇总结果 List result = new ArrayList<>(); @@ -1332,6 +1849,13 @@ public class ShortDramaServiceImpl implements IShortDramaService { } } + private record VideoGenerationGroup(List shots, String initialFrame) { } + + static List selectVideoRange(List shots, Integer sceneStart, Integer sceneCount) { + return shots.stream().filter(shot -> sceneStart == null || (shot.getSceneNo() != null && shot.getSceneNo() >= sceneStart)) + .limit(sceneCount == null ? Long.MAX_VALUE : sceneCount).toList(); + } + static List> groupContinuousScenes(List storyboards) { List> groups = new ArrayList<>(); String previousKey = null; @@ -1350,11 +1874,18 @@ public class ShortDramaServiceImpl implements IShortDramaService { /** 同场景组内串行生成;包括第一镜在内,完成并取得末帧后才允许下一镜提交。 */ List generateGroupSerial(List group, String videoModel, Long userId) { + return generateGroupSerial(group, videoModel, userId, null); + } + + List generateGroupSerial(List group, String videoModel, Long userId, String initialFrame) { List result = new ArrayList<>(); - String prevLastFrameUrl = null; + String prevLastFrameUrl = initialFrame; for (int index = 0; index < group.size(); index++) { ShortDramaStoryboard sb = group.get(index); ShortDramaStoryboardVo vo = generateVideo(sb.getId(), videoModel, userId, prevLastFrameUrl); + if (vo != null && ("submission_unknown".equals(vo.getVideoStatus()) + || (vo.getVideoId() != null && vo.getVideoId().startsWith("local:")))) + throw new IllegalStateException("镜头" + sb.getSceneNo() + "提交结果尚未确认,已停止本组,禁止重复提交"); if (vo == null || !"done".equals(vo.getVideoStatus())) { vo = ensureVideoDone(sb.getId(), videoModel, userId); } @@ -1380,7 +1911,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { while (System.currentTimeMillis() < deadline) { ShortDramaStoryboardVo vo = retrieveVideo(storyboardId, videoModel, userId); if (vo == null) return null; - if ("done".equals(vo.getVideoStatus()) || "failed".equals(vo.getVideoStatus())) { + if ("done".equals(vo.getVideoStatus()) || "failed".equals(vo.getVideoStatus()) || "submission_unknown".equals(vo.getVideoStatus()) + || (vo.getVideoId() != null && vo.getVideoId().startsWith("local:"))) { return vo; } try { Thread.sleep(2000); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return vo; } @@ -1400,12 +1932,177 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (script == null || !projectId.equals(script.getProjectId())) { throw new IllegalArgumentException("剧本不存在"); } - clearAssets(projectId); ChatModelVo modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel(); - AbstractChatService chatService = getChatService(modelVo); - ChatModel chatModel = chatService.buildChatModel(modelVo); - executePhase2_AssetAnalysis(chatModel, projectId, script); - return getDetail(projectId, userId); + if (!beginAssetGeneration(scriptId)) throw new IllegalStateException("该剧本已有文字生成任务,请查询原任务"); + try { + generateUnifiedAssets(project, script, modelVo, userId, null, null); + return getDetail(projectId, userId); + } finally { assetGenerationStates.remove(scriptId); } + } + + private synchronized boolean beginAssetGeneration(Long scriptId) { + if (storyboardGenerationStates.containsKey(scriptId)) return false; + return assetGenerationStates.putIfAbsent(scriptId, new AtomicBoolean(true)) == null; + } + + private static String assetSourceHash(ShortDramaScript script) { + return cn.hutool.crypto.digest.DigestUtil.sha256Hex(firstNotBlank(script.getScriptText(), script.getOutlineText(), "") + + "\n" + ShortDramaScriptPreparation.worldContext(script) + "\n" + Objects.toString(script.getTone(), "")); + } + + @Override + public java.util.Map assetAnalysisStatus(Long projectId, Long scriptId, String requestId, Long userId) { + validateProjectOwner(projectId, userId); + var script = scriptMapper.selectById(scriptId); + if (script == null || !projectId.equals(script.getProjectId())) throw new IllegalArgumentException("剧本不属于当前项目"); + var status = assetStatuses.read(projectId, scriptId, ShortDramaPlanningStatus.checkedRequestId(requestId)); + var data = assetStatusData(status, projectId, scriptId); + data.put("currentScriptHash", assetSourceHash(script)); + data.put("observedAt", java.time.Instant.now().toString()); + return data; + } + + private java.util.Map assetStatusData(ShortDramaPlanningStatus.Status status, Long projectId, Long scriptId) { + var data = new LinkedHashMap(); + boolean active = assetGenerationStates.containsKey(scriptId); + data.put("schemaVersion", "asset-status-v1"); data.put("projectId", projectId); data.put("scriptId", scriptId); + data.put("requestId", status == null ? null : status.requestId()); + data.put("state", status == null ? (active ? "running" : "not_observed") + : !status.terminal() && !assetStatuses.currentRuntime(status) ? "submission_unknown" : status.state()); + data.put("terminal", status != null && status.terminal()); data.put("activeLock", active); data.put("retrySafe", false); + if (status != null) { + data.put("scriptHash", status.scriptHash()); data.put("model", status.model()); + data.put("submittedAt", status.submittedAt()); data.put("updatedAt", status.updatedAt()); + data.put("assetCount", status.panelCount()); data.put("error", status.error()); + var progress = ShortDramaPlanningProgress.read(projectId, scriptId, "assets:" + status.requestId()); + if (progress != null) data.put("progress", progress); + } + return data; + } + + @Override + public SseEmitter analyzeAssetsStream(Long projectId, Long scriptId, Long userId, String model, String requestId) { + var project = validateProjectOwner(projectId, userId); + var script = scriptMapper.selectById(scriptId); + if (script == null || !projectId.equals(script.getProjectId())) throw new IllegalArgumentException("剧本不属于当前项目"); + var modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel(); + String requested = ShortDramaPlanningStatus.checkedRequestId(requestId), sourceHash = assetSourceHash(script); + String tenant = org.ruoyi.common.tenant.helper.TenantHelper.getTenantId(); + var emitter = new SseEmitter(7_200_000L); + activeEmitters.put(emitter, new AtomicBoolean(true)); + emitter.onCompletion(() -> closeEmitter(emitter)); emitter.onTimeout(() -> closeEmitter(emitter)); emitter.onError(error -> closeEmitter(emitter)); + if (requested != null) { + var existing = assetStatuses.read(projectId, scriptId, requested); + if (existing != null) { + if (!sourceHash.equals(existing.scriptHash()) || !modelVo.getModelName().equals(existing.model())) { + completeEmitter(emitter); throw new IllegalArgumentException("requestId已用于不同的剧本或模型"); + } + sendEmitterEvent(emitter, SseEmitter.event().name("submission").data(assetStatusData(existing, projectId, scriptId))); + completeEmitter(emitter); return emitter; + } + } + if (!beginAssetGeneration(scriptId)) { + completeEmitter(emitter); throw new IllegalStateException("该剧本已有文字生成任务,请查询原任务"); + } + final ShortDramaPlanningStatus.Status queued; + try { + var previous = assetStatuses.read(projectId, scriptId, null); + if (previous != null && !previous.terminal()) { + if (assetStatuses.currentRuntime(previous)) throw new IllegalStateException("原资产请求尚未结束,请查询原任务"); + assetStatuses.update(previous, "error", null, "服务已重启,原资产任务不在当前实例中,原资产保留"); + } + queued = assetStatuses.queued(projectId, scriptId, requested == null ? UUID.randomUUID().toString() : requested, sourceHash, modelVo.getModelName(), 0); + } catch (RuntimeException e) { assetGenerationStates.remove(scriptId); completeEmitter(emitter); throw e; } + var progress = new ShortDramaPlanningProgress.Tracker(queued.requestId(), queued.submittedAt(), + ShortDramaPlanningProgress.persistence(projectId, scriptId, "assets:" + queued.requestId(), snapshot -> + sendEmitterEvent(emitter, SseEmitter.event().name("progress").data(JsonUtils.toJsonString(snapshot))))); + planningProgress.put(emitter, progress); + progress.phase("assets", "资产分析请求已登记,等待模型返回"); + sendEmitterEvent(emitter, SseEmitter.event().name("submission").data(assetStatusData(queued, projectId, scriptId))); + try { + CompletableFuture.runAsync(() -> { + try { + org.ruoyi.common.tenant.helper.TenantHelper.dynamic(tenant, () -> { + assetStatuses.update(queued, "running", null, null); + int count = generateUnifiedAssets(project, script, modelVo, userId, emitter, progress); + var completed = assetStatuses.update(queued, "done", count, null); + progress.terminal("done"); + sendEmitterEvent(emitter, SseEmitter.event().name("complete").data(assetStatusData(completed, projectId, scriptId))); + completeEmitter(emitter); + }); + } catch (Exception e) { + log.warn("资产分析未完成 projectId={}, requestId={}", projectId, queued.requestId(), e); + org.ruoyi.common.tenant.helper.TenantHelper.dynamic(tenant, () -> assetStatuses.update(queued, "error", null, e.getMessage())); + progress.terminal("error"); + sendEmitterEvent(emitter, SseEmitter.event().name("error").data(Map.of("requestId", queued.requestId(), "message", ShortDramaSceneCandidateDiagnostics.redact(Objects.toString(e.getMessage(), "资产分析失败"))))); + completeEmitterWithError(emitter, e); + } finally { planningProgress.remove(emitter); assetGenerationStates.remove(scriptId); } + }, storyboardPlanningExecutor); + } catch (RuntimeException e) { + assetStatuses.update(queued, "error", null, "后台未接受任务:" + e.getMessage()); progress.terminal("error"); + planningProgress.remove(emitter); assetGenerationStates.remove(scriptId); completeEmitterWithError(emitter, e); + } + return emitter; + } + + private int generateUnifiedAssets(ShortDramaProject project, ShortDramaScript script, ChatModelVo modelVo, Long userId, + SseEmitter emitter, ShortDramaPlanningProgress.Tracker progress) { + String source = firstNotBlank(script.getScriptText(), script.getOutlineText(), ""); + if (StrUtil.isBlank(source)) throw new IllegalArgumentException("请先保存有效剧本正文"); + Long projectId = project.getId(); + var snapshot = skillCatalog.snapshot(project); + var request = ShortDramaWritingRequest.forText(modelVo.getModelName(), source); + var service = getChatService(modelVo); + var chat = service.buildChatModel(modelVo, request); + var streaming = emitter == null ? null : service.buildStreamingChatModel(modelVo, request); + int[] ordinals = new int[4]; + var preview = new ShortDramaAssetGeneration.Preview((category, item) -> { + if (progress != null) progress.assetCard(category, ++ordinals[category], "draft", item); + }); + if (progress != null) progress.phase("assets", "正在提取角色、场景和道具,返回后逐项显示"); + String raw = streamingChat(streaming, chat, ShortDramaAssetGeneration.prompt(source + ShortDramaScriptPreparation.worldContext(script), script.getTone(), snapshot.direction()), + emitter, "assets", preview::accept, "角色、场景和道具"); + var result = ShortDramaAssetGeneration.parse(raw); + List extractedCharacters = result.characters().stream() + .map(item -> parseJson(item.toString(), AssetGeneratedCharacter.class)).toList(); + List extractedLocations = result.locations().stream() + .map(item -> parseJson(item.toString(), AssetGeneratedLocation.class)).toList(); + requireAssetCharacters(extractedCharacters); requireAssetLocations(extractedLocations); + if (progress != null) progress.phase("persist", "资产清单已校验,正在保存"); + String sourceHash = assetSourceHash(script); + transactionTemplate.executeWithoutResult(status -> { + // Lock the script row so a concurrent script save cannot slip between verification and commit. + var current = scriptMapper.selectOne(new LambdaQueryWrapper().eq(ShortDramaScript::getId, script.getId()).last("FOR UPDATE")); + if (current == null || !sourceHash.equals(assetSourceHash(current))) throw new IllegalStateException("分析期间剧本已修改,本轮结果未保存,原资产保留"); + skillCatalog.verify(snapshot, projectMapper.selectById(projectId)); + Set characterNames = characterMapper.selectList(new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId, projectId)) + .stream().map(c -> c.getName().trim().toLowerCase(Locale.ROOT)).collect(java.util.stream.Collectors.toSet()); + for (var character : extractedCharacters) { + character.setName(character.getName().trim()); + if (characterNames.add(character.getName().toLowerCase(Locale.ROOT))) insertCharacter(projectId, character, character.getVisualDescription()); + } + Set locationNames = locationMapper.selectList(new LambdaQueryWrapper().eq(ShortDramaLocation::getProjectId, projectId)) + .stream().map(l -> l.getName().trim().toLowerCase(Locale.ROOT)).collect(java.util.stream.Collectors.toSet()); + for (var location : extractedLocations) { + if (!locationNames.add(location.getName().trim().toLowerCase(Locale.ROOT))) continue; + var entity = new ShortDramaLocation(); entity.setId(IdUtil.getSnowflakeNextId()); entity.setProjectId(projectId); + entity.setName(location.getName().trim()); entity.setSummary(location.getSummary()); + entity.setHasCrowd(Boolean.TRUE.equals(location.getHasCrowd())); entity.setCrowdDescription(location.getCrowdDescription()); + entity.setAvailableSlots(JsonUtils.toJsonString(location.getAvailableSlots())); entity.setDescriptions(JsonUtils.toJsonString(location.getDescriptions())); + locationMapper.insert(entity); + } + visualAssets.saveAnalyzedProps(projectId, result.props(), userId); + }); + if (progress != null) { + for (int category = 1; category <= 3; category++) { + progress.clearDrafts(category); + var items = category == 1 ? result.characters() : category == 2 ? result.locations() : result.props(); + for (int i = 0; i < items.size(); i++) progress.assetCard(category, i + 1, "ready", items.get(i)); + } + } + log.info("资产单次生成完成: projectId={}, model={}, characters={}, locations={}, props={}", projectId, modelVo.getModelName(), + result.characters().size(), result.locations().size(), result.props().size()); + return result.size(); } @Override @@ -1466,26 +2163,45 @@ public class ShortDramaServiceImpl implements IShortDramaService { @Override public ShortDramaLocationVo generateLocationImage(Long locationId, String imageModel, String referenceImageUrl, Long userId) { + return generateLocationImage(locationId, imageModel, referenceImageUrl, null, userId); + } + + @Override + public ShortDramaLocationVo generateLocationImage(Long locationId, String imageModel, String referenceImageUrl, String revisionRequirements, Long userId) { + boolean revision = ShortDramaLocationArtPrompt.isRevision(null, revisionRequirements); ShortDramaLocation location = locationMapper.selectById(locationId); if (location == null) throw new IllegalArgumentException("场景不存在"); validateProjectOwner(location.getProjectId(), userId); ChatModelVo modelVo = chatModelService.selectModelByName(imageModel); if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel); String prompt = firstNotBlank(primaryLocationDescription(location), location.getSummary(), location.getName()); - String finalPrompt = ShortDramaDirectorSkills.load("visual-world") + ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + prompt + - ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + artStyleSuffix(location.getProjectId()); + String finalPrompt = buildLocationPrompt(location.getName(), prompt, location.getProjectId(), revisionRequirements); String referenceImage = validateReferenceImageUrl(referenceImageUrl); + if (readJsonStringList(location.getImageUrls()).size() >= ShortDramaImageConstants.MAX_IMAGE_VARIANTS) + throw new IllegalArgumentException("场景图片已达上限,请审阅并删除不需要的候选后再生成"); + var evidence = imagePromptEvidence.prepare("location", locationId, location.getProjectId(), userId, imageModel, + revision ? "location_revision" : "location", revisionRequirements, finalPrompt, referenceImage); + log.info("短剧地点图提示词证据 attemptId={} assetId={} purpose={} promptSha256={}", + evidence.attemptId(), locationId, evidence.referencePurpose(), evidence.promptSha256()); String imageUrl = imageServiceFactory.getOriginalService(modelVo.getProviderCode()) .generateImage(org.ruoyi.common.chat.entity.image.ImageContext.builder() .chatModelVo(modelVo).prompt(finalPrompt).size(projectAspectRatio(location.getProjectId())).image(referenceImage).build()); + imagePromptEvidence.submitted(evidence, null, StrUtil.isNotBlank(imageUrl) ? "completed" : "no_image_returned"); if (StrUtil.isNotBlank(imageUrl)) { - location.setReferenceImageUrl(imageUrl); List urls = readJsonStringList(location.getImageUrls()); List descs = readJsonStringList(location.getImageDescriptions()); - addImageUrl(urls, imageUrl, descs, finalPrompt); + if (urls.contains(imageUrl)) return MapstructUtils.convert(location, ShortDramaLocationVo.class); + if (revision) { + location.setPreviousImageUrls(location.getImageUrls()); + location.setPreviousDescriptions(location.getImageDescriptions()); + } + addImageUrl(urls, imageUrl, descs, finalPrompt); location.setImageUrls(JsonUtils.toJsonString(urls)); location.setImageDescriptions(JsonUtils.toJsonString(descs)); - location.setSelectedImageIndex(urls.size() - 1); + if (!revision) { + location.setReferenceImageUrl(imageUrl); + location.setSelectedImageIndex(urls.size() - 1); + } locationMapper.updateById(location); } return MapstructUtils.convert(location, ShortDramaLocationVo.class); @@ -1660,29 +2376,12 @@ public class ShortDramaServiceImpl implements IShortDramaService { @Override public ShortDramaLocationVo regenerateLocationImage(Long locationId, String imageModel, String referenceImageUrl, Long userId) { - ShortDramaLocation location = locationMapper.selectById(locationId); - if (location == null) throw new IllegalArgumentException("场景不存在"); - validateProjectOwner(location.getProjectId(), userId); - ChatModelVo modelVo = chatModelService.selectModelByName(imageModel); - if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel); - String prompt = firstNotBlank(primaryLocationDescription(location), location.getSummary(), location.getName()); - String finalPrompt = ShortDramaDirectorSkills.load("visual-world") + ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + prompt + - ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + artStyleSuffix(location.getProjectId()); - String referenceImage = validateReferenceImageUrl(referenceImageUrl); - String imageUrl = imageServiceFactory.getOriginalService(modelVo.getProviderCode()) - .generateImage(org.ruoyi.common.chat.entity.image.ImageContext.builder() - .chatModelVo(modelVo).prompt(finalPrompt).size(projectAspectRatio(location.getProjectId())).image(referenceImage).build()); - if (StrUtil.isNotBlank(imageUrl)) { - location.setReferenceImageUrl(imageUrl); - List urls = readJsonStringList(location.getImageUrls()); - List descs = readJsonStringList(location.getImageDescriptions()); - addImageUrl(urls, imageUrl, descs, finalPrompt); - location.setImageUrls(JsonUtils.toJsonString(urls)); - location.setImageDescriptions(JsonUtils.toJsonString(descs)); - location.setSelectedImageIndex(urls.size() - 1); - locationMapper.updateById(location); - } - return MapstructUtils.convert(location, ShortDramaLocationVo.class); + return regenerateLocationImage(locationId, imageModel, referenceImageUrl, null, userId); + } + + @Override + public ShortDramaLocationVo regenerateLocationImage(Long locationId, String imageModel, String referenceImageUrl, String revisionRequirements, Long userId) { + return generateLocationImage(locationId, imageModel, referenceImageUrl, revisionRequirements, userId); } @Override @@ -1770,11 +2469,36 @@ public class ShortDramaServiceImpl implements IShortDramaService { @Override public MediaGenerationResponse startImageGeneration(String assetType, Long assetId, String model, String referenceImageUrl, Long userId) { + return startImageGeneration(assetType, assetId, model, referenceImageUrl, null, null, userId); + } + + @Override + public MediaGenerationResponse startImageGeneration(String assetType, Long assetId, String model, String referenceImageUrl, + String referencePurpose, String revisionRequirements, Long userId) { + return startImageGeneration(assetType, assetId, model, referenceImageUrl, referencePurpose, + revisionRequirements, null, userId); + } + + @Override + public MediaGenerationResponse startImageGeneration(String assetType, Long assetId, String model, String referenceImageUrl, + String referencePurpose, String revisionRequirements, + List styleReferenceImageUrls, Long userId) { + boolean locationAsset = "location".equals(assetType); + boolean revision = locationAsset ? ShortDramaLocationArtPrompt.isRevision(referencePurpose, revisionRequirements) + : ShortDramaCharacterArtPrompt.isRevision(referencePurpose, revisionRequirements); + String referenceImage = validateReferenceImageUrl(referenceImageUrl); + List styleImages = styleReferenceImageUrls == null ? List.of() + : styleReferenceImageUrls.stream().map(ShortDramaServiceImpl::validateReferenceImageUrl).toList(); + List imageReferences = ShortDramaImageStyleReferences.ordered(referenceImage, styleImages); + if (revision && !locationAsset && (!"appearance".equals(assetType) || StrUtil.isBlank(referenceImage))) { + throw new IllegalArgumentException("身份修订仅用于角色形象,且必须提供身份参考图"); + } ChatModelVo modelVo = chatModelService.selectModelByName(model); if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + model); String prompt; String size; + Long projectId; Integer seed = null; if ("appearance".equals(assetType)) { ShortDramaCharacterAppearance appearance = characterAppearanceMapper.selectById(assetId); @@ -1782,27 +2506,49 @@ public class ShortDramaServiceImpl implements IShortDramaService { ShortDramaCharacter character = characterMapper.selectById(appearance.getCharacterId()); if (character == null) throw new IllegalArgumentException("角色不存在"); validateProjectOwner(character.getProjectId(), userId); + projectId = character.getProjectId(); + if (readJsonStringList(appearance.getImageUrls()).size() >= ShortDramaImageConstants.MAX_IMAGE_VARIANTS) { + throw new IllegalArgumentException("角色图片已达上限,请审阅并删除不需要的候选后再生成"); + } String basePrompt = firstNotBlank(appearance.getDescription(), character.getVisualDescription(), character.getName()); - prompt = buildCharacterPrompt(basePrompt, character.getProjectId()); + prompt = buildCharacterPrompt(basePrompt, projectId, referencePurpose, revisionRequirements); size = "3:2"; seed = ShortDramaImageConstants.styleSeed(character.getProjectId()); } else if ("location".equals(assetType)) { ShortDramaLocation location = locationMapper.selectById(assetId); if (location == null) throw new IllegalArgumentException("场景不存在"); validateProjectOwner(location.getProjectId(), userId); + projectId = location.getProjectId(); + if (readJsonStringList(location.getImageUrls()).size() >= ShortDramaImageConstants.MAX_IMAGE_VARIANTS) + throw new IllegalArgumentException("场景图片已达上限,请审阅并删除不需要的候选后再生成"); String basePrompt = firstNotBlank(primaryLocationDescription(location), location.getSummary(), location.getName()); - prompt = ShortDramaDirectorSkills.load("visual-world") + ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + basePrompt + - ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + artStyleSuffix(location.getProjectId()); + prompt = buildLocationPrompt(location.getName(), basePrompt, projectId, revisionRequirements); size = projectAspectRatio(location.getProjectId()); seed = ShortDramaImageConstants.styleSeed(location.getProjectId()); } else { throw new IllegalArgumentException("不支持的资产类型: " + assetType); } - return imageServiceFactory.getOriginalService(modelVo.getProviderCode()) + if (!styleImages.isEmpty()) { + prompt += ShortDramaImageStyleReferences.direction(locationAsset, imageReferences.size()); + } + ShortDramaImagePromptEvidence.Evidence evidence = imagePromptEvidence.prepare(assetType, assetId, projectId, userId, + model, locationAsset ? (revision ? "location_revision" : "location") : (revision ? "identity_revision" : "identity"), revisionRequirements, prompt, referenceImage); + log.info("短剧图片提示词证据 attemptId={} assetType={} assetId={} purpose={} promptSha256={}", + evidence.attemptId(), assetType, assetId, evidence.referencePurpose(), evidence.promptSha256()); + MediaGenerationResponse response = imageServiceFactory.getOriginalService(modelVo.getProviderCode()) .startImageGeneration(ImageContext.builder() .chatModelVo(modelVo).prompt(prompt).size(size).seed(seed) - .image(validateReferenceImageUrl(referenceImageUrl)).build()); + .image(referenceImage).referenceImages(styleImages.isEmpty() ? null : imageReferences).build()); + try { + imagePromptEvidence.submitted(evidence, response == null ? null : response.getId(), response == null ? "unknown" : response.getStatus()); + } catch (IllegalStateException ex) { + String prediction = response == null ? null : response.getId(); + log.error("图片任务证据保存失败 attemptId={} predictionId={} promptSha256={}", + evidence.attemptId(), prediction, evidence.promptSha256()); + throw new IllegalStateException(ex.getMessage() + ";attemptId=" + evidence.attemptId() + ",predictionId=" + prediction, ex); + } + return response; } @Override @@ -1825,18 +2571,22 @@ public class ShortDramaServiceImpl implements IShortDramaService { throw new IllegalStateException("图片生成完成但未获取到URL"); } + ShortDramaImagePromptEvidence.Evidence evidence = imagePromptEvidence.forPrediction("appearance", appearanceId, userId, model, predictionId); String basePrompt = firstNotBlank(appearance.getDescription(), character.getVisualDescription(), character.getName()); - String finalPrompt = buildCharacterPrompt(basePrompt, character.getProjectId()); - - appearance.setPreviousImageUrls(appearance.getImageUrls()); - appearance.setPreviousDescriptions(appearance.getImageDescriptions()); - appearance.setReferenceImageUrl(imageUrl); + String finalPrompt = evidence == null ? buildCharacterPrompt(basePrompt, character.getProjectId()) : evidence.finalPrompt(); + boolean revision = evidence != null && evidence.revision(); List urls = readJsonStringList(appearance.getImageUrls()); List descs = readJsonStringList(appearance.getImageDescriptions()); + if (urls.contains(imageUrl)) return MapstructUtils.convert(appearance, ShortDramaCharacterAppearanceVo.class); + appearance.setPreviousImageUrls(appearance.getImageUrls()); + appearance.setPreviousDescriptions(appearance.getImageDescriptions()); addImageUrl(urls, imageUrl, descs, finalPrompt); appearance.setImageUrls(JsonUtils.toJsonString(urls)); appearance.setImageDescriptions(JsonUtils.toJsonString(descs)); - appearance.setSelectedImageIndex(urls.size() - 1); + if (!revision) { + appearance.setReferenceImageUrl(imageUrl); + appearance.setSelectedImageIndex(urls.size() - 1); + } characterAppearanceMapper.updateById(appearance); return MapstructUtils.convert(appearance, ShortDramaCharacterAppearanceVo.class); @@ -1861,18 +2611,22 @@ public class ShortDramaServiceImpl implements IShortDramaService { } String prompt = firstNotBlank(primaryLocationDescription(location), location.getSummary(), location.getName()); - String finalPrompt = ShortDramaDirectorSkills.load("visual-world") + ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + prompt + - ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + artStyleSuffix(location.getProjectId()); + ShortDramaImagePromptEvidence.Evidence evidence = imagePromptEvidence.forPrediction("location", locationId, userId, model, predictionId); + String finalPrompt = evidence == null ? buildLocationPrompt(location.getName(), prompt, location.getProjectId()) : evidence.finalPrompt(); + boolean revision = evidence != null && evidence.revision(); - location.setPreviousImageUrls(location.getImageUrls()); - location.setPreviousDescriptions(location.getImageDescriptions()); - location.setReferenceImageUrl(imageUrl); List urls = readJsonStringList(location.getImageUrls()); List descs = readJsonStringList(location.getImageDescriptions()); + if (urls.contains(imageUrl)) return MapstructUtils.convert(location, ShortDramaLocationVo.class); + location.setPreviousImageUrls(location.getImageUrls()); + location.setPreviousDescriptions(location.getImageDescriptions()); addImageUrl(urls, imageUrl, descs, finalPrompt); location.setImageUrls(JsonUtils.toJsonString(urls)); location.setImageDescriptions(JsonUtils.toJsonString(descs)); - location.setSelectedImageIndex(urls.size() - 1); + if (!revision) { + location.setReferenceImageUrl(imageUrl); + location.setSelectedImageIndex(urls.size() - 1); + } locationMapper.updateById(location); return MapstructUtils.convert(location, ShortDramaLocationVo.class); @@ -1942,15 +2696,24 @@ public class ShortDramaServiceImpl implements IShortDramaService { } /** - * 流式调用模型。onPartial 回调在每次新 token 到来时以当前完整 buffer 调用, - * 调用方可在此做增量解析(如分镜 panel 增量推送)。onPartial 为 null 时行为同旧版。 + * 流式回调只接收新增正文,避免逐 token 重扫完整响应。进度只记录字符数与时间,不暴露思考正文。 */ private String streamingChat(StreamingChatModel streamingModel, ChatModel chatModel, String prompt, SseEmitter emitter, String streamPhase, java.util.function.Consumer onPartial) { + return streamingChat(streamingModel, chatModel, prompt, emitter, streamPhase, onPartial, streamPhase); + } + + private String streamingChat(StreamingChatModel streamingModel, ChatModel chatModel, + String prompt, SseEmitter emitter, String streamPhase, + java.util.function.Consumer onPartial, String callLabel) { if (streamingModel == null || emitter == null) { return chatModel.chat(prompt); } + var progress = planningProgress.get(emitter); + String callId = progress == null ? null : progress.startCall(streamPhase, callLabel, prompt.length()); + final long callStartedAt = System.currentTimeMillis(); + final java.util.concurrent.atomic.AtomicLong firstContentAt = new java.util.concurrent.atomic.AtomicLong(); StringBuilder buf = new StringBuilder(); CompletableFuture done = new CompletableFuture<>(); ScheduledExecutorService heartbeat = Executors.newSingleThreadScheduledExecutor(runnable -> { @@ -1958,31 +2721,41 @@ public class ShortDramaServiceImpl implements IShortDramaService { thread.setDaemon(true); return thread; }); - heartbeat.scheduleAtFixedRate(() -> emit(emitter, streamPhase, "running", "模型仍在处理中,请稍候..."), - 15, 15, TimeUnit.SECONDS); + heartbeat.scheduleAtFixedRate(() -> { + if (progress != null) progress.heartbeat(); + else emit(emitter, streamPhase, "running", "模型仍在处理中,请稍候..."); + }, 15, 15, TimeUnit.SECONDS); // 首 token 超时兜底:部分聚合站对 stream=true 既不返回内容也不报错, - // 长场分镜的推理可能超过90秒;保留心跳,5分钟无正文才判为无响应。 + // Seed 2.1 Pro 长总纲首正文实测超过 11 分钟,需与 Atlas transport 的期限一致。 final AtomicBoolean firstTokenReceived = new AtomicBoolean(false); final java.util.concurrent.atomic.AtomicLong lastTokenAt = new java.util.concurrent.atomic.AtomicLong(System.currentTimeMillis()); heartbeat.scheduleAtFixedRate(() -> { if (firstTokenReceived.get() && System.currentTimeMillis()-lastTokenAt.get()>90_000) done.completeExceptionally(new RuntimeException("模型输出停滞超过90秒,已完成部分保留")); }, 15, 15, TimeUnit.SECONDS); heartbeat.schedule(() -> { if (firstTokenReceived.compareAndSet(false, true)) { done.completeExceptionally(new RuntimeException( - "模型在90秒内未返回正文,已完成场次保留,请重试或更换模型")); + "模型在15分钟内未返回正文,已完成场次保留,请查询原请求后再恢复")); } - }, 90, TimeUnit.SECONDS); + }, 15, TimeUnit.MINUTES); List messages = List.of(UserMessage.from(prompt)); streamingModel.chat(messages, new StreamingChatResponseHandler() { + @Override + public void onPartialThinking(PartialThinking thinking) { + if (done.isDone() || thinking == null || thinking.text() == null) return; + lastTokenAt.set(System.currentTimeMillis()); + if (progress != null) progress.activity(callId, thinking.text(), true); + } @Override public void onPartialResponse(String text) { - if (done.isDone()) return; + if (done.isDone() || text == null || text.isEmpty()) return; + firstContentAt.compareAndSet(0, System.currentTimeMillis()); + if (progress != null) progress.activity(callId, text, false); firstTokenReceived.set(true); lastTokenAt.set(System.currentTimeMillis()); buf.append(text); // Detail batches run independently; do not interleave their JSON in the UI. if (!"storyboard_detail".equals(streamPhase) && !"storyboard_plan_parallel".equals(streamPhase)) emitStream(emitter, streamPhase, text); if (onPartial != null) { - try { onPartial.accept(buf.toString()); } catch (Exception ex) { + try { onPartial.accept(text); } catch (Exception ex) { log.warn("流式增量回调异常: {}", ex.getMessage()); } } @@ -1998,87 +2771,77 @@ public class ShortDramaServiceImpl implements IShortDramaService { } }); try { - done.orTimeout(8, TimeUnit.MINUTES).join(); + done.orTimeout(30, TimeUnit.MINUTES).join(); } catch (Exception e) { Throwable cause = e.getCause() != null ? e.getCause() : e; throw new RuntimeException(cause instanceof java.util.concurrent.TimeoutException - ? "模型在8分钟内未完成当前批次,请检查模型服务状态或更换模型后重试" + ? "模型在30分钟内未完成当前批次,请检查原请求状态后再恢复" : "模型调用失败:" + cause.getMessage(), cause); } finally { heartbeat.shutdownNow(); + if (progress != null) progress.finishCall(callId, !done.isCompletedExceptionally()); + log.info("分镜模型阶段: requestId={}, phase={}, label={}, promptChars={}, outputChars={}, firstContentMs={}, elapsedMs={}, success={}", + progress == null ? "untracked" : progress.snapshot().requestId(), streamPhase, callLabel, prompt.length(), buf.length(), + firstContentAt.get() == 0 ? -1 : firstContentAt.get() - callStartedAt, System.currentTimeMillis() - callStartedAt, !done.isCompletedExceptionally()); } return buf.toString(); } - // ==================== Phase 1: 剧本打磨(单次流式调用,元信息 JSON + 剧本正文)==================== + // ==================== Phase 1: 固定格式剧本生成(单次流式调用,元信息 JSON + 剧本正文)==================== private static final String SCRIPT_DELIMITER = "===SCRIPT==="; - private static final String PHASE1_COMBINED_SYSTEM = ShortDramaDirectorSkills.load("emotional-dialogue", "story-causality", "visual-world", "real-material") + """ - 你是顶级短剧编剧、剧本统筹和场景规划师。根据用户提供的故事资料,创作一部剧情完整、可拍摄、具有多场次推进的短剧,而不是只截取结局或高潮片段。 + /** Read-only snapshots of the actual production templates; no project data or model call. */ + static java.util.Map systemPromptCatalog() { + var result = new java.util.LinkedHashMap(); + result.put("剧本写作与打磨", PHASE1_COMBINED_SYSTEM); + result.put("提取角色档案", buildCharacterProfilePrompt("{{剧本正文与风格基调}}")); + result.put("提取场景资产", buildLocationCreatePrompt("{{剧本正文与风格基调}}")); + result.put("提取关键道具", ShortDramaVisualAssetService.SCRIPT_PROP_EXTRACTION_PROMPT); + result.put("分镜道具关联", ShortDramaVisualAssetService.PROP_EXTRACTION_PROMPT); + result.put("分镜拆解与节奏规划", buildStoryboardPlanPrompt("{{剧本正文}}", "{{角色库}}", "{{场景库}}", "{{角色关系}}", "{{人物形象}}", "{{场景描述}}", "{{项目风格}}", "{{画幅}}")); + result.put("摄影与机位规则", buildCinematographerPrompt("{{分镜规划}}", 0, "{{场景描述}}", "{{角色信息}}")); + result.put("表演与对白指导", buildActingDirectionPrompt("{{分镜规划}}", 0, "{{角色信息}}")); + result.put("分镜首帧与视频导演稿", buildStoryboardDetailPrompt("{{分镜规划}}", "{{人物年龄与性别}}", "{{场景描述}}", "{{项目风格}}", "{{画幅}}")); + return java.util.Collections.unmodifiableMap(result); + } - 【输出格式 - 严格按顺序】 - 先输出一行 JSON 元信息,换行后输出 "===SCRIPT===", 再换行后输出完整剧本正文(按用户指定时长展开,不用固定字数压缩故事)。 + private static final String PHASE1_COMBINED_SYSTEM = """ + 你是短剧编剧。只完成一件事:把用户输入写成固定格式、可直接审阅的纯文本剧本。 - JSON 格式(一行完成,不要换行): - {"projectName":"项目名","description":"简介(30-80字)","scriptName":"剧本名","tone":"类型与基调","outlineText":"简明剧情大纲:整体节奏、因果链与情绪转折"} + 输出顺序必须为: + 第一行单行 JSON:{"projectName":"项目名","description":"30至80字简介","scriptName":"剧本名","tone":"类型与基调","outlineText":"简明因果大纲"} + 第二行:===SCRIPT=== + 之后只写剧本正文。 - 【完整故事弧 - 最高优先级】 - 1. 必须覆盖用户资料中的完整主线,禁止只选择决赛、告白、复仇等最后高潮单独成篇 - 2. 场次数服从故事复杂度和用户时长,不设最低场次数;简单故事合并重复信息,不为了完整而增加支线 - 3. 只覆盖本故事必要的起因、变化、选择与结果;生活故事以认知变化为主,不套用挫折、背叛或反击模板 - 4. 每场都必须改变剧情状态,说明角色目标、阻碍、行动和结果;禁止多个场次只是重复训练、重复争吵或重复比赛 - 5. 反派倒戈、关系破裂、能力掌握、团队团结、比赛逆转等重大变化必须提前铺垫,不能突然发生 - 6. 开场不得直接进入最终决战,除非用户明确要求只写高潮片段 + 剧本正文固定格式(结构以《甲申逆命 E01|青芜第一炮》为模板,但不得复用其人物、情节或设定): + 《剧名》第一集:集名 - 【因果与信息边界】 - - outlineText只负责整体节奏、情绪转折与因果关系;每段用1-2句说明为何发生、带来什么变化和下一步,标注时间预算,不写分镜。 - - 家庭、生活题材按人物认知变化推进,不得强塞反派、背叛、比赛或逆转。 - - 区分观众知道什么、主角知道什么;推测必须经人物查证后才能作为事实。 - - 软件只做资料识别和统计,不可凭空推断人物秘密;真实界面与票据的素材使用说明放入分镜,不写进叙事正文。 - - 每场景头写“内景/外景 地点 时间(预计XX秒)”;各场合计贴近用户时长;对白、动作、停顿均需有实际内容承载。 + 一 地点或事件。时间。 - 【分场要求】 - - 使用标准场景头:内景/外景 + 具体地点 + 时间 - - 地点变化、时间跳变或一个戏剧单元完成时另起新场 - - 每场包含:场景建立、明确冲突、角色行动、可见结果、引向下一场的钩子 - - 场间用动作、决定、视线、声音、时间推进或人物出发进行衔接 - - 闪回最多使用一次,且不能替代正常的前期铺垫 + 用可见、可听的事实建立环境与当下处境。 - 【剧情忠实与提炼】 - - 用户输入可能包含新闻摘要、角色资料、链接来源和重复段落。提取其中的故事事实,不要把网址、媒体名称、点赞按钮或来源标签写进剧本 - - 保留核心人物、核心冲突、关键反转与结局;可压缩次要人物和重复信息,但不得删除导致主线断裂的阶段 - - 不照搬现实演员姓名作为角色说明;只使用故事中的角色名 - - 不虚构用户资料之外的重大设定、冠军、背叛原因或人物关系;必要的过渡动作可以合理补全 + 写人物动作、事件变化和场间承接。 - 【剧本格式】 - 场景头 - 一句交代必要的情境 - 推动剧情的主要行动 - 角色名:「台词」 - 自然收束并承接下一场;不重复标注已表达的结果或钩子 + 角色名(可选状态或声源):台词 - 【大纲、正文、分镜分工 - 最高优先级】 - - 大纲负责全片节奏、连贯性和因果;保持简明,不复述完整台词或展开执行细节。 - - 正文只保留必要情境、主要行动和有效对白。一个连续行为用一句说明,不拆成拿起、抬手、转腕、放下的流水账。 - - 不在正文写景别、机位、运镜、光线参数、左右站位、持物手、逐秒安排、表演参数或软件按钮操作;这些由分镜阶段根据正文补充。 - - 核心道具出现与移动要交代其剧情原因,但不写机械过程。简单故事不增加无关误会、维修教学、反复核账或多次告别来凑时长。 - - 不以字数、场数判定完整,不为了达到1000字或5场而扩写。完整性只看必要因果链是否成立。 + 二 地点或事件。时间。 + …… - 【台词与动作】 - - 对话必须推动认知、关系、决定或行动变化 - - 重要决定先有触发,再有反应,最后付诸行动 - - 将心理活动转译为可见动作、表情、选择或简短独白 - - 比赛、战斗和训练必须写清动作过程与结果,不能只用“经过努力”“最终获胜”概括 + 第一集完。 - 【自检 - 输出前内部完成】 - - 场次是否必要,简明正文是否覆盖了完整因果链,技术细节是否留给分镜 - - 是否覆盖用户资料中的必要起因、变化、选择和结果,而没有强加复杂故事模板 - - 删除任一场是否会造成剧情断裂;若不会,该场应合并或强化 - - 是否存在角色无原因倒戈、突然掌握能力、突然团结或突然获胜 - - 是否误把最终高潮写成了整部剧本 + 格式约束: + 1. 正文禁止 Markdown,不得出现标题井号、星号加粗、引用符、列表符或代码围栏。 + 2. 每场必须以“中文场次序号+全角空格+地点或事件+时间”开头,例如“一 黑屏。冬。”或“二 县衙二堂。接上。”。 + 3. JSON的tone按当前故事自动概括风格与基调,包含叙事类型、情绪、节奏及适合角色和场景的视觉方向;以用户明确要求为准,不固定套用其他作品风格。正文只写场景、行动、声音和对白;不写镜号、机位、运镜、逐秒安排、画面比例、生成规则或创作说明。 + 4. 场次标题后直接写自然的动作与环境段落,不加“场景”“行动”“说明”等模板标签。 + 5. 保留用户给出的核心人物、冲突、关键转折和结局。每场都要改变剧情状态,场与场之间必须有明确因果承接。 + 6. 对白符合人物身份和语境,删掉解释剧情的套话、总结句和机械问答。 + 7. 若用户提供“当前剧本”和“修改意见”,以当前剧本为底稿,只按意见重写;未提及的人物、事实、因果和结局保持一致。 + 8. 军事内容只写非操作性叙事,不输出武器制造参数或使用教程。 + 9. 内容规模服从用户的原事件:测试片段、一个简单动作或几句话的场面不扩成多场完整故事,不增加挑战、支线、往返、失败重试或解释对白。未给时长时按原事件自然长度写;有明确短片时长要求时先估算对白与独占动作容量,再写能容纳的剧本,不把长故事写完后交给分镜硬压。 - 只输出上述格式,不要任何额外说明。 + 除规定的 JSON、分隔符和剧本正文外,不输出任何说明。 """; /** @@ -2102,73 +2865,107 @@ public class ShortDramaServiceImpl implements IShortDramaService { int count = 0; for (String line : scriptText.split("\\R")) { String normalized = line.trim(); - if (normalized.matches("^(内景|外景|内外景|闪回[::]?\\s*(内景|外景)?).*")) count++; + if (normalized.matches("^[一二三四五六七八九十百零〇]+ \\S+.*")) count++; } return count; } - static boolean scriptNeedsExpansion(ShortDramaScriptResult result) { - if (result == null || StrUtil.isBlank(result.getScriptText())) return true; - return countSceneHeadings(result.getScriptText()) == 0; + static String normalizePlainScriptText(String source) { + if (source == null) return ""; + String normalized = source.replace("\r\n", "\n").replace('\r', '\n') + .replaceAll("(?m)^\\s*```[^\\n]*$", "") + .replaceAll("(?m)^\\s{0,3}#{1,6}\\s*", "") + .replaceAll("(?m)^\\s{0,3}>\\s?", "") + .replaceAll("(?m)^\\s*[-+*]\\s+(?=\\S)", "") + .replaceAll("\\*\\*([^*\\n]+)\\*\\*", "$1") + .replaceAll("__([^_\\n]+)__", "$1") + .replaceAll("(?m)[ \\t]+$", "") + .replaceAll("\\n{3,}", "\n\n") + .trim(); + return normalized; } - private ShortDramaScriptResult expandIncompleteScript(ChatModel chatModel, ShortDramaIdeaBo bo, - ShortDramaScriptResult initial) { - String prompt = PHASE1_COMBINED_SYSTEM - + "\n\n以下初稿缺少可识别的场景头。只修复剧本结构,保留简洁叙事,不增加支线、镜头细节或无关场次。" - + "\n原始资料:\n" + sanitizeIdeaInput(bo.getIdea()) - + "\n\n不合格初稿:\n" + (initial == null ? "无" : firstNotBlank(initial.getScriptText(), "无")); - ShortDramaScriptResult expanded = parsePhase1Response(chatModel.chat(prompt)); - return expanded != null && !scriptNeedsExpansion(expanded) ? expanded : initial; + private static String phase1UserPrompt(ShortDramaIdeaBo bo, String revisionInstruction) { + String projectName = firstNotBlank(bo.getProjectName(), "短剧项目"); + String revision = StrUtil.trim(revisionInstruction); + if (StrUtil.isNotBlank(revision)) { + return "用户期望项目名:" + projectName + + "\n\n当前剧本:\n" + sanitizeIdeaInput(bo.getIdea()) + + "\n\n修改意见:\n" + revision; + } + return "用户期望项目名:" + projectName + "\n\n故事想法:\n" + sanitizeIdeaInput(bo.getIdea()); + } + + private static ShortDramaScriptResult requireFixedScript(ShortDramaScriptResult result) { + if (result == null || StrUtil.isBlank(result.getScriptText())) { + throw new RuntimeException("剧本生成失败:模型返回格式异常,请重试"); + } + result.setScriptText(normalizePlainScriptText(result.getScriptText())); + if (countSceneHeadings(result.getScriptText()) == 0) { + throw new RuntimeException("剧本生成失败:正文未按固定场次格式输出,请重试"); + } + return result; } private ShortDramaScriptResult executePhase1_ScriptPolish(ChatModel chatModel, ShortDramaIdeaBo bo, + String revisionInstruction, Consumer onPhase) { - String projectName = firstNotBlank(bo.getProjectName(), "短剧项目"); - String prompt = PHASE1_COMBINED_SYSTEM + "\n\n用户期望项目名:" + projectName + "\n用户创意:" + sanitizeIdeaInput(bo.getIdea()); + String prompt = PHASE1_COMBINED_SYSTEM + "\n\n" + phase1UserPrompt(bo, revisionInstruction); long t0 = System.currentTimeMillis(); String resp = chatModel.chat(prompt); - log.info("Phase 1 剧本打磨: elapsed={}ms len={}", System.currentTimeMillis() - t0, + log.info("Phase 1 剧本生成: elapsed={}ms len={}", System.currentTimeMillis() - t0, resp != null ? resp.length() : 0); - ShortDramaScriptResult result = parsePhase1Response(resp); - if (result == null || StrUtil.isBlank(result.getScriptText())) { - throw new RuntimeException("剧本打磨失败:LLM 返回格式异常,请重试"); - } - if (scriptNeedsExpansion(result)) { - log.warn("Phase 1 剧本缺少场景结构,尝试修复: length={} scenes={}", - result.getScriptText().length(), countSceneHeadings(result.getScriptText())); - result = expandIncompleteScript(chatModel, bo, result); - } + ShortDramaScriptResult result = requireFixedScript(parsePhase1Response(resp)); if (onPhase != null) onPhase.accept("outline_done"); return result; } private ShortDramaScriptResult executePhase1_ScriptPolish(ChatModel chatModel, ShortDramaIdeaBo bo) { - return executePhase1_ScriptPolish(chatModel, bo, null); + return executePhase1_ScriptPolish(chatModel, bo, null, null); } /** * 流式版本:StreamingChatModel 单次调用,边收边解析,JSON 完成后立即推流剧本正文 */ private ShortDramaScriptResult executePhase1_Streaming(StreamingChatModel streamingModel, - ShortDramaIdeaBo bo, SseEmitter emitter) { - String projectName = firstNotBlank(bo.getProjectName(), "短剧项目"); + ShortDramaIdeaBo bo, SseEmitter emitter, ShortDramaScriptProgress progress) { String systemPrompt = PHASE1_COMBINED_SYSTEM; - String userPrompt = "用户期望项目名:" + projectName + "\n用户创意:" + sanitizeIdeaInput(bo.getIdea()); + String userPrompt = phase1UserPrompt(bo, null); List messages = List.of( SystemMessage.from(systemPrompt), UserMessage.from(userPrompt)); StringBuilder buf = new StringBuilder(); ShortDramaScriptResult[] result = {null}; boolean[] scriptStreaming = {false}; + int[] streamedScriptChars = {0}; + boolean[] thinkingStarted = {false}; + boolean[] contentStarted = {false}; CompletableFuture streamDone = new CompletableFuture<>(); long t0 = System.currentTimeMillis(); + progress.start(systemPrompt.length() + userPrompt.length()); streamingModel.chat(messages, new StreamingChatResponseHandler() { + @Override + public void onPartialThinking(PartialThinking thinking) { + if (thinking == null || StrUtil.isEmpty(thinking.text())) return; + if (!thinkingStarted[0]) { + thinkingStarted[0] = true; + emit(emitter, "polish", "running", "模型正在处理剧情,收到正文后实时显示..."); + log.info("Phase 1 首次思考输出: elapsed={}ms", System.currentTimeMillis() - t0); + } + progress.thinking(thinking.text().length()); + } + @Override public void onPartialResponse(String text) { + if (StrUtil.isEmpty(text)) return; + if (!contentStarted[0]) { + contentStarted[0] = true; + emit(emitter, "outline", "running", "正在整理故事信息,随后输出剧本正文..."); + } + progress.content(text.length()); buf.append(text); if (!scriptStreaming[0]) { // 检查 JSON 元信息是否已完整(由 ===SCRIPT=== 分隔符标记) @@ -2182,19 +2979,30 @@ public class ShortDramaServiceImpl implements IShortDramaService { scriptStreaming[0] = true; log.info("Phase 1 流式 JSON 解析完成: elapsed={}ms ok={}", System.currentTimeMillis() - t0, result[0] != null); - emit(emitter, "outline", "done", "大纲已生成,正在展开完整剧本..."); + emit(emitter, "outline", "done", "剧本信息已生成,正在输出正文..."); // 分隔符后面的已有文字作为剧本开头推送 String tail = buf.substring(delim + SCRIPT_DELIMITER.length()); + streamedScriptChars[0] = tail.length(); if (StrUtil.isNotBlank(tail.trim())) { + progress.script(tail.length()); emitStream(emitter, "script", tail); } } } else { + streamedScriptChars[0] += text.length(); + progress.script(text.length()); emitStream(emitter, "script", text); } } @Override public void onCompleteResponse(ChatResponse response) { + // 某些兼容供应商只回调完整响应,或仅在完整响应中补上最后一段。 + String completeText = response != null && response.aiMessage() != null ? response.aiMessage().text() : null; + if (StrUtil.isNotBlank(completeText) && completeText.startsWith(buf.toString())) { + if (completeText.length() > buf.length()) progress.content(completeText.length() - buf.length()); + buf.setLength(0); + buf.append(completeText); + } // 兜底:如果没找到分隔符,尝试从完整响应提取 JSON if (!scriptStreaming[0] || result[0] == null) { result[0] = parsePhase1Response(buf.toString()); @@ -2204,11 +3012,17 @@ public class ShortDramaServiceImpl implements IShortDramaService { String fullText = buf.toString(); int delim = fullText.indexOf(SCRIPT_DELIMITER); String scriptText = delim >= 0 - ? fullText.substring(delim + SCRIPT_DELIMITER.length()).trim() + ? fullText.substring(delim + SCRIPT_DELIMITER.length()) : extractScriptAfterJson(fullText); + if (StrUtil.isBlank(scriptText)) scriptText = result[0].getScriptText(); if (StrUtil.isBlank(result[0].getScriptText()) && StrUtil.isNotBlank(scriptText)) { result[0].setScriptText(StringUtils.strip(scriptText)); } + if (scriptText != null && scriptText.length() > streamedScriptChars[0]) { + String suffix = scriptText.substring(streamedScriptChars[0]); + progress.script(suffix.length()); + emitStream(emitter, "script", suffix); + } } emitStreamDone(emitter); log.info("Phase 1 流式完成: totalElapsed={}ms scriptLen={}", @@ -2226,19 +3040,9 @@ public class ShortDramaServiceImpl implements IShortDramaService { try { streamDone.join(); } catch (Exception e) { - throw new RuntimeException("剧本打磨失败:" + e.getMessage(), e); + throw new RuntimeException("剧本生成失败:" + e.getMessage(), e); } - if (result[0] == null || StrUtil.isBlank(result[0].getScriptText())) { - throw new RuntimeException("剧本打磨失败:LLM 返回格式异常,请重试"); - } - if (scriptNeedsExpansion(result[0])) { - emit(emitter, "script", "running", "检测到剧情场次不足,正在扩展完整故事线..."); - ChatModelVo fallbackModel = findChatModel(); - ChatModel fallbackChatModel = getChatService(fallbackModel).buildChatModel(fallbackModel); - result[0] = expandIncompleteScript(fallbackChatModel, bo, result[0]); - emit(emitter, "script", "done", "完整多场次剧本已生成"); - } - return result[0]; + return requireFixedScript(result[0]); } private ShortDramaScriptResult parsePhase1Response(String resp) { @@ -2257,7 +3061,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (json == null) return null; ShortDramaScriptResult result = parseJson(json, ShortDramaScriptResult.class); if (result != null && StrUtil.isNotBlank(scriptPart)) { - result.setScriptText(StringUtils.strip(scriptPart)); + result.setScriptText(normalizePlainScriptText(StringUtils.strip(scriptPart))); } return result; } @@ -2280,8 +3084,16 @@ public class ShortDramaServiceImpl implements IShortDramaService { private void executePhase2_AssetAnalysis(ChatModel chatModel, StreamingChatModel streamModel, Long projectId, ShortDramaScript script, SseEmitter emitter) { - String scriptText = firstNotBlank(script.getScriptText(), script.getOutlineText(), ""); - if (StrUtil.isBlank(scriptText)) return; + executePhase2_AssetAnalysis(chatModel, streamModel, projectId, script, emitter, false); + } + + private void executePhase2_AssetAnalysis(ChatModel chatModel, StreamingChatModel streamModel, + Long projectId, ShortDramaScript script, SseEmitter emitter, boolean replaceAssets) { + String source = firstNotBlank(script.getScriptText(), script.getOutlineText(), ""); + if (StrUtil.isBlank(source)) throw new IllegalArgumentException("资产分析需要已保存的有效剧本,世界观不能替代正文"); + String scriptText = source + ShortDramaScriptPreparation.worldContext(script); + var frozenAssetSkills = skillCatalog.snapshot(projectMapper.selectById(projectId)); + String assetDirection = frozenAssetSkills.direction(); try { List characters; @@ -2290,41 +3102,46 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (emitter != null && streamModel != null) { // 流式模式:串行执行,用户能看到每个阶段的原始输出 emit(emitter, "assets_chars", "running", "正在分析角色档案..."); - String charsResp = streamingChat(streamModel, chatModel, buildCharacterProfilePrompt(scriptText), emitter, "assets"); + String charsResp = streamingChat(streamModel, chatModel, buildCharacterProfilePrompt(scriptText) + assetDirection, emitter, "assets"); characters = parseCharacterList(charsResp); + requireAssetCharacters(characters); emit(emitter, "assets_chars", "done", "角色分析完成,提取 " + characters.size() + " 个角色"); emit(emitter, "assets_locs", "running", "正在分析场景站位..."); StreamingChatModel locsStreamModel = buildStreamingChatModel(); - String locsResp = streamingChat(locsStreamModel, chatModel, buildLocationCreatePrompt(scriptText), emitter, "assets"); + String locsResp = streamingChat(locsStreamModel, chatModel, buildLocationCreatePrompt(scriptText) + assetDirection, emitter, "assets"); locations = parseLocationList(locsResp); + requireAssetLocations(locations); emit(emitter, "assets_locs", "done", "场景分析完成,提取 " + locations.size() + " 个场景"); } else { // 非流式:保持原有并发模式,速度更快 CompletableFuture> charsFuture = CompletableFuture.supplyAsync(() -> { - String resp = chatModel.chat(buildCharacterProfilePrompt(scriptText)); + String resp = chatModel.chat(buildCharacterProfilePrompt(scriptText) + assetDirection); return parseCharacterList(resp); }); CompletableFuture> locsFuture = CompletableFuture.supplyAsync(() -> { - String resp = chatModel.chat(buildLocationCreatePrompt(scriptText)); + String resp = chatModel.chat(buildLocationCreatePrompt(scriptText) + assetDirection); return parseLocationList(resp); }); characters = charsFuture.join(); locations = locsFuture.join(); } - characters = deduplicateCharacters(characters); - locations = deduplicateLocations(locations); + requireAssetCharacters(characters); + requireAssetLocations(locations); + List checkedCharacters = deduplicateCharacters(characters); + List checkedLocations = deduplicateLocations(locations); - // 2b: 并发为每个角色生成视觉描述(不论流式与否,均并发执行) - List> visualFutures = new ArrayList<>(); - for (AssetGeneratedCharacter c : characters) { - visualFutures.add(CompletableFuture.runAsync(() -> { + // Worker tasks only prepare data. A failed sibling may still finish its + // provider request, but it cannot write assets after this operation fails. + List> visualFutures = new ArrayList<>(); + for (AssetGeneratedCharacter c : checkedCharacters) { + visualFutures.add(CompletableFuture.supplyAsync(() -> { String visualDesc = ""; try { - String vr = chatModel.chat(buildCharacterVisualPrompt(c, script.getTone())); + String vr = chatModel.chat(buildCharacterVisualPrompt(c, script.getTone()) + assetDirection); AssetCharacterVisualResult visResult = parseJson(extractJson(vr), AssetCharacterVisualResult.class); if (visResult != null && visResult.getCharacters() != null) { for (AssetCharVisual cv : visResult.getCharacters()) { @@ -2340,18 +3157,25 @@ public class ShortDramaServiceImpl implements IShortDramaService { } } } catch (Exception e) { - log.warn("角色「{}」视觉描述生成失败: {}", c.getName(), e.getMessage()); - } - - synchronized (this) { - insertCharacter(projectId, c, visualDesc); + throw new IllegalStateException("角色「" + c.getName() + "」视觉描述生成失败", e); } + String description = firstNotBlank(visualDesc, c.getVisualKeywords(), ""); + if (StrUtil.isBlank(description)) throw new IllegalStateException("角色「" + c.getName() + "」没有有效视觉描述"); + return description; })); } CompletableFuture.allOf(visualFutures.toArray(new CompletableFuture[0])).join(); + List visualDescriptions = visualFutures.stream().map(CompletableFuture::join).toList(); - // 插入场景 - for (AssetGeneratedLocation loc : locations) { + // Preserve approved assets until both extraction branches and every visual + // description succeed. The replacement and all inserts share one transaction. + transactionTemplate.executeWithoutResult(status -> { + skillCatalog.verify(frozenAssetSkills, projectMapper.selectById(projectId)); + if (replaceAssets) clearAssets(projectId); + for (int index = 0; index < checkedCharacters.size(); index++) { + insertCharacter(projectId, checkedCharacters.get(index), visualDescriptions.get(index)); + } + for (AssetGeneratedLocation loc : checkedLocations) { ShortDramaLocation entity = new ShortDramaLocation(); entity.setId(IdUtil.getSnowflakeNextId()); entity.setProjectId(projectId); @@ -2362,9 +3186,28 @@ public class ShortDramaServiceImpl implements IShortDramaService { entity.setAvailableSlots(loc.getAvailableSlots() != null ? JsonUtils.toJsonString(loc.getAvailableSlots()) : null); entity.setDescriptions(loc.getDescriptions() != null ? JsonUtils.toJsonString(loc.getDescriptions()) : null); locationMapper.insert(entity); - } + } + }); } catch (Exception e) { - log.warn("Phase 2 资产分析失败: {}", e.getMessage()); + Throwable cause = e; + while (cause instanceof java.util.concurrent.CompletionException && cause.getCause() != null) cause = cause.getCause(); + log.warn("Phase 2 资产分析失败,项目{}原有资产保留: {}", projectId, cause.getMessage(), cause); + throw new IllegalStateException("资产分析未完成:" + firstNotBlank(cause.getMessage(), cause.getClass().getSimpleName()) + + "。已保存剧本与原有资产保留,请核对请求结果后手动继续", cause); + } + } + + private static void requireAssetCharacters(List characters) { + if (characters == null || characters.isEmpty()) throw new IllegalStateException("模型未返回可解析的角色档案"); + if (characters.stream().anyMatch(character -> character == null || StrUtil.isBlank(character.getName()))) { + throw new IllegalStateException("角色档案缺少有效角色名称"); + } + } + + private static void requireAssetLocations(List locations) { + if (locations == null || locations.isEmpty()) throw new IllegalStateException("模型未返回可解析的场景档案"); + if (locations.stream().anyMatch(location -> location == null || StrUtil.isBlank(location.getName()))) { + throw new IllegalStateException("场景档案缺少有效场景名称"); } } @@ -2411,7 +3254,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { } private static String buildCharacterProfilePrompt(String scriptText) { - return ShortDramaDirectorSkills.load("emotional-dialogue", "story-causality", "visual-world") + """ + return ShortDramaDirectorSkills.load("emotional-dialogue", "story-causality", "visual-world", "character-art-direction") + + ShortDramaAssetAesthetics.characterReference() + """ 你是专业的"选角指导"。请基于提供的文本(小说、剧本或混合格式),分析并输出所有需要制作形象的角色档案信息。 【你的职责】 @@ -2446,7 +3290,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { 4级(贵族/精英级):贵族、企业家,服装精致考究,使用高档面料和精致细节 3级(专业/品质级):中产阶级、专业人士,服装得体有品,剪裁讲究 2级(日常/普通级):普通人、学生,服装简洁日常,款式普通但整洁 - 1级(朴素/统一级):平民、底层劳动者,服装朴素统一,基础款式 + 1级(朴素/功能级):平民、劳动者,服装以功能为主,剪裁、材料与修补依个人身份和工作区分 【性格标签 personality_tags】 气质类:高冷、温柔、阳光、忧郁、神秘、妩媚、清冷、热情 @@ -2455,8 +3299,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { 用逗号分隔,至少2个,最多5个 【视觉关键词 visual_keywords】 - 风格类:精英气质、街头潮流、学院风、复古优雅、运动活力、文艺气息、冷淡极简 - 特征类:病弱感、禁欲系、狼狗系、奶狗系、御姐范、萝莉感、大叔味 + 写可落地的选角和妆造特征:年龄感、面部轮廓、眉眼组合、修整程度、服装材料、劳动痕迹。 + 不用「禁欲系」「奶狗系」等标签替代具体人物细节;不按主配角重要性统一美化脸部。 用逗号分隔 【输出格式】 @@ -2470,12 +3314,12 @@ public class ShortDramaServiceImpl implements IShortDramaService { "roleLevel": "S/A/B/C/D", "gender": "男/女", "ageRange": "约二十五岁", - "archetype": "角色原型(如霸道总裁)", - "personalityTags": "高冷,腹黑", + "archetype": "角色在剧情中的身份与职责", + "personalityTags": "谨慎,务实", "costumeTier": 3, - "visualKeywords": "精英气质,禁欲系", + "visualKeywords": "窄长脸,直眉,素面棉布衣", "suggestedColors": "深蓝,金色", - "primaryIdentifier": "眼角泪痣(仅S/A级需要)" + "primaryIdentifier": "剧本明确的固定辨识点;无则填写无,不凭空加痣或疤" } ] } @@ -2488,13 +3332,14 @@ public class ShortDramaServiceImpl implements IShortDramaService { private static String buildCharacterVisualPrompt(AssetGeneratedCharacter character, String tone) { String levelDesc = switch (firstNotBlank(character.getRoleLevel(), "B")) { - case "S" -> "180-220字,必须有极高的视觉辨识度和主角气质,五官精致,禁止用「普通」「平凡」「不起眼」"; - case "A" -> "150-180字,有明显的个人特色和记忆点,长相精致有吸引力"; + case "S" -> "180-220字,有清晰选角辨识度;脸部与体态贴合年龄、身份和经历,不要求五官精致或偶像化"; + case "A" -> "150-180字,有可见个人特征,与其他人物的轮廓和妆造可区分,不统一美化"; case "B" -> "120-150字,有基本的辨识特征"; case "C" -> "80-120字,简洁但完整的形象描述"; default -> "50-80字,基础形象即可"; }; - return ShortDramaDirectorSkills.load("visual-world") + """ + return ShortDramaDirectorSkills.load("visual-world", "character-art-direction") + + ShortDramaAssetAesthetics.characterReference() + """ 你是专业的"角色视觉设计师"。根据角色档案信息,生成详细的人物外貌描述(用于AI图片生成)。 【视觉层级规范】描述长度要求:%s @@ -2508,7 +3353,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { - 鼻子:高挺、小巧、笔直、精致等 - 嘴唇:薄厚、形状(小巧、丰润) - 眉毛:浓淡、形状(剑眉、柳叶眉) - - 独特记号:痣(位置)、雀斑、小疤痕等 + - 独特记号:只写剧本或已确认设定中的痣、伤侧、衣物辨识点;没有就不新增 + - 年龄与肤质:符合年龄的自然状态,细节服从视觉风格,不统一精修或夸张衰老 💇 发型描写(必须详细): - 发色:乌黑、深棕、栗色、金棕等 @@ -2519,9 +3365,9 @@ public class ShortDramaServiceImpl implements IShortDramaService { 👤 体态:身形(修长、健硕、纤细、匀称)、身高感(高挑、娇小、适中) - 👔 服装配饰:上衣、下装、鞋子(必填!)、配饰 + 👔 服装配饰:上衣、下装、鞋子(必填!)、配饰;补充剪裁、材料厚薄、接缝与有依据的固定磨损位置 - 【禁止描写】皮肤颜色、眼睛颜色、唇色、表情/姿态/动作、背景/环境、情绪形容词、抽象气质、「可能」「或」等不确定描述 + 【身份卡边界】允许符合年龄和身份的肤质、自然肤色及稳定的眉眼肩颈习惯。表情和性格须转为可见的轻微状态,不固定哭泣或愤怒等具体场次表演。禁止背景、剧情动作、抽象气质和「可能」「或」等不确定描述。 【服装华丽度对照】 5级:刺绣、镶嵌、定制剪裁、稀有面料 @@ -2567,7 +3413,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { } private static String buildLocationCreatePrompt(String scriptText) { - return ShortDramaDirectorSkills.load("visual-world") + """ + return ShortDramaDirectorSkills.load("visual-world") + ShortDramaAssetAesthetics.locationReference() + """ 你是"场景资产建立师"。请基于文本筛选需要制作画面的场景,生成用于出图的资产JSON。 【筛选规则】 @@ -2632,60 +3478,114 @@ public class ShortDramaServiceImpl implements IShortDramaService { private List executePhase3_StoryboardPlan(ChatModel chatModel, StreamingChatModel streamingModel, ShortDramaScript script, Long projectId, SseEmitter emitter, String modelName) { + return executePhase3_StoryboardPlan(chatModel, streamingModel, script, projectId, emitter, modelName, 1); + } + + private List executePhase3_StoryboardPlan(ChatModel chatModel, StreamingChatModel streamingModel, + ShortDramaScript script, Long projectId, SseEmitter emitter, String modelName, int minimum) { + return executePhase3_StoryboardPlan(chatModel, streamingModel, script, projectId, emitter, modelName, minimum, java.util.UUID.randomUUID().toString()); + } + + private List executePhase3_StoryboardPlan(ChatModel chatModel, StreamingChatModel streamingModel, + ShortDramaScript script, Long projectId, SseEmitter emitter, String modelName, int minimum, String requestId) { String text=firstNotBlank(script.getScriptText(),script.getOutlineText(), ""); if(StrUtil.isBlank(text))return List.of(); - String charsLib=buildCharactersLibString(projectId),locsLib=buildLocationsLibString(projectId); - String intro=buildCharactersIntroString(projectId),variants=buildCharactersAppearanceListString(projectId),descriptions=buildCharactersFullDescString(projectId); + String locsLib=buildLocationsLibString(projectId); String style=artStyleSuffix(projectId),aspect=projectAspectRatio(projectId); - String checkpoint=storyboardCheckpointKey(script,projectId)+":parallel-v3:"+ShortDramaDirectorSkills.VERSION+":"+cn.hutool.crypto.digest.DigestUtil.sha256Hex(modelName); + var assets = planningAssets(projectId); + String checkpoint = planningCheckpointKey(script, projectId, modelName, minimum); List scenes=splitScriptScenes(text); + var progress = emitter == null ? null : planningProgress.get(emitter); var completed=new java.util.concurrent.atomic.AtomicInteger(); List indices=java.util.stream.IntStream.range(0,scenes.size()).boxed().toList(); String tenant = org.ruoyi.common.tenant.helper.TenantHelper.getTenantId(); var byScene=ShortDramaParallel.mapOrdered(indices,3,sceneIndex->org.ruoyi.common.tenant.helper.TenantHelper.dynamic(tenant,()->{ - String context="全剧大纲(只作因果上下文):\n"+firstNotBlank(script.getOutlineText(),text) + String context=ShortDramaScriptPreparation.worldContext(script)+"\n全剧大纲(只作因果上下文):\n"+firstNotBlank(script.getOutlineText(),text) +"\n当前第"+(sceneIndex+1)+"/"+scenes.size()+"场,只拆本场:\n"+scenes.get(sceneIndex); if(sceneIndex>0) { String previous=scenes.get(sceneIndex-1); context+="\n上一场原文结尾(只作桥梁参考,不重复生成):"+previous.substring(Math.max(0,previous.length()-1000)); } - String prompt=buildStoryboardPlanPrompt(context,charsLib,locsLib,intro,variants,descriptions,style,aspect); String sceneScript=scenes.get(sceneIndex); + var scoped = sceneCharacterLibraries(assets, sceneScript, previousSceneBridge(scenes, sceneIndex)); + String prompt=buildStoryboardPlanPrompt(context,scoped.get("names"),locsLib,scoped.get("introductions"),scoped.get("appearances"),scoped.get("descriptions"),style,aspect); prompt+="\n本次只拆以下场次,时长预算以此标题为准:"+sceneScript.lines().findFirst().orElse("") - +"\n输出前先列内部节拍时间表并加总duration;将正常表演、核实信息、接收对白、情绪反应算入时间。正文是简述,分镜应展开表演,不逐句机械切镜。禁止增加支线、重复台词或无意义空镜。"+sceneBudgetScaffold(sceneScript); + +"\n本场出镜和发声只以本场原文及明确的上一场承接为据。世界观、大纲、全剧角色库只校对身份与材料,不能据此添加本场未安排的演员、CG或精灵。" + +"\n在同一次规划中按原文有效表演时间组织同空间连续动作与对白,再安排合适的生成段数。用户没有指定时间时按原事件实际容量估算,不按题材、句数或预设片长扩充。不追求固定镜数,也不新增对白、阻碍、反应轮次或过场;描述画面细节不增加事件时长。时长、节奏与承接检查仅作审阅建议,不是必须满足的数值或逐字相等限制。"+ShortDramaSceneBudget.scaffold(sceneScript, minimum); + final String initialPrompt = prompt; + var identity = sceneIdentity(projectId, script, scenes, sceneIndex, style, aspect, modelName, minimum); String key=checkpoint+":scene:"+(sceneIndex+1); - String cached=RedisUtils.getCacheObject(key); + var proof = sceneCheckpoints.compatible(identity, assets, true); + String cached = proof == null ? RedisUtils.getCacheObject(key) : null; List result=null; - if(StrUtil.isNotBlank(cached)) { - result=parsePanelList(cached); - if (result != null && !result.isEmpty()) try { validateScenePlan(sceneScript,result); } + boolean recoveredDraft = false; + if(proof != null || StrUtil.isNotBlank(cached)) { + result = proof == null ? parsePanelList(cached) : proof.panels(); + if (result != null && !result.isEmpty()) try { assets.referenced(result); assets.validateSceneCast(sceneScript, previousSceneBridge(scenes, sceneIndex), result); ShortDramaShotDesign.validate(result); validateScenePlan(sceneScript,result,minimum); } catch (RuntimeException invalid) { - RedisUtils.setCacheObject(key+":draft",JsonUtils.toJsonString(result),java.time.Duration.ofDays(7)); + if (!(invalid instanceof ShortDramaSceneCheckpoint.BindingMismatch)) sceneCheckpoints.save(identity, assets, result, false, "validation_failed"); result=null; } } + if (result != null && !result.isEmpty() && emitter != null) + emit(emitter,"storyboard_plan","running","第"+(sceneIndex+1)+"场已恢复已校验规划(未请求模型)"); if(result==null || result.isEmpty()) { - String draft = RedisUtils.getCacheObject(key+":draft"); - if (StrUtil.isNotBlank(draft)) { - result = parsePanelList(draft); + var draftProof = sceneCheckpoints.compatible(identity, assets, false); + String draft = draftProof == null ? RedisUtils.getCacheObject(key+":draft") : null; + if (draftProof != null || StrUtil.isNotBlank(draft)) { + result = draftProof == null ? parsePanelList(draft) : draftProof.panels(); if (result != null && !result.isEmpty()) { String issue = "恢复上次未通过的场次;保留原文对白与人物表演,重新核对时间表"; - try { validateScenePlan(sceneScript,result); } catch (RuntimeException invalid) { issue=invalid.getMessage(); } - prompt = buildSceneBudgetRepairPrompt(sceneScript, result, issue); + try { assets.referenced(result); assets.validateSceneCast(sceneScript, previousSceneBridge(scenes, sceneIndex), result); ShortDramaShotDesign.validate(result); validateScenePlan(sceneScript,result,minimum); issue = null; } + catch (ShortDramaSceneCheckpoint.BindingMismatch invalid) { result = null; issue = invalid.getMessage(); } + catch (ShortDramaShotDesign.InvalidDesign invalid) { result = null; issue = invalid.getMessage(); } + catch (RuntimeException invalid) { issue=invalid.getMessage(); } + if (result != null && issue == null) { + recoveredDraft = true; + sceneCheckpoints.save(identity, assets, result, true, "draft_recovered"); + RedisUtils.setCacheObject(key, JsonUtils.toJsonString(result), java.time.Duration.ofDays(7)); + } else { result = null; prompt = initialPrompt + "\n上次输出无法读取或绑定:" + issue + "。返回完整本场JSON,保留原文。"; } } } - for(int attempt=1;attempt<=2;attempt++)try { - boolean repairing = result != null && !result.isEmpty(); - if(emitter!=null)emit(emitter,"storyboard_plan","running","第"+(sceneIndex+1)+"场"+(repairing?"校对对白与时间表":"规划")+"(第"+attempt+"次)"); - String response=emitter==null?chatModel.chat(prompt):streamingChat(streamingModel!=null?streamingModel:buildStreamingChatModel(),chatModel,prompt,emitter,"storyboard_plan_parallel"); - result=repairing?applyTimingRepair(result,response):parsePanelList(response); + // Retry only unreadable output or unusable references; creative reviews never request a rewrite. + for(int attempt=1;attempt<=2 && !recoveredDraft;attempt++) { + String candidateId = null; + try { + if(emitter!=null)emit(emitter,"storyboard_plan","running","第"+(sceneIndex+1)+"场规划(第"+attempt+"次)"); + if (progress != null) progress.clearDrafts(sceneIndex + 1); + var partialOrdinal = new java.util.concurrent.atomic.AtomicInteger(); + var preview = new ShortDramaPanelStreamParser(node -> { + if (progress != null) progress.card(sceneIndex + 1, partialOrdinal.incrementAndGet(), "draft", node); + }); + String response=emitter==null?chatModel.chat(prompt):streamingChat(streamingModel!=null?streamingModel:buildStreamingChatModel(),chatModel,prompt,emitter,"storyboard_plan_parallel", preview::accept, "第"+(sceneIndex+1)+"场规划 · 第"+attempt+"次"); + candidateId = sceneCandidates.recordResponse(identity, requestId, attempt, "plan", response); + result = parseRawPanelList(response); + if (result != null) ShortDramaShotDesign.expandDeltas(result); if(result==null || result.isEmpty())throw new IllegalStateException("模型未返回有效镜头"); + assets.referenced(result); + assets.validateSceneCast(sceneScript, previousSceneBridge(scenes, sceneIndex), result); + sceneCheckpoints.save(identity, assets, result, false, "native_draft"); RedisUtils.setCacheObject(key+":draft",JsonUtils.toJsonString(result),java.time.Duration.ofDays(7)); - validateScenePlan(sceneScript,result); + ShortDramaShotDesign.validate(result); + validateScenePlan(sceneScript,result,minimum); + sceneCheckpoints.save(identity, assets, result, true, "native_validated"); RedisUtils.setCacheObject(key,JsonUtils.toJsonString(result),java.time.Duration.ofDays(7)); + sceneCandidates.checked(identity, candidateId, null); break; - }catch(Exception e){if(attempt==2)throw new IllegalStateException("第"+(sceneIndex+1)+"场失败,其他已完成场次保留:"+e.getMessage(),e); - prompt=result==null?prompt+"\n修复无效输出:"+e.getMessage():buildSceneBudgetRepairPrompt(sceneScript,result,e.getMessage());} + }catch(Exception e){ + log.warn("分镜规划校验未通过: requestId={}, scene={}, attempt={}, issue={}", requestId, sceneIndex + 1, attempt, ShortDramaSceneCandidateDiagnostics.redact(e.getMessage())); + if (candidateId != null) sceneCandidates.checked(identity, candidateId, e.getMessage()); + if(attempt>=2)throw new IllegalStateException("第"+(sceneIndex+1)+"场失败,其他已完成场次保留:"+e.getMessage(),e); + result = null; + prompt = initialPrompt + "\n上次输出无法读取或绑定:" + e.getMessage() + + "。请返回完整本场JSON,使用登记的准确角色、形象和场景名称;不要因为格式修复而增删原剧情。"; + } + } + } + if (progress != null) { + progress.clearDrafts(sceneIndex + 1); + for (int ordinal = 0; ordinal < result.size(); ordinal++) + progress.card(sceneIndex + 1, ordinal + 1, "planned", AtlasMediaSupport.OBJECT_MAPPER.valueToTree(result.get(ordinal))); } if(emitter!=null)emit(emitter,"storyboard_plan","running","分场规划已完成 "+completed.incrementAndGet()+"/"+scenes.size()+" 场(最多3路并发)"); return result; @@ -2693,17 +3593,48 @@ public class ShortDramaServiceImpl implements IShortDramaService { List all=new ArrayList<>(); for(int i=0;i panels) { + validateScenePlan(scene, panels, 1); + } + + private static void validateCompletedSceneBudgets(ShortDramaScript script, List panels, int minimum) { + var scenes = splitScriptScenes(firstNotBlank(script.getScriptText(), script.getOutlineText(), "")); + for (int i = 0; i < scenes.size(); i++) { + int number = i + 1; + var group = panels.stream().filter(p -> java.util.Objects.equals(p.getSceneNumber(), number)).toList(); + if (group.isEmpty()) throw new IllegalStateException("细化后缺少第" + number + "场,未落库"); + ShortDramaShotDesign.validate(group); + validateScenePlan(scenes.get(i), group, minimum); + } + } + + static void validateScenePlan(String scene, List panels, int minimum) { + validateSceneDuration(scene, panels, minimum); + for (int i = 0; i < panels.size(); i++) { + var panel = panels.get(i); + ShortDramaTiming.validate(i + 1, panel.getDuration(), panel.getSourceText(), buildContinuityJson(panel)); + } + for (String issue : scenePlanIssues(scene, panels)) log.info("分镜内容审阅建议(继续保存):{}", issue); + } + + static List scenePlanIssues(String scene, List panels) { List errors = new ArrayList<>(); - try { validateSceneDuration(scene, panels); } catch (RuntimeException e) { errors.add(e.getMessage()); } + var estimate = java.util.regex.Pattern.compile("预计\\s*(\\d+(?:\\.\\d+)?)\\s*秒").matcher(scene.lines().findFirst().orElse("")); + if (estimate.find()) { + double expected = Double.parseDouble(estimate.group(1)); + int actual = panels.stream().mapToInt(p -> p.getDuration() == null ? 0 : p.getDuration()).sum(); + if (Math.abs(expected - actual) > Math.max(.5, expected * .1)) + errors.add("本场预计" + expected + "秒,当前安排" + actual + "秒,可按实际内容与节奏审阅"); + } for (int i=0;i panels) { - if (panels.stream().anyMatch(p->p.getDuration()==null || p.getDuration()<1 || p.getDuration()>15)) throw new IllegalStateException("每镜必须明确分配1至15秒"); - String heading=scene.lines().findFirst().orElse(""); - var matcher=java.util.regex.Pattern.compile("预计\\s*(\\d+(?:\\.\\d+)?)\\s*秒").matcher(heading); - if (!matcher.find()) return; - double expected=Double.parseDouble(matcher.group(1)); - int actual=panels.stream().mapToInt(p->p.getDuration()==null?0:p.getDuration()).sum(); - double tolerance = Math.max(0.5, expected * 0.1); - if (expected>0 && Math.abs(actual-expected)>tolerance) - throw new IllegalStateException("本场预计"+expected+"秒,规划为"+actual+"秒;允许整数范围" - +(int)Math.ceil(expected-tolerance)+"至"+(int)Math.floor(expected+tolerance) - +"秒。保留对白和真实反应,不可用空镜填充;确实无法容纳时先修订剧本预算"); + validateSceneDuration(scene, panels, 1); + } + + static void validateSceneDuration(String scene, List panels, int minimum) { + if (panels == null || panels.isEmpty()) throw new IllegalStateException("本场未返回有效分镜"); + if (panels.stream().anyMatch(p -> p == null || p.getDuration() == null || p.getDuration() <= 0)) + throw new IllegalStateException("制作估算时长须为正整数秒"); } static String buildSceneBudgetRepairPrompt(String scene, List panels, String error) { - ArrayNode compact=JsonNodeFactory.instance.arrayNode(); - for(int i=0;i applyTimingRepair(List originals,String response) { - var mapper=new com.fasterxml.jackson.databind.ObjectMapper(); - try { - JsonNode edits=mapper.readTree(response.strip().replaceFirst("^```(?:json)?\\s*", "").replaceFirst("\\s*```$", "")); - if(!edits.isArray() || edits.isEmpty())throw new IllegalArgumentException("时间表须为非空数组"); - List repaired=new ArrayList<>(); - for(JsonNode edit:edits) { - int from=edit.path("from_panel").asInt(0); - if(from<1 || from>originals.size())throw new IllegalArgumentException("时间表原镜序号无效"); - var panel=mapper.convertValue(mapper.valueToTree(originals.get(from-1)),StoryboardPanelData.class); - if(!edit.hasNonNull("source_text") || !edit.path("timing").isObject())throw new IllegalArgumentException("时间表缺少原话或时间预算"); - panel.setSourceText(edit.path("source_text").asText()); panel.setTiming(edit.path("timing")); - panel.setDescription(edit.path("description").asText(panel.getDescription())); - panel.setStartState(edit.path("start_state").asText(panel.getStartState())); - panel.setEndState(edit.path("end_state").asText(panel.getEndState())); - panel.setStoryAction(panel.getDescription()); panel.setStoryResult(panel.getEndState()); - panel.setContinuityAction("从"+panel.getStartState()+"连续执行至"+panel.getEndState()); - if(edit.path("performance_beats").isArray())panel.setPerformanceBeats(edit.path("performance_beats")); - panel.setDuration(Math.max(1,(int)Math.ceil(ShortDramaTiming.requiredSeconds(panel.getSourceText(),buildContinuityJson(panel))))); - panel.setPanelNumber(repaired.size()+1); panel.setSegmentNumber(repaired.size()+1); - repaired.add(panel); - } - return repaired; - } catch(Exception e) { throw new IllegalArgumentException("时间表修订无效:"+e.getMessage(),e); } + static List applyTimingRepair(List originals, String response) { + return ShortDramaSceneBudget.applyRepair(originals, response); } private static String sceneBudgetScaffold(String scene) { - var match=java.util.regex.Pattern.compile("预计\\s*(\\d+)\\s*秒").matcher(scene.lines().findFirst().orElse("")); - if(!match.find())return ""; - int seconds=Integer.parseInt(match.group(1)); - if(seconds<1)return ""; - int count=(int)Math.ceil(seconds/12d),base=seconds/count,remainder=seconds%count; - List timeline=new ArrayList<>(); - for(int i=0;i() .in(ShortDramaCharacterAppearance::getCharacterId, chars.stream().map(ShortDramaCharacter::getId).toList()))); ShortDramaProject project = projectMapper.selectById(projectId); - return checkpointKey(projectId, script.getId(), script.getScriptText(), script.getOutlineText(), + return checkpointKey(projectId, script.getId(), script.getScriptText(), script.getOutlineText()+ShortDramaScriptPreparation.worldContext(script), project.getArtStyle(), projectAspectRatio(projectId), planningAssetSignature(assets)); } @@ -2834,6 +3718,10 @@ public class ShortDramaServiceImpl implements IShortDramaService { boolean seenHeader = false; for (String line : text.split("\\R")) { boolean heading = line.strip().matches("^(?:#{1,6}\\s*)?(?:第[一二三四五六七八九十0-9]+场[::、. ]*)?(?:内景|外景|内外景|闪回[::]?\\s*(?:内景|外景)).*"); + // Numbered screenplay headings are scene boundaries too; ordinary prose stays intact. + heading = heading || line.strip().matches("^#{2,6}\\s+[一二三四五六七八九十百0-9]+[\\s 、..]+\\S.*"); + // This is the plain-text heading emitted by our own initial screenplay writer. + heading = heading || line.strip().matches("^[一二三四五六七八九十百]+[\\s ]+[^::\\n]+[。..]\\s*(?:[^::\\n]+[。..]|([^\\n]*))?$"); if (heading && seenHeader) { scenes.add(current.toString().trim()); current.setLength(0); } if (heading) seenHeader = true; current.append(line).append("\n"); @@ -2845,27 +3733,39 @@ public class ShortDramaServiceImpl implements IShortDramaService { static String buildStoryboardPlanPrompt(String text, String charsLib, String locsLib, String charsIntro, String charsAppearanceList, String charsFullDesc, String artStyle, String aspectRatio) { - return ShortDramaDirectorSkills.load("emotional-dialogue", "story-causality", "director-blocking", "real-material", "director-review") + """ - 你是专业的分镜规划师。请根据剧本内容将故事拆解成连续的分镜头。 + artStyle = ShortDramaDirectorSkills.compactSelected(artStyle, "emotional-dialogue", "story-causality", "cinematic-storyboard", "director-blocking", "real-material", "director-review"); + return ShortDramaDirectorSkills.load("emotional-dialogue", "story-causality", "cinematic-storyboard", "director-blocking", "real-material", "director-review") + """ + 你是专业的分镜规划师,为原剧本安排连贯的视频生成段。先设计段落,再安排段内摄影子镜头,不把每次换景别、反应或摄影切点都保存为独立生成任务。 【三级规划流程 - 最高优先级】 - 必须先在内部完成以下三级规划,再输出镜头JSON;不要输出分析过程: - 1. 分场 scene:按地点变化、时间跳变或戏剧单元收束切场,同一场是同一时空下的连续戏 - 2. 切片段 segment:每场沿叙事顺序在情绪转折、动作段落、说话人切换或信息揭示处切分;每片段总时长不得超过15秒 - 3. 拆镜头 panel:每个片段内部拆成连续可拍镜头,每镜只承担一个剧情职责 + 在同一次请求中完成以下三级制作规划,在scene_plan中给简短的制作摘要,再输出panels;不输出推理过程,不追求固定镜数: + 1. 原文预算场 scene:本次只拆一个原文场,scene_number保留原文场身份;同场多地点或跨年蒙太奇只划分segment,不按空间或年份重排预算场 + 2. 预演 segment:先估算原文对白、独占动作和必要停顿的真实时间;未给时长时由内容决定,不预设片长。说话人切换、情绪反应或动作阶段本身不构成拆镜理由 + 3. 生成段 panel:一条JSON就是一次视频生成,等于一个segment,时长按实际内容估算。先按原事件的因果、对白与动作容量安排连贯表演,再决定自然分段和摄影切点。段内可切换摄影角度或注意中心,不按句子或摄影切点逐项创建任务;不使用总秒数除以上限作为固定镜数公式 每个片段必须形成小型因果闭环:bridge_in触发 → segment_goal → 连续动作/对话 → segment_result → bridge_out。 相邻片段必须通过动作、情绪、视线或声音中的至少一种桥梁衔接,禁止在片段边界冻结后跳转。 - 【镜头数量控制 - 最高优先级】 + 【先规划段落,再写摄影子镜头】 + - scene_plan先写core_event(本场真正发生的原事件)、estimated_seconds(原对白与有效表演的粗估总秒数)、segment_outline(自然段落安排) + - segment_outline每项写goal、estimated_seconds、camera_beats、boundary_reason。camera_beats只是段内拍法,可是一镜连续完成,也可有多个自然切点;不把这些子镜头分别列为panels。boundary_reason说明开场,或后续新目标、明确换时换地、对白动作容量为何需要另段;摄影换角度不是划段理由 + - 原文几句话讲一次连续操作时,先整体预演“触发—操作—可见结果”,将空间建立和反应穿插到主动作中。不要先给每句话建一段,再为这些段落凑目标、结果和时长 + - 先按人物持续目标聚合核心事件。门闩卡住、抬闩、推门是一次操作,不把开始状态、问题、应对、恢复分别分配一个生成段。起始空间随主动作建立,恢复后的姿态是动作末尾的落点;展开panels前,在本次规划内合并能连续完成的相邻安排,不用后置镜数拦截 + - 总量锚定用于规划:先按本场事件规模决定大致表演长度,再安排自然分段。字数只参考内容规模,材料、衣着、光色与环境的描述不等于需要额外播放的时间 + - 原文多场、多事件或长对白时保留其真实容量;短动作不用凑固定段长、固定子镜头数或完整短剧套路。结尾结果成立即可收束 + + 【按原文规模安排生成段】 - 按可拍动作和场次时间决定镜数,不按字数凑镜头,不强制建立镜、反应镜独立成镜 - - 当前仅规划一场,不限制全剧镜头总数。本场按场景头预计秒数分配镜头,duration合计应接近预计时长(误差不超过10%%);不得省略发现、反应、决定等因果中间态 + - 按有效表演时间、信息重点和自然节奏安排合适的生成段,不能先列建立、触发、反应、决定、行动、结果七类镜头再填剧情。有明确场次预算才核对总时长(误差不超过10%%);无预算就按原事件自然长度结束 + - 同一连续事件可以同段完成,也可按观看重点和表演节奏自然分段;不要求几句话固定一段,不追求指定镜数。多场、多事件、长对白保留真实内容容量 + - 同框可看清的触发、操作和结果可以放在同段;对白与听者反应可在同一双人构图中完成,不为每次应答机械另建生成任务。新增panel的motivation写清叙事、观看重点或节奏理由 + - 不延长或扩写原剧情:禁止添加试探、失败、重试、解释性对白、无关出发抵达或第三次反应;环境、衣料、运镜的精细描写与原动作并行,不单独成为镜头 - 普通单镜按对白、独占动作与自然停顿预算分配,固定机位和安静反应均可,不强制每秒变化 - - 单镜超过8秒需有实际对白或动作时长依据,不强制三段动作;任何单镜不得超过15秒 - - 每个片段内所有镜头duration之和必须≤15秒,超过必须拆成新片段 + - 单镜按实际对白或动作安排节奏,不强制三段动作,不因估算秒数差异增拆镜头 + - 每个panel就是一个生成片段,segment_number与本场panel序号一一对应;不要在一个segment内另列多个panel。段内可先全景看清关系,再用局部摄影切点看清手与绳的接触,之后回到动作结果 - 时长必须由实际动作、对白和运镜容量决定,禁止按场景类型机械给长时长 - 原文只支持1-3秒内容时,应与相邻因果动作合并,禁止靠环境空转填充成长镜头 - - 对话密集场景:先计算发音时长再拆镜,一镜最多一个主要说话人,另一人可有短回应;长对白按自然语义切到倾听画面,保留完整原话 + - 对话密集场景:先计算发音时长再拆生成段,同一段可让不同人物依次说话和同步回应,不能同时抢声;长对白可在段内切到倾听画面,保留完整原话 【分镜原则】 1. 场景信息可在主动作镜头内建立,不强制另加建立镜头 @@ -2875,32 +3775,35 @@ public class ShortDramaServiceImpl implements IShortDramaService { 【每个分镜包含】 - panel_number: 全剧镜头序号 - - scene_number: 场次序号;地点/时间变化时递增 - - segment_number: 当前场内片段序号;每片段总时长≤15秒 + - scene_number: 当前原文预算场身份,不因本场内部地点/时间变化递增 + - segment_number: 当前场内片段序号;与自然生成段对应 - segment_goal: 当前片段要完成的唯一剧情任务 - segment_result: 片段结束时完成的剧情变化 - bridge_in: 本片段如何承接上一片段,首片段写开场建立 - bridge_out: 留给下一片段承接的动作、情绪、视线或声音 - - description: 画面描述(可视化元素,禁止主观情绪词) - - characters: [{name, appearance, slot}],name必须与资产库一致 + - description: 按实际段内摄影顺序写观众看到与听到的画面、动作与回应;段内有切点时写清“切到/切回”和信息目的,无必要时保持一镜连续。禁止主观情绪词,不新增原文事件 + - shot_design: focus、viewer_gain、framing、axis、movement、motivation、transition、cut_out均必填。首镜或显式换时/换地镜另写完整cut_in、state_in,预先列齐本段后续会变化的演员、器物、环境与声音属性及原文初态;每镜写state_delta对象,只含本镜真正变化的初态已有键,无变化为{}。后续continuous镜省略cut_in/state_in;后台逐字继承前镜cut_out/state_out,并用state_delta合成末态供审阅。不要反复抄写相同状态,新出现的状态可在当前段补充,不猜造此前初态;完整state_in/state_out旧格式仍可用,文字差异仅供审阅。 + - characters: [{name, appearance, slot}],name必须与资产库一致;appearance只能逐字复制“角色形象列表”中方括号后的changeReason,或只写方括号内的数字索引,禁止把服装描述、年龄说明或“某某形象:……”写进appearance - location: 从场景资产库选择,名字完全一致 - scene_type: daily/emotion/action/epic/suspense - - source_text: 对应原文片段(必填,保留实际对白与说话人) + - source_text: 对应原文片段(必填,保留实际对白与说话人)。保留动作段落与对白的换行;每句对白独占一行,写为“角色名:「原句」”,不得把对白后的动作接在同一行。字幕、文书文字标为“画面文字:”,不要当成对白。叙述性冒号后换行,禁止将整段动作压平拼进说话人行,否则时长估算会误把动作当台词。 - performance_beats: [{name,acting}],逐个出镜角色写本镜触发、意图、可见回应与未解决的情绪;禁止通用表情模板。无人镜头为[] - - start_state: 当前镜头第一帧状态,必须继承上一镜头end_state(场景切换除外) + - start_state: 当前镜头0秒静态状态;连续同一时刻承接上一镜end_state,明确时间跳转/跨年匹配切可在同一几何空间重新建立当前年龄、服饰、季候和姿态 - end_state: 当前镜头最后一帧状态,明确人物位置、朝向、姿态、道具和动作结果 - continuity_action: 与下一镜头衔接的未完成动作、视线方向或运动趋势 - spatial_anchor: 场景空间锚点,明确左右、前后、远近和人物相对位置 - - present_characters: 当前场景仍然在场的全部人物;未明确离场不得消失 + - present_characters: 本镜0秒画面中的登记角色精确名字,只写具名/已登记演员与明确可见CG;后续入场者放characters但不提前加入首帧名单,画外声不列入 + - background_extras: 可空文字字段;仅描述本镜0秒原文明确安排的匿名群演,格式“0秒:可见人数=6-8;身份服饰=匿名军士,粗布军服;位置=梯架两侧;姿态动作=双手扶梯、尚未登顶”,不得创建名字或ID - beat_type: 当前镜头唯一的剧情职责,只能是setup/trigger/reaction/decision/action/result/transition之一 - narrative_cause: 当前镜头发生的直接原因,必须来自上一镜头story_result或剧本中的明确触发 - character_goal: 当前焦点角色在这一刻想达成的具体目标 - story_action: 为实现目标采取的可见行动或说出的关键台词 - story_result: 当前行动造成的新信息、新阻碍、位置变化或关系变化,镜头结束前必须发生 - - next_hook: story_result中必须在下一镜头得到回应的问题、动作或后果 - - duration: 镜头时长1-15秒。以对白实读、独占动作、反应停顿之和分配,不能按情绪类型机械分配。 - - timing: {spoken_text:实际发音台词(金额日期展开读音), speech_rate:语速默认4老人3.2, action_seconds:不与对白重叠的动作秒数, pause_seconds:反应停顿秒数, action_note:动作与对白是否同步}。 - - duration必须至少容纳timing各项;长台词装不下则拆镜,宁可调整场次时长也不能加速朗读。 + - next_hook: 有后续生成段时写其承接的问题、动作或后果;本场结尾写明确收束,不凭空制造下一镜 + - duration: 制作估算的正整数秒。以对白实读、独占动作、反应停顿为参考,允许估算差异,不是视频模型的固定秒数。 + - timing: {spoken_text:实际发音台词(金额日期展开读音), speech_rate:语速默认4老人3.2, action_seconds:不与对白重叠的动作秒数, pause_seconds:必要停顿秒数(无则0), action_note:动作与对白是否同步, pacing_note:较长非对白时段的具体事件/独占动作及其叙事必要性}。 + - duration与timing均为估算参考,真实对白可以和视线、表情、运镜同时进行,不把误差当成必须拆镜的理由。 + - 先按有效内容安排自然节奏,再估时。眨眼、转头、观察服饰可与对白并行;pacing_note可解释有意义的动作和停顿,不必凑最短整数秒或严格满足总预算。保留动作发起、结果与反应的因果关系,不为凑时长增加事件。 【片段间过渡设计 - 最高优先级】 @@ -2912,11 +3815,11 @@ public class ShortDramaServiceImpl implements IShortDramaService { 【剧情因果链 - 最高优先级】 在输出JSON前,先在内部按剧本原文顺序建立完整因果主线,但不要输出分析过程。 - 1. 每个镜头只能承担一个主要剧情职责:建立、触发、反应、决定、行动、结果或转场 + 1. beat_type标记每段的主要剧情职责,不是拆镜清单。同一关注中心的触发、应对、结果应在同段连续演完,不为职责标签拆成独立生成任务 2. 镜头N必须产生story_result;镜头N+1的narrative_cause必须直接承接该story_result或next_hook 3. 禁止“因为剧本接下来这样写”式跳跃。角色改变位置、情绪、目标、关系或道具状态时,必须先出现触发和过渡动作 4. 若状态从A变到C,必须展示B的过渡过程,可在同一连续镜头内完成,例如站立→坐下→坐着,不必为每个状态拆镜 - 5. 对话不能只是轮流说话:每句关键台词必须改变对方认知、决定或行动,并在下一镜展示反应 + 5. 对话不能只是轮流说话:关键台词带来的认知、决定或行动变化可以由同框听者同步反应体现,不强制留到下一镜 6. 场景切换必须有剧情原因和转场钩子,例如人物出发、时间推进、视线落向目标或结果揭示 7. source_text必须严格按原文顺序覆盖,不得将后文结果提前,也不得遗漏导致因果断裂的关键动作 8. 禁止连续镜头重复同一信息;每一镜结束时剧情状态必须相对开头发生可说明的变化 @@ -2924,8 +3827,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { 10. 最终自检整条链:删除任一镜头后若不影响理解,说明该镜头无剧情作用,应合并或重写 【叙事连续性与空间锚定 - 最高优先级】 - 1. 同一location内,镜头N+1的start_state必须逐项承接镜头N的end_state,不得重置人物姿态或位置 - 2. 角色进入场景后,在明确离场、切为拍不到该角色的特写/反打、或切换场景前,必须持续存在于present_characters + 1. 同一location且同一连续时刻内,下一镜start_state承接上一镜end_state;原文明示跨年/时间跳转时可匹配切重建当前年龄、服饰、季候与姿态,保留几何,禁止要求人物在镜内瞬变 + 2. 登记演员是否可见由本镜0秒构图及原文决定;跨原文场不得仅因上一场未写离场就继承演员,背景匿名群众使用background_extras,不升级为角色资产 3. 连续动作应包含开始、过程与结果;能够在预算内完成时优先放在同一镜头,只有叙事视点或时间容量确实要求时才拆镜 4. 保持180度轴线:同一对话或对峙中,人物左右关系、面对方向不得无理由反转 5. 道具归属、手持状态、服装形象、伤势、光线方向和时间必须连续 @@ -2937,8 +3840,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { - 项目画面比例:%s。构图和角色站位必须适应该画幅,避免主体被裁切 【输出格式】 - 只返回JSON数组: -[{"panel_number":1,"scene_number":1,"segment_number":1,"segment_goal":"张三进入办公室并打断李四","segment_result":"李四注意力转向张三","bridge_in":"开场建立办公室与人物位置","bridge_out":"张三的话音落下,李四抬头形成反应钩子","description":"...","characters":[{"name":"张三","appearance":"初始形象","slot":"门口"}],"location":"办公室_白天","scene_type":"daily","source_text":"...","start_state":"张三位于门外右侧,面向办公室","end_state":"张三推门后停在门内右侧,身体朝向李四","continuity_action":"张三保持推门手势并准备开口","spatial_anchor":"张三画面右侧,李四画面左侧办公桌后","present_characters":["张三","李四"],"beat_type":"trigger","narrative_cause":"张三收到必须立即开会的消息","character_goal":"让李四停止工作并听取通知","story_action":"张三推门进入并开口宣布开会","story_result":"李四被打断并抬头看向张三","next_hook":"下一镜展示李四对通知的反应","duration":8}] + 只返回JSON对象,先scene_plan制作摘要,后panels实际生成段;estimated_seconds是规划参考,不是指定镜数的公式。示例只有一个完整动作段,不是所有故事都固定一段: +{"scene_plan":{"core_event":"来者推门打断桌前人的工作,对方抬头回应","estimated_seconds":8,"segment_outline":[{"goal":"同框看清推门引起抬头的结果","estimated_seconds":8,"camera_beats":["门与桌同框呈现推门、抬头与视线相遇"]}]},"panels":[{"panel_number":1,"scene_number":1,"segment_number":1,"segment_goal":"张三进入办公室并打断李四","segment_result":"李四注意力转向张三","bridge_in":"开场建立办公室与人物位置","bridge_out":"两人视线相遇,动作收束","description":"...","shot_design":{"focus":"张三推门时李四抬头的动作接触与反应","viewer_gain":"李四注意被来者打断","framing":"门与桌同侧中景,张三在右、李四在左","axis":"办公桌南侧,人物左右固定","movement":"固定机位","motivation":"同框看清推门触发抬头的因果","transition":"opening","cut_in":"门关闭,李四正看桌上文件","cut_out":"张三推门停下,李四抬头,两人视线相遇","state_in":{"张三.位置":"门外右侧","李四.视线":"桌上文件","光线.方向":"左窗"},"state_delta":{"张三.位置":"门内右侧","李四.视线":"张三"}},"timing":{"spoken_text":"","speech_rate":4,"action_seconds":6,"pause_seconds":2,"action_note":"推门与抬头先后发生","pacing_note":"人物进入并引起注意"},"characters":[{"name":"张三","appearance":"初始形象","slot":"门口"},{"name":"李四","appearance":"初始形象","slot":"桌后"}],"location":"办公室_白天","scene_type":"daily","source_text":"...","start_state":"张三位于门外右侧,面向办公室,李四看文件","end_state":"张三停在门内右侧,李四抬头与其视线相遇","continuity_action":"视线相遇后收束","spatial_anchor":"张三画面右侧,李四画面左侧办公桌后","present_characters":["李四"],"beat_type":"trigger","narrative_cause":"张三推门进入,打断李四看文件","character_goal":"让李四注意来者","story_action":"张三推门进入,李四抬头","story_result":"李四被打断并抬头看向张三","next_hook":"本场视线相遇后收束,不新增下一镜","duration":8}]} ⚠️ 严格JSON,不得输出markdown。 角色资产库:%s @@ -2949,7 +3852,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { 剧本内容: %s - """.formatted(artStyle, aspectRatio, charsLib, locsLib, charsIntro, charsAppearanceList, charsFullDesc, text); + """.formatted(artStyle, aspectRatio, charsLib, locsLib, charsIntro, charsAppearanceList, charsFullDesc, text) + ShortDramaBackgroundExtras.promptRules(); } // ==================== Phase 4: 摄影规则 ==================== @@ -2963,7 +3866,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { String panelsJson = JsonUtils.toJsonString(panels); String locsDesc = buildLocationsDescString(projectId); String charsInfo = buildCharactersInfoString(projectId); - String prompt = buildCinematographerPrompt(panelsJson, panels.size(), locsDesc, charsInfo); + String prompt = buildCinematographerPrompt(panelsJson, panels.size(), locsDesc, charsInfo) + projectDirection(projectId); String response; if (emitter != null) { response = streamingChat(buildStreamingChatModel(), chatModel, prompt, emitter, "photography"); @@ -3063,7 +3966,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { try { String panelsJson = JsonUtils.toJsonString(panels); String charsInfo = buildCharactersInfoString(projectId); - String prompt = buildActingDirectionPrompt(panelsJson, panels.size(), charsInfo); + String prompt = buildActingDirectionPrompt(panelsJson, panels.size(), charsInfo) + projectDirection(projectId); String response; if (emitter != null) { response = streamingChat(buildStreamingChatModel(), chatModel, prompt, emitter, "acting"); @@ -3343,31 +4246,50 @@ public class ShortDramaServiceImpl implements IShortDramaService { return; } try { - String panelsJson = JsonUtils.toJsonString(panels); - String charsAgeGender = buildCharactersAgeGenderString(projectId) - + "\n【已确认的角色形象,必须逐镜保持,禁止自行换装】\n" + buildCharacterAppearanceConstraints(projectId); + String panelsJson = detailInput(panels); + var detailLibrary = panelCharacterLibraries(planningAssets(projectId), panels); + String charsAgeGender = detailLibrary.get("names") + "\n" + detailLibrary.get("descriptions") + + "\n【已确认的角色形象,必须逐镜保持,禁止自行换装】\n" + detailLibrary.get("appearances"); String locsDesc = buildLocationsDescString(projectId); String prompt = buildStoryboardDetailPrompt(panelsJson, charsAgeGender, locsDesc, artStyleSuffix(projectId), projectAspectRatio(projectId)); String detailCacheKey = "short-drama:checkpoint:v1:" + projectId + ":detail:v2:" + cn.hutool.crypto.digest.DigestUtil.sha256Hex(modelName + "\n" + prompt); String response = RedisUtils.getCacheObject(detailCacheKey); + List results = null; + if (StrUtil.isNotBlank(response)) { + try { results = reviewStoryboardDetailResponse(response, panels); } + catch (RuntimeException invalid) { response = null; } + } if (StrUtil.isBlank(response)) { + String repairBaseline = null; + java.util.Set repairNumbers = java.util.Set.of(); for (int attempt = 1; attempt <= 2; attempt++) { try { if (emitter != null) { StreamingChatModel activeStreamingModel = streamingModel != null ? streamingModel : buildStreamingChatModel(); - response = streamingChat(activeStreamingModel, chatModel, prompt, emitter, "storyboard_detail"); + response = streamingChat(activeStreamingModel, chatModel, prompt, emitter, "storyboard_detail", null, + (attempt == 1 ? "镜" + panels.get(0).getPanelNumber() + "–" + panels.get(panels.size()-1).getPanelNumber() + "细化" : "镜" + repairNumbers + "修复") + " · 第" + attempt + "次"); } else { response = chatModel.chat(prompt); } + if (repairBaseline != null && repairNumbers.size() < panels.size()) response = mergeDetailRepair(repairBaseline, response, repairNumbers); + results = reviewStoryboardDetailResponse(response, panels); break; } catch (Exception e) { if (attempt == 2) throw e; - if (emitter != null) emit(emitter, "storyboard_detail", "running", "本批连接中断,正在重试;已完成批次已保存"); + log.warn("分镜细化校验触发修复: projectId={}, panels={}, reason={}", projectId, + panels.stream().map(StoryboardPanelData::getPanelNumber).toList(), ShortDramaSceneCandidateDiagnostics.redact(e.getMessage())); + repairBaseline = response; + repairNumbers = detailRepairNumbers(response, panels); + final var invalidNumbers = repairNumbers; + var repairPanels = panels.stream().filter(panel -> invalidNumbers.contains(panel.getPanelNumber())).toList(); + prompt = buildStoryboardDetailPrompt(detailInput(repairPanels), charsAgeGender, locsDesc, + artStyleSuffix(projectId), projectAspectRatio(projectId)) + + "\n只修复以下镜号:" + repairNumbers + ";已通过镜头保留。具体校验问题:" + e.getMessage(); + if (emitter != null) emit(emitter, "storyboard_detail", "running", "本批细化未通过,正在修复:" + e.getMessage() + ";已完成批次已保存"); } } } - List results = parseJsonArray(extractJson(response), StoryboardDetailResult.class); if (results != null) { int matched = 0; for (StoryboardDetailResult r : results) { @@ -3386,6 +4308,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (panel.getPresentCharacters() == null && r.getPresentCharacters() != null) { panel.setPresentCharacters(r.getPresentCharacters()); } + if (r.getBackgroundExtras() != null) panel.setBackgroundExtras(r.getBackgroundExtras()); panel.setSegmentGoal(firstNotBlank(r.getSegmentGoal(), panel.getSegmentGoal())); @@ -3415,147 +4338,161 @@ public class ShortDramaServiceImpl implements IShortDramaService { throw new IllegalStateException("镜头细化不完整,旧分镜已保留,请重试"); } RedisUtils.setCacheObject(detailCacheKey, response, java.time.Duration.ofDays(7)); + var progress = emitter == null ? null : planningProgress.get(emitter); + if (progress != null) for (var panel : panels) + progress.ready(panel.getPanelNumber(), AtlasMediaSupport.OBJECT_MAPPER.valueToTree(panel)); } catch (Exception e) { throw new IllegalStateException("分镜细化失败:" + e.getMessage(), e); } } - private static String buildStoryboardDetailPrompt(String panelsJson, String charsAgeGender, String locsDesc, - String artStyle, String aspectRatio) { - return ShortDramaDirectorSkills.load("emotional-dialogue", "director-blocking", "visual-world", "real-material", "director-review") + """ - 你是顶级电影分镜师。根据分镜规划和场景类型,设计镜头语言和视频提示词。 - - 【你的职责】 - 为每个分镜设计景别、视角、镜头运动,撰写video_prompt(用年龄段+性别替代角色名),撰写image_prompt。 - - 【镜头语言库】 - 景别:大远景/远景/全景/中景/近景/特写/极端特写 - 视角:平视/仰拍/俯拍/越肩/荷兰角/主观视角 - 镜头运动:固定/缓推/缓拉/跟随/急推/急拉/环绕/升起/俯冲/手持晃动 - - 【根据scene_type选择镜头风格】 - daily:中景、近景为主,优先固定机位;人物移动或信息揭示时才跟随或推近 - emotion:近景、特写捕捉反应;允许固定机位留出停顿,不自动添加环绕 - action:按清晰可辨的动作选择景别,一个镜头一个主要动作;不要在单镜内连续切机位 - epic:必要时用远景建立规模,按实际空间选择机位 - suspense:通过信息遮挡、视线与反应建立悬念,运镜有具体叙事理由才采用 - - 【全局视觉约束 - 最高优先级】 - - 视觉风格:%s。description、video_prompt、image_prompt都必须显式体现该风格 - - 严禁使用与目标风格冲突的表达;动漫/漫画风格不得写成真人、实拍、照片级人物 - - 画面比例:%s。镜头构图、主体位置和留白必须适应该比例 - - 【三级结构审核与修正 - 最高优先级】 - 在设计镜头语言前,先审核输入的scene_number/segment_number分组并直接在输出中修正字段: - - 每个segment的duration总和必须≤15秒;超过时在合理剧情节点增加segment_number - - 单镜按对白、独占动作与停顿预算分配1-15秒;超出15秒时在完整动作或语句边界拆镜 - - 同一segment内必须围绕同一个segment_goal,结尾落实segment_result - - 相邻segment的bridge_out与bridge_in必须能直接连读;不匹配时重写桥梁字段和当前镜头描述 - - 检查A→C跳跃:位置、姿态、情绪、信息或道具状态缺少B过程时,必须把B过程融入相邻镜头描述 - - 不得改变镜头顺序或剧本事件顺序,不得新增剧本外剧情 - - 【剧情节拍落实 - 最高优先级】 - - 输入中的beat_type、narrative_cause、character_goal、story_action、story_result、next_hook必须完整保留 - - description和video_prompt必须实际拍出story_action以及story_result,不能只拍角色站立或环境氛围 - - 镜头开头先回应narrative_cause,镜头结尾必须出现story_result,并留下next_hook - - 如果story_result无法在当前时长内可视化,必须简化动作而不是丢掉结果 - - 不得擅自加入输入因果链之外的新事件、台词、能力或角色行为 - - 【角色与素材一致性 - 最高优先级】 - - 必须遵守提供的角色形象档案:发型、服装、年龄保持一致。不得根据场景或职业自行添加工装、围裙、外套、伤痕或手机裂纹。 - - 声源角色不等于出镜角色,是否出镜以present_characters及原文为准;手机录音不生成说话者的实体画面。 - - 原文标注后期插入的照片、程序界面、账本与表格,必须保留后期替换说明,只设计手部、视线和操作。不要要求视频模型逐字绘制真实文字。 - - 长账目可以用核对动作、屏幕素材和短句传达,避免在几秒内快速念完整张表。不得修改地址、金额、收款状态或其他事实。 - - 【前后镜头连续性 - 最高优先级】 - - 输入已经包含start_state、end_state、continuity_action、spatial_anchor、present_characters,必须完整保留并落实到description/video_prompt/image_prompt - - 当前镜头开头必须明确从start_state开始,结尾必须准确停在end_state - - video_prompt必须按“继承上一镜状态→执行当前动作→停在可供下一镜承接的状态”书写 - - 同场景相邻镜头不得改变人物左右关系、朝向、道具所在手、站坐姿态、伤势和光线方向 - - 反打/特写可以不显示其他人,但不得暗示其他人消失或换位 - - 如果输入连续性字段冲突,优先修正当前镜头以承接上一镜头,不得各镜头独立创作 - - 【时长匹配规则 - 最高优先级】 - - 输入中的duration就是目标视频秒数,video_prompt必须完整覆盖该时长 - - duration为4-7秒:至少写清开始动作、持续变化、结束状态 - - 长镜头允许静止倾听、呼吸和反应停顿;一个镜头只承担一个主要叙事动作,不因时长长而强加运镜或新动作 - - 禁止用一个瞬时动作支撑长镜头,例如仅“吐信、转头、抬手”却标10秒 - - description与video_prompt共享同一动作、台词和起止状态,不能为了填时长增加剧情 - - 【video_prompt撰写规则 - 重要】 - video_prompt 是发给视频模型的核心可拍指令,必须用"导演笔记"风格写,每个字都可拍、按时序展开。视频模型不认识名字,必须用年龄段+性别替代角色: - - 年龄段:少年/少女(10-16)、年轻男子/年轻女子(17-30)、中年男子/中年女子(31-50)、老年男子/老年女子(50+) - - 必须依次包含以下可拍维度: - 1) 景别+机位架设位置:如"中景,机位架设在店内深处正对门口" - 2) 主体动作(按时序):写清一个主动作及结果,允许自然停顿。不得要求每秒新增动作。台词、独占动作、反应停顿合计须装得下;可同步的眼神动作不重复计时 - 3) 运镜:从镜头运动词库选一个主导运镜(推/拉/摇/移/跟/环绕/手持/固定),写明方向与节奏,禁止只用"缓缓"这种无信息量词 - 4) 光影:方向+色温+阴影色,与当前镜头光源位置绑死(如"晨光从卷帘门缝隙逆光射入,发丝边缘泛柔光,店内深处阴影偏冷蓝"),禁止情绪词 - 5) 道具/穿着:从 source_text 和角色设定提取具体道具与穿着,写进动作流 - 6) 台词:若 source_text 有台词,以「角色说的话」标注并标语气(小声/平淡/恳求),供后续口型对齐;无台词则不写 - - 禁止参数堆砌(8K/HDR/fps/Rec.色域这类),视频模型不认 - - 静态机位允许人物静止倾听;特写优先固定镜头,保留自然呼吸与反应 - - description 必须与 video_prompt 的节拍、光影、动作一致 - - 【表演与摄影约束】 - - 一个镜头一个主导运镜;固定机位可以完整承载对白和反应,不能每镜都推近。 - - 坐、站、持物手、门开闭、电源和关键道具位置必须与前后镜及参考图一致。 - - 光线由地点、时间、真实光源决定,不能随情绪标签突然改变。 - - 台词时长按实际发音计算,年份/金额/FM先展开读音。默认约4字/秒,老人约3.2字/秒,仅为预算假设。 - - 先加台词时间、无法同步的动作时间与必要停顿,超时必须拆镜或申请改写;不能删原文台词后假称完整保留。 - - 【image_prompt撰写规则】 - - 只描述start_state的静态起点,不画end_state,不提前完成坐下、递物、取物等动作 - - 已确认的手机、眼镜、衣物等不可被其他道具替代;看手机时间不能擅改为看手表 - - 使用角色实际名字(不是年龄段+性别) - - 包含角色位置、场景环境、光线氛围 - - 纯视觉描述,禁止抽象词 - - 【输出格式】 - 只返回JSON数组。保留输入中的所有原始字段,补充shot_type、camera_move、video_prompt、image_prompt、sceneTitle: - - { - "panel_number": 1, - "shot_type": "平视中景", - "camera_move": "缓推", - "description": "从start_state开始并停在end_state的优化画面描述", - "start_state": "继承的镜头起始状态", - "end_state": "可供下一镜承接的镜头结束状态", - "continuity_action": "延续到下一镜头的动作或视线", - "spatial_anchor": "人物左右前后与朝向", - "present_characters": ["张三"], - "beat_type": "action", - "narrative_cause": "上一镜头留下的直接原因", - "character_goal": "当前角色的具体目标", - "story_action": "镜头中执行的关键动作", - "story_result": "动作造成的剧情变化", - "next_hook": "下一镜必须回应的后果", - "video_prompt": "中景,机位架设在办公室深处正对会议桌。年轻男子站在深棕色实木桌前,双手撑在桌面上,身体微前倾,目光扫过桌前众人后停住。镜头从中景手持缓推至他上半身,保持平视距离,约两秒推到位后固定。午后阳光从右侧窗户斜照进来,在他脸侧形成暖侧光,桌面阴影偏冷。他抬头环视,低声开口:「开会了。」", - "image_prompt": "张三站在办公室中央,双手撑在深棕色实木桌面上,表情严肃,午后阳光从右侧窗户斜照进来", - "sceneTitle": "张三宣布开会", - "characters": [{"name":"张三","appearance":"初始形象","slot":"办公室中央"}], - "location": "办公室_白天", - "scene_type": "daily", - "source_text": "张三对大家说:开会了" + /** Review the complete batch before merging; malformed output cannot partially replace a draft. */ + static List reviewStoryboardDetailResponse(String response, List panels) { + List results = parseJsonArray(extractJson(response), StoryboardDetailResult.class); + if (results == null || results.isEmpty()) throw new IllegalStateException("分镜细化未返回有效JSON数组"); + java.util.Set seen = new java.util.HashSet<>(); + List errors = new ArrayList<>(); + for (StoryboardDetailResult detail : results) { + var panel = panels.stream().filter(p -> p.getPanelNumber().equals(detail.getPanelNumber())).findFirst().orElse(null); + if (panel == null || !seen.add(detail.getPanelNumber())) { + errors.add("细化返回了未知或重复镜号:" + detail.getPanelNumber()); continue; } - ⚠️ JSON安全:严格遵守JSON标准格式。字符串值内的双引号必须转义为\"。对话引号统一使用「」代替英文双引号。特写镜头必须使用固定镜头。 + detail.setVideoPrompt(ShortDramaVideoPromptReview.normalizeDialogue(detail.getVideoPrompt(), panel.getSourceText())); + if (panel.getShotDesign() != null && !panel.getShotDesign().isNull()) { + // Detailing enriches directions. Preserve planned metadata even if the model paraphrases it. + detail.setStartState(panel.getStartState()); + detail.setEndState(panel.getEndState()); + } + if (StrUtil.isBlank(detail.getImagePrompt())) errors.add("镜头" + detail.getPanelNumber() + "缺少首帧提示词"); + try { + ShortDramaBackgroundExtras.parse(panel.getBackgroundExtras()); + if (detail.getBackgroundExtras() != null && !Objects.toString(panel.getBackgroundExtras(), "").trim().equals(detail.getBackgroundExtras().trim())) + errors.add("镜头" + detail.getPanelNumber() + "细化不得增加、删除或改写已规划的0秒匿名群演"); + Set permitted = new HashSet<>(panel.getPresentCharacters() == null ? List.of() : panel.getPresentCharacters()); + if (panel.getPresentCharacters() == null && panel.getCharacters() != null) panel.getCharacters().forEach(ref -> permitted.add(ref.getName())); + if (detail.getPresentCharacters() != null && !permitted.containsAll(detail.getPresentCharacters())) + errors.add("镜头" + detail.getPanelNumber() + "细化不得增加未规划的具名出镜角色或CG"); + } catch (IllegalArgumentException e) { errors.add("镜头" + detail.getPanelNumber() + ":" + e.getMessage()); } + if (StrUtil.isBlank(detail.getVideoPrompt())) errors.add("镜头" + detail.getPanelNumber() + "缺少视频提示词"); + for (String issue : ShortDramaVideoPromptReview.issues(detail.getVideoPrompt(), panel.getDuration(), panel.getSourceText())) + log.info("镜头{}导演稿审阅建议(继续保存):{}", detail.getPanelNumber(), issue); + } + for (var panel : panels) if (!seen.contains(panel.getPanelNumber())) errors.add("细化遗漏镜头" + panel.getPanelNumber()); + if (!errors.isEmpty()) throw new IllegalStateException(String.join(";", errors)); + return results; + } - 分镜规划:%s + static void validateVideoPromptPanels(List panels) { + for (var panel : panels) ShortDramaVideoPromptReview.validate(panel.getPanelNumber(), panel.getVideoPrompt(), + panel.getDuration(), panel.getSourceText()); + } - 角色年龄性别信息:%s + static String buildStoryboardDetailPrompt(String panelsJson, String charsAgeGender, String locsDesc, + String artStyle, String aspectRatio) { + return ShortDramaDirectorSkills.load("cinematic-storyboard", "video-prompt") + """ + 你负责把已校验分镜落实为可执行的首帧和连续导演描述,不重新规划剧情。 + 只细化本批给定镜号。shot_design中的focus、framing、axis、movement、motivation、cut_in/out与物理状态是已审阅契约;机位、主动作、重心、持物与声画接点均须落实,不另选姿态或拍法。 + 原文、角色形象、出镜名单、群演、场地和原对白为权威;无对白时写清实际环境声。后台保留这些原始字段、起止状态、timing与所有资产绑定,无需在输出中重复。 + video_prompt从给定首态展开:建立本镜前中后景和视线、写动作发起及可见结果与反应,具体机位和主导运镜有动机,沿既有光源写光色材质,声音有方向与远近,最后准确交到end_state。按实际演员与事件写自然段,保留原话及声源,不写逐秒清单或通用导演前缀,不用抽象结果摘要代替可拍过程。 + image_prompt只描述本镜start_state的静态首帧,含具体姿态、接触、位置、人物服装、所见道具、背景与光线;不提前画出结束动作。 + 每镜保留一个主导运镜。时长与timing用于预算,不能靠多余事件、停顿、换装、额外切镜或删台词填充。沿用原scene/segment身份,不重新分组、增删或重排镜头。 + 视觉方向:%s + 构图画幅:%s + 只返回JSON数组,每镜仅补充以下字段,不复述整套原规划: + {"panel_number":1,"shot_type":"平视中景","camera_move":"固定","sceneTitle":"本镜具体关注点","description":"当前动作与结果","video_prompt":"完整连续导演描述","image_prompt":"静态首帧描述"} + 如明确返回start_state/end_state等原始字段,其值必须逐字保持。字符串内对白用「」;JSON必须有效。 + 已校验规划:%s + 本批角色档案:%s + 本批场景资料:%s + """.formatted(ShortDramaDirectorSkills.compactSelected(artStyle, "cinematic-storyboard", "video-prompt"), aspectRatio, panelsJson, charsAgeGender, locsDesc) + + ShortDramaBackgroundExtras.promptRules(); + } - 场景描述:%s - """.formatted(artStyle, aspectRatio, panelsJson, charsAgeGender, locsDesc); + /** Keep approved input metadata; compact null/unused transport fields rather than asking the model to echo them. */ + static String detailInput(List panels) { + var array = AtlasMediaSupport.OBJECT_MAPPER.createArrayNode(); + for (var panel : panels) { + JsonNode full = AtlasMediaSupport.OBJECT_MAPPER.valueToTree(panel); + var input = array.addObject(); + for (String field : List.of("panel_number", "scene_number", "segment_number", "segment_goal", "segment_result", + "description", "source_text", "duration", "timing", "shot_design", "start_state", "end_state", + "spatial_anchor", "continuity_action", "present_characters", "background_extras", "characters", "location", + "narrative_cause", "story_action", "story_result", "performance_beats")) { + JsonNode value = full.get(field); + if (value != null && !value.isNull()) input.set(field, value); + } + } + return array.toString(); + } + + static java.util.Set detailRepairNumbers(String response, List panels) { + var failed = new java.util.LinkedHashSet(); + try { + JsonNode array = AtlasMediaSupport.OBJECT_MAPPER.readTree(extractJson(response)); + if (!array.isArray()) throw new IllegalArgumentException(); + var known = panels.stream().map(StoryboardPanelData::getPanelNumber).collect(java.util.stream.Collectors.toSet()); + for (JsonNode row : array) if (!known.contains(row.path("panel_number").asInt(-1))) throw new IllegalArgumentException(); + for (var panel : panels) { + var rows = AtlasMediaSupport.OBJECT_MAPPER.createArrayNode(); + for (JsonNode row : array) if (row.path("panel_number").asInt(-1) == panel.getPanelNumber()) rows.add(row); + try { reviewStoryboardDetailResponse(rows.toString(), List.of(panel)); } + catch (RuntimeException invalid) { failed.add(panel.getPanelNumber()); } + } + if (failed.isEmpty()) panels.forEach(panel -> failed.add(panel.getPanelNumber())); // Unrecognized transport/extra rows need a full repair. + } catch (Exception invalid) { panels.forEach(panel -> failed.add(panel.getPanelNumber())); } + return failed; + } + + static String mergeDetailRepair(String original, String repair, java.util.Set repairedNumbers) { + try { + JsonNode old = AtlasMediaSupport.OBJECT_MAPPER.readTree(extractJson(original)); + JsonNode patch = AtlasMediaSupport.OBJECT_MAPPER.readTree(extractJson(repair)); + if (!old.isArray() || !patch.isArray()) return repair; + var merged = AtlasMediaSupport.OBJECT_MAPPER.createArrayNode(); + for (JsonNode row : old) if (!repairedNumbers.contains(row.path("panel_number").asInt(-1))) merged.add(row); + for (JsonNode row : patch) merged.add(row); + return merged.toString(); + } catch (Exception invalid) { return repair; } } // ==================== 持久化 ==================== private List persistStoryboards(Long projectId, Long scriptId, List panels, ShortDramaScript script) { + var frozen = planningSkillSnapshots.get(scriptId); + if (frozen != null) skillCatalog.verify(frozen, projectMapper.selectById(projectId)); if (panels.isEmpty()) throw new IllegalStateException("没有有效分镜,旧分镜已保留"); + validateVideoPromptPanels(panels); // Before the transaction deletes any existing storyboard. + // A reviewed paid shot may already exist while the remaining episode is being planned. + // Preserve only explicitly locked, completed shots in their exact ordinal slot. The + // lock lives in continuityJson so ordinary old drafts are still replaced atomically. + Map lockedBySceneNo = storyboardMapper.selectList( + new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getScriptId, scriptId) + .orderByAsc(ShortDramaStoryboard::getSceneNo)) + .stream().filter(this::isLockedApprovedStoryboard) + .collect(java.util.stream.Collectors.toMap( + ShortDramaStoryboard::getSceneNo, value -> value, (left, right) -> left, LinkedHashMap::new)); + for (var entry : lockedBySceneNo.entrySet()) { + if (entry.getKey() == null || entry.getKey() < 1 || entry.getKey() > panels.size()) + throw new IllegalStateException("锁定成片镜头号超出新规划范围,旧分镜已保留"); + Integer plannedDuration = panels.get(entry.getKey() - 1).getDuration(); + if (!Objects.equals(entry.getValue().getDurationSeconds(), plannedDuration)) + throw new IllegalStateException("锁定成片镜头" + entry.getKey() + "时长与新规划不一致,旧分镜已保留"); + } return transactionTemplate.execute(status -> { - storyboardMapper.delete(new LambdaQueryWrapper().eq(ShortDramaStoryboard::getScriptId, scriptId)); + var delete = new LambdaQueryWrapper().eq(ShortDramaStoryboard::getScriptId, scriptId); + if (!lockedBySceneNo.isEmpty()) delete.notIn(ShortDramaStoryboard::getId, + lockedBySceneNo.values().stream().map(ShortDramaStoryboard::getId).toList()); + storyboardMapper.delete(delete); List result = new ArrayList<>(); int sceneNo = 1; for (StoryboardPanelData panel : panels) { + ShortDramaStoryboard locked = lockedBySceneNo.get(sceneNo); + if (locked != null) { + result.add(MapstructUtils.convert(locked, ShortDramaStoryboardVo.class)); + sceneNo++; + continue; + } ShortDramaStoryboard entity = new ShortDramaStoryboard(); entity.setId(IdUtil.getSnowflakeNextId()); entity.setProjectId(projectId); @@ -3567,7 +4504,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { entity.setShotType(firstNotBlank(panel.getShotType(), "平视中景")); entity.setCameraMove(firstNotBlank(panel.getCameraMove(), "缓推")); entity.setDurationSeconds(panel.getDuration() != null && panel.getDuration() > 0 ? panel.getDuration() : defaultDurationForSceneType(entity.getSceneType())); - entity.setVideoPrompt(firstNotBlank(panel.getVideoPrompt(), buildVideoPromptFallback(script, panel.getDescription(), sceneNo, panel.getSceneType(), panel.getDuration()))); + entity.setVideoPrompt(panel.getVideoPrompt()); entity.setVideoStatus("pending"); entity.setLocationName(panel.getLocation()); entity.setSourceText(panel.getSourceText()); @@ -3589,8 +4526,53 @@ public class ShortDramaServiceImpl implements IShortDramaService { }); } + private boolean isLockedApprovedStoryboard(ShortDramaStoryboard storyboard) { + if (!"done".equals(storyboard.getVideoStatus()) + || StrUtil.isBlank(storyboard.getVideoId()) || StrUtil.isBlank(storyboard.getVideoUrl())) return false; + try { + JsonNode continuity = JsonUtils.parseObject( + StrUtil.blankToDefault(storyboard.getContinuityJson(), "{}"), JsonNode.class); + return continuity.path("lock_storyboard").asBoolean(false); + } catch (Exception malformed) { + throw new IllegalStateException("锁定成片镜头的连续性JSON无效,旧分镜已保留", malformed); + } + } + // ==================== 辅助方法:资产库字符串构建 ==================== + /** The same explicit source-name/alias rule as cast validation; dialogue speakers and required CG stay available. */ + static java.util.Map sceneCharacterLibraries(ShortDramaSceneCheckpoint.Assets assets, String scene, String previous) { + Set names = assets.sourceNames(scene, previous); + return characterLibraries(assets, names); + } + + /** Detail batches receive their actual identity dependencies, including original offscreen speakers. */ + static java.util.Map panelCharacterLibraries(ShortDramaSceneCheckpoint.Assets assets, List panels) { + Set names = new HashSet<>(); + for (var binding : assets.referenced(panels)) if ("character".equals(binding.kind())) names.add(binding.name()); + return characterLibraries(assets, names); + } + + private static java.util.Map characterLibraries(ShortDramaSceneCheckpoint.Assets assets, Set names) { + var selected = assets.characters().stream().filter(c -> names.contains(c.getName())).toList(); + StringBuilder library = new StringBuilder(), intro = new StringBuilder(), appearances = new StringBuilder(), description = new StringBuilder(); + for (var c : selected) { + library.append("- ").append(c.getName()).append(",").append(firstNotBlank(c.getRoleLevel(), "B")).append("级,") + .append(firstNotBlank(c.getGender(), "未知")).append(",").append(firstNotBlank(c.getAgeRange(), "未知年龄")) + .append(";明确原文别名:").append(Objects.toString(c.getAliases(), "无")).append("\n"); + intro.append("- ").append(c.getName()).append(":").append(Objects.toString(c.getIntroduction(), "")).append("\n"); + description.append("- ").append(c.getName()).append(":").append(firstNotBlank(c.getVisualDescription(), c.getIntroduction(), "无描述")).append("\n"); + appearances.append("- ").append(c.getName()).append("的形象:"); + for (var a : assets.appearances()) if (Objects.equals(a.getCharacterId(), c.getId())) + appearances.append("[").append(a.getAppearanceIndex()).append("]").append(firstNotBlank(a.getChangeReason(), "形象")) + .append(":").append(firstNotBlank(a.getDescription(), c.getVisualDescription(), "沿用已有参考图")).append(";"); + appearances.append("\n"); + } + return java.util.Map.of("names", firstNotBlank(library.toString(), "暂无本场明确登记角色,匿名群众按background_extras描述"), + "introductions", firstNotBlank(intro.toString(), "暂无"), "appearances", firstNotBlank(appearances.toString(), "暂无"), + "descriptions", firstNotBlank(description.toString(), "暂无")); + } + private String buildCharactersLibString(Long projectId) { List chars = characterMapper.selectList( new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId, projectId)); @@ -3740,10 +4722,9 @@ public class ShortDramaServiceImpl implements IShortDramaService { private ChatModelVo findChatModel() { org.ruoyi.common.chat.domain.bo.chat.ChatModelBo query = new org.ruoyi.common.chat.domain.bo.chat.ChatModelBo(); query.setCategory("chat"); + query.setProviderCode("atlas"); List models = chatModelService.queryAvailableList(query); - if (models != null && !models.isEmpty()) { - return models.get(0); - } + if (models != null && !models.isEmpty()) return models.get(0); throw new IllegalArgumentException("无可用聊天模型"); } @@ -3751,7 +4732,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { return chatServiceFactory.getOriginalService(shortDramaProviderCode(modelVo.getProviderCode(), modelVo.getModelName())); } - static String shortDramaProviderCode(String provider, String model) { + public static String shortDramaProviderCode(String provider, String model) { return (org.ruoyi.enums.ChatModeType.OPEN_AI.getCode().equalsIgnoreCase(provider) || org.ruoyi.enums.ChatModeType.ATLAS.getCode().equalsIgnoreCase(provider)) && StrUtil.containsIgnoreCase(model, "deepseek") @@ -3802,16 +4783,19 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (StrUtil.isBlank(audio.getText())) throw new IllegalArgumentException("语音文案不能为空"); ChatModelVo modelVo = chatModelService.selectModelByName(audioModel); if (modelVo == null) throw new IllegalArgumentException("未找到语音模型配置: " + audioModel); + if (audioModel.startsWith("suno/")) throw new IllegalArgumentException("Suno 是音乐模型,请使用音乐创作面板;对白配音需选择语音模型"); if (!org.ruoyi.enums.ModelType.AUDIO.getKey().equals(modelVo.getCategory())) { throw new IllegalArgumentException("模型分类不是语音模型: " + audioModel); } - // 对白类型:从关联镜头收集出场角色及其子形象音色,作为 references + @audioN 标记 - List> references = buildAudioReferences(audio); + // Role-bound Seed Audio samples are executable references; appearance.voice is only a description. + var speech = ShortDramaCharacterVoiceService.MODEL.equals(audioModel) ? characterVoices.speech(audio) + : new ShortDramaCharacterVoiceService.Speech(audio.getText(),List.of()); + List> references = speech.references(); AudioContext ctx = AudioContext.builder() .chatModelVo(modelVo) - .input(audio.getText()) - .voice(StrUtil.isBlank(audio.getVoice()) ? null : audio.getVoice()) + .input(speech.text()) + .voice(ShortDramaCharacterVoiceService.MODEL.equals(audioModel) || StrUtil.isBlank(audio.getVoice()) ? null : audio.getVoice()) .responseFormat("mp3") .references(references) .build(); @@ -3849,39 +4833,6 @@ public class ShortDramaServiceImpl implements IShortDramaService { return MapstructUtils.convert(audio, ShortDramaAudioVo.class); } - /** - * 对白类型音频:从关联镜头的出场角色收集子形象音色,构造 references(speaker)。 - * text 中可用 @audioN 引用对应角色。旁白类型返回空列表。 - */ - private List> buildAudioReferences(ShortDramaAudio audio) { - if (!"dialogue".equals(audio.getAudioType()) || audio.getLinkedStoryboardId() == null) { - return List.of(); - } - ShortDramaStoryboard sb = storyboardMapper.selectById(audio.getLinkedStoryboardId()); - if (sb == null) return List.of(); - List refs = parseCharacterRefs(sb.getCharactersJson()); - if (refs == null || refs.isEmpty()) return List.of(); - List> result = new ArrayList<>(); - for (CharacterRef ref : refs) { - ShortDramaCharacter ch = findCharacterByName(sb.getProjectId(), ref.getName()); - if (ch == null) continue; - // 取该角色的主形象(appearanceIndex=0)音色 - List aps = characterAppearanceMapper.selectList( - new LambdaQueryWrapper() - .eq(ShortDramaCharacterAppearance::getCharacterId, ch.getId()) - .orderByAsc(ShortDramaCharacterAppearance::getAppearanceIndex)); - for (ShortDramaCharacterAppearance ap : aps) { - if (StrUtil.isNotBlank(ap.getVoice())) { - java.util.Map r = new LinkedHashMap<>(); - r.put("speaker", ap.getVoice()); - result.add(r); - break; - } - } - } - return result; - } - /** Atlas 异步音频轮询,累计不超过 3 分钟。 */ private MediaGenerationResponse pollAudioDone(ChatModelVo modelVo, String predictionId) { long deadline = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(3); @@ -3921,7 +4872,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { private StreamingChatModel buildStreamingChatModel() { ChatModelVo modelVo = findChatModel(); AbstractChatService chatService = getChatService(modelVo); - return chatService.buildStreamingChatModel(modelVo, new ChatRequest()); + return chatService.buildStreamingChatModel(modelVo, ShortDramaWritingRequest.forText(modelVo.getModelName(), null)); } private ShortDramaProject validateProjectOwner(Long projectId, Long userId) { @@ -3945,6 +4896,20 @@ public class ShortDramaServiceImpl implements IShortDramaService { } static void normalizeContinuityChain(List panels) { + normalizeContinuityChain(panels, false); + } + + /** Native Phase 3 scene IDs name source-script budget groups, not visual spaces or montage stages. */ + static void normalizeContinuityChain(List panels, boolean preserveSourceScenes) { + if (preserveSourceScenes) { + Integer previousSource = null; + for (var panel : panels) { + Integer source = panel.getSceneNumber(); + if (source == null || source < 1 || previousSource != null && source < previousSource) + throw new IllegalArgumentException("原文场身份须为正整数并按剧本顺序排列,不得按地点重新分配"); + previousSource = source; + } + } StoryboardPanelData previous = null; int sceneNumber = 1; int segmentNumber = 1; @@ -3954,23 +4919,31 @@ public class ShortDramaServiceImpl implements IShortDramaService { for (StoryboardPanelData current : panels) { Integer requestedScene = current.getSceneNumber(); String currentLocation = firstNotBlank(current.getLocation(), ""); - boolean newScene = previous != null && (!java.util.Objects.equals(previousLocation, currentLocation) - || (requestedScene != null && previousRequestedScene != null && !requestedScene.equals(previousRequestedScene))); + boolean newSpace = previous != null && !java.util.Objects.equals(previousLocation, currentLocation); + boolean sourceChanged = previous != null && requestedScene != null && previousRequestedScene != null + && !requestedScene.equals(previousRequestedScene); + boolean newScene = sourceChanged || !preserveSourceScenes && newSpace; + boolean continuityReset = newScene || newSpace; if (newScene) { sceneNumber++; segmentNumber = 1; segmentDuration = 0; } - int duration = current.getDuration() != null && current.getDuration() > 0 - ? Math.max(1, Math.min(current.getDuration(), 15)) : 6; - current.setDuration(duration); - previousRequestedScene = requestedScene; - boolean requestedNewSegment = current.getSegmentNumber() != null && current.getSegmentNumber() > segmentNumber; - if (previous != null && !newScene && (requestedNewSegment || segmentDuration + duration > 15)) { + if (preserveSourceScenes && newSpace && !newScene) { + // Keep the budget scene; a new space gets a distinct segment and cannot inherit old blocking. segmentNumber++; segmentDuration = 0; } - current.setSceneNumber(sceneNumber); + int duration = current.getDuration() != null && current.getDuration() > 0 + ? current.getDuration() : 6; + current.setDuration(duration); + previousRequestedScene = requestedScene; + boolean requestedNewSegment = current.getSegmentNumber() != null && current.getSegmentNumber() > segmentNumber; + if (previous != null && !continuityReset && (requestedNewSegment || segmentDuration + duration > 15)) { + segmentNumber++; + segmentDuration = 0; + } + current.setSceneNumber(preserveSourceScenes ? requestedScene : sceneNumber); current.setSegmentNumber(segmentNumber); segmentDuration += duration; previousLocation = currentLocation; @@ -3988,7 +4961,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { previous.setBridgeOut(firstNotBlank(previous.getBridgeOut(), current.getBridgeIn())); } } - if (previous != null && !newScene) { + if (previous != null && !continuityReset) { String inheritedState = firstNotBlank(previous.getEndState(), previous.getDescription(), previous.getSourceText(), ""); current.setStartState(firstNotBlank(current.getStartState(), inheritedState)); if (StrUtil.isBlank(current.getSpatialAnchor())) { @@ -4044,7 +5017,12 @@ public class ShortDramaServiceImpl implements IShortDramaService { continuity.put("story_action", firstNotBlank(panel.getStoryAction(), "")); continuity.put("story_result", firstNotBlank(panel.getStoryResult(), "")); continuity.put("next_hook", firstNotBlank(panel.getNextHook(), "")); + if (panel.getShotDesign() != null) continuity.set("shot_design", panel.getShotDesign()); if (panel.getPerformanceBeats() != null) continuity.set("performance_beats", panel.getPerformanceBeats()); + if (StrUtil.isNotBlank(panel.getBackgroundExtras())) { + ShortDramaBackgroundExtras.parse(panel.getBackgroundExtras()); + continuity.put("background_extras", panel.getBackgroundExtras()); + } if (panel.getTiming() != null) continuity.set("timing", panel.getTiming()); return continuity.toString(); } @@ -4056,6 +5034,11 @@ public class ShortDramaServiceImpl implements IShortDramaService { panel.setStartState(continuity.path("start_state").asText(null)); panel.setEndState(continuity.path("end_state").asText(null)); panel.setContinuityAction(continuity.path("continuity_action").asText(null)); + if (continuity.has("background_extras")) { + JsonNode extras = continuity.path("background_extras"); + if (!extras.isNull() && !extras.isTextual()) throw new IllegalArgumentException("background_extras必须为文字字段"); + panel.setBackgroundExtras(extras.asText(null)); + } panel.setSpatialAnchor(continuity.path("spatial_anchor").asText(null)); JsonNode present = continuity.path("present_characters"); if (present.isArray()) { @@ -4075,6 +5058,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { panel.setStoryAction(continuity.path("story_action").asText(null)); panel.setStoryResult(continuity.path("story_result").asText(null)); panel.setNextHook(continuity.path("next_hook").asText(null)); + if (continuity.has("shot_design")) panel.setShotDesign(continuity.get("shot_design")); if (continuity.has("timing")) panel.setTiming(continuity.get("timing")); if (continuity.has("performance_beats")) panel.setPerformanceBeats(continuity.get("performance_beats")); } catch (Exception e) { @@ -4157,46 +5141,6 @@ public class ShortDramaServiceImpl implements IShortDramaService { return chunks.stream().filter(StrUtil::isNotBlank).limit(12).toList(); } - /** 按 scene_type 选具体运镜描述,消除"缓缓推近"这类无信息量词。 */ - private static String cameraMoveForScene(String sceneType) { - return switch (firstNotBlank(sceneType, "daily")) { - case "action" -> "手持跟移,允许轻微晃动,快速横移跟随动作"; - case "emotion" -> "从中景缓推至近景,两秒到位后固定,聚焦面部"; - case "epic" -> "大远景缓拉升起,展现环境规模后停住"; - case "suspense" -> "斯坦尼康式低速缓推,略带左右摇摆模拟紧张"; - default -> "平视中景手持缓推,约两秒到位后固定"; - }; - } - - /** - * Phase 6 失败时的兜底 video_prompt。按 duration 分档写可拍节拍, - * 不再一句"镜头缓缓推近,自然光线"。 - */ - private static String buildVideoPromptFallback(ShortDramaScript script, String text, int sceneNo, - String sceneType, Integer duration) { - String tone = firstNotBlank(script.getTone(), "短剧"); - String camera = cameraMoveForScene(sceneType); - String light = switch (firstNotBlank(sceneType, "daily")) { - case "suspense" -> "低调硬光,保留阴影层次"; - case "emotion" -> "柔和侧光,突出面部情绪"; - case "action" -> "高反差侧光,强化动作轮廓"; - case "epic" -> "大范围自然光,突出空间规模"; - default -> "自然柔光,主光从画面侧上方斜照"; - }; - int dur = duration != null && duration > 0 ? duration : 6; - String beats; - if (dur >= 8) { - beats = "前段:" + text + ";中段:动作持续变化、视线或姿态推进;后段:收束在结束状态,留出下一镜承接"; - } else { - beats = "开始动作:" + text + ";持续变化:动作与视线推进;结束状态:收束停住"; - } - return "中景,镜头" + camera + "。" + beats + "。" + tone + "风格。" + light + "。短剧镜头" + sceneNo; - } - - private static String buildVideoPromptFallback(ShortDramaScript script, String text, int sceneNo) { - return buildVideoPromptFallback(script, text, sceneNo, "daily", 6); - } - // ==================== JSON 解析 ==================== private static String extractJson(String response) { @@ -4331,12 +5275,18 @@ public class ShortDramaServiceImpl implements IShortDramaService { } private List parsePanelList(String response) { + var panels = parseRawPanelList(response); + if (panels != null) ShortDramaShotDesign.expandDeltas(panels); + return panels; + } + + private List parseRawPanelList(String response) { String json = extractJson(response); if (StrUtil.isBlank(json)) return null; try { - if (json.trim().startsWith("[")) { - return JsonUtils.parseArray(json, StoryboardPanelData.class); - } + return ShortDramaPlanResponse.parse(json); + } catch (ShortDramaShotDesign.InvalidDesign e) { + throw e; } catch (Exception e) { log.warn("分镜规划JSON解析失败: {}", e.getMessage()); } @@ -4347,6 +5297,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { @Data public static class StoryboardPanelData { + @JsonProperty("background_extras") + private String backgroundExtras; @JsonProperty("panel_number") private Integer panelNumber; @JsonProperty("scene_number") @@ -4394,6 +5346,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { private String nextHook; @JsonProperty("performance_beats") private JsonNode performanceBeats; + @JsonProperty("shot_design") + private JsonNode shotDesign; // Phase 4 fills: private String photographyRules; // Phase 5 fills: @@ -4415,6 +5369,33 @@ public class ShortDramaServiceImpl implements IShortDramaService { private String name; private String appearance; private String slot; + + /** + * Planning models occasionally wrap the single appearance identifier in + * an array. Accept that harmless shape drift while keeping ambiguous + * multi-value bindings invalid. + */ + @JsonSetter("appearance") + public void setAppearance(JsonNode value) { + if (value == null || value.isNull()) { + appearance = null; + return; + } + if (value.isTextual() || value.isNumber()) { + appearance = value.asText(); + return; + } + if (value.isArray() && value.size() == 1 + && (value.get(0).isTextual() || value.get(0).isNumber())) { + appearance = value.get(0).asText(); + return; + } + throw new IllegalArgumentException("characters.appearance必须是单个形象标识"); + } + + public void setAppearance(String value) { + appearance = value; + } } @Data @@ -4425,7 +5406,9 @@ public class ShortDramaServiceImpl implements IShortDramaService { } @Data - private static class StoryboardDetailResult { + static class StoryboardDetailResult { + @JsonProperty("background_extras") + private String backgroundExtras; @JsonProperty("panel_number") private Integer panelNumber; @JsonProperty("scene_number") @@ -4486,6 +5469,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { private String personalityTags; private Integer costumeTier; private String visualKeywords; + private String visualDescription; private String suggestedColors; private String primaryIdentifier; } @@ -4532,18 +5516,64 @@ public class ShortDramaServiceImpl implements IShortDramaService { private String artStyleSuffix(Long projectId) { ShortDramaProject project = projectMapper.selectById(projectId); if (project == null) return ""; - return ShortDramaImageConstants.artStylePrompt(project.getArtStyle()); + return ShortDramaImageConstants.artStylePrompt(skillCatalog.effectiveArtStyle(project)) + projectDirection(projectId); } - /** - * 拼装角色生图最终 prompt:风格前缀 + 构图前缀 + 角色描述 + 三视图后缀 + 风格后缀。 - * 把风格词放在 prompt 最前面,避免被角色描述稀释导致画风漂移、每个角色风格不一致。 - */ + /** All character generation and editing paths share style-aware identity reference rules. */ private String buildCharacterPrompt(String basePrompt, Long projectId) { - String styleSuffix = artStyleSuffix(projectId); - String stylePrefix = StrUtil.isNotBlank(styleSuffix) ? styleSuffix + "," : ""; - return ShortDramaDirectorSkills.load("visual-world") + stylePrefix + ShortDramaImageConstants.CHARACTER_PROMPT_PREFIX - + basePrompt + ShortDramaImageConstants.CHARACTER_PROMPT_SUFFIX; + return buildCharacterPrompt(basePrompt, projectId, null, null); + } + + private String buildCharacterPrompt(String basePrompt, Long projectId, String referencePurpose, String revisionRequirements) { + ShortDramaProject project = projectMapper.selectById(projectId); + return ShortDramaCharacterArtPrompt.reference(basePrompt, effectiveArtStyle(project), referencePurpose, + revisionRequirements, assetWorldbuilding(projectId)) + selectedVisual(project, "aesthetic"); + } + + private String buildLocationPrompt(String name, String description, Long projectId) { + return buildLocationPrompt(name, description, projectId, null); + } + + private String buildLocationPrompt(String name, String description, Long projectId, String revisionRequirements) { + ShortDramaProject project = projectMapper.selectById(projectId); + return ShortDramaLocationArtPrompt.reference(name, description, effectiveArtStyle(project), assetWorldbuilding(projectId), revisionRequirements) + + selectedVisual(project, "aesthetic"); + } + + private String effectiveArtStyle(ShortDramaProject project) { + if (skillCatalog != null) return skillCatalog.effectiveArtStyle(project); + return project == null ? null : project.getArtStyle(); + } + + private String selectedVisual(ShortDramaProject project, String type) { + return skillCatalog == null ? "" : skillCatalog.selectedVisual(project, type); + } + + private String projectDirection(Long projectId) { + var project = projectMapper.selectById(projectId); + for (var frozen : planningSkillSnapshots.values()) if (Objects.equals(projectId, frozen.projectId())) { + skillCatalog.verify(frozen, project); + return frozen.direction(); + } + return skillCatalog.selected(project, "aesthetic") + skillCatalog.selected(project, "director"); + } + private ShortDramaProject ideaProject(ShortDramaIdeaBo idea) { + var project = new ShortDramaProject(); project.setAestheticSkillName(idea.getAestheticSkillName()); project.setDirectorSkillName(idea.getDirectorSkillName()); + project.setArtStyle(idea.getArtStyle()); return project; + } + private void validateSkillBindings(ShortDramaProject project) { + if (StrUtil.isNotBlank(project.getAestheticSkillName())) { + var skill = skillCatalog.requireEnabled(project.getAestheticSkillName(), "aesthetic"); + if (StrUtil.isNotBlank(skill.artStyle())) project.setArtStyle(skill.artStyle()); + } + if (StrUtil.isNotBlank(project.getDirectorSkillName())) skillCatalog.requireEnabled(project.getDirectorSkillName(), "director"); + } + + /** Assets belong to the latest saved screenplay for this project; no global or previous-project setting is reused. */ + private String assetWorldbuilding(Long projectId) { + ShortDramaScript script = scriptMapper.selectOne(new LambdaQueryWrapper() + .eq(ShortDramaScript::getProjectId, projectId).orderByDesc(ShortDramaScript::getId).last("limit 1")); + return script == null ? "" : ShortDramaScriptPreparation.worldContext(script); } private String projectAspectRatio(Long projectId) { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaShotContractRepair.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaShotContractRepair.java new file mode 100644 index 00000000..60d0994b --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaShotContractRepair.java @@ -0,0 +1,115 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.ruoyi.service.media.AtlasMediaSupport; +import org.ruoyi.service.shortdrama.impl.ShortDramaServiceImpl.StoryboardPanelData; +import java.util.*; + +/** Repairs only physical state contracts; the approved scene plan is never regenerated. */ +final class ShortDramaShotContractRepair { + private ShortDramaShotContractRepair() {} + + static String prompt(String scene, List panels, String issue) { + var context = AtlasMediaSupport.OBJECT_MAPPER.createArrayNode(); + for (var panel : panels) { + var node = context.addObject(); + node.put("panel_number", panel.getPanelNumber()); + node.put("description", panel.getDescription()); + node.put("start_state", panel.getStartState()); + node.put("end_state", panel.getEndState()); + node.set("shot_design", panel.getShotDesign()); + } + var required = segmentKeys(panels); + var template = AtlasMediaSupport.OBJECT_MAPPER.createArrayNode(); + for (var panel : panels) { + var patch = template.addObject(); + patch.put("panel_number", panel.getPanelNumber()); + if (required.containsKey(panel.getPanelNumber())) { + var initial = patch.putObject("state_in"); + for (String key : required.get(panel.getPanelNumber())) + initial.put(key, panel.getShotDesign().path("state_in").path(key).asText("")); + } + patch.putObject("state_delta"); + } + return """ + 仅校对现有专业分镜的物理状态契约,不重写剧情、镜号、时长、构图、轴线、运镜或交镜动作。 + 输出JSON数组,每个镜号恰好一次:[{"panel_number":1,"state_in":{...},"state_delta":{...}}, + {"panel_number":2,"state_delta":{...}}]。只允许这三个字段。 + opening、time_change、space_change镜必须明确state_in;continuous镜禁止state_in,后台逐字继承前镜末态与交镜接点。 + 每个连续段的初始state_in必须预先包含全段所有state_delta所用的键,并给出原文支持的真实初态。 + 在提交前扫描后续所有delta键;若如人物视线、手部动作、器物受力、气流、声音会发生变化,须在该段首镜初态写明。 + 下方已计算每段首镜必须覆盖的准确锚点清单。首镜state_in必须逐字包含该段清单所有键,不能遗漏、改名或添加新键。 + 下方给出可直接填写的JSON骨架。保留所有镜号与键名;state_in的空字符串须根据原文补为具体初态,不得删除这一项。已有文字只有与原文矛盾时才更正。state_delta仍须填写本镜真实变化,不能把所有变化改成空对象。 + 每镜state_delta只能选用该段清单中的准确键,禁止把神态改写为表情等近义键。没有变化写{}。 + 为清单每个键从原文和原规划写出实际初态,不能只处理错误提示中的一个键。不新增原文没有的动作、姿态或器物。 + 每镜delta只写该镜结束时真实改变的已有属性;不写state_out,后台按继承状态加delta展开后仍严格校验。 + 当前问题: + """ + issue + "\n每段首镜的完整准确锚点清单:\n" + required + "\n完整JSON填写骨架:\n" + template + "\n本场原文:\n" + scene + "\n待校对的原规划(全部非状态字段将原样保留):\n" + context; + } + + static Map> segmentKeys(List panels) { + var segments = new LinkedHashMap>(); + Set current = null; + for (var panel : panels) { + var design = panel.getShotDesign(); + if (design == null || !design.isObject()) throw new ShortDramaShotDesign.InvalidDesign("状态校对缺少原镜头契约"); + if (current == null || !"continuous".equals(design.path("transition").asText())) { + current = new LinkedHashSet<>(); segments.put(panel.getPanelNumber(), current); + } + for (String field : List.of("state_in", "state_out", "state_delta")) { + var state = design.path(field); + if (state.isObject()) state.fieldNames().forEachRemaining(current::add); + } + } + return segments; + } + + static List apply(List panels, JsonNode patches) { + if (patches == null || !patches.isArray() || patches.size() != panels.size()) + throw new ShortDramaShotDesign.InvalidDesign("状态校对须覆盖原镜号且不得增减镜头"); + var byNumber = new HashMap(); + for (var patch : patches) { + if (!patch.isObject() || !patch.path("panel_number").isIntegralNumber()) + throw new ShortDramaShotDesign.InvalidDesign("状态校对缺少原镜号"); + patch.fieldNames().forEachRemaining(key -> { + if (!Set.of("panel_number", "state_in", "state_delta").contains(key)) + throw new ShortDramaShotDesign.InvalidDesign("状态校对不能修改其他字段:" + key); + }); + if (byNumber.put(patch.path("panel_number").asInt(), patch) != null) + throw new ShortDramaShotDesign.InvalidDesign("状态校对镜号重复"); + } + var required = segmentKeys(panels); + var repaired = new ArrayList(); + for (var panel : panels) { + JsonNode patch = byNumber.remove(panel.getPanelNumber()); + if (patch == null || !patch.path("state_delta").isObject()) + throw new ShortDramaShotDesign.InvalidDesign("状态校对缺少原镜号或state_delta"); + var copy = AtlasMediaSupport.OBJECT_MAPPER.convertValue(panel, StoryboardPanelData.class); + if (!(copy.getShotDesign() instanceof ObjectNode design)) + throw new ShortDramaShotDesign.InvalidDesign("状态校对缺少原镜头契约"); + boolean continuous = "continuous".equals(design.path("transition").asText()); + if (continuous && patch.has("state_in")) + throw new ShortDramaShotDesign.InvalidDesign("连续镜状态校对须继承前镜末态"); + if (!continuous && !patch.path("state_in").isObject()) + throw new ShortDramaShotDesign.InvalidDesign("新连续段状态校对缺少明确初态"); + if (required.containsKey(panel.getPanelNumber())) { + var returnedKeys = new HashSet(); patch.path("state_in").fieldNames().forEachRemaining(returnedKeys::add); + if (!returnedKeys.equals(required.get(panel.getPanelNumber()))) { + var missing = new TreeSet<>(required.get(panel.getPanelNumber())); missing.removeAll(returnedKeys); + var extra = new TreeSet<>(returnedKeys); extra.removeAll(required.get(panel.getPanelNumber())); + throw new ShortDramaShotDesign.InvalidDesign("镜头" + panel.getPanelNumber() + "连续状态校对未完成:缺少锚点" + missing + ";多出锚点" + extra); + } + } + design.remove(List.of("state_in", "state_out", "state_delta")); + if (continuous) design.remove("cut_in"); + else design.set("state_in", patch.get("state_in").deepCopy()); + design.set("state_delta", patch.get("state_delta").deepCopy()); + repaired.add(copy); + } + if (!byNumber.isEmpty()) throw new ShortDramaShotDesign.InvalidDesign("状态校对包含未知镜号"); + ShortDramaShotDesign.expandDeltas(repaired); + ShortDramaShotDesign.validate(repaired); + return repaired; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaShotDesign.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaShotDesign.java new file mode 100644 index 00000000..363d8d15 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaShotDesign.java @@ -0,0 +1,86 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import org.ruoyi.service.shortdrama.impl.ShortDramaServiceImpl.StoryboardPanelData; +import java.util.List; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; + +/** Structural shot contracts do not replace visual review of generated media. */ +@Slf4j +final class ShortDramaShotDesign { + private ShortDramaShotDesign() {} + static final class InvalidDesign extends IllegalArgumentException { + InvalidDesign(String message) { super(message); } + } + /** Expand only an explicit delta contract. Never repair contradictory full-state output. */ + static void expandDeltas(List panels) { + JsonNode previous = null; + for (int i = 0; i < panels.size(); i++) { + JsonNode original = panels.get(i).getShotDesign(); + if (original == null || !original.isObject()) { previous = original; continue; } + var design = (com.fasterxml.jackson.databind.node.ObjectNode) original; + if (design.has("state_delta")) { + String prefix = "镜头" + (i + 1) + "专业分镜:"; + boolean continuous = previous != null && "continuous".equals(design.path("transition").asText()); + if (continuous) { + if (!design.has("state_in")) design.set("state_in", previous.path("state_out").deepCopy()); + if (!design.has("cut_in")) design.set("cut_in", previous.path("cut_out").deepCopy()); + } + JsonNode input = design.path("state_in"), delta = design.path("state_delta"); + if (!input.isObject() || !delta.isObject()) { previous = design; continue; } + var output = (com.fasterxml.jackson.databind.node.ObjectNode) input.deepCopy(); + delta.fields().forEachRemaining(entry -> { + output.set(entry.getKey(), entry.getValue()); + }); + // Preserve explicit states and wording. Missing anchors are review suggestions, not gates. + if (!design.has("state_out")) design.set("state_out", output); + design.remove("state_delta"); // Stored/read-back contracts always have complete first and last states. + } + previous = design; + } + } + static void validate(List panels) { + for (String issue : issues(panels)) log.info("分镜承接审阅建议(继续保存):{}", issue); + } + static List issues(List panels) { + try { validateContract(panels); return List.of(); } + catch (InvalidDesign issue) { return List.of(issue.getMessage()); } + } + private static void validateContract(List panels) { + JsonNode previous = null; + for (int i = 0; i < panels.size(); i++) { + JsonNode design = panels.get(i).getShotDesign(); + String prefix = "镜头" + (i + 1) + "专业分镜:"; + if (design == null || !design.isObject()) throw new InvalidDesign(prefix + "缺少shot_design镜头契约"); + for (String key : List.of("focus", "viewer_gain", "framing", "axis", "movement", "motivation", "cut_in", "cut_out", "transition")) { + if (!design.path(key).isTextual() || design.path(key).asText().isBlank()) + throw new InvalidDesign(prefix + "缺少具体的" + key); + } + String transition = design.path("transition").asText(); + if (!Set.of("opening", "continuous", "time_change", "space_change").contains(transition)) + throw new InvalidDesign(prefix + "transition类型无效"); + for (String key : List.of("state_in", "state_out")) { + JsonNode state = design.path(key); + if (!state.isObject() || state.isEmpty()) throw new InvalidDesign(prefix + key + "须为非空物理状态对象"); + state.fields().forEachRemaining(entry -> { + if (entry.getKey().isBlank() || !entry.getValue().isTextual() || entry.getValue().asText().isBlank()) + throw new InvalidDesign(prefix + key + "须逐项写具体文字状态"); + }); + } + java.util.Set inputKeys = new java.util.HashSet<>(), outputKeys = new java.util.HashSet<>(); + design.path("state_in").fieldNames().forEachRemaining(inputKeys::add); + design.path("state_out").fieldNames().forEachRemaining(outputKeys::add); + if (!inputKeys.equals(outputKeys)) throw new InvalidDesign(prefix + "镜内首末态须保留相同物理锚点,不能因裁幅改变删去世界状态"); + if (previous != null && "opening".equals(transition)) + throw new InvalidDesign(prefix + "同场后续镜不能以opening掩盖承接"); + if (previous != null && "continuous".equals(transition)) { + if (!previous.path("cut_out").asText().equals(design.path("cut_in").asText())) + throw new InvalidDesign(prefix + "cut_in须承接前镜cut_out的同一动作接点"); + if (!previous.path("state_out").equals(design.path("state_in"))) + throw new InvalidDesign(prefix + "state_in与前镜state_out不一致,存在姿态、持物或物理状态跳变"); + } + previous = design; + } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSkillCatalog.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSkillCatalog.java new file mode 100644 index 00000000..87c2f7ce --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSkillCatalog.java @@ -0,0 +1,264 @@ +package org.ruoyi.service.shortdrama.impl; + +import cn.hutool.crypto.digest.DigestUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.ruoyi.domain.entity.shortdrama.ShortDramaProject; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; + +/** Editable production direction. Files are data only: this service never executes scripts. */ +@Service +public class ShortDramaSkillCatalog { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Set TYPES = Set.of("aesthetic", "director"); + private static final Set STYLES = org.ruoyi.constant.ShortDramaImageConstants.ART_STYLE_PROMPTS.keySet(); + private static final int MAX_TEXT = 200_000, MAX_FILES = 32, MAX_TOTAL = 1_000_000, MAX_MEDIA_BODY = 2_000; + private final Path root; + private boolean initialized; + public ShortDramaSkillCatalog(@Value("${short-drama.skill-directory:}") String configured) { + root = (configured == null || configured.isBlank() ? Path.of(System.getProperty("user.dir"), "data", "short-drama-skills") : Path.of(configured)).toAbsolutePath().normalize(); + } + public record SkillFile(String path, String content) {} + public record Save(String name, String title, String type, String description, Boolean enabled, String artStyle, + String body, List files, String expectedVersion) {} + public record Skill(String name, String title, String type, String description, boolean enabled, String artStyle, + String version, String body, List files) {} + public record Option(String name, String title, String type, String description, boolean enabled, String artStyle, String version) {} + public record Snapshot(Long projectId, String version, String direction) {} + public synchronized Snapshot snapshot(ShortDramaProject project) { + return new Snapshot(project == null ? null : project.getId(), snapshotVersion(project), selected(project, "aesthetic") + selected(project, "director")); + } + public synchronized void verify(Snapshot snapshot, ShortDramaProject project) { + if (!Objects.equals(snapshot.version(), snapshotVersion(project))) + throw new IllegalStateException("制作期间选中技能或内容版本已改变,本轮未完成;旧素材和已完成草稿保留,请按新版本重新规划"); + } + private String snapshotVersion(ShortDramaProject project) { return DigestUtil.sha256Hex(projectVersion(project) + "\n" + (project == null ? "" : Objects.toString(project.getArtStyle(), ""))); } + + private synchronized void initialize() { + if (initialized) return; + try { + Files.createDirectories(root); rejectSymlinks(root); + // Only seed absent directories. The runtime directory, never this list, drives the catalog. + for (String name : List.of("chinese-3d-aesthetics", "ming-county-aesthetics", "compact-short-drama", "cinematic-storyboard")) { + if (Files.exists(root.resolve(name), LinkOption.NOFOLLOW_LINKS)) continue; + String prefix = "short-drama/skill-catalog/" + name + "/"; + try (var in = new ClassPathResource(prefix + "manifest.json", getClass().getClassLoader()).getInputStream(); + var body = new ClassPathResource(prefix + "SKILL.md", getClass().getClassLoader()).getInputStream()) { + var metadata = JSON.readTree(in); + List references = new ArrayList<>(); + for (var entry : metadata.path("files")) { + String path = entry.asText(); safeFile(path); + try (var reference = new ClassPathResource(prefix + path, getClass().getClassLoader()).getInputStream()) { + references.add(new SkillFile(path, new String(reference.readAllBytes(), StandardCharsets.UTF_8))); + } + } + writeNew(new Save(name, metadata.path("title").asText(), metadata.path("type").asText(), + metadata.path("description").asText(), true, metadata.path("artStyle").asText(""), + new String(body.readAllBytes(), StandardCharsets.UTF_8), references, null)); + } + } + initialized = true; + } catch (IOException e) { throw new IllegalStateException("制作技能目录初始化失败", e); } + } + + public synchronized List