mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-11-11 21:40:41 +00:00
feat: 增强短剧导演规则与素材生成及合成
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -57,3 +57,4 @@ logs/
|
||||
.flattened-pom.xml
|
||||
/.claude/settings.local.json
|
||||
/docs/docker/milvus/volumes/
|
||||
/.codex-runtime/
|
||||
|
||||
@@ -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='道具资产和逐镜关键帧,可续跑';
|
||||
@@ -47,4 +47,6 @@ public class ImageContext {
|
||||
* 参考图 URL(图生图用)
|
||||
*/
|
||||
private String image;
|
||||
/** Ordered reference images for multi-subject image editing. */
|
||||
private java.util.List<String> referenceImages;
|
||||
}
|
||||
|
||||
@@ -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<ShortDramaDetailVo> 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<ShortDramaDetailVo> polishScript(@NotNull @PathVariable Long projectId) {
|
||||
return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId()));
|
||||
public R<ShortDramaDetailVo> 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<ShortDramaDetailVo> 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<Integer> 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,
|
||||
|
||||
@@ -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<ShortDramaStoryboard>()
|
||||
.eq(ShortDramaStoryboard::getId, shot.getId()).set(ShortDramaStoryboard::getContinuityJson, continuity.toString()));
|
||||
}
|
||||
@PostMapping(value="/{shotId}",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public synchronized R<Object> 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<list.size(); i++) if (replaceUrl.equals(list.get(i).path("url").asText())) replace=i;
|
||||
if (replace < 0) throw new IllegalArgumentException("素材已变化,请刷新后重试");
|
||||
if (files.length != 1) throw new IllegalArgumentException("更换时请选择一张图片");
|
||||
}
|
||||
if (files.length == 0 || files.length > 20 || (replace < 0 && list.size()+files.length > 20))
|
||||
throw new IllegalArgumentException("每个镜头最多20张素材");
|
||||
// Validate the whole selection before storing or changing any bindings.
|
||||
List<byte[]> 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<files.length; i++) {
|
||||
var file=files[i]; String type=file.getContentType();
|
||||
String name=UUID.randomUUID()+("image/png".equals(type)?".png":".jpg");
|
||||
Files.write(dir.resolve(name),contents.get(i),StandardOpenOption.CREATE_NEW);
|
||||
ObjectNode image = replace >= 0 ? ((ObjectNode)list.get(replace)).deepCopy() : JSON.createObjectNode();
|
||||
image.remove("items");
|
||||
image.put("url", "/short-drama/"+projectId+"/materials/file/"+name);
|
||||
image.put("filename", Objects.toString(file.getOriginalFilename(), "真实素材"));
|
||||
image.put("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<Object> 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<byte[]> 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<org.springframework.core.io.Resource> 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<ShortDramaStoryboard>().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<String> command=new ArrayList<>(List.of("ffmpeg","-v","error","-y"));List<String> filters=new ArrayList<>();StringBuilder concat=new StringBuilder();
|
||||
double duration=shot.getDurationSeconds(), part=duration/images.size();
|
||||
for(int i=0;i<images.size();i++) {
|
||||
String prefix="/short-drama/"+projectId+"/materials/file/",url=images.get(i).path("url").asText();
|
||||
if(!url.startsWith(prefix))throw new IllegalArgumentException("只允许当前项目已上传的原图");
|
||||
String name=url.substring(prefix.length());if(!name.matches("[a-f0-9-]{36}\\.(png|jpg)"))throw new IllegalArgumentException("素材路径无效");
|
||||
command.addAll(List.of("-loop","1","-t",Double.toString(part),"-i",dir.resolve(name).toString()));
|
||||
filters.add("["+i+":v]"+cropFilter(images.get(i).path("crop"))+"scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=white,setsar=1,fps=24,trim=duration="+part+",setpts=PTS-STARTPTS[v"+i+"]");concat.append("[v").append(i).append("]");
|
||||
}
|
||||
command.addAll(List.of("-f","lavfi","-t",Double.toString(duration),"-i","anullsrc=r=48000:cl=stereo"));
|
||||
filters.add(concat+"concat=n="+images.size()+":v=1:a=0[out]");Path output=dir.resolve(job+".mp4");
|
||||
command.addAll(List.of("-filter_complex",String.join(";",filters),"-map","[out]","-map",images.size()+":a","-t",Double.toString(duration),"-c:v","libx264","-preset","fast","-crf","18","-pix_fmt","yuv420p","-c:a","aac","-movflags","+faststart",output.toString()));
|
||||
org.ruoyi.service.shortdrama.impl.ShortDramaSoundService.run(command,dir.resolve(job+".log"));
|
||||
String videoUrl="/short-drama/"+projectId+"/materials/video/"+job;
|
||||
boards.update(null,new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ShortDramaStoryboard>().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+",";
|
||||
}
|
||||
}
|
||||
@@ -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<Integer> 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<byte[]> 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)));
|
||||
}
|
||||
}
|
||||
@@ -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<Map<String,Object>> 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<Integer> shotNumbers,String imageUrl) { }
|
||||
@PostMapping("/prop") public R<Map<String,Object>> 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<Map<String,Object>> opening(@PathVariable Long projectId,@RequestParam String model) {return R.ok(service.generateOpening(projectId,model,LoginHelper.getUserId()));}
|
||||
@PostMapping("/generate-range") public R<Map<String,Object>> 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<Map<String,Object>> status(@PathVariable Long projectId) {return R.ok(service.status(projectId,LoginHelper.getUserId()));}
|
||||
@PostMapping("/plan") public R<Map<String,Object>> plan(@PathVariable Long projectId,@RequestParam(required=false) String model) {return R.ok(service.plan(projectId,model,LoginHelper.getUserId()));}
|
||||
@PostMapping("/recover") public R<Map<String,Object>> recover(@PathVariable Long projectId) {return R.ok(service.recover(projectId,LoginHelper.getUserId()));}
|
||||
@PostMapping("/generate") public R<Map<String,Object>> generate(@PathVariable Long projectId,@RequestParam String model) {return R.ok(service.generate(projectId,model,LoginHelper.getUserId()));}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<StoryboardPanelData> panels;
|
||||
}
|
||||
@@ -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<ShortDramaStoryboardBo> storyboards;
|
||||
private List<ShortDramaCharacterBo> characters;
|
||||
private List<ShortDramaLocationBo> locations;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ShortDramaVisualAsset> { }
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -51,10 +51,10 @@ public interface IShortDramaService {
|
||||
|
||||
List<ShortDramaStoryboardVo> 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<ShortDramaStoryboardVo> 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<ShortDramaStoryboardVo> generatePhotographyRules(Long projectId, Long scriptId, Long userId);
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,11 +96,11 @@ public class FfmpegVideoComposer {
|
||||
List<MediaInfo> 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;
|
||||
}
|
||||
|
||||
@@ -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<String,String> 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); }
|
||||
}
|
||||
}
|
||||
@@ -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 <T,R> List<R> mapOrdered(List<T> inputs,int concurrency,Function<T,R> fn) {
|
||||
if(inputs.isEmpty())return List.of();
|
||||
var pool=Executors.newFixedThreadPool(Math.min(concurrency,inputs.size()));
|
||||
List<Future<R>> jobs=new ArrayList<>();
|
||||
try {
|
||||
for(var input:inputs)jobs.add(pool.submit(()->fn.apply(input)));
|
||||
List<R> 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();}
|
||||
}
|
||||
}
|
||||
@@ -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<ShortDramaStoryboard>().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<Long> 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<ShortDramaVisualAsset>()
|
||||
.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<ShortDramaStoryboard>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Integer> 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<Sound> list(Long id) throws Exception {
|
||||
Path dir=directory(id);if(!Files.isDirectory(dir))return List.of();
|
||||
try(var files=Files.list(dir)){List<Sound> 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<Integer> 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<String> references(Long project,int shot,org.ruoyi.common.chat.domain.vo.chat.ChatModelVo model) {
|
||||
try {
|
||||
List<String> 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<org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard> 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<String> command=new ArrayList<>(List.of("ffmpeg","-v","error","-y","-i",video.toString()));
|
||||
List<String> filters=new ArrayList<>();List<String> tracks=new ArrayList<>();int input=1;
|
||||
for(var sound:music) {
|
||||
double cursor=0,start=-1,end=0;
|
||||
List<double[]> 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<String> 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("无法解码音频,请使用有效的音频文件");
|
||||
}
|
||||
}
|
||||
@@ -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 + "秒,请缩短台词、拆镜或延长时长");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Long, String> running = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<Long, String> 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<ShortDramaVisualAsset> rows(Long id) {
|
||||
return assets.selectList(new LambdaQueryWrapper<ShortDramaVisualAsset>().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<String,Object> 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<String,Object> 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<ShortDramaStoryboard>().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 || end<start) throw new IllegalStateException("道具分析未返回有效列表,原资产未变更");
|
||||
var props=JSON.readTree(raw.substring(start,end+1));
|
||||
if(!props.isArray() || props.size()>12) throw new IllegalStateException("道具清单格式不正确");
|
||||
Set<String> keys=new HashSet<>();
|
||||
Set<Integer> 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<Integer> 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<ShortDramaVisualAsset>().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<Long> selected) {
|
||||
var current=boards.selectList(new LambdaQueryWrapper<ShortDramaStoryboard>().eq(ShortDramaStoryboard::getProjectId,id));
|
||||
for(var shot:current) {
|
||||
if(selected != null && !selected.contains(shot.getId())) continue;
|
||||
var old=assets.selectOne(new LambdaQueryWrapper<ShortDramaVisualAsset>().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<String> 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<ShortDramaVisualAsset>().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<String> baseReferences(ShortDramaStoryboard shot) throws Exception {
|
||||
List<String> refs=new ArrayList<>();
|
||||
var bindings=JSON.readTree(shot.getCharactersJson()==null?"[]":shot.getCharactersJson());
|
||||
for(var binding:bindings) {
|
||||
var ch=characters.selectOne(new LambdaQueryWrapper<ShortDramaCharacter>().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<ShortDramaCharacterAppearance>().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<ShortDramaLocation>().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<String,Object> saveProp(Long id,String title,String prompt,List<Integer> 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<ShortDramaStoryboard>().eq(ShortDramaStoryboard::getProjectId,id));
|
||||
Set<Integer> 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<ShortDramaVisualAsset>().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<ShortDramaVisualAsset>()
|
||||
.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<String,Object> generateOpening(Long id,String model,Long user) { return generateRange(id,model,user,1,10); }
|
||||
public Map<String,Object> generateRange(Long id,String model,Long user,int start,int end) {
|
||||
if(start<1 || end<start || end-start>=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<ShortDramaStoryboard>().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<CompletableFuture<Void>> 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<String> readyPropReferences(ShortDramaStoryboard shot) {
|
||||
List<String> 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<String,Object> 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<String,Object> 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<String,Object> 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<CompletableFuture<Void>> 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<String> 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<ShortDramaCharacter>().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()<deadline) {
|
||||
if(Thread.currentThread().isInterrupted())return;
|
||||
var result=predictions.retrieve(m,a.getPredictionId());
|
||||
if(result!=null && ("completed".equals(result.getStatus()) || "succeeded".equals(result.getStatus())) && result.getUrl()!=null && !result.getUrl().isBlank()) {
|
||||
a.setImageUrl(result.getUrl());a.setStatus("done");a.setUpdateTime(new Date());assets.updateById(a);return;
|
||||
}
|
||||
if(result!=null && "failed".equals(result.getStatus())) {providerFailed=true;throw new IllegalStateException("模型图片任务失败");}
|
||||
Thread.sleep(2500);
|
||||
}
|
||||
a.setStatus("waiting");a.setErrorMessage("服务端任务仍未完成;再次继续会查询原任务,不重复提交");assets.updateById(a);
|
||||
} catch(InterruptedException e) {Thread.currentThread().interrupt();}
|
||||
catch(Exception e) {a.setStatus(!providerFailed && a.getPredictionId()!=null && !a.getPredictionId().isBlank()?"waiting":"failed");a.setErrorMessage(safeError(e));a.setUpdateTime(new Date());assets.updateById(a);}
|
||||
}
|
||||
private boolean linked(ShortDramaVisualAsset prop,int number) throws Exception {
|
||||
for(var n:JSON.readTree(prop.getReferenceImages()))if(n.asInt()==number)return true;return false;
|
||||
}
|
||||
private boolean hasProp(JsonNode names,String title) { for(var n:names) if(title.equals(n.asText()))return true;return false; }
|
||||
public String readyFrame(Long storyboard) {
|
||||
var a=assets.selectOne(new LambdaQueryWrapper<ShortDramaVisualAsset>().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()));}
|
||||
}
|
||||
@@ -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<String> 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());
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: director-blocking
|
||||
description: 动作调度与连续性
|
||||
---
|
||||
|
||||
# 动作调度与连续性
|
||||
|
||||
用于分镜、摄影、表演和视频提示词。先决定行为,再选择能看清行为的机位。每镜明确:叙事作用、触发原因、主体目标、起始姿态、视线对象、持物手、主要动作、结束状态及承接。
|
||||
首帧是行为即将发生或正在进行的瞬间,不是人物介绍照。清理遗物应为俯身低头整理、手与物有接触;默认视线在场内对象上。只有剧本明确要求打破第四面墙才直视镜头。不能仅以“站在桌前”代替动作起点。
|
||||
每个子镜头聚焦一个主要动作或反应。需要多个构图时,明确内部切点和秒数,切换仍需空间或动作动机,不强制每3秒切镜。不要求将整条因果链挤入一个连续长镜。
|
||||
逐镜传递人物位置、朝向、左右手、道具位置/持有/开合、入口、时间与光线;下一镜起点继承上一镜终点,省略过程时写明合理时间切换。不要把手机在桌上点击写成拿起收听。
|
||||
抽象情绪转成可见行为:指腹摸镜腿、手停住、视线停在眼镜,再放下眼镜拿手机。不可要求模型直接画“想起父亲”。
|
||||
摄影机位和人物身体朝向分开描述;人物参考只锁身份,不能继承正脸凝视。每镜主运镜最多一种,固定镜头允许。
|
||||
对白与注视可重叠;取物、放物、换手需真实时间,不能用快放过关。声音来自谁、屏幕是否发声需明确。保留当前接口输出结构及字段。
|
||||
|
||||
细化只能落实既有动作,不能交换对白先后或替换已确认道具:手机看时间不可变成手表;已经从窗边移到方桌的收音机,未出现搬动前不得回到窗边。纯屏幕插入镜的出镜人物为空,不继承上一镜演员。
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: director-review
|
||||
description: 导演审片标准
|
||||
---
|
||||
|
||||
# 导演审片标准
|
||||
|
||||
用于生成前审阅和提示词检查。先检查故事作用、因果、动作可执行性、视线、空间状态、时间预算和世界设定,再检查实际生成图。
|
||||
关键帧对照可观察项:是否正在执行指定起始动作;是否错误看镜头;手和道具是否相接;背景是否符合维护程度与陈设密度;身份和衣服是否稳定。文本合格或接口成功不能代替画面验收。
|
||||
每个问题记录预期、实际证据、责任阶段、修改范围和复查结果。字段完整不等于通过,未看过图片不可声称视觉通过;审阅结论不得由生图状态done自动推导。
|
||||
连续段落合看相邻镜头,而非只看单张美感;重要状态冲突优先于平均分。确认一镜后再按用户范围推进;发现共性问题改规则并列出受影响镜头,不静默批量重绘。
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: real-material
|
||||
description: 真实界面与证据植入
|
||||
---
|
||||
|
||||
# 真实界面与证据植入
|
||||
|
||||
用于分镜、视频提示词和审阅。已绑定的真实照片、账页、软件页面和Excel均是原始证据,保留多个素材及其顺序、用途、时间范围。不能把多张素材压缩为第一张,也不能自动重绘文字。
|
||||
真实界面已显示结果时,不虚构点击生成或加载过程。区分原图直接插入与电脑屏幕透视贴片;过肩镜头需保留肩部、电脑边框和桌面空间关系,下一镜才能切屏幕细节。
|
||||
票据日期和画面设备与故事时间核对;真实截图若含不符年份的模型名、系统时间,标记为后期需处理,不伪称已经修复。AI只能识别、整理、统计,真相需人物查证。
|
||||
本阶段没有授权生成视频时仅输出计划,不启动视频任务。
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: story-causality
|
||||
description: 剧情因果与节奏
|
||||
---
|
||||
|
||||
# 剧情因果与节奏
|
||||
|
||||
用于剧本和大纲。先落实用户确认的故事年份、生活背景、人物目标与信息边界。年份与装修年代分开,旧物不代表故事发生在旧年代。
|
||||
大纲只写每场的目标、触发、行动、结果和下一场原因及预算;正文只保留必要行动和对白,不混入镜头、机位、逐秒或软件操作参数。
|
||||
每场新增信息或改变人物选择;合并重复反应,不用空镜、无目的独处或重复告别凑时长。把“清理→触物→回忆的行为→主动听语音”作为因果链,不拆成互不相干的摆拍。
|
||||
不新增已确认故事之外的秘密或反转;推测需经人物求证。软件能整理证据,不能替人物理解父亲或判断欠款。
|
||||
按对白、不可重叠动作与自然停顿估时,不能靠字数推出片长;用户时长优先。保留当前接口要求的输出结构。
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: video-continuity
|
||||
description: 视频试片阶段的物体连续性与画外声约束
|
||||
---
|
||||
|
||||
# 视频连续性
|
||||
|
||||
同一连续场景须维护道具外观与摆放清单:眼镜框形与材质、垫布颜色、纸箱数量和开合、手机壳不能逐镜重新设计。
|
||||
已装箱的工具、箱子与桌上遗物,未被人物搬走就必须继续存在;画外道具仍保留相对位置,不把工作台恢复成空净场景母图。新来的饭盒只能占用空位,不能替换原有道具。
|
||||
已审阅关键帧优先于旧版视觉草稿,场景母图仅约束建筑与光线。动作从首帧的状态展开,未设计的道具移动、换手、消失均禁止。
|
||||
手机语音只从手机传出,收听者闭口倾听,不对口型;声音原文与发言人不得改写。无对白镜头只保留自然环境与动作声,不添加旁白解释感情。
|
||||
|
||||
## 屏幕与实物操作
|
||||
手机型号、镜头数量、刘海形状、保护壳及持物手均由固定资产决定。微信旧语音通过点击聊天中的语音条播放;不是来电,不长按录音,不凭空出现独立重播键。播放前、播放中、结束状态必须写清,屏幕文字优先使用原始UI素材。
|
||||
放手机须依次经过降低、背壳接触台面、松指、收手。写清屏幕朝向;不得一边握着一边已经躺在桌面,禁止翻面或穿桌。
|
||||
食物、容器和筷子跨镜分别锁定数量、材质、开盖状态与所在桌面。跨桌必须有取走和放下的可见动作,或明确时间省略;不能通过切镜让物件瞬移。
|
||||
电脑软件植入必须满足输入文件确实存在、上传后提交、等待后出结果、结果引发人物行动的顺序。不得以结果页冒充上传页;清晰文字和点击使用真实素材或确定性合成。
|
||||
背景音乐由合成阶段统一混入,人物对白与手机语音镜头不额外生成音乐,避免两套配乐叠加。
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: visual-world
|
||||
description: 场景与人物视觉锁定
|
||||
---
|
||||
|
||||
# 场景与人物视觉锁定
|
||||
|
||||
用于人物、场景和镜头图片。分别锁定故事年份、建筑装修风格、维护程度、陈设密度;服装与设备服从项目设定。禁止把年代感、情感、父亲遗物自动翻译成破败、霉斑、灰尘、垃圾或废弃空间。
|
||||
场景参考为无人物的空间基准图:明确入口、窗、工作台、主要家具相对位置,光源可解释;陈设分区有序,留出人物活动区。只保留少量有叙事作用的可移动物,避免杂物淹没主角。
|
||||
人物设定图用于身份和服装,正面视图属于设定用途;叙事首帧必须重新按本镜动作、视线和机位构图,不复制设定图姿势。先遵守用户确认的身份,不能借重新生图换脸、换年龄、换衣服。
|
||||
背景参考锁定建筑与家具,移动道具和门灯状态以本镜为准。手接触的物体应看得见且数量合理。场景不能被误生成人物合影。
|
||||
图片只画当前时刻,不提前出现后续发现的账本或已完成动作。说明构图焦点和允许出现的物件,不能只堆砌“电影级、悲伤、怀旧”。
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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("旧版深色工装"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ShortDramaServiceImpl.StoryboardPanelData>();
|
||||
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:")<result.indexOf("低头整理工具"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@Tag("dev") class ShortDramaParallelTest {
|
||||
@Test void runsThreeScenesConcurrentlyButKeepsScriptOrder() {
|
||||
var latch=new CountDownLatch(3);var active=new AtomicInteger();var max=new AtomicInteger();
|
||||
var result=ShortDramaParallel.mapOrdered(List.of(1,2,3,4,5,6),3,n->{
|
||||
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;}));
|
||||
}
|
||||
}
|
||||
@@ -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<TransactionStatus>)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));
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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("手已放开手机"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user