Commit e7ad0c17 authored by yaoke.yk's avatar yaoke.yk

分镜页面优化生成视频,参数必填问题

parent d9a2baad
......@@ -39,6 +39,42 @@ const DEFAULT_VIDEO_MODEL = VIDEO_MODEL_OPTIONS[0].value;
const MAX_CHARACTERS = 5;
const MAX_PROPS = 5;
const PROMPT_PREVIEW_LIMIT = 80;
// 分镜编辑器草稿存到 localStorage,按 (project, episode, storyboard) 分 key。
// 跨页面切换/刷新都不会丢;展开分镜时优先用草稿,没草稿才回退到上一次任务。
// v2: duration 默认 5s(之前 15s),audioOn 默认 true(之前 false)。bump 版本号让旧草稿失效。
const SB_DRAFT_KEY_PREFIX = "yaoai:sb-draft:v2";
const sbDraftKey = (pid: string, eid: string, sbId: string) =>
`${SB_DRAFT_KEY_PREFIX}:${pid}:${eid}:${sbId}`;
interface SbDraft {
shortDescription: string;
characterKeys: string[];
sceneKey: string;
propKeys: string[];
freeformPrompt: string;
selectedDuration: number;
selectedRatio: string;
selectedModel: string;
audioOn: boolean;
}
function loadSbDraft(key: string): SbDraft | null {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as SbDraft) : null;
} catch {
return null;
}
}
function saveSbDraft(key: string, d: SbDraft) {
try {
localStorage.setItem(key, JSON.stringify(d));
} catch {
// quota / private mode — 静默失败
}
}
const FOCUS_RING = "focus:outline-none focus:ring-2 focus:ring-[#1c64ff]/20 focus:border-[#1c64ff]/45";
const CONTROL_SURFACE = "border border-slate-200/80 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.04)]";
const SOFT_PANEL = "border border-slate-200/80 bg-white/95 shadow-[0_8px_24px_rgba(15,23,42,0.05)]";
......@@ -97,10 +133,10 @@ export function StoryboardWorkspace() {
const [styleKey] = useState<string>(""); // reserved; not exposed in this UI
const [shortDescription, setShortDescription] = useState<string>("");
const [freeformPrompt, setFreeformPrompt] = useState<string>("");
const [selectedDuration, setSelectedDuration] = useState(15);
const [selectedDuration, setSelectedDuration] = useState(5);
const [selectedRatio, setSelectedRatio] = useState<string>("16:9");
const [selectedModel, setSelectedModel] = useState<string>(DEFAULT_VIDEO_MODEL);
const [audioOn, setAudioOn] = useState(false);
const [audioOn, setAudioOn] = useState(true);
const [generatingVideoId, setGeneratingVideoId] = useState<string | null>(null);
// Modals
......@@ -147,6 +183,9 @@ export function StoryboardWorkspace() {
// 编辑器状态只在切换分镜卡片时从最近任务回填一次。
// 之后输入框完全归用户掌控,不会被 videoTasks 刷新(如点击生成后 mutation 失效)覆盖。
const lastSyncedExpandedIdRef = useRef<string | null>(null);
// sync effect 触发 setStuff 后,本轮 render 内 save effect 会用陈旧 state 误存。
// 设这个 ref 让 save effect 跳过 sync 后的第一次触发,等下一轮 render 拿到新 state 再存。
const skipNextDraftSaveRef = useRef(false);
// Reset on episode change
useEffect(() => {
......@@ -173,7 +212,25 @@ export function StoryboardWorkspace() {
if (!videoTasksFetched) return; // 等首次拉到任务列表再回填,避免空数据回填后被锁
if (lastSyncedExpandedIdRef.current === expandedId) return;
lastSyncedExpandedIdRef.current = expandedId;
skipNextDraftSaveRef.current = true; // 跳过本轮陈旧 state 的误存
// 优先从 localStorage 草稿恢复(跨页面切换/刷新都不丢)
const dKey = pid && eid ? sbDraftKey(pid, eid, expanded.id) : null;
const draft = dKey ? loadSbDraft(dKey) : null;
if (draft) {
setShortDescription(draft.shortDescription);
setCharacterKeys(draft.characterKeys);
setSceneKey(draft.sceneKey);
setPropKeys(draft.propKeys);
setFreeformPrompt(draft.freeformPrompt);
setSelectedDuration(draft.selectedDuration);
setSelectedRatio(draft.selectedRatio);
setSelectedModel(draft.selectedModel);
setAudioOn(draft.audioOn);
return;
}
// 没草稿才回退到上一次任务
setShortDescription(expanded.shortDescription ?? "");
const lastTask = getTaskForSb(expanded);
const initialChars = lastTask?.characterImageKeys
......@@ -187,6 +244,30 @@ export function StoryboardWorkspace() {
if (typeof lastTask?.generateAudio === "boolean") setAudioOn(lastTask.generateAudio);
}, [expandedId, videoTasksFetched]);
// 编辑器任意字段变化即写入 localStorage 草稿
useEffect(() => {
if (!expandedId || !pid || !eid) return;
if (skipNextDraftSaveRef.current) {
skipNextDraftSaveRef.current = false;
return;
}
saveSbDraft(sbDraftKey(pid, eid, expandedId), {
shortDescription,
characterKeys,
sceneKey,
propKeys,
freeformPrompt,
selectedDuration,
selectedRatio,
selectedModel,
audioOn,
});
}, [
expandedId, pid, eid,
shortDescription, characterKeys, sceneKey, propKeys, freeformPrompt,
selectedDuration, selectedRatio, selectedModel, audioOn,
]);
const handleEpisodeSelect = (ep: Episode) => {
setActiveEpisodeId(ep.id);
navigate(`/project/${pid}/storyboard/${ep.id}`, { replace: true });
......@@ -211,7 +292,7 @@ export function StoryboardWorkspace() {
dialogues: "",
cameraDirection: "",
compositionGuide: "",
durationSeconds: 15,
durationSeconds: 5,
startFramePrompt: "",
motionScript: "",
notes: "",
......@@ -254,7 +335,9 @@ export function StoryboardWorkspace() {
};
const promptHasContent = freeformPrompt.trim().length > 0;
const canGenerate = !!expanded && characterKeys.length > 0 && !!sceneKey
// 至少一张参考图(角色/场景/道具任一)+ 提示词非空
const hasAnyReferenceImage = characterKeys.length > 0 || !!sceneKey || propKeys.length > 0;
const canGenerate = !!expanded && hasAnyReferenceImage
&& promptHasContent && !generateStructured.isPending;
const handleGenerate = async () => {
......@@ -526,7 +609,7 @@ export function StoryboardWorkspace() {
<button
onClick={handleGenerate}
disabled={!canGenerate || generatingVideoId === expanded?.id}
title={!canGenerate ? "需要:至少 1 个出镜角色 + 场景图 + 提示词" : ""}
title={!canGenerate ? "需要:至少 1 张参考图(角色/场景/道具任一)+ 提示词" : ""}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-[#1c64ff] text-white text-xs font-semibold tracking-wide shadow-[0_9px_20px_rgba(28,100,255,0.26)] transition-all duration-150 hover:bg-[#1857e8] hover:shadow-[0_12px_24px_rgba(28,100,255,0.32)] hover:-translate-y-px disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none whitespace-nowrap"
>
{generatingVideoId === expanded?.id ? (
......
......@@ -23,9 +23,9 @@ public final class SeedancePromptBuilder {
*/
/** V17:单段 freeform 提示词模式(前端单输入框后的主要入口)。 */
public static String buildPromptFreeform(String freeformBody, int characterCount,
int propCount, boolean hasStyleImage) {
boolean hasSceneImage, int propCount, boolean hasStyleImage) {
StringBuilder sb = new StringBuilder();
appendImageHeader(sb, characterCount, propCount, hasStyleImage);
appendImageHeader(sb, characterCount, hasSceneImage, propCount, hasStyleImage);
sb.append("\n分镜内容:\n");
if (freeformBody != null && !freeformBody.isBlank()) {
sb.append(freeformBody.trim()).append('\n');
......@@ -35,9 +35,9 @@ public final class SeedancePromptBuilder {
}
public static String buildPrompt(UserPromptParts parts, int characterCount,
int propCount, boolean hasStyleImage) {
boolean hasSceneImage, int propCount, boolean hasStyleImage) {
StringBuilder sb = new StringBuilder();
appendImageHeader(sb, characterCount, propCount, hasStyleImage);
appendImageHeader(sb, characterCount, hasSceneImage, propCount, hasStyleImage);
sb.append("\n分镜内容:\n");
appendIfPresent(sb, "人物动作", parts.characterAction());
......@@ -50,23 +50,30 @@ public final class SeedancePromptBuilder {
return sb.toString();
}
/**
* 根据实际存在的图类型动态分配 @图N 编号(角色 → 场景 → 道具 → 风格)。
* 任一类可缺省;全空时不输出图片说明。
*/
private static void appendImageHeader(StringBuilder sb, int characterCount,
int propCount, boolean hasStyleImage) {
int imageCount;
if (characterCount <= 1) {
sb.append("@图1 是角色参考图,请保持人物脸型、发型、服装、气质一致,不要随意改变角色身份。\n");
imageCount = 1;
} else {
sb.append("@图1-").append(characterCount)
boolean hasSceneImage, int propCount, boolean hasStyleImage) {
int idx = 0;
if (characterCount == 1) {
sb.append("@图").append(idx + 1)
.append(" 是角色参考图,请保持人物脸型、发型、服装、气质一致,不要随意改变角色身份。\n");
idx += 1;
} else if (characterCount > 1) {
sb.append("@图").append(idx + 1).append("-").append(idx + characterCount)
.append(" 是出镜角色参考图(按顺序对应剧本中第 1..").append(characterCount)
.append(" 位角色),请保持每位人物的脸型、发型、服装、气质一致,不要混淆角色身份。\n");
imageCount = characterCount;
idx += characterCount;
}
sb.append("@图").append(imageCount + 1)
if (hasSceneImage) {
sb.append("@图").append(idx + 1)
.append(" 是场景参考图,请参考空间结构、环境布局、光影、氛围和构图。\n");
imageCount += 1;
idx += 1;
}
if (propCount > 0) {
int propStart = imageCount + 1;
int propStart = idx + 1;
int propEnd = propStart + propCount - 1;
if (propCount == 1) {
sb.append("@图").append(propStart).append(" 是道具参考图,请在画面中合理使用。\n");
......@@ -74,10 +81,10 @@ public final class SeedancePromptBuilder {
sb.append("@图").append(propStart).append("-").append(propEnd)
.append(" 是道具参考图,请在画面中合理使用这些道具。\n");
}
imageCount += propCount;
idx += propCount;
}
if (hasStyleImage) {
sb.append("@图").append(imageCount + 1)
sb.append("@图").append(idx + 1)
.append(" 是风格参考图,请参考整体美术风格、色彩和质感。\n");
}
}
......@@ -110,7 +117,9 @@ public final class SeedancePromptBuilder {
}
}
}
if (sceneImageKey != null && !sceneImageKey.isBlank()) {
keys.add(sceneImageKey);
}
if (propImageKeys != null) {
for (String k : propImageKeys) {
if (k != null && !k.isBlank()) {
......
......@@ -3,8 +3,6 @@ package com.yaoai.api.dto.ai;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
......@@ -31,8 +29,7 @@ public class StructuredVideoGenerateRequest {
@Size(max = 5, message = "出镜角色最多 5 个")
private List<String> characterImageKeys;
/** @图2 场景图(必填) */
@NotBlank(message = "场景图不能为空")
/** 场景图(可选)。角色/场景/道具至少传一种参考图,由 service 层校验。 */
private String sceneImageKey;
/** 用户结构化提示词(旧版 5 段,与 freeformPrompt 二选一,至少一项非空) */
......
......@@ -283,6 +283,9 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
throw new BizException(ErrorCode.NOT_FOUND, "场景不存在");
}
if (scene.getImagePrompt() == null || scene.getImagePrompt().isBlank()) {
scene.setImagePrompt(buildSceneImagePromptFallback(scene));
}
if (scene.getImagePrompt() == null || scene.getImagePrompt().isBlank()) {
throw new BizException(ErrorCode.INVALID_PARAM, "场景缺少图片 Prompt");
}
......@@ -358,6 +361,9 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
@Override
public Scene saveScene(Scene scene) {
if (scene.getImagePrompt() == null || scene.getImagePrompt().isBlank()) {
scene.setImagePrompt(buildSceneImagePromptFallback(scene));
}
if (scene.getId() == null) {
sceneMapper.insert(scene);
} else {
......@@ -366,6 +372,29 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
return scene;
}
private String buildSceneImagePromptFallback(Scene scene) {
if (scene == null) {
return "";
}
StringBuilder prompt = new StringBuilder();
if (scene.getName() != null && !scene.getName().isBlank()) {
prompt.append(scene.getName().trim());
}
if (scene.getDescription() != null && !scene.getDescription().isBlank()) {
if (prompt.length() > 0) {
prompt.append(", ");
}
prompt.append(scene.getDescription().trim());
}
if (scene.getSceneType() != null && !scene.getSceneType().isBlank()) {
if (prompt.length() > 0) {
prompt.append(", ");
}
prompt.append("indoor".equalsIgnoreCase(scene.getSceneType()) ? "indoor scene" : "outdoor scene");
}
return prompt.toString();
}
@Override
public void deleteCharacter(Long id, Long tenantId) {
Character character = characterMapper.selectById(id);
......
......@@ -140,22 +140,26 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
// 角色:优先使用 characterImageKeys 列表,回退到旧单图字段
List<String> characterKeys = s.getCharacterImageKeys();
if (characterKeys == null || characterKeys.isEmpty()) {
if (s.getCharacterImageKey() == null || s.getCharacterImageKey().isBlank()) {
throw new BizException(ErrorCode.INVALID_PARAM, "角色图不能为空");
}
if (s.getCharacterImageKey() != null && !s.getCharacterImageKey().isBlank()) {
characterKeys = java.util.List.of(s.getCharacterImageKey());
} else {
characterKeys = Collections.emptyList();
}
if (s.getSceneImageKey() == null || s.getSceneImageKey().isBlank()) {
throw new BizException(ErrorCode.INVALID_PARAM, "场景图不能为空");
}
// 1. 拼装 final prompt + 图片顺序
boolean hasScene = s.getSceneImageKey() != null && !s.getSceneImageKey().isBlank();
int propCount = s.getPropImageKeys() != null ? s.getPropImageKeys().size() : 0;
boolean hasStyle = s.getStyleImageKey() != null && !s.getStyleImageKey().isBlank();
// 至少要有一张参考图(角色/场景/道具任一),否则前端不应允许提交
if (characterKeys.isEmpty() && !hasScene && propCount == 0) {
throw new BizException(ErrorCode.INVALID_PARAM, "至少需要一张参考图(角色/场景/道具任一)");
}
// 1. 拼装 final prompt + 图片顺序
String finalPrompt;
if (s.getFreeformPrompt() != null && !s.getFreeformPrompt().isBlank()) {
finalPrompt = SeedancePromptBuilder.buildPromptFreeform(
s.getFreeformPrompt(), characterKeys.size(), propCount, hasStyle);
s.getFreeformPrompt(), characterKeys.size(), hasScene, propCount, hasStyle);
} else {
finalPrompt = SeedancePromptBuilder.buildPrompt(
new SeedancePromptBuilder.UserPromptParts(
......@@ -166,6 +170,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
s.getVideoStyle()
),
characterKeys.size(),
hasScene,
propCount,
hasStyle
);
......@@ -186,7 +191,9 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
task.setTaskType("structured_video");
task.setStatus("pending");
task.setPrompt(finalPrompt);
if (!characterKeys.isEmpty()) {
task.setCharacterImageKey(characterKeys.get(0));
}
task.setCharacterImageKeys(toJson(characterKeys));
task.setSceneImageKey(s.getSceneImageKey());
task.setPropImageKeys(toJson(s.getPropImageKeys()));
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment