From 5e66c7bd3088cc466505699387b3620f6ae765e5 Mon Sep 17 00:00:00 2001 From: ageerle Date: Tue, 20 Oct 2026 09:30:36 +0800 Subject: [PATCH] feat(short-drama): add director workspaces and refine video production Add canvas and director APIs, production skill catalogs, prompt preservation, video references and Atlas H3 support. Include schema migrations, upload limits, regression coverage and the new demo URL. --- README.md | 1 + README_ZH.md | 1 + docs/script/sql/ruoyi-ai.sql | 8 +- ...10-06-short-drama-image-prompt-history.sql | 8 + ...26-10-06-short-drama-storyboard-skills.sql | 7 + .../src/main/resources/application.yml | 4 +- .../chat/entity/video/VideoContext.java | 3 + .../ShortDramaCanvasController.java | 18 + .../shortdrama/ShortDramaController.java | 18 +- .../ShortDramaDirectorController.java | 18 + .../bo/shortdrama/ShortDramaIdeaBo.java | 1 + .../bo/shortdrama/ShortDramaProjectBo.java | 1 + .../ShortDramaScriptRevisionBo.java | 3 + .../ShortDramaVideoRejectionBo.java | 10 + .../entity/shortdrama/ShortDramaProject.java | 7 +- .../vo/shortdrama/ShortDramaProjectVo.java | 1 + .../shortdrama/IShortDramaService.java | 9 + .../impl/ShortDramaBackgroundExtras.java | 2 +- .../impl/ShortDramaCanvasService.java | 74 +++ .../impl/ShortDramaCharacterArtPrompt.java | 2 +- .../impl/ShortDramaCharacterVoiceService.java | 4 +- .../impl/ShortDramaDirectorService.java | 142 +++++ .../impl/ShortDramaDirectorSkills.java | 13 +- .../impl/ShortDramaImageStyleReferences.java | 5 +- .../impl/ShortDramaMediaRepair.java | 30 + .../impl/ShortDramaScriptPreparation.java | 4 +- .../impl/ShortDramaServiceImpl.java | 587 +++++++++--------- .../impl/ShortDramaSkillCatalog.java | 107 +++- .../impl/ShortDramaVideoPromptPolicy.java | 48 ++ .../impl/ShortDramaVideoPromptReview.java | 34 +- .../impl/ShortDramaVideoPropReferences.java | 5 +- .../impl/ShortDramaVideoReferenceMirrors.java | 31 + .../impl/ShortDramaVisualAssetService.java | 11 +- .../VideoSubmissionRejectedException.java | 29 + .../AtlasVideoGenerationServiceImpl.java | 67 +- .../github-anime-aesthetics/SKILL.md | 21 + .../github-anime-aesthetics/manifest.json | 11 + .../references/source.md | 13 + .../github-cg-aesthetics/SKILL.md | 21 + .../github-cg-aesthetics/manifest.json | 11 + .../github-cg-aesthetics/references/source.md | 13 + .../github-comic-motion-aesthetics/SKILL.md | 21 + .../manifest.json | 11 + .../references/source.md | 13 + .../github-drama-workflow/SKILL.md | 22 + .../github-drama-workflow/manifest.json | 11 + .../references/source.md | 13 + .../github-film-aesthetics/SKILL.md | 21 + .../github-film-aesthetics/manifest.json | 11 + .../references/source.md | 13 + .../github-handdrawn-aesthetics/SKILL.md | 21 + .../github-handdrawn-aesthetics/manifest.json | 11 + .../references/source.md | 13 + .../github-higgsfield-style-library/SKILL.md | 21 + .../manifest.json | 11 + .../references/source.md | 13 + .../github-manju-directing/SKILL.md | 28 + .../github-manju-directing/manifest.json | 11 + .../references/source.md | 13 + .../github-novel-adaptation/SKILL.md | 22 + .../github-novel-adaptation/manifest.json | 11 + .../references/source.md | 13 + .../github-seedance-case-library/SKILL.md | 21 + .../manifest.json | 11 + .../references/source.md | 13 + .../github-seedance-consistency/SKILL.md | 31 + .../github-seedance-consistency/manifest.json | 11 + .../references/source.md | 13 + .../github-seedance-directing/SKILL.md | 64 ++ .../github-seedance-directing/manifest.json | 11 + .../references/source.md | 13 + .../github-seedance-prompt-guide/SKILL.md | 22 + .../manifest.json | 11 + .../references/source.md | 13 + .../github-short-drama-writing/SKILL.md | 29 + .../github-short-drama-writing/manifest.json | 11 + .../references/source.md | 13 + .../github-storyboard-generator/SKILL.md | 22 + .../github-storyboard-generator/manifest.json | 11 + .../references/source.md | 13 + .../skills/cinematic-storyboard/SKILL.md | 20 + .../skills/director-blocking/SKILL.md | 5 + .../skills/director-review/SKILL.md | 24 + .../skills/emotional-dialogue/SKILL.md | 2 +- .../skills/github-production-methods/SKILL.md | 10 + .../skills/github-shot-methods/SKILL.md | 10 + .../skills/github-story-methods/SKILL.md | 9 + .../skills/story-causality/SKILL.md | 8 +- .../skills/video-continuity/SKILL.md | 5 + .../short-drama/skills/video-prompt/SKILL.md | 51 +- .../impl/ShortDramaBackgroundExtrasTest.java | 40 +- .../impl/ShortDramaCanvasServiceTest.java | 50 ++ .../ShortDramaCharacterArtPromptTest.java | 10 + .../impl/ShortDramaDirectorServiceTest.java | 100 +++ .../impl/ShortDramaGithubSkillsTest.java | 99 +++ .../ShortDramaImageStyleReferencesTest.java | 7 + .../impl/ShortDramaMediaRepairTest.java | 30 + .../ShortDramaPartialPlanRecoveryTest.java | 1 + .../impl/ShortDramaPlainSceneHeadingTest.java | 18 + .../ShortDramaScriptOnlyCreationTest.java | 4 + .../impl/ShortDramaScriptPreparationTest.java | 30 + .../impl/ShortDramaScriptSkillsTest.java | 61 ++ .../impl/ShortDramaSkillCatalogTest.java | 76 ++- .../impl/ShortDramaSkillMarketTest.java | 2 +- .../impl/ShortDramaVideoDurationTest.java | 2 +- .../impl/ShortDramaVideoPromptPolicyTest.java | 143 +++++ .../impl/ShortDramaVideoPromptReviewTest.java | 57 +- .../ShortDramaVideoReferenceMirrorsTest.java | 24 + .../impl/ShortDramaVideoReferenceTest.java | 31 + .../ShortDramaVideoResolutionFrameTest.java | 8 + .../impl/ShortDramaVideoSubmissionTest.java | 167 ++++- .../ShortDramaVisualAssetVersioningTest.java | 20 + .../video/provider/AtlasH3VideoTest.java | 40 ++ .../video/provider/AtlasMotionVideoTest.java | 33 + .../AtlasSeedanceVoicePayloadTest.java | 76 +++ .../changan-prompt-regression.json | 20 + 116 files changed, 2965 insertions(+), 388 deletions(-) create mode 100644 docs/script/sql/update/2026-10-06-short-drama-image-prompt-history.sql create mode 100644 docs/script/sql/update/2026-10-06-short-drama-storyboard-skills.sql create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaCanvasController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaDirectorController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaVideoRejectionBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaCanvasService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaDirectorService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaMediaRepair.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoPromptPolicy.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoReferenceMirrors.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/VideoSubmissionRejectedException.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-anime-aesthetics/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-anime-aesthetics/manifest.json create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-anime-aesthetics/references/source.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-cg-aesthetics/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-cg-aesthetics/manifest.json create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-cg-aesthetics/references/source.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-comic-motion-aesthetics/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-comic-motion-aesthetics/manifest.json create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-comic-motion-aesthetics/references/source.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-drama-workflow/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-drama-workflow/manifest.json create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-drama-workflow/references/source.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-film-aesthetics/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-film-aesthetics/manifest.json create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-film-aesthetics/references/source.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-handdrawn-aesthetics/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-handdrawn-aesthetics/manifest.json create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-handdrawn-aesthetics/references/source.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-higgsfield-style-library/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-higgsfield-style-library/manifest.json create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-higgsfield-style-library/references/source.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-manju-directing/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-manju-directing/manifest.json create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-manju-directing/references/source.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-novel-adaptation/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-novel-adaptation/manifest.json create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-novel-adaptation/references/source.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-seedance-case-library/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-seedance-case-library/manifest.json create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-seedance-case-library/references/source.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-seedance-consistency/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-seedance-consistency/manifest.json create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-seedance-consistency/references/source.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-seedance-directing/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-seedance-directing/manifest.json create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-seedance-directing/references/source.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-seedance-prompt-guide/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-seedance-prompt-guide/manifest.json create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-seedance-prompt-guide/references/source.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-short-drama-writing/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-short-drama-writing/manifest.json create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-short-drama-writing/references/source.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-storyboard-generator/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-storyboard-generator/manifest.json create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skill-catalog/github-storyboard-generator/references/source.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/github-production-methods/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/github-shot-methods/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/github-story-methods/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaCanvasServiceTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaDirectorServiceTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaGithubSkillsTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaMediaRepairTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaScriptSkillsTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoPromptPolicyTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoReferenceMirrorsTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoReferenceTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/video/provider/AtlasH3VideoTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/video/provider/AtlasMotionVideoTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/video/provider/AtlasSeedanceVoicePayloadTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/resources/short-drama/changan-prompt-regression.json diff --git a/README.md b/README.md index 99d330f4..14e5d557 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ | Admin Panel | http://129.226.199.247:25666 | admin / admin123 | | User Frontend | http://129.226.199.247:25137 | admin / admin123 | | Commercial Edition | https://web.ruoyiai.chat | WeChat QR code login | +| Additional Demo | [code.ruoyiai.chat](https://code.ruoyiai.chat/) | — | ## ✨ Core Features diff --git a/README_ZH.md b/README_ZH.md index 034fc5ff..45750cec 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -30,6 +30,7 @@ | 管理端 | http://129.226.199.247:25666 | admin / admin123 | | 用户端 | http://129.226.199.247:25137 | admin / admin123 | | 商业版 | https://web.ruoyiai.chat | 微信扫码登录 | +| 新版演示 | [code.ruoyiai.chat](https://code.ruoyiai.chat/) | — | ## ✨ 核心亮点 diff --git a/docs/script/sql/ruoyi-ai.sql b/docs/script/sql/ruoyi-ai.sql index 91bc16fc..9e928430 100644 --- a/docs/script/sql/ruoyi-ai.sql +++ b/docs/script/sql/ruoyi-ai.sql @@ -1385,10 +1385,10 @@ CREATE TABLE `short_drama_character_appearance` ( `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', `image_urls` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '生成图片URL列表(JSON数组)', - `image_descriptions` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '每张图片对应的提示词(JSON数组)', + `image_descriptions` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '每张图片对应的提示词(JSON数组)', `selected_image_index` int NULL DEFAULT 0 COMMENT '当前选中的图片索引', `previous_image_urls` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮图片URL列表(撤销用,JSON数组)', - `previous_descriptions` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)', + `previous_descriptions` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)', `voice` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '音色名(如 zh_male_taocheng_uranus_bigtts),用于该形象对白配音', PRIMARY KEY (`id`) USING BTREE, UNIQUE INDEX `uk_character_appearance`(`character_id` ASC, `appearance_index` ASC) USING BTREE, @@ -1428,10 +1428,10 @@ CREATE TABLE `short_drama_location` ( `update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id', `image_urls` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '生成图片URL列表(JSON数组)', - `image_descriptions` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '每张图片对应的提示词(JSON数组)', + `image_descriptions` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '每张图片对应的提示词(JSON数组)', `selected_image_index` int NULL DEFAULT 0 COMMENT '当前选中的图片索引', `previous_image_urls` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮图片URL列表(撤销用,JSON数组)', - `previous_descriptions` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)', + `previous_descriptions` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)', PRIMARY KEY (`id`) USING BTREE, INDEX `idx_project_id`(`project_id` ASC) USING BTREE, INDEX `idx_tenant_id`(`tenant_id` ASC) USING BTREE diff --git a/docs/script/sql/update/2026-10-06-short-drama-image-prompt-history.sql b/docs/script/sql/update/2026-10-06-short-drama-image-prompt-history.sql new file mode 100644 index 00000000..722d5ffe --- /dev/null +++ b/docs/script/sql/update/2026-10-06-short-drama-image-prompt-history.sql @@ -0,0 +1,8 @@ +-- Full image prompt histories can exceed TEXT's 65535 byte capacity after a few revisions. +-- Enlarge both the history and undo columns while retaining all earlier candidates. +ALTER TABLE short_drama_character_appearance + MODIFY COLUMN image_descriptions MEDIUMTEXT NULL COMMENT '每张图片对应的提示词(JSON数组)', + MODIFY COLUMN previous_descriptions MEDIUMTEXT NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)'; +ALTER TABLE short_drama_location + MODIFY COLUMN image_descriptions MEDIUMTEXT NULL COMMENT '每张图片对应的提示词(JSON数组)', + MODIFY COLUMN previous_descriptions MEDIUMTEXT NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)'; diff --git a/docs/script/sql/update/2026-10-06-short-drama-storyboard-skills.sql b/docs/script/sql/update/2026-10-06-short-drama-storyboard-skills.sql new file mode 100644 index 00000000..150cc73c --- /dev/null +++ b/docs/script/sql/update/2026-10-06-short-drama-storyboard-skills.sql @@ -0,0 +1,7 @@ +-- Add ordered multi-selection without rewriting existing project bindings. +SET @storyboard_skills_sql = (SELECT IF(COUNT(*) = 0, + 'ALTER TABLE short_drama_project ADD COLUMN storyboard_skill_names TEXT NULL COMMENT ''Ordered storyboard skill catalog names as JSON''', + 'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'short_drama_project' AND column_name = 'storyboard_skill_names'); +PREPARE storyboard_skills_stmt FROM @storyboard_skills_sql; +EXECUTE storyboard_skills_stmt; +DEALLOCATE PREPARE storyboard_skills_stmt; diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 8251c72d..9577c5b7 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -122,9 +122,9 @@ spring: servlet: multipart: # 单个文件大小 - max-file-size: 10MB + max-file-size: 30MB # 设置总上传的文件大小 - max-request-size: 20MB + max-request-size: 40MB mvc: # 设置静态资源路径 防止所有请求都去查静态资源 static-path-pattern: /static/** 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 c79b2392..f6041cbf 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 @@ -34,6 +34,9 @@ public class VideoContext { /** 多参考图 URL(多参考图生视频模式,配合 @imageN prompt 使用) */ private java.util.List referenceImages; + /** Motion/camera reference clips; distinct from appearance reference images. */ + private java.util.List referenceVideos; + /** 参考音频 URL 列表(对白口型对齐模式) */ private java.util.List referenceAudios; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaCanvasController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaCanvasController.java new file mode 100644 index 00000000..7c96b54e --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaCanvasController.java @@ -0,0 +1,18 @@ +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.ShortDramaCanvasService; +import org.springframework.web.bind.annotation.*; + +@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}/canvas") +public class ShortDramaCanvasController { + private final ShortDramaCanvasService service; + @GetMapping public R read(@PathVariable Long projectId) throws Exception { + return R.ok(service.read(projectId, LoginHelper.getUserId())); + } + @PutMapping public R save(@PathVariable Long projectId, @RequestBody ShortDramaCanvasService.Canvas request) throws Exception { + return R.ok(service.save(projectId, LoginHelper.getUserId(), request)); + } +} 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 724b1667..39b9a334 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 @@ -22,6 +22,7 @@ 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.ShortDramaVideoRejectionBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaIdeaBo; import org.ruoyi.domain.bo.shortdrama.ShortDramaImageRevisionBo; import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterVo; @@ -174,6 +175,21 @@ public class ShortDramaController { return R.ok(shortDramaService.retrieveVideo(storyboardId, model, LoginHelper.getUserId())); } + /** Recover an ID verified in provider history; queries the original provider before releasing the claim. */ + @PostMapping("/storyboard/{storyboardId}/video-submissions/{requestId}/resolve") + public R resolveVideoSubmission(@NotNull @PathVariable Long storyboardId, + @PathVariable String requestId, @NotBlank @RequestParam String predictionId, + @RequestParam(required = false) String confirmedFailureEvidence) { + return R.ok(shortDramaService.resolveVideoSubmission(storyboardId, requestId, predictionId, LoginHelper.getUserId(), confirmedFailureEvidence)); + } + + /** No prediction ID is invented for an explicit legacy rejection. This only releases its local claim. */ + @PostMapping("/storyboard/{storyboardId}/video-submissions/{requestId}/confirm-rejection") + public R confirmVideoSubmissionRejection(@NotNull @PathVariable Long storyboardId, + @PathVariable String requestId, @Valid @RequestBody ShortDramaVideoRejectionBo evidence) { + return R.ok(shortDramaService.confirmVideoSubmissionRejection(storyboardId, requestId, LoginHelper.getUserId(), evidence)); + } + @PostMapping("/{projectId}/generate-all-videos") public R> generateAllVideos(@NotNull @PathVariable Long projectId, @NotBlank @RequestParam String model, @@ -217,7 +233,7 @@ public class ShortDramaController { @PostMapping("/{projectId}/polish-script") public R polishScript(@NotNull @PathVariable Long projectId, @Valid @RequestBody ShortDramaScriptRevisionBo revision) { - return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId(), revision.getInstruction())); + return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId(), revision.getInstruction(), revision.getModel())); } // ==================== 资产分析 ==================== diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaDirectorController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaDirectorController.java new file mode 100644 index 00000000..467151dd --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaDirectorController.java @@ -0,0 +1,18 @@ +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.ShortDramaDirectorService; +import org.springframework.web.bind.annotation.*; + +@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}/director/{id}") +public class ShortDramaDirectorController { + private final ShortDramaDirectorService service; + @GetMapping public R read(@PathVariable Long projectId, @PathVariable String id) throws Exception { + return R.ok(service.read(projectId, id, LoginHelper.getUserId())); + } + @PutMapping public R save(@PathVariable Long projectId, @PathVariable String id, @RequestBody ShortDramaDirectorService.Director request) throws Exception { + return R.ok(service.save(projectId, id, LoginHelper.getUserId(), request)); + } +} 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 8dc7134a..66c076cb 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 @@ -17,6 +17,7 @@ public class ShortDramaIdeaBo { private String artStyle; private String aestheticSkillName; private String directorSkillName; + private java.util.List storyboardSkillNames; private String aspectRatio; 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 918f8ba8..694a214f 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 @@ -26,6 +26,7 @@ public class ShortDramaProjectBo extends BaseEntity { private String artStyle; private String aestheticSkillName; private String directorSkillName; + private java.util.List storyboardSkillNames; private String composeAspectRatio; } 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 index 39bdf905..4dafe2d4 100644 --- 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 @@ -10,4 +10,7 @@ public class ShortDramaScriptRevisionBo { @NotBlank(message = "修改意见不能为空") @Size(max = 10000, message = "修改意见不能超过10000字") private String instruction; + + /** Optional exact writing model; omission preserves the configured default. */ + private String model; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaVideoRejectionBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaVideoRejectionBo.java new file mode 100644 index 00000000..b0b2f3df --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaVideoRejectionBo.java @@ -0,0 +1,10 @@ +package org.ruoyi.domain.bo.shortdrama; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** Owner attestation for a legacy HTTP rejection whose body was retained in runtime logs. */ +public record ShortDramaVideoRejectionBo(@NotNull Integer httpStatus, + @NotBlank @Size(max = 2000) String responseBody, + @NotBlank @Size(min = 20, max = 2000) String evidence) { } 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 aa714373..407e5ec3 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 @@ -2,6 +2,8 @@ package org.ruoyi.domain.entity.shortdrama; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import lombok.Data; import lombok.EqualsAndHashCode; import org.ruoyi.common.mybatis.core.domain.BaseEntity; @@ -12,7 +14,7 @@ import java.util.Date; @Data @EqualsAndHashCode(callSuper = true) -@TableName("short_drama_project") +@TableName(value = "short_drama_project", autoResultMap = true) public class ShortDramaProject extends BaseEntity { @Serial @@ -35,6 +37,9 @@ public class ShortDramaProject extends BaseEntity { private String artStyle; private String aestheticSkillName; private String directorSkillName; + /** Ordered supplementary skills for storyboard planning and review. */ + @TableField(typeHandler = JacksonTypeHandler.class) + private java.util.List storyboardSkillNames; private Long composedVideoOssId; 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 07f62706..5188429b 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 @@ -31,6 +31,7 @@ public class ShortDramaProjectVo implements Serializable { private String artStyle; private String aestheticSkillName; private String directorSkillName; + private java.util.List storyboardSkillNames; private Long composedVideoOssId; 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 97c7496b..155bf27e 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 @@ -52,6 +52,13 @@ public interface IShortDramaService { org.ruoyi.service.shortdrama.impl.ShortDramaVideoSubmissionStore.Submission videoSubmission(Long storyboardId, String requestId, Long userId); + ShortDramaStoryboardVo resolveVideoSubmission(Long storyboardId, String requestId, String predictionId, Long userId); + + ShortDramaStoryboardVo resolveVideoSubmission(Long storyboardId, String requestId, String predictionId, Long userId, String confirmedFailureEvidence); + + ShortDramaStoryboardVo confirmVideoSubmissionRejection(Long storyboardId, String requestId, Long userId, + org.ruoyi.domain.bo.shortdrama.ShortDramaVideoRejectionBo evidence); + ShortDramaStoryboardVo retrieveVideo(Long storyboardId, String videoModel, Long userId); /** 代理流式输出视频,绕过 OSS 强制下载 header */ @@ -68,6 +75,8 @@ public interface IShortDramaService { /** 按修改意见重新生成固定格式剧本 */ ShortDramaDetailVo polishScript(Long projectId, Long userId, String instruction); + ShortDramaDetailVo polishScript(Long projectId, Long userId, String instruction, String model); + /** 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); 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 index 0f7322b6..95055996 100644 --- 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 @@ -11,7 +11,7 @@ final class 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 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(); } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaCanvasService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaCanvasService.java new file mode 100644 index 00000000..378a0344 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaCanvasService.java @@ -0,0 +1,74 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import java.nio.file.*; +import java.util.*; + +/** Editable production documents. Media submission and business ownership remain in their existing services. */ +@Service +public class ShortDramaCanvasService { + private static final ObjectMapper JSON = new ObjectMapper(); + private final ShortDramaSoundService owners; + private final Path root; + public record Canvas(long revision, JsonNode document) { } + @Autowired public ShortDramaCanvasService(ShortDramaSoundService owners) { this(owners, Path.of("data", "short-drama-canvases")); } + ShortDramaCanvasService(ShortDramaSoundService owners, Path root) { this.owners = owners; this.root = root.toAbsolutePath(); } + private Path file(Long project) { return root.resolve(project + ".json"); } + public synchronized Canvas read(Long project, Long user) throws Exception { + owners.owner(project, user); + return Files.exists(file(project)) ? JSON.readValue(file(project).toFile(), Canvas.class) : new Canvas(0, null); + } + public synchronized Canvas save(Long project, Long user, Canvas request) throws Exception { + owners.owner(project, user); + validate(request.document()); + Canvas previous = read(project, user); + if (previous.revision() != request.revision()) throw new IllegalArgumentException("画布已在另一窗口更新,请重新加载后继续编辑"); + Canvas saved = new Canvas(previous.revision() + 1, request.document().deepCopy()); + Files.createDirectories(root); + Path temporary = Files.createTempFile(root, "canvas-", ".tmp"); + try { + JSON.writeValue(temporary.toFile(), saved); + try { Files.move(temporary, file(project), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException ignored) { Files.move(temporary, file(project), StandardCopyOption.REPLACE_EXISTING); } + } finally { Files.deleteIfExists(temporary); } + return saved; + } + static void validate(JsonNode doc) throws Exception { + if (doc == null || !doc.isObject() || doc.path("schema").asInt() != 1 || !doc.path("nodes").isArray() || !doc.path("edges").isArray()) + throw new IllegalArgumentException("画布格式无效"); + if (JSON.writeValueAsBytes(doc).length > 2 * 1024 * 1024 || doc.path("nodes").size() > 500 || doc.path("edges").size() > 1500) + throw new IllegalArgumentException("画布超过保存上限"); + Set ids = new HashSet<>(); Map kinds = new HashMap<>(); + Set supported = Set.of("text", "script", "image", "video", "audio", "group", "previs"); + for (JsonNode n : doc.path("nodes")) { + String id = n.path("id").asText(); + if (id.isBlank() || id.length() > 180 || !ids.add(id) || !supported.contains(n.path("kind").asText()) || !n.path("data").isObject() + || !n.path("position").path("x").isNumber() || !n.path("position").path("y").isNumber() + || !Double.isFinite(n.path("position").path("x").asDouble()) || !Double.isFinite(n.path("position").path("y").asDouble())) + throw new IllegalArgumentException("节点格式或编号无效"); + kinds.put(id, n.path("kind").asText()); + } + Map> paths = new HashMap<>(); Set edgeIds = new HashSet<>(); + for (JsonNode e : doc.path("edges")) { + String source = e.path("source").asText(), target = e.path("target").asText(); + if (!edgeIds.add(e.path("id").asText()) || !ids.contains(source) || !ids.contains(target) || source.equals(target) + || "group".equals(kinds.get(source)) || "group".equals(kinds.get(target))) throw new IllegalArgumentException("连线端点无效"); + paths.computeIfAbsent(source, ignored -> new ArrayList<>()).add(target); + } + Set visited = new HashSet<>(), active = new HashSet<>(); + for (String id : ids) visit(id, paths, visited, active); + for (JsonNode n : doc.path("nodes")) { + String parent = n.path("parentId").asText(); + if (!parent.isEmpty() && (!"group".equals(kinds.get(parent)) || parent.equals(n.path("id").asText()) || "group".equals(n.path("kind").asText()))) + throw new IllegalArgumentException("分组关系无效"); + } + } + private static void visit(String id, Map> paths, Set visited, Set active) { + if (active.contains(id)) throw new IllegalArgumentException("连线不能形成循环"); + if (!visited.add(id)) return; + active.add(id); for (String next : paths.getOrDefault(id, List.of())) visit(next, paths, visited, active); active.remove(id); + } +} 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 index ab4c40dd..59a92abd 100644 --- 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 @@ -27,7 +27,7 @@ final class ShortDramaCharacterArtPrompt { 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*全身|(?:不要|不做|不用)三视图).*" ); + boolean singleReference = subjectDescription.matches("(?s).*(?:(?:单幅|单张)(?:(?:三分之四|3/4)?(?:完整)?全身|角色|人物|定妆)|单人\\s*3/4\\s*全身|(?:不要|不做|不用)三视图).*" ); if (NON_HUMAN_SUBJECT.matcher(subjectDescription).find()) { return nonHumanReference(subjectDescription, style, worldbuilding) + revisionRules(revision, revisionRequirements, true); } 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 index 4c6caa51..f4fa6871 100644 --- 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 @@ -250,8 +250,8 @@ public class ShortDramaCharacterVoiceService { 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这些样音只用于同一角色音色参考。按原对白与声源顺序表演,画外对白仍由对应角色发声;不要朗读样音正文或绑定说明,不给静默听者增加台词。"; + String direction=bindings.isEmpty()?"":"\n【角色音色】\n"+bindings.stream().map(b -> b.characterName()+"对应@音频"+b.audioIndex()).reduce((a,b)->a+";"+b).orElse("") + +"。仅参考音色,按本镜对白发声,不朗读样音;画外声不驱动画内口型。"; 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/ShortDramaDirectorService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaDirectorService.java new file mode 100644 index 00000000..338bd4fb --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaDirectorService.java @@ -0,0 +1,142 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import java.nio.file.*; +import java.util.*; + +/** Small, owned previs documents; saving a document never submits a media task. */ +@Service +public class ShortDramaDirectorService { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Set ACTIONS = Set.of("idle", "walk", "run", "wave", "sit", "reach", "point", "nod", "shakeHead", "crouch", "bow", "talk"); + private static final Set JOINTS = Set.of("pelvis", "torso", "head", "leftShoulder", "rightShoulder", "leftArm", "leftElbow", "rightArm", "rightElbow", "leftHand", "rightHand", "leftLeg", "leftKnee", "rightLeg", "rightKnee", "leftFoot", "rightFoot"); + private final ShortDramaSoundService owners; + private final Path root; + public record Director(long revision, JsonNode document) { } + @Autowired public ShortDramaDirectorService(ShortDramaSoundService owners) { this(owners, Path.of("data", "short-drama-directors")); } + ShortDramaDirectorService(ShortDramaSoundService owners, Path root) { this.owners = owners; this.root = root.toAbsolutePath().normalize(); } + private Path file(Long project, String id) { + if (project == null || project <= 0 || id == null || !id.matches("[a-zA-Z0-9_-]{1,100}")) throw new IllegalArgumentException("工程编号无效"); + return root.resolve(project.toString()).resolve(id + ".json"); + } + public synchronized Director read(Long project, String id, Long user) throws Exception { + owners.owner(project, user); Path target = file(project, id); + return Files.exists(target) ? JSON.readValue(target.toFile(), Director.class) : new Director(0, null); + } + public synchronized Director save(Long project, String id, Long user, Director request) throws Exception { + owners.owner(project, user); Path target = file(project, id); + if (request == null) throw new IllegalArgumentException("工程不能为空"); + validate(request.document()); + var previous = read(project, id, user); + if (previous.revision() != request.revision()) throw new IllegalArgumentException("导演工程已在另一窗口更新,请先导出备份,再重新加载"); + var saved = new Director(previous.revision() + 1, request.document().deepCopy()); + Files.createDirectories(target.getParent()); + Path temporary = Files.createTempFile(target.getParent(), "director-", ".tmp"); + try { + JSON.writeValue(temporary.toFile(), saved); + try { Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException ignored) { Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); } + } finally { Files.deleteIfExists(temporary); } + return saved; + } + static void validate(JsonNode doc) throws Exception { + if (doc == null || !doc.isObject() || !doc.path("schema").isIntegralNumber() || doc.path("schema").asInt() != 1 || !doc.path("fps").isIntegralNumber() || doc.path("fps").asInt() != 24 + || !doc.path("duration").isIntegralNumber() || doc.path("duration").asInt() < 2 || doc.path("duration").asInt() > 60 + || !Set.of("16:9", "9:16").contains(doc.path("aspect").asText()) || !doc.path("objects").isArray() || doc.path("objects").size() > 80 + || JSON.writeValueAsBytes(doc).length > 1024 * 1024) throw new IllegalArgumentException("工程格式无效或超过上限"); + int frames = doc.path("duration").asInt() * 24; + Set ids = new HashSet<>(); + for (JsonNode o : doc.path("objects")) { + if (!o.path("id").asText().matches("[a-zA-Z0-9_-]{1,100}") || !ids.add(o.path("id").asText()) + || !Set.of("actor", "box", "wall", "door", "castle", "cannon").contains(o.path("kind").asText()) || !o.path("name").isTextual() + || o.path("name").asText().isBlank() || o.path("name").asText().length() > 100 || !o.path("color").asText().matches("#[a-fA-F0-9]{6}") + || !o.path("visible").isBoolean() || !o.path("locked").isBoolean()) throw new IllegalArgumentException("场景对象无效"); + vector(o.path("scale"), true); keys(o.path("keys"), frames); + if (o.has("fireFrame") && (!o.path("kind").asText().equals("cannon") || !o.path("fireFrame").isIntegralNumber() || o.path("fireFrame").asInt() < 0 || o.path("fireFrame").asInt() >= frames)) throw new IllegalArgumentException("开炮时刻超出大炮的时间轴范围"); + if (o.has("autoFace") && !o.path("autoFace").isBoolean()) throw new IllegalArgumentException("朝向选项无效"); + for (String option : List.of("grounded", "quaternionPose")) if (o.has(option) && (!o.path(option).isBoolean() || o.path(option).asBoolean() && !o.path("kind").asText().equals("actor"))) throw new IllegalArgumentException("人物姿态选项无效"); + for (JsonNode k : o.path("keys")) { + vector(k.path("position"), false); number(k.path("yaw"), -360, 360); + if (!ACTIONS.contains(k.path("action").asText())) throw new IllegalArgumentException("白模动作无效"); + ease(k); + if (k.has("pose")) { + JsonNode pose = k.path("pose"); + if (!pose.isObject()) throw new IllegalArgumentException("关节姿态无效"); + var fields = pose.fields(); + while (fields.hasNext()) { + var entry = fields.next(); + if (entry.getKey().equals("height")) number(entry.getValue(), -.9, 1); + else { + if (!JOINTS.contains(entry.getKey()) || !entry.getValue().isArray() || entry.getValue().size() != 3) throw new IllegalArgumentException("关节名称或角度无效"); + for (JsonNode angle : entry.getValue()) number(angle, -180, 180); + } + } + } + } + if (o.has("clips")) { + JsonNode clips = o.path("clips"); + if (!o.path("kind").asText().equals("actor") || !clips.isArray() || clips.size() > 100) throw new IllegalArgumentException("动作片段无效"); + int previousEnd = 0; Set clipIds = new HashSet<>(); + for (JsonNode c : clips) { + int start = c.path("start").asInt(-1), end = c.path("end").asInt(-1), blend = c.path("blend").asInt(-1); + if (!c.path("id").isTextual() || !c.path("id").asText().matches("[a-zA-Z0-9_-]{1,100}") || !clipIds.add(c.path("id").asText()) + || !ACTIONS.contains(c.path("action").asText()) || !c.path("start").isIntegralNumber() || !c.path("end").isIntegralNumber() + || start < previousEnd || end <= start || end > frames || !c.path("blend").isIntegralNumber() || blend < 0 || blend > (end-start)/2) + throw new IllegalArgumentException("动作片段时间无效、重叠或过渡过长"); + number(c.path("speed"), .25, 3); number(c.path("strength"), 0, 1.5); previousEnd = end; + } + } + } + for (JsonNode o : doc.path("objects")) if (o.has("lookAtId") && (!o.path("lookAtId").isTextual() || !ids.contains(o.path("lookAtId").asText()) || o.path("lookAtId").asText().equals(o.path("id").asText()))) throw new IllegalArgumentException("注视对象无效"); + for (JsonNode o : doc.path("objects")) if (o.has("targetId")) { + boolean castle = false; + for (JsonNode target : doc.path("objects")) if (target.path("id").asText().equals(o.path("targetId").asText()) && target.path("kind").asText().equals("castle")) castle = true; + if (!o.path("kind").asText().equals("cannon") || !o.path("targetId").isTextual() || !castle) throw new IllegalArgumentException("大炮命中目标必须是当前场景的城门城墙"); + } + keys(doc.path("camera").path("keys"), frames); + if (doc.has("markers")) { + JsonNode markers = doc.path("markers"); + if (!markers.isArray() || markers.size() > 100) throw new IllegalArgumentException("动作标记无效"); + for (JsonNode marker : markers) if (!marker.path("frame").isIntegralNumber() || marker.path("frame").asInt() < 0 || marker.path("frame").asInt() >= frames || !marker.path("label").isTextual() || marker.path("label").asText().isBlank() || marker.path("label").asText().length() > 60) throw new IllegalArgumentException("动作标记无效"); + } + for (JsonNode k : doc.path("camera").path("keys")) { + vector(k.path("position"), false); vector(k.path("target"), false); + number(k.path("fov"), 15, 100); number(k.path("roll"), -180, 180); + ease(k); + boolean same = true; + for (int i = 0; i < 3; i++) if (Math.abs(k.path("position").get(i).asDouble() - k.path("target").get(i).asDouble()) >= .001) same = false; + if (same) throw new IllegalArgumentException("机位与拍摄目标不能重合"); + } + if (doc.path("camera").has("follow")) { + JsonNode f = doc.path("camera").path("follow"); + if (!f.isObject() || !ids.contains(f.path("objectId").asText()) || !Set.of("aim", "follow").contains(f.path("mode").asText())) throw new IllegalArgumentException("摄影机跟随对象无效"); + vector(f.path("offset"), false); number(f.path("height"), 0, 3); + if (f.path("mode").asText().equals("follow")) { + double x=f.path("offset").get(0).asDouble(), y=f.path("offset").get(1).asDouble()-f.path("height").asDouble(), z=f.path("offset").get(2).asDouble(); + if (Math.sqrt(x*x+y*y+z*z) < .01) throw new IllegalArgumentException("跟随机位不能与目标重合"); + } + } + } + private static void ease(JsonNode key) { + if (key.has("ease") && (!key.path("ease").isTextual() || !Set.of("linear", "smooth", "hold").contains(key.path("ease").asText()))) throw new IllegalArgumentException("插值方式无效"); + } + private static void vector(JsonNode v, boolean scale) { + if (!v.isArray() || v.size() != 3) throw new IllegalArgumentException("坐标格式无效"); + for (JsonNode n : v) number(n, scale ? .1 : -100, scale ? 20 : 100); + } + private static void number(JsonNode v, double min, double max) { + if (!v.isNumber() || !Double.isFinite(v.asDouble()) || v.asDouble() < min || v.asDouble() > max) throw new IllegalArgumentException("数值超出范围"); + } + private static void keys(JsonNode list, int frames) { + if (!list.isArray() || list.isEmpty() || list.size() > 1440 || list.get(0).path("frame").asInt(-1) != 0) throw new IllegalArgumentException("关键帧必须从第 0 帧开始"); + int previous = -1; + for (JsonNode k : list) { + int frame = k.path("frame").asInt(-1); + if (!k.path("frame").isIntegralNumber() || frame <= previous || frame >= frames) throw new IllegalArgumentException("关键帧顺序或时间无效"); + previous = frame; + } + } +} 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 8b3686f8..347c1ea1 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,22 @@ 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-video-prompt-v11"; + public static final String VERSION = "director-video-prompt-v16"; 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"); + // Reuse the same stage-specific methods in every existing drafting/review path. + // Catalog styles remain opt-in; this never loads every external handbook. + var staged = new java.util.LinkedHashSet(); + for (String name : names) { + staged.add(name); + if ("story-causality".equals(name)) staged.add("github-story-methods"); + if ("cinematic-storyboard".equals(name) || "video-prompt".equals(name)) staged.add("github-shot-methods"); + if ("director-review".equals(name)) staged.add("github-production-methods"); + } + for (String name : staged) 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. */ 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 index 470b8707..fd00270e 100644 --- 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 @@ -19,8 +19,9 @@ final class ShortDramaImageStyleReferences { } static String direction(boolean location, int count) { - return "\n【有序参考图用途】第1张仅用于" + (location ? "本场景的门墙、出入口和方位" : "本角色的身份、年龄、服装与配色") + return "\n【有序参考图用途】第1张仅用于" + (location ? "本场景的门墙、出入口和方位" : "本角色的身份、年龄;服装与配色以本轮明确修订为准,未修订项沿用主体参考") + ";第2至" + count + "张仅用于影视画面中的光色、明暗层次、表面质感与摄影呈现。" - + "将后者的视觉处理用到第1张的内容,不复制后者的人脸、衣装、建筑、道具或构图,输出一个连续单幅。"; + + "将后者的视觉处理用到第1张的内容,不复制后者的人脸、衣装、建筑、道具或构图。" + + (location ? "输出一个连续单幅场景。" : "构图按本次资产的明确用途与布局;人物多视图共享同一身份与本轮衣装。"); } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaMediaRepair.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaMediaRepair.java new file mode 100644 index 00000000..940f2b2f --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaMediaRepair.java @@ -0,0 +1,30 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard; +import java.util.Objects; + +/** A separate repair candidate can reuse produced assets while a later screenplay is being drafted. */ +final class ShortDramaMediaRepair { + private static final ObjectMapper JSON = new ObjectMapper(); + private ShortDramaMediaRepair() { } + static Long sourceId(ShortDramaStoryboard candidate) { + try { + var value = JSON.readTree(Objects.toString(candidate.getContinuityJson(), "{}")).path("repair_source_board"); + long id = Long.parseLong(value.asText("")); + return id > 0 && !Objects.equals(id, candidate.getId()) ? id : null; + } catch (Exception ignored) { return null; } + } + static boolean usesProducedAssets(ShortDramaStoryboard candidate, ShortDramaStoryboard source) { + if (source == null || !Objects.equals(sourceId(candidate), source.getId())) return false; + if (!Objects.equals(candidate.getProjectId(), source.getProjectId()) + || !Objects.equals(candidate.getScriptId(), source.getScriptId()) + || !Objects.equals(candidate.getLocationName(), source.getLocationName())) return false; + if (!"done".equals(source.getVideoStatus()) || source.getVideoId() == null || source.getVideoId().isBlank() + || source.getVideoUrl() == null || source.getVideoUrl().isBlank()) return false; + try { + var cast = JSON.readTree(Objects.toString(candidate.getCharactersJson(), "[]")); + return cast.isArray() && !cast.isEmpty() && cast.equals(JSON.readTree(source.getCharactersJson())); + } catch (Exception ignored) { return false; } + } +} 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 index 40c0fd74..bd3b2004 100644 --- 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 @@ -9,6 +9,8 @@ public final class 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()); + + (StrUtil.isBlank(script.getWorldbuilding()) ? "" : "\n【当前项目世界观,人物与场景必须遵守】\n" + script.getWorldbuilding()) + + (StrUtil.isBlank(script.getRevisionNotes()) ? "" : "\n【本版已保存的制作与修订要求】\n" + script.getRevisionNotes() + + "\n按这些要求确定本阶段范围、沿用资产与候选边界;分镜阶段以当前剧本正文为剧情和对白依据,不因历史修订说明改回旧剧情。\n"); } } 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 ae062592..b51a9e35 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 @@ -75,6 +75,8 @@ import org.ruoyi.service.chat.AbstractChatService; import org.ruoyi.service.media.AtlasPredictionService; import org.ruoyi.service.shortdrama.IShortDramaService; import org.ruoyi.service.shortdrama.IShortDramaVideoComposeService; +import org.ruoyi.service.video.VideoSubmissionRejectedException; +import org.ruoyi.domain.bo.shortdrama.ShortDramaVideoRejectionBo; import org.springframework.stereotype.Service; import org.ruoyi.common.redis.utils.RedisUtils; import org.springframework.transaction.annotation.Transactional; @@ -115,6 +117,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { private final ShortDramaAudioMapper audioMapper; private final ShortDramaVisualAssetService visualAssets; private final ShortDramaSoundService sounds; + private final ShortDramaVideoReferenceService motionReferences; private final ShortDramaCharacterVoiceService characterVoices; private final ShortDramaSkillCatalog skillCatalog; private final IChatModelService chatModelService; @@ -334,6 +337,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { project.setArtStyle(firstNotBlank(bo.getArtStyle(), "script-tone")); project.setAestheticSkillName(bo.getAestheticSkillName()); project.setDirectorSkillName(bo.getDirectorSkillName()); + project.setStoryboardSkillNames(bo.getStoryboardSkillNames()); validateSkillBindings(project); project.setComposeAspectRatio(normalizeAspectRatio(bo.getAspectRatio())); project.setStatus("draft"); @@ -356,13 +360,19 @@ public class ShortDramaServiceImpl implements IShortDramaService { @Override public ShortDramaDetailVo polishScript(Long projectId, Long userId, String instruction) { + return polishScript(projectId, userId, instruction, null); + } + + @Override + public ShortDramaDetailVo polishScript(Long projectId, Long userId, String instruction, String model) { 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 = findChatModel(); + var selectedSnapshot = skillCatalog.snapshot(project); + ChatModelVo modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel(); AbstractChatService chatService = getChatService(modelVo); ChatModel chatModel = chatService.buildChatModel(modelVo, ShortDramaWritingRequest.forText(modelVo.getModelName(), null)); @@ -372,32 +382,44 @@ public class ShortDramaServiceImpl implements IShortDramaService { bo.setArtStyle(project.getArtStyle()); bo.setAestheticSkillName(project.getAestheticSkillName()); bo.setDirectorSkillName(project.getDirectorSkillName()); + bo.setStoryboardSkillNames(project.getStoryboardSkillNames()); ShortDramaScriptResult result = executePhase1_ScriptPolish(chatModel, bo, instruction.trim(), null); - - if (script == null) { - script = new ShortDramaScript(); - script.setId(IdUtil.getSnowflakeNextId()); - script.setProjectId(projectId); - script.setSourceType("llm"); - } + skillCatalog.verify(selectedSnapshot, validateProjectOwner(projectId, userId)); + ShortDramaScript latest = scriptMapper.selectOne(new LambdaQueryWrapper() + .eq(ShortDramaScript::getProjectId, projectId).orderByDesc(ShortDramaScript::getId).last("limit 1")); + if (!Objects.equals(script == null ? null : script.getId(), latest == null ? null : latest.getId()) + || (script != null && !Objects.equals(assetSourceHash(script), assetSourceHash(latest)))) + throw new IllegalStateException("改写期间剧本已变化,本轮未保存;原剧本和素材保留,请合并当前修改后重写"); + // A rewrite is a new screenplay version; earlier boards continue to point at their original source. + script = rewrittenScript(projectId, script, result, instruction.trim(), modelVo.getModelName(), selectedSnapshot.version()); project.setStatus("script_changed"); videoComposeService.invalidateComposition(projectId); project.setProjectName(firstNotBlank(result.getProjectName(), project.getProjectName())); project.setDescription(firstNotBlank(result.getDescription(), project.getDescription())); projectMapper.updateById(project); - script.setScriptName(firstNotBlank(result.getScriptName(), script.getScriptName())); - 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 { - scriptMapper.insert(script); - } + scriptMapper.insert(script); return getDetail(projectId, userId); } + static ShortDramaScript rewrittenScript(Long projectId, ShortDramaScript previous, ShortDramaScriptResult result, + String instruction, String model, String skillVersion) { + var script = new ShortDramaScript(); + script.setId(IdUtil.getSnowflakeNextId()); + script.setProjectId(projectId); + script.setSourceType("llm"); + if (previous != null) { + script.setCreationMode(previous.getCreationMode()); + script.setSourceMaterials(previous.getSourceMaterials()); + script.setWorldbuilding(previous.getWorldbuilding()); + } + script.setScriptName(firstNotBlank(result.getScriptName(), "改写剧本")); + script.setOutlineText(result.getOutlineText()); + script.setScriptText(result.getScriptText()); + script.setTone(result.getTone()); + script.setRevisionNotes(instruction + "\n[writing-model:" + model + "]\n[selected-skills-version:" + skillVersion + "]"); + return script; + } + // ==================== 项目/剧本 CRUD ==================== @Override @@ -409,6 +431,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { 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.getStoryboardSkillNames() == null) entity.setStoryboardSkillNames(previous.getStoryboardSkillNames()); + else entity.setStoryboardSkillNames(skillCatalog.normalizeStoryboardSkills(bo.getStoryboardSkillNames())); 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"); @@ -519,8 +543,9 @@ public class ShortDramaServiceImpl implements IShortDramaService { var script=scriptMapper.selectById(scriptId); if(script==null || !projectId.equals(script.getProjectId()))throw new IllegalArgumentException("剧本不属于当前项目"); if(!java.util.Objects.equals(script.getScriptText(),review.getExpectedScriptText()))throw new IllegalStateException("剧本已修改,请重新合并审阅稿"); - var existing=storyboardMapper.selectList(new LambdaQueryWrapper().eq(ShortDramaStoryboard::getProjectId,projectId)); - if(!existing.isEmpty())throw new IllegalStateException("已有分镜请使用分镜修订入口;规划导入仅适用于尚未落库的项目"); + var existing=storyboardMapper.selectList(new LambdaQueryWrapper() + .eq(ShortDramaStoryboard::getProjectId,projectId).eq(ShortDramaStoryboard::getScriptId,scriptId)); + if(!existing.isEmpty())throw new IllegalStateException("当前剧本版本已有分镜,请使用分镜修订入口;其他剧本版本保持"); List scenes=splitScriptScenes(script.getScriptText()); var panels=review.getPanels(); List selected = reviewedSceneNumbers(scenes, panels, review.getSceneNumbers()); @@ -715,7 +740,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { 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())) + String version = project != null && (StrUtil.isNotBlank(project.getAestheticSkillName()) || StrUtil.isNotBlank(project.getDirectorSkillName()) || project.getStoryboardSkillNames() != null && !project.getStoryboardSkillNames().isEmpty()) ? skillCatalog.projectVersion(project) : ""; return new ShortDramaSceneCheckpoint.Assets(characters, appearances, locations, version); } @@ -738,7 +763,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { 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())) + String selectedVersion = project != null && (StrUtil.isNotBlank(project.getAestheticSkillName()) || StrUtil.isNotBlank(project.getDirectorSkillName()) || project.getStoryboardSkillNames() != null && !project.getStoryboardSkillNames().isEmpty()) ? ":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; @@ -1015,12 +1040,12 @@ public class ShortDramaServiceImpl implements IShortDramaService { entity.setDurationSeconds(defaultDurationForSceneType(entity.getSceneType())); } 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. + // Manual drafts use the same advisory review as model planning and paid submission. + // Missing staging needs review, not a keyword-based refusal to save creative work. 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)); + ShortDramaVideoPromptReview.validate(entity.getSceneNo(), entity.getVideoPrompt(), + ShortDramaVideoDuration.reviewSeconds(entity.getContinuityJson()), entity.getSourceText()); } if (entity.getId() == null) { entity.setVideoStatus("pending"); @@ -1073,8 +1098,16 @@ public class ShortDramaServiceImpl implements IShortDramaService { throw new IllegalStateException("原视频标记已完成但缺少文件与任务编号;请先核对原任务,再显式重生成"); } if (ShortDramaVisualAssetService.directInsert(storyboard.getContinuityJson())) throw new IllegalStateException("本镜使用素材,不能通过AI视频模型重绘;请在剪辑阶段使用已绑定原图"); - if ("script_changed".equals(project.getStatus())) throw new IllegalStateException("剧本已修改,请重新分析资产和生成分镜后再生成视频"); + if ("script_changed".equals(project.getStatus())) { + Long repairSource = ShortDramaMediaRepair.sourceId(storyboard); + if (repairSource == null || !ShortDramaMediaRepair.usesProducedAssets(storyboard, storyboardMapper.selectById(repairSource))) + throw new IllegalStateException("剧本已修改,请重新分析资产和生成分镜后再生成视频;局部返修须关联同一制作版本的已完成镜头并保留演员和场景绑定"); + } if (storyboardGenerationStates.containsKey(storyboard.getScriptId())) throw new IllegalStateException("分镜正在更新,请完成审阅后再生成视频"); + String selectedVideoModel = JsonUtils.parseObject(StrUtil.blankToDefault(storyboard.getContinuityJson(), "{}"), JsonNode.class) + .path("video_model").asText("").trim(); + if (!selectedVideoModel.isEmpty() && !selectedVideoModel.equals(videoModel)) + throw new IllegalArgumentException("请求视频模型与本镜已保存选择不一致,请使用:" + selectedVideoModel); ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, videoModel, "video"); if (modelVo == null) throw new IllegalArgumentException("未找到视频模型配置: " + videoModel); @@ -1083,6 +1116,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { // 收集所有参考图(角色 + 场景 + 末帧承接) List referenceImages = findStoryboardReferenceImages(storyboard, lastFrameUrl); var voicePlan = characterVoices.plan(storyboard, null, true); + var motionClips = motionReferences.selected(project.getId(), userId, storyboard.getContinuityJson()); + ShortDramaVideoReferenceService.validateModel(modelVo.getModelName(), motionClips); // 根据参考图数量自动切换模型 if (referenceImages != null && !referenceImages.isEmpty() || !voicePlan.references().isEmpty()) { @@ -1106,18 +1141,22 @@ public class ShortDramaServiceImpl implements IShortDramaService { } String enrichedPrompt = buildEnrichedVideoPrompt(storyboard, referenceImages, lastFrameUrl); + if (!motionClips.isEmpty()) enrichedPrompt += "\n动作节奏参考" + java.util.stream.IntStream.range(0, motionClips.size()) + .mapToObj(i -> "@video" + (i + 1)).collect(java.util.stream.Collectors.joining("、")) + ",外观与场景按导演稿。"; 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())) + if (!voicePlan.references().isEmpty() && modelVo.getModelName().startsWith("bytedance/seedance-2.0") && (referenceImages==null || referenceImages.isEmpty()) && motionClips.isEmpty()) throw new IllegalArgumentException("Seedance 2.0声音参考需要至少一张角色或场景参考图;首帧仍为可选"); enrichedPrompt += voicePlan.direction(); + List transportedImages = ShortDramaVideoReferenceMirrors.apply(storyboard.getContinuityJson(), referenceImages); + VideoContext ctx = VideoContext.builder() .chatModelVo(modelVo) .prompt(enrichedPrompt) .size(projectAspectRatio(storyboard.getProjectId())) .seconds(videoSeconds) .resolution(JsonUtils.parseObject(StrUtil.blankToDefault(storyboard.getContinuityJson(), "{}"), JsonNode.class).path("video_resolution").asText("720p")) - .referenceImages(referenceImages) + .referenceImages(transportedImages) .generateAudio(Boolean.TRUE) .returnLastFrame(Boolean.TRUE) .lastFrameUrl(lastFrameUrl) @@ -1127,6 +1166,13 @@ public class ShortDramaServiceImpl implements IShortDramaService { 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()); + parameters.put("promptPolicyVersion", ShortDramaVideoPromptPolicy.VERSION); + parameters.put("directorSkillsVersion", ShortDramaDirectorSkills.VERSION); + parameters.put("selectedSkills", skillCatalog.selectedVersions(project)); + parameters.put("directorPromptCharacters", ShortDramaVideoPromptPolicy.characters(storyboard.getVideoPrompt())); + parameters.put("submittedPromptCharacters", ShortDramaVideoPromptPolicy.characters(enrichedPrompt)); + if (!motionClips.isEmpty()) parameters.put("motionReferences", motionClips); + if (!Objects.equals(referenceImages, transportedImages)) parameters.put("originalImages", referenceImages); 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); @@ -1136,6 +1182,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { // 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)); + ctx.setReferenceVideos(motionReferences.transport(project.getId(), modelVo, motionClips)); String generationToken = "local:" + requestId; long now = System.currentTimeMillis(); var receipt = new ShortDramaVideoSubmissionStore.Submission(requestId, storyboardId, project.getId(), userId, @@ -1166,6 +1213,14 @@ public class ShortDramaServiceImpl implements IShortDramaService { try { response = videoServiceFactory.getOriginalService(modelVo.getProviderCode()).generateVideo(ctx); } catch (RuntimeException ex) { + if (ex instanceof VideoSubmissionRejectedException rejected && receipt.providerCode().equals(rejected.providerCode())) { + videoSubmissions.save(receipt.update(null, "failed", rejected.getMessage())); + storyboardMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaStoryboard::getId, storyboardId).eq(ShortDramaStoryboard::getVideoId, generationToken) + .set(ShortDramaStoryboard::getVideoId, null).set(ShortDramaStoryboard::getVideoStatus, "failed")); + log.warn("镜头{}视频提交已明确拒绝,requestId={}: {}", storyboardId, requestId, rejected.getMessage()); + return MapstructUtils.convert(storyboardMapper.selectById(storyboardId), ShortDramaStoryboardVo.class); + } videoSubmissions.save(receipt.update(null, "submission_unknown", "提交结果未知;请核对上游任务记录,未自动重试")); storyboardMapper.update(null, new LambdaUpdateWrapper() .eq(ShortDramaStoryboard::getId, storyboardId) @@ -1226,6 +1281,100 @@ public class ShortDramaServiceImpl implements IShortDramaService { return videoSubmissions.read(storyboardId, requestId); } + @Override + public ShortDramaStoryboardVo resolveVideoSubmission(Long storyboardId, String requestId, String predictionId, Long userId) { + return resolveVideoSubmission(storyboardId, requestId, predictionId, userId, null); + } + + @Override + public ShortDramaStoryboardVo confirmVideoSubmissionRejection(Long storyboardId, String requestId, Long userId, + ShortDramaVideoRejectionBo evidence) { + String canonical = ShortDramaVideoSubmissionStore.requestId(requestId); + if (evidence == null || evidence.httpStatus() == null || evidence.evidence() == null + || evidence.evidence().length() < 20 || evidence.evidence().length() > 2000 + || !evidence.evidence().contains(canonical)) + throw new IllegalArgumentException("拒绝确认须记录原 requestId 和服务日志核对依据"); + var rejected = VideoSubmissionRejectedException.atlasResponse(evidence.httpStatus(), evidence.responseBody()); + if (rejected == null) throw new IllegalArgumentException("仅明确的 Atlas HTTP 402 余额拒绝可使用此恢复路径;其他结果须保留未知状态"); + synchronized (videoSubmissions.lock(storyboardId)) { + var shot = storyboardMapper.selectById(storyboardId); + if (shot == null) throw new IllegalArgumentException("分镜不存在"); + validateProjectOwner(shot.getProjectId(), userId); + var receipt = videoSubmissions.read(storyboardId, canonical); + if (receipt == null || !userId.equals(receipt.userId()) || !shot.getProjectId().equals(receipt.projectId()) + || !"atlas".equals(receipt.providerCode())) throw new IllegalArgumentException("原 Atlas 视频提交记录不存在或无权限"); + if ("failed".equals(receipt.status()) && receipt.predictionId() == null && Objects.toString(receipt.error(), "").contains("HTTP 402")) + return MapstructUtils.convert(shot, ShortDramaStoryboardVo.class); + String local = "local:" + canonical; + if (receipt.predictionId() != null || !"submission_unknown".equals(receipt.status()) + || !"submission_unknown".equals(shot.getVideoStatus()) || !local.equals(shot.getVideoId())) + throw new IllegalStateException("任务已受理或当前关联已变更,不能释放原提交状态"); + videoSubmissions.save(receipt.update(null, "failed", rejected.getMessage() + ";项目所有者核对日志:" + evidence.evidence())); + storyboardMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaStoryboard::getId, storyboardId).eq(ShortDramaStoryboard::getVideoId, local) + .set(ShortDramaStoryboard::getVideoId, null).set(ShortDramaStoryboard::getVideoStatus, "failed")); + log.warn("镜头{}原提交经日志核对为明确HTTP 402拒绝,requestId={},未提交新任务", storyboardId, canonical); + return MapstructUtils.convert(storyboardMapper.selectById(storyboardId), ShortDramaStoryboardVo.class); + } + } + + @Override + public ShortDramaStoryboardVo resolveVideoSubmission(Long storyboardId, String requestId, String predictionId, Long userId, String confirmedFailureEvidence) { + if (predictionId == null || !predictionId.matches("[A-Za-z0-9_-]{1,200}")) + throw new IllegalArgumentException("上游任务编号无效"); + if (confirmedFailureEvidence != null && (confirmedFailureEvidence.length() < 20 || confirmedFailureEvidence.length() > 2000 + || !confirmedFailureEvidence.contains(predictionId))) + throw new IllegalArgumentException("人工失败确认须记录匹配的供应商任务编号和控制台核对依据"); + String canonical = ShortDramaVideoSubmissionStore.requestId(requestId); + synchronized (videoSubmissions.lock(storyboardId)) { + var shot = storyboardMapper.selectById(storyboardId); + if (shot == null) throw new IllegalArgumentException("分镜不存在"); + validateProjectOwner(shot.getProjectId(), userId); + var receipt = videoSubmissions.read(storyboardId, canonical); + if (receipt == null || !userId.equals(receipt.userId())) throw new IllegalArgumentException("视频提交记录不存在或无权限"); + String local = "local:" + canonical; + if (predictionId.equals(receipt.predictionId())) return retrieveVideo(storyboardId, receipt.actualModel(), userId); + if (receipt.predictionId() != null || !Set.of("submitting", "submission_unknown").contains(receipt.status()) + || !local.equals(shot.getVideoId())) throw new IllegalStateException("原任务已变更,不能覆盖当前视频关联"); + var model = ShortDramaAtlasModels.require(chatModelService, receipt.actualModel(), "video"); + if (model == null || !receipt.providerCode().equals(model.getProviderCode())) + throw new IllegalArgumentException("原视频供应商配置已变更"); + MediaGenerationResponse response; + boolean manuallyConfirmed = false; + try { + response = videoServiceFactory.getOriginalService(receipt.providerCode()).retrieveVideo( + VideoContext.builder().chatModelVo(model).prompt("retrieve").videoId(predictionId).build()); + } catch (RuntimeException ex) { + log.warn("视频历史恢复查询失败,保留requestId={},predictionId={}: {}", canonical, predictionId, ex.getMessage()); + // An explicit owner confirmation from verified history is separate from a transport error. + // Without that evidence, a timeout always remains unknown and cannot trigger another payment. + if (confirmedFailureEvidence == null) throw ex; + manuallyConfirmed = true; + response = MediaGenerationResponse.builder().id(predictionId).status("failed").build(); + } + if (response == null || !predictionId.equals(response.getId()) || StrUtil.isBlank(response.getStatus())) + throw new IllegalStateException("上游未确认该任务编号,保留未知提交状态"); + if (StrUtil.isNotBlank(response.getRawResponse())) { + var raw = JsonUtils.parseObject(response.getRawResponse(), JsonNode.class); + if (!predictionId.equals(raw.path("data").path("id").asText())) + throw new IllegalStateException("上游未返回匹配的任务记录,不能据此重试"); + } + String state = StrUtil.isNotBlank(response.getUrl()) ? "done" : videoTerminalFailure(response.getStatus()) ? "failed" + : Set.of("completed", "succeeded").contains(response.getStatus()) ? "submission_unknown" : "generating"; + var recovered = receipt.result(predictionId, response.getUrl(), response.getLastFrameUrl(), state, + manuallyConfirmed ? "上游查询不可用;项目所有者明确确认控制台失败并授权重试。核对依据:" + confirmedFailureEvidence + : "已从供应商历史恢复任务编号并查询核对;上游状态=" + response.getStatus()); + videoSubmissions.save(recovered); + storyboardMapper.update(null, new LambdaUpdateWrapper() + .eq(ShortDramaStoryboard::getId, storyboardId).eq(ShortDramaStoryboard::getVideoId, local) + .set(ShortDramaStoryboard::getVideoId, predictionId).set(ShortDramaStoryboard::getVideoStatus, state) + .set("done".equals(state), ShortDramaStoryboard::getVideoUrl, response.getUrl()) + .set("done".equals(state), ShortDramaStoryboard::getLastFrameUrl, response.getLastFrameUrl())); + if ("done".equals(state)) videoComposeService.invalidateComposition(shot.getProjectId()); + return MapstructUtils.convert(storyboardMapper.selectById(storyboardId), ShortDramaStoryboardVo.class); + } + } + /** 收集分镜关联的所有参考图:角色形象图(按出场顺序)+ 场景图 + 可选末帧承接 */ private List findStoryboardReferenceImages(ShortDramaStoryboard storyboard, String lastFrameUrl) { List images = new ArrayList<>(); @@ -1277,273 +1426,55 @@ public class ShortDramaServiceImpl implements IShortDramaService { } catch (Exception ignored) { return false; } } - /** 构建增强提示词:融合镜头语言、摄影规则、角色信息、表演指导、场景描述 */ + /** Keep the saved director text once; append only concrete media bindings. */ private String buildEnrichedVideoPrompt(ShortDramaStoryboard storyboard) { return buildEnrichedVideoPrompt(storyboard, null, null); } - /** 构建增强提示词(含 @imageN 参考图引用,用于 reference-to-video 模型) */ - private String buildEnrichedVideoPrompt(ShortDramaStoryboard storyboard, java.util.List refImages) { + private String buildEnrichedVideoPrompt(ShortDramaStoryboard storyboard, List refImages) { return buildEnrichedVideoPrompt(storyboard, refImages, null); } - /** 构建增强提示词(含参考图 + 末帧首帧承接) */ - private String buildEnrichedVideoPrompt(ShortDramaStoryboard storyboard, java.util.List refImages, String lastFrameUrl) { + private String buildEnrichedVideoPrompt(ShortDramaStoryboard storyboard, 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(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", "video-prompt")); - sb.append(selectedDirection); - - // 1. 镜头语言标签 + // Validate actual selections. Their versions are recorded in the submission, not read as video prose. + skillCatalog.selectedVersions(selectedProject); + String prompt = ShortDramaVideoPromptPolicy.execution(storyboard.getVideoPrompt(), storyboard.getSourceText()); + if (refImages == null || refImages.isEmpty()) return prompt; String frame = videoStartFrame(storyboard); - if (StrUtil.isNotBlank(frame) && hasRefImages && refImages.contains(frame)) { - sb.append("[本镜关键帧] @image").append(refImages.indexOf(frame)+1) - .append("是已生成的本镜构图和起始状态;锁定人物身份、道具外观和站位,从该状态展开本镜动作。其他参考图只用于身份和空间,不复制多视图。\n"); - } - sb.append("[镜头: ").append(firstNotBlank(storyboard.getSceneType(), "daily")); - if (StrUtil.isNotBlank(storyboard.getShotType())) sb.append(", ").append(storyboard.getShotType()); - if (StrUtil.isNotBlank(storyboard.getCameraMove())) sb.append(", ").append(storyboard.getCameraMove()); - sb.append("]\n"); + if (refImages.size() == 1 && StrUtil.isNotBlank(frame) && refImages.contains(frame) + && StrUtil.isBlank(lastFrameUrl)) + return ShortDramaVideoPromptReview.anchoredPrompt(storyboard.getVideoPrompt(), null, + storyboard.getSourceText(), null, null); - // 1.1 核心动作描述前置(最高权重,确保模型优先关注当前镜头的具体可拍内容) - if (StrUtil.isNotBlank(storyboard.getVideoPrompt())) { - sb.append("[核心动作] ").append(storyboard.getVideoPrompt()).append("\n"); - } - - // 2. 摄影规则 - if (StrUtil.isNotBlank(storyboard.getPhotographyRules())) { - try { - JsonNode rules = JsonUtils.parseObject(storyboard.getPhotographyRules(), JsonNode.class); - if (rules != null) { - StringBuilder photo = new StringBuilder("[摄影:"); - JsonNode lighting = rules.path("lighting"); - if (!lighting.isMissingNode()) { - String dir = lighting.path("direction").asText(null); - String qual = lighting.path("quality").asText(null); - if (dir != null) photo.append(" ").append(dir).append(qual != null ? qual : "").append("光"); - } - String dof = rules.path("depth_of_field").asText(null); - if (dof != null) photo.append(", 景深:").append(dof); - String tone = rules.path("color_tone").asText(null); - if (tone != null) photo.append(", 色调:").append(tone); - if (photo.length() > 4) { photo.append("]"); sb.append(photo).append("\n"); } - - JsonNode photoChars = rules.path("characters"); - if (photoChars.isArray() && !photoChars.isEmpty()) { - sb.append("[站位] "); - for (int i = 0; i < photoChars.size(); i++) { - JsonNode pc = photoChars.get(i); - if (i > 0) sb.append(";"); - sb.append(pc.path("name").asText("")) - .append("在").append(pc.path("screen_position").asText("")) - .append(pc.path("posture").asText("")) - .append("面向").append(pc.path("facing").asText("")); - } - sb.append("\n"); - } + StringBuilder bindings = new StringBuilder(); + int inherited = StrUtil.isBlank(lastFrameUrl) ? -1 : refImages.indexOf(lastFrameUrl); + int first = StrUtil.isBlank(frame) ? -1 : refImages.indexOf(frame); + if (inherited >= 0) bindings.append("@image").append(inherited + 1).append("为上一镜末帧,首态从该图延续。\n"); + else if (first >= 0) bindings.append("@image").append(first + 1).append("为起始画面。\n"); + var characters = parseCharacterRefs(storyboard.getCharactersJson()); + for (CharacterRef ref : characters == null ? List.of() : characters) { + int index = findRefImageIndex(refImages, storyboard.getProjectId(), ref.getName(), ref.getAppearance()); + if (index < 0 && multiShotSegment(storyboard)) + throw new IllegalArgumentException("连续多镜段缺少角色参考:" + ref.getName()); + if (index >= 0) { + bindings.append(ref.getName()); + if (characters.stream().filter(other -> Objects.equals(ref.getName(), other.getName())).count() > 1) { + bindings.append("(形象").append(Objects.toString(ref.getAppearance(), "")); + if (StrUtil.isNotBlank(ref.getSlot())) bindings.append(",").append(ref.getSlot()); + bindings.append(")"); } - } catch (Exception e) { log.debug("解析摄影规则失败: {}", e.getMessage()); } - } - - // 3. 角色信息(名字→年龄性别+视觉描述,附加站位 + @imageN 参考图标记) - List chars = parseCharacterRefs(storyboard.getCharactersJson()); - if (chars != null && !chars.isEmpty()) { - sb.append("[角色] "); - for (int i = 0; i < chars.size(); i++) { - CharacterRef ref = chars.get(i); - if (i > 0) sb.append(";"); - ShortDramaCharacter ch = findCharacterByName(storyboard.getProjectId(), ref.getName()); - String desc = ch != null - ? firstNotBlank(ch.getAgeRange(), "") + firstNotBlank(ch.getGender(), "") - : ref.getName(); - sb.append(desc).append("(").append(ref.getName()).append(")"); - // 附加角色视觉描述,确保文字 prompt 也承载外貌信息,不能全靠参考图 - if (ch != null && StrUtil.isNotBlank(ch.getVisualDescription())) { - sb.append(",外貌:").append(ch.getVisualDescription()); - } - // 多参考图模式:附加 @imageN 标记 - if (hasRefImages) { - int imgIdx = findRefImageIndex(refImages, storyboard.getProjectId(), ref.getName(), ref.getAppearance()); - if (imgIdx >= 0) sb.append("@image").append(imgIdx + 1); - } - if (StrUtil.isNotBlank(ref.getSlot())) sb.append("在").append(ref.getSlot()); - } - sb.append("\n"); - } - - // 角色参考图模式下,参考图容易被模型扩散到背景群众造成"所有人一张脸"。 - // 仅 reference-to-video 模式(有参考图)追加身份隔离规则;纯文生视频不需要。 - if (hasRefImages) { - appendCharacterIdentityIsolationPrompt(sb, storyboard, chars, refImages); - } - - // 4. 场景描述(+ @imageN 参考图标记) - if (StrUtil.isNotBlank(storyboard.getLocationName())) { - sb.append("[场景] ").append(storyboard.getLocationName()); - ShortDramaLocation loc = findLocationByName(storyboard.getProjectId(), storyboard.getLocationName()); - if (loc != null && StrUtil.isNotBlank(loc.getSummary())) { - sb.append(" — ").append(loc.getSummary()); - } - // 多参考图模式:附加 @imageN 标记 - if (hasRefImages) { - int imgIdx = findLocationRefImageIndex(refImages, storyboard.getProjectId(), storyboard.getLocationName()); - if (imgIdx >= 0) sb.append("@image").append(imgIdx + 1); - } - sb.append("\n"); - } - - // 5. 表演指导 - if (StrUtil.isNotBlank(storyboard.getActingNotes())) { - try { - JsonNode actingArr = JsonUtils.parseObject(storyboard.getActingNotes(), JsonNode.class); - if (actingArr != null && actingArr.isArray() && !actingArr.isEmpty()) { - sb.append("[表演] "); - for (int i = 0; i < actingArr.size(); i++) { - JsonNode an = actingArr.get(i); - if (i > 0) sb.append(";"); - sb.append(an.path("name").asText("")).append(":") - .append(an.path("acting").asText("")); - } - sb.append("\n"); - } - } catch (Exception e) { log.debug("解析表演指导失败: {}", e.getMessage()); } - } - - // 6. 前后镜头连续性状态 - appendContinuityPrompt(sb, storyboard); - - // 6.1 首帧承接:当存在上一镜末帧 URL 时,提示模型首帧继承末帧画面 - if (StrUtil.isNotBlank(lastFrameUrl)) { - int lastFrameImageIndex = refImages != null ? refImages.indexOf(lastFrameUrl) : -1; - if (lastFrameImageIndex >= 0) { - sb.append("[首帧承接] 当前视频第一帧必须严格继承 @image").append(lastFrameImageIndex + 1) - .append("(上一镜末帧)的人物位置、姿态、朝向、服装、道具和光线方向,") - .append("不得改变人物左右关系或重置场景,动作从该帧状态自然延续。\n"); + bindings.append("的身份与衣装对应@image").append(index + 1).append(";\n"); } } - - // 7. 画面描述(AI 撰写的分镜画面叙述) - if (StrUtil.isNotBlank(storyboard.getSceneText()) - && !storyboard.getSceneText().equals(storyboard.getVideoPrompt())) { - sb.append("[画面] ").append(storyboard.getSceneText()).append("\n"); - } - - // 8. 视觉参考(image_prompt 含光线/氛围/构图细节) - // A reviewed/regenerated keyframe supersedes the original visual draft. - // Re-appending that draft can reset props and framing to the rejected version. - if (StrUtil.isBlank(frame) && StrUtil.isNotBlank(storyboard.getImagePrompt())) { - sb.append("[视觉] ").append(storyboard.getImagePrompt()).append("\n"); - } - - // 9. 目标时长与节奏 - sb.append(visualAssets.readyPropDirection(storyboard, refImages)); - sb.append(ShortDramaVideoDuration.direction(storyboard.getContinuityJson())); - sb.append("\n"); - - // 10. 视觉风格 - String artStyle = ShortDramaImageConstants.artStylePrompt(skillCatalog.effectiveArtStyle(selectedProject)); - if (StrUtil.isNotBlank(artStyle)) { - sb.append("\n[风格] ").append(artStyle); - } - - // 11. 视频描述(核心驱动 prompt) - sb.append("\n").append(storyboard.getVideoPrompt()); - if (StrUtil.isNotBlank(storyboard.getSourceText())) { - sb.append("\n[已审阅剧情与声音原文]\n").append(storyboard.getSourceText()) - .append("\n对白内容、发言人和先后顺序以以上原文为准,不改写、不对调、不新增台词;手机录音只作画外声,不生成说话者实体。"); - } - return sb.toString(); + int location = StrUtil.isBlank(storyboard.getLocationName()) ? -1 + : findLocationRefImageIndex(refImages, storyboard.getProjectId(), storyboard.getLocationName()); + if (location >= 0) bindings.append(storyboard.getLocationName()).append("的空间对应@image").append(location + 1).append(";\n"); + bindings.append(Objects.toString(visualAssets.readyPropDirection(storyboard, refImages), "")); + if (bindings.isEmpty()) return prompt; + return prompt + "\n[参考绑定]\n" + bindings + "身份与器物参考只确定外观,出场时机与动作按导演稿,不复制参考背景或多视图。"; } - /** - * 为分镜统一追加人物身份隔离规则。 - *

- * 参考图只绑定对应具名角色,不能被模型当作群众的通用脸部模板; - * 单人近景进一步限制为仅一张可辨认人脸,多角色镜则要求逐人绑定、互不混脸。 - */ - private void appendCharacterIdentityIsolationPrompt(StringBuilder sb, - ShortDramaStoryboard storyboard, - List chars, - List refImages) { - boolean hasRefImages = refImages != null && !refImages.isEmpty(); - int characterCount = chars == null ? 0 : chars.size(); - 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) { - sb.append("本镜没有具名角色参考。若出现群众,每个人必须具有不同的脸型、五官比例、身高、体态、发型和服装细节;") - .append("禁止复制脸、镜像人物、双胞胎式重复、同一人物贴图和整排相同表情。"); - } else if (characterCount == 1) { - String characterName = chars.get(0).getName(); - sb.append("唯一具名角色为").append(characterName).append(";") - .append(hasRefImages ? "该角色参考图只绑定此人,绝不能扩散给背景人物;" : "角色身份只属于此人;"); - if (closeShot && !visibleExtras) { - sb.append("这是单人近景/特写,画面只允许一张可辨认人脸。其他人物必须留在画外,") - .append("不得出现背景人头、倒影脸、反射脸、画像脸、脸谱幻影或多重曝光;"); - } else { - sb.append(visibleExtras ? "本镜已明确的0秒匿名群演必须按background_extras保留,群众不得复用" : "若剧情必须出现无名群众,群众不得复用").append(characterName) - .append("的五官,后景人物应弱化面部并保持彼此差异;"); - } - sb.append("禁止克隆、分身、镜像脸和同脸不同装。"); - } else { - sb.append("本镜有").append(characterCount).append("名具名角色:"); - for (int i = 0; i < chars.size(); i++) { - if (i > 0) sb.append("、"); - CharacterRef ref = chars.get(i); - sb.append(ref.getName()); - if (hasRefImages) { - int imageIndex = findRefImageIndex(refImages, storyboard.getProjectId(), ref.getName(), ref.getAppearance()); - if (imageIndex >= 0) sb.append("仅绑定@image").append(imageIndex + 1); - } - } - sb.append("。各角色必须保持各自独立五官、年龄、性别、发型和服装,禁止互相混脸、交换五官或把第一张参考脸复制给全员;") - .append("无名群众同样不得复用任何具名角色的脸。画面中不得额外生成角色副本、镜像分身或重复人头。"); - } - sb.append("\n"); - } - - /** 在参考图列表中找到指定角色图片的索引 */ private int findRefImageIndex(java.util.List refImages, Long projectId, String characterName) { return findRefImageIndex(refImages, projectId, characterName, null); } @@ -2452,15 +2383,18 @@ public class ShortDramaServiceImpl implements IShortDramaService { public String uploadReferenceImage(org.springframework.web.multipart.MultipartFile file, String model, Long userId) { if (userId == null) throw new IllegalArgumentException("用户未登录"); if (file == null || file.isEmpty()) throw new IllegalArgumentException("参考图不能为空"); - if (file.getSize() > 10L * 1024 * 1024) throw new IllegalArgumentException("参考图不能超过10MB"); + ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, model, "image", "video"); + if (modelVo == null) throw new IllegalArgumentException("未找到参考素材模型配置: " + model); + int maxMegabytes = modelVo.getModelName().startsWith("bytedance/seedance-2.0-mini/") + || modelVo.getModelName().startsWith("bytedance/seedance-2.5/") ? 30 : 10; + if (file.getSize() >= (long)maxMegabytes * 1024 * 1024) + throw new IllegalArgumentException("当前模型参考图须小于" + maxMegabytes + "MB"); String contentType = firstNotBlank(file.getContentType(), ""); if (!("image/jpeg".equalsIgnoreCase(contentType) || "image/png".equalsIgnoreCase(contentType) || "image/webp".equalsIgnoreCase(contentType))) { throw new IllegalArgumentException("参考图仅支持JPEG、PNG或WebP"); } - ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, model, "image", "video"); - if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + model); try { return imageServiceFactory.getOriginalService(modelVo.getProviderCode()) .uploadMedia(modelVo, file.getBytes(), file.getOriginalFilename(), contentType); @@ -2836,12 +2770,14 @@ public class ShortDramaServiceImpl implements IShortDramaService { 1. 正文禁止 Markdown,不得出现标题井号、星号加粗、引用符、列表符或代码围栏。 2. 每场必须以“中文场次序号+全角空格+地点或事件+时间”开头,例如“一 黑屏。冬。”或“二 县衙二堂。接上。”。 3. JSON的tone按当前故事自动概括风格与基调,包含叙事类型、情绪、节奏及适合角色和场景的视觉方向;以用户明确要求为准,不固定套用其他作品风格。正文只写场景、行动、声音和对白;不写镜号、机位、运镜、逐秒安排、画面比例、生成规则或创作说明。 - 4. 场次标题后直接写自然的动作与环境段落,不加“场景”“行动”“说明”等模板标签。 + 4. 场次标题后直接写自然的动作与环境段落,不加“场景”“行动”“说明”等模板标签。剧名、场次标题、每段动作和每句对白各占独立段落,段落之间空一行;对白与随后动作分开,不能把整场压成一个长段。大纲也按场分段,便于阅读。 5. 保留用户给出的核心人物、冲突、关键转折和结局。每场都要改变剧情状态,场与场之间必须有明确因果承接。 6. 对白符合人物身份和语境,删掉解释剧情的套话、总结句和机械问答。 7. 若用户提供“当前剧本”和“修改意见”,以当前剧本为底稿,只按意见重写;未提及的人物、事实、因果和结局保持一致。 8. 军事内容只写非操作性叙事,不输出武器制造参数或使用教程。 9. 内容规模服从用户的原事件:测试片段、一个简单动作或几句话的场面不扩成多场完整故事,不增加挑战、支线、往返、失败重试或解释对白。未给时长时按原事件自然长度写;有明确短片时长要求时先估算对白与独占动作容量,再写能容纳的剧本,不把长故事写完后交给分镜硬压。 + 10. 除非用户本轮明确指定目标时长,否则剧本名称、大纲、正文不自行填写总分钟数、场次秒数、预计时长或时长预算,也不从旧剧本、参考案例或历史生成参数继承片长。故事里的年代、季节、翌晨和三个月后等剧情时间正常保留。内容长度由当前事件、对白与选择决定,不为默认片长扩写或删减。 + 11. 用户要求从已生成剧情之后续写时,从给定末态继续,只输出尚未生成的部分;已经制作的对白、事件和起止状态作为承接依据,不重复重写。允许修订的穿帮片段应单独处理,不用它推翻其他已制作段落。 除规定的 JSON、分隔符和剧本正文外,不输出任何说明。 """; @@ -2884,18 +2820,31 @@ public class ShortDramaServiceImpl implements IShortDramaService { .replaceAll("(?m)[ \\t]+$", "") .replaceAll("\\n{3,}", "\n\n") .trim(); - return normalized; + return normalized.lines().map(String::strip).filter(line -> !line.isBlank()) + .collect(java.util.stream.Collectors.joining("\n\n")); } - private static String phase1UserPrompt(ShortDramaIdeaBo bo, String revisionInstruction) { + private String phase1UserPrompt(ShortDramaIdeaBo bo, String revisionInstruction) { String projectName = firstNotBlank(bo.getProjectName(), "短剧项目"); String revision = StrUtil.trim(revisionInstruction); + ShortDramaProject selected = phase1SkillProject(bo); + String directions = ShortDramaDirectorSkills.load("story-causality", "emotional-dialogue") + + skillCatalog.selectedWritingDirection(selected); if (StrUtil.isNotBlank(revision)) { return "用户期望项目名:" + projectName + "\n\n当前剧本:\n" + sanitizeIdeaInput(bo.getIdea()) - + "\n\n修改意见:\n" + revision; + + directions + "\n\n修改意见:\n" + revision; } - return "用户期望项目名:" + projectName + "\n\n故事想法:\n" + sanitizeIdeaInput(bo.getIdea()); + return "用户期望项目名:" + projectName + "\n\n故事想法:\n" + sanitizeIdeaInput(bo.getIdea()) + directions; + } + + private static ShortDramaProject phase1SkillProject(ShortDramaIdeaBo bo) { + var selected = new ShortDramaProject(); + selected.setArtStyle(bo.getArtStyle()); + selected.setAestheticSkillName(bo.getAestheticSkillName()); + selected.setDirectorSkillName(bo.getDirectorSkillName()); + selected.setStoryboardSkillNames(bo.getStoryboardSkillNames()); + return selected; } private static ShortDramaScriptResult requireFixedScript(ShortDramaScriptResult result) { @@ -2903,6 +2852,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { throw new RuntimeException("剧本生成失败:模型返回格式异常,请重试"); } result.setScriptText(normalizePlainScriptText(result.getScriptText())); + result.setOutlineText(normalizePlainScriptText(result.getOutlineText())); if (countSceneHeadings(result.getScriptText()) == 0) { throw new RuntimeException("剧本生成失败:正文未按固定场次格式输出,请重试"); } @@ -2912,6 +2862,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { private ShortDramaScriptResult executePhase1_ScriptPolish(ChatModel chatModel, ShortDramaIdeaBo bo, String revisionInstruction, Consumer onPhase) { + var selectedSnapshot = skillCatalog.snapshot(phase1SkillProject(bo)); String prompt = PHASE1_COMBINED_SYSTEM + "\n\n" + phase1UserPrompt(bo, revisionInstruction); long t0 = System.currentTimeMillis(); @@ -2919,6 +2870,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { log.info("Phase 1 剧本生成: elapsed={}ms len={}", System.currentTimeMillis() - t0, resp != null ? resp.length() : 0); + skillCatalog.verify(selectedSnapshot, phase1SkillProject(bo)); ShortDramaScriptResult result = requireFixedScript(parsePhase1Response(resp)); if (onPhase != null) onPhase.accept("outline_done"); return result; @@ -2934,6 +2886,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { private ShortDramaScriptResult executePhase1_Streaming(StreamingChatModel streamingModel, ShortDramaIdeaBo bo, SseEmitter emitter, ShortDramaScriptProgress progress) { String systemPrompt = PHASE1_COMBINED_SYSTEM; + var selectedSnapshot = skillCatalog.snapshot(phase1SkillProject(bo)); String userPrompt = phase1UserPrompt(bo, null); List messages = List.of( SystemMessage.from(systemPrompt), UserMessage.from(userPrompt)); @@ -3044,6 +2997,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { } catch (Exception e) { throw new RuntimeException("剧本生成失败:" + ShortDramaModelErrors.message(e), e); } + skillCatalog.verify(selectedSnapshot, phase1SkillProject(bo)); return requireFixedScript(result[0]); } @@ -3594,6 +3548,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { })); List all=new ArrayList<>(); for(int i=0;i scenes, int index) { + String headings = java.util.stream.IntStream.rangeClosed(0, index) + .mapToObj(i -> "第" + (i + 1) + "场:" + scenes.get(i).lines().filter(s -> !s.isBlank()).findFirst().orElse("")) + .collect(java.util.stream.Collectors.joining("\n")); + String current = scenes.get(index); + return "原剧本场次顺序与时间(接上场按此继承,不从场景母图猜昼夜天气):\n" + headings + + "\n本场原文开头:\n" + current.substring(0, Math.min(current.length(), 600)); + } + static void validateScenePlan(String scene, List panels) { validateScenePlan(scene, panels, 1); } @@ -3640,17 +3605,16 @@ public class ShortDramaServiceImpl implements IShortDramaService { catch (RuntimeException e) { errors.add(e.getMessage()); } } StringBuilder spoken=new StringBuilder(); - var quotes=java.util.regex.Pattern.compile("「([^」]+)」"); for(var p:panels) { - var m=quotes.matcher(firstNotBlank(p.getSourceText(),"")); - while(m.find())spoken.append(m.group(1).replaceAll("[^\\p{IsHan}A-Za-z0-9]","")); + for (String line : ShortDramaVideoPromptReview.sourceDialogue(p.getSourceText())) + spoken.append(line.replaceAll("[^\\p{IsHan}A-Za-z0-9]","")); } - int cursor=0; var original=quotes.matcher(scene); - while(original.find()) { - String line=original.group(1).replaceAll("[^\\p{IsHan}A-Za-z0-9]",""); + int cursor=0; + for (String original : ShortDramaVideoPromptReview.sourceDialogue(scene)) { + String line=original.replaceAll("[^\\p{IsHan}A-Za-z0-9]",""); if(line.isBlank())continue; int at=spoken.indexOf(line,cursor); - if(at<0)errors.add("遗漏或乱序原文对白:「"+original.group(1)+"」;跨镜拆句须保留原字句,source_text只含本镜部分"); + if(at<0)errors.add("遗漏或乱序原文对白:「"+original+"」;跨镜拆句须保留原字句,source_text只含本镜部分"); else cursor=at+line.length(); } return List.copyOf(errors); @@ -3722,6 +3686,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { 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.*"); + // Episode-scene identifiers require screenplay time and interior/exterior tokens. + heading = heading || line.strip().matches("^(?:#{1,6}\\s*)?[0-9]+[--–][0-9]+[\\s ]+(?:拂晓|黎明|清晨|早晨|晨|日|白天|午后|傍晚|黄昏|夜|深夜|接上)[\\s ]+(?:内外景|内景|外景|内外|内|外)[\\s ]+[^::\\n]+$"); // 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); } @@ -4391,17 +4357,19 @@ public class ShortDramaServiceImpl implements IShortDramaService { static String buildStoryboardDetailPrompt(String panelsJson, String charsAgeGender, String locsDesc, String artStyle, String aspectRatio) { - return ShortDramaDirectorSkills.load("cinematic-storyboard", "video-prompt") + """ + return ShortDramaDirectorSkills.load("cinematic-storyboard", "video-prompt") + ShortDramaVideoPromptPolicy.draftingRules() + """ 你负责把已校验分镜落实为可执行的首帧和连续导演描述,不重新规划剧情。 只细化本批给定镜号。shot_design中的focus、framing、axis、movement、motivation、cut_in/out与物理状态是已审阅契约;机位、主动作、重心、持物与声画接点均须落实,不另选姿态或拍法。 原文、角色形象、出镜名单、群演、场地和原对白为权威;无对白时写清实际环境声。后台保留这些原始字段、起止状态、timing与所有资产绑定,无需在输出中重复。 + source_scene_context保留原场次的昼夜、季节与天气;场景资料只是空间和材质参考,不能把原文冬夜改成白天、雨停后重新下雨。逐镜形象清单已经解析到实际衣装,只能使用该镜绑定的版本;本镜受伤或破损后不能在后镜恢复完整。 video_prompt从给定首态展开:建立本镜前中后景和视线、写动作发起及可见结果与反应,具体机位和主导运镜有动机,沿既有光源写光色材质,声音有方向与远近,最后准确交到end_state。按实际演员与事件写自然段,保留原话及声源,不写逐秒清单或通用导演前缀,不用抽象结果摘要代替可拍过程。 + video_prompt固定保存为三个部分:【起始构图】;镜头1起连续编号的导演段;以“当前空间为”开头的光色、声音和末态段。每个部分及编号镜头之间写真实空行,不把收束段并入镜头1。只有实际采用并审阅首帧时改用【首帧实况】。 image_prompt只描述本镜start_state的静态首帧,含具体姿态、接触、位置、人物服装、所见道具、背景与光线;不提前画出结束动作。 每镜保留一个主导运镜。时长与timing用于预算,不能靠多余事件、停顿、换装、额外切镜或删台词填充。沿用原scene/segment身份,不重新分组、增删或重排镜头。 视觉方向:%s 构图画幅:%s 只返回JSON数组,每镜仅补充以下字段,不复述整套原规划: - {"panel_number":1,"shot_type":"平视中景","camera_move":"固定","sceneTitle":"本镜具体关注点","description":"当前动作与结果","video_prompt":"完整连续导演描述","image_prompt":"静态首帧描述"} + {"panel_number":1,"shot_type":"平视中景","camera_move":"固定","sceneTitle":"本镜具体关注点","description":"当前动作与结果","video_prompt":"【起始构图】具体首态。\\n\\n镜头1:具体表演、摄影和原对白。\\n\\n当前空间为具体地点。此刻光源、声音与实际末态。","image_prompt":"静态首帧描述"} 如明确返回start_state/end_state等原始字段,其值必须逐字保持。字符串内对白用「」;JSON必须有效。 已校验规划:%s 本批角色档案:%s @@ -4417,7 +4385,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { 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", + "description", "source_text", "source_scene_context", "scene_type", "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); @@ -4552,7 +4520,18 @@ public class ShortDramaServiceImpl implements IShortDramaService { 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); + var library = new java.util.HashMap<>(characterLibraries(assets, names)); + StringBuilder selected = new StringBuilder(); + for (var panel : panels) for (var binding : assets.referenced(List.of(panel))) { + if (!"appearance".equals(binding.kind())) continue; + var appearance = assets.appearances().stream().filter(a -> Objects.equals(a.getId(), binding.id())).findFirst().orElseThrow(); + selected.append("- 镜头").append(panel.getPanelNumber()).append(",").append(binding.name()) + .append("仅绑定形象[").append(appearance.getAppearanceIndex()).append("]") + .append(firstNotBlank(appearance.getChangeReason(), "已选形象")).append(":") + .append(firstNotBlank(appearance.getDescription(), "沿用该形象参考图")).append("\n"); + } + library.put("appearances", firstNotBlank(selected.toString(), "本批未绑定独立衣装形象;按原文与既有身份描述。")); + return library; } private static java.util.Map characterLibraries(ShortDramaSceneCheckpoint.Assets assets, Set names) { @@ -5005,6 +4984,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { ArrayNode presentCharacters = continuity.putArray("present_characters"); if (panel.getPresentCharacters() != null) panel.getPresentCharacters().forEach(presentCharacters::add); continuity.put("scene_number", panel.getSceneNumber() != null ? panel.getSceneNumber() : 1); + if (StrUtil.isNotBlank(panel.getSourceSceneContext())) continuity.put("source_scene_context", panel.getSourceSceneContext()); continuity.put("segment_number", panel.getSegmentNumber() != null ? panel.getSegmentNumber() : 1); continuity.put("segment_goal", firstNotBlank(panel.getSegmentGoal(), "")); continuity.put("segment_result", firstNotBlank(panel.getSegmentResult(), "")); @@ -5046,6 +5026,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { panel.setPresentCharacters(names); } if (continuity.has("scene_number")) panel.setSceneNumber(continuity.path("scene_number").asInt(1)); + if (continuity.has("source_scene_context")) panel.setSourceSceneContext(continuity.path("source_scene_context").asText()); if (continuity.has("segment_number")) panel.setSegmentNumber(continuity.path("segment_number").asInt(1)); panel.setSegmentGoal(continuity.path("segment_goal").asText(null)); panel.setSegmentResult(continuity.path("segment_result").asText(null)); @@ -5319,6 +5300,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { private String sceneType; @JsonProperty("source_text") private String sourceText; + @JsonProperty("source_scene_context") + private String sourceSceneContext; private Integer duration; private JsonNode timing; @JsonProperty("start_state") @@ -5554,13 +5537,15 @@ public class ShortDramaServiceImpl implements IShortDramaService { skillCatalog.verify(frozen, project); return frozen.direction(); } - return skillCatalog.selected(project, "aesthetic") + skillCatalog.selected(project, "director"); + return skillCatalog.selectedDirection(project); } private ShortDramaProject ideaProject(ShortDramaIdeaBo idea) { var project = new ShortDramaProject(); project.setAestheticSkillName(idea.getAestheticSkillName()); project.setDirectorSkillName(idea.getDirectorSkillName()); + project.setStoryboardSkillNames(idea.getStoryboardSkillNames()); project.setArtStyle(idea.getArtStyle()); return project; } private void validateSkillBindings(ShortDramaProject project) { + project.setStoryboardSkillNames(skillCatalog.normalizeStoryboardSkills(project.getStoryboardSkillNames())); if (StrUtil.isNotBlank(project.getAestheticSkillName())) { var skill = skillCatalog.requireEnabled(project.getAestheticSkillName(), "aesthetic"); if (StrUtil.isNotBlank(skill.artStyle())) project.setArtStyle(skill.artStyle()); 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 index 0ff19ffe..faf1978b 100644 --- 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 @@ -5,6 +5,7 @@ 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.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.stereotype.Service; import java.io.IOException; @@ -41,7 +42,7 @@ public class ShortDramaSkillCatalog { 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")); + return new Snapshot(project == null ? null : project.getId(), snapshotVersion(project), selectedDirection(project)); } public synchronized void verify(Snapshot snapshot, ShortDramaProject project) { if (!Objects.equals(snapshot.version(), snapshotVersion(project))) @@ -54,7 +55,13 @@ public class ShortDramaSkillCatalog { 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")) { + var resolver = new PathMatchingResourcePatternResolver(getClass().getClassLoader()); + var manifests = resolver.getResources("classpath*:short-drama/skill-catalog/*/manifest.json"); + Arrays.sort(manifests, Comparator.comparing(resource -> resource.getDescription())); + for (var manifest : manifests) { + var manifestUrl = manifest.getURL().toString(); + String name = safeName(manifestUrl.substring(manifestUrl.indexOf("short-drama/skill-catalog/") + + "short-drama/skill-catalog/".length()).split("/")[0]); 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(); @@ -67,8 +74,8 @@ public class ShortDramaSkillCatalog { 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(""), + writeNew(new Save(name, metadata.path("title").asText(), canonicalType(name, metadata.path("type").asText()), + metadata.path("description").asText(), metadata.path("enabled").asBoolean(true), metadata.path("artStyle").asText(""), new String(body.readAllBytes(), StandardCharsets.UTF_8), references, null)); } } @@ -77,7 +84,7 @@ public class ShortDramaSkillCatalog { } public synchronized List