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

@@ -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 与内容审阅。不沿用来源固定镜长、逐秒模板、固定集数、模型名单、付费自动化或审批口令。
许可快照只在项目技能源目录保存;原文引用的其他文件不代表已全部收录、阅读或安装。没有明确许可时不再分发上游全文。技能查阅和字段审阅不等于实际成片验收。

View File

@@ -0,0 +1,21 @@
# 二维手绘 · 线条与色板
## 视觉决定
按本剧批准方向选择稳定线条、造型比例和主辅色,人物轮廓、发束、服饰边缘与道具形态在不同景别保持。手绘、平涂、水彩和水墨是不同材料方向,不能把无渐变规则套到水彩或墨色过渡。
场景空间可简化但位置、尺度与接触关系清楚,人物动作保留重心和轮廓可读性。纹理和边缘变化体现选择的画材,避免三维塑料高光混入二维平涂。阴影和光源属于同一场景规则,沿已批准画面逐张核对。
## 资产审阅
先比较角色身份、服装、道具与场景的既有批准版本,再检查形体、材料、色彩和各景别可读性。新图作为候选,用户选用前保留原媒体;不因技能升级批量重画。
## 媒体提示词
按本剧批准方向选择稳定线条、造型比例和主辅色,人物轮廓、发束、服饰边缘与道具形态在不同景别保持。手绘、平涂、水彩和水墨是不同材料方向,不能把无渐变规则套到水彩或墨色过渡。
场景空间可简化但位置、尺度与接触关系清楚,人物动作保留重心和轮廓可读性。纹理和边缘变化体现选择的画材,避免三维塑料高光混入二维平涂。阴影和光源属于同一场景规则,沿已批准画面逐张核对。
## 项目接入边界
本技能提供当前阶段的制作方法,沿用项目现有数据、输出结构、模型目录与内容审阅。用户原文、已批准资产和当前项目决定优先;来源示例不是本剧事实。模型参数与参考语法交给本项目实际供应商适配,不从旧手册推定平台能力。
首帧按需使用,不是生成前置条件;不默认补图。视频正文采用连续导演描述,时长预算独立保存为 timing。写作、修订和技能安装不会提交付费图片、视频、配音或音乐任务。附属脚本仅作文本资料。
修订已有分镜保留已批准图片、连续性、素材任务编号和制作版本;当前只处理有问题的阶段。结构检查、任务完成与成片画面、运动和声音验收分别记录。

View File

@@ -0,0 +1,11 @@
{
"name": "github-handdrawn-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、二维手绘、漫画运动、动漫动作以及产品广告。每类方法按当前故事和制作方向选用,不能把广告展示、两秒开场或技术术语当成短剧统一规范。
## 查阅与应用
从风格中提取可审阅的决定:人物比例、造型层次、材料反光、色板、空间深度、线条与光源。镜头方法说明如何让观众看清事件,声音说明如何支持变化;不靠精美、大片、8K 等词替代这些决定。
电影、CG、手绘、漫画运动和动漫的项目适配条目分别保存在审美分类,可按实际方向选择。其他电商、品牌、时装、食物和建筑条目只作相关场景参考,不进入默认短剧生成。
上游对输出分辨率、帧率、同步准确性和渲染引擎的描述不代表本项目供应商能力。没有完整影片或同配置实验时,保持方法参考的证据级别。
## 接入范围
本条归视觉风格,在资产配置中选中后,根据当前题材提炼统一的造型、材质、色彩与光影方向,不混用资料库内的全部画风。公开仓库未核到明确再分发许可,正文为本项目自主编写的方法,来源只登记链接、提交和阅读范围,不打包上游全文或媒体。
## 项目接入边界
本技能提供当前阶段的制作方法,沿用项目现有数据、输出结构、模型目录与内容审阅。用户原文、已批准资产和当前项目决定优先;来源示例不是本剧事实。模型参数与参考语法交给本项目实际供应商适配,不从旧手册推定平台能力。
首帧按需使用,不是生成前置条件;不默认补图。视频正文采用连续导演描述,时长预算独立保存为 timing。写作、修订和技能安装不会提交付费图片、视频、配音或音乐任务。附属脚本仅作文本资料。
修订已有分镜保留已批准图片、连续性、素材任务编号和制作版本;当前只处理有问题的阶段。结构检查、任务完成与成片画面、运动和声音验收分别记录。

View File

@@ -0,0 +1,11 @@
{
"name": "github-higgsfield-style-library",
"title": "影视风格 · 题材与视觉方法库",
"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,28 @@
# 漫剧导演 · 空间、动作与表演
## 空间与表演
读取当前剧本、真实人数、资产版本和起始空间。把人物位置、身体朝向、支撑物与持物状态写成可见关系,摄影机位置另记。画外人物仍保留世界状态,不因裁幅消失而瞬移。
文戏以人物此刻需求决定目光、呼吸、手势和对手反应,微表情与变化时机对应,不给全体人物套同一种悲伤或笑容。对白逐句保留语气和原声源,画外声不错误绑定到画内嘴型。
动作戏先建立距离、接触点、重心、受力方向和结果,再安排景别。冲击、烟尘和碎裂来自明确原因,不能用闪光替代命中,不能用摆拍替代行动。
## 摄影与段缝
景别必须能承载本镜发生的事件,细节特写不承担需要全身或全空间才能读懂的动作。摄影重点可以从一人移到另一人,不强制首镜单主体;以实际剧情和空间清晰为准。
连续段从上一段末态进入,站位、手物接触和已发生结果保持。换时换地、反应镜或过渡镜需说明作用;不默认每个接缝补画一张图,不靠拉伸、慢动作或空等凑满预算。
负面要求尽量转化为可见的正向执行细节,材料、光源与角色行为具体对应。模型选择、接口语法和参考限制使用本项目目录,不沿用来源中未经当前接口核实的能力对照表。
## 媒体提示词
以实际人物的站位、支撑、持物和目光起始,写清刺激、选择、动作、接触、受力结果及对手回应。景别让观众看清事件,摄影与光色服务表演;原对白声源准确,动作声音与接触同步。末态保留身体、道具和空间变化供下一段继承。
## 项目接入边界
本技能提供当前阶段的制作方法,沿用项目现有数据、输出结构、模型目录与内容审阅。用户原文、已批准资产和当前项目决定优先;来源示例不是本剧事实。模型参数与参考语法交给本项目实际供应商适配,不从旧手册推定平台能力。
首帧按需使用,不是生成前置条件;不默认补图。视频正文采用连续导演描述,时长预算独立保存为 timing。写作、修订和技能安装不会提交付费图片、视频、配音或音乐任务。附属脚本仅作文本资料。
修订已有分镜保留已批准图片、连续性、素材任务编号和制作版本;当前只处理有问题的阶段。结构检查、任务完成与成片画面、运动和声音验收分别记录。
## 门口与空间转换
涉及进出门时,分别确定摄影机所在一侧、人物起点、门扇开合与铰链侧、跨门路线和抵达位置。门可内开或外开,按当前资产与剧情确定;门扇运动不代表人的行进方向。有锁或门闩时先解除,再移动门扇,钥匙和锁的去向延续到下一镜。
让观众看见人物从起始一侧经过门口到目标一侧,或在能核对位置的动作匹配点切镜;不能只写“推门”就切到人物已在房间深处。入场完成后再取物、检查或交谈;后续离场单独保留它的触发与路线。

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