From 91b6b53ce9f39b249286ebee48fc72365b491d55 Mon Sep 17 00:00:00 2001 From: ageerle Date: Wed, 30 Sep 2026 14:54:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E7=9F=AD=E5=89=A7?= =?UTF-8?q?=E5=AF=BC=E6=BC=94=E8=A7=84=E5=88=99=E4=B8=8E=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=8F=8A=E5=90=88=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../2026-09-30-short-drama-visual-asset.sql | 21 + .../chat/entity/image/ImageContext.java | 2 + .../shortdrama/ShortDramaController.java | 22 +- .../ShortDramaMaterialController.java | 186 ++++ .../shortdrama/ShortDramaSoundController.java | 21 + .../ShortDramaVisualAssetController.java | 22 + .../bo/shortdrama/ShortDramaIdeaBo.java | 3 + .../shortdrama/ShortDramaReviewedPlanBo.java | 14 + .../bo/shortdrama/ShortDramaRevisionBo.java | 18 + .../shortdrama/ShortDramaVisualAsset.java | 26 + .../ShortDramaVisualAssetMapper.java | 4 + .../AtlasImageGenerationServiceImpl.java | 25 + .../service/media/AtlasPredictionService.java | 12 + .../shortdrama/IShortDramaService.java | 6 +- .../composition/CompositionSource.java | 13 +- .../composition/FfmpegVideoComposer.java | 4 +- .../impl/ShortDramaDirectorSkills.java | 29 + .../shortdrama/impl/ShortDramaParallel.java | 27 + .../impl/ShortDramaRevisionService.java | 88 ++ .../impl/ShortDramaServiceImpl.java | 899 ++++++++++++------ .../impl/ShortDramaSoundService.java | 96 ++ .../shortdrama/impl/ShortDramaTiming.java | 44 + .../impl/ShortDramaVideoComposeWorker.java | 22 +- .../impl/ShortDramaVisualAssetService.java | 410 ++++++++ .../AtlasVideoGenerationServiceImpl.java | 20 +- .../skills/director-blocking/SKILL.md | 16 + .../skills/director-review/SKILL.md | 11 + .../skills/emotional-dialogue/SKILL.md | 35 + .../short-drama/skills/real-material/SKILL.md | 11 + .../skills/story-causality/SKILL.md | 12 + .../skills/video-continuity/SKILL.md | 18 + .../short-drama/skills/visual-world/SKILL.md | 12 + .../shortdrama/ShortDramaMaterialTest.java | 29 + .../AtlasImageGenerationServiceImplTest.java | 23 +- .../media/AtlasPredictionServiceTest.java | 8 + .../CompositionPlannedDurationTest.java | 13 + .../ShortDramaAppearanceContinuityTest.java | 37 + .../impl/ShortDramaContinuityTest.java | 110 +++ .../impl/ShortDramaDirectorSkillsTest.java | 73 ++ .../impl/ShortDramaParallelTest.java | 24 + .../impl/ShortDramaRevisionServiceTest.java | 35 + .../shortdrama/impl/ShortDramaTimingTest.java | 75 ++ .../impl/ShortDramaVideoContinuityTest.java | 62 ++ .../impl/ShortDramaVisualAssetTest.java | 73 ++ .../provider/AtlasVideoDurationTest.java | 14 + 46 files changed, 2401 insertions(+), 325 deletions(-) create mode 100644 docs/script/sql/update/2026-09-30-short-drama-visual-asset.sql create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaMaterialController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaSoundController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaVisualAssetController.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaReviewedPlanBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaRevisionBo.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaVisualAsset.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaVisualAssetMapper.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaDirectorSkills.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaParallel.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaRevisionService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSoundService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaTiming.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVisualAssetService.java create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/director-blocking/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/director-review/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/emotional-dialogue/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/real-material/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/story-causality/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/video-continuity/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/visual-world/SKILL.md create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/controller/shortdrama/ShortDramaMaterialTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/CompositionPlannedDurationTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAppearanceContinuityTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaContinuityTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaDirectorSkillsTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaParallelTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaRevisionServiceTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaTimingTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoContinuityTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVisualAssetTest.java create mode 100644 ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/video/provider/AtlasVideoDurationTest.java diff --git a/.gitignore b/.gitignore index 5989fdc8..f28a83c1 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ logs/ .flattened-pom.xml /.claude/settings.local.json /docs/docker/milvus/volumes/ +/.codex-runtime/ diff --git a/docs/script/sql/update/2026-09-30-short-drama-visual-asset.sql b/docs/script/sql/update/2026-09-30-short-drama-visual-asset.sql new file mode 100644 index 00000000..1f309498 --- /dev/null +++ b/docs/script/sql/update/2026-09-30-short-drama-visual-asset.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS short_drama_visual_asset ( + id bigint NOT NULL PRIMARY KEY, + project_id bigint NOT NULL, + tenant_id varchar(20) NOT NULL DEFAULT '000000', + storyboard_id bigint DEFAULT NULL, + asset_key varchar(180) NOT NULL, + kind varchar(24) NOT NULL, + title varchar(255) NOT NULL, + prompt longtext, + reference_images longtext, + source_hash varchar(64), + status varchar(24) NOT NULL DEFAULT 'pending', + image_url text, + model varchar(255), + prediction_id varchar(255), + error_message text, + create_time datetime DEFAULT CURRENT_TIMESTAMP, + update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_project_asset (project_id,asset_key), + KEY idx_storyboard (storyboard_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='道具资产和逐镜关键帧,可续跑'; diff --git a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/image/ImageContext.java b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/image/ImageContext.java index ce6302c0..e57b86f1 100644 --- a/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/image/ImageContext.java +++ b/ruoyi-common/ruoyi-common-chat/src/main/java/org/ruoyi/common/chat/entity/image/ImageContext.java @@ -47,4 +47,6 @@ public class ImageContext { * 参考图 URL(图生图用) */ private String image; + /** Ordered reference images for multi-subject image editing. */ + private java.util.List referenceImages; } 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 6ecd69a3..8e6e2a65 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 @@ -63,6 +63,14 @@ public class ShortDramaController { private final OssService ossService; + private final org.ruoyi.service.shortdrama.impl.ShortDramaRevisionService revisionService; + + @PostMapping("/{projectId}/revision") + public R applyRevision(@PathVariable Long projectId, + @Valid @RequestBody org.ruoyi.domain.bo.shortdrama.ShortDramaRevisionBo revision) { + return R.ok(revisionService.apply(projectId, revision, LoginHelper.getUserId())); + } + // ==================== 项目 ==================== @GetMapping("/projects") @@ -174,8 +182,8 @@ public class ShortDramaController { /** Phase 1: 剧本打磨 */ @PostMapping("/{projectId}/polish-script") - public R polishScript(@NotNull @PathVariable Long projectId) { - return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId())); + public R polishScript(@NotNull @PathVariable Long projectId, @RequestParam(required = false) String model) { + return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId(), model)); } // ==================== 资产分析 ==================== @@ -183,8 +191,8 @@ public class ShortDramaController { /** Phase 2: 资产分析(角色+场景提取) */ @PostMapping("/{projectId}/analyze-assets") public R analyzeAssets(@NotNull @PathVariable Long projectId, - @NotNull @RequestParam Long scriptId) { - return R.ok(shortDramaService.analyzeAssets(projectId, scriptId, LoginHelper.getUserId())); + @NotNull @RequestParam Long scriptId, @RequestParam(required = false) String model) { + return R.ok(shortDramaService.analyzeAssets(projectId, scriptId, LoginHelper.getUserId(), model)); } // ==================== 分镜流水线 ==================== @@ -198,6 +206,12 @@ public class ShortDramaController { } /** Phase 3-6: SSE 流式生成分镜,持续推送规划和细化进度 */ + @PostMapping("/{projectId}/plan-storyboard/review") + public R importReviewedPlan(@PathVariable Long projectId, + @Valid @RequestBody org.ruoyi.domain.bo.shortdrama.ShortDramaReviewedPlanBo review) { + return R.ok(shortDramaService.importReviewedPlan(projectId,review,LoginHelper.getUserId())); + } + @PostMapping("/{projectId}/plan-storyboard/stream") public SseEmitter planStoryboardStream(@NotNull @PathVariable Long projectId, @NotNull @RequestParam Long scriptId, diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaMaterialController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaMaterialController.java new file mode 100644 index 00000000..412fe408 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaMaterialController.java @@ -0,0 +1,186 @@ +package org.ruoyi.controller.shortdrama; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import org.ruoyi.common.core.domain.R; +import org.ruoyi.common.satoken.utils.LoginHelper; +import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard; +import org.ruoyi.mapper.shortdrama.ShortDramaProjectMapper; +import org.ruoyi.mapper.shortdrama.ShortDramaStoryboardMapper; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import javax.imageio.ImageIO; +import java.io.ByteArrayInputStream; +import java.nio.file.*; +import java.util.*; + +/** Original screen captures are stored locally and never sent through an image model. */ +@RestController @RequiredArgsConstructor +@RequestMapping("/short-drama/{projectId}/materials") +public class ShortDramaMaterialController { + private final ShortDramaProjectMapper projects; + private final ShortDramaStoryboardMapper boards; + private static final ObjectMapper JSON = new ObjectMapper(); + private void owner(Long id) { + var p=projects.selectById(id); + if(p==null || !Objects.equals(p.getUserId(),LoginHelper.getUserId())) throw new IllegalArgumentException("项目不存在或无权限"); + } + private Path directory(Long project) { return Path.of("data","short-drama-materials",project.toString()).toAbsolutePath().normalize(); } + private ShortDramaStoryboard editableShot(Long projectId, Long shotId) { + owner(projectId); + var shot = boards.selectById(shotId); + if (shot == null || !projectId.equals(shot.getProjectId())) throw new IllegalArgumentException("镜头不属于当前项目"); + if (!"pending".equals(shot.getVideoStatus())) throw new IllegalStateException("请先停止该镜头的视频任务"); + return shot; + } + static com.fasterxml.jackson.databind.node.ArrayNode items(ObjectNode continuity) { + var media = continuity.path("source_media"); + if (media.path("items").isArray()) return ((com.fasterxml.jackson.databind.node.ArrayNode) media.path("items")).deepCopy(); + var result = JSON.createArrayNode(); + if (media.hasNonNull("url")) result.add(media.deepCopy()); + return result; + } + static void setItems(ObjectNode continuity, com.fasterxml.jackson.databind.node.ArrayNode items) { + if (items.isEmpty()) { continuity.remove("source_media"); return; } + ObjectNode media = ((ObjectNode) items.get(0)).deepCopy(); + media.remove("items"); + media.put("mode", "direct_insert"); + media.set("items", items); + continuity.set("source_media", media); + } + private void save(ShortDramaStoryboard shot, ObjectNode continuity) { + // Update only material metadata; do not overwrite unrelated shot fields. + boards.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(ShortDramaStoryboard::getId, shot.getId()).set(ShortDramaStoryboard::getContinuityJson, continuity.toString())); + } + @PostMapping(value="/{shotId}",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + public synchronized R upload(@PathVariable Long projectId, @PathVariable Long shotId, + @RequestPart("file") MultipartFile[] files, @RequestParam(required=false) String replaceUrl) throws Exception { + var shot = editableShot(projectId, shotId); + ObjectNode continuity = (ObjectNode) JSON.readTree(Objects.toString(shot.getContinuityJson(), "{}")); + var list = items(continuity); + int replace = -1; + if (replaceUrl != null) { + for (int i=0; i 20 || (replace < 0 && list.size()+files.length > 20)) + throw new IllegalArgumentException("每个镜头最多20张素材"); + // Validate the whole selection before storing or changing any bindings. + List contents = new ArrayList<>(); + for (var file : files) { + if (file.isEmpty() || file.getSize()>10*1024*1024) throw new IllegalArgumentException("请选择10MB以内的PNG或JPEG图片"); + byte[] bytes = file.getBytes(); + if (!Set.of("image/png", "image/jpeg").contains(Objects.toString(file.getContentType(), "")) || ImageIO.read(new ByteArrayInputStream(bytes))==null) + throw new IllegalArgumentException("图片格式无效:"+file.getOriginalFilename()); + contents.add(bytes); + } + Path dir=directory(projectId); Files.createDirectories(dir); + for (int i=0; i= 0 ? ((ObjectNode)list.get(replace)).deepCopy() : JSON.createObjectNode(); + image.remove("items"); + image.put("url", "/short-drama/"+projectId+"/materials/file/"+name); + image.put("filename", Objects.toString(file.getOriginalFilename(), "真实素材")); + image.put("type", type); + if (replace >= 0) list.set(replace, image); else list.add(image); + } + setItems(continuity, list); save(shot, continuity); + return R.ok(continuity.get("source_media")); + } + @DeleteMapping("/{shotId}") + public synchronized R remove(@PathVariable Long projectId, @PathVariable Long shotId, @RequestParam String url) throws Exception { + var shot=editableShot(projectId, shotId); + ObjectNode continuity=(ObjectNode)JSON.readTree(Objects.toString(shot.getContinuityJson(),"{}")); + var list=items(continuity); + for (int i=list.size()-1; i>=0; i--) if (url.equals(list.get(i).path("url").asText())) list.remove(i); + setItems(continuity, list); save(shot, continuity); + // Unbind only. Original files remain available for recovery. + return R.ok(continuity.get("source_media")); + } + @GetMapping("/file/{name}") + public ResponseEntity content(@PathVariable Long projectId,@PathVariable String name) throws Exception { + owner(projectId); + if(!name.matches("[a-f0-9-]{36}\\.(png|jpg)")) return ResponseEntity.badRequest().build(); + Path file=directory(projectId).resolve(name).normalize(); + if(!file.startsWith(directory(projectId)) || !Files.isRegularFile(file)) return ResponseEntity.notFound().build(); + return ResponseEntity.ok().contentType(name.endsWith(".png")?MediaType.IMAGE_PNG:MediaType.IMAGE_JPEG).header("Cache-Control","private, max-age=3600").body(Files.readAllBytes(file)); + } + + @GetMapping("/video/{id}") + public ResponseEntity video(@PathVariable Long projectId,@PathVariable String id) { + owner(projectId);if(!id.matches("[a-f0-9-]{36}"))return ResponseEntity.badRequest().build(); + var file=directory(projectId).resolve(id+".mp4");if(!Files.isRegularFile(file))return ResponseEntity.notFound().build(); + return ResponseEntity.ok().contentType(MediaType.valueOf("video/mp4")).body(new org.springframework.core.io.FileSystemResource(file)); + } + @PostMapping(value="/{shotId}/video", consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + public synchronized R importVideo(@PathVariable Long projectId, @PathVariable Long shotId, + @RequestPart("file") MultipartFile upload, @RequestParam String expectedVideoId) throws Exception { + owner(projectId); + var shot=boards.selectById(shotId); + if(shot==null || !projectId.equals(shot.getProjectId()))throw new IllegalArgumentException("镜头不属于当前项目"); + if("generating".equals(shot.getVideoStatus()) || !Objects.equals(expectedVideoId,Objects.toString(shot.getVideoId(),""))) + throw new IllegalArgumentException("视频状态已变化,请刷新后重试"); + var project=projects.selectById(projectId); + if(Set.of("pending","processing").contains(Objects.toString(project.getComposeStatus(),"")))throw new IllegalArgumentException("请等待成片合成结束"); + if(upload.isEmpty() || upload.getSize()>19*1024*1024)throw new IllegalArgumentException("请选择19MB以内的视频"); + Path dir=directory(projectId);Files.createDirectories(dir); + String id=UUID.randomUUID().toString();Path input=dir.resolve(id+".upload"),output=dir.resolve(id+".mp4"),probe=dir.resolve(id+".probe"); + try { + upload.transferTo(input); + org.ruoyi.service.shortdrama.impl.ShortDramaSoundService.run(List.of("ffprobe","-v","error","-show_entries","format=duration:stream=codec_type,width,height","-of","json",input.toString()),probe); + var info=JSON.readTree(Files.readString(probe));double duration=info.path("format").path("duration").asDouble(-1); + boolean video=false,audio=false; + for(var stream:info.path("streams")) {if("video".equals(stream.path("codec_type").asText())){video=true;if(stream.path("width").asInt()>3840 || stream.path("height").asInt()>3840)throw new IllegalArgumentException("视频尺寸超过4K");}if("audio".equals(stream.path("codec_type").asText()))audio=true;} + if(!video || !audio || !Double.isFinite(duration) || Math.abs(duration-shot.getDurationSeconds())>0.25)throw new IllegalArgumentException("视频须包含音轨,且时长与本镜预算一致(允许0.25秒误差)"); + org.ruoyi.service.shortdrama.impl.ShortDramaSoundService.run(List.of("ffmpeg","-v","error","-y","-i",input.toString(),"-map","0:v:0","-map","0:a:0","-t",shot.getDurationSeconds().toString(),"-c:v","libx264","-preset","fast","-crf","18","-pix_fmt","yuv420p","-c:a","aac","-movflags","+faststart",output.toString()),dir.resolve(id+".log")); + String url="/short-drama/"+projectId+"/materials/video/"+id; + var update=new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper().eq(ShortDramaStoryboard::getId,shotId) + .eq(ShortDramaStoryboard::getVideoStatus,shot.getVideoStatus()); + if(shot.getVideoId()==null)update.isNull(ShortDramaStoryboard::getVideoId);else update.eq(ShortDramaStoryboard::getVideoId,shot.getVideoId()); + int changed=boards.update(null,update.set(ShortDramaStoryboard::getVideoUrl,url).set(ShortDramaStoryboard::getVideoId,"material:"+id) + .set(ShortDramaStoryboard::getVideoStatus,"done").set(ShortDramaStoryboard::getLastFrameUrl,null).set(ShortDramaStoryboard::getUpdateTime,new Date())); + if(changed!=1)throw new IllegalStateException("镜头已更新,修订片段未覆盖当前版本"); + return R.ok(Map.of("videoUrl",url)); + } finally {Files.deleteIfExists(input);} + } + @PostMapping("/{shotId}/render") + public synchronized R render(@PathVariable Long projectId,@PathVariable Long shotId) throws Exception { + owner(projectId);var shot=boards.selectById(shotId); + if(shot==null || !projectId.equals(shot.getProjectId()) || "generating".equals(shot.getVideoStatus()))throw new IllegalArgumentException("镜头不存在或正在生成"); + var continuity=(ObjectNode)JSON.readTree(Objects.toString(shot.getContinuityJson(),"{}")); + if(!"direct_insert".equals(continuity.path("source_media").path("mode").asText()))throw new IllegalArgumentException("仅支持真实素材镜头"); + var images=items(continuity);if(images.isEmpty())throw new IllegalArgumentException("请先上传真实素材"); + Path dir=directory(projectId);String job=UUID.randomUUID().toString(); + List command=new ArrayList<>(List.of("ffmpeg","-v","error","-y"));List filters=new ArrayList<>();StringBuilder concat=new StringBuilder(); + double duration=shot.getDurationSeconds(), part=duration/images.size(); + for(int i=0;i().eq(ShortDramaStoryboard::getId,shotId) + .set(ShortDramaStoryboard::getVideoUrl,videoUrl).set(ShortDramaStoryboard::getVideoStatus,"done").set(ShortDramaStoryboard::getVideoId,"material:"+job)); + return R.ok(Map.of("videoUrl",videoUrl,"duration",duration)); + } + static String cropFilter(com.fasterxml.jackson.databind.JsonNode crop) { + if(crop.isMissingNode() || crop.isNull())return ""; + if(!crop.isObject())throw new IllegalArgumentException("素材裁切区域格式无效"); + double x=crop.path("x").asDouble(-1),y=crop.path("y").asDouble(-1),w=crop.path("width").asDouble(-1),h=crop.path("height").asDouble(-1); + if(!Double.isFinite(x+y+w+h) || x<0 || y<0 || w<=0 || h<=0 || x+w>1.00001 || y+h>1.00001)throw new IllegalArgumentException("素材裁切区域须在原图内"); + return "crop=iw*"+w+":ih*"+h+":iw*"+x+":ih*"+y+","; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaSoundController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaSoundController.java new file mode 100644 index 00000000..fdc92d18 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaSoundController.java @@ -0,0 +1,21 @@ +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.ShortDramaSoundService; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.nio.file.Files; +import java.util.List; +@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}/sounds") +public class ShortDramaSoundController { + private final ShortDramaSoundService service; + @GetMapping public R list(@PathVariable Long projectId) throws Exception {service.owner(projectId,LoginHelper.getUserId());return R.ok(service.list(projectId));} + @PostMapping public R upload(@PathVariable Long projectId,@RequestPart MultipartFile file,@RequestParam String purpose,@RequestParam List shots,@RequestParam(defaultValue="0.12") double volume,@RequestParam(defaultValue="0") double offset) throws Exception { + return R.ok(service.upload(projectId,LoginHelper.getUserId(),file,purpose,shots,volume,offset)); + } + @GetMapping("/{id}") public ResponseEntity audio(@PathVariable Long projectId,@PathVariable String id) throws Exception { + service.owner(projectId,LoginHelper.getUserId());return ResponseEntity.ok().contentType(MediaType.valueOf("audio/mpeg")).body(Files.readAllBytes(service.file(projectId,id))); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaVisualAssetController.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaVisualAssetController.java new file mode 100644 index 00000000..30b8d312 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/controller/shortdrama/ShortDramaVisualAssetController.java @@ -0,0 +1,22 @@ +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.ShortDramaVisualAssetService; +import org.springframework.web.bind.annotation.*; +import java.util.Map; + +@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}/visual-assets") +public class ShortDramaVisualAssetController { + private final ShortDramaVisualAssetService service; + public record ReviseFrame(String prompt,String model) { } + @PostMapping("/{assetId}/revise") public R> revise(@PathVariable Long projectId,@PathVariable Long assetId,@RequestBody ReviseFrame body) {return R.ok(service.reviseFrame(projectId,assetId,body.prompt(),body.model(),LoginHelper.getUserId()));} + public record Prop(String title,String prompt,java.util.List shotNumbers,String imageUrl) { } + @PostMapping("/prop") public R> prop(@PathVariable Long projectId,@RequestBody Prop body) {return R.ok(service.saveProp(projectId,body.title(),body.prompt(),body.shotNumbers(),body.imageUrl(),LoginHelper.getUserId()));} + @PostMapping("/generate-opening") public R> opening(@PathVariable Long projectId,@RequestParam String model) {return R.ok(service.generateOpening(projectId,model,LoginHelper.getUserId()));} + @PostMapping("/generate-range") public R> range(@PathVariable Long projectId,@RequestParam String model,@RequestParam int start,@RequestParam int end) {return R.ok(service.generateRange(projectId,model,LoginHelper.getUserId(),start,end));} + @GetMapping public R> status(@PathVariable Long projectId) {return R.ok(service.status(projectId,LoginHelper.getUserId()));} + @PostMapping("/plan") public R> plan(@PathVariable Long projectId,@RequestParam(required=false) String model) {return R.ok(service.plan(projectId,model,LoginHelper.getUserId()));} + @PostMapping("/recover") public R> recover(@PathVariable Long projectId) {return R.ok(service.recover(projectId,LoginHelper.getUserId()));} + @PostMapping("/generate") public R> generate(@PathVariable Long projectId,@RequestParam String model) {return R.ok(service.generate(projectId,model,LoginHelper.getUserId()));} +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaIdeaBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaIdeaBo.java index b6eeb4f7..b99d91a0 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,4 +17,7 @@ public class ShortDramaIdeaBo { private String artStyle; private String aspectRatio; + + /** Stop after script so the author can review before asset/shot planning. */ + private Boolean scriptOnly = false; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaReviewedPlanBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaReviewedPlanBo.java new file mode 100644 index 00000000..7eed97c2 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaReviewedPlanBo.java @@ -0,0 +1,14 @@ +package org.ruoyi.domain.bo.shortdrama; + +import jakarta.validation.constraints.*; +import lombok.Data; +import java.util.List; +import org.ruoyi.service.shortdrama.impl.ShortDramaServiceImpl.StoryboardPanelData; + +@Data +public class ShortDramaReviewedPlanBo { + @NotNull private Long scriptId; + @NotBlank private String expectedScriptText; + private String model; + @NotEmpty @Size(max=500) private List panels; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaRevisionBo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaRevisionBo.java new file mode 100644 index 00000000..3391a1a6 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/shortdrama/ShortDramaRevisionBo.java @@ -0,0 +1,18 @@ +package org.ruoyi.domain.bo.shortdrama; + +import lombok.Data; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +@Data +public class ShortDramaRevisionBo { + @NotNull private Long scriptId; + @NotBlank private String expectedScriptText; + @NotBlank private String scriptText; + private String outlineText; + @NotEmpty private List storyboards; + private List characters; + private List locations; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaVisualAsset.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaVisualAsset.java new file mode 100644 index 00000000..5d339590 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/shortdrama/ShortDramaVisualAsset.java @@ -0,0 +1,26 @@ +package org.ruoyi.domain.entity.shortdrama; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.util.Date; + +@Data +@TableName("short_drama_visual_asset") +public class ShortDramaVisualAsset { + @TableId private Long id; + private Long projectId; + private Long storyboardId; + private String assetKey; + private String kind; + private String title; + private String prompt; + private String referenceImages; + private String sourceHash; + private String status; + private String imageUrl; + private String model; + private String predictionId; + private String errorMessage; + private Date createTime; + private Date updateTime; +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaVisualAssetMapper.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaVisualAssetMapper.java new file mode 100644 index 00000000..58b4130b --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/mapper/shortdrama/ShortDramaVisualAssetMapper.java @@ -0,0 +1,4 @@ +package org.ruoyi.mapper.shortdrama; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.ruoyi.domain.entity.shortdrama.ShortDramaVisualAsset; +public interface ShortDramaVisualAssetMapper extends BaseMapper { } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java index 1d7a478e..3d2daf42 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImpl.java @@ -118,6 +118,12 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ payload.put("output_format", "jpeg"); payload.put("quality", "high"); payload.put("moderation", "low"); + if (modelName.startsWith("openai/gpt-image-2.5-")) { + payload.remove("enable_base64_output"); + payload.remove("moderation"); + payload.put("background", "opaque"); + payload.put("n", 1); + } } if (StrUtil.isNotBlank(image)) { if (gptImageModel) { @@ -139,6 +145,12 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ if (StrUtil.isBlank(size)) return null; String normalizedSize = size.trim(); if (isGptImageModel(modelName)) { + if (modelName.startsWith("openai/gpt-image-2/") || modelName.startsWith("openai/gpt-image-2.5-")) { + if ("16:9".equals(normalizedSize)) return "1536x864"; + if ("9:16".equals(normalizedSize)) return "864x1536"; + if ("4:3".equals(normalizedSize)) return "1536x1152"; + if ("3:4".equals(normalizedSize)) return "1152x1536"; + } String mappedSize = GPT_IMAGE_SIZE_MAP.get(normalizedSize); if (mappedSize != null) return mappedSize; return resolveGptImagePixelSize(normalizedSize); @@ -170,6 +182,7 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ ObjectNode payload = buildPayload(chatModelVo, prompt, size, seed, image); payload.put("enable_sync_mode", false); + applyReferenceImages(payload, imageContext); Request request = new Request.Builder() .url(AtlasMediaSupport.endpoint(chatModelVo.getApiHost(), "/model/generateImage")) @@ -204,6 +217,18 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ } } + static void applyReferenceImages(ObjectNode payload, ImageContext context) { + var references = context.getReferenceImages(); + if (references == null || references.isEmpty()) return; + int limit = context.getChatModelVo().getModelName().startsWith("openai/gpt-image-2.5-") ? 16 : 10; + if (references.size() > limit) throw new IllegalArgumentException("当前模型最多绑定" + limit + "张参考图,请减少不必要道具"); + if (!isGptImageModel(context.getChatModelVo().getModelName())) { + throw new IllegalArgumentException("当前多参考图资产流程需要支持 images 的 GPT Image 模型"); + } + var images = payload.putArray("images"); + references.stream().filter(StrUtil::isNotBlank).distinct().forEach(images::add); + } + @Override public String uploadMedia(ChatModelVo model, byte[] content, String fileName, String contentType) { if (content == null || content.length == 0) { diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java index ef720466..ef86e2aa 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/media/AtlasPredictionService.java @@ -50,6 +50,10 @@ public class AtlasPredictionService { .build(); } if (!response.isSuccessful()) { + if (response.code() == 400 || response.code() == 422) { + MediaGenerationResponse terminal = terminalFailure(responseText, predictionId, mediaType(model.getCategory())); + if (terminal != null) return terminal; + } throw new IllegalArgumentException("Atlas Cloud生成结果查询失败: " + response.code() + " - " + responseText); } return toResponse(responseText, mediaType(model.getCategory())); @@ -58,6 +62,14 @@ public class AtlasPredictionService { } } + MediaGenerationResponse terminalFailure(String raw, String predictionId, String type) throws IOException { + JsonNode data; + try { data = AtlasMediaSupport.OBJECT_MAPPER.readTree(raw).path("data"); } + catch (com.fasterxml.jackson.core.JsonProcessingException e) { return null; } + if (!predictionId.equals(data.path("id").asText()) || !"failed".equals(data.path("status").asText())) return null; + return toResponse(raw, type); + } + public MediaGenerationResponse toResponse(String raw, String type) throws IOException { JsonNode root = AtlasMediaSupport.OBJECT_MAPPER.readTree(raw); JsonNode data = root.path("data"); 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 751a71ec..f6bdf0e2 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 @@ -51,10 +51,10 @@ public interface IShortDramaService { List generateAllVideos(Long projectId, String videoModel, Long userId); - ShortDramaDetailVo analyzeAssets(Long projectId, Long scriptId, Long userId); + ShortDramaDetailVo analyzeAssets(Long projectId, Long scriptId, Long userId, String model); /** Phase 1: 剧本打磨,重新生成更丰富的剧本内容 */ - ShortDramaDetailVo polishScript(Long projectId, Long userId); + ShortDramaDetailVo polishScript(Long projectId, Long userId, String model); /** Phase 3-6: 分镜规划+摄影规则+表演指导+分镜细化 */ List planStoryboard(Long projectId, Long scriptId, String model, Long userId); @@ -62,6 +62,8 @@ public interface IShortDramaService { /** Phase 3-6: 流式生成分镜并推送实时进度 */ SseEmitter planStoryboardStream(Long projectId, Long scriptId, String model, Long userId); + int importReviewedPlan(Long projectId, org.ruoyi.domain.bo.shortdrama.ShortDramaReviewedPlanBo review, Long userId); + /** Phase 4: 重新生成摄影规则 */ List generatePhotographyRules(Long projectId, Long scriptId, Long userId); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSource.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSource.java index ccc1fe0a..494b006f 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSource.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/CompositionSource.java @@ -3,7 +3,9 @@ package org.ruoyi.service.shortdrama.composition; import java.nio.file.Path; import java.util.Objects; -public record CompositionSource(Path path, Double fallbackDurationSeconds) { +public record CompositionSource(Path path, Double fallbackDurationSeconds, Double plannedDurationSeconds) { + + public CompositionSource(Path path, Double fallbackDurationSeconds) { this(path, fallbackDurationSeconds, null); } public CompositionSource { path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); @@ -11,5 +13,14 @@ public record CompositionSource(Path path, Double fallbackDurationSeconds) { && (!Double.isFinite(fallbackDurationSeconds) || fallbackDurationSeconds <= 0)) { throw new IllegalArgumentException("fallbackDurationSeconds must be positive"); } + if (plannedDurationSeconds != null && (!Double.isFinite(plannedDurationSeconds) || plannedDurationSeconds <= 0)) { + throw new IllegalArgumentException("plannedDurationSeconds must be positive"); + } + } + + MediaInfo forTimeline(MediaInfo media) { + if (plannedDurationSeconds == null) return media; + if (media.durationSeconds() + 0.15 < plannedDurationSeconds) throw new IllegalArgumentException("源视频短于镜头预算,请修订或补齐视频"); + return new MediaInfo(plannedDurationSeconds, media.width(), media.height(), media.hasAudio(), media.videoStreamIndex()); } } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegVideoComposer.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegVideoComposer.java index 49727e0c..e6cf2ca5 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegVideoComposer.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/composition/FfmpegVideoComposer.java @@ -96,11 +96,11 @@ public class FfmpegVideoComposer { List result = new ArrayList<>(spec.sources().size()); for (int index = 0; index < spec.sources().size(); index++) { CompositionSource source = spec.sources().get(index); - result.add(mediaProbe.probe( + result.add(source.forTimeline(mediaProbe.probe( source.path(), source.fallbackDurationSeconds(), workDir.resolve("probe-input-" + jobToken + "-" + index + ".log") - )); + ))); } return result; } 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 new file mode 100644 index 00000000..9fe86645 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaDirectorSkills.java @@ -0,0 +1,29 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.springframework.core.io.ClassPathResource; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** Versioned, bundled production skills. Loaded by default, independent of tool calling. */ +public final class ShortDramaDirectorSkills { + private ShortDramaDirectorSkills() {} + public static final String VERSION = "director-emotion-v2"; + private static final Map CACHE = new ConcurrentHashMap<>(); + public static String load(String... names) { + StringBuilder result = new StringBuilder("【默认导演业务规范:在保持输出格式和用户意图的前提下优先遵循】\n"); + for (String name : names) result.append(CACHE.computeIfAbsent(name, ShortDramaDirectorSkills::read)).append("\n"); + return result.toString(); + } + static String read(String name) { + if (!name.matches("[a-z-]+")) throw new IllegalArgumentException("Invalid skill name"); + // Common-pool threads may not inherit Spring Boot's nested-jar context loader. + try (var in = new ClassPathResource("short-drama/skills/"+name+"/SKILL.md", + ShortDramaDirectorSkills.class.getClassLoader()).getInputStream()) { + String text = new String(in.readAllBytes(), StandardCharsets.UTF_8); + if (text.startsWith("---")) text = text.substring(text.indexOf("---",3)+3).strip(); + if (text.isBlank()) throw new IllegalStateException("Empty director skill: "+name); + return "[skill:"+name+"@"+VERSION+"]\n"+text; + } catch (Exception e) { throw new IllegalStateException("Cannot load director skill: "+name,e); } + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaParallel.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaParallel.java new file mode 100644 index 00000000..1b0ea03a --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaParallel.java @@ -0,0 +1,27 @@ +package org.ruoyi.service.shortdrama.impl; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Function; + +/** Bounded parallelism, deterministic output order, fail without losing completed checkpoints. */ +final class ShortDramaParallel { + private ShortDramaParallel() { } + static List mapOrdered(List inputs,int concurrency,Function fn) { + if(inputs.isEmpty())return List.of(); + var pool=Executors.newFixedThreadPool(Math.min(concurrency,inputs.size())); + List> jobs=new ArrayList<>(); + try { + for(var input:inputs)jobs.add(pool.submit(()->fn.apply(input))); + List results=new ArrayList<>(); + ExecutionException failure=null; + for(var job:jobs) { + try { results.add(job.get()); } + catch(ExecutionException e) { results.add(null); if(failure==null)failure=e; } + } + if(failure!=null)throw failure; + return results; + } catch(InterruptedException e) {Thread.currentThread().interrupt();throw new IllegalStateException("任务中断,已完成部分可恢复",e);} + catch(ExecutionException e) {throw new IllegalStateException(e.getCause().getMessage(),e.getCause());} + finally {pool.shutdownNow();} + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaRevisionService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaRevisionService.java new file mode 100644 index 00000000..4d079303 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaRevisionService.java @@ -0,0 +1,88 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import org.ruoyi.common.core.utils.MapstructUtils; +import org.ruoyi.domain.bo.shortdrama.ShortDramaRevisionBo; +import org.ruoyi.domain.entity.shortdrama.*; +import org.ruoyi.domain.vo.shortdrama.ShortDramaDetailVo; +import org.ruoyi.mapper.shortdrama.*; +import org.ruoyi.service.shortdrama.IShortDramaService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import java.util.stream.Collectors; + +/** Applies an explicitly reviewed, complete revision without rerunning generative steps. */ +@Service +@RequiredArgsConstructor +public class ShortDramaRevisionService { + private final ShortDramaProjectMapper projects; + private final ShortDramaScriptMapper scripts; + private final ShortDramaStoryboardMapper boards; + private final ShortDramaCharacterMapper characters; + private final ShortDramaLocationMapper locations; + private final ShortDramaVisualAssetMapper visualAssets; + private final ShortDramaVisualAssetService visualService; + private final IShortDramaService drama; + private final TransactionTemplate transactions; + + public ShortDramaDetailVo apply(Long projectId, ShortDramaRevisionBo revision, Long userId) { + transactions.executeWithoutResult(tx -> { + var project = projects.selectById(projectId); + if (project == null || !userId.equals(project.getUserId())) throw new IllegalArgumentException("项目不存在或无权限"); + if (!Set.of("storyboard_ready", "script_changed").contains(project.getStatus())) throw new IllegalStateException("请等待项目生成完成再导入修订稿"); + var script = scripts.selectById(revision.getScriptId()); + if (script == null || !projectId.equals(script.getProjectId())) throw new IllegalArgumentException("剧本不属于当前项目"); + if (!Objects.equals(script.getScriptText(), revision.getExpectedScriptText())) throw new IllegalStateException("剧本已发生变化,请重新导出并合并修订"); + var existing = boards.selectList(new LambdaQueryWrapper().eq(ShortDramaStoryboard::getProjectId, projectId)); + if (existing.stream().anyMatch(s -> "generating".equals(s.getVideoStatus()))) throw new IllegalStateException("请等待运行中的视频任务完成再修订"); + var ids = existing.stream().map(ShortDramaStoryboard::getId).collect(Collectors.toSet()); + var incoming = revision.getStoryboards(); + if (incoming == null || incoming.size() != ids.size() || !ids.equals(incoming.stream().map(s -> s.getId()).collect(Collectors.toSet()))) throw new IllegalArgumentException("修订稿必须完整覆盖当前项目的镜头,不能增删或重复镜号"); + var currentById = existing.stream().collect(Collectors.toMap(ShortDramaStoryboard::getId, s -> s)); + for (var s : incoming) { + var current = currentById.get(s.getId()); + if (!projectId.equals(s.getProjectId()) || !script.getId().equals(s.getScriptId()) || !Objects.equals(current.getSceneNo(), s.getSceneNo())) throw new IllegalArgumentException("分镜项目、剧本或镜号不匹配"); + if (s.getUpdateTime() == null || !Objects.equals(s.getUpdateTime(), current.getUpdateTime())) throw new IllegalStateException("镜头" + s.getSceneNo() + "已发生变化,请重新导出再修订"); + if (s.getSourceText() == null || s.getSourceText().isBlank() || s.getVideoPrompt() == null || s.getVideoPrompt().isBlank()) throw new IllegalArgumentException("原文及视频提示词不能为空"); + ShortDramaTiming.validate(s.getSceneNo(), s.getDurationSeconds(), s.getSourceText(), s.getContinuityJson()); + } + if (revision.getCharacters() != null) for (var c : revision.getCharacters()) { + var current = characters.selectById(c.getId()); + if (current == null || !projectId.equals(current.getProjectId()) || !projectId.equals(c.getProjectId()) || !Objects.equals(current.getName(), c.getName())) throw new IllegalArgumentException("角色不属于当前项目或角色名变化"); + characters.updateById(MapstructUtils.convert(c, ShortDramaCharacter.class)); + } + if (revision.getLocations() != null) for (var l : revision.getLocations()) { + var current = locations.selectById(l.getId()); + if (current == null || !projectId.equals(current.getProjectId()) || !projectId.equals(l.getProjectId()) || !Objects.equals(current.getName(), l.getName())) throw new IllegalArgumentException("场景不属于当前项目或场景名变化"); + locations.updateById(MapstructUtils.convert(l, ShortDramaLocation.class)); + } + Set changedFrames = new HashSet<>(); + for (var s : incoming) { + var current=currentById.get(s.getId()); + if (!Objects.equals(current.getImagePrompt(),s.getImagePrompt()) || !Objects.equals(current.getContinuityJson(),s.getContinuityJson())) { + changedFrames.add(s.getId()); + visualAssets.update(null,new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(ShortDramaVisualAsset::getProjectId,projectId).eq(ShortDramaVisualAsset::getStoryboardId,s.getId()) + .set(ShortDramaVisualAsset::getStatus,"obsolete")); + } + boolean videoChanged=changedFrames.contains(s.getId()) || !Objects.equals(current.getVideoPrompt(),s.getVideoPrompt()) + || !Objects.equals(current.getSourceText(),s.getSourceText()) || !Objects.equals(current.getDurationSeconds(),s.getDurationSeconds()); + if(videoChanged) { + boards.update(null,new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(ShortDramaStoryboard::getId,s.getId()).set(ShortDramaStoryboard::getVideoStatus,"pending") + .set(ShortDramaStoryboard::getVideoId,null).set(ShortDramaStoryboard::getVideoUrl,null).set(ShortDramaStoryboard::getLastFrameUrl,null)); + } + s.setUpdateTime(null); boards.updateById(MapstructUtils.convert(s, ShortDramaStoryboard.class)); + } + script.setScriptText(revision.getScriptText()); + if (revision.getOutlineText() != null) script.setOutlineText(revision.getOutlineText()); + scripts.updateById(script); + visualService.synchronizeExistingFrames(projectId, changedFrames); + project.setStatus("storyboard_ready"); + projects.updateById(project); + }); + return drama.getDetail(projectId, userId); + } +} 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 a97607c1..99aca90a 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 @@ -71,6 +71,7 @@ import org.ruoyi.service.media.AtlasPredictionService; import org.ruoyi.service.shortdrama.IShortDramaService; import org.ruoyi.service.shortdrama.IShortDramaVideoComposeService; import org.springframework.stereotype.Service; +import org.ruoyi.common.redis.utils.RedisUtils; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -95,6 +96,7 @@ import java.util.function.Consumer; @RequiredArgsConstructor public class ShortDramaServiceImpl implements IShortDramaService { + private final org.springframework.transaction.support.TransactionTemplate transactionTemplate; private final ShortDramaProjectMapper projectMapper; private final ShortDramaScriptMapper scriptMapper; private final ShortDramaStoryboardMapper storyboardMapper; @@ -102,6 +104,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { private final ShortDramaCharacterAppearanceMapper characterAppearanceMapper; private final ShortDramaLocationMapper locationMapper; private final ShortDramaAudioMapper audioMapper; + private final ShortDramaVisualAssetService visualAssets; + private final ShortDramaSoundService sounds; private final IChatModelService chatModelService; private final ChatServiceFactory chatServiceFactory; private final VideoServiceFactory videoServiceFactory; @@ -187,6 +191,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { ShortDramaProject project = buildAndInsertProject(userId, polishResult, bo); ShortDramaScript script = buildAndInsertScript(project.getId(), polishResult); + if (Boolean.TRUE.equals(bo.getScriptOnly())) return getDetail(project.getId(), userId); + // Phase 2: 资产分析(角色+场景提取) executePhase2_AssetAnalysis(chatModel, project.getId(), script); @@ -198,7 +204,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { @Override public SseEmitter createFromIdeaStream(ShortDramaIdeaBo bo, Long userId) { - SseEmitter emitter = new SseEmitter(1_800_000L); + SseEmitter emitter = new SseEmitter(7_200_000L); AtomicBoolean emitterActive = new AtomicBoolean(true); activeEmitters.put(emitter, emitterActive); emitter.onCompletion(() -> closeEmitter(emitter)); @@ -221,6 +227,13 @@ public class ShortDramaServiceImpl implements IShortDramaService { ShortDramaScript script = buildAndInsertScript(projectId, polishResult); emit(emitter, "polish", "done", "剧本打磨完成"); + if (Boolean.TRUE.equals(bo.getScriptOnly())) { + sendEmitterEvent(emitter, SseEmitter.event().name("complete") + .data("{\"projectId\":\"" + projectId + "\"}")); + completeEmitter(emitter); + return; + } + // Phase 2: 资产分析(角色 + 场景 并发,流式输出) emit(emitter, "assets", "running", "正在分析角色和场景..."); StreamingChatModel assetsStreamModel = chatService.buildStreamingChatModel(modelVo, new ChatRequest()); @@ -308,7 +321,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { private static String escapeJson(String s) { if (s == null) return ""; - return s.replace("\\", "\\\\").replace("\"", "\\\""); + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\r", "\\r").replace("\n", "\\n").replace("\t", "\\t"); } private ShortDramaProject buildAndInsertProject(Long userId, ShortDramaScriptResult polishResult, ShortDramaIdeaBo bo) { @@ -371,13 +384,13 @@ public class ShortDramaServiceImpl implements IShortDramaService { } @Override - public ShortDramaDetailVo polishScript(Long projectId, Long userId) { + public ShortDramaDetailVo polishScript(Long projectId, Long userId, String model) { ShortDramaProject project = validateProjectOwner(projectId, userId); ShortDramaScript script = scriptMapper.selectOne(new LambdaQueryWrapper() .eq(ShortDramaScript::getProjectId, projectId) .orderByDesc(ShortDramaScript::getId).last("limit 1")); String idea = script != null ? firstNotBlank(script.getScriptText(), script.getOutlineText(), project.getDescription()) : project.getDescription(); - ChatModelVo modelVo = findChatModel(); + ChatModelVo modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel(); AbstractChatService chatService = getChatService(modelVo); ChatModel chatModel = chatService.buildChatModel(modelVo); @@ -392,6 +405,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { script.setProjectId(projectId); script.setSourceType("llm"); } + project.setStatus("script_changed"); + videoComposeService.invalidateComposition(projectId); project.setProjectName(firstNotBlank(result.getProjectName(), project.getProjectName())); project.setDescription(firstNotBlank(result.getDescription(), project.getDescription())); projectMapper.updateById(project); @@ -428,6 +443,14 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (project == null || !userId.equals(project.getUserId())) { throw new IllegalArgumentException("项目不存在或无权限"); } + ShortDramaScript existing = bo.getId() == null ? null : scriptMapper.selectById(bo.getId()); + if (existing != null && !bo.getProjectId().equals(existing.getProjectId())) throw new IllegalArgumentException("剧本不属于当前项目"); + if (existing == null || !java.util.Objects.equals(existing.getScriptText(), bo.getScriptText()) + || !java.util.Objects.equals(existing.getOutlineText(), bo.getOutlineText())) { + project.setStatus("script_changed"); + projectMapper.updateById(project); + videoComposeService.invalidateComposition(project.getId()); + } ShortDramaScript entity = MapstructUtils.convert(bo, ShortDramaScript.class); entity.setSourceType(StrUtil.blankToDefault(entity.getSourceType(), "manual")); if (entity.getId() == null) { @@ -452,22 +475,19 @@ public class ShortDramaServiceImpl implements IShortDramaService { throw new IllegalArgumentException("剧本不存在"); } videoComposeService.invalidateComposition(projectId); - // 删除旧分镜 - storyboardMapper.delete(new LambdaQueryWrapper() - .eq(ShortDramaStoryboard::getScriptId, scriptId)); ChatModelVo modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel(); AbstractChatService chatService = getChatService(modelVo); ChatModel chatModel = chatService.buildChatModel(modelVo); - List panels = executePhase3_StoryboardPlan(chatModel, script, projectId); + List panels = executePhase3_StoryboardPlan(chatModel, null, script, projectId, null, modelVo.getModelName()); if (panels.isEmpty()) { panels = fallbackPanels(script); } if (!panels.isEmpty()) { List photographyRules = buildLocalPhotographyRules(panels); List actingDirections = buildLocalActingDirections(panels); - executePhase6_StoryboardDetail(chatModel, panels, projectId); + executePhase6_StoryboardDetail(chatModel, null, panels, projectId, null, modelVo.getModelName()); normalizeContinuityChain(panels); panels = mergePanelsWithRules(panels, photographyRules, actingDirections); } @@ -482,16 +502,53 @@ public class ShortDramaServiceImpl implements IShortDramaService { return generateStoryboards(projectId, scriptId, model, userId); } + @Override + public int importReviewedPlan(Long projectId, org.ruoyi.domain.bo.shortdrama.ShortDramaReviewedPlanBo review, Long userId) { + validateProjectOwner(projectId,userId); + Long scriptId=review.getScriptId(); + if(!beginStoryboardGeneration(scriptId))throw new IllegalStateException("请等待当前分镜生成结束"); + try { + 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("已有分镜请使用分镜修订入口;规划导入仅适用于尚未落库的项目"); + List scenes=splitScriptScenes(script.getScriptText()); + var panels=review.getPanels(); + validateReviewedPlan(scenes,panels); + var model=StrUtil.isNotBlank(review.getModel())?validateAndGetModel(review.getModel()):findChatModel(); + String checkpoint=storyboardCheckpointKey(script,projectId)+":parallel-v3:"+ShortDramaDirectorSkills.VERSION+":"+cn.hutool.crypto.digest.DigestUtil.sha256Hex(model.getModelName()); + for(int i=0;ijava.util.Objects.equals(p.getSceneNumber(),sceneNo)).toList(); + RedisUtils.setCacheObject(checkpoint+":scene:"+sceneNo,JsonUtils.toJsonString(group),java.time.Duration.ofDays(7)); + } + return panels.size(); + } finally { endStoryboardGeneration(scriptId); } + } + + static void validateReviewedPlan(List scenes,List panels) { + if(panels==null || panels.isEmpty() || panels.size()>500)throw new IllegalArgumentException("规划镜头数量无效"); + if(panels.stream().anyMatch(p->p.getSceneNumber()==null || p.getSceneNumber()<1 || p.getSceneNumber()>scenes.size()))throw new IllegalArgumentException("规划场次不属于当前剧本"); + for(int i=0;ijava.util.Objects.equals(p.getSceneNumber(),sceneNo)).toList(); + if(group.isEmpty())throw new IllegalArgumentException("缺少第"+sceneNo+"场规划"); + validateScenePlan(scenes.get(i),group); + } + } + @Override public SseEmitter planStoryboardStream(Long projectId, Long scriptId, String model, Long userId) { - SseEmitter emitter = new SseEmitter(1_800_000L); + SseEmitter emitter = new SseEmitter(7_200_000L); AtomicBoolean emitterActive = new AtomicBoolean(true); activeEmitters.put(emitter, emitterActive); emitter.onCompletion(() -> closeEmitter(emitter)); emitter.onTimeout(() -> closeEmitter(emitter)); emitter.onError(error -> closeEmitter(emitter)); - CompletableFuture.runAsync(() -> { + String tenant = org.ruoyi.common.tenant.helper.TenantHelper.getTenantId(); + CompletableFuture.runAsync(() -> org.ruoyi.common.tenant.helper.TenantHelper.dynamic(tenant, () -> { if (!beginStoryboardGeneration(scriptId)) { sendEmitterEvent(emitter, SseEmitter.event().name("error") .data("{\"message\":\"该剧本正在生成分镜,请勿重复提交\"}")); @@ -506,8 +563,6 @@ public class ShortDramaServiceImpl implements IShortDramaService { } videoComposeService.invalidateComposition(projectId); - storyboardMapper.delete(new LambdaQueryWrapper() - .eq(ShortDramaStoryboard::getScriptId, scriptId)); ChatModelVo modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel(); AbstractChatService chatService = getChatService(modelVo); @@ -516,7 +571,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { log.info("开始流式生成分镜: projectId={}, scriptId={}, model={}", projectId, scriptId, modelVo.getModelName()); emit(emitter, "storyboard_plan", "running", "正在规划分镜镜头,模型开始输出后会实时显示..."); - List panels = executePhase3_StoryboardPlan(chatModel, streamingModel, script, projectId, emitter); + List panels = executePhase3_StoryboardPlan(chatModel, streamingModel, script, projectId, emitter, modelVo.getModelName()); if (panels.isEmpty()) { throw new IllegalStateException("分镜规划失败,模型未返回有效镜头"); } @@ -531,11 +586,13 @@ public class ShortDramaServiceImpl implements IShortDramaService { emit(emitter, "acting", "done", "表演指导生成完成"); emit(emitter, "storyboard_detail", "running", "正在细化镜头提示词和时长..."); - executePhase6_StoryboardDetail(chatModel, streamingModel, panels, projectId, emitter); + executePhase6_StoryboardDetail(chatModel, streamingModel, panels, projectId, emitter, modelVo.getModelName()); + emit(emitter, "storyboard_detail", "done", "全部镜头细化完成"); normalizeContinuityChain(panels); panels = mergePanelsWithRules(panels, photographyRules, actingDirections); List storyboards = persistStoryboards(projectId, scriptId, panels, script); + // Keep validated content-addressed checkpoints for seven days; a successful run is reusable. log.info("流式生成分镜完成: projectId={}, count={}", projectId, storyboards.size()); sendEmitterEvent(emitter, SseEmitter.event().name("complete") .data("{\"projectId\":\"" + projectId + "\",\"count\":" + storyboards.size() + "}")); @@ -548,7 +605,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { } finally { endStoryboardGeneration(scriptId); } - }); + })); return emitter; } @@ -626,6 +683,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (entity.getDurationSeconds() == null || entity.getDurationSeconds() <= 0) { entity.setDurationSeconds(defaultDurationForSceneType(entity.getSceneType())); } + ShortDramaTiming.validate(entity.getSceneNo(), entity.getDurationSeconds(), entity.getSourceText(), entity.getContinuityJson()); if (entity.getId() == null) { entity.setVideoStatus("pending"); storyboardMapper.insert(entity); @@ -651,16 +709,21 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (storyboard == null) throw new IllegalArgumentException("分镜不存在"); ShortDramaProject project = projectMapper.selectById(storyboard.getProjectId()); if (project == null || !userId.equals(project.getUserId())) throw new IllegalArgumentException("项目不存在或无权限"); + if (ShortDramaVisualAssetService.directInsert(storyboard.getContinuityJson())) throw new IllegalStateException("本镜使用真实素材,不能通过AI视频模型重绘;请在剪辑阶段使用已绑定原图"); + if ("script_changed".equals(project.getStatus())) throw new IllegalStateException("剧本已修改,请重新分析资产和生成分镜后再生成视频"); + if (storyboardGenerationStates.containsKey(storyboard.getScriptId())) throw new IllegalStateException("分镜正在更新,请完成审阅后再生成视频"); ChatModelVo modelVo = chatModelService.selectModelByName(videoModel); if (modelVo == null) throw new IllegalArgumentException("未找到视频模型配置: " + videoModel); + ShortDramaTiming.validate(storyboard.getSceneNo(), storyboard.getDurationSeconds(), storyboard.getSourceText(), storyboard.getContinuityJson()); + // 收集所有参考图(角色 + 场景 + 末帧承接) List referenceImages = findStoryboardReferenceImages(storyboard, lastFrameUrl); // 根据参考图数量自动切换模型 if (referenceImages != null && !referenceImages.isEmpty()) { String targetModel; - if (referenceImages.size() >= 2) { + if (referenceImages.size() >= 2 || videoModel.endsWith("/reference-to-video")) { targetModel = videoModel.replace("/text-to-video", "/reference-to-video") .replace("/image-to-video", "/reference-to-video"); } else { @@ -673,7 +736,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { modelVo = switched; log.info("参考图({}张) → 切换模型: {}", referenceImages.size(), targetModel); } else { - log.info("参考图({}张)但模型未注册: {},保持原模型", referenceImages.size(), targetModel); + throw new IllegalArgumentException("当前镜头需要参考图,但对应模型未配置:"+targetModel+"。请选择已配置的多参考图生视频模型,不能降级为文生视频。"); } } } @@ -686,6 +749,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { .size(projectAspectRatio(storyboard.getProjectId())) .seconds(storyboard.getDurationSeconds()) .referenceImages(referenceImages) + .referenceAudios(sounds.references(storyboard.getProjectId(),storyboard.getSceneNo(),modelVo)) .generateAudio(Boolean.TRUE) .returnLastFrame(Boolean.TRUE) .lastFrameUrl(lastFrameUrl) @@ -747,6 +811,10 @@ public class ShortDramaServiceImpl implements IShortDramaService { /** 收集分镜关联的所有参考图:角色形象图(按出场顺序)+ 场景图 + 可选末帧承接 */ private List findStoryboardReferenceImages(ShortDramaStoryboard storyboard, String lastFrameUrl) { List images = new ArrayList<>(); + String frame = visualAssets.readyFrame(storyboard.getId()); + // The reviewed frame already resolves identity, set and props. Do not let the + // original design sheets compete with it and recreate objects or camera angles. + if (StrUtil.isNotBlank(frame)) return List.of(frame); List chars = parseCharacterRefs(storyboard.getCharactersJson()); if (chars != null) { for (CharacterRef ref : chars) { @@ -762,6 +830,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { images.add(img); } } + for (String prop : visualAssets.readyPropReferences(storyboard)) if (!images.contains(prop)) images.add(prop); // 末帧承接:放在最后一张,@imageN 标记会自动绑定 if (StrUtil.isNotBlank(lastFrameUrl) && !images.contains(lastFrameUrl)) { images.add(lastFrameUrl); @@ -782,9 +851,22 @@ public class ShortDramaServiceImpl implements IShortDramaService { /** 构建增强提示词(含参考图 + 末帧首帧承接) */ private String buildEnrichedVideoPrompt(ShortDramaStoryboard storyboard, java.util.List refImages, String lastFrameUrl) { boolean hasRefImages = refImages != null && !refImages.isEmpty(); + try { + var c=JsonUtils.parseObject(storyboard.getContinuityJson(),JsonNode.class); + if(hasRefImages && refImages.size()==1 && StrUtil.isNotBlank(visualAssets.readyFrame(storyboard.getId())))return + "本镜唯一画面参考是image 1。严格保持该构图、人物、服装、左右关系、道具与背景,连续单一机位,不切镜、不镜像、不添加参考图中没有的物件。\n" + + "起点:"+c.path("start_state").asText()+"\n"+storyboard.getVideoPrompt() + + "\n终点:"+c.path("end_state").asText()+"\n只执行当前镜头,不展示后续镜头,不配乐。"; + } catch(Exception ignored) { } StringBuilder sb = new StringBuilder(); + sb.append(ShortDramaDirectorSkills.load("emotional-dialogue", "director-blocking", "real-material", "video-continuity")); // 1. 镜头语言标签 + String frame = visualAssets.readyFrame(storyboard.getId()); + 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()); @@ -914,16 +996,15 @@ public class ShortDramaServiceImpl implements IShortDramaService { } // 8. 视觉参考(image_prompt 含光线/氛围/构图细节) - if (StrUtil.isNotBlank(storyboard.getImagePrompt())) { + // 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. 目标时长与节奏 int durationSeconds = storyboard.getDurationSeconds() != null ? storyboard.getDurationSeconds() : 6; - sb.append("[时长] ").append(durationSeconds).append("秒,动作与运镜必须完整覆盖全程"); - if (durationSeconds >= 8) { - sb.append(",按前段、中段、后段三个连续节拍展开,避免动作提前结束"); - } + sb.append("[时长] ").append(durationSeconds).append("秒,按已审阅的对白、独占动作及停顿预算自然表演;固定机位和安静倾听均可保留,不添加新动作填秒数"); sb.append("\n"); // 10. 视觉风格 @@ -934,6 +1015,10 @@ public class ShortDramaServiceImpl implements IShortDramaService { // 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(); } @@ -1214,19 +1299,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { .eq(ShortDramaStoryboard::getProjectId, projectId) .orderByAsc(ShortDramaStoryboard::getSceneNo)); - // 按 locationName 分组:同场景内串行(保末帧拼接),跨场景组并发 - List> groups = new ArrayList<>(); - List currentGroup = new ArrayList<>(); - String currentLoc = null; - for (ShortDramaStoryboard sb : storyboards) { - String loc = StrUtil.blankToDefault(sb.getLocationName(), ""); - if (!loc.equals(currentLoc)) { - if (!currentGroup.isEmpty()) { groups.add(currentGroup); currentGroup = new ArrayList<>(); } - currentLoc = loc; - } - currentGroup.add(sb); - } - if (!currentGroup.isEmpty()) groups.add(currentGroup); + for (ShortDramaStoryboard shot : storyboards) ShortDramaTiming.validate(shot.getSceneNo(), shot.getDurationSeconds(), shot.getSourceText(), shot.getContinuityJson()); + List> groups = groupContinuousScenes(storyboards); // 跨场景组并发,组上限 4 int parallel = Math.min(4, Math.max(1, groups.size())); @@ -1239,10 +1313,18 @@ public class ShortDramaServiceImpl implements IShortDramaService { } // 按 sceneNo 顺序汇总结果 List result = new ArrayList<>(); + List failures = new ArrayList<>(); for (java.util.concurrent.Future> f : futures) { try { result.addAll(f.get()); } - catch (Exception e) { log.warn("视频生成分组失败: {}", e.getMessage()); } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("视频生成等待被中断,请检查各镜头状态", e); + } + catch (java.util.concurrent.ExecutionException e) { + failures.add(e.getCause() != null ? e.getCause().getMessage() : e.getMessage()); + } } + if (!failures.isEmpty()) throw new IllegalStateException(String.join(";", failures)); result.sort(java.util.Comparator.comparing(v -> v.getSceneNo() == null ? Integer.MAX_VALUE : v.getSceneNo())); return result; } finally { @@ -1250,25 +1332,39 @@ public class ShortDramaServiceImpl implements IShortDramaService { } } - /** 同场景组内串行生成:上一镜末帧喂下一镜首帧。 */ - private List generateGroupSerial(List group, String videoModel, Long userId) { + static List> groupContinuousScenes(List storyboards) { + List> groups = new ArrayList<>(); + String previousKey = null; + for (ShortDramaStoryboard sb : storyboards) { + String scene = ""; + if (StrUtil.isNotBlank(sb.getContinuityJson())) { + scene = cn.hutool.json.JSONUtil.parseObj(sb.getContinuityJson()).getStr("scene_number", ""); + } + String key = scene + "|" + StrUtil.blankToDefault(sb.getLocationName(), ""); + if (!key.equals(previousKey)) groups.add(new ArrayList<>()); + groups.get(groups.size() - 1).add(sb); + previousKey = key; + } + return groups; + } + + /** 同场景组内串行生成;包括第一镜在内,完成并取得末帧后才允许下一镜提交。 */ + List generateGroupSerial(List group, String videoModel, Long userId) { List result = new ArrayList<>(); String prevLastFrameUrl = null; - for (ShortDramaStoryboard sb : group) { - try { - String lastFrameForThis = StrUtil.isNotBlank(prevLastFrameUrl) ? prevLastFrameUrl : null; - ShortDramaStoryboardVo vo = generateVideo(sb.getId(), videoModel, userId, lastFrameForThis); - if (lastFrameForThis != null) { - vo = ensureVideoDone(sb.getId(), videoModel, userId); - } - result.add(vo); - ShortDramaStoryboard latest = storyboardMapper.selectById(sb.getId()); - prevLastFrameUrl = (latest != null && StrUtil.isNotBlank(latest.getLastFrameUrl())) ? latest.getLastFrameUrl() : null; - } catch (Exception e) { - log.warn("镜头{}视频生成失败: {}", sb.getSceneNo(), e.getMessage()); - ShortDramaStoryboard current = storyboardMapper.selectById(sb.getId()); - result.add(MapstructUtils.convert(current != null ? current : sb, ShortDramaStoryboardVo.class)); - prevLastFrameUrl = null; + for (int index = 0; index < group.size(); index++) { + ShortDramaStoryboard sb = group.get(index); + ShortDramaStoryboardVo vo = generateVideo(sb.getId(), videoModel, userId, prevLastFrameUrl); + if (vo == null || !"done".equals(vo.getVideoStatus())) { + vo = ensureVideoDone(sb.getId(), videoModel, userId); + } + if (vo == null || !"done".equals(vo.getVideoStatus())) { + throw new IllegalStateException("镜头" + sb.getSceneNo() + "尚未成功完成,已停止后续连续镜头"); + } + result.add(vo); + prevLastFrameUrl = vo.getLastFrameUrl(); + if (index < group.size() - 1 && StrUtil.isBlank(prevLastFrameUrl)) { + throw new IllegalStateException("镜头" + sb.getSceneNo() + "缺少末帧,已停止后续连续镜头"); } } return result; @@ -1278,7 +1374,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { * 后台同步轮询单镜视频直到 done/failed(用于同场景末帧拼接时拿到末帧再喂下一镜)。 * 单镜累计轮询不超过 5 分钟,超时按当前状态返回。 */ - private ShortDramaStoryboardVo ensureVideoDone(Long storyboardId, String videoModel, Long userId) { + ShortDramaStoryboardVo ensureVideoDone(Long storyboardId, String videoModel, Long userId) { long deadline = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(5); try { while (System.currentTimeMillis() < deadline) { @@ -1298,14 +1394,14 @@ public class ShortDramaServiceImpl implements IShortDramaService { // ==================== 资产分析与管理 ==================== @Override - public ShortDramaDetailVo analyzeAssets(Long projectId, Long scriptId, Long userId) { + public ShortDramaDetailVo analyzeAssets(Long projectId, Long scriptId, Long userId, String model) { ShortDramaProject project = validateProjectOwner(projectId, userId); ShortDramaScript script = scriptMapper.selectById(scriptId); if (script == null || !projectId.equals(script.getProjectId())) { throw new IllegalArgumentException("剧本不存在"); } clearAssets(projectId); - ChatModelVo modelVo = findChatModel(); + ChatModelVo modelVo = StrUtil.isNotBlank(model) ? validateAndGetModel(model) : findChatModel(); AbstractChatService chatService = getChatService(modelVo); ChatModel chatModel = chatService.buildChatModel(modelVo); executePhase2_AssetAnalysis(chatModel, projectId, script); @@ -1376,7 +1472,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { ChatModelVo modelVo = chatModelService.selectModelByName(imageModel); if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel); String prompt = firstNotBlank(primaryLocationDescription(location), location.getSummary(), location.getName()); - String finalPrompt = ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + prompt + + String finalPrompt = ShortDramaDirectorSkills.load("visual-world") + ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + prompt + ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + artStyleSuffix(location.getProjectId()); String referenceImage = validateReferenceImageUrl(referenceImageUrl); String imageUrl = imageServiceFactory.getOriginalService(modelVo.getProviderCode()) @@ -1570,7 +1666,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { ChatModelVo modelVo = chatModelService.selectModelByName(imageModel); if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel); String prompt = firstNotBlank(primaryLocationDescription(location), location.getSummary(), location.getName()); - String finalPrompt = ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + prompt + + String finalPrompt = ShortDramaDirectorSkills.load("visual-world") + ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + prompt + ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + artStyleSuffix(location.getProjectId()); String referenceImage = validateReferenceImageUrl(referenceImageUrl); String imageUrl = imageServiceFactory.getOriginalService(modelVo.getProviderCode()) @@ -1695,7 +1791,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { if (location == null) throw new IllegalArgumentException("场景不存在"); validateProjectOwner(location.getProjectId(), userId); String basePrompt = firstNotBlank(primaryLocationDescription(location), location.getSummary(), location.getName()); - prompt = ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + basePrompt + + prompt = ShortDramaDirectorSkills.load("visual-world") + ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + basePrompt + ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + artStyleSuffix(location.getProjectId()); size = projectAspectRatio(location.getProjectId()); seed = ShortDramaImageConstants.styleSeed(location.getProjectId()); @@ -1765,7 +1861,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { } String prompt = firstNotBlank(primaryLocationDescription(location), location.getSummary(), location.getName()); - String finalPrompt = ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + prompt + + String finalPrompt = ShortDramaDirectorSkills.load("visual-world") + ShortDramaImageConstants.LOCATION_PROMPT_PREFIX + prompt + ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + artStyleSuffix(location.getProjectId()); location.setPreviousImageUrls(location.getImageUrls()); @@ -1865,21 +1961,26 @@ public class ShortDramaServiceImpl implements IShortDramaService { heartbeat.scheduleAtFixedRate(() -> emit(emitter, streamPhase, "running", "模型仍在处理中,请稍候..."), 15, 15, TimeUnit.SECONDS); // 首 token 超时兜底:部分聚合站对 stream=true 既不返回内容也不报错, - // 外层 30 分钟总超时才会失败,前端会长时间卡死。90 秒内未收到任何 token 时快速失败。 + // 长场分镜的推理可能超过90秒;保留心跳,5分钟无正文才判为无响应。 final AtomicBoolean firstTokenReceived = new AtomicBoolean(false); + final java.util.concurrent.atomic.AtomicLong lastTokenAt = new java.util.concurrent.atomic.AtomicLong(System.currentTimeMillis()); + heartbeat.scheduleAtFixedRate(() -> { if (firstTokenReceived.get() && System.currentTimeMillis()-lastTokenAt.get()>90_000) done.completeExceptionally(new RuntimeException("模型输出停滞超过90秒,已完成部分保留")); }, 15, 15, TimeUnit.SECONDS); heartbeat.schedule(() -> { if (firstTokenReceived.compareAndSet(false, true)) { done.completeExceptionally(new RuntimeException( - "模型在90秒内未返回任何流式内容,可能该模型或服务不支持流式输出,请更换模型或使用同步生成")); + "模型在90秒内未返回正文,已完成场次保留,请重试或更换模型")); } }, 90, TimeUnit.SECONDS); List messages = List.of(UserMessage.from(prompt)); streamingModel.chat(messages, new StreamingChatResponseHandler() { @Override public void onPartialResponse(String text) { + if (done.isDone()) return; firstTokenReceived.set(true); + lastTokenAt.set(System.currentTimeMillis()); buf.append(text); - emitStream(emitter, streamPhase, text); + // Detail batches run independently; do not interleave their JSON in the UI. + if (!"storyboard_detail".equals(streamPhase) && !"storyboard_plan_parallel".equals(streamPhase)) emitStream(emitter, streamPhase, text); if (onPartial != null) { try { onPartial.accept(buf.toString()); } catch (Exception ex) { log.warn("流式增量回调异常: {}", ex.getMessage()); @@ -1897,10 +1998,12 @@ public class ShortDramaServiceImpl implements IShortDramaService { } }); try { - done.orTimeout(30, TimeUnit.MINUTES).join(); + done.orTimeout(8, TimeUnit.MINUTES).join(); } catch (Exception e) { Throwable cause = e.getCause() != null ? e.getCause() : e; - throw new RuntimeException("模型在30分钟内未完成响应,请检查模型服务状态或更换模型后重试", cause); + throw new RuntimeException(cause instanceof java.util.concurrent.TimeoutException + ? "模型在8分钟内未完成当前批次,请检查模型服务状态或更换模型后重试" + : "模型调用失败:" + cause.getMessage(), cause); } finally { heartbeat.shutdownNow(); } @@ -1911,23 +2014,30 @@ public class ShortDramaServiceImpl implements IShortDramaService { private static final String SCRIPT_DELIMITER = "===SCRIPT==="; - private static final String PHASE1_COMBINED_SYSTEM = """ + private static final String PHASE1_COMBINED_SYSTEM = ShortDramaDirectorSkills.load("emotional-dialogue", "story-causality", "visual-world", "real-material") + """ 你是顶级短剧编剧、剧本统筹和场景规划师。根据用户提供的故事资料,创作一部剧情完整、可拍摄、具有多场次推进的短剧,而不是只截取结局或高潮片段。 【输出格式 - 严格按顺序】 - 先输出一行 JSON 元信息,换行后输出 "===SCRIPT===", 再换行后输出完整剧本正文(1200-2500字)。 + 先输出一行 JSON 元信息,换行后输出 "===SCRIPT===", 再换行后输出完整剧本正文(按用户指定时长展开,不用固定字数压缩故事)。 JSON 格式(一行完成,不要换行): - {"projectName":"项目名","description":"简介(30-80字)","scriptName":"剧本名","tone":"类型与基调","outlineText":"完整分场大纲(500-1000字)"} + {"projectName":"项目名","description":"简介(30-80字)","scriptName":"剧本名","tone":"类型与基调","outlineText":"简明剧情大纲:整体节奏、因果链与情绪转折"} 【完整故事弧 - 最高优先级】 1. 必须覆盖用户资料中的完整主线,禁止只选择决赛、告白、复仇等最后高潮单独成篇 - 2. 至少包含5个有不同剧情功能的场次;资料跨度较大时建议6-10场 - 3. 场次必须覆盖:人物与困境建立 → 改变契机 → 发展/训练或关系推进 → 重大挫折或背叛 → 主角自主决定与重组 → 高潮行动 → 结果与主题落点 + 2. 场次数服从故事复杂度和用户时长,不设最低场次数;简单故事合并重复信息,不为了完整而增加支线 + 3. 只覆盖本故事必要的起因、变化、选择与结果;生活故事以认知变化为主,不套用挫折、背叛或反击模板 4. 每场都必须改变剧情状态,说明角色目标、阻碍、行动和结果;禁止多个场次只是重复训练、重复争吵或重复比赛 5. 反派倒戈、关系破裂、能力掌握、团队团结、比赛逆转等重大变化必须提前铺垫,不能突然发生 6. 开场不得直接进入最终决战,除非用户明确要求只写高潮片段 + 【因果与信息边界】 + - outlineText只负责整体节奏、情绪转折与因果关系;每段用1-2句说明为何发生、带来什么变化和下一步,标注时间预算,不写分镜。 + - 家庭、生活题材按人物认知变化推进,不得强塞反派、背叛、比赛或逆转。 + - 区分观众知道什么、主角知道什么;推测必须经人物查证后才能作为事实。 + - 软件只做资料识别和统计,不可凭空推断人物秘密;真实界面与票据的素材使用说明放入分镜,不写进叙事正文。 + - 每场景头写“内景/外景 地点 时间(预计XX秒)”;各场合计贴近用户时长;对白、动作、停顿均需有实际内容承载。 + 【分场要求】 - 使用标准场景头:内景/外景 + 具体地点 + 时间 - 地点变化、时间跳变或一个戏剧单元完成时另起新场 @@ -1943,10 +2053,17 @@ public class ShortDramaServiceImpl implements IShortDramaService { 【剧本格式】 场景头 - 场景环境与人物初始状态 - 连续动作描述 + 一句交代必要的情境 + 推动剧情的主要行动 角色名:「台词」 - 该场结果与进入下一场的动作钩子 + 自然收束并承接下一场;不重复标注已表达的结果或钩子 + + 【大纲、正文、分镜分工 - 最高优先级】 + - 大纲负责全片节奏、连贯性和因果;保持简明,不复述完整台词或展开执行细节。 + - 正文只保留必要情境、主要行动和有效对白。一个连续行为用一句说明,不拆成拿起、抬手、转腕、放下的流水账。 + - 不在正文写景别、机位、运镜、光线参数、左右站位、持物手、逐秒安排、表演参数或软件按钮操作;这些由分镜阶段根据正文补充。 + - 核心道具出现与移动要交代其剧情原因,但不写机械过程。简单故事不增加无关误会、维修教学、反复核账或多次告别来凑时长。 + - 不以字数、场数判定完整,不为了达到1000字或5场而扩写。完整性只看必要因果链是否成立。 【台词与动作】 - 对话必须推动认知、关系、决定或行动变化 @@ -1955,8 +2072,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { - 比赛、战斗和训练必须写清动作过程与结果,不能只用“经过努力”“最终获胜”概括 【自检 - 输出前内部完成】 - - 是否至少5个场景头,且地点/时间/剧情功能有变化 - - 是否覆盖了用户资料中的起点、发展、低谷、反击和结局 + - 场次是否必要,简明正文是否覆盖了完整因果链,技术细节是否留给分镜 + - 是否覆盖用户资料中的必要起因、变化、选择和结果,而没有强加复杂故事模板 - 删除任一场是否会造成剧情断裂;若不会,该场应合并或强化 - 是否存在角色无原因倒戈、突然掌握能力、突然团结或突然获胜 - 是否误把最终高潮写成了整部剧本 @@ -1990,15 +2107,15 @@ public class ShortDramaServiceImpl implements IShortDramaService { return count; } - private static boolean scriptNeedsExpansion(ShortDramaScriptResult result) { + static boolean scriptNeedsExpansion(ShortDramaScriptResult result) { if (result == null || StrUtil.isBlank(result.getScriptText())) return true; - return result.getScriptText().length() < 1000 || countSceneHeadings(result.getScriptText()) < 5; + return countSceneHeadings(result.getScriptText()) == 0; } private ShortDramaScriptResult expandIncompleteScript(ChatModel chatModel, ShortDramaIdeaBo bo, ShortDramaScriptResult initial) { String prompt = PHASE1_COMBINED_SYSTEM - + "\n\n以下初稿场次不足或过度集中在高潮。请基于原始资料完整重写,不要只修改局部。" + + "\n\n以下初稿缺少可识别的场景头。只修复剧本结构,保留简洁叙事,不增加支线、镜头细节或无关场次。" + "\n原始资料:\n" + sanitizeIdeaInput(bo.getIdea()) + "\n\n不合格初稿:\n" + (initial == null ? "无" : firstNotBlank(initial.getScriptText(), "无")); ShortDramaScriptResult expanded = parsePhase1Response(chatModel.chat(prompt)); @@ -2020,7 +2137,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { throw new RuntimeException("剧本打磨失败:LLM 返回格式异常,请重试"); } if (scriptNeedsExpansion(result)) { - log.warn("Phase 1 剧本场次不足,自动扩写: length={} scenes={}", + log.warn("Phase 1 剧本缺少场景结构,尝试修复: length={} scenes={}", result.getScriptText().length(), countSceneHeadings(result.getScriptText())); result = expandIncompleteScript(chatModel, bo, result); } @@ -2294,7 +2411,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { } private static String buildCharacterProfilePrompt(String scriptText) { - return """ + return ShortDramaDirectorSkills.load("emotional-dialogue", "story-causality", "visual-world") + """ 你是专业的"选角指导"。请基于提供的文本(小说、剧本或混合格式),分析并输出所有需要制作形象的角色档案信息。 【你的职责】 @@ -2377,7 +2494,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { case "C" -> "80-120字,简洁但完整的形象描述"; default -> "50-80字,基础形象即可"; }; - return """ + return ShortDramaDirectorSkills.load("visual-world") + """ 你是专业的"角色视觉设计师"。根据角色档案信息,生成详细的人物外貌描述(用于AI图片生成)。 【视觉层级规范】描述长度要求:%s @@ -2450,7 +2567,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { } private static String buildLocationCreatePrompt(String scriptText) { - return """ + return ShortDramaDirectorSkills.load("visual-world") + """ 你是"场景资产建立师"。请基于文本筛选需要制作画面的场景,生成用于出图的资产JSON。 【筛选规则】 @@ -2509,51 +2626,226 @@ public class ShortDramaServiceImpl implements IShortDramaService { } private List executePhase3_StoryboardPlan(ChatModel chatModel, StreamingChatModel streamingModel, - ShortDramaScript script, Long projectId, SseEmitter emitter) { - try { - String text = firstNotBlank(script.getScriptText(), script.getOutlineText(), ""); - if (StrUtil.isBlank(text)) return List.of(); - - String charsLib = buildCharactersLibString(projectId); - String locsLib = buildLocationsLibString(projectId); - String charsIntro = buildCharactersIntroString(projectId); - String charsAppearanceList = buildCharactersAppearanceListString(projectId); - String charsFullDesc = buildCharactersFullDescString(projectId); - - String prompt = buildStoryboardPlanPrompt(text, charsLib, locsLib, charsIntro, charsAppearanceList, charsFullDesc, - artStyleSuffix(projectId), projectAspectRatio(projectId)); - String response; - if (emitter != null) { - StreamingChatModel activeStreamingModel = streamingModel != null ? streamingModel : buildStreamingChatModel(); - // 流式增量:每解析出一个完整 panel 就推给前端,不等整个数组完成 - final org.ruoyi.service.shortdrama.support.IncrementalJsonArrayExtractor extractor = - new org.ruoyi.service.shortdrama.support.IncrementalJsonArrayExtractor<>(StoryboardPanelData.class); - response = streamingChat(activeStreamingModel, chatModel, prompt, emitter, "storyboard_plan", buf -> { - List fresh = extractor.feed(buf); - for (StoryboardPanelData p : fresh) { - if (p != null) emitPanel(emitter, p); - } - }); - } else { - response = chatModel.chat(prompt); - } - List panels = parsePanelList(response); - if (panels != null && !panels.isEmpty()) { - for (int i = 0; i < panels.size(); i++) { - panels.get(i).setPanelNumber(i + 1); - } - return panels; - } - } catch (Exception e) { - log.warn("Phase 3 分镜规划失败: {}", e.getMessage()); - } - return List.of(); + ShortDramaScript script, Long projectId, SseEmitter emitter) { + return executePhase3_StoryboardPlan(chatModel, streamingModel, script, projectId, emitter, findChatModel().getModelName()); } - private static String buildStoryboardPlanPrompt(String text, String charsLib, String locsLib, + private List executePhase3_StoryboardPlan(ChatModel chatModel, StreamingChatModel streamingModel, + ShortDramaScript script, Long projectId, SseEmitter emitter, String modelName) { + String text=firstNotBlank(script.getScriptText(),script.getOutlineText(), ""); + if(StrUtil.isBlank(text))return List.of(); + String charsLib=buildCharactersLibString(projectId),locsLib=buildLocationsLibString(projectId); + String intro=buildCharactersIntroString(projectId),variants=buildCharactersAppearanceListString(projectId),descriptions=buildCharactersFullDescString(projectId); + String style=artStyleSuffix(projectId),aspect=projectAspectRatio(projectId); + String checkpoint=storyboardCheckpointKey(script,projectId)+":parallel-v3:"+ShortDramaDirectorSkills.VERSION+":"+cn.hutool.crypto.digest.DigestUtil.sha256Hex(modelName); + List scenes=splitScriptScenes(text); + var completed=new java.util.concurrent.atomic.AtomicInteger(); + List indices=java.util.stream.IntStream.range(0,scenes.size()).boxed().toList(); + String tenant = org.ruoyi.common.tenant.helper.TenantHelper.getTenantId(); + var byScene=ShortDramaParallel.mapOrdered(indices,3,sceneIndex->org.ruoyi.common.tenant.helper.TenantHelper.dynamic(tenant,()->{ + String context="全剧大纲(只作因果上下文):\n"+firstNotBlank(script.getOutlineText(),text) + +"\n当前第"+(sceneIndex+1)+"/"+scenes.size()+"场,只拆本场:\n"+scenes.get(sceneIndex); + if(sceneIndex>0) { + String previous=scenes.get(sceneIndex-1); + context+="\n上一场原文结尾(只作桥梁参考,不重复生成):"+previous.substring(Math.max(0,previous.length()-1000)); + } + String prompt=buildStoryboardPlanPrompt(context,charsLib,locsLib,intro,variants,descriptions,style,aspect); + String sceneScript=scenes.get(sceneIndex); + prompt+="\n本次只拆以下场次,时长预算以此标题为准:"+sceneScript.lines().findFirst().orElse("") + +"\n输出前先列内部节拍时间表并加总duration;将正常表演、核实信息、接收对白、情绪反应算入时间。正文是简述,分镜应展开表演,不逐句机械切镜。禁止增加支线、重复台词或无意义空镜。"+sceneBudgetScaffold(sceneScript); + String key=checkpoint+":scene:"+(sceneIndex+1); + String cached=RedisUtils.getCacheObject(key); + List result=null; + if(StrUtil.isNotBlank(cached)) { + result=parsePanelList(cached); + if (result != null && !result.isEmpty()) try { validateScenePlan(sceneScript,result); } + catch (RuntimeException invalid) { + RedisUtils.setCacheObject(key+":draft",JsonUtils.toJsonString(result),java.time.Duration.ofDays(7)); + result=null; + } + } + if(result==null || result.isEmpty()) { + String draft = RedisUtils.getCacheObject(key+":draft"); + if (StrUtil.isNotBlank(draft)) { + result = parsePanelList(draft); + if (result != null && !result.isEmpty()) { + String issue = "恢复上次未通过的场次;保留原文对白与人物表演,重新核对时间表"; + try { validateScenePlan(sceneScript,result); } catch (RuntimeException invalid) { issue=invalid.getMessage(); } + prompt = buildSceneBudgetRepairPrompt(sceneScript, result, issue); + } + } + for(int attempt=1;attempt<=2;attempt++)try { + boolean repairing = result != null && !result.isEmpty(); + if(emitter!=null)emit(emitter,"storyboard_plan","running","第"+(sceneIndex+1)+"场"+(repairing?"校对对白与时间表":"规划")+"(第"+attempt+"次)"); + String response=emitter==null?chatModel.chat(prompt):streamingChat(streamingModel!=null?streamingModel:buildStreamingChatModel(),chatModel,prompt,emitter,"storyboard_plan_parallel"); + result=repairing?applyTimingRepair(result,response):parsePanelList(response); + if(result==null || result.isEmpty())throw new IllegalStateException("模型未返回有效镜头"); + RedisUtils.setCacheObject(key+":draft",JsonUtils.toJsonString(result),java.time.Duration.ofDays(7)); + validateScenePlan(sceneScript,result); + RedisUtils.setCacheObject(key,JsonUtils.toJsonString(result),java.time.Duration.ofDays(7)); + break; + }catch(Exception e){if(attempt==2)throw new IllegalStateException("第"+(sceneIndex+1)+"场失败,其他已完成场次保留:"+e.getMessage(),e); + prompt=result==null?prompt+"\n修复无效输出:"+e.getMessage():buildSceneBudgetRepairPrompt(sceneScript,result,e.getMessage());} + } + if(emitter!=null)emit(emitter,"storyboard_plan","running","分场规划已完成 "+completed.incrementAndGet()+"/"+scenes.size()+" 场(最多3路并发)"); + return result; + })); + List all=new ArrayList<>(); + for(int i=0;i panels) { + List errors = new ArrayList<>(); + try { validateSceneDuration(scene, panels); } catch (RuntimeException e) { errors.add(e.getMessage()); } + for (int i=0;i panels) { + if (panels.stream().anyMatch(p->p.getDuration()==null || p.getDuration()<1 || p.getDuration()>15)) throw new IllegalStateException("每镜必须明确分配1至15秒"); + String heading=scene.lines().findFirst().orElse(""); + var matcher=java.util.regex.Pattern.compile("预计\\s*(\\d+(?:\\.\\d+)?)\\s*秒").matcher(heading); + if (!matcher.find()) return; + double expected=Double.parseDouble(matcher.group(1)); + int actual=panels.stream().mapToInt(p->p.getDuration()==null?0:p.getDuration()).sum(); + double tolerance = Math.max(0.5, expected * 0.1); + if (expected>0 && Math.abs(actual-expected)>tolerance) + throw new IllegalStateException("本场预计"+expected+"秒,规划为"+actual+"秒;允许整数范围" + +(int)Math.ceil(expected-tolerance)+"至"+(int)Math.floor(expected+tolerance) + +"秒。保留对白和真实反应,不可用空镜填充;确实无法容纳时先修订剧本预算"); + } + + static String buildSceneBudgetRepairPrompt(String scene, List panels, String error) { + ArrayNode compact=JsonNodeFactory.instance.arrayNode(); + for(int i=0;i applyTimingRepair(List originals,String response) { + var mapper=new com.fasterxml.jackson.databind.ObjectMapper(); + try { + JsonNode edits=mapper.readTree(response.strip().replaceFirst("^```(?:json)?\\s*", "").replaceFirst("\\s*```$", "")); + if(!edits.isArray() || edits.isEmpty())throw new IllegalArgumentException("时间表须为非空数组"); + List repaired=new ArrayList<>(); + for(JsonNode edit:edits) { + int from=edit.path("from_panel").asInt(0); + if(from<1 || from>originals.size())throw new IllegalArgumentException("时间表原镜序号无效"); + var panel=mapper.convertValue(mapper.valueToTree(originals.get(from-1)),StoryboardPanelData.class); + if(!edit.hasNonNull("source_text") || !edit.path("timing").isObject())throw new IllegalArgumentException("时间表缺少原话或时间预算"); + panel.setSourceText(edit.path("source_text").asText()); panel.setTiming(edit.path("timing")); + panel.setDescription(edit.path("description").asText(panel.getDescription())); + panel.setStartState(edit.path("start_state").asText(panel.getStartState())); + panel.setEndState(edit.path("end_state").asText(panel.getEndState())); + panel.setStoryAction(panel.getDescription()); panel.setStoryResult(panel.getEndState()); + panel.setContinuityAction("从"+panel.getStartState()+"连续执行至"+panel.getEndState()); + if(edit.path("performance_beats").isArray())panel.setPerformanceBeats(edit.path("performance_beats")); + panel.setDuration(Math.max(1,(int)Math.ceil(ShortDramaTiming.requiredSeconds(panel.getSourceText(),buildContinuityJson(panel))))); + panel.setPanelNumber(repaired.size()+1); panel.setSegmentNumber(repaired.size()+1); + repaired.add(panel); + } + return repaired; + } catch(Exception e) { throw new IllegalArgumentException("时间表修订无效:"+e.getMessage(),e); } + } + + private static String sceneBudgetScaffold(String scene) { + var match=java.util.regex.Pattern.compile("预计\\s*(\\d+)\\s*秒").matcher(scene.lines().findFirst().orElse("")); + if(!match.find())return ""; + int seconds=Integer.parseInt(match.group(1)); + if(seconds<1)return ""; + int count=(int)Math.ceil(seconds/12d),base=seconds/count,remainder=seconds%count; + List timeline=new ArrayList<>(); + for(int i=0;i assets = new ArrayList<>(); + var chars = characterMapper.selectList(new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId, projectId)); + assets.addAll(chars); + assets.addAll(locationMapper.selectList(new LambdaQueryWrapper().eq(ShortDramaLocation::getProjectId, projectId))); + if (!chars.isEmpty()) assets.addAll(characterAppearanceMapper.selectList(new LambdaQueryWrapper() + .in(ShortDramaCharacterAppearance::getCharacterId, chars.stream().map(ShortDramaCharacter::getId).toList()))); + ShortDramaProject project = projectMapper.selectById(projectId); + return checkpointKey(projectId, script.getId(), script.getScriptText(), script.getOutlineText(), + project.getArtStyle(), projectAspectRatio(projectId), planningAssetSignature(assets)); + } + + static String planningAssetSignature(List assets) { + List records = new ArrayList<>(); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + for (var asset : assets) { + JsonNode data = mapper.valueToTree(asset); + ObjectNode relevant = JsonNodeFactory.instance.objectNode(); + relevant.put("type", asset.getClass().getSimpleName()); + for (String field : List.of("id", "characterId", "name", "gender", "ageRange", "roleLevel", + "personalityTags", "introduction", "visualDescription", "summary", "descriptions", + "availableSlots", "hasCrowd", "crowdDescription", "appearanceIndex", "changeReason", "description")) { + if (data.has(field)) relevant.set(field, data.get(field)); + } + records.add(relevant.toString()); + } + // Image completion and selection must not discard successful text-planning checkpoints. + java.util.Collections.sort(records); + return cn.hutool.crypto.digest.DigestUtil.sha256Hex(String.join("\n", records)); + } + + static String checkpointKey(Long projectId, Long scriptId, String script, String outline, String style, String aspect, String assetVersion) { + String input = String.join("\n", StrUtil.nullToEmpty(script), StrUtil.nullToEmpty(outline), StrUtil.nullToEmpty(style), aspect, assetVersion); + return "short-drama:checkpoint:v1:" + projectId + ":" + scriptId + ":" + cn.hutool.crypto.digest.DigestUtil.sha256Hex(input); + } + + static List splitScriptScenes(String text) { + List scenes = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean seenHeader = false; + for (String line : text.split("\\R")) { + boolean heading = line.strip().matches("^(?:#{1,6}\\s*)?(?:第[一二三四五六七八九十0-9]+场[::、. ]*)?(?:内景|外景|内外景|闪回[::]?\\s*(?:内景|外景)).*"); + if (heading && seenHeader) { scenes.add(current.toString().trim()); current.setLength(0); } + if (heading) seenHeader = true; + current.append(line).append("\n"); + } + if (!current.toString().isBlank()) scenes.add(current.toString().trim()); + return scenes; + } + + static String buildStoryboardPlanPrompt(String text, String charsLib, String locsLib, String charsIntro, String charsAppearanceList, String charsFullDesc, String artStyle, String aspectRatio) { - return """ + return ShortDramaDirectorSkills.load("emotional-dialogue", "story-causality", "director-blocking", "real-material", "director-review") + """ 你是专业的分镜规划师。请根据剧本内容将故事拆解成连续的分镜头。 【三级规划流程 - 最高优先级】 @@ -2566,20 +2858,20 @@ public class ShortDramaServiceImpl implements IShortDramaService { 相邻片段必须通过动作、情绪、视线或声音中的至少一种桥梁衔接,禁止在片段边界冻结后跳转。 【镜头数量控制 - 最高优先级】 - - 目标比例:每15-25个有效剧情字符≈1个镜头,根据动作和台词密度调整 - - 硬上限:最多40个镜头,超过时优先合并重复信息,不得删除因果中间态 - - 普通镜头5-8秒;无台词镜头也应优先保持5-8秒并包含持续可见变化 - - 单镜超过8秒只允许用于不可拆的连续台词、连续动作或史诗建立镜头,并必须包含前段→中段→后段的持续变化;任何单镜不得超过15秒 + - 按可拍动作和场次时间决定镜数,不按字数凑镜头,不强制建立镜、反应镜独立成镜 + - 当前仅规划一场,不限制全剧镜头总数。本场按场景头预计秒数分配镜头,duration合计应接近预计时长(误差不超过10%%);不得省略发现、反应、决定等因果中间态 + - 普通单镜按对白、独占动作与自然停顿预算分配,固定机位和安静反应均可,不强制每秒变化 + - 单镜超过8秒需有实际对白或动作时长依据,不强制三段动作;任何单镜不得超过15秒 - 每个片段内所有镜头duration之和必须≤15秒,超过必须拆成新片段 - 时长必须由实际动作、对白和运镜容量决定,禁止按场景类型机械给长时长 - 原文只支持1-3秒内容时,应与相邻因果动作合并,禁止靠环境空转填充成长镜头 - - 对话密集场景:多句对话可合并到一个镜头 + - 对话密集场景:先计算发音时长再拆镜,一镜最多一个主要说话人,另一人可有短回应;长对白按自然语义切到倾听画面,保留完整原话 【分镜原则】 - 1. 每个场景开始→1个建立镜头 - 2. 每个关键动作→1个镜头 - 3. 每段对话→1-2个镜头(说话者+必要时听者反应) - 4. 情绪高潮点→1个特写镜头 + 1. 场景信息可在主动作镜头内建立,不强制另加建立镜头 + 2. 同一目的下连续动作可以同镜,切镜由注意力或关系变化驱动 + 3. 对话按真实发音和必要反应预算拆镜,不给每段固定镜数 + 4. 情绪转折可以留在稳定双人中景,只有细节承担信息时才用特写 【每个分镜包含】 - panel_number: 全剧镜头序号 @@ -2593,7 +2885,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { - characters: [{name, appearance, slot}],name必须与资产库一致 - location: 从场景资产库选择,名字完全一致 - scene_type: daily/emotion/action/epic/suspense - - source_text: 对应原文片段(必填) + - source_text: 对应原文片段(必填,保留实际对白与说话人) + - performance_beats: [{name,acting}],逐个出镜角色写本镜触发、意图、可见回应与未解决的情绪;禁止通用表情模板。无人镜头为[] - start_state: 当前镜头第一帧状态,必须继承上一镜头end_state(场景切换除外) - end_state: 当前镜头最后一帧状态,明确人物位置、朝向、姿态、道具和动作结果 - continuity_action: 与下一镜头衔接的未完成动作、视线方向或运动趋势 @@ -2605,12 +2898,10 @@ public class ShortDramaServiceImpl implements IShortDramaService { - story_action: 为实现目标采取的可见行动或说出的关键台词 - story_result: 当前行动造成的新信息、新阻碍、位置变化或关系变化,镜头结束前必须发生 - next_hook: story_result中必须在下一镜头得到回应的问题、动作或后果 - - duration: 镜头时长(秒),根据scene_type差异化分配: - · daily(日常对话):5-8秒 - · emotion(情绪高潮):6-10秒 - · action(动作冲突):5-8秒 - · epic(史诗大景):8-15秒 - · suspense(悬疑紧张):6-10秒 + - duration: 镜头时长1-15秒。以对白实读、独占动作、反应停顿之和分配,不能按情绪类型机械分配。 + - timing: {spoken_text:实际发音台词(金额日期展开读音), speech_rate:语速默认4老人3.2, action_seconds:不与对白重叠的动作秒数, pause_seconds:反应停顿秒数, action_note:动作与对白是否同步}。 + - duration必须至少容纳timing各项;长台词装不下则拆镜,宁可调整场次时长也不能加速朗读。 + 【片段间过渡设计 - 最高优先级】 1. 连续动作:前片段bridge_out写动作起始态,后片段bridge_in必须从动作进行时或完成时开始 @@ -2624,7 +2915,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { 1. 每个镜头只能承担一个主要剧情职责:建立、触发、反应、决定、行动、结果或转场 2. 镜头N必须产生story_result;镜头N+1的narrative_cause必须直接承接该story_result或next_hook 3. 禁止“因为剧本接下来这样写”式跳跃。角色改变位置、情绪、目标、关系或道具状态时,必须先出现触发和过渡动作 - 4. 若状态从A跳到C,必须增加展示B的镜头,例如站立→坐着必须展示坐下,平静→愤怒必须展示触发与表情变化 + 4. 若状态从A变到C,必须展示B的过渡过程,可在同一连续镜头内完成,例如站立→坐下→坐着,不必为每个状态拆镜 5. 对话不能只是轮流说话:每句关键台词必须改变对方认知、决定或行动,并在下一镜展示反应 6. 场景切换必须有剧情原因和转场钩子,例如人物出发、时间推进、视线落向目标或结果揭示 7. source_text必须严格按原文顺序覆盖,不得将后文结果提前,也不得遗漏导致因果断裂的关键动作 @@ -2635,7 +2926,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { 【叙事连续性与空间锚定 - 最高优先级】 1. 同一location内,镜头N+1的start_state必须逐项承接镜头N的end_state,不得重置人物姿态或位置 2. 角色进入场景后,在明确离场、切为拍不到该角色的特写/反打、或切换场景前,必须持续存在于present_characters - 3. 连续动作必须拆成“动作开始→动作过程→动作结果”,下一镜头从上一镜头尚未完成的动作或结果继续 + 3. 连续动作应包含开始、过程与结果;能够在预算内完成时优先放在同一镜头,只有叙事视点或时间容量确实要求时才拆镜 4. 保持180度轴线:同一对话或对峙中,人物左右关系、面对方向不得无理由反转 5. 道具归属、手持状态、服装形象、伤势、光线方向和时间必须连续 6. 每个镜头生成前自检:人物从哪里来、现在在哪里、面向谁、正在做什么、镜头结束后停在哪里 @@ -2711,7 +3002,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { } private static String buildCinematographerPrompt(String panelsJson, int panelCount, String locsDesc, String charsInfo) { - return """ + return ShortDramaDirectorSkills.load("emotional-dialogue", "director-blocking", "visual-world") + """ 你是一位经验丰富的电影摄影指导(Director of Photography)。你的任务是为一组分镜中的每个镜头分别设计摄影规则。 【核心职责】 @@ -2806,7 +3097,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { } private static String buildActingDirectionPrompt(String panelsJson, int panelCount, String charsInfo) { - return """ + return ShortDramaDirectorSkills.load("director-blocking") + """ 你是一位经验丰富的表演指导(Acting Director)。你的任务是为一组分镜中的每个镜头设计角色的表演细节。 【核心职责】 @@ -2877,13 +3168,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { ObjectNode lighting = rule.putObject("lighting"); lighting.put("direction", "根据场景主光方向保持连续"); - lighting.put("quality", switch (firstNotBlank(panel.getSceneType(), "daily")) { - case "suspense" -> "低调硬光,保留阴影层次"; - case "emotion" -> "柔和侧光,突出面部情绪"; - case "action" -> "高反差光线,强化动作轮廓"; - case "epic" -> "大范围自然光,突出空间规模"; - default -> "自然柔光,肤色与环境协调"; - }); + lighting.put("quality", "沿用当前地点和时段的真实主光,禁止因情绪标签改变色温、方向或曝光"); ArrayNode characters = rule.putArray("characters"); if (panel.getCharacters() != null) { @@ -2896,26 +3181,15 @@ public class ShortDramaServiceImpl implements IShortDramaService { } } - String sceneType = firstNotBlank(panel.getSceneType(), "daily"); - rule.put("depth_of_field", switch (sceneType) { - case "epic" -> "深景深,清晰展现环境规模"; - case "emotion" -> "浅景深,突出角色表情"; - case "suspense" -> "中浅景深,保留环境压迫感"; - default -> "中等景深,兼顾角色与环境"; - }); - rule.put("color_tone", switch (sceneType) { - case "suspense" -> "冷暗色调"; - case "emotion" -> "克制柔和色调"; - case "action" -> "高对比高饱和色调"; - case "epic" -> "宏大通透色调"; - default -> "自然统一色调"; - }); + rule.put("depth_of_field", "依景别保留叙事所需的人物与道具清晰度,对话双人镜避免一人失焦"); + rule.put("color_tone", "同地点同时间保持曝光、白平衡与参考图一致"); + rule.put("axis", "先建立空间;同一对话轴同侧拍摄,反打保留视线方向,改变轴侧须经过中性机位或明确移动"); rules.add(rule); } return rules; } - private static List buildLocalActingDirections(List panels) { + static List buildLocalActingDirections(List panels) { List directions = new ArrayList<>(); for (StoryboardPanelData panel : panels) { ActingDirectionResult result = new ActingDirectionResult(); @@ -2925,7 +3199,19 @@ public class ShortDramaServiceImpl implements IShortDramaService { for (CharacterRef ref : panel.getCharacters()) { ObjectNode character = characters.addObject(); character.put("name", firstNotBlank(ref.getName(), "角色")); - character.put("acting", localActingText(panel.getSceneType(), panel.getDuration())); + String acting = null; + if (panel.getPerformanceBeats() != null && panel.getPerformanceBeats().isArray()) { + for (JsonNode beat : panel.getPerformanceBeats()) { + if (ref.getName() != null && ref.getName().equals(beat.path("name").asText())) { + acting = beat.path("acting").asText(null); + break; + } + } + } + character.put("acting", firstNotBlank(acting, + "依据本镜原文回应,不新增情绪或台词。原文:" + firstNotBlank(panel.getSourceText(), panel.getDescription(), "") + + ";当前行动:" + firstNotBlank(panel.getStoryAction(), "按原文行动") + + ";回应后的结果:" + firstNotBlank(panel.getStoryResult(), panel.getEndState(), "保持原文状态"))); } } result.setCharacters(characters); @@ -2934,20 +3220,6 @@ public class ShortDramaServiceImpl implements IShortDramaService { return directions; } - private static String localActingText(String sceneType, Integer duration) { - String performance = switch (firstNotBlank(sceneType, "daily")) { - case "emotion" -> "表情逐步变化,先克制呼吸,再通过眼神和细微肢体释放情绪"; - case "action" -> "动作预备清晰,发力过程连贯,结束后保留重心与呼吸反馈"; - case "epic" -> "动作沉稳有力量,视线跟随环境或对手变化,保持画面张力"; - case "suspense" -> "控制呼吸与眨眼频率,用迟疑、停顿和缓慢转头制造紧张感"; - default -> "保持自然呼吸和目光交流,动作从起始状态平滑过渡到结束状态"; - }; - if (duration != null && duration >= 8) { - performance += ",按前段、中段、后段完成三个连续表演节拍"; - } - return performance; - } - private static void mergePhotographyRules(List panels, List photographyRules) { validatePhotographyRules(panels, photographyRules); applyPhotographyRules(panels, photographyRules); @@ -3035,27 +3307,73 @@ public class ShortDramaServiceImpl implements IShortDramaService { private void executePhase6_StoryboardDetail(ChatModel chatModel, StreamingChatModel streamingModel, List panels, Long projectId, SseEmitter emitter) { + executePhase6_StoryboardDetail(chatModel, streamingModel, panels, projectId, emitter, "default"); + if (emitter != null) emit(emitter, "storyboard_detail", "done", "全部镜头细化完成"); + } + + private void executePhase6_StoryboardDetail(ChatModel chatModel, StreamingChatModel streamingModel, + List panels, Long projectId, SseEmitter emitter, String modelName) { + if (panels.size() > 8) { + String tenant = org.ruoyi.common.tenant.helper.TenantHelper.getTenantId(); + int batches = (panels.size() + 7) / 8; + java.util.concurrent.ExecutorService pool = Executors.newFixedThreadPool(Math.min(3, batches)); + java.util.concurrent.atomic.AtomicInteger finished = new java.util.concurrent.atomic.AtomicInteger(); + try { + List> futures = new ArrayList<>(); + for (int offset = 0; offset < panels.size(); offset += 8) { + List batch = panels.subList(offset, Math.min(offset + 8, panels.size())); + futures.add(pool.submit(() -> org.ruoyi.common.tenant.helper.TenantHelper.dynamic(tenant, () -> { + executePhase6_StoryboardDetail(chatModel, streamingModel, batch, projectId, emitter, modelName); + int count = finished.incrementAndGet(); + if (emitter != null) emit(emitter, "storyboard_detail", "running", "镜头细化已完成 " + count + "/" + batches + " 批"); + }))); + } + List errors = new ArrayList<>(); + for (java.util.concurrent.Future future : futures) { + try { future.get(); } + catch (java.util.concurrent.ExecutionException e) { errors.add(e.getCause().getMessage()); } + } + if (!errors.isEmpty()) throw new IllegalStateException(String.join(";", errors)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("镜头细化被中断,已完成批次已保存", e); + } finally { + pool.shutdownNow(); + } + return; + } try { String panelsJson = JsonUtils.toJsonString(panels); - String charsAgeGender = buildCharactersAgeGenderString(projectId); + String charsAgeGender = buildCharactersAgeGenderString(projectId) + + "\n【已确认的角色形象,必须逐镜保持,禁止自行换装】\n" + buildCharacterAppearanceConstraints(projectId); String locsDesc = buildLocationsDescString(projectId); String prompt = buildStoryboardDetailPrompt(panelsJson, charsAgeGender, locsDesc, artStyleSuffix(projectId), projectAspectRatio(projectId)); - String response; - if (emitter != null) { - StreamingChatModel activeStreamingModel = streamingModel != null ? streamingModel : buildStreamingChatModel(); - response = streamingChat(activeStreamingModel, chatModel, prompt, emitter, "storyboard_detail"); - } else { - response = chatModel.chat(prompt); + String detailCacheKey = "short-drama:checkpoint:v1:" + projectId + ":detail:v2:" + cn.hutool.crypto.digest.DigestUtil.sha256Hex(modelName + "\n" + prompt); + String response = RedisUtils.getCacheObject(detailCacheKey); + if (StrUtil.isBlank(response)) { + for (int attempt = 1; attempt <= 2; attempt++) { + try { + if (emitter != null) { + StreamingChatModel activeStreamingModel = streamingModel != null ? streamingModel : buildStreamingChatModel(); + response = streamingChat(activeStreamingModel, chatModel, prompt, emitter, "storyboard_detail"); + } else { + response = chatModel.chat(prompt); + } + break; + } catch (Exception e) { + if (attempt == 2) throw e; + if (emitter != null) emit(emitter, "storyboard_detail", "running", "本批连接中断,正在重试;已完成批次已保存"); + } + } } List results = parseJsonArray(extractJson(response), StoryboardDetailResult.class); if (results != null) { int matched = 0; for (StoryboardDetailResult r : results) { if (r.getPanelNumber() == null) continue; - int idx = r.getPanelNumber() - 1; - if (idx >= 0 && idx < panels.size()) { - StoryboardPanelData panel = panels.get(idx); + StoryboardPanelData panel = panels.stream().filter(p -> r.getPanelNumber().equals(p.getPanelNumber())).findFirst().orElse(null); + if (panel != null) { panel.setShotType(r.getShotType()); panel.setCameraMove(r.getCameraMove()); panel.setVideoPrompt(r.getVideoPrompt()); @@ -3065,11 +3383,11 @@ public class ShortDramaServiceImpl implements IShortDramaService { panel.setEndState(firstNotBlank(r.getEndState(), panel.getEndState())); panel.setContinuityAction(firstNotBlank(r.getContinuityAction(), panel.getContinuityAction())); panel.setSpatialAnchor(firstNotBlank(r.getSpatialAnchor(), panel.getSpatialAnchor())); - if (r.getPresentCharacters() != null && !r.getPresentCharacters().isEmpty()) { + if (panel.getPresentCharacters() == null && r.getPresentCharacters() != null) { panel.setPresentCharacters(r.getPresentCharacters()); } - panel.setSceneNumber(r.getSceneNumber() != null ? r.getSceneNumber() : panel.getSceneNumber()); - panel.setSegmentNumber(r.getSegmentNumber() != null ? r.getSegmentNumber() : panel.getSegmentNumber()); + + panel.setSegmentGoal(firstNotBlank(r.getSegmentGoal(), panel.getSegmentGoal())); panel.setSegmentResult(firstNotBlank(r.getSegmentResult(), panel.getSegmentResult())); panel.setBridgeIn(firstNotBlank(r.getBridgeIn(), panel.getBridgeIn())); @@ -3086,98 +3404,25 @@ public class ShortDramaServiceImpl implements IShortDramaService { matched++; } } - // 节拍校验 + 二次补写:video_prompt 节拍数低于 ⌈duration/3⌉ 时补写一次 - if (matched > 0 && streamingModel == null && emitter == null) { - ensureVideoPromptBeats(chatModel, panels, projectId); - } + // Natural pauses are valid. Do not inflate action count merely to fill duration. if (matched == 0 && emitter != null) { emit(emitter, "storyboard_detail", "error", "分镜细化JSON解析成功但未匹配到任何镜头"); } else if (matched > 0 && emitter != null) { - emit(emitter, "storyboard_detail", "done", "分镜细化完成(" + matched + "/" + panels.size() + ")"); - } - } else if (emitter != null) { - emit(emitter, "storyboard_detail", "error", "分镜细化失败:LLM返回格式异常"); - } - } catch (Exception e) { - log.warn("Phase 6 分镜细化失败: {}", e.getMessage()); - if (emitter != null) emit(emitter, "storyboard_detail", "error", "分镜细化异常:" + e.getMessage()); - } - } - - /** - * 校验每个 panel 的 video_prompt 节拍数(按顿号/逗号/句号粗估可见动作短语), - * 低于 ⌈duration/3⌉ 时发起一次二次 LLM 调用补写。补写后再次校验,仍不达标则保留并记 warn。 - * 仅在非流式(同步生成)模式下执行,避免流式场景重复请求。 - */ - private void ensureVideoPromptBeats(ChatModel chatModel, List panels, Long projectId) { - List deficient = new ArrayList<>(); - for (StoryboardPanelData panel : panels) { - if (StrUtil.isBlank(panel.getVideoPrompt())) continue; - int duration = panel.getDuration() != null && panel.getDuration() > 0 ? panel.getDuration() : 6; - int required = Math.max(2, (duration + 2) / 3); - int actual = countBeats(panel.getVideoPrompt()); - if (actual < required) { - deficient.add(panel); - } - } - if (deficient.isEmpty()) return; - try { - String supplementPrompt = buildVideoPromptSupplementPrompt(deficient, artStyleSuffix(projectId), projectAspectRatio(projectId)); - String response = chatModel.chat(supplementPrompt); - List results = parseJsonArray(extractJson(response), StoryboardDetailResult.class); - if (results != null) { - for (StoryboardDetailResult r : results) { - if (r.getPanelNumber() == null) continue; - int idx = r.getPanelNumber() - 1; - if (idx >= 0 && idx < panels.size() && StrUtil.isNotBlank(r.getVideoPrompt())) { - StoryboardPanelData panel = panels.get(idx); - if (StrUtil.isNotBlank(r.getDescription())) panel.setDescription(r.getDescription()); - panel.setVideoPrompt(r.getVideoPrompt()); - log.info("Phase 6 节拍补写完成 panel={} 节拍 {}->{}", - r.getPanelNumber(), - countBeats(panel.getVideoPrompt()), - panel.getVideoPrompt()); - } + emit(emitter, "storyboard_detail", "running", "本批细化完成(" + matched + "/" + panels.size() + ")"); } } + if (panels.stream().anyMatch(p -> StrUtil.isBlank(p.getVideoPrompt()) || StrUtil.isBlank(p.getImagePrompt()))) { + throw new IllegalStateException("镜头细化不完整,旧分镜已保留,请重试"); + } + RedisUtils.setCacheObject(detailCacheKey, response, java.time.Duration.ofDays(7)); } catch (Exception e) { - log.warn("Phase 6 节拍补写失败: {}", e.getMessage()); + throw new IllegalStateException("分镜细化失败:" + e.getMessage(), e); } } - /** 粗估 video_prompt 的可见节拍数:按顿号、逗号、分号、句号、换行切分的动作短语数。 */ - private static int countBeats(String videoPrompt) { - if (StrUtil.isBlank(videoPrompt)) return 0; - String[] parts = videoPrompt.split("[、,,;;。\n]"); - int count = 0; - for (String p : parts) { - String t = p.trim(); - if (t.length() >= 2) count++; - } - return count; - } - - private static String buildVideoPromptSupplementPrompt(List deficient, String artStyle, String aspectRatio) { - StringBuilder json = new StringBuilder(); - for (StoryboardPanelData p : deficient) { - json.append(JsonUtils.toJsonString(p)).append(","); - } - if (json.length() > 0 && json.charAt(json.length() - 1) == ',') json.deleteCharAt(json.length() - 1); - return """ - 以下是 video_prompt 节拍数不足的分镜,每个镜头的 video_prompt 必须按时长写出足够可见节拍。 - 按导演笔记风格重写 video_prompt(景别+机位、按时序的动作节拍、运镜、光影方向、道具、台词),禁止参数堆砌。 - duration 为 4-7 秒写 3 个连续节拍,8 秒以上按前段/中段/后段写至少 3 节拍。保留原有信息,只扩写动作细节。 - 只返回 JSON 数组,字段:panel_number、video_prompt、description。 - - 视觉风格:%s - 画幅:%s - 待补写分镜:[%s] - """.formatted(artStyle, aspectRatio, json); - } - private static String buildStoryboardDetailPrompt(String panelsJson, String charsAgeGender, String locsDesc, String artStyle, String aspectRatio) { - return """ + return ShortDramaDirectorSkills.load("emotional-dialogue", "director-blocking", "visual-world", "real-material", "director-review") + """ 你是顶级电影分镜师。根据分镜规划和场景类型,设计镜头语言和视频提示词。 【你的职责】 @@ -3189,11 +3434,11 @@ public class ShortDramaServiceImpl implements IShortDramaService { 镜头运动:固定/缓推/缓拉/跟随/急推/急拉/环绕/升起/俯冲/手持晃动 【根据scene_type选择镜头风格】 - daily:中景、近景为主,平视+越肩,优先使用缓推/缓拉/轻微跟随 - emotion:近景、特写捕捉情绪,缓慢推进、环绕运镜 - action:景别快速切换,特写+全景交替,仰拍/俯拍/荷兰角,急推急拉/跟随/手持晃动 - epic:必须有大远景建立规模,俯拍/升起/俯冲 - suspense:主观视角/荷兰角,缓慢推进制造压迫 + daily:中景、近景为主,优先固定机位;人物移动或信息揭示时才跟随或推近 + emotion:近景、特写捕捉反应;允许固定机位留出停顿,不自动添加环绕 + action:按清晰可辨的动作选择景别,一个镜头一个主要动作;不要在单镜内连续切机位 + epic:必要时用远景建立规模,按实际空间选择机位 + suspense:通过信息遮挡、视线与反应建立悬念,运镜有具体叙事理由才采用 【全局视觉约束 - 最高优先级】 - 视觉风格:%s。description、video_prompt、image_prompt都必须显式体现该风格 @@ -3203,7 +3448,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { 【三级结构审核与修正 - 最高优先级】 在设计镜头语言前,先审核输入的scene_number/segment_number分组并直接在输出中修正字段: - 每个segment的duration总和必须≤15秒;超过时在合理剧情节点增加segment_number - - 普通单镜5-8秒;超过8秒只允许用于不可拆的连续台词、连续动作或史诗建立镜头;任何镜头≤15秒 + - 单镜按对白、独占动作与停顿预算分配1-15秒;超出15秒时在完整动作或语句边界拆镜 - 同一segment内必须围绕同一个segment_goal,结尾落实segment_result - 相邻segment的bridge_out与bridge_in必须能直接连读;不匹配时重写桥梁字段和当前镜头描述 - 检查A→C跳跃:位置、姿态、情绪、信息或道具状态缺少B过程时,必须把B过程融入相邻镜头描述 @@ -3216,6 +3461,12 @@ public class ShortDramaServiceImpl implements IShortDramaService { - 如果story_result无法在当前时长内可视化,必须简化动作而不是丢掉结果 - 不得擅自加入输入因果链之外的新事件、台词、能力或角色行为 + 【角色与素材一致性 - 最高优先级】 + - 必须遵守提供的角色形象档案:发型、服装、年龄保持一致。不得根据场景或职业自行添加工装、围裙、外套、伤痕或手机裂纹。 + - 声源角色不等于出镜角色,是否出镜以present_characters及原文为准;手机录音不生成说话者的实体画面。 + - 原文标注后期插入的照片、程序界面、账本与表格,必须保留后期替换说明,只设计手部、视线和操作。不要要求视频模型逐字绘制真实文字。 + - 长账目可以用核对动作、屏幕素材和短句传达,避免在几秒内快速念完整张表。不得修改地址、金额、收款状态或其他事实。 + 【前后镜头连续性 - 最高优先级】 - 输入已经包含start_state、end_state、continuity_action、spatial_anchor、present_characters,必须完整保留并落实到description/video_prompt/image_prompt - 当前镜头开头必须明确从start_state开始,结尾必须准确停在end_state @@ -3227,30 +3478,34 @@ public class ShortDramaServiceImpl implements IShortDramaService { 【时长匹配规则 - 最高优先级】 - 输入中的duration就是目标视频秒数,video_prompt必须完整覆盖该时长 - duration为4-7秒:至少写清开始动作、持续变化、结束状态 - - duration为8秒及以上:必须按“前段→中段→后段”写至少3个连续可见节拍,并包含持续运镜或环境变化 + - 长镜头允许静止倾听、呼吸和反应停顿;一个镜头只承担一个主要叙事动作,不因时长长而强加运镜或新动作 - 禁止用一个瞬时动作支撑长镜头,例如仅“吐信、转头、抬手”却标10秒 - - description也必须同步扩写这些节拍,保证画面描述与video_prompt一致 + - description与video_prompt共享同一动作、台词和起止状态,不能为了填时长增加剧情 【video_prompt撰写规则 - 重要】 video_prompt 是发给视频模型的核心可拍指令,必须用"导演笔记"风格写,每个字都可拍、按时序展开。视频模型不认识名字,必须用年龄段+性别替代角色: - 年龄段:少年/少女(10-16)、年轻男子/年轻女子(17-30)、中年男子/中年女子(31-50)、老年男子/老年女子(50+) - 必须依次包含以下可拍维度: 1) 景别+机位架设位置:如"中景,机位架设在店内深处正对门口" - 2) 主体动作(按时序):按 duration 分档写连续可见节拍——4-7秒写"开始动作→持续变化→结束状态";8秒及以上按"前段→中段→后段"写至少3个连续可见动作。每秒至少一个可见动作变化,禁止用一个瞬时动作支撑长镜头 + 2) 主体动作(按时序):写清一个主动作及结果,允许自然停顿。不得要求每秒新增动作。台词、独占动作、反应停顿合计须装得下;可同步的眼神动作不重复计时 3) 运镜:从镜头运动词库选一个主导运镜(推/拉/摇/移/跟/环绕/手持/固定),写明方向与节奏,禁止只用"缓缓"这种无信息量词 4) 光影:方向+色温+阴影色,与当前镜头光源位置绑死(如"晨光从卷帘门缝隙逆光射入,发丝边缘泛柔光,店内深处阴影偏冷蓝"),禁止情绪词 5) 道具/穿着:从 source_text 和角色设定提取具体道具与穿着,写进动作流 6) 台词:若 source_text 有台词,以「角色说的话」标注并标语气(小声/平淡/恳求),供后续口型对齐;无台词则不写 - 禁止参数堆砌(8K/HDR/fps/Rec.色域这类),视频模型不认 - - 禁止纯静态描述,特写镜头必须使用"固定镜头" + - 静态机位允许人物静止倾听;特写优先固定镜头,保留自然呼吸与反应 - description 必须与 video_prompt 的节拍、光影、动作一致 - 【动态优先原则 - 核心规则】 - 视频不能僵硬!每个video_prompt必须按时序含可见动作。即使对话场景也要有动作变化。 - ✅ 正确示例(6秒、2节拍):"中景,机位架设在店内深处正对门口。年轻女子从右侧卷帘门推门进入,门推开约45°,晨光从门缝逆光射入在她发丝边缘形成柔光晕。她站定在门口,身体微前倾又顿住,手里攥着一张揉皱的纸,眼神在店内游移后落向画面中央偏左的座位。镜头手持跟随,从门口缓推至她停步处,保持中景距离。她小声开口:「那个……这里理发吗?」" - ❌ 错误:"年轻女子坐在沙发上,镜头固定"(无节拍、无光影、无运镜节奏) + 【表演与摄影约束】 + - 一个镜头一个主导运镜;固定机位可以完整承载对白和反应,不能每镜都推近。 + - 坐、站、持物手、门开闭、电源和关键道具位置必须与前后镜及参考图一致。 + - 光线由地点、时间、真实光源决定,不能随情绪标签突然改变。 + - 台词时长按实际发音计算,年份/金额/FM先展开读音。默认约4字/秒,老人约3.2字/秒,仅为预算假设。 + - 先加台词时间、无法同步的动作时间与必要停顿,超时必须拆镜或申请改写;不能删原文台词后假称完整保留。 【image_prompt撰写规则】 + - 只描述start_state的静态起点,不画end_state,不提前完成坐下、递物、取物等动作 + - 已确认的手机、眼镜、衣物等不可被其他道具替代;看手机时间不能擅改为看手表 - 使用角色实际名字(不是年龄段+性别) - 包含角色位置、场景环境、光线氛围 - 纯视觉描述,禁止抽象词 @@ -3295,6 +3550,9 @@ public class ShortDramaServiceImpl implements IShortDramaService { // ==================== 持久化 ==================== private List persistStoryboards(Long projectId, Long scriptId, List panels, ShortDramaScript script) { + if (panels.isEmpty()) throw new IllegalStateException("没有有效分镜,旧分镜已保留"); + return transactionTemplate.execute(status -> { + storyboardMapper.delete(new LambdaQueryWrapper().eq(ShortDramaStoryboard::getScriptId, scriptId)); List result = new ArrayList<>(); int sceneNo = 1; for (StoryboardPanelData panel : panels) { @@ -3324,7 +3582,11 @@ public class ShortDramaServiceImpl implements IShortDramaService { result.add(MapstructUtils.convert(entity, ShortDramaStoryboardVo.class)); sceneNo++; } + ShortDramaProject project = projectMapper.selectById(projectId); + project.setStatus("storyboard_ready"); + projectMapper.updateById(project); return result; + }); } // ==================== 辅助方法:资产库字符串构建 ==================== @@ -3408,6 +3670,22 @@ public class ShortDramaServiceImpl implements IShortDramaService { return sb.toString(); } + String buildCharacterAppearanceConstraints(Long projectId) { + StringBuilder text = new StringBuilder(); + for (ShortDramaCharacter character : characterMapper.selectList(new LambdaQueryWrapper() + .eq(ShortDramaCharacter::getProjectId, projectId))) { + List appearances = characterAppearanceMapper.selectList( + new LambdaQueryWrapper().eq(ShortDramaCharacterAppearance::getCharacterId, character.getId()) + .orderByAsc(ShortDramaCharacterAppearance::getAppearanceIndex)); + if (appearances.isEmpty()) text.append(character.getName()).append(":").append(character.getVisualDescription()).append("\n"); + for (ShortDramaCharacterAppearance appearance : appearances) { + text.append(character.getName()).append(" / ").append(appearance.getChangeReason()).append(":") + .append(firstNotBlank(appearance.getDescription(), character.getVisualDescription(), "沿用已有参考图")).append("\n"); + } + } + return text.toString(); + } + private String buildLocationsDescString(Long projectId) { List locs = locationMapper.selectList( new LambdaQueryWrapper().eq(ShortDramaLocation::getProjectId, projectId)); @@ -3470,7 +3748,14 @@ public class ShortDramaServiceImpl implements IShortDramaService { } private AbstractChatService getChatService(ChatModelVo modelVo) { - return chatServiceFactory.getOriginalService(modelVo.getProviderCode()); + return chatServiceFactory.getOriginalService(shortDramaProviderCode(modelVo.getProviderCode(), modelVo.getModelName())); + } + + static String shortDramaProviderCode(String provider, String model) { + return (org.ruoyi.enums.ChatModeType.OPEN_AI.getCode().equalsIgnoreCase(provider) + || org.ruoyi.enums.ChatModeType.ATLAS.getCode().equalsIgnoreCase(provider)) + && StrUtil.containsIgnoreCase(model, "deepseek") + ? org.ruoyi.enums.ChatModeType.DEEP_SEEK.getCode() : provider; } // ==================== 语音资产 ==================== @@ -3659,24 +3944,29 @@ public class ShortDramaServiceImpl implements IShortDramaService { audioMapper.delete(new LambdaQueryWrapper().eq(ShortDramaAudio::getProjectId, projectId)); } - private static void normalizeContinuityChain(List panels) { + static void normalizeContinuityChain(List panels) { StoryboardPanelData previous = null; int sceneNumber = 1; int segmentNumber = 1; int segmentDuration = 0; String previousLocation = null; + Integer previousRequestedScene = null; for (StoryboardPanelData current : panels) { + Integer requestedScene = current.getSceneNumber(); String currentLocation = firstNotBlank(current.getLocation(), ""); - if (previousLocation != null && !previousLocation.equals(currentLocation)) { + boolean newScene = previous != null && (!java.util.Objects.equals(previousLocation, currentLocation) + || (requestedScene != null && previousRequestedScene != null && !requestedScene.equals(previousRequestedScene))); + if (newScene) { sceneNumber++; segmentNumber = 1; segmentDuration = 0; } int duration = current.getDuration() != null && current.getDuration() > 0 - ? Math.max(5, Math.min(current.getDuration(), 15)) : 6; + ? Math.max(1, Math.min(current.getDuration(), 15)) : 6; current.setDuration(duration); + previousRequestedScene = requestedScene; boolean requestedNewSegment = current.getSegmentNumber() != null && current.getSegmentNumber() > segmentNumber; - if (previous != null && (requestedNewSegment || segmentDuration + duration > 15)) { + if (previous != null && !newScene && (requestedNewSegment || segmentDuration + duration > 15)) { segmentNumber++; segmentDuration = 0; } @@ -3690,7 +3980,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { current.setBridgeIn(firstNotBlank(current.getBridgeIn(), "开场建立")); } else { String previousConsequence = firstNotBlank(previous.getStoryResult(), previous.getNextHook(), previous.getContinuityAction(), previous.getEndState()); - current.setNarrativeCause(previousConsequence); + current.setNarrativeCause(firstNotBlank(current.getNarrativeCause(), previousConsequence)); if (current.getSegmentNumber().equals(previous.getSegmentNumber()) && current.getSceneNumber().equals(previous.getSceneNumber())) { current.setBridgeIn(firstNotBlank(current.getBridgeIn(), previous.getContinuityAction(), previous.getNextHook(), previous.getEndState())); } else { @@ -3698,18 +3988,18 @@ public class ShortDramaServiceImpl implements IShortDramaService { previous.setBridgeOut(firstNotBlank(previous.getBridgeOut(), current.getBridgeIn())); } } - if (previous != null && firstNotBlank(previous.getLocation(), "").equals(firstNotBlank(current.getLocation(), ""))) { + if (previous != null && !newScene) { String inheritedState = firstNotBlank(previous.getEndState(), previous.getDescription(), previous.getSourceText(), ""); - current.setStartState(inheritedState); + current.setStartState(firstNotBlank(current.getStartState(), inheritedState)); if (StrUtil.isBlank(current.getSpatialAnchor())) { current.setSpatialAnchor(previous.getSpatialAnchor()); } - if ((current.getPresentCharacters() == null || current.getPresentCharacters().isEmpty()) + if ((current.getPresentCharacters() == null) && previous.getPresentCharacters() != null) { current.setPresentCharacters(new ArrayList<>(previous.getPresentCharacters())); } } else { - current.setStartState("新场景建立:" + firstNotBlank(current.getLocation(), "新地点") + ",重新交代人物位置、朝向与环境"); + current.setStartState(firstNotBlank(current.getStartState(), "新场景建立:" + firstNotBlank(current.getLocation(), "新地点") + ",重新交代人物位置、朝向与环境")); } current.setEndState(firstNotBlank(current.getEndState(), current.getDescription(), current.getSourceText(), current.getStartState())); current.setContinuityAction(firstNotBlank(current.getContinuityAction(), "从当前结束姿态自然承接下一镜头")); @@ -3721,7 +4011,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { current.setSegmentGoal(firstNotBlank(current.getSegmentGoal(), current.getCharacterGoal(), "完成当前连续剧情动作")); current.setSegmentResult(firstNotBlank(current.getSegmentResult(), current.getStoryResult(), current.getEndState())); current.setBridgeOut(firstNotBlank(current.getBridgeOut(), current.getNextHook(), current.getContinuityAction())); - if (current.getPresentCharacters() == null || current.getPresentCharacters().isEmpty()) { + if (current.getPresentCharacters() == null) { List present = new ArrayList<>(); if (current.getCharacters() != null) { for (CharacterRef ref : current.getCharacters()) { @@ -3754,6 +4044,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { continuity.put("story_action", firstNotBlank(panel.getStoryAction(), "")); continuity.put("story_result", firstNotBlank(panel.getStoryResult(), "")); continuity.put("next_hook", firstNotBlank(panel.getNextHook(), "")); + if (panel.getPerformanceBeats() != null) continuity.set("performance_beats", panel.getPerformanceBeats()); + if (panel.getTiming() != null) continuity.set("timing", panel.getTiming()); return continuity.toString(); } @@ -3783,6 +4075,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { panel.setStoryAction(continuity.path("story_action").asText(null)); panel.setStoryResult(continuity.path("story_result").asText(null)); panel.setNextHook(continuity.path("next_hook").asText(null)); + if (continuity.has("timing")) panel.setTiming(continuity.get("timing")); + if (continuity.has("performance_beats")) panel.setPerformanceBeats(continuity.get("performance_beats")); } catch (Exception e) { log.debug("解析分镜连续性失败: {}", e.getMessage()); } @@ -4075,6 +4369,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { @JsonProperty("source_text") private String sourceText; private Integer duration; + private JsonNode timing; @JsonProperty("start_state") private String startState; @JsonProperty("end_state") @@ -4097,6 +4392,8 @@ public class ShortDramaServiceImpl implements IShortDramaService { private String storyResult; @JsonProperty("next_hook") private String nextHook; + @JsonProperty("performance_beats") + private JsonNode performanceBeats; // Phase 4 fills: private String photographyRules; // Phase 5 fills: @@ -4245,7 +4542,7 @@ public class ShortDramaServiceImpl implements IShortDramaService { private String buildCharacterPrompt(String basePrompt, Long projectId) { String styleSuffix = artStyleSuffix(projectId); String stylePrefix = StrUtil.isNotBlank(styleSuffix) ? styleSuffix + "," : ""; - return stylePrefix + ShortDramaImageConstants.CHARACTER_PROMPT_PREFIX + return ShortDramaDirectorSkills.load("visual-world") + stylePrefix + ShortDramaImageConstants.CHARACTER_PROMPT_PREFIX + basePrompt + ShortDramaImageConstants.CHARACTER_PROMPT_SUFFIX; } diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSoundService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSoundService.java new file mode 100644 index 00000000..b6e91638 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaSoundService.java @@ -0,0 +1,96 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.ruoyi.mapper.shortdrama.ShortDramaProjectMapper; +import org.ruoyi.common.core.service.OssService; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.TimeUnit; + +@Service @RequiredArgsConstructor +public class ShortDramaSoundService { + private final ShortDramaProjectMapper projects; + private final org.ruoyi.common.chat.factory.ImageServiceFactory images; + private static final ObjectMapper JSON=new ObjectMapper(); + public record Sound(String id,String name,String purpose,List shots,double volume,double offset,double duration,String providerUrl) {} + private Path directory(Long project) {return Path.of("data","short-drama-sounds",project.toString()).toAbsolutePath();} + public void owner(Long id,Long user) {var p=projects.selectById(id);if(p==null || !Objects.equals(p.getUserId(),user))throw new IllegalArgumentException("项目不存在或无权限");} + public List list(Long id) throws Exception { + Path dir=directory(id);if(!Files.isDirectory(dir))return List.of(); + try(var files=Files.list(dir)){List all=new ArrayList<>();for(Path f:files.filter(p->p.toString().endsWith(".json")).sorted().toList())all.add(JSON.readValue(f.toFile(),Sound.class));return all;} + } + public Path file(Long project,String id) {if(!id.matches("[a-f0-9-]{36}"))throw new IllegalArgumentException("音频ID无效");return directory(project).resolve(id+".mp3");} + public synchronized Sound upload(Long project,Long user,MultipartFile upload,String purpose,List shots,double volume,double offset) throws Exception { + owner(project,user); + if(!Set.of("bgm","reference").contains(purpose) || shots==null || shots.isEmpty() || shots.stream().anyMatch(n->n<1) || !Double.isFinite(volume) || volume<0 || volume>1 || !Double.isFinite(offset) || offset<0) + throw new IllegalArgumentException("请设置用途、镜号、音量和有效的起点"); + if(upload.isEmpty() || upload.getSize()>19*1024*1024)throw new IllegalArgumentException("音频必须小于19MB"); + Files.createDirectories(directory(project));String id=UUID.randomUUID().toString();Path target=file(project,id); + Path input=Files.createTempFile(directory(project),"input-",".bin"); + try { + upload.transferTo(input); + run(List.of("ffmpeg","-v","error","-y","-i",input.toString(),"-vn","-t","600","-ar","48000","-ac","2","-c:a","libmp3lame",target.toString()),directory(project).resolve(id+".log")); + Path probe=directory(project).resolve(id+".probe"); + run(List.of("ffprobe","-v","error","-show_entries","format=duration","-of","default=nw=1:nk=1",target.toString()),probe); + double duration=Double.parseDouble(Files.readString(probe).trim()); + if(offset>=duration)throw new IllegalArgumentException("起点超出音频长度"); + if("reference".equals(purpose) && (duration<2 || duration>15.1))throw new IllegalArgumentException("生成参考音频应为2至15秒;长音乐请选择背景音乐用途"); + String url=""; + + var sound=new Sound(id,Objects.toString(upload.getOriginalFilename(),"音频"),purpose,shots.stream().distinct().sorted().toList(),volume,offset,duration,url); + JSON.writeValue(directory(project).resolve(id+".json").toFile(),sound);return sound; + }finally{Files.deleteIfExists(input);} + } + public List references(Long project,int shot,org.ruoyi.common.chat.domain.vo.chat.ChatModelVo model) { + try { + List result=new ArrayList<>();double total=0; + for(var s:list(project))if("reference".equals(s.purpose()) && s.shots().contains(shot)) { + if(result.size()>=3)throw new IllegalArgumentException("每镜最多3条参考音频"); + total+=s.duration();if(total>15.1)throw new IllegalArgumentException("每镜参考音频总长不能超过15秒"); + result.add(images.getOriginalService(model.getProviderCode()).uploadMedia(model,Files.readAllBytes(file(project,s.id())),s.name(),"audio/mpeg")); + } + return result; + }catch(Exception e){throw new IllegalStateException("参考音频上传失败,当前模型须支持音频参考:"+e.getMessage(),e);} + } + public Path mix(Long project,List shots,Path video,Path work,double overlap) throws Exception { + var music=list(project).stream().filter(a->"bgm".equals(a.purpose())).toList(); + if(music.isEmpty())return video; + List command=new ArrayList<>(List.of("ffmpeg","-v","error","-y","-i",video.toString())); + List filters=new ArrayList<>();List tracks=new ArrayList<>();int input=1; + for(var sound:music) { + double cursor=0,start=-1,end=0; + List ranges=new ArrayList<>(); + for(var shot:shots) { + double duration=shot.getDurationSeconds(); + if(sound.shots().contains(shot.getSceneNo())) {if(start<0)start=cursor;end=cursor+duration;} + else if(start>=0){ranges.add(new double[]{start,end});start=-1;} + cursor+=Math.max(0,duration-overlap); + } + if(start>=0)ranges.add(new double[]{start,end}); + for(var range:ranges) { + double duration=range[1]-range[0]; + command.addAll(List.of("-stream_loop","-1","-i",file(project,sound.id()).toString())); + String label="m"+input; + filters.add("["+input+":a]atrim=start="+sound.offset()+":duration="+duration+",asetpts=PTS-STARTPTS,volume="+sound.volume()+",afade=t=in:d="+Math.min(1,duration/3)+",afade=t=out:st="+Math.max(0,duration-1)+":d="+Math.min(1,duration/3)+",adelay="+Math.round(range[0]*1000)+":all=1["+label+"]"); + tracks.add("["+label+"]");input++; + } + } + if(tracks.isEmpty())return video; + filters.add(String.join("",tracks)+"amix=inputs="+tracks.size()+":normalize=0[music]"); + filters.add("[0:a]asplit=2[dialogue][side]"); + filters.add("[music][side]sidechaincompress=threshold=0.03:ratio=8:attack=20:release=400[ducked]"); + // Disable limiter make-up gain and leave headroom for AAC intersample peaks. + filters.add("[dialogue][ducked]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.7:level=false[out]"); + Path out=work.resolve("with-background-music.mp4"); + command.addAll(List.of("-filter_complex",String.join(";",filters),"-map","0:v:0","-map","[out]","-c:v","copy","-c:a","aac","-b:a","192k","-movflags","+faststart",out.toString())); + run(command,work.resolve("music-mix.log"));return out; + } + public static void run(List command,Path log) throws Exception { + Process p=new ProcessBuilder(command).redirectErrorStream(true).redirectOutput(log.toFile()).start(); + if(!p.waitFor(120,TimeUnit.SECONDS)){p.destroyForcibly();throw new IllegalStateException("音频处理超时");} + if(p.exitValue()!=0)throw new IllegalArgumentException("无法解码音频,请使用有效的音频文件"); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaTiming.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaTiming.java new file mode 100644 index 00000000..bf6d47b2 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaTiming.java @@ -0,0 +1,44 @@ +package org.ruoyi.service.shortdrama.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.regex.Pattern; + +/** Editable production estimate. Recorded speech duration should replace estimates once available. */ +public final class ShortDramaTiming { + private ShortDramaTiming() { } + private static final Pattern QUOTE = Pattern.compile("「([^」]+)」"); + private static final ObjectMapper JSON = new ObjectMapper(); + + public static double requiredSeconds(String source, String continuity) { + JsonNode root; + try { root = JSON.readTree(continuity == null || continuity.isBlank() ? "{}" : continuity); } + catch (Exception e) { throw new IllegalArgumentException("镜头承接信息须为有效JSON", e); } + if (root == null || !root.isObject()) throw new IllegalArgumentException("镜头承接信息须为JSON对象"); + JsonNode t = root.path("timing"); + StringBuilder fallback = new StringBuilder(); + var matcher = QUOTE.matcher(source == null ? "" : source); + while (matcher.find()) fallback.append(matcher.group(1)).append(' '); + String spoken = t.has("spoken_text") ? t.path("spoken_text").asText() : fallback.toString(); + double units = Math.max(spokenUnits(spoken), spokenUnits(fallback.toString())); + double rate = t.path("speech_rate").asDouble(4); + if (!Double.isFinite(rate) || rate < 2 || rate > 5) throw new IllegalArgumentException("对白语速须在2至5字/秒之间"); + double measured = t.path("measured_audio_seconds").asDouble(0); + double action = t.path("action_seconds").asDouble(0); + double pause = t.path("pause_seconds").asDouble(spoken.isBlank() ? 0 : 2); + if (!Double.isFinite(action) || !Double.isFinite(pause) || !Double.isFinite(measured) + || action < 0 || pause < 0 || measured < 0) throw new IllegalArgumentException("时间预算必须是非负有限数值"); + return Math.ceil(((measured > 0 ? measured : units / rate) + action + pause) * 10) / 10; + } + + private static double spokenUnits(String text) { + return text.codePoints().mapToDouble(cp -> cp >= 0x3400 && cp <= 0x9fff ? 1 + : cp >= '0' && cp <= '9' ? 1.5 : (cp >= 'a' && cp <= 'z') || (cp >= 'A' && cp <= 'Z') ? 1 : 0).sum(); + } + + public static void validate(Integer number, Integer duration, String source, String continuity) { + if (duration == null || duration <= 0 || duration > 60) throw new IllegalArgumentException("镜头时长须在1至60秒之间"); + double required = requiredSeconds(source, continuity); + if (required > duration + 0.05) throw new IllegalArgumentException("镜头" + number + "内容至少约" + required + "秒,分配仅" + duration + "秒,请缩短台词、拆镜或延长时长"); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeWorker.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeWorker.java index a5e77a94..00697495 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeWorker.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoComposeWorker.java @@ -48,6 +48,7 @@ public class ShortDramaVideoComposeWorker { private final FfmpegCompositionProperties compositionProperties; private final SafeVideoSourceDownloader sourceDownloader; private final OssService ossService; + private final ShortDramaSoundService sounds; @Async("videoCompositionExecutor") public void composeAsync(ShortDramaVideoComposeJob job) { @@ -83,6 +84,7 @@ public class ShortDramaVideoComposeWorker { return; } + Path finalPath=sounds.mix(job.projectId(),storyboards,artifact.path(),workDirectory,job.transitionType().toString().equalsIgnoreCase("NONE")?0:job.transitionDurationSeconds().doubleValue()); ShortDramaProject current = selectActiveProject(job); if (current == null) { return; @@ -90,9 +92,9 @@ public class ShortDramaVideoComposeWorker { Long previousOssId = current.getComposedVideoOssId(); boolean localStorage = "local".equalsIgnoreCase(compositionProperties.getStorageMode()); if (localStorage) { - persistLocalComposition(job.projectId(), artifact.path()); + persistLocalComposition(job.projectId(), finalPath); } else { - OssDTO uploaded = ossService.uploadFile(artifact.path().toFile()); + OssDTO uploaded = ossService.uploadFile(finalPath.toFile()); if (uploaded == null || uploaded.getOssId() == null) { throw new ServiceException("成片上传对象存储失败"); } @@ -182,16 +184,18 @@ public class ShortDramaVideoComposeWorker { ShortDramaStoryboard storyboard = storyboards.get(index); Path target = workDirectory.resolve("clip-" + String.format("%04d", index + 1) + ".mp4"); long remainingTotalBytes = maxTotalSourceBytes - totalBytes; - totalBytes += sourceDownloader.download( - storyboard.getVideoUrl(), - target, - maxSourceBytes, - remainingTotalBytes - ); + String localPrefix="/short-drama/"+job.projectId()+"/materials/video/"; + if(storyboard.getVideoUrl().startsWith(localPrefix)) { + String id=storyboard.getVideoUrl().substring(localPrefix.length()); + if(!id.matches("[a-f0-9-]{36}"))throw new ServiceException("素材片段路径无效"); + Path source=Path.of("data","short-drama-materials",job.projectId().toString(),id+".mp4"); + long size=Files.size(source);if(size>maxSourceBytes || size>remainingTotalBytes)throw new ServiceException("素材超过大小限制"); + Files.copy(source,target);totalBytes+=size; + } else totalBytes += sourceDownloader.download(storyboard.getVideoUrl(),target,maxSourceBytes,remainingTotalBytes); Double fallbackDuration = storyboard.getDurationSeconds() == null ? null : storyboard.getDurationSeconds().doubleValue(); - sources.add(new CompositionSource(target, fallbackDuration)); + sources.add(new CompositionSource(target, fallbackDuration, fallbackDuration)); int progress = 10 + (int) Math.round(30.0 * (index + 1) / storyboards.size()); if (!updateProgress(job, progress)) { return List.of(); diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVisualAssetService.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVisualAssetService.java new file mode 100644 index 00000000..146ac842 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/shortdrama/impl/ShortDramaVisualAssetService.java @@ -0,0 +1,410 @@ +package org.ruoyi.service.shortdrama.impl; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.crypto.digest.DigestUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo; +import org.ruoyi.common.chat.entity.image.ImageContext; +import org.ruoyi.common.chat.factory.ImageServiceFactory; +import org.ruoyi.common.chat.service.chat.IChatModelService; +import org.ruoyi.common.json.utils.JsonUtils; +import org.ruoyi.common.tenant.helper.TenantHelper; +import org.ruoyi.domain.entity.shortdrama.*; +import org.ruoyi.mapper.shortdrama.*; +import org.ruoyi.factory.ChatServiceFactory; +import org.ruoyi.service.media.AtlasPredictionService; +import org.springframework.stereotype.Service; +import java.util.*; +import java.util.concurrent.*; + +/** Identity/location masters remain stable; props and shot compositions have separate lifecycles. */ +@Service @RequiredArgsConstructor @Slf4j +public class ShortDramaVisualAssetService { + private final ShortDramaVisualAssetMapper assets; + private final ShortDramaProjectMapper projects; + private final ShortDramaScriptMapper scripts; + private final ShortDramaStoryboardMapper boards; + private final ShortDramaCharacterMapper characters; + private final ShortDramaCharacterAppearanceMapper appearances; + private final ShortDramaLocationMapper locations; + private final IChatModelService models; + private final ChatServiceFactory chats; + private final ImageServiceFactory images; + private final AtlasPredictionService predictions; + private static final ObjectMapper JSON = new ObjectMapper(); + private final ExecutorService coordinators = Executors.newFixedThreadPool(2); + private final ExecutorService imageWorkers = Executors.newFixedThreadPool(4); + private final ConcurrentHashMap running = new ConcurrentHashMap<>(); + private final ConcurrentHashMap errors = new ConcurrentHashMap<>(); + @PreDestroy void close() { coordinators.shutdownNow(); imageWorkers.shutdownNow(); } + + private ShortDramaProject owner(Long id, Long user) { + var p = projects.selectById(id); + if (p == null || !Objects.equals(user, p.getUserId())) throw new IllegalArgumentException("项目不存在或无权限"); + return p; + } + private List rows(Long id) { + return assets.selectList(new LambdaQueryWrapper().eq(ShortDramaVisualAsset::getProjectId,id) + .ne(ShortDramaVisualAsset::getStatus,"obsolete").orderByAsc(ShortDramaVisualAsset::getId)); + } + public record AssetView(Long id,Long storyboardId,String kind,String title,String prompt,String status, + String imageUrl,String model,String predictionId,String errorMessage) { } + public Map status(Long id, Long user) { + var project=owner(id,user); + var rows = rows(id); + return Map.of("outdated","script_changed".equals(project.getStatus()),"running",running.containsKey(id),"phase",running.getOrDefault(id,"idle"), + "error",errors.getOrDefault(id,""),"assets",rows.stream().map(a->new AssetView(a.getId(),a.getStoryboardId(),a.getKind(),a.getTitle(),a.getPrompt(),a.getStatus(),a.getImageUrl(),a.getModel(),a.getPredictionId(),a.getErrorMessage())).toList(), + "completed",rows.stream().filter(a -> "done".equals(a.getStatus())).count(),"total",rows.size()); + } + public Map plan(Long id, String model, Long user) { + requireCurrentScript(id,user); + if (running.putIfAbsent(id,"正在分析关键道具与镜头覆盖") != null) throw new IllegalStateException("资产任务正在运行"); + errors.remove(id); + String tenant = TenantHelper.getTenantId(); + coordinators.submit(() -> TenantHelper.dynamic(tenant, () -> { + try { planNow(id,model); } + catch(Exception e) { errors.put(id,safeError(e)); log.warn("视觉资产规划失败 project={}: {}",id,safeError(e)); } + finally { running.remove(id); } + })); + return status(id,user); + } + private void planNow(Long id, String model) throws Exception { + var shots=boards.selectList(new LambdaQueryWrapper().eq(ShortDramaStoryboard::getProjectId,id).orderByAsc(ShortDramaStoryboard::getSceneNo)); + if(shots.isEmpty()) throw new IllegalStateException("请先确认分镜,再规划逐镜资产"); + var script=scripts.selectById(shots.get(0).getScriptId()); + var m=models.selectModelByName(model); + if(m==null) { var q=new ChatModelBo();q.setCategory("chat");m=models.queryAvailableList(q).stream().findFirst().orElseThrow(); } + var chat=chats.getOriginalService(ShortDramaServiceImpl.shortDramaProviderCode(m.getProviderCode(),m.getModelName())).buildChatModel(m); + // Do not invent extra characters or costumes merely to increase image counts. + String prompt="从剧本提取反复出现、跨镜必须保持外观一致的关键实物道具。不是人物、建筑、屏幕截图或人物动作。只要3-10项,若不足3项按实际数量。合并同一个物体的别称,不把同型号的不同物体强行合并。描述外形材质颜色磨损,不新增品牌或剧情;账页和屏幕清晰文字由后期素材替换。返回JSON数组 [{\"name\":\"物体名\",\"description\":\"可生成单一道具定妆图的描述\",\"shot_numbers\":[1,2]}]。shot_numbers只填该实物真实出现的镜头,不按同名词误配。剧本:\n"+script.getScriptText()+"\n镜头列表:\n"+ + shots.stream().map(s->s.getSceneNo()+":"+s.getSourceText()+"\n本镜画面设计:"+Objects.toString(s.getImagePrompt(),"")) + .reduce("",(a,b)->a+b+"\n") + +"\n正文是简述,道具覆盖必须同时参考各镜画面设计。区分画面中可见、包内收起、禁止出现等语境;不能因通用提示里出现一个物体名就绑定所有镜头。"; + String raw=chat.chat(prompt); int start=raw.indexOf('['),end=raw.lastIndexOf(']'); + if(start<0 || end12) throw new IllegalStateException("道具清单格式不正确"); + Set keys=new HashSet<>(); + Set numbers=new HashSet<>();shots.forEach(s->numbers.add(s.getSceneNo())); + for(var prop:props) { + String name=prop.path("name").asText().trim(), description=prop.path("description").asText().trim(); + if(name.isBlank() || description.isBlank() || !prop.path("shot_numbers").isArray()) throw new IllegalStateException("道具缺少外观或镜头绑定"); + List linked=new ArrayList<>();for(var n:prop.path("shot_numbers")) if(numbers.contains(n.asInt())) linked.add(n.asInt()); + if(linked.isEmpty()) continue; + String key="prop:"+name; if(!keys.add(key)) continue; + String desc="写实电影道具定妆图。单个物体,简洁中性背景,清楚表现正面和关键结构,不出现人物、拼贴分格、水印或额外道具。"+description; + // reference_images holds typed linkage for props until image generation; no external URL is inferred. + upsert(id,null,key,"prop",name,desc,JsonUtils.toJsonString(linked),DigestUtil.sha256Hex(desc+linked)); + } + String propsVersion=props.toString(); + for(var shot:shots) { + if (directInsert(shot.getContinuityJson())) continue; + var refs=baseReferences(shot); + if(refs.isEmpty()) throw new IllegalStateException("镜头"+shot.getSceneNo()+"缺少已确认的人物/场景参考图"); + String key="shot:"+shot.getId();keys.add(key); + String framePrompt=framePrompt(shot); + upsert(id,shot.getId(),key,"shot_frame","镜 "+shot.getSceneNo()+" · "+shot.getSceneTitle(),framePrompt, + JsonUtils.toJsonString(Map.of("urls",refs,"propsVersion",propsVersion)),frameHash(shot,refs,propsVersion)); + } + for(var old:rows(id)) if(!keys.contains(old.getAssetKey()) && !Objects.toString(old.getSourceHash(),"").startsWith("locked:")) assets.update(null,new LambdaUpdateWrapper().eq(ShortDramaVisualAsset::getId,old.getId()).set(ShortDramaVisualAsset::getStatus,"obsolete")); + } + static String framePrompt(ShortDramaStoryboard shot) { + String start="", anchor="", present="", directive=""; + try { var continuity=JSON.readTree(Objects.toString(shot.getContinuityJson(),"{}")); + directive=continuity.path("keyframe_directive").asText(""); + start=continuity.path("start_state").asText(""); anchor=continuity.path("spatial_anchor").asText(""); + present=continuity.path("present_characters").toString(); + var cast=continuity.path("present_characters"); + if(cast.isArray() && cast.size()==1) { + // A scene-wide anchor can mention someone who has not entered this shot yet. + // For a solo shot use its structured binding, not the shared cast description. + String name=cast.get(0).asText(), slot=""; + var bindings=JSON.readTree(Objects.toString(shot.getCharactersJson(),"[]")); + for(var binding:bindings) if(name.equals(binding.path("name").asText())) slot=binding.path("slot").asText(""); + anchor="本镜画面严格只有1人:"+name+",位置:"+slot+"。背景、门口、玻璃反射均无其他人物,不提前画入尚未入场的配角。视线和动作遵照起始状态。"; + } + } catch(Exception ignored) { } + return ShortDramaDirectorSkills.load("visual-world", "director-blocking", "director-review") + "电影单镜首帧,单幅完整构图,不生成三视图、分镜格或字幕。参考图中的多视角人物只作为同一身份,不复制成多人。严格按本镜角色数量、持物手、坐立和门灯状态构图。只画起始状态,不提前执行结束动作。清晰账页、软件屏幕留给后期替换。\n" + +"本镜景别与角度:"+Objects.toString(shot.getShotType(),"")+"。构图以本镜要求为准,参考人物和场景图只锁定身份、外观和建筑,不继承它们的景别、正面机位或人物站位。\n" + +"出镜人物名单(空数组表示无人):"+present+"\n空间与视线:"+anchor+"\n起始状态:"+start+"\n" + +(start.isBlank()?Objects.toString(shot.getImagePrompt(),""):"起始姿态为本图唯一动作依据,不拼接后续动作结果。服装、身份和建筑细节遵照绑定参考图。") + + closeFrameInstruction(shot) + +(directive.isBlank()?"":"\n本镜经人工审阅的构图修订(在起始状态内落实):"+directive); + } + static String closeFrameInstruction(ShortDramaStoryboard shot) { + String type=Objects.toString(shot.getShotType(),""); + if((!type.contains("近景") && !type.contains("特写")) || type.contains("中近景") || type.contains("双人")) return ""; + String focus="本镜叙事主体"; + try { + var bindings=JSON.readTree(Objects.toString(shot.getCharactersJson(),"[]")); + String title=Objects.toString(shot.getSceneTitle(),""); + for(var binding:bindings) if(!binding.path("name").asText().isBlank() && title.startsWith(binding.path("name").asText())) { + focus=binding.path("name").asText();break; + } + String reviewed=JSON.readTree(Objects.toString(shot.getContinuityJson(),"{}")).path("frame_focus").asText(""); + if(!reviewed.isBlank()) focus=reviewed; + } catch(Exception ignored) { } + return "\n景别落实:"+type+"以"+focus+"为视觉中心。人物近景只保留头肩或上半身,人物特写聚焦脸部或动作细节;道具特写只框叙事物体。不得退成展示整个房间和整张桌子的双人全景。名单限定允许出现的人物,不要求全部同时入画;同场倾听者可以在画外或只有肩部边缘,禁止为了参考图齐全而扩宽构图。"; + } + static boolean directInsert(String continuity) { + try { return "direct_insert".equals(JSON.readTree(Objects.toString(continuity,"{}")).path("source_media").path("mode").asText()); } + catch(Exception e) { return false; } + } + /** Reuse the approved prop library when only an existing shot is revised. */ + public void synchronizeExistingFrames(Long id) { synchronizeExistingFrames(id, null); } + public void synchronizeExistingFrames(Long id, Set selected) { + var current=boards.selectList(new LambdaQueryWrapper().eq(ShortDramaStoryboard::getProjectId,id)); + for(var shot:current) { + if(selected != null && !selected.contains(shot.getId())) continue; + var old=assets.selectOne(new LambdaQueryWrapper().eq(ShortDramaVisualAsset::getProjectId,id).eq(ShortDramaVisualAsset::getStoryboardId,shot.getId()).last("limit 1")); + if(old==null) continue; + if(directInsert(shot.getContinuityJson())) { + old.setStatus("obsolete");assets.updateById(old);continue; + } + try { + String propsVersion=JSON.readTree(old.getReferenceImages()).path("propsVersion").asText(); + var refs=baseReferences(shot); + upsert(id,shot.getId(),old.getAssetKey(),"shot_frame","镜 "+shot.getSceneNo()+" · "+shot.getSceneTitle(),framePrompt(shot),JsonUtils.toJsonString(Map.of("urls",refs,"propsVersion",propsVersion)),frameHash(shot,refs,propsVersion)); + } catch(Exception e) { throw new IllegalStateException("镜头"+shot.getSceneNo()+"参考图需要更新",e); } + } + } + static String frameHash(ShortDramaStoryboard shot,List refs,String props) { + return DigestUtil.sha256Hex(framePrompt(shot)+shot.getPhotographyRules()+shot.getCharactersJson()+shot.getContinuityJson()+refs+props); + } + private void upsert(Long project,Long shot,String key,String kind,String title,String prompt,String refs,String hash) { + var old=assets.selectOne(new LambdaQueryWrapper().eq(ShortDramaVisualAsset::getProjectId,project).eq(ShortDramaVisualAsset::getAssetKey,key)); + if(old!=null && Objects.toString(old.getSourceHash(),"").startsWith("locked:") && !hash.startsWith("locked:"))return; + if(old!=null && hash.equals(old.getSourceHash()) && !"obsolete".equals(old.getStatus())) return; + if(old==null) {old=new ShortDramaVisualAsset();old.setId(IdUtil.getSnowflakeNextId());old.setProjectId(project);old.setAssetKey(key);} + boolean insert=old.getKind()==null; + old.setStoryboardId(shot);old.setKind(kind);old.setTitle(title);old.setPrompt(prompt);old.setReferenceImages(refs);old.setSourceHash(hash);old.setStatus("pending"); + old.setImageUrl("");old.setPredictionId("");old.setErrorMessage("");old.setUpdateTime(new Date()); + if(insert) {old.setCreateTime(new Date());assets.insert(old);} else assets.updateById(old); + } + static boolean voiceOnly(ShortDramaCharacter ch) { + String introduction=Objects.toString(ch.getIntroduction(),""); + return introduction.contains("全剧仅以手机语音出现") || introduction.contains("全剧仅画外音") || introduction.contains("无实体出场"); + } + private List baseReferences(ShortDramaStoryboard shot) throws Exception { + List refs=new ArrayList<>(); + var bindings=JSON.readTree(shot.getCharactersJson()==null?"[]":shot.getCharactersJson()); + for(var binding:bindings) { + var ch=characters.selectOne(new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId,shot.getProjectId()).eq(ShortDramaCharacter::getName,binding.path("name").asText()).last("limit 1")); + if(ch==null) throw new IllegalStateException("人物绑定不存在:"+binding.path("name").asText()); + if(voiceOnly(ch)) continue; + var variants=appearances.selectList(new LambdaQueryWrapper().eq(ShortDramaCharacterAppearance::getCharacterId,ch.getId()).orderByAsc(ShortDramaCharacterAppearance::getAppearanceIndex)); + var selected=variants.stream().filter(a->Objects.equals(a.getChangeReason(),binding.path("appearance").asText())).findFirst().orElse(variants.isEmpty()?null:variants.get(0)); + String url=selected==null?ch.getReferenceImageUrl():selected.getReferenceImageUrl(); + if(url==null || url.isBlank()) throw new IllegalStateException(ch.getName()+"缺少已确认形象图"); + if(!refs.contains(url))refs.add(url); + } + var loc=locations.selectOne(new LambdaQueryWrapper().eq(ShortDramaLocation::getProjectId,shot.getProjectId()).eq(ShortDramaLocation::getName,shot.getLocationName()).last("limit 1")); + if(loc==null || loc.getReferenceImageUrl()==null || loc.getReferenceImageUrl().isBlank()) throw new IllegalStateException("场景缺少参考图:"+shot.getLocationName()); + if(!refs.contains(loc.getReferenceImageUrl()))refs.add(loc.getReferenceImageUrl()); + return refs; + } + public Map saveProp(Long id,String title,String prompt,List shotNumbers,String imageUrl,Long user) { + requireCurrentScript(id,user); + if(title==null || title.isBlank() || title.length()>80 || prompt==null || prompt.isBlank() || prompt.length()>12000) + throw new IllegalArgumentException("请填写道具名和外观描述"); + var shots=boards.selectList(new LambdaQueryWrapper().eq(ShortDramaStoryboard::getProjectId,id)); + Set valid=new HashSet<>();shots.forEach(s->valid.add(s.getSceneNo())); + if(shotNumbers==null || shotNumbers.isEmpty() || !valid.containsAll(shotNumbers))throw new IllegalArgumentException("请选择实际存在的镜号"); + if(running.putIfAbsent(id,"保存固定道具")!=null)throw new IllegalStateException("请等待资产生成完成"); + try { + var linked=shotNumbers.stream().distinct().sorted().toList(); + upsert(id,null,"prop:"+title.trim(),"prop",title.trim(),prompt,JsonUtils.toJsonString(linked),"locked:"+DigestUtil.sha256Hex(prompt+linked+Objects.toString(imageUrl,"")).substring(0,57)); + if(imageUrl!=null && !imageUrl.isBlank()) { + if(!imageUrl.startsWith("https://"))throw new IllegalArgumentException("参考图必须是已上传的HTTPS地址"); + assets.update(null,new LambdaUpdateWrapper().eq(ShortDramaVisualAsset::getProjectId,id).eq(ShortDramaVisualAsset::getAssetKey,"prop:"+title.trim()) + .set(ShortDramaVisualAsset::getImageUrl,imageUrl).set(ShortDramaVisualAsset::getStatus,"done")); + } + for(var shot:shots)if(linked.contains(shot.getSceneNo())) assets.update(null,new LambdaUpdateWrapper() + .eq(ShortDramaVisualAsset::getStoryboardId,shot.getId()).set(ShortDramaVisualAsset::getStatus,"pending") + .set(ShortDramaVisualAsset::getPredictionId,"").set(ShortDramaVisualAsset::getErrorMessage,"固定道具已更新,需要重生关键帧")); + return status(id,user); + } finally {running.remove(id);} + } + public Map generateOpening(Long id,String model,Long user) { return generateRange(id,model,user,1,10); } + public Map generateRange(Long id,String model,Long user,int start,int end) { + if(start<1 || end=10)throw new IllegalArgumentException("每批请选择连续1至10镜"); + requireCurrentScript(id,user); + var configured=models.selectModelByName(model); + if(configured==null || !"image".equals(configured.getCategory()))throw new IllegalArgumentException("请选择生图模型"); + var selected=boards.selectList(new LambdaQueryWrapper().eq(ShortDramaStoryboard::getProjectId,id).ge(ShortDramaStoryboard::getSceneNo,start).le(ShortDramaStoryboard::getSceneNo,end)) + .stream().map(ShortDramaStoryboard::getId).collect(java.util.stream.Collectors.toSet()); + if(selected.isEmpty())throw new IllegalArgumentException("范围内没有镜头"); + if(running.putIfAbsent(id,"生成第"+start+"至"+end+"镜资产")!=null)throw new IllegalStateException("资产任务正在运行"); + String tenant=TenantHelper.getTenantId();errors.remove(id); + coordinators.submit(()->TenantHelper.dynamic(tenant,()->{ + try { + runGroup(id,model,"prop"); + List> tasks=new ArrayList<>(); + for(var a:rows(id))if("shot_frame".equals(a.getKind()) && selected.contains(a.getStoryboardId()) && !"done".equals(a.getStatus())) + tasks.add(CompletableFuture.runAsync(()->TenantHelper.dynamic(tenant,()->generateOne(a,model)),imageWorkers)); + CompletableFuture.allOf(tasks.toArray(new CompletableFuture[0])).join(); + } catch(Exception e){errors.put(id,safeError(e));}finally{running.remove(id);} + })); + return status(id,user); + } + public List readyPropReferences(ShortDramaStoryboard shot) { + List result=new ArrayList<>(); + try { + var visible=JSON.readTree(Objects.toString(shot.getContinuityJson(),"{}")).path("visible_props"); + for(var a:rows(shot.getProjectId()))if("prop".equals(a.getKind()) && "done".equals(a.getStatus()) + && (visible.isArray()?hasProp(visible,a.getTitle()):linked(a,shot.getSceneNo()))) result.add(a.getImageUrl()); + }catch(Exception e){throw new IllegalStateException("道具参考绑定无效",e);} + return result; + } + public Map generate(Long id,String model,Long user) { + requireCurrentScript(id,user); + var configured=models.selectModelByName(model); + if(configured==null || !"image".equals(configured.getCategory()))throw new IllegalArgumentException("请选择已配置的生图模型"); + if(rows(id).isEmpty())throw new IllegalStateException("请先分析镜头资产"); + if(running.putIfAbsent(id,"生成关键道具")!=null)throw new IllegalStateException("资产任务正在运行"); + errors.remove(id); + String tenant = TenantHelper.getTenantId(); + coordinators.submit(()->TenantHelper.dynamic(tenant, () -> { + try { + runGroup(id,model,"prop"); + running.put(id,"生成逐镜关键帧(4路并发,可关闭页面后恢复查看)"); + runGroup(id,model,"shot_frame"); + } catch(Exception e) {errors.put(id,safeError(e));} + finally {running.remove(id);} + })); + return status(id,user); + } + public Map reviseFrame(Long id,Long assetId,String prompt,String model,Long user) { + requireCurrentScript(id,user); + var asset=assets.selectById(assetId); + if(asset==null || !id.equals(asset.getProjectId()) || !"shot_frame".equals(asset.getKind())) throw new IllegalArgumentException("只能修正本项目的镜头关键帧"); + if(prompt==null || prompt.isBlank() || prompt.length()>12000) throw new IllegalArgumentException("请填写有效的画面描述(最多12000字)"); + var configured=models.selectModelByName(model); + if(configured==null || !"image".equals(configured.getCategory()))throw new IllegalArgumentException("请选择生图模型"); + if(running.putIfAbsent(id,"修正单镜构图")!=null)throw new IllegalStateException("请等待当前资产队列完成后修正单图"); + try { + var metadata=(com.fasterxml.jackson.databind.node.ObjectNode)JSON.readTree(asset.getReferenceImages()); + var currentShot=boards.selectById(asset.getStoryboardId()); + var currentRefs=baseReferences(currentShot); + metadata.set("urls",JSON.valueToTree(currentRefs)); + asset.setSourceHash(frameHash(currentShot,currentRefs,metadata.path("propsVersion").asText())); + metadata.put("previousImageUrl",Objects.toString(asset.getImageUrl(),"")); + metadata.put("previousPrompt",Objects.toString(asset.getPrompt(),"")); + metadata.put("previousPredictionId",Objects.toString(asset.getPredictionId(),"")); + asset.setReferenceImages(metadata.toString());asset.setPrompt(prompt);asset.setPredictionId(""); + asset.setStatus("pending");asset.setErrorMessage("");asset.setUpdateTime(new Date());assets.updateById(asset); + } catch(Exception e) {running.remove(id);throw new IllegalStateException(safeError(e),e);} + String tenant=TenantHelper.getTenantId();errors.remove(id); + coordinators.submit(()->TenantHelper.dynamic(tenant,()->{ + try {CompletableFuture.runAsync(()->TenantHelper.dynamic(tenant,()->generateOne(asset,model)),imageWorkers).join();} + catch(Exception e){errors.put(id,safeError(e));} + finally {running.remove(id);} + })); + return status(id,user); + } + + private void requireCurrentScript(Long id,Long user) { + if("script_changed".equals(owner(id,user).getStatus()))throw new IllegalStateException("剧本已改变,请先重新规划分镜;旧版资产保留"); + } + public Map recover(Long id,Long user) { + owner(id,user); + var acknowledged=rows(id).stream().filter(a->Set.of("generating","waiting").contains(a.getStatus()) && a.getPredictionId()!=null && !a.getPredictionId().isBlank()).toList(); + if(acknowledged.isEmpty())return status(id,user); + if(running.putIfAbsent(id,"仅恢复已提交的图片任务")!=null)throw new IllegalStateException("资产任务正在运行"); + String tenant=TenantHelper.getTenantId();errors.remove(id); + coordinators.submit(()->TenantHelper.dynamic(tenant,()->{ + try { + var tasks=acknowledged.stream().map(a->CompletableFuture.runAsync(()->TenantHelper.dynamic(tenant,()->generateOne(a,a.getModel())),imageWorkers)).toList(); + CompletableFuture.allOf(tasks.toArray(new CompletableFuture[0])).join(); + }catch(Exception e){errors.put(id,safeError(e));}finally{running.remove(id);} + })); + return status(id,user); + } + + private void runGroup(Long project,String model,String kind) { + List> tasks=new ArrayList<>(); + String tenant=TenantHelper.getTenantId(); + for(var a:rows(project))if(kind.equals(a.getKind()) && !"done".equals(a.getStatus()))tasks.add(CompletableFuture.runAsync(()->TenantHelper.dynamic(tenant,()->generateOne(a,model)),imageWorkers)); + CompletableFuture.allOf(tasks.toArray(new CompletableFuture[0])).join(); + } + private void generateOne(ShortDramaVisualAsset a,String model) { + boolean providerFailed = false; + try { + List refs=new ArrayList<>();String prompt=a.getPrompt(); + if("shot_frame".equals(a.getKind())) { + var shot=boards.selectById(a.getStoryboardId()); + if(shot==null || !isCurrentFrame(a,shot))throw new IllegalStateException("镜头或参考图已修改,请重新分析资产"); + refs.addAll(baseReferences(shot)); + var bindings=JSON.readTree(shot.getCharactersJson());int index=1; + for(var binding:bindings) { + var ch=characters.selectOne(new LambdaQueryWrapper().eq(ShortDramaCharacter::getProjectId,shot.getProjectId()).eq(ShortDramaCharacter::getName,binding.path("name").asText()).last("limit 1")); + if(ch!=null && !voiceOnly(ch))prompt+="\n参考图"+(index++)+"绑定人物:"+binding.path("name").asText()+"。"; + } + prompt+="\n参考图"+refs.size()+"绑定场景建筑,移动物体按本镜起始状态摆放。"; + var compositionRefs=JSON.readTree(Objects.toString(shot.getContinuityJson(),"{}")).path("composition_reference_urls"); + if(compositionRefs.isArray()) { + if(compositionRefs.size()>2)throw new IllegalArgumentException("每镜最多2张连续构图参考"); + for(var ref:compositionRefs) { + String url=ref.asText();if(!url.startsWith("https://"))throw new IllegalArgumentException("构图参考必须为已上传图片"); + if(!refs.contains(url)){refs.add(url);prompt+="\n参考图"+refs.size()+"为相邻已审阅镜头:锁定摄影轴线、电脑桌与背景方位、人物身份服装。只按当前起始状态改变手势和道具位置,不复制参考镜的动作。";} + } + } + var visibleProps=JSON.readTree(Objects.toString(shot.getContinuityJson(),"{}")).path("visible_props"); + for(var prop:rows(a.getProjectId()))if("prop".equals(prop.getKind()) && (visibleProps.isArray() ? hasProp(visibleProps,prop.getTitle()) : linked(prop,shot.getSceneNo()))) { + if(!"done".equals(prop.getStatus()))throw new IllegalStateException("等待道具资产:"+prop.getTitle()); + refs.add(prop.getImageUrl());prompt+="\n参考图"+refs.size()+"绑定道具:"+prop.getTitle()+"。固定外观以此道具图为准,不能沿用其他参考里的旧物件。"+prop.getPrompt(); + } + } + String selected=model; + if(!refs.isEmpty() && model.endsWith("/text-to-image"))selected=model.replace("/text-to-image","/edit"); + if(refs.isEmpty() && model.endsWith("/edit"))selected=model.replace("/edit","/text-to-image"); + var m=models.selectModelByName(selected);if(m==null)throw new IllegalStateException("缺少对应多参考图模型配置:"+selected); + // Resume acknowledged jobs; never submit the same prediction twice. + boolean resume=a.getPredictionId()!=null && !a.getPredictionId().isBlank() && !"failed".equals(a.getStatus()); + if(resume && a.getModel()!=null)m=models.selectModelByName(a.getModel()); + a.setModel(m.getModelName());a.setErrorMessage("");a.setStatus("generating");a.setUpdateTime(new Date());assets.updateById(a); + if(!resume) { + a.setPredictionId("");assets.updateById(a); + var result=images.getOriginalService(m.getProviderCode()).startImageGeneration(ImageContext.builder().chatModelVo(m) + .prompt(prompt).size("shot_frame".equals(a.getKind())?projects.selectById(a.getProjectId()).getComposeAspectRatio():"1:1") + .referenceImages(refs).build()); + if(result==null || result.getId()==null || result.getId().isBlank())throw new IllegalStateException("模型未返回任务ID"); + a.setPredictionId(result.getId());assets.updateById(a); + } + long deadline=System.currentTimeMillis()+TimeUnit.MINUTES.toMillis(8); + while(System.currentTimeMillis()().eq(ShortDramaVisualAsset::getStoryboardId,storyboard).eq(ShortDramaVisualAsset::getStatus,"done").last("limit 1")); + if(a==null)return null; + var shot=boards.selectById(storyboard); + try { return shot!=null && isCurrentFrame(a,shot)?a.getImageUrl():null; } + catch(Exception e) {return null;} + } + private boolean isCurrentFrame(ShortDramaVisualAsset a, ShortDramaStoryboard shot) throws Exception { + String propsVersion=JSON.readTree(a.getReferenceImages()).path("propsVersion").asText(); + return Objects.equals(a.getSourceHash(),frameHash(shot,baseReferences(shot),propsVersion)); + } + private static String safeError(Exception e) {String message=e.getMessage();return message==null?"资产任务失败":message.substring(0,Math.min(300,message.length()));} +} diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/AtlasVideoGenerationServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/AtlasVideoGenerationServiceImpl.java index e7832dad..773e0c3a 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/AtlasVideoGenerationServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/video/provider/AtlasVideoGenerationServiceImpl.java @@ -39,18 +39,25 @@ public class AtlasVideoGenerationServiceImpl extends AbstractVideoGenerationServ ChatModelVo model = videoContext.getChatModelVo(); ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode(); payload.put("model", model.getModelName()); - payload.put("prompt", videoContext.getPrompt()); + Integer requestSeconds = requestDuration(model.getModelName(), videoContext.getSeconds()); + String prompt = videoContext.getPrompt(); + if (requestSeconds != null && videoContext.getSeconds() != null && requestSeconds > videoContext.getSeconds() && videoContext.getSeconds() > 0) { + prompt += "\n剪辑只使用前" + videoContext.getSeconds() + "秒,请在此前完成本镜动作;剩余时间保持结束姿势,不新增动作或对白。"; + } + payload.put("prompt", prompt); if (StrUtil.isNotBlank(videoContext.getSize())) { payload.put("size", videoContext.getSize()); } if (videoContext.getSeconds() != null) { - payload.put("duration", videoContext.getSeconds()); + payload.put("duration", requestSeconds); } if (StrUtil.isNotBlank(videoContext.getQuality())) { payload.put("quality", videoContext.getQuality()); } java.util.List refImages = videoContext.getReferenceImages(); - if (refImages != null && !refImages.isEmpty()) { + if (model.getModelName().startsWith("bytedance/seedance-") && model.getModelName().endsWith("/image-to-video") && refImages != null && !refImages.isEmpty()) { + payload.put("image",refImages.get(0)); + } else if (refImages != null && !refImages.isEmpty()) { com.fasterxml.jackson.databind.node.ArrayNode arr = payload.putArray("reference_images"); for (String url : refImages) { arr.add(url); @@ -94,6 +101,13 @@ public class AtlasVideoGenerationServiceImpl extends AbstractVideoGenerationServ } } + static Integer requestDuration(String model, Integer seconds) { + if (seconds == null || !model.startsWith("bytedance/seedance-2.0")) return seconds; + if (seconds == -1) return seconds; + if (seconds < 1 || seconds > 15) throw new IllegalArgumentException("Seedance 2.0镜头须在1至15秒内,短于4秒的镜头生成后按剪辑时长截取"); + return Math.max(4, seconds); + } + @Override protected MediaGenerationResponse doRetrieveVideo(VideoContext videoContext) { return atlasPredictionService.retrieve(videoContext.getChatModelVo(), videoContext.getVideoId()); diff --git a/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/director-blocking/SKILL.md b/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/director-blocking/SKILL.md new file mode 100644 index 00000000..903a3a25 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/director-blocking/SKILL.md @@ -0,0 +1,16 @@ +--- +name: director-blocking +description: 动作调度与连续性 +--- + +# 动作调度与连续性 + +用于分镜、摄影、表演和视频提示词。先决定行为,再选择能看清行为的机位。每镜明确:叙事作用、触发原因、主体目标、起始姿态、视线对象、持物手、主要动作、结束状态及承接。 +首帧是行为即将发生或正在进行的瞬间,不是人物介绍照。清理遗物应为俯身低头整理、手与物有接触;默认视线在场内对象上。只有剧本明确要求打破第四面墙才直视镜头。不能仅以“站在桌前”代替动作起点。 +每个子镜头聚焦一个主要动作或反应。需要多个构图时,明确内部切点和秒数,切换仍需空间或动作动机,不强制每3秒切镜。不要求将整条因果链挤入一个连续长镜。 +逐镜传递人物位置、朝向、左右手、道具位置/持有/开合、入口、时间与光线;下一镜起点继承上一镜终点,省略过程时写明合理时间切换。不要把手机在桌上点击写成拿起收听。 +抽象情绪转成可见行为:指腹摸镜腿、手停住、视线停在眼镜,再放下眼镜拿手机。不可要求模型直接画“想起父亲”。 +摄影机位和人物身体朝向分开描述;人物参考只锁身份,不能继承正脸凝视。每镜主运镜最多一种,固定镜头允许。 +对白与注视可重叠;取物、放物、换手需真实时间,不能用快放过关。声音来自谁、屏幕是否发声需明确。保留当前接口输出结构及字段。 + +细化只能落实既有动作,不能交换对白先后或替换已确认道具:手机看时间不可变成手表;已经从窗边移到方桌的收音机,未出现搬动前不得回到窗边。纯屏幕插入镜的出镜人物为空,不继承上一镜演员。 diff --git a/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/director-review/SKILL.md b/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/director-review/SKILL.md new file mode 100644 index 00000000..ffe81767 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/director-review/SKILL.md @@ -0,0 +1,11 @@ +--- +name: director-review +description: 导演审片标准 +--- + +# 导演审片标准 + +用于生成前审阅和提示词检查。先检查故事作用、因果、动作可执行性、视线、空间状态、时间预算和世界设定,再检查实际生成图。 +关键帧对照可观察项:是否正在执行指定起始动作;是否错误看镜头;手和道具是否相接;背景是否符合维护程度与陈设密度;身份和衣服是否稳定。文本合格或接口成功不能代替画面验收。 +每个问题记录预期、实际证据、责任阶段、修改范围和复查结果。字段完整不等于通过,未看过图片不可声称视觉通过;审阅结论不得由生图状态done自动推导。 +连续段落合看相邻镜头,而非只看单张美感;重要状态冲突优先于平均分。确认一镜后再按用户范围推进;发现共性问题改规则并列出受影响镜头,不静默批量重绘。 diff --git a/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/emotional-dialogue/SKILL.md b/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/emotional-dialogue/SKILL.md new file mode 100644 index 00000000..f8f9de08 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/emotional-dialogue/SKILL.md @@ -0,0 +1,35 @@ +--- +name: emotional-dialogue +description: 用人物欲望、回应和有代价的选择构建情感,并传递到分镜表演。 +--- + +# 情感与对白 + +## 剧本阶段 +先区分每个人眼前要办的事、难以承认的需要和不愿被触碰的旧伤。配角也有自己的生活与顾虑,不是替主角解释真相的工具。 +关系戏必须有双向回应:试探或请求→对方抵抗、误解或回避→说话者改换说法或行动→关系发生有限变化。正文直接写出可说出口的对白,不能以“二人交谈,理解了彼此”代替。 +话题可以是零钱、座位、饭菜、物品去留;潜台词从上下文及回应成立,不把每句话写成人生金句。允许答非所问、改口、普通的幽默,但不能每句都省略或结巴。 +情感转折需先有具体触发,再有抵抗,最后由一个有代价的小选择证明。帮助别人不自动抵消对家人的亏欠;理解、原谅、悲伤消失是不同事情。不能靠配角劝一句、遗书揭密、突然绝症或煽情旁白强行和解。 +同一道具可以在不同关系阶段承担不同意义,但不要反复凝视道具代替交流。沉默必须是人物在听、忍住回应、决定留下等可识别行为,不用空转凑时长。 +信息边界逐人核对:一句话提到的旧事必须有本人经历或前文告知作依据;创作资料里知道的事不等于角色知道。若配角回应毕业典礼等旧伤,先写主角实际说出该事,禁止读心。 +交易动作逐笔核对付款人、收款人、金额、已付/未付;“记了金额”不等于“已收到钱”。不要为了制造推辞戏凭空增加退款、垫付或换手,动作与对白必须一致。 +在输出前算术核对各场预计秒数之和与用户总时长,不能只在大纲声称达标;短小开场不能分配90秒。场次数服从时空和关系变化,不为七场把同一对话拆成多次往返。 +大纲每场只交代“带着什么情绪进入→被哪件具体事触动→怎样回应→带着什么未解决的问题离开”;正文不附创作分析,保留行动和对白。用户事实优先,不照搬参考电影情节或台词。 + +## 分镜与表演阶段 +保留原文关键对白及回应顺序。source_text包含本镜真正说出的原话;timing.spoken_text只含实际发音,不含角色名、舞台提示。预算包括倾听反应,不能为塞满15秒删掉关系变化。 +规划时为出镜人物提供performance_beats数组,每项{name,acting}。acting简明写出“被哪句原话/动作触发→此刻试图对谁做什么→可见回应→镜末仍保留什么情绪”。发言人与倾听者分别设计,不能同场所有人套同一句眼含热泪。 +无对白镜头也应有来自原文的行为动机;没有人物的屏幕插入镜performance_beats为空数组。不得给录音中的父亲添加出镜表演。 +亲密对话优先稳定机位与视线关系,允许把说话声延续到对方倾听画面;不强制每句反打,也不因情绪高潮自动推近、流泪或配悲伤钢琴。多人口型能力未经验证时,拆成一个主要说话人的镜头,不让双方同时开口。 + +逐镜同步一张人物状态账:站/坐、左右手持物、位置、视线目标、已经知道的信息。没有坐下动作前不得写手扶膝盖;没有取出/交接动作前不得换手或突然持物。表演指导与start_state/end_state必须一致。 +对白容量必须逐字核算:发音字数/语速 + 独占动作 + 停顿 ≤ duration。说话同时发生的动作不重复记独占时间;超出15秒就按语义拆镜,禁止删原话、虚报语速或把对白只写进分析。 + +## 审阅标准 +逐场检查具体证据:角色说了什么,对方如何回应,选择是否比上一场改变,旧伤是否仍被承认。只有抽象形容词、单向讲解、全知配角、无触发落泪或突然释怀时重写该场。不得用字段齐全或分数声称观众一定会感动;成片表演仍需人工审阅。 + +## 方法来源(只借鉴方法,本文为项目规则) +- huobao script-rewriter:对白推进、场次情绪转折 https://github.com/chatfire-AI/huobao-drama/blob/master/backend/workspace/skills/script-rewriter/SKILL.md +- DirectorSKILL:声音、倾听镜头与对白预算 https://github.com/wuwangzhang1216/DirectorSKILL/blob/main/references/sound-and-dialogue.md +- Kenneth Lonergan / WGA East:日常行为承载关系,不把丧失简化为成长公式 https://www.wgaeast.org/onwriting/kenneth-lonergan-manchester-by-the-sea/ +- 是枝裕和 / BFI:每个人都有自己的视角 https://www.bfi.org.uk/interviews/hirokazu-koreeda-broker-im-interested-this-innate-human-desire-form-familial-unit diff --git a/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/real-material/SKILL.md b/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/real-material/SKILL.md new file mode 100644 index 00000000..0d34f3d2 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/real-material/SKILL.md @@ -0,0 +1,11 @@ +--- +name: real-material +description: 真实界面与证据植入 +--- + +# 真实界面与证据植入 + +用于分镜、视频提示词和审阅。已绑定的真实照片、账页、软件页面和Excel均是原始证据,保留多个素材及其顺序、用途、时间范围。不能把多张素材压缩为第一张,也不能自动重绘文字。 +真实界面已显示结果时,不虚构点击生成或加载过程。区分原图直接插入与电脑屏幕透视贴片;过肩镜头需保留肩部、电脑边框和桌面空间关系,下一镜才能切屏幕细节。 +票据日期和画面设备与故事时间核对;真实截图若含不符年份的模型名、系统时间,标记为后期需处理,不伪称已经修复。AI只能识别、整理、统计,真相需人物查证。 +本阶段没有授权生成视频时仅输出计划,不启动视频任务。 diff --git a/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/story-causality/SKILL.md b/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/story-causality/SKILL.md new file mode 100644 index 00000000..9c705f95 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/story-causality/SKILL.md @@ -0,0 +1,12 @@ +--- +name: story-causality +description: 剧情因果与节奏 +--- + +# 剧情因果与节奏 + +用于剧本和大纲。先落实用户确认的故事年份、生活背景、人物目标与信息边界。年份与装修年代分开,旧物不代表故事发生在旧年代。 +大纲只写每场的目标、触发、行动、结果和下一场原因及预算;正文只保留必要行动和对白,不混入镜头、机位、逐秒或软件操作参数。 +每场新增信息或改变人物选择;合并重复反应,不用空镜、无目的独处或重复告别凑时长。把“清理→触物→回忆的行为→主动听语音”作为因果链,不拆成互不相干的摆拍。 +不新增已确认故事之外的秘密或反转;推测需经人物求证。软件能整理证据,不能替人物理解父亲或判断欠款。 +按对白、不可重叠动作与自然停顿估时,不能靠字数推出片长;用户时长优先。保留当前接口要求的输出结构。 diff --git a/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/video-continuity/SKILL.md b/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/video-continuity/SKILL.md new file mode 100644 index 00000000..0cfe20c3 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/video-continuity/SKILL.md @@ -0,0 +1,18 @@ +--- +name: video-continuity +description: 视频试片阶段的物体连续性与画外声约束 +--- + +# 视频连续性 + +同一连续场景须维护道具外观与摆放清单:眼镜框形与材质、垫布颜色、纸箱数量和开合、手机壳不能逐镜重新设计。 +已装箱的工具、箱子与桌上遗物,未被人物搬走就必须继续存在;画外道具仍保留相对位置,不把工作台恢复成空净场景母图。新来的饭盒只能占用空位,不能替换原有道具。 +已审阅关键帧优先于旧版视觉草稿,场景母图仅约束建筑与光线。动作从首帧的状态展开,未设计的道具移动、换手、消失均禁止。 +手机语音只从手机传出,收听者闭口倾听,不对口型;声音原文与发言人不得改写。无对白镜头只保留自然环境与动作声,不添加旁白解释感情。 + +## 屏幕与实物操作 +手机型号、镜头数量、刘海形状、保护壳及持物手均由固定资产决定。微信旧语音通过点击聊天中的语音条播放;不是来电,不长按录音,不凭空出现独立重播键。播放前、播放中、结束状态必须写清,屏幕文字优先使用原始UI素材。 +放手机须依次经过降低、背壳接触台面、松指、收手。写清屏幕朝向;不得一边握着一边已经躺在桌面,禁止翻面或穿桌。 +食物、容器和筷子跨镜分别锁定数量、材质、开盖状态与所在桌面。跨桌必须有取走和放下的可见动作,或明确时间省略;不能通过切镜让物件瞬移。 +电脑软件植入必须满足输入文件确实存在、上传后提交、等待后出结果、结果引发人物行动的顺序。不得以结果页冒充上传页;清晰文字和点击使用真实素材或确定性合成。 +背景音乐由合成阶段统一混入,人物对白与手机语音镜头不额外生成音乐,避免两套配乐叠加。 diff --git a/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/visual-world/SKILL.md b/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/visual-world/SKILL.md new file mode 100644 index 00000000..55eeaf04 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/main/resources/short-drama/skills/visual-world/SKILL.md @@ -0,0 +1,12 @@ +--- +name: visual-world +description: 场景与人物视觉锁定 +--- + +# 场景与人物视觉锁定 + +用于人物、场景和镜头图片。分别锁定故事年份、建筑装修风格、维护程度、陈设密度;服装与设备服从项目设定。禁止把年代感、情感、父亲遗物自动翻译成破败、霉斑、灰尘、垃圾或废弃空间。 +场景参考为无人物的空间基准图:明确入口、窗、工作台、主要家具相对位置,光源可解释;陈设分区有序,留出人物活动区。只保留少量有叙事作用的可移动物,避免杂物淹没主角。 +人物设定图用于身份和服装,正面视图属于设定用途;叙事首帧必须重新按本镜动作、视线和机位构图,不复制设定图姿势。先遵守用户确认的身份,不能借重新生图换脸、换年龄、换衣服。 +背景参考锁定建筑与家具,移动道具和门灯状态以本镜为准。手接触的物体应看得见且数量合理。场景不能被误生成人物合影。 +图片只画当前时刻,不提前出现后续发现的账本或已完成动作。说明构图焦点和允许出现的物件,不能只堆砌“电影级、悲伤、怀旧”。 diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/controller/shortdrama/ShortDramaMaterialTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/controller/shortdrama/ShortDramaMaterialTest.java new file mode 100644 index 00000000..4479a5a0 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/controller/shortdrama/ShortDramaMaterialTest.java @@ -0,0 +1,29 @@ +package org.ruoyi.controller.shortdrama; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +@Tag("dev") +class ShortDramaMaterialTest { + private final ObjectMapper json = new ObjectMapper(); + @Test void appendMigratesLegacyWithoutLosingOriginalOrShotMetadata() throws Exception { + ObjectNode c=(ObjectNode)json.readTree("{\"start_state\":\"desk\",\"source_media\":{\"url\":\"a\",\"label\":\"book\",\"usage\":\"0-2s\"}}"); + var items=ShortDramaMaterialController.items(c); + items.add(json.createObjectNode().put("url","b")); + items.add(json.createObjectNode().put("url","c")); + ShortDramaMaterialController.setItems(c,items); + assertEquals("desk",c.path("start_state").asText()); + assertEquals("a",c.at("/source_media/url").asText()); + assertEquals("0-2s",c.at("/source_media/items/0/usage").asText()); + assertEquals(3,c.at("/source_media/items").size()); + } + @Test void removingPrimaryPromotesNextAndLastRemovalClearsMode() throws Exception { + ObjectNode c=(ObjectNode)json.readTree("{\"source_media\":{\"url\":\"a\",\"items\":[{\"url\":\"a\"},{\"url\":\"b\"}]}}"); + var items=ShortDramaMaterialController.items(c);items.remove(0); + ShortDramaMaterialController.setItems(c,items); + assertEquals("b",c.at("/source_media/url").asText()); + items.remove(0);ShortDramaMaterialController.setItems(c,items); + assertFalse(c.has("source_media")); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImplTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImplTest.java index 2bde2b94..01180369 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImplTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/image/provider/AtlasImageGenerationServiceImplTest.java @@ -9,6 +9,23 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("dev") class AtlasImageGenerationServiceImplTest { + @Test void sendsAllIdentityAndLocationReferencesInOrder() { + var model=new org.ruoyi.common.chat.domain.vo.chat.ChatModelVo();model.setModelName("openai/gpt-image-2/edit"); + var context=org.ruoyi.common.chat.entity.image.ImageContext.builder().chatModelVo(model).referenceImages(java.util.List.of("person1","person2","location","prop")).build(); + var payload=new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode(); + AtlasImageGenerationServiceImpl.applyReferenceImages(payload,context); + assertEquals(4,payload.path("images").size());assertEquals("location",payload.path("images").get(2).asText()); + } + + @Test void supportsGptImage25NativeWidescreenAndSixteenReferences() { + var model=new org.ruoyi.common.chat.domain.vo.chat.ChatModelVo();model.setModelName("openai/gpt-image-2.5-flare/edit"); + var refs=java.util.stream.IntStream.range(0,16).mapToObj(i->"reference-"+i).toList(); + var context=org.ruoyi.common.chat.entity.image.ImageContext.builder().chatModelVo(model).referenceImages(refs).build(); + var payload=new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode(); + AtlasImageGenerationServiceImpl.applyReferenceImages(payload,context); + assertEquals(16,payload.path("images").size()); + assertEquals("1536x864",AtlasImageGenerationServiceImpl.resolveSize(model.getModelName(),"16:9")); + } @Test void shouldUseGptImageSupportedLandscapeSize() { @@ -16,14 +33,14 @@ class AtlasImageGenerationServiceImplTest { assertEquals("1536x1024", AtlasImageGenerationServiceImpl.resolveSize(model, "3:2")); assertEquals("1536x1024", AtlasImageGenerationServiceImpl.resolveSize(model, "1152*768")); - assertEquals("1536x1024", AtlasImageGenerationServiceImpl.resolveSize(model, "16:9")); + assertEquals("1536x864", AtlasImageGenerationServiceImpl.resolveSize(model, "16:9")); } @Test void shouldUseGptImageSupportedPortraitAndSquareSizes() { String model = "openai/gpt-image-2/edit"; - assertEquals("1024x1536", AtlasImageGenerationServiceImpl.resolveSize(model, "9:16")); + assertEquals("864x1536", AtlasImageGenerationServiceImpl.resolveSize(model, "9:16")); assertEquals("1024x1024", AtlasImageGenerationServiceImpl.resolveSize(model, "1:1")); } @@ -41,4 +58,4 @@ class AtlasImageGenerationServiceImplTest { assertTrue(AtlasImageGenerationServiceImpl.isGptImageModel("openai/gpt-image-2/edit")); assertFalse(AtlasImageGenerationServiceImpl.isGptImageModel("microsoft/mai-image-2.5/edit")); } -} \ No newline at end of file +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/media/AtlasPredictionServiceTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/media/AtlasPredictionServiceTest.java index 6f6b93b9..b8ba26d9 100644 --- a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/media/AtlasPredictionServiceTest.java +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/media/AtlasPredictionServiceTest.java @@ -7,6 +7,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; @Tag("dev") class AtlasPredictionServiceTest { + @Test void failedPredictionInHttp400IsTerminalButUnrelatedErrorsAreNot() throws Exception { + var service = new AtlasPredictionService(); + String raw = "{\"code\":400,\"data\":{\"id\":\"task-3s\",\"status\":\"failed\",\"error\":\"duration unsupported\"}}"; + assertEquals("failed", service.terminalFailure(raw, "task-3s", "video").getStatus()); + org.junit.jupiter.api.Assertions.assertNull(service.terminalFailure(raw, "different-task", "video")); + org.junit.jupiter.api.Assertions.assertNull(service.terminalFailure("{\"message\":\"temporary failure\"}", "task-3s", "video")); + org.junit.jupiter.api.Assertions.assertNull(service.terminalFailure("Bad gateway", "task-3s", "video")); + } @Test void keepsAudioTypeAndMimeThroughPolling() throws Exception { var service = new AtlasPredictionService(); diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/CompositionPlannedDurationTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/CompositionPlannedDurationTest.java new file mode 100644 index 00000000..5465fb65 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/composition/CompositionPlannedDurationTest.java @@ -0,0 +1,13 @@ +package org.ruoyi.service.shortdrama.composition; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import java.nio.file.Path; +import static org.junit.jupiter.api.Assertions.*; +@Tag("dev") class CompositionPlannedDurationTest { + @Test void generatedPaddingDoesNotLengthenTimelineAndShortSourcesAreRejected() { + var source=new CompositionSource(Path.of("clip.mp4"),3d,3d); + assertEquals(3d,source.forTimeline(new MediaInfo(4d,1280,720,true,0)).durationSeconds()); + assertThrows(IllegalArgumentException.class,()->source.forTimeline(new MediaInfo(2d,1280,720,true,0))); + assertEquals(4d,new CompositionSource(Path.of("clip.mp4"),3d).forTimeline(new MediaInfo(4d,1280,720,true,0)).durationSeconds()); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAppearanceContinuityTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAppearanceContinuityTest.java new file mode 100644 index 00000000..8883b0c1 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaAppearanceContinuityTest.java @@ -0,0 +1,37 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacter; +import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacterAppearance; +import org.ruoyi.mapper.shortdrama.ShortDramaCharacterMapper; +import org.ruoyi.mapper.shortdrama.ShortDramaCharacterAppearanceMapper; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@Tag("dev") +class ShortDramaAppearanceContinuityTest { + @Test + void detailUsesEditedAppearanceInsteadOfStaleCharacterDescription() { + var service = mock(ShortDramaServiceImpl.class, CALLS_REAL_METHODS); + var characters = mock(ShortDramaCharacterMapper.class); + var appearances = mock(ShortDramaCharacterAppearanceMapper.class); + ReflectionTestUtils.setField(service, "characterMapper", characters); + ReflectionTestUtils.setField(service, "characterAppearanceMapper", appearances); + var character = new ShortDramaCharacter(); + character.setId(1L); + character.setName("陈念"); + character.setVisualDescription("旧版深色工装"); + var appearance = new ShortDramaCharacterAppearance(); + appearance.setChangeReason("初始形象"); + appearance.setDescription("26岁,米白色长袖衬衫,深蓝牛仔裤"); + when(characters.selectList(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(List.of(character)); + when(appearances.selectList(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(List.of(appearance)); + String constraints = service.buildCharacterAppearanceConstraints(2L); + assertTrue(constraints.contains("陈念 / 初始形象")); + assertTrue(constraints.contains("米白色长袖衬衫")); + assertFalse(constraints.contains("旧版深色工装")); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaContinuityTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaContinuityTest.java new file mode 100644 index 00000000..c445368a --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaContinuityTest.java @@ -0,0 +1,110 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +@Tag("dev") +class ShortDramaContinuityTest { + @Test + void conciseScriptIsNotExpandedMerelyForLengthOrSceneCount() { + var result=new org.ruoyi.domain.bo.shortdrama.ShortDramaScriptResult(); + result.setScriptText("内景 修理铺 下午(预计8秒)\n陈念把账本放入包里,背好包离开。"); + assertFalse(ShortDramaServiceImpl.scriptNeedsExpansion(result)); + result.setScriptText("");assertTrue(ShortDramaServiceImpl.scriptNeedsExpansion(result)); + } + @Test + void normalizationPreservesShortShotBudgets() { + var a=panel(1,"修理铺","取账本");a.setDuration(3); + var b=panel(1,"修理铺","放入包");b.setDuration(3); + var c=panel(1,"修理铺","背好包");c.setDuration(2); + ShortDramaServiceImpl.normalizeContinuityChain(List.of(a,b,c)); + assertEquals(8,a.getDuration()+b.getDuration()+c.getDuration()); + } + + @Test + void compatibleDeepSeekUsesAdapterThatSendsThinkingSetting() { + assertEquals("deepseek", ShortDramaServiceImpl.shortDramaProviderCode("openai", "deepseek-ai/deepseek-v4.1-flash")); + assertEquals("deepseek", ShortDramaServiceImpl.shortDramaProviderCode("atlas", "deepseek-ai/deepseek-v4.1-flash")); + assertEquals("atlas", ShortDramaServiceImpl.shortDramaProviderCode("atlas", "gemini-3-flash")); + assertEquals("openai", ShortDramaServiceImpl.shortDramaProviderCode("openai", "gemini-3-flash")); + assertEquals("custom_api", ShortDramaServiceImpl.shortDramaProviderCode("custom_api", "deepseek-v4")); + } + + @Test + void checkpointSurvivesRetryButChangesWithScriptOrAssetsOrProject() { + String key = ShortDramaServiceImpl.checkpointKey(1L, 2L, "正文", "大纲", "realistic", "16:9", "15:100"); + assertEquals(key, ShortDramaServiceImpl.checkpointKey(1L, 2L, "正文", "大纲", "realistic", "16:9", "15:100")); + assertNotEquals(key, ShortDramaServiceImpl.checkpointKey(1L, 2L, "正文已改", "大纲", "realistic", "16:9", "15:100")); + assertNotEquals(key, ShortDramaServiceImpl.checkpointKey(1L, 2L, "正文", "大纲", "realistic", "16:9", "15:101")); + assertNotEquals(key, ShortDramaServiceImpl.checkpointKey(3L, 2L, "正文", "大纲", "realistic", "16:9", "15:100")); + } + + @Test + void scenePromptRendersDurationBudgetWithoutFormatErrors() { + String prompt = ShortDramaServiceImpl.buildStoryboardPlanPrompt("本场预计75秒", "陈念", "修理铺", "女儿", "衬衫", "人物描述", "写实", "16:9"); + assertTrue(prompt.contains("误差不超过10%")); + assertTrue(prompt.contains("本场预计75秒")); + assertFalse(prompt.contains("最多40个镜头")); + } + + @Test + void sameLocationOnNextDayIsANewScene() { + var evening = panel(1, "维修铺", "女儿合上账本"); + var morning = panel(2, "维修铺", "女儿带上账本出门"); + morning.setStartState("次日上午,女儿已换好衣服站在门口"); + morning.setNarrativeCause("约定周三上午有人在家"); + ShortDramaServiceImpl.normalizeContinuityChain(List.of(evening, morning)); + assertEquals(2, morning.getSceneNumber()); + assertEquals(1, morning.getSegmentNumber()); + assertEquals("次日上午,女儿已换好衣服站在门口", morning.getStartState()); + assertEquals("约定周三上午有人在家", morning.getNarrativeCause()); + } + + @Test + void preservesSpecificCauseAndStartStateInContinuousScene() { + var first = panel(1, "维修铺", "账本摊开在桌上"); + var next = panel(1, "维修铺", "女儿标记重复地址"); + next.setStartState("女儿低头看桌上摊开的账本,镜头切近手部"); + next.setNarrativeCause("她发现同一地址出现五次"); + ShortDramaServiceImpl.normalizeContinuityChain(List.of(first, next)); + assertEquals("她发现同一地址出现五次", next.getNarrativeCause()); + assertEquals("女儿低头看桌上摊开的账本,镜头切近手部", next.getStartState()); + assertEquals(2, next.getSegmentNumber()); // 8 + 8 exceeds the 15-second segment budget. + } + + @Test + void missingContinuousStartCanInheritActualPreviousEnd() { + var first = panel(1, "维修铺", "右手压住账本,左手指向地址"); + var next = panel(1, "维修铺", "拿起手机拍照"); + ShortDramaServiceImpl.normalizeContinuityChain(List.of(first, next)); + assertEquals(first.getEndState(), next.getStartState()); + } + + @Test + void sceneSplittingPreservesPreambleTimeJumpAndEnding() { + var scenes = ShortDramaServiceImpl.splitScriptScenes("片名:爸爸没说完的话\n内景 维修铺 夜(预计40秒)\n发现账本\n外景 榕树巷 次日(预计40秒)\n敲门\n内景 维修铺 次日夜(预计40秒)\n留下下一次探访日期"); + assertEquals(3, scenes.size()); + assertTrue(scenes.get(0).startsWith("片名")); + assertTrue(scenes.get(2).contains("下一次探访日期")); + } + + @Test void explicitEmptyCastDoesNotInheritPreviousActor() { + var first=panel(1,"维修铺","陈念在电脑前");first.setPresentCharacters(List.of("陈念")); + var screen=panel(1,"维修铺","屏幕插入");screen.setPresentCharacters(List.of()); + ShortDramaServiceImpl.normalizeContinuityChain(List.of(first,screen)); + assertTrue(screen.getPresentCharacters().isEmpty()); + } + + private static ShortDramaServiceImpl.StoryboardPanelData panel(int scene, String location, String end) { + var panel = new ShortDramaServiceImpl.StoryboardPanelData(); + panel.setSceneNumber(scene); + panel.setSegmentNumber(1); + panel.setLocation(location); + panel.setDuration(8); + panel.setEndState(end); + panel.setStoryResult(end); + return panel; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaDirectorSkillsTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaDirectorSkillsTest.java new file mode 100644 index 00000000..340de64a --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaDirectorSkillsTest.java @@ -0,0 +1,73 @@ +package org.ruoyi.service.shortdrama.impl; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard; +import static org.junit.jupiter.api.Assertions.*; +@Tag("dev") +class ShortDramaDirectorSkillsTest { + @Test void imageCompletionKeepsPlanningCheckpointButDescriptionChangesInvalidateIt() { + var location = new org.ruoyi.domain.entity.shortdrama.ShortDramaLocation(); + location.setId(1L); location.setName("维修铺"); location.setDescriptions("正常维护"); + String before = ShortDramaServiceImpl.planningAssetSignature(java.util.List.of(location)); + location.setImageUrls("[\"https://example.com/new-image.png\"]"); + location.setUpdateTime(new java.util.Date()); + assertEquals(before, ShortDramaServiceImpl.planningAssetSignature(java.util.List.of(location))); + location.setDescriptions("家具位置改变"); + assertNotEquals(before, ShortDramaServiceImpl.planningAssetSignature(java.util.List.of(location))); + } + @Test void sceneBudgetReportsUsableIntegerBoundsWithoutRelaxingTheGuard() { + var panel = new ShortDramaServiceImpl.StoryboardPanelData(); panel.setDuration(5); + var panels = new java.util.ArrayList(); + for(int i=0;i<17;i++) panels.add(panel); + var error=assertThrows(IllegalStateException.class,()->ShortDramaServiceImpl.validateSceneDuration("内景 维修铺(预计95秒)",panels)); + assertTrue(error.getMessage().contains("86至104秒")); + } + @Test void bundledSkillLoadsEvenWhenWorkerContextCannotSeeApplicationResources() { + var thread = Thread.currentThread(); + var previous = thread.getContextClassLoader(); + try { + thread.setContextClassLoader(new ClassLoader(null) {}); + assertTrue(ShortDramaDirectorSkills.read("director-blocking").contains("默认") + || ShortDramaDirectorSkills.read("director-blocking").contains("调度")); + } finally { thread.setContextClassLoader(previous); } + } + @Test void allDefaultSkillsAreBundledAndVersioned() { + for (String name : new String[]{"emotional-dialogue","story-causality","director-blocking","visual-world","real-material","director-review","video-continuity"}) { + String text=ShortDramaDirectorSkills.load(name); + assertTrue(text.contains("[skill:"+name+"@")); + assertFalse(text.contains("description:")); + } + assertThrows(IllegalStateException.class,()->ShortDramaDirectorSkills.load("missing-skill")); + } + @Test void performanceIsMatchedByPersonRatherThanSharedEmotionTemplate() throws Exception { + var panel = new ShortDramaServiceImpl.StoryboardPanelData(); + panel.setPanelNumber(1); + panel.setSourceText("陈念:我没说要扔。林叔收回伸向纸箱的手。"); + var daughter = new ShortDramaServiceImpl.CharacterRef(); daughter.setName("陈念"); + var uncle = new ShortDramaServiceImpl.CharacterRef(); uncle.setName("林叔"); + panel.setCharacters(java.util.List.of(daughter, uncle)); + panel.setPerformanceBeats(new com.fasterxml.jackson.databind.ObjectMapper().readTree( + "[{\"name\":\"林叔\",\"acting\":\"听见她拒绝后收回手,不再催促\"},{\"name\":\"陈念\",\"acting\":\"护住眼镜盒,拒绝叔叔替自己决定\"},{\"name\":\"父亲\",\"acting\":\"不应出镜\"}]")); + var result = ShortDramaServiceImpl.buildLocalActingDirections(java.util.List.of(panel)).get(0).getCharacters(); + assertEquals(2, result.size()); + assertEquals("护住眼镜盒,拒绝叔叔替自己决定", result.get(0).path("acting").asText()); + assertEquals("听见她拒绝后收回手,不再催促", result.get(1).path("acting").asText()); + } + @Test void missingPerformanceUsesSourceInsteadOfInventingTears() { + var panel = new ShortDramaServiceImpl.StoryboardPanelData(); panel.setPanelNumber(1); + panel.setSceneType("emotion"); panel.setSourceText("陈念把零钱推回周姨面前。"); + var ref = new ShortDramaServiceImpl.CharacterRef(); ref.setName("陈念"); + panel.setCharacters(java.util.List.of(ref)); + String acting = ShortDramaServiceImpl.buildLocalActingDirections(java.util.List.of(panel)).get(0).getCharacters().toString(); + assertTrue(acting.contains("把零钱推回")); + assertFalse(acting.contains("释放情绪")); + } + @Test void actualFramePromptCarriesGazeAndWorldRulesBeforeShotDescription() { + var shot=new ShortDramaStoryboard();shot.setImagePrompt("女主正在清理遗物"); + shot.setContinuityJson("{\"start_state\":\"低头整理工具\"}"); + String result=ShortDramaVisualAssetService.framePrompt(shot); + assertTrue(result.contains("只有剧本明确要求打破第四面墙才直视镜头")); + assertTrue(result.contains("维护程度"));assertTrue(result.contains("低头整理工具")); + assertTrue(result.indexOf("[skill:"){ + max.accumulateAndGet(active.incrementAndGet(),Math::max);latch.countDown(); + try {assertTrue(latch.await(2,TimeUnit.SECONDS));Thread.sleep((7-n)*8L);return n*10;} + catch(InterruptedException e){throw new RuntimeException(e);} + finally{active.decrementAndGet();} + }); + assertEquals(List.of(10,20,30,40,50,60),result);assertEquals(3,max.get()); + } + @Test void aFailedSceneDoesNotProducePartialSuccessfulFilm() { + assertThrows(IllegalStateException.class,()->ShortDramaParallel.mapOrdered(List.of(1,2),2,n->{if(n==2)throw new IllegalArgumentException("scene failed");return n;})); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaRevisionServiceTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaRevisionServiceTest.java new file mode 100644 index 00000000..ad13cc75 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaRevisionServiceTest.java @@ -0,0 +1,35 @@ +package org.ruoyi.service.shortdrama.impl; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.ruoyi.domain.bo.shortdrama.ShortDramaRevisionBo; +import org.ruoyi.domain.entity.shortdrama.*; +import org.ruoyi.mapper.shortdrama.*; +import org.ruoyi.service.shortdrama.IShortDramaService; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.function.Consumer; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@Tag("dev") +class ShortDramaRevisionServiceTest { + @Test void unauthorizedAndStaleRevisionNeverWrite() { + var projects = mock(ShortDramaProjectMapper.class); + var scripts = mock(ShortDramaScriptMapper.class); + var boards = mock(ShortDramaStoryboardMapper.class); + var tx = mock(TransactionTemplate.class); + doAnswer(call -> { ((Consumer)call.getArgument(0)).accept(mock(TransactionStatus.class)); return null; }).when(tx).executeWithoutResult(any()); + var service = new ShortDramaRevisionService(projects, scripts, boards, + mock(ShortDramaCharacterMapper.class), mock(ShortDramaLocationMapper.class), mock(ShortDramaVisualAssetMapper.class), mock(ShortDramaVisualAssetService.class), mock(IShortDramaService.class), tx); + var p = new ShortDramaProject(); p.setId(1L); p.setUserId(7L); p.setStatus("storyboard_ready"); + when(projects.selectById(1L)).thenReturn(p); + var r = new ShortDramaRevisionBo(); r.setScriptId(2L); r.setExpectedScriptText("old"); + assertThrows(IllegalArgumentException.class, () -> service.apply(1L, r, 8L)); + verifyNoInteractions(scripts, boards); + var s = new ShortDramaScript(); s.setId(2L); s.setProjectId(1L); s.setScriptText("newer user edit"); + when(scripts.selectById(2L)).thenReturn(s); + assertThrows(IllegalStateException.class, () -> service.apply(1L, r, 7L)); + verifyNoInteractions(boards); + verify(scripts, never()).updateById(any(ShortDramaScript.class)); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaTimingTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaTimingTest.java new file mode 100644 index 00000000..f2eb87d4 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaTimingTest.java @@ -0,0 +1,75 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +@Tag("dev") +class ShortDramaTimingTest { + @Test void emptyEditableSpeechDoesNotEraseDialogueFromSource() { + assertThrows(IllegalArgumentException.class, () -> ShortDramaTiming.validate(17, 2, + "陈念:「六月十二,换电容,三十五元,未收。」", "{\"timing\":{\"spoken_text\":\"\"}}")); + } + @Test void dialogueCannotBeSqueezedIntoTwoSeconds() { + assertThrows(IllegalArgumentException.class, () -> ShortDramaTiming.validate(17, 2, + "陈念:「六月十二,换电容,三十五元,未收。」", "{}")); + } + @Test void actualAudioReplacesEstimatedSpeechAndAddsExclusiveActionAndPause() { + assertEquals(9.5, ShortDramaTiming.requiredSeconds("", """ + {"timing":{"spoken_text":"你好","measured_audio_seconds":6,"action_seconds":2,"pause_seconds":1.5}} + """)); + } + @Test void silentActionStillRequiresItsOwnBudget() { + assertThrows(IllegalArgumentException.class, () -> ShortDramaTiming.validate(1, 5, "", """ + {"timing":{"spoken_text":"","action_seconds":7,"pause_seconds":1}} + """)); + } + @Test void negativeBudgetsAndNonObjectMetadataAreRejected() { + assertThrows(IllegalArgumentException.class, () -> ShortDramaTiming.requiredSeconds("", "{\"timing\":{\"action_seconds\":-1}}")); + assertThrows(IllegalArgumentException.class, () -> ShortDramaTiming.requiredSeconds("", "[]")); + } + @Test void digitsAreNotTreatedAsSilentPunctuation() { + assertEquals(2.8, ShortDramaTiming.requiredSeconds("「35」", "{}")); + } + @Test void sceneTotalCannotHideAnOverfullDialogueShot() { + var a=new ShortDramaServiceImpl.StoryboardPanelData(); + a.setDuration(3); a.setSourceText("陈念:「我毕业,他答应来的。我等到学校关门。」"); + var b=new ShortDramaServiceImpl.StoryboardPanelData(); b.setDuration(7); b.setSourceText(""); + var panels=java.util.List.of(a,b); + assertDoesNotThrow(() -> ShortDramaServiceImpl.validateSceneDuration("第1场 预计10秒",panels)); + var error=assertThrows(IllegalStateException.class, () -> ShortDramaServiceImpl.validateScenePlan("第1场 预计10秒",panels)); + assertTrue(error.getMessage().contains("镜头1内容至少")); + } + @Test void naturallyBudgetedDialoguePassesPlanningGate() { + var a=new ShortDramaServiceImpl.StoryboardPanelData(); + a.setDuration(7); a.setSourceText("陈念:「我毕业,他答应来的。我等到学校关门。」"); + assertDoesNotThrow(() -> ShortDramaServiceImpl.validateScenePlan("第1场 预计7秒",java.util.List.of(a))); + } + @Test void timingRepairSplitsSpeechAndComputesDurationsWithoutChangingOriginal() { + var original=new ShortDramaServiceImpl.StoryboardPanelData(); + original.setLocation("客厅"); original.setDuration(9); original.setSourceText("「你好。坐吧。」"); + var repaired=ShortDramaServiceImpl.applyTimingRepair(java.util.List.of(original), """ + [{"from_panel":1,"source_text":"「你好。」","timing":{"spoken_text":"你好","speech_rate":4,"action_seconds":0,"pause_seconds":1}}, + {"from_panel":1,"source_text":"「坐吧。」","timing":{"spoken_text":"坐吧","speech_rate":4,"action_seconds":2,"pause_seconds":0}}] + """); + assertEquals(2,repaired.size()); assertEquals(2,repaired.get(0).getDuration()); + assertEquals(3,repaired.get(1).getDuration()); assertEquals("客厅",repaired.get(1).getLocation()); + assertEquals(9,original.getDuration()); + assertDoesNotThrow(() -> ShortDramaServiceImpl.validateScenePlan("预计5秒\n「你好。坐吧。」",repaired)); + } + @Test void planningRejectsOmittedDialogueEvenWhenTimeFits() { + var p=new ShortDramaServiceImpl.StoryboardPanelData();p.setDuration(7);p.setSourceText("「你好。」"); + assertTrue(assertThrows(IllegalStateException.class,()->ShortDramaServiceImpl.validateScenePlan( + "预计7秒\n「你好。」「坐吧。」",java.util.List.of(p))).getMessage().contains("遗漏或乱序")); + } + @Test void reviewedPlanCannotOmitOrInventScenes() { + var p=new ShortDramaServiceImpl.StoryboardPanelData();p.setDuration(5);p.setSceneNumber(1);p.setSourceText("整理工具"); + assertThrows(IllegalArgumentException.class,()->ShortDramaServiceImpl.validateReviewedPlan(java.util.List.of("预计5秒","预计5秒"),java.util.List.of(p))); + p.setSceneNumber(3); + assertThrows(IllegalArgumentException.class,()->ShortDramaServiceImpl.validateReviewedPlan(java.util.List.of("预计5秒"),java.util.List.of(p))); + } + @Test void reviewedPlanUsesTheSameTimingAndDialogueGate() { + var p=new ShortDramaServiceImpl.StoryboardPanelData();p.setDuration(2);p.setSceneNumber(1);p.setSourceText("「我毕业,他答应来的。我等到学校关门。」"); + assertThrows(IllegalStateException.class,()->ShortDramaServiceImpl.validateReviewedPlan(java.util.List.of("预计2秒"),java.util.List.of(p))); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoContinuityTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoContinuityTest.java new file mode 100644 index 00000000..14e698bf --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVideoContinuityTest.java @@ -0,0 +1,62 @@ +package org.ruoyi.service.shortdrama.impl; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard; +import org.ruoyi.domain.vo.shortdrama.ShortDramaStoryboardVo; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@Tag("dev") +class ShortDramaVideoContinuityTest { + @Test + void waitsForFirstShotBeforeSubmittingNextWithItsActualLastFrame() { + var service = mock(ShortDramaServiceImpl.class, CALLS_REAL_METHODS); + doReturn(video("generating", null)).when(service).generateVideo(1L, "model", 1L, null); + doReturn(video("done", "frame-1")).when(service).ensureVideoDone(1L, "model", 1L); + doReturn(video("done", "frame-2")).when(service).generateVideo(2L, "model", 1L, "frame-1"); + var result = service.generateGroupSerial(List.of(shot(1, 1), shot(2, 1)), "model", 1L); + var order = inOrder(service); + order.verify(service).generateVideo(1L, "model", 1L, null); + order.verify(service).ensureVideoDone(1L, "model", 1L); + order.verify(service).generateVideo(2L, "model", 1L, "frame-1"); + assertEquals(2, result.size()); + } + + @Test + void missingLastFrameOrFailedShotNeverSubmitsDownstreamShot() { + for (String status : List.of("done", "failed", "generating")) { + var service = mock(ShortDramaServiceImpl.class, CALLS_REAL_METHODS); + doReturn(video(status, null)).when(service).generateVideo(1L, "model", 1L, null); + doReturn(video(status, null)).when(service).ensureVideoDone(1L, "model", 1L); + assertThrows(IllegalStateException.class, () -> service.generateGroupSerial( + List.of(shot(1, 1), shot(2, 1)), "model", 1L)); + verify(service, never()).generateVideo(eq(2L), anyString(), anyLong(), nullable(String.class)); + } + } + + @Test + void samePhysicalLocationAfterTimeJumpDoesNotInheritEarlierSceneFrame() { + var groups = ShortDramaServiceImpl.groupContinuousScenes(List.of(shot(1, 1), shot(2, 1), shot(3, 2))); + assertEquals(2, groups.size()); + assertEquals(2, groups.get(0).size()); + assertEquals(3L, groups.get(1).get(0).getId()); + } + + private static ShortDramaStoryboard shot(long id, int scene) { + var shot = new ShortDramaStoryboard(); + shot.setId(id); + shot.setSceneNo((int) id); + shot.setLocationName("修理铺"); + shot.setContinuityJson("{\"scene_number\":" + scene + "}"); + return shot; + } + + private static ShortDramaStoryboardVo video(String status, String frame) { + var video = new ShortDramaStoryboardVo(); + video.setVideoStatus(status); + video.setLastFrameUrl(frame); + return video; + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVisualAssetTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVisualAssetTest.java new file mode 100644 index 00000000..44837199 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/shortdrama/impl/ShortDramaVisualAssetTest.java @@ -0,0 +1,73 @@ +package org.ruoyi.service.shortdrama.impl; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +@Tag("dev") class ShortDramaVisualAssetTest { + @Test void originalMediaIsExcludedFromGenerativeFrames() { + assertTrue(ShortDramaVisualAssetService.directInsert("{\"source_media\":{\"mode\":\"direct_insert\"}}")); + assertFalse(ShortDramaVisualAssetService.directInsert("{\"source_media\":{\"mode\":\"reference\"}}")); + assertFalse(ShortDramaVisualAssetService.directInsert(null)); + } + @Test void sceneBudgetRejectsSilentDurationInflation() { + var panel=new ShortDramaServiceImpl.StoryboardPanelData();panel.setDuration(15); + assertThrows(IllegalStateException.class,()->ShortDramaServiceImpl.validateSceneDuration("第一场 内景 修理铺 下午(预计8秒)",List.of(panel))); + panel.setDuration(8); + assertDoesNotThrow(()->ShortDramaServiceImpl.validateSceneDuration("第一场 内景 修理铺 下午(预计8秒)",List.of(panel))); + } + @Test void voiceOnlyCharacterDoesNotRequireVisualIdentity() { + var character=new org.ruoyi.domain.entity.shortdrama.ShortDramaCharacter(); + character.setIntroduction("全剧仅以手机语音出现,无实体出场"); + assertTrue(ShortDramaVisualAssetService.voiceOnly(character)); + character.setIntroduction("陈念在铺内播放手机语音"); + assertFalse(ShortDramaVisualAssetService.voiceOnly(character)); + } + @Test void changedStateOrReferenceInvalidatesFrame() { + var s=new ShortDramaStoryboard();s.setImagePrompt("同一画面");s.setCharactersJson("[]");s.setContinuityJson("坐着"); + var first=ShortDramaVisualAssetService.frameHash(s,List.of("face"),"props"); + s.setContinuityJson("站着");assertNotEquals(first,ShortDramaVisualAssetService.frameHash(s,List.of("face"),"props")); + s.setContinuityJson("坐着");assertNotEquals(first,ShortDramaVisualAssetService.frameHash(s,List.of("new face"),"props")); + assertTrue(ShortDramaVisualAssetService.framePrompt(s).contains("只画起始状态")); + } + @Test void cameraChangeInvalidatesPreviouslyGeneratedComposition() { + var shot=new ShortDramaStoryboard();shot.setImagePrompt("核对账本");shot.setShotType("平视中景"); + var original=ShortDramaVisualAssetService.frameHash(shot,List.of("face"),"props"); + shot.setShotType("俯拍近景"); + assertNotEquals(original,ShortDramaVisualAssetService.frameHash(shot,List.of("face"),"props")); + assertTrue(ShortDramaVisualAssetService.framePrompt(shot).contains("俯拍近景")); + } + @Test void firstFrameDoesNotMixInAConflictingEndPose() { + var shot=new ShortDramaStoryboard();shot.setImagePrompt("两人已经坐好"); + shot.setContinuityJson("{\"start_state\":\"两人站立准备拉椅\",\"present_characters\":[\"陈念\",\"周姨\"]}"); + String prompt=ShortDramaVisualAssetService.framePrompt(shot); + assertTrue(prompt.contains("两人站立准备拉椅"));assertFalse(prompt.contains("两人已经坐好")); + } + @Test void soloFrameDoesNotImportAnAbsentCharacterFromSceneAnchor() { + var shot=new ShortDramaStoryboard(); + shot.setCharactersJson("[{\"name\":\"陈念\",\"slot\":\"工作台右侧\"}]"); + shot.setContinuityJson("{\"start_state\":\"陈念低头收工具\",\"present_characters\":[\"陈念\"],\"spatial_anchor\":\"陈念在工作台右侧,林叔在入口\"}"); + String prompt=ShortDramaVisualAssetService.framePrompt(shot); + assertTrue(prompt.contains("画面严格只有1人:陈念")); + assertTrue(prompt.contains("工作台右侧")); + assertFalse(prompt.contains("林叔在入口")); + } + @Test void closeFrameUsesNarrativeFocusWithoutForcingAllParticipantsIntoFrame() { + var shot=new ShortDramaStoryboard();shot.setShotType("近景");shot.setSceneTitle("周姨说有人嗯一声与没人不同"); + shot.setCharactersJson("[{\"name\":\"陈念\"},{\"name\":\"周姨\"}]"); + String instruction=ShortDramaVisualAssetService.closeFrameInstruction(shot); + assertTrue(instruction.contains("以周姨为视觉中心")); + assertTrue(instruction.contains("不要求全部同时入画")); + shot.setContinuityJson("{\"frame_focus\":\"陈念的倾听反应\"}"); + assertTrue(ShortDramaVisualAssetService.closeFrameInstruction(shot).contains("以陈念的倾听反应为视觉中心")); + shot.setShotType("双人中景");assertEquals("",ShortDramaVisualAssetService.closeFrameInstruction(shot)); + } + @Test void reviewedDirectiveSurvivesStructuredStartState() { + var shot=new ShortDramaStoryboard();shot.setImagePrompt("手已放开手机"); + shot.setContinuityJson("{\"start_state\":\"右手持手机\",\"keyframe_directive\":\"两枚对角后摄,手机仍在手中\"}"); + String prompt=ShortDramaVisualAssetService.framePrompt(shot); + assertTrue(prompt.contains("右手持手机")); + assertTrue(prompt.contains("两枚对角后摄,手机仍在手中")); + assertFalse(prompt.contains("手已放开手机")); + } +} diff --git a/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/video/provider/AtlasVideoDurationTest.java b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/video/provider/AtlasVideoDurationTest.java new file mode 100644 index 00000000..2591cbf8 --- /dev/null +++ b/ruoyi-modules/ruoyi-chat/src/test/java/org/ruoyi/service/video/provider/AtlasVideoDurationTest.java @@ -0,0 +1,14 @@ +package org.ruoyi.service.video.provider; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +@Tag("dev") class AtlasVideoDurationTest { + @Test void shortEditorialShotsUseSupportedProviderDuration() { + String model="bytedance/seedance-2.0-mini/reference-to-video"; + assertEquals(4,AtlasVideoGenerationServiceImpl.requestDuration(model,3)); + assertEquals(9,AtlasVideoGenerationServiceImpl.requestDuration(model,9)); + assertEquals(-1,AtlasVideoGenerationServiceImpl.requestDuration(model,-1)); + assertThrows(IllegalArgumentException.class,()->AtlasVideoGenerationServiceImpl.requestDuration(model,16)); + assertEquals(3,AtlasVideoGenerationServiceImpl.requestDuration("another-model",3)); + } +}