feat(short-drama): add director workspaces and refine video production

Add canvas and director APIs, production skill catalogs, prompt preservation, video references and Atlas H3 support. Include schema migrations, upload limits, regression coverage and the new demo URL.
This commit is contained in:
ageerle
2026-10-20 09:30:36 +08:00
parent 8ab3638012
commit 5e66c7bd30
116 changed files with 2965 additions and 388 deletions

View File

@@ -30,6 +30,7 @@
| Admin Panel | http://129.226.199.247:25666 | admin / admin123 |
| User Frontend | http://129.226.199.247:25137 | admin / admin123 |
| Commercial Edition | https://web.ruoyiai.chat | WeChat QR code login |
| Additional Demo | [code.ruoyiai.chat](https://code.ruoyiai.chat/) | — |
## ✨ Core Features

View File

@@ -30,6 +30,7 @@
| 管理端 | http://129.226.199.247:25666 | admin / admin123 |
| 用户端 | http://129.226.199.247:25137 | admin / admin123 |
| 商业版 | https://web.ruoyiai.chat | 微信扫码登录 |
| 新版演示 | [code.ruoyiai.chat](https://code.ruoyiai.chat/) | — |
## ✨ 核心亮点

View File

@@ -1385,10 +1385,10 @@ CREATE TABLE `short_drama_character_appearance` (
`update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id',
`image_urls` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '生成图片URL列表(JSON数组)',
`image_descriptions` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '每张图片对应的提示词(JSON数组)',
`image_descriptions` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '每张图片对应的提示词(JSON数组)',
`selected_image_index` int NULL DEFAULT 0 COMMENT '当前选中的图片索引',
`previous_image_urls` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮图片URL列表(撤销用,JSON数组)',
`previous_descriptions` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)',
`previous_descriptions` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)',
`voice` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '音色名(如 zh_male_taocheng_uranus_bigtts),用于该形象对白配音',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_character_appearance`(`character_id` ASC, `appearance_index` ASC) USING BTREE,
@@ -1428,10 +1428,10 @@ CREATE TABLE `short_drama_location` (
`update_time` datetime NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户Id',
`image_urls` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '生成图片URL列表(JSON数组)',
`image_descriptions` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '每张图片对应的提示词(JSON数组)',
`image_descriptions` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '每张图片对应的提示词(JSON数组)',
`selected_image_index` int NULL DEFAULT 0 COMMENT '当前选中的图片索引',
`previous_image_urls` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮图片URL列表(撤销用,JSON数组)',
`previous_descriptions` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)',
`previous_descriptions` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_project_id`(`project_id` ASC) USING BTREE,
INDEX `idx_tenant_id`(`tenant_id` ASC) USING BTREE

View File

@@ -0,0 +1,8 @@
-- Full image prompt histories can exceed TEXT's 65535 byte capacity after a few revisions.
-- Enlarge both the history and undo columns while retaining all earlier candidates.
ALTER TABLE short_drama_character_appearance
MODIFY COLUMN image_descriptions MEDIUMTEXT NULL COMMENT '每张图片对应的提示词(JSON数组)',
MODIFY COLUMN previous_descriptions MEDIUMTEXT NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)';
ALTER TABLE short_drama_location
MODIFY COLUMN image_descriptions MEDIUMTEXT NULL COMMENT '每张图片对应的提示词(JSON数组)',
MODIFY COLUMN previous_descriptions MEDIUMTEXT NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)';

View File

@@ -0,0 +1,7 @@
-- Add ordered multi-selection without rewriting existing project bindings.
SET @storyboard_skills_sql = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE short_drama_project ADD COLUMN storyboard_skill_names TEXT NULL COMMENT ''Ordered storyboard skill catalog names as JSON''',
'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'short_drama_project' AND column_name = 'storyboard_skill_names');
PREPARE storyboard_skills_stmt FROM @storyboard_skills_sql;
EXECUTE storyboard_skills_stmt;
DEALLOCATE PREPARE storyboard_skills_stmt;

View File

@@ -122,9 +122,9 @@ spring:
servlet:
multipart:
# 单个文件大小
max-file-size: 10MB
max-file-size: 30MB
# 设置总上传的文件大小
max-request-size: 20MB
max-request-size: 40MB
mvc:
# 设置静态资源路径 防止所有请求都去查静态资源
static-path-pattern: /static/**

View File

@@ -34,6 +34,9 @@ public class VideoContext {
/** 多参考图 URL(多参考图生视频模式,配合 @imageN prompt 使用) */
private java.util.List<String> referenceImages;
/** Motion/camera reference clips; distinct from appearance reference images. */
private java.util.List<String> referenceVideos;
/** 参考音频 URL 列表(对白口型对齐模式) */
private java.util.List<String> referenceAudios;

View File

@@ -0,0 +1,18 @@
package org.ruoyi.controller.shortdrama;
import lombok.RequiredArgsConstructor;
import org.ruoyi.common.core.domain.R;
import org.ruoyi.common.satoken.utils.LoginHelper;
import org.ruoyi.service.shortdrama.impl.ShortDramaCanvasService;
import org.springframework.web.bind.annotation.*;
@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}/canvas")
public class ShortDramaCanvasController {
private final ShortDramaCanvasService service;
@GetMapping public R<ShortDramaCanvasService.Canvas> read(@PathVariable Long projectId) throws Exception {
return R.ok(service.read(projectId, LoginHelper.getUserId()));
}
@PutMapping public R<ShortDramaCanvasService.Canvas> save(@PathVariable Long projectId, @RequestBody ShortDramaCanvasService.Canvas request) throws Exception {
return R.ok(service.save(projectId, LoginHelper.getUserId(), request));
}
}

View File

@@ -22,6 +22,7 @@ import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptRevisionBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaStoryboardBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaVideoRejectionBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaIdeaBo;
import org.ruoyi.domain.bo.shortdrama.ShortDramaImageRevisionBo;
import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterVo;
@@ -174,6 +175,21 @@ public class ShortDramaController {
return R.ok(shortDramaService.retrieveVideo(storyboardId, model, LoginHelper.getUserId()));
}
/** Recover an ID verified in provider history; queries the original provider before releasing the claim. */
@PostMapping("/storyboard/{storyboardId}/video-submissions/{requestId}/resolve")
public R<ShortDramaStoryboardVo> resolveVideoSubmission(@NotNull @PathVariable Long storyboardId,
@PathVariable String requestId, @NotBlank @RequestParam String predictionId,
@RequestParam(required = false) String confirmedFailureEvidence) {
return R.ok(shortDramaService.resolveVideoSubmission(storyboardId, requestId, predictionId, LoginHelper.getUserId(), confirmedFailureEvidence));
}
/** No prediction ID is invented for an explicit legacy rejection. This only releases its local claim. */
@PostMapping("/storyboard/{storyboardId}/video-submissions/{requestId}/confirm-rejection")
public R<ShortDramaStoryboardVo> confirmVideoSubmissionRejection(@NotNull @PathVariable Long storyboardId,
@PathVariable String requestId, @Valid @RequestBody ShortDramaVideoRejectionBo evidence) {
return R.ok(shortDramaService.confirmVideoSubmissionRejection(storyboardId, requestId, LoginHelper.getUserId(), evidence));
}
@PostMapping("/{projectId}/generate-all-videos")
public R<List<ShortDramaStoryboardVo>> generateAllVideos(@NotNull @PathVariable Long projectId,
@NotBlank @RequestParam String model,
@@ -217,7 +233,7 @@ public class ShortDramaController {
@PostMapping("/{projectId}/polish-script")
public R<ShortDramaDetailVo> polishScript(@NotNull @PathVariable Long projectId,
@Valid @RequestBody ShortDramaScriptRevisionBo revision) {
return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId(), revision.getInstruction()));
return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId(), revision.getInstruction(), revision.getModel()));
}
// ==================== 资产分析 ====================

View File

@@ -0,0 +1,18 @@
package org.ruoyi.controller.shortdrama;
import lombok.RequiredArgsConstructor;
import org.ruoyi.common.core.domain.R;
import org.ruoyi.common.satoken.utils.LoginHelper;
import org.ruoyi.service.shortdrama.impl.ShortDramaDirectorService;
import org.springframework.web.bind.annotation.*;
@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}/director/{id}")
public class ShortDramaDirectorController {
private final ShortDramaDirectorService service;
@GetMapping public R<ShortDramaDirectorService.Director> read(@PathVariable Long projectId, @PathVariable String id) throws Exception {
return R.ok(service.read(projectId, id, LoginHelper.getUserId()));
}
@PutMapping public R<ShortDramaDirectorService.Director> save(@PathVariable Long projectId, @PathVariable String id, @RequestBody ShortDramaDirectorService.Director request) throws Exception {
return R.ok(service.save(projectId, id, LoginHelper.getUserId(), request));
}
}

View File

@@ -17,6 +17,7 @@ public class ShortDramaIdeaBo {
private String artStyle;
private String aestheticSkillName;
private String directorSkillName;
private java.util.List<String> storyboardSkillNames;
private String aspectRatio;

View File

@@ -26,6 +26,7 @@ public class ShortDramaProjectBo extends BaseEntity {
private String artStyle;
private String aestheticSkillName;
private String directorSkillName;
private java.util.List<String> storyboardSkillNames;
private String composeAspectRatio;
}

View File

@@ -10,4 +10,7 @@ public class ShortDramaScriptRevisionBo {
@NotBlank(message = "修改意见不能为空")
@Size(max = 10000, message = "修改意见不能超过10000字")
private String instruction;
/** Optional exact writing model; omission preserves the configured default. */
private String model;
}

View File

@@ -0,0 +1,10 @@
package org.ruoyi.domain.bo.shortdrama;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
/** Owner attestation for a legacy HTTP rejection whose body was retained in runtime logs. */
public record ShortDramaVideoRejectionBo(@NotNull Integer httpStatus,
@NotBlank @Size(max = 2000) String responseBody,
@NotBlank @Size(min = 20, max = 2000) String evidence) { }

View File

@@ -2,6 +2,8 @@ package org.ruoyi.domain.entity.shortdrama;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
@@ -12,7 +14,7 @@ import java.util.Date;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("short_drama_project")
@TableName(value = "short_drama_project", autoResultMap = true)
public class ShortDramaProject extends BaseEntity {
@Serial
@@ -35,6 +37,9 @@ public class ShortDramaProject extends BaseEntity {
private String artStyle;
private String aestheticSkillName;
private String directorSkillName;
/** Ordered supplementary skills for storyboard planning and review. */
@TableField(typeHandler = JacksonTypeHandler.class)
private java.util.List<String> storyboardSkillNames;
private Long composedVideoOssId;

View File

@@ -31,6 +31,7 @@ public class ShortDramaProjectVo implements Serializable {
private String artStyle;
private String aestheticSkillName;
private String directorSkillName;
private java.util.List<String> storyboardSkillNames;
private Long composedVideoOssId;

View File

@@ -52,6 +52,13 @@ public interface IShortDramaService {
org.ruoyi.service.shortdrama.impl.ShortDramaVideoSubmissionStore.Submission videoSubmission(Long storyboardId, String requestId, Long userId);
ShortDramaStoryboardVo resolveVideoSubmission(Long storyboardId, String requestId, String predictionId, Long userId);
ShortDramaStoryboardVo resolveVideoSubmission(Long storyboardId, String requestId, String predictionId, Long userId, String confirmedFailureEvidence);
ShortDramaStoryboardVo confirmVideoSubmissionRejection(Long storyboardId, String requestId, Long userId,
org.ruoyi.domain.bo.shortdrama.ShortDramaVideoRejectionBo evidence);
ShortDramaStoryboardVo retrieveVideo(Long storyboardId, String videoModel, Long userId);
/** 代理流式输出视频,绕过 OSS 强制下载 header */
@@ -68,6 +75,8 @@ public interface IShortDramaService {
/** 按修改意见重新生成固定格式剧本 */
ShortDramaDetailVo polishScript(Long projectId, Long userId, String instruction);
ShortDramaDetailVo polishScript(Long projectId, Long userId, String instruction, String model);
/** Phase 3-6: 分镜规划+摄影规则+表演指导+分镜细化 */
List<ShortDramaStoryboardVo> planStoryboard(Long projectId, Long scriptId, String model, Long userId);
List<ShortDramaStoryboardVo> planStoryboard(Long projectId, Long scriptId, String model, Long userId, Integer minimumShotSeconds);

View File

@@ -11,7 +11,7 @@ final class ShortDramaBackgroundExtras {
private static final Pattern COUNT = Pattern.compile("^0(?:秒|s)\\s*[::]\\s*可见人数\\s*[=::]\\s*(\\d+)(?:\\s*[-–—~~至]\\s*(\\d+))?\\s*[;;]");
// Role/category vocabulary, rather than a list of identities from any particular drama.
// A generic occupation alias does not uniquely identify its registered foreground representative.
private static final String CATEGORY = "军队|敌军|守军|援军|骑兵|步兵|乱兵|溃兵|散兵|兵卒|军士|士兵|军群|群众|百姓|村民|乡亲|路人|行人|人群|众人|同伴|差役|衙役|工匠|宾客|队伍|护卫|兵士|乡勇|徒弟|学徒|学生|工人|农民|农户|商人|顾客|乘客|医护人员|护士|医生|警员|警察|记者|店员|伙计|侍卫|侍从|仆人|仆役|富户|宫女|老人|老汉|少年|少女|儿童";
private static final String CATEGORY = "军队|敌军|守军|援军|骑兵|步兵|乱兵|溃兵|散兵|兵卒|守卒|守卫|军士|士兵|军群|群众|百姓|村民|乡亲|路人|行人|人群|众人|同伴|差役|衙役|工匠|宾客|队伍|护卫|兵士|乡勇|徒弟|学徒|学生|工人|农民|农户|商人|顾客|乘客|医护人员|护士|医生|警员|警察|记者|店员|伙计|侍卫|侍从|仆人|仆役|富户|宫女|老人|老汉|少年|少女|儿童";
private static final Pattern GROUP = Pattern.compile(CATEGORY);
private static final Pattern GENERIC_NAME = Pattern.compile("^(?:(?:前景|后景|年轻|年老|老|小|普通|匿名|无名|一名|领队的|排首的|带头的|男|女))*(?:" + CATEGORY + ")$");
static boolean genericRole(String name) { return name != null && GENERIC_NAME.matcher(name.trim()).matches(); }

View File

@@ -0,0 +1,74 @@
package org.ruoyi.service.shortdrama.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.nio.file.*;
import java.util.*;
/** Editable production documents. Media submission and business ownership remain in their existing services. */
@Service
public class ShortDramaCanvasService {
private static final ObjectMapper JSON = new ObjectMapper();
private final ShortDramaSoundService owners;
private final Path root;
public record Canvas(long revision, JsonNode document) { }
@Autowired public ShortDramaCanvasService(ShortDramaSoundService owners) { this(owners, Path.of("data", "short-drama-canvases")); }
ShortDramaCanvasService(ShortDramaSoundService owners, Path root) { this.owners = owners; this.root = root.toAbsolutePath(); }
private Path file(Long project) { return root.resolve(project + ".json"); }
public synchronized Canvas read(Long project, Long user) throws Exception {
owners.owner(project, user);
return Files.exists(file(project)) ? JSON.readValue(file(project).toFile(), Canvas.class) : new Canvas(0, null);
}
public synchronized Canvas save(Long project, Long user, Canvas request) throws Exception {
owners.owner(project, user);
validate(request.document());
Canvas previous = read(project, user);
if (previous.revision() != request.revision()) throw new IllegalArgumentException("画布已在另一窗口更新,请重新加载后继续编辑");
Canvas saved = new Canvas(previous.revision() + 1, request.document().deepCopy());
Files.createDirectories(root);
Path temporary = Files.createTempFile(root, "canvas-", ".tmp");
try {
JSON.writeValue(temporary.toFile(), saved);
try { Files.move(temporary, file(project), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); }
catch (AtomicMoveNotSupportedException ignored) { Files.move(temporary, file(project), StandardCopyOption.REPLACE_EXISTING); }
} finally { Files.deleteIfExists(temporary); }
return saved;
}
static void validate(JsonNode doc) throws Exception {
if (doc == null || !doc.isObject() || doc.path("schema").asInt() != 1 || !doc.path("nodes").isArray() || !doc.path("edges").isArray())
throw new IllegalArgumentException("画布格式无效");
if (JSON.writeValueAsBytes(doc).length > 2 * 1024 * 1024 || doc.path("nodes").size() > 500 || doc.path("edges").size() > 1500)
throw new IllegalArgumentException("画布超过保存上限");
Set<String> ids = new HashSet<>(); Map<String,String> kinds = new HashMap<>();
Set<String> supported = Set.of("text", "script", "image", "video", "audio", "group", "previs");
for (JsonNode n : doc.path("nodes")) {
String id = n.path("id").asText();
if (id.isBlank() || id.length() > 180 || !ids.add(id) || !supported.contains(n.path("kind").asText()) || !n.path("data").isObject()
|| !n.path("position").path("x").isNumber() || !n.path("position").path("y").isNumber()
|| !Double.isFinite(n.path("position").path("x").asDouble()) || !Double.isFinite(n.path("position").path("y").asDouble()))
throw new IllegalArgumentException("节点格式或编号无效");
kinds.put(id, n.path("kind").asText());
}
Map<String,List<String>> paths = new HashMap<>(); Set<String> edgeIds = new HashSet<>();
for (JsonNode e : doc.path("edges")) {
String source = e.path("source").asText(), target = e.path("target").asText();
if (!edgeIds.add(e.path("id").asText()) || !ids.contains(source) || !ids.contains(target) || source.equals(target)
|| "group".equals(kinds.get(source)) || "group".equals(kinds.get(target))) throw new IllegalArgumentException("连线端点无效");
paths.computeIfAbsent(source, ignored -> new ArrayList<>()).add(target);
}
Set<String> visited = new HashSet<>(), active = new HashSet<>();
for (String id : ids) visit(id, paths, visited, active);
for (JsonNode n : doc.path("nodes")) {
String parent = n.path("parentId").asText();
if (!parent.isEmpty() && (!"group".equals(kinds.get(parent)) || parent.equals(n.path("id").asText()) || "group".equals(n.path("kind").asText())))
throw new IllegalArgumentException("分组关系无效");
}
}
private static void visit(String id, Map<String,List<String>> paths, Set<String> visited, Set<String> active) {
if (active.contains(id)) throw new IllegalArgumentException("连线不能形成循环");
if (!visited.add(id)) return;
active.add(id); for (String next : paths.getOrDefault(id, List.of())) visit(next, paths, visited, active); active.remove(id);
}
}

View File

@@ -27,7 +27,7 @@ final class ShortDramaCharacterArtPrompt {
String subjectDescription = description == null ? "" : description;
// Portraits are independent casting masters, not a small inset in a full-body sheet.
boolean portraitReference = subjectDescription.matches("(?s).*(?:单幅|单张)(?:头肩|面部|脸部|胸像)(?:面部)?(?:母版|定妆|肖像|特写).*" );
boolean singleReference = subjectDescription.matches("(?s).*(?:单幅(?:角色|人物|定妆|全身)|单张(?:角色|人物|定妆|全身)|单人\\s*3/4\\s*全身|(?:不要|不做|不用)三视图).*" );
boolean singleReference = subjectDescription.matches("(?s).*(?:(?:单幅|单张)(?:(?:三分之四|3/4)?(?:完整)?全身|角色|人物|定妆)|单人\\s*3/4\\s*全身|(?:不要|不做|不用)三视图).*" );
if (NON_HUMAN_SUBJECT.matcher(subjectDescription).find()) {
return nonHumanReference(subjectDescription, style, worldbuilding) + revisionRules(revision, revisionRequirements, true);
}

View File

@@ -250,8 +250,8 @@ public class ShortDramaCharacterVoiceService {
refs.addAll(sounds.referenceFiles(shot.getProjectId(),shot.getSceneNo()));
if(model!=null && !model.isBlank())try {ShortDramaVoiceContract.validateReferences(model,refs.stream().map(ShortDramaSoundService.Reference::duration).toList());}catch(IllegalArgumentException e){issues.add(e.getMessage());}
if(required && !issues.isEmpty())throw new ShortDramaVoiceException("镜"+shot.getSceneNo()+"声音绑定未就绪:"+String.join(";",issues));
String direction=bindings.isEmpty()?"":"\n【本镜角色声音绑定】\n"+bindings.stream().map(b -> b.characterName()+"(角色ID "+b.characterId()+")使用 @音频"+b.audioIndex()+" 的音色").reduce((a,b)->a+"\n"+b).orElse("")
+"\n这些样音只用于同一角色音色参考。按原对白与声源顺序表演,画外对白仍由对应角色发声;不要朗读样音正文或绑定说明,不给静默听者增加台词。";
String direction=bindings.isEmpty()?"":"\n【角色音色】\n"+bindings.stream().map(b -> b.characterName()+"对应@音频"+b.audioIndex()).reduce((a,b)->a+";"+b).orElse("")
+"。仅参考音色,按本镜对白发声,不朗读样音;画外声不驱动画内口型。";
return new Plan(List.copyOf(bindings),List.copyOf(refs),direction,List.copyOf(issues),resolved.explicit(),resolved.actors().stream().map(c -> c.getId().toString()).toList());
} catch(RuntimeException e){throw e;}catch(Exception e){throw new IllegalStateException("角色声音档案无法读取,请修复后再生成视频",e);}
}

View File

@@ -0,0 +1,142 @@
package org.ruoyi.service.shortdrama.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.nio.file.*;
import java.util.*;
/** Small, owned previs documents; saving a document never submits a media task. */
@Service
public class ShortDramaDirectorService {
private static final ObjectMapper JSON = new ObjectMapper();
private static final Set<String> ACTIONS = Set.of("idle", "walk", "run", "wave", "sit", "reach", "point", "nod", "shakeHead", "crouch", "bow", "talk");
private static final Set<String> JOINTS = Set.of("pelvis", "torso", "head", "leftShoulder", "rightShoulder", "leftArm", "leftElbow", "rightArm", "rightElbow", "leftHand", "rightHand", "leftLeg", "leftKnee", "rightLeg", "rightKnee", "leftFoot", "rightFoot");
private final ShortDramaSoundService owners;
private final Path root;
public record Director(long revision, JsonNode document) { }
@Autowired public ShortDramaDirectorService(ShortDramaSoundService owners) { this(owners, Path.of("data", "short-drama-directors")); }
ShortDramaDirectorService(ShortDramaSoundService owners, Path root) { this.owners = owners; this.root = root.toAbsolutePath().normalize(); }
private Path file(Long project, String id) {
if (project == null || project <= 0 || id == null || !id.matches("[a-zA-Z0-9_-]{1,100}")) throw new IllegalArgumentException("工程编号无效");
return root.resolve(project.toString()).resolve(id + ".json");
}
public synchronized Director read(Long project, String id, Long user) throws Exception {
owners.owner(project, user); Path target = file(project, id);
return Files.exists(target) ? JSON.readValue(target.toFile(), Director.class) : new Director(0, null);
}
public synchronized Director save(Long project, String id, Long user, Director request) throws Exception {
owners.owner(project, user); Path target = file(project, id);
if (request == null) throw new IllegalArgumentException("工程不能为空");
validate(request.document());
var previous = read(project, id, user);
if (previous.revision() != request.revision()) throw new IllegalArgumentException("导演工程已在另一窗口更新,请先导出备份,再重新加载");
var saved = new Director(previous.revision() + 1, request.document().deepCopy());
Files.createDirectories(target.getParent());
Path temporary = Files.createTempFile(target.getParent(), "director-", ".tmp");
try {
JSON.writeValue(temporary.toFile(), saved);
try { Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); }
catch (AtomicMoveNotSupportedException ignored) { Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); }
} finally { Files.deleteIfExists(temporary); }
return saved;
}
static void validate(JsonNode doc) throws Exception {
if (doc == null || !doc.isObject() || !doc.path("schema").isIntegralNumber() || doc.path("schema").asInt() != 1 || !doc.path("fps").isIntegralNumber() || doc.path("fps").asInt() != 24
|| !doc.path("duration").isIntegralNumber() || doc.path("duration").asInt() < 2 || doc.path("duration").asInt() > 60
|| !Set.of("16:9", "9:16").contains(doc.path("aspect").asText()) || !doc.path("objects").isArray() || doc.path("objects").size() > 80
|| JSON.writeValueAsBytes(doc).length > 1024 * 1024) throw new IllegalArgumentException("工程格式无效或超过上限");
int frames = doc.path("duration").asInt() * 24;
Set<String> ids = new HashSet<>();
for (JsonNode o : doc.path("objects")) {
if (!o.path("id").asText().matches("[a-zA-Z0-9_-]{1,100}") || !ids.add(o.path("id").asText())
|| !Set.of("actor", "box", "wall", "door", "castle", "cannon").contains(o.path("kind").asText()) || !o.path("name").isTextual()
|| o.path("name").asText().isBlank() || o.path("name").asText().length() > 100 || !o.path("color").asText().matches("#[a-fA-F0-9]{6}")
|| !o.path("visible").isBoolean() || !o.path("locked").isBoolean()) throw new IllegalArgumentException("场景对象无效");
vector(o.path("scale"), true); keys(o.path("keys"), frames);
if (o.has("fireFrame") && (!o.path("kind").asText().equals("cannon") || !o.path("fireFrame").isIntegralNumber() || o.path("fireFrame").asInt() < 0 || o.path("fireFrame").asInt() >= frames)) throw new IllegalArgumentException("开炮时刻超出大炮的时间轴范围");
if (o.has("autoFace") && !o.path("autoFace").isBoolean()) throw new IllegalArgumentException("朝向选项无效");
for (String option : List.of("grounded", "quaternionPose")) if (o.has(option) && (!o.path(option).isBoolean() || o.path(option).asBoolean() && !o.path("kind").asText().equals("actor"))) throw new IllegalArgumentException("人物姿态选项无效");
for (JsonNode k : o.path("keys")) {
vector(k.path("position"), false); number(k.path("yaw"), -360, 360);
if (!ACTIONS.contains(k.path("action").asText())) throw new IllegalArgumentException("白模动作无效");
ease(k);
if (k.has("pose")) {
JsonNode pose = k.path("pose");
if (!pose.isObject()) throw new IllegalArgumentException("关节姿态无效");
var fields = pose.fields();
while (fields.hasNext()) {
var entry = fields.next();
if (entry.getKey().equals("height")) number(entry.getValue(), -.9, 1);
else {
if (!JOINTS.contains(entry.getKey()) || !entry.getValue().isArray() || entry.getValue().size() != 3) throw new IllegalArgumentException("关节名称或角度无效");
for (JsonNode angle : entry.getValue()) number(angle, -180, 180);
}
}
}
}
if (o.has("clips")) {
JsonNode clips = o.path("clips");
if (!o.path("kind").asText().equals("actor") || !clips.isArray() || clips.size() > 100) throw new IllegalArgumentException("动作片段无效");
int previousEnd = 0; Set<String> clipIds = new HashSet<>();
for (JsonNode c : clips) {
int start = c.path("start").asInt(-1), end = c.path("end").asInt(-1), blend = c.path("blend").asInt(-1);
if (!c.path("id").isTextual() || !c.path("id").asText().matches("[a-zA-Z0-9_-]{1,100}") || !clipIds.add(c.path("id").asText())
|| !ACTIONS.contains(c.path("action").asText()) || !c.path("start").isIntegralNumber() || !c.path("end").isIntegralNumber()
|| start < previousEnd || end <= start || end > frames || !c.path("blend").isIntegralNumber() || blend < 0 || blend > (end-start)/2)
throw new IllegalArgumentException("动作片段时间无效、重叠或过渡过长");
number(c.path("speed"), .25, 3); number(c.path("strength"), 0, 1.5); previousEnd = end;
}
}
}
for (JsonNode o : doc.path("objects")) if (o.has("lookAtId") && (!o.path("lookAtId").isTextual() || !ids.contains(o.path("lookAtId").asText()) || o.path("lookAtId").asText().equals(o.path("id").asText()))) throw new IllegalArgumentException("注视对象无效");
for (JsonNode o : doc.path("objects")) if (o.has("targetId")) {
boolean castle = false;
for (JsonNode target : doc.path("objects")) if (target.path("id").asText().equals(o.path("targetId").asText()) && target.path("kind").asText().equals("castle")) castle = true;
if (!o.path("kind").asText().equals("cannon") || !o.path("targetId").isTextual() || !castle) throw new IllegalArgumentException("大炮命中目标必须是当前场景的城门城墙");
}
keys(doc.path("camera").path("keys"), frames);
if (doc.has("markers")) {
JsonNode markers = doc.path("markers");
if (!markers.isArray() || markers.size() > 100) throw new IllegalArgumentException("动作标记无效");
for (JsonNode marker : markers) if (!marker.path("frame").isIntegralNumber() || marker.path("frame").asInt() < 0 || marker.path("frame").asInt() >= frames || !marker.path("label").isTextual() || marker.path("label").asText().isBlank() || marker.path("label").asText().length() > 60) throw new IllegalArgumentException("动作标记无效");
}
for (JsonNode k : doc.path("camera").path("keys")) {
vector(k.path("position"), false); vector(k.path("target"), false);
number(k.path("fov"), 15, 100); number(k.path("roll"), -180, 180);
ease(k);
boolean same = true;
for (int i = 0; i < 3; i++) if (Math.abs(k.path("position").get(i).asDouble() - k.path("target").get(i).asDouble()) >= .001) same = false;
if (same) throw new IllegalArgumentException("机位与拍摄目标不能重合");
}
if (doc.path("camera").has("follow")) {
JsonNode f = doc.path("camera").path("follow");
if (!f.isObject() || !ids.contains(f.path("objectId").asText()) || !Set.of("aim", "follow").contains(f.path("mode").asText())) throw new IllegalArgumentException("摄影机跟随对象无效");
vector(f.path("offset"), false); number(f.path("height"), 0, 3);
if (f.path("mode").asText().equals("follow")) {
double x=f.path("offset").get(0).asDouble(), y=f.path("offset").get(1).asDouble()-f.path("height").asDouble(), z=f.path("offset").get(2).asDouble();
if (Math.sqrt(x*x+y*y+z*z) < .01) throw new IllegalArgumentException("跟随机位不能与目标重合");
}
}
}
private static void ease(JsonNode key) {
if (key.has("ease") && (!key.path("ease").isTextual() || !Set.of("linear", "smooth", "hold").contains(key.path("ease").asText()))) throw new IllegalArgumentException("插值方式无效");
}
private static void vector(JsonNode v, boolean scale) {
if (!v.isArray() || v.size() != 3) throw new IllegalArgumentException("坐标格式无效");
for (JsonNode n : v) number(n, scale ? .1 : -100, scale ? 20 : 100);
}
private static void number(JsonNode v, double min, double max) {
if (!v.isNumber() || !Double.isFinite(v.asDouble()) || v.asDouble() < min || v.asDouble() > max) throw new IllegalArgumentException("数值超出范围");
}
private static void keys(JsonNode list, int frames) {
if (!list.isArray() || list.isEmpty() || list.size() > 1440 || list.get(0).path("frame").asInt(-1) != 0) throw new IllegalArgumentException("关键帧必须从第 0 帧开始");
int previous = -1;
for (JsonNode k : list) {
int frame = k.path("frame").asInt(-1);
if (!k.path("frame").isIntegralNumber() || frame <= previous || frame >= frames) throw new IllegalArgumentException("关键帧顺序或时间无效");
previous = frame;
}
}
}

View File

@@ -8,13 +8,22 @@ import java.util.concurrent.ConcurrentHashMap;
/** Versioned, bundled production skills. Loaded by default, independent of tool calling. */
public final class ShortDramaDirectorSkills {
private ShortDramaDirectorSkills() {}
public static final String VERSION = "director-video-prompt-v11";
public static final String VERSION = "director-video-prompt-v16";
public static final String PACING_VERSION = "content-pacing-advisory-v2";
public static final String PLANNING_VERSION = "semantic-segment-advisory-v12";
private static final Map<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");
// Reuse the same stage-specific methods in every existing drafting/review path.
// Catalog styles remain opt-in; this never loads every external handbook.
var staged = new java.util.LinkedHashSet<String>();
for (String name : names) {
staged.add(name);
if ("story-causality".equals(name)) staged.add("github-story-methods");
if ("cinematic-storyboard".equals(name) || "video-prompt".equals(name)) staged.add("github-shot-methods");
if ("director-review".equals(name)) staged.add("github-production-methods");
}
for (String name : staged) result.append(CACHE.computeIfAbsent(name, ShortDramaDirectorSkills::read)).append("\n");
return result.toString();
}
/** Only remove an exact duplicate in the selected-skill section. Edited bindings and references stay intact. */

View File

@@ -19,8 +19,9 @@ final class ShortDramaImageStyleReferences {
}
static String direction(boolean location, int count) {
return "\n【有序参考图用途】第1张仅用于" + (location ? "本场景的门墙、出入口和方位" : "本角色的身份、年龄、服装与配色")
return "\n【有序参考图用途】第1张仅用于" + (location ? "本场景的门墙、出入口和方位" : "本角色的身份、年龄;服装与配色以本轮明确修订为准,未修订项沿用主体参考")
+ ";第2至" + count + "张仅用于影视画面中的光色、明暗层次、表面质感与摄影呈现。"
+ "将后者的视觉处理用到第1张的内容,不复制后者的人脸、衣装、建筑、道具或构图,输出一个连续单幅。";
+ "将后者的视觉处理用到第1张的内容,不复制后者的人脸、衣装、建筑、道具或构图。"
+ (location ? "输出一个连续单幅场景。" : "构图按本次资产的明确用途与布局;人物多视图共享同一身份与本轮衣装。");
}
}

View File

@@ -0,0 +1,30 @@
package org.ruoyi.service.shortdrama.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard;
import java.util.Objects;
/** A separate repair candidate can reuse produced assets while a later screenplay is being drafted. */
final class ShortDramaMediaRepair {
private static final ObjectMapper JSON = new ObjectMapper();
private ShortDramaMediaRepair() { }
static Long sourceId(ShortDramaStoryboard candidate) {
try {
var value = JSON.readTree(Objects.toString(candidate.getContinuityJson(), "{}")).path("repair_source_board");
long id = Long.parseLong(value.asText(""));
return id > 0 && !Objects.equals(id, candidate.getId()) ? id : null;
} catch (Exception ignored) { return null; }
}
static boolean usesProducedAssets(ShortDramaStoryboard candidate, ShortDramaStoryboard source) {
if (source == null || !Objects.equals(sourceId(candidate), source.getId())) return false;
if (!Objects.equals(candidate.getProjectId(), source.getProjectId())
|| !Objects.equals(candidate.getScriptId(), source.getScriptId())
|| !Objects.equals(candidate.getLocationName(), source.getLocationName())) return false;
if (!"done".equals(source.getVideoStatus()) || source.getVideoId() == null || source.getVideoId().isBlank()
|| source.getVideoUrl() == null || source.getVideoUrl().isBlank()) return false;
try {
var cast = JSON.readTree(Objects.toString(candidate.getCharactersJson(), "[]"));
return cast.isArray() && !cast.isEmpty() && cast.equals(JSON.readTree(source.getCharactersJson()));
} catch (Exception ignored) { return false; }
}
}

View File

@@ -9,6 +9,8 @@ public final class ShortDramaScriptPreparation {
public static String worldContext(ShortDramaScript script) {
return (StrUtil.isBlank(script.getTone()) ? "" : "\n【剧本审阅中维护的风格与基调】\n" + script.getTone() + "\n按当前剧本维护的方向处理角色、场景、材质、光色和表演;具体已批准素材的身份与时点继续保留。\n")
+ (StrUtil.isBlank(script.getWorldbuilding()) ? "" : "\n【当前项目世界观,人物与场景必须遵守】\n" + script.getWorldbuilding());
+ (StrUtil.isBlank(script.getWorldbuilding()) ? "" : "\n【当前项目世界观,人物与场景必须遵守】\n" + script.getWorldbuilding())
+ (StrUtil.isBlank(script.getRevisionNotes()) ? "" : "\n【本版已保存的制作与修订要求】\n" + script.getRevisionNotes()
+ "\n按这些要求确定本阶段范围、沿用资产与候选边界;分镜阶段以当前剧本正文为剧情和对白依据,不因历史修订说明改回旧剧情。\n");
}
}

View File

@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.ruoyi.domain.entity.shortdrama.ShortDramaProject;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Service;
import java.io.IOException;
@@ -41,7 +42,7 @@ public class ShortDramaSkillCatalog {
public record Option(String name, String title, String type, String description, boolean enabled, String artStyle, String version) {}
public record Snapshot(Long projectId, String version, String direction) {}
public synchronized Snapshot snapshot(ShortDramaProject project) {
return new Snapshot(project == null ? null : project.getId(), snapshotVersion(project), selected(project, "aesthetic") + selected(project, "director"));
return new Snapshot(project == null ? null : project.getId(), snapshotVersion(project), selectedDirection(project));
}
public synchronized void verify(Snapshot snapshot, ShortDramaProject project) {
if (!Objects.equals(snapshot.version(), snapshotVersion(project)))
@@ -54,7 +55,13 @@ public class ShortDramaSkillCatalog {
try {
Files.createDirectories(root); rejectSymlinks(root);
// Only seed absent directories. The runtime directory, never this list, drives the catalog.
for (String name : List.of("chinese-3d-aesthetics", "ming-county-aesthetics", "compact-short-drama", "cinematic-storyboard")) {
var resolver = new PathMatchingResourcePatternResolver(getClass().getClassLoader());
var manifests = resolver.getResources("classpath*:short-drama/skill-catalog/*/manifest.json");
Arrays.sort(manifests, Comparator.comparing(resource -> resource.getDescription()));
for (var manifest : manifests) {
var manifestUrl = manifest.getURL().toString();
String name = safeName(manifestUrl.substring(manifestUrl.indexOf("short-drama/skill-catalog/")
+ "short-drama/skill-catalog/".length()).split("/")[0]);
if (Files.exists(root.resolve(name), LinkOption.NOFOLLOW_LINKS)) continue;
String prefix = "short-drama/skill-catalog/" + name + "/";
try (var in = new ClassPathResource(prefix + "manifest.json", getClass().getClassLoader()).getInputStream();
@@ -67,8 +74,8 @@ public class ShortDramaSkillCatalog {
references.add(new SkillFile(path, new String(reference.readAllBytes(), StandardCharsets.UTF_8)));
}
}
writeNew(new Save(name, metadata.path("title").asText(), metadata.path("type").asText(),
metadata.path("description").asText(), true, metadata.path("artStyle").asText(""),
writeNew(new Save(name, metadata.path("title").asText(), canonicalType(name, metadata.path("type").asText()),
metadata.path("description").asText(), metadata.path("enabled").asBoolean(true), metadata.path("artStyle").asText(""),
new String(body.readAllBytes(), StandardCharsets.UTF_8), references, null));
}
}
@@ -77,7 +84,7 @@ public class ShortDramaSkillCatalog {
}
public synchronized List<Option> list(String type, boolean includeDisabled) {
initialize(); if (type != null && !type.isBlank() && !TYPES.contains(type)) throw new IllegalArgumentException("技能类型须为aesthetic或director");
initialize(); if (type != null && !type.isBlank() && !TYPES.contains(type)) throw new IllegalArgumentException("不支持的制作技能分类:" + type);
try (var directories = Files.list(root)) {
List<Option> result = new ArrayList<>();
for (Path dir : directories.sorted().toList()) {
@@ -121,12 +128,81 @@ public class ShortDramaSkillCatalog {
}
/** Image/video prompts receive curated direction, not a raw source ledger or executable text. */
public String selectedVisual(ShortDramaProject project, String type) { return selected(project, type, false); }
public String selectedDirection(ShortDramaProject project) {
return selected(project, "aesthetic") + selected(project, "director");
}
/** Writing style controls the screenplay; camera and asset styles belong to later stages. */
public String selectedWritingDirection(ShortDramaProject project) {
StringBuilder direction = new StringBuilder();
for (Skill skill : supplementary(project)) {
if ("screenwriting".equals(skill.type())) direction.append(renderSelected(skill, true));
}
if (direction.isEmpty()) return "";
return "\n【本次剧本写作采用的实际项目技能】\n" + direction
+ "\n【剧本阶段适用范围】编剧风格用于剧情结构、人物目标、阻力、选择、后果、对白和分集变化。"
+ "用户本轮改写意见优先,允许据此重写对白和事件呈现。固定剧本输出格式优先于资料中的镜头卡格式;正文不写镜号、机位、运镜、逐秒清单、生成参数或技能说明。"
+ "分镜设计和视频提示词语法留到后续分镜阶段。\n";
}
/** Validate every selected name before saving; an empty list explicitly clears the selection. */
public List<String> normalizeStoryboardSkills(List<String> names) {
if (names == null) return null;
LinkedHashSet<String> normalized = new LinkedHashSet<>();
Map<String, String> selectedTypes = new HashMap<>();
for (String name : names) {
if (name == null || name.isBlank()) throw new IllegalArgumentException("分镜技能名称不能为空");
Skill skill = detail(name.trim());
if (!TYPES.contains(skill.type()) || !skill.enabled()) throw new IllegalArgumentException("分镜技能缺失或已停用:" + name);
if (Set.of("aesthetic", "director").contains(skill.type()))
throw new IllegalArgumentException(skill.title() + "请在" + ("aesthetic".equals(skill.type()) ? "资产配置的视觉风格" : "导演风格") + "中选择,该分类最多一项");
String previous = selectedTypes.putIfAbsent(skill.type(), skill.name());
if (previous != null && !previous.equals(skill.name())) {
String label = CATEGORIES.stream().filter(category -> category.type().equals(skill.type()))
.map(Category::title).findFirst().orElse(skill.type());
throw new IllegalArgumentException(label + "最多选择一个技能,请重新选择");
}
normalized.add(skill.name());
}
return List.copyOf(normalized);
}
private List<Skill> supplementary(ShortDramaProject project) {
if (project == null || project.getStoryboardSkillNames() == null) return List.of();
List<Skill> skills = new ArrayList<>();
Set<String> seen = new LinkedHashSet<>();
// New selection rules must not invalidate previously saved multi-skill productions.
// Read every existing binding and verify its actual package; normalize only on editing selections.
for (String name : project.getStoryboardSkillNames()) {
if (name == null || name.isBlank() || !seen.add(name.trim())) continue;
Skill skill = detail(name.trim());
if (!TYPES.contains(skill.type()) || !skill.enabled())
throw new IllegalArgumentException("分镜技能缺失或已停用:" + name);
if (skill.name().equals(project.getAestheticSkillName()) || skill.name().equals(project.getDirectorSkillName())) continue;
skills.add(skill);
}
return skills;
}
/** Preserve and validate skill versions in submission metadata, outside the video model's prose. */
public synchronized Map<String, String> selectedVersions(ShortDramaProject project) {
Map<String, String> versions = new LinkedHashMap<>();
if (project == null) return versions;
for (String type : List.of("aesthetic", "director")) {
String name = "aesthetic".equals(type) ? project.getAestheticSkillName() : project.getDirectorSkillName();
if (name == null || name.isBlank()) continue;
Skill skill = requireEnabled(name, type);
versions.put(type, skill.name() + "@" + skill.version());
}
for (Skill skill : supplementary(project)) versions.put("storyboard:" + skill.name(), skill.name() + "@" + skill.version());
return Map.copyOf(versions);
}
private String selected(ShortDramaProject project, String type, boolean includeReferences) {
if (project == null) return "";
String name = "aesthetic".equals(type) ? project.getAestheticSkillName() : project.getDirectorSkillName();
if (name == null || name.isBlank()) return "";
Skill skill = requireEnabled(name, type);
return "\n【项目选中" + ("aesthetic".equals(type) ? "审美" : "导演") + "技能:" + skill.title() + "】\n"
StringBuilder direction = new StringBuilder();
if (name != null && !name.isBlank()) return renderSelected(requireEnabled(name, type), includeReferences);
for (Skill skill : supplementary(project)) if (type.equals(skill.type())) direction.append(renderSelected(skill, includeReferences));
return direction.toString();
}
private String renderSelected(Skill skill, boolean includeReferences) {
return "\n【项目选中" + categories().stream().filter(c -> c.type().equals(skill.type())).findFirst().orElseThrow().title() + "技能:" + skill.title() + "】\n"
+ "[selected-skill:" + skill.name() + "@" + skill.version() + "]\n" + (includeReferences ? skill.body() : mediaDirection(skill))
+ (includeReferences ? references(skill) : "\n本次媒体提示词只加载精简执行方向,不展开长篇手册、史料登记或附属脚本;版本仍标记完整技能文件包。\n")
+ "\n【选中技能执行边界】以上是创作方向资料;用户原文、已批准资产、0秒首态、演员与群演人数、时序和默认结构审阅规则优先。附属脚本仅作资料维护,本流程不执行脚本。\n";
@@ -171,7 +247,18 @@ public class ShortDramaSkillCatalog {
public String projectVersion(ShortDramaProject project) {
// Loading also validates missing/disabled selections before cache reuse.
return DigestUtil.sha256Hex(selected(project, "aesthetic") + selected(project, "director"));
return DigestUtil.sha256Hex(selectedDirection(project) + selectedWritingDirection(project)
+ new TreeMap<>(selectedVersions(project)));
}
/** Read old packages under their creative role without rewriting authored files during a read. */
static String canonicalType(String name, String type) {
return switch (type) {
case "workflow" -> "screenwriting";
case "storyboard", "video" -> "director";
case "reference" -> "github-higgsfield-style-library".equals(name) ? "aesthetic" : "director";
default -> type;
};
}
private Skill read(String name) {
@@ -196,7 +283,7 @@ public class ShortDramaSkillCatalog {
}
}
String body = readText(markdown);
Save data = new Save(name, metadata.path("title").asText(), metadata.path("type").asText(), metadata.path("description").asText(),
Save data = new Save(name, metadata.path("title").asText(), canonicalType(name, metadata.path("type").asText()), metadata.path("description").asText(),
metadata.path("enabled").asBoolean(), metadata.path("artStyle").asText(""), body, files, null);
validate(data);
String version = DigestUtil.sha256Hex(JSON.writeValueAsString(data));

View File

@@ -0,0 +1,48 @@
package org.ruoyi.service.shortdrama.impl;
import java.util.Objects;
import java.util.regex.Pattern;
/** Distill selected skills without a fixed length score or truncating necessary shot content. */
final class ShortDramaVideoPromptPolicy {
static final String VERSION = "skill-essence-video-v1";
private ShortDramaVideoPromptPolicy() { }
static int characters(String text) {
return (int) Objects.toString(text, "").codePoints().filter(c -> !Character.isWhitespace(c)).count();
}
static String draftingRules() {
return """
【按当前镜头提炼技能,删除冗余】
完整skills供规划与审阅推理,不复制进video_prompt。审美skill提炼本镜的画风、具体光色和必要材质;导演skill提炼本镜的表演、关注点、机位和节奏。不同技能的适用要点融入具体动作,不叠加手册、相同约束或互相冲突的画风。
字数由本镜真实出镜、对白、动作链、效果阶段、声源和交镜关系决定,不设置统一300/500字上限,不按scene_type套固定容量,也不因超字数自动重写或截断。简短行军与多角色对白、特效因果镜不能使用同一预算。
保留当前空间与必要站位、动作发起→作用对象→可见结果→关键反应、摄影关注变化、具体光色和声音、结束状态。多人对话保留逐句原话、声源和回应;效果镜保留触发、展开、破坏或余波。涉及手与物接触、受力、投影可见性和连续性时,必要细节不能删。
已有参考图不重复整套静态外貌和衣装,但必须保留本镜变化、持物、接触和会影响动作的空间条件。微动作仅在承载意图或因果时展开,不能机械省掉表演。
删除同义形容词、重复摄影/美术段、反复锁定声明、与本镜无关的禁令、下一镜完整剧情、资料来源和技能版本。时长、分辨率、画幅留在参数;素材索引由后端绑定。原话及有效导演信息完整,重复信息只表达一次。
""";
}
/** The director text is authoritative; supplement only genuinely missing original speech. */
static String execution(String prompt, String source) {
String text = Objects.toString(prompt, "");
StringBuilder missing = new StringBuilder();
// A line can be split around an acting cue: 「小翠,」...「我就知道。」 is one original utterance.
var quotes = Pattern.compile("「([^」]+)」|“([^”]+)”").matcher(text);
StringBuilder spoken = new StringBuilder();
while (quotes.find()) spoken.append(quotes.group(1) == null ? quotes.group(2) : quotes.group(1));
int cursor = 0, spokenCursor = 0;
for (String line : ShortDramaVideoPromptReview.sourceDialogue(source)) {
int found = text.indexOf(line, cursor);
int quoted = spoken.indexOf(line, spokenCursor);
if (found >= 0 || quoted >= 0) {
if (found >= 0) cursor = found + line.length();
if (quoted >= 0) spokenCursor = quoted + line.length();
continue;
}
String speaker = ShortDramaVideoPromptReview.sourceSpeaker(source, line);
missing.append(speaker.isBlank() ? "原文对白" : speaker).append(":「").append(line).append("」\n");
}
return missing.isEmpty() ? text : text + "\n[补充原对白]\n" + missing;
}
}

View File

@@ -13,9 +13,9 @@ public final class ShortDramaVideoPromptReview {
"光色与材质", "声音", "结束与承接", "约束"};
private static final Pattern RANGE = Pattern.compile("(?<![\\d.])(\\d+(?:\\.\\d+)?)\\s*(?:秒|s)?\\s*[-–—~至到]\\s*(\\d+(?:\\.\\d+)?)\\s*(?:秒|s)", Pattern.CASE_INSENSITIVE);
private static final Pattern QUOTE = Pattern.compile("「([^」]+)」|“([^”]+)”");
private static final Pattern SOURCE_DIALOGUE = Pattern.compile("「([^」]+)」|“([^”]+)”|(?m)^\\s*([\\p{IsHan}A-Za-z0-9()()· ]{1,24})\\s*[::]\\s*([^\\r\\n]+)");
private static final Pattern SOURCE_DIALOGUE = Pattern.compile("「([^」]+)」|“([^”]+)”|(?m)^\\s*([\\p{IsHan}A-Za-z0-9()()· ,,]{1,32})\\s*[::]\\s*([^\\r\\n]+)");
private static final Pattern VISUAL_TEXT_CONTEXT = Pattern.compile(
".*(?:屏幕|界面|查询栏|字幕|叠字|光字|画面文字|告示|牌匾|标题|题字|字样|标注|显示|内容为).*");
".*(?:屏幕|界面|查询栏|字幕|叠字|光字|画面文字|告示|牌匾|铭牌|牌面|标题|题字|字样|标注|显示|内容为).*");
private static final Pattern PLACEHOLDER = Pattern.compile("^(?:待补充|待生成|待设计|同上|略|TODO|暂无|未填写)[。;;\\s]*$", Pattern.CASE_INSENSITIVE);
public static List<String> issues(String prompt, Integer duration, String source) {
@@ -64,9 +64,23 @@ public final class ShortDramaVideoPromptReview {
issues.add("对白与口型须明确声源和开口规则");
if (spokenLines.isEmpty() && !dialogue.isBlank() && QUOTE.matcher(dialogue).find())
issues.add("原文无标注对白,须核对是否擅加台词");
reviewDoorway(text, source, issues);
return List.copyOf(issues);
}
/** Missing staging is advisory; door swing alone cannot determine entry or exit. */
private static void reviewDoorway(String prompt, String source, List<String> issues) {
String action = QUOTE.matcher((source == null ? "" : source) + "\n" + prompt).replaceAll("");
if (!action.matches("(?s).*(?:进屋|进门|入库|进入(?:屋|房|库)|走入(?:屋|房|库)|出门|出屋|走出(?:屋|房|库)|跨[^。;\\n]{0,10}门槛).*")) return;
String staging = QUOTE.matcher(prompt).replaceAll("");
if (!staging.matches("(?s).*(?:门外|门内|屋外|屋内|库外|库内|室外|室内).*"))
issues.add("进出门动作须核对人物起始一侧与抵达位置");
if (!staging.matches("(?s).*(?:跨[^。;\\n]{0,10}门槛|越过门槛|穿过门口|从[^。;\\n]{0,24}(?:走入|走出|进入|走到)).*"))
issues.add("进出门动作须写清跨门路线,开门不等于人物已经到位");
if (!staging.matches("(?s).*(?:摄影机|机位|镜头)[^。;\\n]{0,32}(?:门外|门内|屋外|屋内|库外|库内|室外|室内).*"))
issues.add("进出门动作须说明摄影机所在一侧,门扇方向与人物行进方向分别核对");
}
/** Keep the model's staging while making the dialogue block mechanically match source text. */
static String normalizeDialogue(String prompt, String source) {
String text = prompt == null ? "" : prompt;
@@ -114,7 +128,7 @@ public final class ShortDramaVideoPromptReview {
return List.copyOf(lines);
}
private static String sourceSpeaker(String source, String line) {
static String sourceSpeaker(String source, String line) {
var matcher = SOURCE_DIALOGUE.matcher((source == null ? "" : source).replace("\\n", "\n"));
while (matcher.find()) if (matcher.group(3) != null && matcher.group(4).contains(line))
return matcher.group(3).replaceAll("([^)]*)|\\([^)]*\\)", "")
@@ -172,18 +186,8 @@ public final class ShortDramaVideoPromptReview {
if (!issues.isEmpty()) log.info("镜头{}导演稿审阅建议(继续保存或生成):{}", number, String.join(";", issues));
}
/** Used by the one-keyframe fast path, which must retain speech, duration and skills. */
/** The reviewed image binds the start; skills and provider parameters stay outside execution prose. */
static String anchoredPrompt(String prompt, Integer duration, String source, String start, String end) {
String reviewedStart = section(nullToEmpty(prompt), "首帧实况");
String actualStart = reviewedStart.isBlank() ? nullToEmpty(start) : reviewedStart;
return ShortDramaDirectorSkills.load("video-prompt", "video-continuity")
+ "本镜唯一画面参考是image 1,索引只说明实际已绑定的首帧。锁定身份、服装、构图、左右关系、持物手和光源,单一连续镜头,不复制多视图。\n"
+ "【绑定起止状态】起点:" + actualStart + ";终点:" + nullToEmpty(end)
+ (duration == null
? "\n【时长与内容】未指定视频秒数,完整执行原剧情、对白及动作顺序,不按制作估算删减。\n"
: "\n【用户指定视频时长】" + duration + "秒\n") + nullToEmpty(prompt)
+ "\n【已审阅剧情与声音原文】\n" + nullToEmpty(source)
+ "\n只执行当前镜头。对白原话、发言人、先后顺序与声音位置以原文为准;画外声/心声不驱动画面人物口型;不得让听者代说。无特别约定不加音乐,不显示下一镜剧情。";
return "image 1为起始画面,后续动作按导演稿。\n" + ShortDramaVideoPromptPolicy.execution(prompt, source);
}
private static String nullToEmpty(String value) { return value == null ? "" : value; }
}

View File

@@ -13,9 +13,8 @@ final class ShortDramaVideoPropReferences {
int index = images.indexOf(binding.imageUrl());
if (index < 0) throw new IllegalArgumentException("道具参考未实际附图:" + binding.title());
text.append(binding.title()).append("对应@image").append(index + 1)
.append(",仅提供器物轮廓、材质与比例;位置、持握者、动作和出现时机按本镜导演稿。")
.append("同一物件保持形制,不能复制棚拍背景或让器物凭空出现。\n");
.append(";\n");
}
return text.toString();
return text.append("图片仅提供器物外形,位置、持握与出现时机按导演稿。\n").toString();
}
}

View File

@@ -0,0 +1,31 @@
package org.ruoyi.service.shortdrama.impl;
import org.ruoyi.service.media.AtlasMediaSupport;
import java.net.URI;
import java.util.*;
/** Transport copies keep the approved asset and its reference index unchanged. */
final class ShortDramaVideoReferenceMirrors {
private ShortDramaVideoReferenceMirrors() {}
static List<String> apply(String continuity, List<String> originals) {
try {
var root = AtlasMediaSupport.OBJECT_MAPPER.readTree(continuity == null || continuity.isBlank() ? "{}" : continuity);
var mirrors = root.path("video_reference_mirrors");
if (mirrors.isMissingNode() || mirrors.isNull()) return originals;
if (!mirrors.isObject()) throw new IllegalArgumentException("视频参考镜像须为原图URL到上传URL的映射");
var selected = originals == null ? List.<String>of() : originals;
var names = mirrors.fieldNames();
while (names.hasNext()) {
String source = names.next();
if (!selected.contains(source)) throw new IllegalArgumentException("视频参考镜像已过期:原图不在本镜参考中");
var target = mirrors.get(source);
if (!target.isTextual()) throw new IllegalArgumentException("视频参考镜像URL无效");
URI uri = URI.create(target.asText());
if (!Set.of("https", "http").contains(uri.getScheme()) || uri.getHost() == null || uri.getUserInfo() != null)
throw new IllegalArgumentException("视频参考镜像必须是完整HTTP图片URL");
}
return selected.stream().map(source -> mirrors.has(source) ? mirrors.get(source).asText() : source).toList();
} catch (java.io.IOException e) { throw new IllegalArgumentException("视频参考镜像JSON无效", e); }
}
}

View File

@@ -88,7 +88,7 @@ public class ShortDramaVisualAssetService {
}
public record AssetView(Long id,Long storyboardId,String kind,String title,String prompt,String status,
String imageUrl,String model,String predictionId,String errorMessage,String referenceImageUrl,boolean undoAvailable) { }
String imageUrl,String model,String predictionId,String errorMessage,String referenceImageUrl,boolean undoAvailable,List<Integer> shotNumbers) { }
/** Called inside the asset-analysis transaction, after the entire result has been validated. */
void saveAnalyzedProps(Long id, List<JsonNode> props, Long user) {
Set<String> existing = rows(id).stream().filter(a -> "prop".equals(a.getKind()))
@@ -109,7 +109,7 @@ public class ShortDramaVisualAssetService {
if (history > 0) undoableProps.add(key.substring(0, history));
}
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(),propReferenceImage(a.getReferenceImages()),"prop".equals(a.getKind()) && undoableProps.contains(a.getAssetKey()))).toList(),
"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(),propReferenceImage(a.getReferenceImages()),"prop".equals(a.getKind()) && undoableProps.contains(a.getAssetKey()),"prop".equals(a.getKind()) ? propShotNumbers(a.getReferenceImages()) : List.of())).toList(),
"completed",rows.stream().filter(a -> "done".equals(a.getStatus())).count(),"total",rows.size());
}
/**
@@ -173,8 +173,8 @@ public class ShortDramaVisualAssetService {
if(shots.isEmpty()) throw new IllegalStateException("请先确认分镜,再规划逐镜资产");
var script=scripts.selectById(shots.get(0).getScriptId());
var project = projects.selectById(id);
String selectedDirection = skillCatalog.selected(project, "aesthetic") + skillCatalog.selected(project, "director");
String selectedMediaDirection = skillCatalog.selectedVisual(project, "aesthetic") + skillCatalog.selectedVisual(project, "director");
String selectedDirection = skillCatalog.selected(project, "aesthetic");
String selectedMediaDirection = skillCatalog.selectedVisual(project, "aesthetic");
var m=model==null || model.isBlank() ? ShortDramaAtlasModels.defaultModel(models,"chat") : ShortDramaAtlasModels.require(models,model,"chat");
var chat=chats.getOriginalService(ShortDramaServiceImpl.shortDramaProviderCode(m.getProviderCode(),m.getModelName())).buildChatModel(m);
// Do not invent extra characters or costumes merely to increase image counts.
@@ -316,7 +316,7 @@ public class ShortDramaVisualAssetService {
}
private String selectedDirection(Long projectId) {
var project = projects.selectById(projectId);
return skillCatalog.selectedVisual(project, "aesthetic") + skillCatalog.selectedVisual(project, "director");
return skillCatalog.selectedVisual(project, "aesthetic");
}
private String productionFramePrompt(ShortDramaStoryboard shot) { return framePrompt(shot) + selectedDirection(shot.getProjectId()); }
private String productionFrameHash(ShortDramaStoryboard shot, List<String> refs, String props) {
@@ -330,6 +330,7 @@ public class ShortDramaVisualAssetService {
var old = (com.fasterxml.jackson.databind.node.ObjectNode) JSON.readTree(before.getContinuityJson());
var next = (com.fasterxml.jackson.databind.node.ObjectNode) JSON.readTree(after.getContinuityJson());
old.remove("video_resolution"); next.remove("video_resolution");
old.remove("video_model"); next.remove("video_model");
old.remove("video_seconds"); next.remove("video_seconds");
old.remove("video_use_start_frame"); next.remove("video_use_start_frame");
old.remove("video_reference_include_location"); next.remove("video_reference_include_location");

View File

@@ -0,0 +1,29 @@
package org.ruoyi.service.video;
import org.ruoyi.service.media.AtlasMediaSupport;
/** A provider response that explicitly confirms no video task was accepted. */
public final class VideoSubmissionRejectedException extends IllegalArgumentException {
private final String responseBody;
private VideoSubmissionRejectedException(String responseBody) {
super("Atlas Cloud拒绝视频提交: HTTP 402 - " + responseBody);
this.responseBody = responseBody;
}
public String providerCode() { return "atlas"; }
public String responseBody() { return responseBody; }
/** Be conservative: timeouts, 5xx and bodies with any task data remain unknown. */
public static VideoSubmissionRejectedException atlasResponse(int httpStatus, String body) {
if (httpStatus != 402 || body == null || body.length() > 2000) return null;
try {
var json = AtlasMediaSupport.OBJECT_MAPPER.readTree(body);
if (json != null && json.isObject() && json.size() == 2
&& json.path("code").isIntegralNumber() && json.path("code").asInt() == 402
&& "insufficient balance".equalsIgnoreCase(json.path("msg").asText().trim()))
return new VideoSubmissionRejectedException(json.toString());
} catch (java.io.IOException ignored) { /* An unrecognized response cannot release a paid claim. */ }
return null;
}
}

View File

@@ -16,6 +16,7 @@ import org.ruoyi.enums.ChatModeType;
import org.ruoyi.service.media.AtlasMediaSupport;
import org.ruoyi.service.media.AtlasPredictionService;
import org.ruoyi.service.video.AbstractVideoGenerationService;
import org.ruoyi.service.video.VideoSubmissionRejectedException;
import org.springframework.stereotype.Component;
import java.io.IOException;
@@ -48,6 +49,8 @@ public class AtlasVideoGenerationServiceImpl extends AbstractVideoGenerationServ
ResponseBody body = response.body();
String responseText = body == null ? "" : body.string();
if (!response.isSuccessful()) {
var rejected = VideoSubmissionRejectedException.atlasResponse(response.code(), responseText);
if (rejected != null) throw rejected;
throw new IllegalArgumentException("Atlas Cloud视频生成任务创建失败: " + response.code() + " - " + responseText);
}
return atlasPredictionService.toResponse(responseText, "video");
@@ -58,12 +61,21 @@ public class AtlasVideoGenerationServiceImpl extends AbstractVideoGenerationServ
static ObjectNode buildPayload(VideoContext videoContext) {
ChatModelVo model = videoContext.getChatModelVo();
if ("minimax/h3-developer/image-to-video".equals(model.getModelName())) {
return buildH3Payload(videoContext);
}
boolean seedanceMini = model.getModelName().startsWith("bytedance/seedance-2.0-mini/");
ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode();
payload.put("model", model.getModelName());
Integer requestSeconds = requestDuration(model.getModelName(), videoContext.getSeconds());
String prompt = videoContext.getPrompt();
if (model.getModelName().startsWith("bytedance/seedance-2.5/")) prompt = prompt.replaceAll("@image(\\d+)", "@Image$1").replaceAll("@audio(\\d+)", "@Audio$1");
if (model.getModelName().startsWith("bytedance/seedance-2.5/")) {
// Role bindings and older saved directions use Chinese labels. Seedance 2.5
// resolves reference media through @ImageN / @AudioN / @VideoN, in list order.
prompt = prompt.replaceAll("(?i)@(?:image|图片)(\\d+)", "@Image$1")
.replaceAll("(?i)@(?:audio|音频)(\\d+)", "@Audio$1")
.replaceAll("(?i)@(?:video|视频)(\\d+)", "@Video$1");
}
payload.put("prompt", prompt);
if (StrUtil.isNotBlank(videoContext.getSize())) {
payload.put(model.getModelName().startsWith("bytedance/seedance-") ? "ratio" : "size", videoContext.getSize());
@@ -119,6 +131,16 @@ public class AtlasVideoGenerationServiceImpl extends AbstractVideoGenerationServ
if (refImages != null && !refImages.isEmpty()) payload.put("omni_reference_task_type", "reference");
}
var refVideos = videoContext.getReferenceVideos();
if (refVideos != null && !refVideos.isEmpty()) {
boolean full = model.getModelName().startsWith("bytedance/seedance-2.5/");
if ((!full && !model.getModelName().startsWith("bytedance/seedance-2.0")) || !model.getModelName().endsWith("/reference-to-video"))
throw new IllegalArgumentException("当前模型不支持视频参考");
if (refVideos.size() > (full ? 10 : 3)) throw new IllegalArgumentException("动作视频参考数量超出模型限制");
var videos = payload.putArray("reference_videos");
for (String url : refVideos) videos.add(url);
if (full) payload.put("omni_reference_task_type", "reference");
}
// 同步音频生成(环境音/动效)
if (videoContext.getGenerateAudio() != null) {
payload.put("generate_audio", videoContext.getGenerateAudio());
@@ -128,15 +150,20 @@ public class AtlasVideoGenerationServiceImpl extends AbstractVideoGenerationServ
if (refAudios != null && !refAudios.isEmpty()) {
if (seedanceMini) {
if (refAudios.size() > 3) throw new IllegalArgumentException("Seedance 2.0 Mini最多3条参考音频,总时长须不超过15秒");
if (!payload.has("reference_images") && !payload.has("image")) {
throw new IllegalArgumentException("Seedance 2.0 Mini参考音频需要至少1张参考图");
if (!payload.has("reference_images") && !payload.has("image") && !payload.has("reference_videos")) {
throw new IllegalArgumentException("Seedance 2.0 Mini参考音频需要至少1张参考图或1条参考视频");
}
}
if (model.getModelName().startsWith("bytedance/seedance-2.5/") && refAudios.size() > 10) throw new IllegalArgumentException("Seedance 2.5最多10条参考音频");
boolean full = model.getModelName().startsWith("bytedance/seedance-2.5/");
if (full && !model.getModelName().endsWith("/reference-to-video")) {
throw new IllegalArgumentException("Seedance 2.5音频参考须使用reference-to-video模型");
}
if (full && refAudios.size() > 10) throw new IllegalArgumentException("Seedance 2.5最多10条参考音频");
com.fasterxml.jackson.databind.node.ArrayNode arr = payload.putArray("reference_audios");
for (String url : refAudios) {
arr.add(url);
}
if (full) payload.put("omni_reference_task_type", "reference");
}
// 返回末帧(同场景连续镜头首帧承接用)
if (videoContext.getReturnLastFrame() != null) {
@@ -146,6 +173,38 @@ public class AtlasVideoGenerationServiceImpl extends AbstractVideoGenerationServ
return payload;
}
/** H3's published schema uses a single image, adaptive ratio and explicit SR tiers. */
private static ObjectNode buildH3Payload(VideoContext context) {
Integer seconds = context.getSeconds();
if (seconds == null || seconds < 4 || seconds > 15)
throw new IllegalArgumentException("H3 Developer须指定4至15秒视频时长");
String resolution = switch (StrUtil.blankToDefault(context.getResolution(), "768P").toLowerCase(java.util.Locale.ROOT)) {
case "480p" -> "480P";
case "768p" -> "768P";
case "1440p-sr" -> "1440p-sr";
case "4k-sr" -> "4k-sr";
default -> throw new IllegalArgumentException("H3 Developer不支持该分辨率,请选择480P、768P、1440p-sr或4k-sr");
};
var images = context.getReferenceImages();
if (images != null && images.size() > 1)
throw new IllegalArgumentException("H3 Developer仅支持一张起始图,请关闭额外角色和场景参考");
String image = images != null && !images.isEmpty() ? images.get(0) : context.getImageUrl();
if (StrUtil.isBlank(image)) throw new IllegalArgumentException("H3图生视频需要已审阅的起始图");
if ((context.getReferenceAudios() != null && !context.getReferenceAudios().isEmpty())
|| (context.getReferenceVideos() != null && !context.getReferenceVideos().isEmpty()))
throw new IllegalArgumentException("H3 Developer不支持音频或视频参考");
ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode();
payload.put("model", context.getChatModelVo().getModelName());
payload.put("prompt", context.getPrompt());
payload.put("image", image);
payload.put("duration", seconds);
payload.put("resolution", resolution);
payload.put("ratio", "adaptive");
payload.put("prompt_expansion", false);
// Do not forward Seedance-only audio/last-frame switches to H3.
return payload;
}
static Integer requestDuration(String model, Integer seconds) {
if (seconds == null) return null;
if (seconds != -1 && seconds <= 0) throw new IllegalArgumentException("视频秒数须为正整数或-1");

View File

@@ -0,0 +1,21 @@
# 动漫动作 · 轮廓与动势
## 视觉决定
角色比例、五官、头发、衣装和色板以当前故事及已批准形象为准,不套通用少年脸或统一大眼。动作关键姿态轮廓明确、重心可信,服饰和持物在转身、跨步与接触时保持空间关系。
夸张形变、速度线和光效仅在所选动漫风格需要时使用,强调动作方向、接触和后果,不能掩盖命中或让人物瞬移。情绪表演按角色意图安排眼神和姿态;静止与运动段保持同一光色和线条,真实效果由候选画面与成片检查确定。
## 资产审阅
先比较角色身份、服装、道具与场景的既有批准版本,再检查形体、材料、色彩和各景别可读性。新图作为候选,用户选用前保留原媒体;不因技能升级批量重画。
## 媒体提示词
角色比例、五官、头发、衣装和色板以当前故事及已批准形象为准,不套通用少年脸或统一大眼。动作关键姿态轮廓明确、重心可信,服饰和持物在转身、跨步与接触时保持空间关系。
夸张形变、速度线和光效仅在所选动漫风格需要时使用,强调动作方向、接触和后果,不能掩盖命中或让人物瞬移。情绪表演按角色意图安排眼神和姿态;静止与运动段保持同一光色和线条,真实效果由候选画面与成片检查确定。
## 项目接入边界
本技能提供当前阶段的制作方法,沿用项目现有数据、输出结构、模型目录与内容审阅。用户原文、已批准资产和当前项目决定优先;来源示例不是本剧事实。模型参数与参考语法交给本项目实际供应商适配,不从旧手册推定平台能力。
首帧按需使用,不是生成前置条件;不默认补图。视频正文采用连续导演描述,时长预算独立保存为 timing。写作、修订和技能安装不会提交付费图片、视频、配音或音乐任务。附属脚本仅作文本资料。
修订已有分镜保留已批准图片、连续性、素材任务编号和制作版本;当前只处理有问题的阶段。结构检查、任务完成与成片画面、运动和声音验收分别记录。

View File

@@ -0,0 +1,11 @@
{
"name": "github-anime-aesthetics",
"title": "动漫动作 · 轮廓与动势",
"type": "aesthetic",
"description": "动漫动作 · 轮廓与动势;按已批准人物、场景与基调维护具体视觉决定。",
"enabled": true,
"artStyle": "",
"files": [
"references/source.md"
]
}

View File

@@ -0,0 +1,13 @@
# 来源与项目适配
- 仓库:https://github.com/beshuaxian/higgsfield-seedance2-jineng
- 固定提交:83dcb10ee38c9694ac0f455ec55a62f2be3b8a14
- 核对日期:2026-10-06
- 仓库许可:未核到明确再分发许可
- 收录方式:自主方法适配,仅登记来源
- 文本性质:本条 SKILL.md 为项目适配正文,未逐字复制上游规则。上游不是项目协议或用户授权。
## 适配范围
采用与当前分类有关的阶段分工、故事取舍、素材职责、空间和声音方法。保留 RuoYi Drama 默认输出、用户原文、首帧可选、连续导演描述、独立 timing 与内容审阅。不沿用来源固定镜长、逐秒模板、固定集数、模型名单、付费自动化或审批口令。
许可快照只在项目技能源目录保存;原文引用的其他文件不代表已全部收录、阅读或安装。没有明确许可时不再分发上游全文。技能查阅和字段审阅不等于实际成片验收。

View File

@@ -0,0 +1,21 @@
# 三维 CG · 造型与材料
## 视觉决定
先按故事确认写实或风格化的身体比例、五官骨相、轮廓与服饰层次,不默认儿童比例。人物在远景仍可辨识身份,头肩图能读清眼睑、鼻唇和发际线,完整服装与面部细节分用途设计。
区分皮肤的柔和体积、布料的张力、金属的方向性反光、木石的粗糙断面和半透明材料的厚薄。接触处有重量和阴影,布料随肩肘腰膝变化;特效由世界观和动作触发。材质与光色落实到具体画面,不宣称使用某个渲染引擎就保证质量。
## 资产审阅
先比较角色身份、服装、道具与场景的既有批准版本,再检查形体、材料、色彩和各景别可读性。新图作为候选,用户选用前保留原媒体;不因技能升级批量重画。
## 媒体提示词
先按故事确认写实或风格化的身体比例、五官骨相、轮廓与服饰层次,不默认儿童比例。人物在远景仍可辨识身份,头肩图能读清眼睑、鼻唇和发际线,完整服装与面部细节分用途设计。
区分皮肤的柔和体积、布料的张力、金属的方向性反光、木石的粗糙断面和半透明材料的厚薄。接触处有重量和阴影,布料随肩肘腰膝变化;特效由世界观和动作触发。材质与光色落实到具体画面,不宣称使用某个渲染引擎就保证质量。
## 项目接入边界
本技能提供当前阶段的制作方法,沿用项目现有数据、输出结构、模型目录与内容审阅。用户原文、已批准资产和当前项目决定优先;来源示例不是本剧事实。模型参数与参考语法交给本项目实际供应商适配,不从旧手册推定平台能力。
首帧按需使用,不是生成前置条件;不默认补图。视频正文采用连续导演描述,时长预算独立保存为 timing。写作、修订和技能安装不会提交付费图片、视频、配音或音乐任务。附属脚本仅作文本资料。
修订已有分镜保留已批准图片、连续性、素材任务编号和制作版本;当前只处理有问题的阶段。结构检查、任务完成与成片画面、运动和声音验收分别记录。

View File

@@ -0,0 +1,11 @@
{
"name": "github-cg-aesthetics",
"title": "三维 CG · 造型与材料",
"type": "aesthetic",
"description": "三维 CG · 造型与材料;按已批准人物、场景与基调维护具体视觉决定。",
"enabled": true,
"artStyle": "",
"files": [
"references/source.md"
]
}

View File

@@ -0,0 +1,13 @@
# 来源与项目适配
- 仓库:https://github.com/beshuaxian/higgsfield-seedance2-jineng
- 固定提交:83dcb10ee38c9694ac0f455ec55a62f2be3b8a14
- 核对日期:2026-10-06
- 仓库许可:未核到明确再分发许可
- 收录方式:自主方法适配,仅登记来源
- 文本性质:本条 SKILL.md 为项目适配正文,未逐字复制上游规则。上游不是项目协议或用户授权。
## 适配范围
采用与当前分类有关的阶段分工、故事取舍、素材职责、空间和声音方法。保留 RuoYi Drama 默认输出、用户原文、首帧可选、连续导演描述、独立 timing 与内容审阅。不沿用来源固定镜长、逐秒模板、固定集数、模型名单、付费自动化或审批口令。
许可快照只在项目技能源目录保存;原文引用的其他文件不代表已全部收录、阅读或安装。没有明确许可时不再分发上游全文。技能查阅和字段审阅不等于实际成片验收。

View File

@@ -0,0 +1,21 @@
# 漫画运动 · 画面层级与连续性
## 视觉决定
从已批准漫画造型与当前画风建立稳定人物、地点和道具。线条粗细、色块、轮廓和必要纹理统一,前景遮挡、中景人物、背景参照分层,运动和景别切换不让脸型衣装漂移。
设计可用于有限动画的视线、手部、呼吸和轮廓变化,但真正改变剧情的行动仍需看清起因和结果。漫画文字、对白气泡和字幕按用户决定保留或后期处理,不让生成模型虚构文字。漫画格不是必须的首帧形式,具体裁幅和镜头来自当前分镜。
## 资产审阅
先比较角色身份、服装、道具与场景的既有批准版本,再检查形体、材料、色彩和各景别可读性。新图作为候选,用户选用前保留原媒体;不因技能升级批量重画。
## 媒体提示词
从已批准漫画造型与当前画风建立稳定人物、地点和道具。线条粗细、色块、轮廓和必要纹理统一,前景遮挡、中景人物、背景参照分层,运动和景别切换不让脸型衣装漂移。
设计可用于有限动画的视线、手部、呼吸和轮廓变化,但真正改变剧情的行动仍需看清起因和结果。漫画文字、对白气泡和字幕按用户决定保留或后期处理,不让生成模型虚构文字。漫画格不是必须的首帧形式,具体裁幅和镜头来自当前分镜。
## 项目接入边界
本技能提供当前阶段的制作方法,沿用项目现有数据、输出结构、模型目录与内容审阅。用户原文、已批准资产和当前项目决定优先;来源示例不是本剧事实。模型参数与参考语法交给本项目实际供应商适配,不从旧手册推定平台能力。
首帧按需使用,不是生成前置条件;不默认补图。视频正文采用连续导演描述,时长预算独立保存为 timing。写作、修订和技能安装不会提交付费图片、视频、配音或音乐任务。附属脚本仅作文本资料。
修订已有分镜保留已批准图片、连续性、素材任务编号和制作版本;当前只处理有问题的阶段。结构检查、任务完成与成片画面、运动和声音验收分别记录。

View File

@@ -0,0 +1,11 @@
{
"name": "github-comic-motion-aesthetics",
"title": "漫画运动 · 画面层级与连续性",
"type": "aesthetic",
"description": "漫画运动 · 画面层级与连续性;按已批准人物、场景与基调维护具体视觉决定。",
"enabled": true,
"artStyle": "",
"files": [
"references/source.md"
]
}

View File

@@ -0,0 +1,13 @@
# 来源与项目适配
- 仓库:https://github.com/beshuaxian/higgsfield-seedance2-jineng
- 固定提交:83dcb10ee38c9694ac0f455ec55a62f2be3b8a14
- 核对日期:2026-10-06
- 仓库许可:未核到明确再分发许可
- 收录方式:自主方法适配,仅登记来源
- 文本性质:本条 SKILL.md 为项目适配正文,未逐字复制上游规则。上游不是项目协议或用户授权。
## 适配范围
采用与当前分类有关的阶段分工、故事取舍、素材职责、空间和声音方法。保留 RuoYi Drama 默认输出、用户原文、首帧可选、连续导演描述、独立 timing 与内容审阅。不沿用来源固定镜长、逐秒模板、固定集数、模型名单、付费自动化或审批口令。
许可快照只在项目技能源目录保存;原文引用的其他文件不代表已全部收录、阅读或安装。没有明确许可时不再分发上游全文。技能查阅和字段审阅不等于实际成片验收。

View File

@@ -0,0 +1,22 @@
# ZenStory · 分阶段制作流程
## 阶段路由
先识别输入是点子、原著、完整剧本、资产、分镜还是已有素材,读取项目真实状态,从缺失或有问题的阶段继续。创作事实使用项目已有数据库和版本记录,不建立与它并行的一套“真相”文件。
## 制作流程
原著分析和分集地图按需要建立;有完整剧本时直接进入资产与视觉决策。剧本负责事件、人物选择、对白和基调,视觉负责身份、造型、场景和道具状态,分镜负责观众信息、空间、切点和动作承接,提示词负责模型可执行的表演与声音。
每次交接写清已确定事实、尚待确认内容、实际素材和本次改动范围。人物主身份与服装版本分开,声音归属人物主身份,镜头持物和视线属于逐镜状态。图片计划与真实文件、生成完成与审阅通过各有状态。
媒体任务沿用项目当前模型、资产及已有提交恢复机制,保存原任务编号与参数。未知结果先查询同一次提交,不能因界面超时重复付费生成。
剪辑只使用真实生成素材,记录可用区间、入出点和取舍,字幕来自原对白,声音按真实时间线处理。保留原片及导出版,区分检查脚本、抽帧、ASR、全片运动观看和试听的证据。
## 返修与交付
返修回到问题所属阶段,保留其余已确认内容。检查镜间与跨集事实是否一致,再交付当前剧本、视觉、分镜、实际提示词、素材与检查记录;不得用一个目标时长或任务终态宣称已成片验收。
## 项目接入边界
本技能提供当前阶段的制作方法,沿用项目现有数据、输出结构、模型目录与内容审阅。用户原文、已批准资产和当前项目决定优先;来源示例不是本剧事实。模型参数与参考语法交给本项目实际供应商适配,不从旧手册推定平台能力。
首帧按需使用,不是生成前置条件;不默认补图。视频正文采用连续导演描述,时长预算独立保存为 timing。写作、修订和技能安装不会提交付费图片、视频、配音或音乐任务。附属脚本仅作文本资料。
修订已有分镜保留已批准图片、连续性、素材任务编号和制作版本;当前只处理有问题的阶段。结构检查、任务完成与成片画面、运动和声音验收分别记录。

View File

@@ -0,0 +1,11 @@
{
"name": "github-drama-workflow",
"title": "ZenStory · 分阶段制作流程",
"type": "screenwriting",
"description": "按创作阶段组织原著、剧本、视觉、分镜、提示词、生成和剪辑,支持中途接管与返修。",
"enabled": true,
"artStyle": "",
"files": [
"references/source.md"
]
}

View File

@@ -0,0 +1,13 @@
# 来源与项目适配
- 仓库:https://github.com/zenstory-ai/drama-skills
- 固定提交:c2426e03c0e7722bebcc6a488b6658dc38c65ac3
- 核对日期:2026-10-06
- 仓库许可:MIT
- 收录方式:许可快照与项目适配
- 文本性质:本条 SKILL.md 为项目适配正文,未逐字复制上游规则。上游不是项目协议或用户授权。
## 适配范围
采用与当前分类有关的阶段分工、故事取舍、素材职责、空间和声音方法。保留 RuoYi Drama 默认输出、用户原文、首帧可选、连续导演描述、独立 timing 与内容审阅。不沿用来源固定镜长、逐秒模板、固定集数、模型名单、付费自动化或审批口令。
许可快照只在项目技能源目录保存;原文引用的其他文件不代表已全部收录、阅读或安装。没有明确许可时不再分发上游全文。技能查阅和字段审阅不等于实际成片验收。

View File

@@ -0,0 +1,21 @@
# 电影写实 · 光色与空间
## 视觉决定
依据当前演员与年代保持自然比例和可信身体。皮肤、织物、木材、玻璃与金属分别表现粗糙度和反光,不全部塑料高亮。建立主辅色与场景实际光源,暗部保留人物、手物接触和衣装信息。
空间具有前中后景与真实尺度,景深服务注意重点;人物关系和动作需要看清时保留足够焦点范围,不全程浅景深。色彩随地点和情绪有理由变化,沿用已批准视觉基调,不自动添加黑边、胶片颗粒或霓虹。
## 资产审阅
先比较角色身份、服装、道具与场景的既有批准版本,再检查形体、材料、色彩和各景别可读性。新图作为候选,用户选用前保留原媒体;不因技能升级批量重画。
## 媒体提示词
依据当前演员与年代保持自然比例和可信身体。皮肤、织物、木材、玻璃与金属分别表现粗糙度和反光,不全部塑料高亮。建立主辅色与场景实际光源,暗部保留人物、手物接触和衣装信息。
空间具有前中后景与真实尺度,景深服务注意重点;人物关系和动作需要看清时保留足够焦点范围,不全程浅景深。色彩随地点和情绪有理由变化,沿用已批准视觉基调,不自动添加黑边、胶片颗粒或霓虹。
## 项目接入边界
本技能提供当前阶段的制作方法,沿用项目现有数据、输出结构、模型目录与内容审阅。用户原文、已批准资产和当前项目决定优先;来源示例不是本剧事实。模型参数与参考语法交给本项目实际供应商适配,不从旧手册推定平台能力。
首帧按需使用,不是生成前置条件;不默认补图。视频正文采用连续导演描述,时长预算独立保存为 timing。写作、修订和技能安装不会提交付费图片、视频、配音或音乐任务。附属脚本仅作文本资料。
修订已有分镜保留已批准图片、连续性、素材任务编号和制作版本;当前只处理有问题的阶段。结构检查、任务完成与成片画面、运动和声音验收分别记录。

View File

@@ -0,0 +1,11 @@
{
"name": "github-film-aesthetics",
"title": "电影写实 · 光色与空间",
"type": "aesthetic",
"description": "电影写实 · 光色与空间;按已批准人物、场景与基调维护具体视觉决定。",
"enabled": true,
"artStyle": "",
"files": [
"references/source.md"
]
}

View File

@@ -0,0 +1,13 @@
# 来源与项目适配
- 仓库:https://github.com/beshuaxian/higgsfield-seedance2-jineng
- 固定提交:83dcb10ee38c9694ac0f455ec55a62f2be3b8a14
- 核对日期:2026-10-06
- 仓库许可:未核到明确再分发许可
- 收录方式:自主方法适配,仅登记来源
- 文本性质:本条 SKILL.md 为项目适配正文,未逐字复制上游规则。上游不是项目协议或用户授权。
## 适配范围
采用与当前分类有关的阶段分工、故事取舍、素材职责、空间和声音方法。保留 RuoYi Drama 默认输出、用户原文、首帧可选、连续导演描述、独立 timing 与内容审阅。不沿用来源固定镜长、逐秒模板、固定集数、模型名单、付费自动化或审批口令。
许可快照只在项目技能源目录保存;原文引用的其他文件不代表已全部收录、阅读或安装。没有明确许可时不再分发上游全文。技能查阅和字段审阅不等于实际成片验收。

Some files were not shown because too many files have changed in this diff Show More