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