Commit a37d46f8 authored by test's avatar test

更新AI生成、分镜工作台、数据库字段

parent 0aa3bd46
FROM docker.m.daocloud.io/library/node:20-alpine AS builder ARG NODE_IMAGE=node:20-bookworm-slim
ARG NGINX_IMAGE=nginx:alpine
FROM ${NODE_IMAGE} AS builder
WORKDIR /app WORKDIR /app
...@@ -12,7 +15,7 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} ...@@ -12,7 +15,7 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build RUN npm run build
FROM docker.m.daocloud.io/library/nginx:1.27-alpine FROM ${NGINX_IMAGE}
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
......
...@@ -19,7 +19,18 @@ server { ...@@ -19,7 +19,18 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
try_files /index.html =404;
}
location /assets/ {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
try_files $uri =404;
}
location / { location / {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
} }
...@@ -22,6 +22,7 @@ import { ...@@ -22,6 +22,7 @@ import {
useAllCharacterStates, useAllCharacterStates,
useScenes, useScenes,
useEpisodes, useEpisodes,
useGenerateStoryboardPrompt,
useGenerateCharacterImage, useGenerateCharacterImage,
useSaveCharacterState, useSaveCharacterState,
useGenerateCharacterStateImage, useGenerateCharacterStateImage,
...@@ -29,9 +30,9 @@ import { ...@@ -29,9 +30,9 @@ import {
import type { Storyboard, Character, CharacterState, Scene, Episode, AiTask } from "../../lib/api/ai"; import type { Storyboard, Character, CharacterState, Scene, Episode, AiTask } from "../../lib/api/ai";
import { useOperationCost } from "../../hooks/useUsage"; import { useOperationCost } from "../../hooks/useUsage";
const MIN_VIDEO_DURATION = 5; const MIN_VIDEO_DURATION = 4;
const MAX_VIDEO_DURATION = 15; const MAX_VIDEO_DURATION = 12;
const DEFAULT_VIDEO_DURATION = 11; const DEFAULT_VIDEO_DURATION = 7;
const DURATION_OPTIONS = Array.from( const DURATION_OPTIONS = Array.from(
{ length: MAX_VIDEO_DURATION - MIN_VIDEO_DURATION + 1 }, { length: MAX_VIDEO_DURATION - MIN_VIDEO_DURATION + 1 },
(_, index) => MIN_VIDEO_DURATION + index (_, index) => MIN_VIDEO_DURATION + index
...@@ -53,8 +54,8 @@ type VideoGenerationMode = "reference" | "frames"; ...@@ -53,8 +54,8 @@ type VideoGenerationMode = "reference" | "frames";
// 分镜编辑器草稿存到 localStorage,按 (project, episode, storyboard) 分 key。 // 分镜编辑器草稿存到 localStorage,按 (project, episode, storyboard) 分 key。
// 跨页面切换/刷新都不会丢;展开分镜时优先用草稿,没草稿才回退到上一次任务。 // 跨页面切换/刷新都不会丢;展开分镜时优先用草稿,没草稿才回退到上一次任务。
// v3: Agent 分镜会从后端落地素材 key,旧草稿可能携带导入阶段的正文噪声,bump 版本号让它失效。 // v4: 旧草稿/任务里可能留有【镜头1】分段式视频词,bump 版本号让它失效。
const SB_DRAFT_KEY_PREFIX = "yaoai:sb-draft:v3"; const SB_DRAFT_KEY_PREFIX = "yaoai:sb-draft:v4";
const sbDraftKey = (pid: string, eid: string, sbId: string) => const sbDraftKey = (pid: string, eid: string, sbId: string) =>
`${SB_DRAFT_KEY_PREFIX}:${pid}:${eid}:${sbId}`; `${SB_DRAFT_KEY_PREFIX}:${pid}:${eid}:${sbId}`;
...@@ -102,14 +103,148 @@ function normalizeVideoDuration(value: number | null | undefined) { ...@@ -102,14 +103,148 @@ function normalizeVideoDuration(value: number | null | undefined) {
return Math.min(MAX_VIDEO_DURATION, Math.max(MIN_VIDEO_DURATION, Math.round(value))); return Math.min(MAX_VIDEO_DURATION, Math.max(MIN_VIDEO_DURATION, Math.round(value)));
} }
const FREEFORM_PLACEHOLDER = `【镜头1】 时长2s | 蒙太奇快切 | 固定机位快速切出 const FREEFORM_PLACEHOLDER = `### 【镜1】定场 — 简短镜头目的
画面定格在角色刚转头的瞬间,背景虚化为暖色光斑
\`\`\`text
[景别] 俯拍 / 全景起幅 -> 中景落幅 / 近景 / 特写
[运镜] 固定机位 / 向右摇摄(pan right) / 缓慢推进
[构图] 按当前画幅写清楚横屏左中右、竖屏上中下或方形中心关系
[画面] 写成完整电影画面,不要堆字段
[灯光] 主光方向、冷暖关系、阴影压力
[声音] 有来源的环境声、动作声或音乐情绪
[时长] 7秒
[台词] 无
\`\`\``;
const OLD_SHOT_SHEET_LABELS = [
"镜头编号:",
"景别:",
"运镜:",
"画面描述:",
"灯光氛围:",
"音效/配乐:",
"时长:",
"台词:",
"镜头接力:",
];
const NEW_SHOT_NOTE_LABELS = ["[景别]", "[运镜]", "[构图]", "[画面]", "[灯光]", "[声音]", "[时长]", "[台词]"];
【镜头2】 时长3s | 推近 | 缓慢推到面部特写 function formatShotSheetPrompt(value: string | null | undefined): string {
角色微微抬眼,眼神坚定,发丝随风轻拂 const text = (value ?? "").trim();
if (!text) {
return text;
}
if (text.includes("### 【镜") && NEW_SHOT_NOTE_LABELS.some((label) => text.includes(label))) {
return text;
}
if (!OLD_SHOT_SHEET_LABELS.every((label) => text.includes(label))) {
return text;
}
const positions = OLD_SHOT_SHEET_LABELS
.map((label) => ({ label, index: text.indexOf(label) }))
.filter((item) => item.index >= 0)
.sort((a, b) => a.index - b.index);
if (positions.length !== OLD_SHOT_SHEET_LABELS.length) {
return text;
}
const sections = Object.fromEntries(positions.map((item, index) => {
const start = item.index + item.label.length;
const end = positions[index + 1]?.index ?? text.length;
return [item.label, text.slice(start, end).trim()];
}));
const shotNumber = sections["镜头编号:"]?.replace(/^镜头/, "").trim() || "1";
const picture = sections["画面描述:"] || "";
const titleSource = picture.split(/[。;;]/).find(Boolean)?.trim() || "当前分镜";
const title = titleSource.length > 18 ? `${titleSource.slice(0, 18)}...` : titleSource;
return `### 【镜${shotNumber}${title} — 视频生成提示
\`\`\`text
[景别] ${sections["景别:"] || ""}
[运镜] ${sections["运镜:"] || ""}
[构图] 按当前画幅明确主体安全区、空间层次和视觉锚点。
[画面] ${picture}
[灯光] ${sections["灯光氛围:"] || ""}
[声音] ${sections["音效/配乐:"] || ""}
[时长] ${sections["时长:"] || ""}
[台词] ${sections["台词:"] || "无"}
\`\`\``;
}
function isLegacySegmentedPrompt(value: string | null | undefined) {
const text = (value ?? "").trim();
if (!text) return false;
return /\d+\s*-\s*\d+\s*(s|秒)/i.test(text)
|| /【\s*镜头\s*\d+\s*】/.test(text)
|| /\[\s*镜头\s*\d+\s*]/.test(text)
|| (text.includes("视觉:") && text.includes("音频:"));
}
function promptForEditor(value: string | null | undefined) {
const text = (value ?? "").trim();
return text && !isLegacySegmentedPrompt(text) ? formatShotSheetPrompt(text) : "";
}
function renderSummaryInline(text: string) {
return text.split(/(\*\*[^*]+\*\*)/g).filter(Boolean).map((part, index) => {
if (part.startsWith("**") && part.endsWith("**")) {
return <strong key={index} className="font-semibold text-[#111827]">{part.slice(2, -2)}</strong>;
}
return <span key={index}>{part}</span>;
});
}
【镜头3】 时长2s | 拉远 | 从面部拉到全景 function episodeSummaryBlocks(summary: string) {
镜头快速拉远,呈现完整环境氛围`; return summary
.replace(/\s+(?=###\s+)/g, "\n")
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean);
}
function EpisodeSummaryPreview({ title, summary }: { title: string; summary: string }) {
const blocks = episodeSummaryBlocks(summary);
return (
<div className="group/episode-summary relative min-w-0 outline-none" tabIndex={0}>
<p className="mt-1 line-clamp-1 cursor-default text-xs leading-5 text-[#475467]">
{summary}
</p>
<div className="pointer-events-none absolute left-0 top-full z-50 mt-2 hidden w-[min(760px,calc(100vw-260px))] group-hover/episode-summary:block group-focus-within/episode-summary:block">
<div className="pointer-events-auto overflow-hidden rounded-lg border border-[#dfe5f2] bg-white shadow-[0_18px_44px_rgba(15,23,42,0.14)] ring-1 ring-slate-900/5">
<div className="border-b border-[#edf1f7] bg-[#fbfcff] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-medium text-[#667085]">分集概要</p>
<p className="mt-0.5 truncate text-sm font-semibold text-[#111827]">{title}</p>
</div>
<span className="flex-shrink-0 rounded-md border border-[#d7ceff] bg-[#f4f0ff] px-2 py-1 text-[11px] font-medium text-[#6147e8]">
悬停查看
</span>
</div>
</div>
<div className="max-h-[360px] overflow-y-auto px-4 py-3">
<div className="space-y-2.5 text-[12px] leading-6 text-[#344054]">
{blocks.map((block, index) => {
const isHeading = block.startsWith("##");
const cleaned = block.replace(/^#{2,3}\s*/, "");
return (
<div
key={`${index}-${cleaned.slice(0, 16)}`}
className={isHeading ? "rounded-md bg-[#f8fafc] px-3 py-2" : "border-l-2 border-[#e4e8f0] pl-3"}
>
<p className={isHeading ? "font-semibold text-[#111827]" : "text-[#344054]"}>
{renderSummaryInline(cleaned)}
</p>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
);
}
function characterFrontKey(c: Character): string | null { function characterFrontKey(c: Character): string | null {
return c.frontImageTosKey ?? c.imageTosKey ?? null; return c.frontImageTosKey ?? c.imageTosKey ?? null;
...@@ -310,6 +445,7 @@ export function StoryboardWorkspace() { ...@@ -310,6 +445,7 @@ export function StoryboardWorkspace() {
const updateSb = useUpdateStoryboard(pid, eid); const updateSb = useUpdateStoryboard(pid, eid);
const uploadFrameImage = useUploadStoryboardFrameImage(pid, eid); const uploadFrameImage = useUploadStoryboardFrameImage(pid, eid);
const generateStructured = useGenerateStructuredVideo(pid); const generateStructured = useGenerateStructuredVideo(pid);
const generatePrompt = useGenerateStoryboardPrompt(pid);
const startAssembly = useStartAssembly(pid, eid); const startAssembly = useStartAssembly(pid, eid);
const videoCredits = useOperationCost("video_generate"); const videoCredits = useOperationCost("video_generate");
...@@ -434,7 +570,7 @@ export function StoryboardWorkspace() { ...@@ -434,7 +570,7 @@ export function StoryboardWorkspace() {
setLastFrameImageKey(draft.lastFrameImageKey || expanded.lastFrameImageKey || ""); setLastFrameImageKey(draft.lastFrameImageKey || expanded.lastFrameImageKey || "");
setLastFrameImageUrl(draft.lastFrameImageUrl || expanded.lastFrameImageUrl || ""); setLastFrameImageUrl(draft.lastFrameImageUrl || expanded.lastFrameImageUrl || "");
setGenerationMode(draft.generationMode ?? "reference"); setGenerationMode(draft.generationMode ?? "reference");
setFreeformPrompt(draft.freeformPrompt); setFreeformPrompt(promptForEditor(draft.freeformPrompt) || promptForEditor(expanded.videoPrompt));
setSelectedDuration(normalizeVideoDuration(draft.selectedDuration)); setSelectedDuration(normalizeVideoDuration(draft.selectedDuration));
setSelectedRatio(draft.selectedRatio); setSelectedRatio(draft.selectedRatio);
setSelectedModel(draft.selectedModel); setSelectedModel(draft.selectedModel);
...@@ -471,7 +607,7 @@ export function StoryboardWorkspace() { ...@@ -471,7 +607,7 @@ export function StoryboardWorkspace() {
setLastFrameImageKey(expanded.lastFrameImageKey ?? ""); setLastFrameImageKey(expanded.lastFrameImageKey ?? "");
setLastFrameImageUrl(expanded.lastFrameImageUrl ?? ""); setLastFrameImageUrl(expanded.lastFrameImageUrl ?? "");
setGenerationMode("reference"); setGenerationMode("reference");
setFreeformPrompt(expanded.videoPrompt?.trim() || (lastTask?.userPrompt?.freeformPrompt ?? "")); setFreeformPrompt(promptForEditor(expanded.videoPrompt) || promptForEditor(lastTask?.userPrompt?.freeformPrompt));
if (typeof lastTask?.videoDuration === "number") { if (typeof lastTask?.videoDuration === "number") {
setSelectedDuration(normalizeVideoDuration(lastTask.videoDuration)); setSelectedDuration(normalizeVideoDuration(lastTask.videoDuration));
} }
...@@ -566,6 +702,22 @@ export function StoryboardWorkspace() { ...@@ -566,6 +702,22 @@ export function StoryboardWorkspace() {
} }
}; };
const handleRefreshPrompt = async () => {
if (!expanded) return;
const prompt = formatShotSheetPrompt(await generatePrompt.mutateAsync({
storyboardId: expanded.id,
ratio: selectedRatio,
}));
setFreeformPrompt(prompt);
await updateSb.mutateAsync({
id: expanded.id,
patch: {
videoPrompt: prompt,
durationSeconds: selectedDuration,
},
});
};
const handleCreate = async () => { const handleCreate = async () => {
const newSb = await createSb.mutateAsync({ const newSb = await createSb.mutateAsync({
sequenceNum: storyboards.length + 1, sequenceNum: storyboards.length + 1,
...@@ -698,6 +850,14 @@ export function StoryboardWorkspace() { ...@@ -698,6 +850,14 @@ export function StoryboardWorkspace() {
); );
const previewDisplaySb = previewSb ?? null; const previewDisplaySb = previewSb ?? null;
const previewTask = previewDisplaySb ? getTaskForSb(previewDisplaySb) : null; const previewTask = previewDisplaySb ? getTaskForSb(previewDisplaySb) : null;
const hasVideoActivity = videoTasks.some((task) =>
task.status === "succeeded"
|| task.status === "running"
|| task.status === "submitted"
|| task.status === "pending"
|| task.status === "failed"
);
const currentPreviewHasVideo = previewTask?.status === "succeeded" && !!previewTask.resultVideoUrl;
const promptPreviewText = freeformPrompt.trim(); const promptPreviewText = freeformPrompt.trim();
const sceneRefDisplay = expanded const sceneRefDisplay = expanded
...@@ -723,7 +883,6 @@ export function StoryboardWorkspace() { ...@@ -723,7 +883,6 @@ export function StoryboardWorkspace() {
: "暂无分集"; : "暂无分集";
const currentEpisodeSummary = currentEpisode?.summary const currentEpisodeSummary = currentEpisode?.summary
|| "当前项目还没有可用分集。生成分集后,可以在这里继续生成分镜。"; || "当前项目还没有可用分集。生成分集后,可以在这里继续生成分镜。";
const selectedScene = sceneKey ? sceneByKey.get(sceneKey) ?? null : null;
const generationModeLabel = generationMode === "frames" ? "首尾帧生成" : "参考图生成"; const generationModeLabel = generationMode === "frames" ? "首尾帧生成" : "参考图生成";
const generateRequirementText = generationMode === "frames" const generateRequirementText = generationMode === "frames"
? "需要:上传首帧和尾帧,并填写提示词" ? "需要:上传首帧和尾帧,并填写提示词"
...@@ -786,7 +945,7 @@ export function StoryboardWorkspace() { ...@@ -786,7 +945,7 @@ export function StoryboardWorkspace() {
</button> </button>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 className="text-base font-semibold text-[#111827]">{currentEpisodeTitle}</h2> <h2 className="text-base font-semibold text-[#111827]">{currentEpisodeTitle}</h2>
<p className="mt-1 line-clamp-1 text-xs leading-5 text-[#475467]">{currentEpisodeSummary}</p> <EpisodeSummaryPreview title={currentEpisodeTitle} summary={currentEpisodeSummary} />
</div> </div>
<button <button
onClick={handleGenerateAll} onClick={handleGenerateAll}
...@@ -935,12 +1094,22 @@ export function StoryboardWorkspace() { ...@@ -935,12 +1094,22 @@ export function StoryboardWorkspace() {
? `已预选 ${referenceCount} 个参考素材,可继续手工调整` ? `已预选 ${referenceCount} 个参考素材,可继续手工调整`
: "可手工选择参考素材;AI 批量生成会自动预选可用角色/场景"} : "可手工选择参考素材;AI 批量生成会自动预选可用角色/场景"}
</p> </p>
<button
type="button"
onClick={handleRefreshPrompt}
disabled={!expanded || generatePrompt.isPending || updateSb.isPending}
title="生成或刷新视频提示词"
className="ml-auto inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md text-[#667085] transition hover:bg-white hover:text-[#7657ff] disabled:opacity-50"
>
{generatePrompt.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
</button>
</div> </div>
<textarea <textarea
value={freeformPrompt} value={freeformPrompt}
onChange={(e) => setFreeformPrompt(e.target.value)} onChange={(e) => setFreeformPrompt(e.target.value)}
placeholder={FREEFORM_PLACEHOLDER} placeholder={FREEFORM_PLACEHOLDER}
className={`flex-1 min-h-[420px] px-3 py-2.5 rounded-md border border-[#d9dee8] bg-white text-sm text-[#111827] leading-relaxed resize-none shadow-inner shadow-slate-100/80 ${FOCUS_RING}`} spellCheck={false}
className={`flex-1 min-h-[420px] px-4 py-3 rounded-md border border-[#d9dee8] bg-white font-mono text-[13px] text-[#111827] leading-7 resize-none shadow-inner shadow-slate-100/80 whitespace-pre-wrap ${FOCUS_RING}`}
/> />
</> </>
)} )}
...@@ -1069,7 +1238,7 @@ export function StoryboardWorkspace() { ...@@ -1069,7 +1238,7 @@ export function StoryboardWorkspace() {
</div> </div>
<div className="flex-1 min-h-0 overflow-hidden bg-white px-5 pb-4 pt-2"> <div className="flex-1 min-h-0 overflow-hidden bg-white px-5 pb-4 pt-2">
{!previewDisplaySb ? ( {!hasVideoActivity || !previewDisplaySb ? (
<div className="h-full flex flex-col items-center justify-center gap-4"> <div className="h-full flex flex-col items-center justify-center gap-4">
<div className="flex w-full max-w-[920px] aspect-video flex-col items-center justify-center rounded-lg border border-dashed border-[#d9dee8] bg-[#fbfcff]"> <div className="flex w-full max-w-[920px] aspect-video flex-col items-center justify-center rounded-lg border border-dashed border-[#d9dee8] bg-[#fbfcff]">
<ImageIcon className="mb-3 h-9 w-9 text-[#98a2b3]" /> <ImageIcon className="mb-3 h-9 w-9 text-[#98a2b3]" />
...@@ -1117,34 +1286,17 @@ export function StoryboardWorkspace() { ...@@ -1117,34 +1286,17 @@ export function StoryboardWorkspace() {
</div> </div>
) : ( ) : (
<div className="h-full flex flex-col items-center justify-center gap-4"> <div className="h-full flex flex-col items-center justify-center gap-4">
<div className="relative w-full max-w-[920px] aspect-video overflow-hidden rounded-lg bg-[#101319] shadow-[0_18px_45px_rgba(15,23,42,0.16)]"> <div className="flex w-full max-w-[920px] aspect-video flex-col items-center justify-center rounded-lg border border-dashed border-[#d9dee8] bg-[#fbfcff]">
{selectedScene?.imageUrl ? ( <ImageIcon className="mb-3 h-9 w-9 text-[#98a2b3]" />
<img src={selectedScene.imageUrl} alt={selectedScene.name} className="h-full w-full object-cover" /> <p className="text-sm font-medium text-[#111827]">暂无生成视频</p>
) : ( <p className="mt-1 text-xs text-[#98a2b3]">点击生成视频后会显示结果。</p>
<div className="flex h-full w-full flex-col items-center justify-center bg-[#fbfcff]">
<ImageIcon className="mb-3 h-9 w-9 text-[#98a2b3]" />
<p className="text-sm font-medium text-[#111827]">暂无生成视频</p>
<p className="mt-1 text-xs text-[#98a2b3]">点击生成视频后会显示结果。</p>
</div>
)}
{selectedScene?.imageUrl && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 to-transparent px-4 pb-3 pt-14">
<div className="flex items-center justify-between text-white">
<div className="flex items-center gap-3 text-xs font-mono">
<Play className="h-4 w-4 fill-white" />
<span>0:00 / 0:{String(selectedDuration).padStart(2, "0")}</span>
</div>
<span className="text-[11px] text-white/75">等待生成</span>
</div>
</div>
)}
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* Thumbnail timeline */} {/* Thumbnail timeline */}
{storyboards.length > 0 && ( {currentPreviewHasVideo && storyboards.length > 0 && (
<div className="bg-white flex-shrink-0"> <div className="bg-white flex-shrink-0">
<div className="flex items-center justify-start gap-3 overflow-x-auto px-3 py-3"> <div className="flex items-center justify-start gap-3 overflow-x-auto px-3 py-3">
{storyboards.map((sb) => { {storyboards.map((sb) => {
......
import { useState, useEffect, useRef } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useParams, useNavigate } from "react-router"; import { useParams, useNavigate } from "react-router";
import { import {
Loader2, Play, Pause, Download, Trash2, Edit2, Check,
SkipBack, SkipForward, Check, Film, Download,
Edit2,
Film,
Loader2,
Pause,
Play,
SkipBack,
SkipForward,
Trash2,
} from "lucide-react"; } from "lucide-react";
import { import {
useVideoTasks,
useEpisodes,
useStoryboards,
useAssemblyTask, useAssemblyTask,
useStartAssembly,
useDeleteVideoTask, useDeleteVideoTask,
useEpisodes,
useStartAssembly,
useStoryboards,
useVideoTasks,
} from "../../hooks/useAi"; } from "../../hooks/useAi";
import type { AiTask, Episode, Storyboard } from "../../lib/api/ai"; import type { AiTask, Episode, Storyboard } from "../../lib/api/ai";
const FRAME_RATE = 24;
const FRAME_STEP = 1 / FRAME_RATE;
const CONTROL_SURFACE = "border border-slate-200/80 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.04)]";
function fmt(secs: number) { function fmt(secs: number) {
const m = Math.floor(secs / 60); const safe = Number.isFinite(secs) ? Math.max(0, secs) : 0;
const s = secs % 60; const m = Math.floor(safe / 60);
const s = Math.floor(safe % 60);
return `${m}:${String(s).padStart(2, "0")}`; return `${m}:${String(s).padStart(2, "0")}`;
} }
function timeAgo(dateStr: string) { function timeAgo(dateStr: string) {
const diff = Date.now() - new Date(dateStr).getTime(); const diff = Date.now() - new Date(dateStr).getTime();
const h = Math.floor(diff / 3600000); const h = Math.floor(diff / 3600000);
...@@ -27,6 +41,13 @@ function timeAgo(dateStr: string) { ...@@ -27,6 +41,13 @@ function timeAgo(dateStr: string) {
return `${Math.floor(h / 24)}天前`; return `${Math.floor(h / 24)}天前`;
} }
function statusLabel(status?: string | null) {
if (status === "succeeded") return "已完成";
if (status === "failed") return "失败";
if (status === "running" || status === "submitted" || status === "pending") return "生成中";
return "未生成";
}
export function VideoGeneration() { export function VideoGeneration() {
const { projectId } = useParams<{ projectId: string }>(); const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
...@@ -34,77 +55,116 @@ export function VideoGeneration() { ...@@ -34,77 +55,116 @@ export function VideoGeneration() {
const { data: episodes = [], isLoading: epLoading } = useEpisodes(pid); const { data: episodes = [], isLoading: epLoading } = useEpisodes(pid);
const { data: allTasks = [] } = useVideoTasks(pid); const { data: allTasks = [] } = useVideoTasks(pid);
const [activeEpId, setActiveEpId] = useState("");
const [activeEpId, setActiveEpId] = useState<string>("");
useEffect(() => { useEffect(() => {
if (episodes.length > 0 && !activeEpId) { if (episodes.length > 0 && !activeEpId) {
setActiveEpId(episodes[0].id); setActiveEpId(episodes[0].id);
} }
}, [episodes.length]); }, [episodes, activeEpId]);
const { data: storyboards = [] } = useStoryboards(pid, activeEpId); const { data: storyboards = [] } = useStoryboards(pid, activeEpId);
const { data: assemblyTask } = useAssemblyTask(pid, activeEpId); const { data: assemblyTask } = useAssemblyTask(pid, activeEpId);
const startAssembly = useStartAssembly(pid, activeEpId); const startAssembly = useStartAssembly(pid, activeEpId);
const deleteTask = useDeleteVideoTask(pid); const deleteTask = useDeleteVideoTask(pid);
// Tasks for active episode const epTasks = useMemo(
const epTasks = allTasks.filter((t) => t.episodeId === activeEpId); () => allTasks.filter((task) => task.episodeId === activeEpId),
[allTasks, activeEpId]
);
const getTaskForSb = (sb: Storyboard): AiTask | undefined => { const getTaskForSb = (sb: Storyboard): AiTask | undefined => {
const byId = epTasks.filter((t) => t.storyboardId === sb.id); const byId = epTasks.filter((task) => task.storyboardId === sb.id);
return byId.find((t) => t.status === "succeeded") return byId.find((task) => task.status === "succeeded")
?? byId.find((t) => t.status === "running" || t.status === "submitted" || t.status === "pending") ?? byId.find((task) => task.status === "running" || task.status === "submitted" || task.status === "pending")
?? byId[0]; ?? byId[0];
}; };
// Succeeded shots in sequence order
const orderedShots = storyboards const orderedShots = storyboards
.map((sb) => ({ sb, task: getTaskForSb(sb) })) .map((sb) => ({ sb, task: getTaskForSb(sb) }))
.filter(({ task }) => task?.status === "succeeded" && task.resultVideoUrl); .filter(({ task }) => task?.status === "succeeded" && task.resultVideoUrl);
const orderedShotTaskIds = orderedShots.map(({ task }) => task!.id); const orderedShotTaskIds = orderedShots.map(({ task }) => task!.id);
// Stats
const totalShots = storyboards.length; const totalShots = storyboards.length;
const doneShots = orderedShots.length; const doneShots = orderedShots.length;
const pendingShots = epTasks.filter( const pendingShots = epTasks.filter((task) =>
(t) => t.status === "pending" || t.status === "submitted" || t.status === "running" task.status === "pending" || task.status === "submitted" || task.status === "running"
).length; ).length;
// Use actual storyboard durationSeconds (updated when video is generated) const totalDuration = storyboards.reduce((sum, sb) => sum + (sb.durationSeconds || 0), 0);
const totalDuration = storyboards.reduce((s, sb) => s + (sb.durationSeconds || 0), 0);
// ─── Player state ───
// "assembly" = show assembled video, "shot" = show individual storyboard video
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [playerMode, setPlayerMode] = useState<"assembly" | "shot">("assembly"); const [playerMode, setPlayerMode] = useState<"assembly" | "shot">("assembly");
const [currentShotIdx, setCurrentShotIdx] = useState(0); const [currentShotIdx, setCurrentShotIdx] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [mediaDuration, setMediaDuration] = useState(0);
const hasAssembly = assemblyTask?.status === "succeeded" && !!assemblyTask.downloadUrl; const hasAssembly = assemblyTask?.status === "succeeded" && !!assemblyTask.downloadUrl;
// Current video URL to show in player
const currentVideoUrl = playerMode === "assembly" && hasAssembly const currentVideoUrl = playerMode === "assembly" && hasAssembly
? assemblyTask!.downloadUrl! ? assemblyTask!.downloadUrl!
: orderedShots[currentShotIdx]?.task?.resultVideoUrl ?? null; : orderedShots[currentShotIdx]?.task?.resultVideoUrl ?? null;
const currentShot = playerMode === "shot" ? orderedShots[currentShotIdx] : null;
const timelineDuration = mediaDuration || (playerMode === "assembly" ? totalDuration : currentShot?.sb.durationSeconds ?? 0) || 1;
const timelineProgress = Math.min(100, Math.max(0, (currentTime / timelineDuration) * 100));
const currentFrame = Math.max(0, Math.round(currentTime * FRAME_RATE));
const currentShotLabel = playerMode === "shot" const shotRanges = useMemo(() => {
? `分镜 ${String(orderedShots[currentShotIdx]?.sb.sequenceNum ?? 1).padStart(3, "0")}` let cursor = 0;
: "整集"; return orderedShots.map(({ sb, task }, index) => {
const duration = sb.durationSeconds || task?.videoDuration || 1;
const range = { index, sb, task, start: cursor, end: cursor + duration, duration };
cursor += duration;
return range;
});
}, [orderedShots]);
const activeTimelineShot = shotRanges.find((shot) => currentTime >= shot.start && currentTime < shot.end) ?? shotRanges[0];
// Sync video src when currentVideoUrl changes
useEffect(() => { useEffect(() => {
const v = videoRef.current; const video = videoRef.current;
if (!v) return; if (!video) return;
v.pause(); video.pause();
video.load();
setPlaying(false); setPlaying(false);
v.load(); setCurrentTime(0);
setMediaDuration(0);
}, [currentVideoUrl]); }, [currentVideoUrl]);
const togglePlay = () => { const syncMediaState = () => {
const v = videoRef.current; const video = videoRef.current;
if (!v) return; if (!video) return;
if (v.paused) { v.play(); setPlaying(true); } setCurrentTime(video.currentTime || 0);
else { v.pause(); setPlaying(false); } if (Number.isFinite(video.duration)) {
setMediaDuration(video.duration || 0);
}
};
const seekTo = (seconds: number) => {
const video = videoRef.current;
if (!video || !currentVideoUrl) return;
const next = Math.min(Math.max(seconds, 0), timelineDuration);
video.currentTime = next;
setCurrentTime(next);
};
const stepFrame = (direction: -1 | 1) => {
const video = videoRef.current;
if (!video || !currentVideoUrl) return;
video.pause();
setPlaying(false);
seekTo(video.currentTime + direction * FRAME_STEP);
};
const togglePlay = async () => {
const video = videoRef.current;
if (!video || !currentVideoUrl) return;
if (video.paused) {
await video.play();
setPlaying(true);
} else {
video.pause();
setPlaying(false);
}
}; };
const selectShot = (idx: number) => { const selectShot = (idx: number) => {
...@@ -112,6 +172,15 @@ export function VideoGeneration() { ...@@ -112,6 +172,15 @@ export function VideoGeneration() {
setPlayerMode("shot"); setPlayerMode("shot");
}; };
const seekAssemblyShot = (idx: number) => {
if (hasAssembly) {
setPlayerMode("assembly");
setTimeout(() => seekTo(shotRanges[idx]?.start ?? 0), 0);
} else {
selectShot(idx);
}
};
const handleDelete = async (taskId: string) => { const handleDelete = async (taskId: string) => {
if (!confirm("确定删除此视频?")) return; if (!confirm("确定删除此视频?")) return;
await deleteTask.mutateAsync(taskId); await deleteTask.mutateAsync(taskId);
...@@ -121,26 +190,23 @@ export function VideoGeneration() { ...@@ -121,26 +190,23 @@ export function VideoGeneration() {
startAssembly.mutate(orderedShotTaskIds); startAssembly.mutate(orderedShotTaskIds);
}; };
// Episode stats helper
const epShotCount = (ep: Episode) => ep.storyboardCount ?? 0; const epShotCount = (ep: Episode) => ep.storyboardCount ?? 0;
const epDoneCount = (ep: Episode) => const epDoneCount = (ep: Episode) =>
allTasks.filter((t) => t.episodeId === ep.id && t.status === "succeeded").length; allTasks.filter((task) => task.episodeId === ep.id && task.status === "succeeded").length;
return ( return (
<div className="h-full flex overflow-hidden bg-background"> <div className="flex h-full overflow-hidden bg-[#f7f8fb] text-[#111827]">
<aside className="flex w-[190px] flex-shrink-0 flex-col border-r border-[#e5e8f0] bg-[#fbfcff]">
{/* ─── Left: Episode list ─── */} <div className="flex h-14 flex-shrink-0 items-center border-b border-[#e5e8f0] px-4">
<div className="flex-shrink-0 border-r border-border flex flex-col" style={{ width: 180 }}> <span className="text-sm font-medium text-[#111827]">选集</span>
<div className="h-14 px-4 border-b border-border flex items-center flex-shrink-0">
<span className="text-sm font-medium text-foreground">选集</span>
</div> </div>
<div className="flex-1 overflow-y-auto py-2"> <div className="flex-1 overflow-y-auto py-2">
{epLoading ? ( {epLoading ? (
<div className="flex justify-center pt-6"> <div className="flex justify-center pt-6">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" /> <Loader2 className="h-4 w-4 animate-spin text-[#98a2b3]" />
</div> </div>
) : episodes.length === 0 ? ( ) : episodes.length === 0 ? (
<p className="text-xs text-muted-foreground text-center pt-6 px-3">暂无分集</p> <p className="px-3 pt-6 text-center text-xs text-[#98a2b3]">暂无分集</p>
) : ( ) : (
episodes.map((ep) => { episodes.map((ep) => {
const total = epShotCount(ep); const total = epShotCount(ep);
...@@ -149,20 +215,24 @@ export function VideoGeneration() { ...@@ -149,20 +215,24 @@ export function VideoGeneration() {
return ( return (
<button <button
key={ep.id} key={ep.id}
onClick={() => { setActiveEpId(ep.id); setPlayerMode("assembly"); setCurrentShotIdx(0); }} onClick={() => {
className={`w-full text-left px-4 py-3 border-b border-border/50 transition-colors ${ setActiveEpId(ep.id);
isActive ? "bg-accent" : "hover:bg-muted" setPlayerMode("assembly");
setCurrentShotIdx(0);
}}
className={`w-full border-b border-[#edf0f5] px-4 py-3 text-left transition ${
isActive ? "bg-[#f4f0ff]" : "hover:bg-white"
}`} }`}
> >
<div className="flex items-center justify-between mb-0.5"> <div className="mb-1 flex items-center justify-between">
<span className={`text-sm font-semibold ${isActive ? "text-primary" : "text-foreground"}`}> <span className={`text-sm font-semibold ${isActive ? "text-[#6147e8]" : "text-[#111827]"}`}>
{ep.episodeNumber} {ep.episodeNumber}
</span> </span>
{isActive && <Check className="w-3.5 h-3.5 text-primary" />} {isActive && <Check className="h-3.5 w-3.5 text-[#7657ff]" />}
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between text-[11px]">
<span className="text-[11px] text-muted-foreground">{total}个分镜</span> <span className="text-[#475467]">{total}个分镜</span>
<span className={`text-[11px] ${done > 0 ? "text-green-600" : "text-muted-foreground"}`}> <span className={done > 0 ? "text-emerald-600" : "text-[#667085]"}>
{done}/{total}完成 {done}/{total}完成
</span> </span>
</div> </div>
...@@ -171,47 +241,51 @@ export function VideoGeneration() { ...@@ -171,47 +241,51 @@ export function VideoGeneration() {
}) })
)} )}
</div> </div>
</div> </aside>
{/* ─── Right: Main content ─── */} <main className="min-w-0 flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto"> <div className="space-y-5 p-5">
<div className="p-6 space-y-6"> <section className="overflow-hidden rounded-lg border border-[#e4e8f0] bg-white shadow-[0_8px_24px_rgba(15,23,42,0.04)]">
<div className="flex items-start justify-between gap-4 border-b border-[#edf0f5] px-5 py-4">
{/* Section 1: 视频播放器 */}
<div>
<div className="flex items-center justify-between mb-1">
<div> <div>
<h2 className="text-lg font-semibold text-foreground">整集视频</h2> <div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground">点击下方分镜缩略图可切换单镜预览</p> <h2 className="text-lg font-semibold tracking-tight text-[#111827]">整集剪辑台</h2>
<span className="rounded-md bg-[#eef4ff] px-2 py-1 text-[11px] font-medium text-[#1c64ff]">
{playerMode === "assembly" ? "整集时间轴" : "单镜预览"}
</span>
</div>
<p className="mt-1 text-xs text-[#667085]">
合成后可拖动时间轴定位画面,也可以用帧按钮按 1/24 秒逐帧检查。
</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
{/* Assembly controls */}
{hasAssembly && ( {hasAssembly && (
<button <button
onClick={() => setPlayerMode("assembly")} onClick={() => setPlayerMode("assembly")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition ${ className={`inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-xs font-medium transition ${
playerMode === "assembly" playerMode === "assembly"
? "bg-primary text-primary-foreground" ? "bg-[#7657ff] text-white shadow-[0_8px_18px_rgba(118,87,255,0.22)]"
: "border border-border text-foreground hover:bg-muted" : `${CONTROL_SURFACE} text-[#111827] hover:border-[#c7d2e5]`
}`} }`}
> >
<Film className="w-3.5 h-3.5" /> <Film className="h-3.5 w-3.5" />
整集 整集
</button> </button>
)} )}
{(assemblyTask?.status === "running" || assemblyTask?.status === "pending") && ( {(assemblyTask?.status === "running" || assemblyTask?.status === "pending") && (
<span className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground"> <span className="inline-flex items-center gap-1.5 rounded-md bg-[#fff7ed] px-3 py-2 text-xs text-orange-600">
<Loader2 className="w-3.5 h-3.5 animate-spin" />合成中... <Loader2 className="h-3.5 w-3.5 animate-spin" />
合成中
</span> </span>
)} )}
{hasAssembly && assemblyTask?.status !== "running" && assemblyTask?.status !== "pending" && ( {hasAssembly && (
<a <a
href={assemblyTask!.downloadUrl!} href={assemblyTask!.downloadUrl!}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-green-600 text-white text-xs hover:bg-green-700 transition" className="inline-flex items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-2 text-xs font-medium text-white transition hover:bg-emerald-700"
> >
<Download className="w-3.5 h-3.5" /> <Download className="h-3.5 w-3.5" />
下载整集 下载整集
</a> </a>
)} )}
...@@ -219,224 +293,276 @@ export function VideoGeneration() { ...@@ -219,224 +293,276 @@ export function VideoGeneration() {
<button <button
onClick={handleStartAssembly} onClick={handleStartAssembly}
disabled={startAssembly.isPending || doneShots === 0} disabled={startAssembly.isPending || doneShots === 0}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs text-foreground hover:bg-muted transition disabled:opacity-50" className={`inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-xs font-medium text-[#111827] transition hover:border-[#c7d2e5] disabled:opacity-50 ${CONTROL_SURFACE}`}
> >
{startAssembly.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Film className="w-3.5 h-3.5" />} {startAssembly.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Film className="h-3.5 w-3.5" />}
{hasAssembly ? "重新合成" : "合成整集"} {hasAssembly ? "重新合成" : "合成整集"}
</button> </button>
)} )}
</div> </div>
</div> </div>
{/* Player */} <div className="grid gap-5 p-5 xl:grid-cols-[minmax(520px,1fr)_360px]">
<div className="rounded-xl overflow-hidden bg-black relative" style={{ aspectRatio: "16/9", maxHeight: 440 }}> <div className="min-w-0">
{currentVideoUrl ? ( <div className="relative overflow-hidden rounded-lg bg-[#0b111b] shadow-[0_18px_45px_rgba(15,23,42,0.18)]" style={{ aspectRatio: "16 / 9" }}>
<> {currentVideoUrl ? (
{/* Badge */} <>
<div className="absolute top-3 left-3 z-10 bg-black/60 text-white text-xs px-2.5 py-1.5 rounded-lg backdrop-blur-sm"> <div className="absolute left-3 top-3 z-10 rounded-md bg-black/55 px-3 py-2 text-white backdrop-blur-sm">
<div className="text-[10px] opacity-70 leading-none mb-0.5"> <div className="text-[10px] leading-none text-white/65">
{playerMode === "assembly" ? "整集合成" : "分镜预览"} {playerMode === "assembly" ? "整集合成" : "分镜预览"}
</div> </div>
<div className="font-medium">{currentShotLabel}</div> <div className="mt-1 text-xs font-semibold">{playerMode === "shot" ? `分镜 ${String(currentShot?.sb.sequenceNum ?? 1).padStart(3, "0")}` : "完整成片"}</div>
</div> </div>
{/* Duration badge */} <div className="absolute right-3 top-3 z-10 rounded-md bg-black/55 px-3 py-2 font-mono text-xs text-white backdrop-blur-sm">
<div className="absolute top-3 right-3 z-10 bg-black/60 text-white text-xs px-2.5 py-1.5 rounded-lg backdrop-blur-sm font-mono"> {fmt(currentTime)} / {fmt(timelineDuration)}
{fmt(totalDuration)} </div>
</div> <video
<video ref={videoRef}
ref={videoRef} src={currentVideoUrl}
src={currentVideoUrl} className="h-full w-full object-contain"
className="w-full h-full object-contain" onLoadedMetadata={syncMediaState}
onPlay={() => setPlaying(true)} onTimeUpdate={syncMediaState}
onPause={() => setPlaying(false)} onPlay={() => setPlaying(true)}
onEnded={() => { onPause={() => setPlaying(false)}
setPlaying(false); onEnded={() => {
// Auto-advance to next shot in shot mode setPlaying(false);
if (playerMode === "shot" && currentShotIdx < orderedShots.length - 1) { if (playerMode === "shot" && currentShotIdx < orderedShots.length - 1) {
setCurrentShotIdx((i) => i + 1); setCurrentShotIdx((idx) => idx + 1);
} }
}} }}
/> />
{/* Play/Pause overlay */} <button
<button onClick={togglePlay}
onClick={togglePlay} className="absolute inset-0 flex items-center justify-center"
className="absolute inset-0 flex items-center justify-center group" aria-label={playing ? "暂停" : "播放"}
> >
<div className={`w-14 h-14 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center group-hover:bg-white/30 transition ${playing ? "opacity-0 group-hover:opacity-100" : ""}`}> <span className={`flex h-16 w-16 items-center justify-center rounded-full bg-white/18 text-white backdrop-blur-sm transition hover:bg-white/28 ${playing ? "opacity-0 hover:opacity-100" : ""}`}>
{playing {playing ? <Pause className="h-8 w-8" /> : <Play className="ml-1 h-8 w-8" />}
? <Pause className="w-7 h-7 text-white" /> </span>
: <Play className="w-7 h-7 text-white ml-0.5" />} </button>
</>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-3">
<Film className="h-12 w-12 text-white/20" />
<p className="text-sm text-white/55">
{assemblyTask?.status === "running" || assemblyTask?.status === "pending"
? "整集视频合成中..."
: doneShots === 0
? "暂无已完成的分镜视频"
: "整集视频尚未合成,可先查看单镜或点击合成整集"}
</p>
</div> </div>
</button> )}
</>
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-3">
<Film className="w-12 h-12 text-white/20" />
<p className="text-sm text-white/50">
{assemblyTask?.status === "running" || assemblyTask?.status === "pending"
? "整集视频合成中..."
: doneShots === 0
? "暂无已完成的分镜视频"
: "整集视频尚未合成"}
</p>
</div> </div>
)}
</div>
{/* Controls row */} <div className="mt-4 rounded-lg border border-[#e4e8f0] bg-[#fbfcff] p-4">
<div className="flex items-center gap-3 mt-3"> <div className="mb-3 flex items-center justify-between gap-3">
<button <div className="flex items-center gap-2">
onClick={() => { <button
const idx = Math.max(0, currentShotIdx - 1); onClick={() => stepFrame(-1)}
selectShot(idx); disabled={!currentVideoUrl}
}} className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-[#475467] transition hover:text-[#7657ff] disabled:opacity-40 ${CONTROL_SURFACE}`}
disabled={orderedShots.length === 0 || (playerMode === "shot" && currentShotIdx === 0)} title="上一帧"
className="p-1.5 rounded-lg hover:bg-muted transition text-muted-foreground disabled:opacity-30" >
> <SkipBack className="h-4 w-4" />
<SkipBack className="w-4 h-4" /> </button>
</button> <button
<button onClick={togglePlay}
onClick={togglePlay} disabled={!currentVideoUrl}
disabled={!currentVideoUrl} className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-[#7657ff] text-white shadow-[0_8px_18px_rgba(118,87,255,0.24)] transition hover:bg-[#6747f4] disabled:opacity-40"
className="w-8 h-8 rounded-full bg-primary flex items-center justify-center hover:bg-primary/90 transition disabled:opacity-40" title={playing ? "暂停" : "播放"}
> >
{playing {playing ? <Pause className="h-4 w-4" /> : <Play className="ml-0.5 h-4 w-4" />}
? <Pause className="w-4 h-4 text-primary-foreground" /> </button>
: <Play className="w-4 h-4 text-primary-foreground ml-0.5" />} <button
</button> onClick={() => stepFrame(1)}
<button disabled={!currentVideoUrl}
onClick={() => { className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-[#475467] transition hover:text-[#7657ff] disabled:opacity-40 ${CONTROL_SURFACE}`}
const idx = Math.min(orderedShots.length - 1, currentShotIdx + 1); title="下一帧"
selectShot(idx); >
}} <SkipForward className="h-4 w-4" />
disabled={orderedShots.length === 0 || (playerMode === "shot" && currentShotIdx >= orderedShots.length - 1)} </button>
className="p-1.5 rounded-lg hover:bg-muted transition text-muted-foreground disabled:opacity-30" </div>
> <div className="flex items-center gap-3 text-xs text-[#667085]">
<SkipForward className="w-4 h-4" /> <span className="font-mono">Frame {currentFrame}</span>
</button> <span className="font-mono">{fmt(currentTime)} / {fmt(timelineDuration)}</span>
<span className="text-xs text-muted-foreground ml-1"> </div>
{playerMode === "shot" && orderedShots.length > 0 </div>
? `${currentShotIdx + 1} / ${orderedShots.length}`
: `共 ${orderedShots.length} 个分镜`}
</span>
</div>
{/* Thumbnail strip — click to switch player to that shot */} <div className="relative">
{orderedShots.length > 0 && ( <div className="absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-[#e8edf6]" />
<div className="flex gap-2 mt-3 overflow-x-auto pb-1"> <div
{orderedShots.map(({ sb, task }, i) => ( className="absolute left-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-[#7657ff]"
<button style={{ width: `${timelineProgress}%` }}
key={sb.id} />
onClick={() => selectShot(i)} <input
className={`flex-shrink-0 relative rounded-lg overflow-hidden border-2 transition ${ type="range"
playerMode === "shot" && currentShotIdx === i min={0}
? "border-primary" max={Math.max(0.01, timelineDuration)}
: "border-transparent hover:border-border" step={FRAME_STEP}
}`} value={Math.min(currentTime, timelineDuration)}
style={{ width: 100, aspectRatio: "16/9" }} onChange={(event) => seekTo(Number(event.target.value))}
> disabled={!currentVideoUrl}
<video className="relative z-10 h-8 w-full cursor-pointer appearance-none bg-transparent accent-[#7657ff] disabled:cursor-not-allowed disabled:opacity-50"
src={task!.resultVideoUrl!} aria-label="视频时间轴"
className="w-full h-full object-cover"
muted
/> />
<div className="absolute bottom-0 left-0 right-0 flex items-end justify-between px-1.5 py-1 bg-gradient-to-t from-black/70"> </div>
<span className="text-[10px] text-white font-medium">{String(sb.sequenceNum).padStart(3, "0")}</span>
<span className="text-[10px] text-white/80">{sb.durationSeconds}s</span> {shotRanges.length > 0 && (
<div className="mt-4">
<div className="mb-2 flex items-center justify-between text-[11px] text-[#667085]">
<span>分镜轨道</span>
<span>{playerMode === "assembly" ? "拖动查看整集画面" : "单镜逐帧预览"}</span>
</div>
<div className="flex h-12 overflow-hidden rounded-md border border-[#dfe5f2] bg-white">
{shotRanges.map((shot) => {
const width = `${Math.max(7, (shot.duration / Math.max(totalDuration || 1, 1)) * 100)}%`;
const active = playerMode === "assembly"
? activeTimelineShot?.sb.id === shot.sb.id
: currentShot?.sb.id === shot.sb.id;
return (
<button
key={shot.sb.id}
onClick={() => seekAssemblyShot(shot.index)}
className={`relative min-w-[54px] border-r border-white px-2 text-left transition last:border-r-0 ${
active ? "bg-[#7657ff] text-white" : "bg-[#eef4ff] text-[#344054] hover:bg-[#e1e9ff]"
}`}
style={{ width }}
>
<span className="block pt-2 text-[10px] font-semibold tabular-nums">
{String(shot.sb.sequenceNum).padStart(3, "0")}
</span>
<span className={`mt-1 block text-[10px] ${active ? "text-white/75" : "text-[#667085]"}`}>{shot.duration}s</span>
</button>
);
})}
</div>
</div> </div>
</button> )}
))} </div>
</div> </div>
)}
</div>
{/* Section 2: Stats */} <aside className="min-w-0 rounded-lg border border-[#e4e8f0] bg-[#fbfcff] p-4">
<div className="grid grid-cols-4 gap-4"> <div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-[#111827]">镜头索引</h3>
<span className="text-xs text-[#667085]">{orderedShots.length}</span>
</div>
<div className="max-h-[520px] space-y-2 overflow-y-auto pr-1">
{orderedShots.length === 0 ? (
<div className="rounded-md border border-dashed border-[#d9dee8] bg-white px-4 py-8 text-center text-xs text-[#98a2b3]">
暂无可预览分镜
</div>
) : (
orderedShots.map(({ sb, task }, index) => {
const active = playerMode === "shot" && currentShotIdx === index;
return (
<button
key={sb.id}
onClick={() => selectShot(index)}
className={`flex w-full gap-3 rounded-md border p-2 text-left transition ${
active ? "border-[#7657ff] bg-[#f4f0ff]" : "border-[#e4e8f0] bg-white hover:border-[#cfd7e6]"
}`}
>
<div className="relative h-14 w-24 flex-shrink-0 overflow-hidden rounded bg-black">
<video src={task!.resultVideoUrl!} muted className="h-full w-full object-cover" />
<span className="absolute bottom-1 left-1 rounded bg-black/65 px-1.5 py-0.5 text-[10px] text-white">
{String(sb.sequenceNum).padStart(3, "0")}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="line-clamp-2 text-xs font-medium leading-5 text-[#111827]">
{sb.shortDescription || sb.detailedDescription || `分镜 ${sb.sequenceNum}`}
</p>
<p className="mt-1 text-[11px] text-[#667085]">{sb.durationSeconds || task?.videoDuration || 0}s · {timeAgo(task!.createdAt)}</p>
</div>
</button>
);
})
)}
</div>
</aside>
</div>
</section>
<section className="grid grid-cols-2 gap-4 xl:grid-cols-4">
{[ {[
{ label: "总分镜数", value: totalShots, color: "text-foreground" }, { label: "总分镜数", value: totalShots, color: "text-[#111827]" },
{ label: "已完成", value: doneShots, color: "text-green-600" }, { label: "已完成", value: doneShots, color: "text-emerald-600" },
{ label: "生成中", value: pendingShots, color: "text-orange-500" }, { label: "生成中", value: pendingShots, color: "text-orange-500" },
{ label: "总时长", value: fmt(totalDuration), color: "text-foreground" }, { label: "总时长", value: fmt(totalDuration), color: "text-[#111827]" },
].map((s) => ( ].map((stat) => (
<div key={s.label} className="rounded-xl border border-border bg-card p-4"> <div key={stat.label} className="rounded-lg border border-[#e4e8f0] bg-white p-5 shadow-[0_8px_24px_rgba(15,23,42,0.04)]">
<div className={`text-2xl font-bold mb-1 ${s.color}`}>{s.value}</div> <div className={`mb-1 text-3xl font-bold tabular-nums ${stat.color}`}>{stat.value}</div>
<div className="text-xs text-muted-foreground">{s.label}</div> <div className="text-xs text-[#667085]">{stat.label}</div>
</div> </div>
))} ))}
</div> </section>
{/* Section 3: 分镜管理 */} <section className="rounded-lg border border-[#e4e8f0] bg-white p-5 shadow-[0_8px_24px_rgba(15,23,42,0.04)]">
<div> <div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-semibold text-foreground mb-4">分镜管理</h3> <h3 className="text-base font-semibold text-[#111827]">分镜管理</h3>
<span className="text-xs text-[#667085]">{doneShots}/{totalShots} 已完成</span>
</div>
{storyboards.length === 0 ? ( {storyboards.length === 0 ? (
<div className="text-center py-12 text-sm text-muted-foreground"> <div className="py-12 text-center text-sm text-[#98a2b3]">
{!activeEpId ? "请从左侧选择集数" : "该集暂无分镜"} {!activeEpId ? "请从左侧选择集数" : "该集暂无分镜"}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
{storyboards.map((sb) => { {storyboards.map((sb) => {
const task = getTaskForSb(sb); const task = getTaskForSb(sb);
const hasVideo = task?.status === "succeeded" && task.resultVideoUrl; const hasVideo = task?.status === "succeeded" && task.resultVideoUrl;
const isPending = task?.status === "pending" || task?.status === "submitted" || task?.status === "running"; const isPending = task?.status === "pending" || task?.status === "submitted" || task?.status === "running";
return ( return (
<div key={sb.id} className="rounded-xl border border-border bg-card overflow-hidden"> <div key={sb.id} className="overflow-hidden rounded-lg border border-[#e4e8f0] bg-white">
{/* Thumbnail — click plays in top player */}
<button <button
className="relative bg-black w-full" className="relative block w-full bg-black"
style={{ aspectRatio: "16/9" }} style={{ aspectRatio: "16 / 9" }}
onClick={() => { onClick={() => {
if (!hasVideo) return; if (!hasVideo) return;
const idx = orderedShots.findIndex((s) => s.sb.id === sb.id); const idx = orderedShots.findIndex((shot) => shot.sb.id === sb.id);
if (idx >= 0) selectShot(idx); if (idx >= 0) selectShot(idx);
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
}} }}
> >
{hasVideo ? ( {hasVideo ? (
<video <video src={task!.resultVideoUrl!} muted className="h-full w-full object-cover" />
src={task!.resultVideoUrl!}
className="w-full h-full object-cover"
muted
/>
) : isPending ? ( ) : isPending ? (
<div className="w-full h-full flex flex-col items-center justify-center gap-1"> <div className="flex h-full w-full flex-col items-center justify-center gap-2">
<Loader2 className="w-6 h-6 animate-spin text-white/40" /> <Loader2 className="h-6 w-6 animate-spin text-white/45" />
<span className="text-[10px] text-white/40">生成中</span> <span className="text-xs text-white/45">生成中</span>
</div> </div>
) : ( ) : (
<div className="w-full h-full flex items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<Play className="w-8 h-8 text-white/20" /> <Film className="h-8 w-8 text-white/20" />
</div> </div>
)} )}
{/* Badges */} <div className="absolute left-2 top-2 rounded bg-black/60 px-2 py-1 text-[11px] font-medium text-white backdrop-blur-sm">
<div className="absolute top-2 left-2 bg-black/60 text-white text-[11px] font-medium px-1.5 py-0.5 rounded backdrop-blur-sm">
分镜 {String(sb.sequenceNum).padStart(3, "0")} 分镜 {String(sb.sequenceNum).padStart(3, "0")}
</div> </div>
{sb.durationSeconds > 0 && ( <div className="absolute right-2 top-2 rounded bg-black/60 px-2 py-1 text-[11px] text-white backdrop-blur-sm">
<div className="absolute top-2 right-2 bg-black/60 text-white text-[11px] px-1.5 py-0.5 rounded backdrop-blur-sm"> {sb.durationSeconds || task?.videoDuration || 0}s
{sb.durationSeconds}s </div>
</div> <div className={`absolute bottom-2 right-2 h-2 w-2 rounded-full ${
)} hasVideo ? "bg-emerald-400" : isPending ? "animate-pulse bg-orange-400" : "bg-slate-400"
{hasVideo && <div className="absolute bottom-2 right-2 w-2 h-2 rounded-full bg-green-400" />} }`} />
{isPending && <div className="absolute bottom-2 right-2 w-2 h-2 rounded-full bg-orange-400 animate-pulse" />}
{/* Play hint overlay */}
{hasVideo && ( {hasVideo && (
<div className="absolute inset-0 bg-black/0 hover:bg-black/20 flex items-center justify-center opacity-0 hover:opacity-100 transition"> <div className="absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition hover:bg-black/20 hover:opacity-100">
<Play className="w-8 h-8 text-white drop-shadow" /> <Play className="h-8 w-8 text-white drop-shadow" />
</div> </div>
)} )}
</button> </button>
<div className="flex items-center justify-between gap-3 px-3 py-3">
{/* Footer */} <div className="min-w-0">
<div className="px-3 py-2.5 flex items-center justify-between"> <p className="truncate text-xs font-medium text-[#111827]">{sb.shortDescription || `分镜 ${sb.sequenceNum}`}</p>
<span className="text-[11px] text-muted-foreground"> <p className="mt-0.5 text-[11px] text-[#667085]">{task ? `${statusLabel(task.status)} · ${timeAgo(task.createdAt)}` : "未生成"}</p>
{task ? timeAgo(task.createdAt) : "未生成"} </div>
</span> <div className="flex flex-shrink-0 items-center gap-2">
<div className="flex items-center gap-3">
<button <button
onClick={() => navigate(`/project/${pid}/storyboard/${activeEpId}`)} onClick={() => navigate(`/project/${pid}/storyboard/${activeEpId}`)}
className="flex items-center gap-1 text-[11px] text-primary hover:underline" className="inline-flex items-center gap-1 text-[11px] text-[#7657ff] hover:underline"
> >
<Edit2 className="w-3 h-3" /> <Edit2 className="h-3 w-3" />
编辑 编辑
</button> </button>
{hasVideo && ( {hasVideo && (
...@@ -444,9 +570,9 @@ export function VideoGeneration() { ...@@ -444,9 +570,9 @@ export function VideoGeneration() {
href={task!.resultVideoUrl!} href={task!.resultVideoUrl!}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex items-center gap-1 text-[11px] text-foreground hover:text-primary" className="inline-flex items-center gap-1 text-[11px] text-[#111827] hover:text-[#7657ff]"
> >
<Download className="w-3 h-3" /> <Download className="h-3 w-3" />
下载 下载
</a> </a>
)} )}
...@@ -454,9 +580,10 @@ export function VideoGeneration() { ...@@ -454,9 +580,10 @@ export function VideoGeneration() {
<button <button
onClick={() => handleDelete(task.id)} onClick={() => handleDelete(task.id)}
disabled={deleteTask.isPending} disabled={deleteTask.isPending}
className="text-destructive hover:text-red-700 disabled:opacity-40" className="text-red-500 transition hover:text-red-700 disabled:opacity-40"
title="删除视频"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
)} )}
</div> </div>
...@@ -466,10 +593,9 @@ export function VideoGeneration() { ...@@ -466,10 +593,9 @@ export function VideoGeneration() {
})} })}
</div> </div>
)} )}
</div> </section>
</div> </div>
</div> </main>
</div> </div>
); );
} }
...@@ -367,7 +367,8 @@ export function useReorderStoryboards(projectId: string, episodeId: string) { ...@@ -367,7 +367,8 @@ export function useReorderStoryboards(projectId: string, episodeId: string) {
export function useGenerateStoryboardPrompt(projectId: string) { export function useGenerateStoryboardPrompt(projectId: string) {
return useMutation({ return useMutation({
mutationFn: (storyboardId: string) => aiApi.generateStoryboardPrompt(projectId, storyboardId), mutationFn: ({ storyboardId, ratio }: { storyboardId: string; ratio?: string }) =>
aiApi.generateStoryboardPrompt(projectId, storyboardId, ratio),
}); });
} }
......
...@@ -261,8 +261,10 @@ export const aiApi = { ...@@ -261,8 +261,10 @@ export const aiApi = {
reorderStoryboards: async (projectId: string, ids: string[]): Promise<void> => { reorderStoryboards: async (projectId: string, ids: string[]): Promise<void> => {
await apiClient.put(`/projects/${projectId}/storyboards/reorder`, { ids }); await apiClient.put(`/projects/${projectId}/storyboards/reorder`, { ids });
}, },
generateStoryboardPrompt: async (projectId: string, storyboardId: string): Promise<string> => { generateStoryboardPrompt: async (projectId: string, storyboardId: string, ratio?: string): Promise<string> => {
const r = await apiClient.post(`/projects/${projectId}/storyboards/${storyboardId}/generate-prompt`); const r = await apiClient.post(`/projects/${projectId}/storyboards/${storyboardId}/generate-prompt`, null, {
params: ratio ? { ratio } : undefined,
});
return r.data.data.prompt; return r.data.data.prompt;
}, },
listShotAssets: async (projectId: string, storyboardId: string): Promise<ShotAsset[]> => { listShotAssets: async (projectId: string, storyboardId: string): Promise<ShotAsset[]> => {
......
...@@ -6,8 +6,35 @@ $frontendDir = Join-Path $root "doc\html" ...@@ -6,8 +6,35 @@ $frontendDir = Join-Path $root "doc\html"
$adminDir = Join-Path $root "yaoai-admin-web" $adminDir = Join-Path $root "yaoai-admin-web"
$mavenCmd = Join-Path $root ".tools\apache-maven-3.9.6\bin\mvn.cmd" $mavenCmd = Join-Path $root ".tools\apache-maven-3.9.6\bin\mvn.cmd"
$jdk17Candidates = @(
"C:\Program Files\Eclipse Adoptium\jdk-17.0.19.10-hotspot",
"C:\Program Files\Eclipse Adoptium\jdk-17.0.17.10-hotspot",
"C:\Program Files\Eclipse Adoptium\jdk-17",
"C:\Program Files\Java\jdk-17",
"D:\Program Files\Java\jdk-17"
)
$jdk17Home = $jdk17Candidates | Where-Object { Test-Path (Join-Path $_ "bin\java.exe") } | Select-Object -First 1
if (-not $jdk17Home) {
$jdk17Home = Get-ChildItem "C:\Program Files\Eclipse Adoptium" -Directory -Filter "jdk-17*" -ErrorAction SilentlyContinue |
Sort-Object Name -Descending |
Select-Object -First 1 -ExpandProperty FullName
}
if (-not $jdk17Home -or -not (Test-Path (Join-Path $jdk17Home "bin\java.exe"))) {
throw "Java 17 JDK not found. Please install JDK 17 and set JAVA_HOME."
}
$env:JAVA_HOME = $jdk17Home
$env:PATH = "$env:JAVA_HOME\bin;$env:PATH"
if (-not (Test-Path $mavenCmd)) {
$chocoMaven = "C:\ProgramData\chocolatey\lib\maven\apache-maven-3.9.16\bin\mvn.cmd"
if (Test-Path $chocoMaven) {
$mavenCmd = $chocoMaven
}
}
Push-Location $root Push-Location $root
try { try {
Write-Host "[dev] using JAVA_HOME=$env:JAVA_HOME"
Write-Host "[dev] starting infrastructure from yaoai-comic-studio/docker-compose.yml ..." Write-Host "[dev] starting infrastructure from yaoai-comic-studio/docker-compose.yml ..."
docker compose -f (Join-Path $backendDir "docker-compose.yml") up -d docker compose -f (Join-Path $backendDir "docker-compose.yml") up -d
...@@ -24,9 +51,11 @@ try { ...@@ -24,9 +51,11 @@ try {
Write-Host "" Write-Host ""
Write-Host "[dev] next commands" Write-Host "[dev] next commands"
Write-Host " 1. build backend" Write-Host " 1. build backend"
Write-Host " `$env:JAVA_HOME = `"$env:JAVA_HOME`""
Write-Host " `$env:PATH = `"`$env:JAVA_HOME\bin;`$env:PATH`""
Write-Host " & `"$mavenCmd`" -f `"$backendDir\pom.xml`" package -pl yaoai-bootstrap -am -DskipTests" Write-Host " & `"$mavenCmd`" -f `"$backendDir\pom.xml`" package -pl yaoai-bootstrap -am -DskipTests"
Write-Host " 2. run backend" Write-Host " 2. run backend"
Write-Host " java -jar `"$backendDir\yaoai-bootstrap\target\yaoai-bootstrap-0.1.0-SNAPSHOT.jar`"" Write-Host " & `"$env:JAVA_HOME\bin\java.exe`" -jar `"$backendDir\yaoai-bootstrap\target\yaoai-bootstrap-0.1.0-SNAPSHOT.jar`""
Write-Host " 3. run frontend" Write-Host " 3. run frontend"
Write-Host " Set-Location `"$frontendDir`"; npm install; npm run dev" Write-Host " Set-Location `"$frontendDir`"; npm install; npm run dev"
Write-Host " 4. run admin-web" Write-Host " 4. run admin-web"
......
# ===== Stage 1: Build ===== # ===== Stage 1: Build =====
FROM docker.m.daocloud.io/library/maven:3.9.9-eclipse-temurin-17 AS builder ARG MAVEN_IMAGE=maven:3.9-eclipse-temurin-17-alpine
ARG RUNTIME_IMAGE=eclipse-temurin:17-jre-alpine
FROM ${MAVEN_IMAGE} AS builder
WORKDIR /workspace WORKDIR /workspace
...@@ -47,7 +50,7 @@ COPY . . ...@@ -47,7 +50,7 @@ COPY . .
RUN mvn -s /root/.m2/settings.xml package -pl yaoai-bootstrap -am -DskipTests -B -ntp RUN mvn -s /root/.m2/settings.xml package -pl yaoai-bootstrap -am -DskipTests -B -ntp
# ===== Stage 2: Runtime ===== # ===== Stage 2: Runtime =====
FROM docker.m.daocloud.io/library/eclipse-temurin:17-jre-jammy FROM ${RUNTIME_IMAGE}
LABEL maintainer="YaoAI Team <yaoke251@gmail.com>" LABEL maintainer="YaoAI Team <yaoke251@gmail.com>"
LABEL org.opencontainers.image.title="YaoAI Comic Studio" LABEL org.opencontainers.image.title="YaoAI Comic Studio"
...@@ -60,6 +63,7 @@ ENV LANG=C.UTF-8 \ ...@@ -60,6 +63,7 @@ ENV LANG=C.UTF-8 \
ARG APT_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/ubuntu ARG APT_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/ubuntu
RUN set -eux; \ RUN set -eux; \
if command -v apt-get >/dev/null 2>&1; then \
if [ -f /etc/apt/sources.list ]; then \ if [ -f /etc/apt/sources.list ]; then \
sed -i "s|http://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g; s|http://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" /etc/apt/sources.list; \ sed -i "s|http://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g; s|http://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" /etc/apt/sources.list; \
fi; \ fi; \
...@@ -68,10 +72,17 @@ RUN set -eux; \ ...@@ -68,10 +72,17 @@ RUN set -eux; \
fi; \ fi; \
apt-get -o Acquire::Retries=5 update \ apt-get -o Acquire::Retries=5 update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ffmpeg \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*; \
elif command -v apk >/dev/null 2>&1; then \
apk add --no-cache ffmpeg; \
fi
# Non-root user # Non-root user
RUN groupadd -r yaoai && useradd -r -g yaoai yaoai RUN if command -v groupadd >/dev/null 2>&1; then \
groupadd -r yaoai && useradd -r -g yaoai yaoai; \
else \
addgroup -S yaoai && adduser -S -G yaoai yaoai; \
fi
WORKDIR /app WORKDIR /app
......
...@@ -5,9 +5,30 @@ ...@@ -5,9 +5,30 @@
@setlocal @setlocal
@REM ==== START VALIDATION ==== @REM ==== START VALIDATION ====
@REM Prefer Java 17 even when PATH or JAVA_HOME points to an older JDK.
for /d %%J in ("C:\Program Files\Eclipse Adoptium\jdk-17*") do (
if exist "%%~fJ\bin\java.exe" (
set "JAVA_HOME=%%~fJ"
)
)
for /d %%J in ("C:\Program Files\Java\jdk-17*") do (
if exist "%%~fJ\bin\java.exe" (
set "JAVA_HOME=%%~fJ"
)
)
for /d %%J in ("D:\Program Files\Java\jdk-17*") do (
if exist "%%~fJ\bin\java.exe" (
set "JAVA_HOME=%%~fJ"
)
)
if not "%JAVA_HOME%"=="" goto OkJHome if not "%JAVA_HOME%"=="" goto OkJHome
@REM Try common Java 17 locations on this machine @REM Try common Java 17 locations on this machine
if exist "C:\Program Files\Eclipse Adoptium\jdk-17.0.19.10-hotspot\bin\java.exe" (
set "JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-17.0.19.10-hotspot"
goto OkJHome
)
if exist "D:\Program Files\Java\jdk-17\bin\java.exe" ( if exist "D:\Program Files\Java\jdk-17\bin\java.exe" (
set "JAVA_HOME=D:\Program Files\Java\jdk-17" set "JAVA_HOME=D:\Program Files\Java\jdk-17"
goto OkJHome goto OkJHome
...@@ -40,6 +61,14 @@ set _JAVACMD=%JAVA_HOME%\bin\java.exe ...@@ -40,6 +61,14 @@ set _JAVACMD=%JAVA_HOME%\bin\java.exe
set MAVEN_WRAPPER_JAR=%~dp0.mvn\wrapper\maven-wrapper.jar set MAVEN_WRAPPER_JAR=%~dp0.mvn\wrapper\maven-wrapper.jar
set MAVEN_WRAPPER_PROPERTIES=%~dp0.mvn\wrapper\maven-wrapper.properties set MAVEN_WRAPPER_PROPERTIES=%~dp0.mvn\wrapper\maven-wrapper.properties
@REM Use locally installed Maven when the wrapper jar is not checked in.
if not exist "%MAVEN_WRAPPER_JAR%" (
if exist "C:\ProgramData\chocolatey\lib\maven\apache-maven-3.9.16\bin\mvn.cmd" (
call "C:\ProgramData\chocolatey\lib\maven\apache-maven-3.9.16\bin\mvn.cmd" %*
exit /B %ERRORLEVEL%
)
)
@REM Download wrapper jar if missing @REM Download wrapper jar if missing
if not exist "%MAVEN_WRAPPER_JAR%" ( if not exist "%MAVEN_WRAPPER_JAR%" (
echo Downloading Maven Wrapper... echo Downloading Maven Wrapper...
......
...@@ -150,9 +150,10 @@ public class StoryboardController { ...@@ -150,9 +150,10 @@ public class StoryboardController {
@Operation(summary = "AI 生成分镜视频描述提示词") @Operation(summary = "AI 生成分镜视频描述提示词")
@PostMapping("/storyboards/{storyboardId}/generate-prompt") @PostMapping("/storyboards/{storyboardId}/generate-prompt")
public ApiResponse<Map<String, String>> generatePrompt(@PathVariable Long projectId, public ApiResponse<Map<String, String>> generatePrompt(@PathVariable Long projectId,
@PathVariable Long storyboardId) { @PathVariable Long storyboardId,
@RequestParam(required = false) String ratio) {
Long tenantId = TenantContext.get(); Long tenantId = TenantContext.get();
Map<String, String> body = Map.of("prompt", storyboardService.generatePrompt(storyboardId, tenantId)); Map<String, String> body = Map.of("prompt", storyboardService.generatePrompt(storyboardId, tenantId, ratio));
return ApiResponse.success(body); return ApiResponse.success(body);
} }
......
ALTER TABLE storyboards
MODIFY COLUMN camera_direction TEXT DEFAULT NULL,
MODIFY COLUMN composition_guide TEXT DEFAULT NULL;
...@@ -3,6 +3,7 @@ package com.yaoai.domain.mapper; ...@@ -3,6 +3,7 @@ package com.yaoai.domain.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yaoai.domain.entity.AiTask; import com.yaoai.domain.entity.AiTask;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import java.util.List; import java.util.List;
...@@ -11,10 +12,10 @@ import java.util.List; ...@@ -11,10 +12,10 @@ import java.util.List;
public interface AiTaskMapper extends BaseMapper<AiTask> { public interface AiTaskMapper extends BaseMapper<AiTask> {
@Select("SELECT * FROM ai_tasks WHERE project_id=#{projectId} AND tenant_id=#{tenantId} ORDER BY created_at DESC") @Select("SELECT * FROM ai_tasks WHERE project_id=#{projectId} AND tenant_id=#{tenantId} ORDER BY created_at DESC")
List<AiTask> findByProject(Long projectId, Long tenantId); List<AiTask> findByProject(@Param("projectId") Long projectId, @Param("tenantId") Long tenantId);
@Select("SELECT * FROM ai_tasks WHERE episode_id=#{episodeId} ORDER BY created_at DESC LIMIT 1") @Select("SELECT * FROM ai_tasks WHERE episode_id=#{episodeId} ORDER BY created_at DESC LIMIT 1")
AiTask findLatestByEpisode(Long episodeId); AiTask findLatestByEpisode(@Param("episodeId") Long episodeId);
@Select(""" @Select("""
SELECT t.* FROM ai_tasks t SELECT t.* FROM ai_tasks t
...@@ -25,7 +26,7 @@ public interface AiTaskMapper extends BaseMapper<AiTask> { ...@@ -25,7 +26,7 @@ public interface AiTaskMapper extends BaseMapper<AiTask> {
AND t.result_video_url IS NOT NULL AND t.result_video_url IS NOT NULL
ORDER BY COALESCE(s.sequence_num, 9999), t.created_at ORDER BY COALESCE(s.sequence_num, 9999), t.created_at
""") """)
List<AiTask> findSucceededByEpisodeOrdered(Long episodeId, Long tenantId); List<AiTask> findSucceededByEpisodeOrdered(@Param("episodeId") Long episodeId, @Param("tenantId") Long tenantId);
/** 待轮询的视频任务:已提交 Ark 且未到终态。limit 控制单轮处理上限,避免单次扫描挤占线程。 */ /** 待轮询的视频任务:已提交 Ark 且未到终态。limit 控制单轮处理上限,避免单次扫描挤占线程。 */
@Select(""" @Select("""
...@@ -35,7 +36,7 @@ public interface AiTaskMapper extends BaseMapper<AiTask> { ...@@ -35,7 +36,7 @@ public interface AiTaskMapper extends BaseMapper<AiTask> {
ORDER BY created_at ASC ORDER BY created_at ASC
LIMIT #{limit} LIMIT #{limit}
""") """)
List<AiTask> findPollableVideoTasks(int limit); List<AiTask> findPollableVideoTasks(@Param("limit") int limit);
@Select(""" @Select("""
SELECT * FROM ai_tasks SELECT * FROM ai_tasks
...@@ -47,5 +48,8 @@ public interface AiTaskMapper extends BaseMapper<AiTask> { ...@@ -47,5 +48,8 @@ public interface AiTaskMapper extends BaseMapper<AiTask> {
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1 LIMIT 1
""") """)
AiTask findLatestSucceededVideoByStoryboard(Long storyboardId, Long tenantId); AiTask findLatestSucceededVideoByStoryboard(
@Param("storyboardId") Long storyboardId,
@Param("tenantId") Long tenantId
);
} }
...@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; ...@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yaoai.domain.entity.CharacterState; import com.yaoai.domain.entity.CharacterState;
import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import java.util.List; import java.util.List;
...@@ -18,7 +19,10 @@ public interface CharacterStateMapper extends BaseMapper<CharacterState> { ...@@ -18,7 +19,10 @@ public interface CharacterStateMapper extends BaseMapper<CharacterState> {
WHERE cs.project_id=#{projectId} AND cs.tenant_id=#{tenantId} WHERE cs.project_id=#{projectId} AND cs.tenant_id=#{tenantId}
ORDER BY c.created_at ASC, cs.created_at ASC ORDER BY c.created_at ASC, cs.created_at ASC
""") """)
List<CharacterState> findByProject(Long projectId, Long tenantId); List<CharacterState> findByProject(
@Param("projectId") Long projectId,
@Param("tenantId") Long tenantId
);
@Select(""" @Select("""
SELECT cs.*, c.name AS character_name SELECT cs.*, c.name AS character_name
...@@ -29,8 +33,15 @@ public interface CharacterStateMapper extends BaseMapper<CharacterState> { ...@@ -29,8 +33,15 @@ public interface CharacterStateMapper extends BaseMapper<CharacterState> {
AND cs.tenant_id=#{tenantId} AND cs.tenant_id=#{tenantId}
ORDER BY cs.created_at ASC ORDER BY cs.created_at ASC
""") """)
List<CharacterState> findByCharacter(Long projectId, Long characterId, Long tenantId); List<CharacterState> findByCharacter(
@Param("projectId") Long projectId,
@Param("characterId") Long characterId,
@Param("tenantId") Long tenantId
);
@Delete("DELETE FROM character_states WHERE character_id=#{characterId} AND tenant_id=#{tenantId}") @Delete("DELETE FROM character_states WHERE character_id=#{characterId} AND tenant_id=#{tenantId}")
int deleteByCharacter(Long characterId, Long tenantId); int deleteByCharacter(
@Param("characterId") Long characterId,
@Param("tenantId") Long tenantId
);
} }
...@@ -29,7 +29,11 @@ public interface StoryboardPipelineService { ...@@ -29,7 +29,11 @@ public interface StoryboardPipelineService {
/** /**
* AI 根据分镜剧本信息生成视频描述提示词(分时段影视风格) * AI 根据分镜剧本信息生成视频描述提示词(分时段影视风格)
*/ */
String generatePrompt(Long storyboardId, Long tenantId); default String generatePrompt(Long storyboardId, Long tenantId) {
return generatePrompt(storyboardId, tenantId, null);
}
String generatePrompt(Long storyboardId, Long tenantId, String videoRatio);
List<Storyboard> populateMissingVideoPromptsByEpisode(Long episodeId, Long tenantId); List<Storyboard> populateMissingVideoPromptsByEpisode(Long episodeId, Long tenantId);
} }
...@@ -51,6 +51,11 @@ import java.util.stream.Collectors; ...@@ -51,6 +51,11 @@ import java.util.stream.Collectors;
public class StoryboardPipelineServiceImpl implements StoryboardPipelineService { public class StoryboardPipelineServiceImpl implements StoryboardPipelineService {
private static final int STORYBOARD_PROMPT_CONCURRENCY = 3; private static final int STORYBOARD_PROMPT_CONCURRENCY = 3;
private static final int MAX_CONTINUITY_CONTEXT_CHARS = 12000; private static final int MAX_CONTINUITY_CONTEXT_CHARS = 12000;
private static final int PROMPT_REPAIR_MAX_ATTEMPTS = 2;
private static final TypeReference<List<String>> LIST_STRING_TYPE = new TypeReference<>() {};
private static final List<String> SHOT_SHEET_LABELS = List.of(
"### 【镜", "```", "[景别]", "[运镜]", "[构图]", "[画面]", "[灯光]", "[声音]", "[时长]", "[台词]"
);
private static final String JSON_RETRY_SUFFIX = """ private static final String JSON_RETRY_SUFFIX = """
上一轮分镜 JSON 不完整或不可解析。请严格重做: 上一轮分镜 JSON 不完整或不可解析。请严格重做:
...@@ -79,22 +84,43 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -79,22 +84,43 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
- Every shot must stay faithful to the episode script and project asset setup. - Every shot must stay faithful to the episode script and project asset setup.
- If project characters/scenes are provided, each shot should use the most relevant existing refs and not leave refs blank unless the shot truly has no character or location. - If project characters/scenes are provided, each shot should use the most relevant existing refs and not leave refs blank unless the shot truly has no character or location.
- Storyboard fields may contain only shot-level narrative, visual action, camera/composition, dialogue, duration, and prompt content. - Storyboard fields may contain only shot-level narrative, visual action, camera/composition, dialogue, duration, and prompt content.
- When adjacent shots stay in the same scene/location, treat them as one continuous physical moment unless the script explicitly introduces a jump in time or blocking.
- Preserve same-scene continuity of blocking, standing or seated positions, facing directions, wardrobe state, held props, emotional intensity, lighting source direction, color temperature, and shadow pressure.
- Do not reset characters to neutral poses when cutting within the same scene. The new shot should inherit the most recent visible state and continue from it.
- Enforce focus relay across neighboring shots: physical relay, gaze relay, or environmental relay should carry the viewer into the next shot when possible.
- Build continuity by micro-causality, not by repeating the same action text. The next shot must feel caused by the prior shot's final focus.
- Every shot should feel like a director's shot breakdown, not a plot recap.
- Prefer an action chain with 2-4 linked beats inside the shot: trigger -> reaction -> counteraction -> residue.
- Use visible evidence for emotion and conflict: micro-expression, hesitation, trembling fingers, avoided eye contact, prop detail, environment reaction, sound, and lighting.
- In same-scene neighboring shots, do not reinvent room geography. Camera angle may change, but staging continuity must remain trackable.
- Short-drama rhythm is preferred: use 4-8 seconds for most shots, 9-12 seconds only for one continuous action that truly needs three readable beats.
- Keep screen geography stable. If a shot says an object starts on screen-right and a character ends on screen-left, explain the camera move or cut that changes the relationship; do not hide it behind rack focus.
- Rack focus / 拉焦 only changes focus depth inside the same composition. It cannot move a knife, hand, face, or character from left to right. For rack focus, write a same-axis depth relation: foreground object -> background character(s), with stable screen positions.
- If a shot opens on a held object, anchor it immediately with the hand, sleeve, body, or shadow of the holder. Never describe a knife/tool as if it floats without a visible holder.
- Avoid exact micro-distances such as 五公分、三厘米、1.5米 unless the script explicitly requires them and the distance is visually measurable. Prefer visual relations such as "刀尖正对着白布下方胸口位置".
- When a shot contains interception, struggle, hesitation, or impact, motion_script must split the action into compact ordered beats using "...;随即...;最后..." so blocking, reaction, and prop movement do not collapse into one static tableau.
- If the final visible state prepares the next shot, keep it as a visible state inside the current shot. Do not write next-shot speculation or a separate handoff instruction into storyboard fields.
- Let composition_guide explicitly describe shot size and framing in Chinese, for example: 中景(人物全身至膝盖) / 近景(人物胸部以上) / 特写(手部或眼部).
- Let camera_direction explicitly describe movement progression, for example: 手持跟拍(handheld tracking shot) -> 急停急推(quick push-in), 缓慢推进(slow push in) -> 焦点转移(rack focus).
- detailed_description must contain director-grade visual content in Chinese: blocking, performance, prop detail, environment reaction, lighting atmosphere, and emotional subtext.
- motion_script must describe how the shot evolves over time, including action rhythm, camera response, and the current shot's final visible state.
- Never output timestamped timeline segments like "0-2s ..." or "0-2 ...", neither in detailed_description nor in motion_script.
Return only a JSON array. Each item must follow this schema: Return only a JSON array. Each item must follow this schema:
[ [
{ {
"sequence_num": 1, "sequence_num": 1,
"scene_number": "001", "scene_number": "001",
"short_description": "One-sentence shot description in Chinese", "short_description": "One-sentence Chinese shot intention with a clear dramatic beat",
"detailed_description": "Detailed scene description in Chinese", "detailed_description": "Director-grade Chinese shot description with action chain, blocking, expression, props, lighting, atmosphere, and subtext",
"characters": "@character refs separated by commas, e.g. @Alice,@Bob; never put scene refs here", "characters": "@character refs separated by commas, e.g. @Alice,@Bob; never put scene refs here",
"scene_ref": "@existing scene name, e.g. @Cafe", "scene_ref": "@existing scene name, e.g. @Cafe",
"dialogues": "Dialogue content, empty string when absent", "dialogues": "Dialogue content with speaker when present, empty string when absent",
"camera_direction": "Camera movement, e.g. static/push/pull/pan/tracking", "camera_direction": "Explicit Chinese camera movement progression with optional English cue",
"composition_guide": "Composition guidance, e.g. close-up/medium/wide", "composition_guide": "Explicit Chinese shot size and framing",
"duration_seconds": 5, "duration_seconds": 7,
"start_frame_prompt": "English still-frame prompt, under 30 words", "start_frame_prompt": "English still-frame prompt, under 30 words",
"motion_script": "Short Chinese motion description for video generation" "motion_script": "Chinese beat-by-beat shot progression including camera reaction and final visible state"
} }
] ]
Do not include any explanation outside the JSON array. Do not include any explanation outside the JSON array.
...@@ -103,16 +129,103 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -103,16 +129,103 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
private static final String PROMPT_GEN_SYSTEM = """ private static final String PROMPT_GEN_SYSTEM = """
You are a professional storyboard prompt writer. You are a professional storyboard prompt writer.
Based on episode context, character appearance, scene setup, and the current storyboard shot, Based on episode context, character appearance, scene setup, and the current storyboard shot,
turn the shot into a concise multi-segment video-generation prompt. turn the shot into a reasoning-first cinematic video-generation prompt.
Requirements: Requirements:
- Stay faithful to the provided plot and the visual setup. - Stay faithful to the provided plot and the visual setup.
- Treat script facts and the consistency bible as locked continuity. - Treat script facts and the consistency bible as locked continuity.
- Never include production/business metadata such as 类型、集数、每集字数、制作方式、变现、付费解锁. - Never include production/business metadata such as 类型、集数、每集字数、制作方式、变现、付费解锁.
- Respect character appearance, costume, and scene details. - Respect character appearance, costume, and scene details.
- Use 2-4 time segments whose total duration matches the storyboard duration. - Preserve same-scene blocking continuity, wardrobe state, prop state, emotional carry-over, and lighting continuity unless the script clearly motivates a change.
- Each segment should include timing, visuals, dialogue when present, and audio mood. - Do not silently reset standing positions, seated positions, hand occupancy, prop placement, costume state, or light direction between adjacent shots in the same scene.
- Return only the prompt text with no extra explanation. - Enforce focus relay with adjacent shots when they are provided, but use it only as hidden planning context. Do not mention next-shot handoff content in the final video prompt.
- Infer and materialize the hidden dramatic reasoning of the shot: shot objective, power relation, emotional subtext, visible evidence, and why the camera moves this way.
- Expand sparse storyboard text into concrete directorial detail rather than repeating the original sentence.
- The prompt must be shootable. Before writing the final answer, silently audit lens scale, camera movement, spatial geography, visible detail, sound source, light source, period plausibility, and duration budget.
- Match shot size to detail scale. If 景别 is 全景/远景, only describe large readable silhouettes, signage, blocking, and major light areas. Do not describe ice beads, fabric fibers, wrench rotation direction, paper paste texture, finger tremors, or other close-up details unless the shot size changes to 中近景/近景/特写.
- Composition must follow the requested video aspect ratio. Never write a composition that only works in another ratio.
- For 16:9 横屏: favor horizontal spatial relations, left/middle/right staging, readable lateral movement, foreground-middle-background depth, and keep key faces/signage away from the extreme left/right edges.
- For 9:16 竖屏: favor vertical layering, top/middle/bottom anchors, foreground obstruction or doorway/window frames, and keep the key subject in the central safe area. Avoid wide horizontal pans that depend on off-screen left-right geography.
- For 1:1 方形: favor centered or diagonal composition, compact spatial relationships, balanced negative space, and one dominant subject plus one secondary anchor. Avoid long lateral geography.
- In [构图] and [画面], explicitly mention the composition anchor implied by the ratio, for example "横屏左侧标语、中部人物、右侧门缝暖光", "竖屏上方招牌、中部人物、下方道具", or "方形画面中心人物、右下角道具".
- If one shot begins wide and ends closer, write 景别 as a progression, for example "全景起幅 -> 中景落幅". Then only put close detail after the camera has reached that closer framing.
- Use camera terms precisely: pan/摇摄 means the camera rotates from a fixed point; truck/dolly/横移/平移 means the camera physically moves sideways. Never call one movement by the other name. If the shot says "pan right", use "向右摇摄", not "横移".
- Keep geography physically trackable. Do not jump between top-of-pole, street level, face detail, and doorway detail unless the camera tilt/pan/reframe explains the route. A single moving shot should have a clear start anchor, middle anchor, and landing anchor.
- Rack focus / 拉焦 cannot move objects left or right and cannot reveal a subject from a different screen position. It only changes focus depth inside the same composition. If using 拉焦, keep foreground and background on a stable same-axis spatial line.
- For foreground-to-background rack focus, state the depth relationship explicitly: foreground object, who holds it, background subject positions, and what stays in the same screen area after focus changes.
- Never let a prop appear to float. If the shot begins on a held knife/tool/object, include the hand, sleeve, or body anchor in the opening image.
- Avoid exact micro-distances such as 五公分 unless they are script-critical and visually measurable. Prefer visual relation such as "刀尖正对着白布下方胸口位置".
- Make action timing explicit inside [画面] with compact beat markers: "...;随即...;最后...". For 6-8 seconds use 2 beats; for 9-12 seconds use 3 beats. Do not make blocking, reaction, and prop movement happen all at once.
- Limit focus anchors by duration. For 5-8 seconds use at most 2 focus anchors; for 9-12 seconds use at most 3 focus anchors; for 13-15 seconds use at most 4 focus anchors. Remove extra micro-details instead of cramming them into one shot.
- Short-drama pacing is preferred. Use 4-8 seconds and tight 2-beat shots by default. Use 9-12 seconds only when one continuous action truly needs three readable beats.
- Do not invent exact technical specs unless provided by the script or asset context. Avoid unsupported wattage, brand, model, numeric distance, license plate, badge number, or tool measurements. Use plausible descriptive ranges only when necessary.
- Keep 1980s Chinese county-town details physically plausible. Prefer aged paper, dry paste, wind-torn edges, dim incandescent or mercury-vapor public lighting, bicycle repair silhouettes, enamel signs, and worn concrete. Avoid implausibly precise or underpowered public street-lamp specs.
- Every sound in 音效/配乐 must have a visible on-screen source, a clearly off-screen source, or a motivated interior/exterior source. If a sound matters, coordinate it with the action in 画面描述. Do not put sound effects only in 画面描述 and omit them from 音效/配乐.
- Light source relationships must be spatially clear. If there are two sources, describe which side/top/back they come from and how they overlap on the subject. Do not list separate light sources without explaining their interaction.
- Hidden continuity handoff must be concrete visual/audio reasoning, not narrative guessing. Use it to choose the current shot's final visible state, but do not output a handoff/接力 field.
- If the selected reference image or scene setup conflicts with the current shot's location/action, do not force incompatible spatial details into the prompt. Stay faithful to the current storyboard and script; use incompatible references only as loose style/material reference.
- Write the result as one Chinese director shot sheet, not as segmented timeline narration.
- Do not output forms like "0-2s ... / 2-5s ..." or "0-2 ... / 2-5 ...", and do not use "视觉:" / "音频:" as the top-level structure.
- Output in this single editable text format, similar to a director's shot note:
### 【镜1】定场 — 简短镜头目的
```text
[景别] 俯拍 / 全景起幅 -> 中景落幅 / 近景 / 特写,必须与可见细节匹配
[运镜] 固定机位 / 向右摇摄(pan right) / 轨道横移(dolly right) / 缓慢推进
[构图] 按当前画幅写清楚构图锚点,例如横屏左/中/右、竖屏上/中/下、方形中心/对角线
[画面] 120-220字,2-3个可拍摄视觉节拍,像示例一样写成完整电影画面,不要堆字段
[灯光] 主光方向、冷暖关系、阴影压力、多个光源如何叠加
[声音] 有画面来源或明确场外来源的环境声/动作声/音乐情绪
[时长] 7秒
[台词] 无 / 角色名:台词
```
- 景别 must be a specific framing description such as 中景(人物全身至膝盖), 近景(人物胸部以上), 特写(手部或眼部), or 全景起幅 -> 中景落幅 when the shot scale changes.
- 运镜 must use exact camera language and one movement family unless a motivated transition is necessary, for example 固定机位向右摇摄(pan right) or 轨道向右平移(dolly/truck right).
- 画面描述 must be the longest part but concise: 120-220 Chinese characters, 2-3 linked visual beats, no more than the allowed focus anchors for the duration, and no details invisible at the stated shot size.
- 灯光氛围 must explain light source direction, color contrast, shadow pressure, and overlap between sources when more than one source exists.
- 音效/配乐 must prioritize concrete diegetic sound first, synchronize sounds with visible/off-screen action, and avoid unmotivated extra noises.
- 时长 must be written as Arabic number plus "", matching the storyboard duration.
- 台词 must include speaker names when dialogue exists; write "" when no dialogue is needed.
- Keep all bracket labels inside one ```text fenced block so the user can freely edit or paste the whole prompt as text.
- Output only the heading and fenced text block. No extra explanation outside them.
""";
private static final String PROMPT_GEN_REPAIR_SYSTEM = """
You are repairing an invalid storyboard prompt output.
Rewrite it into exactly one Chinese director shot sheet for the current shot.
Hard output rules:
- Use exactly this editable director-note format:
### 【镜1】定场 — 简短镜头目的
```text
[景别] ...
[运镜] ...
[构图] ...
[画面] ...
[灯光] ...
[声音] ...
[时长] ...
[台词] ...
```
- Do not output any timestamped segments such as 0-2s, 2-4s, 4-6s, 0-2秒, 2-4秒, 4-6秒.
- Do not use labels like 视觉: or 音频: as the top-level structure.
- Keep 画面描述 as the longest section but concise: 120-220 Chinese characters, 2-3 visible beats, and no detail invisible at the stated 景别.
- Repair lens-scale contradictions: 全景/远景 cannot show tiny beads, fabric fibers, exact wrench rotation, finger tremors, paste texture, or printed-paper microdetail. Either change 景别 to a progression ending closer, or remove the microdetails.
- Repair camera terminology: pan/摇摄 is fixed-point rotation; truck/dolly/横移/平移 is physical side movement. Use one correct term consistently.
- Repair rack-focus spatial errors: 拉焦 only changes focus depth inside one stable composition; it cannot move a knife/object from screen right to screen left or reveal a person in a different position without a reframing/cut.
- For rack focus, rewrite into same-axis depth when possible: foreground held object with visible hand/sleeve/body anchor -> background character positions, while screen-left/screen-right positions stay stable.
- If a knife/tool/object is described in close-up, include who holds it through visible fingers, sleeve, hand shadow, or body relation. Never leave it visually floating.
- Remove exact micro-distances such as 五公分、三厘米、1.5米 unless they are explicitly supplied by source context and visible in frame.
- Repair unclear action timing by writing compact sequence words inside [画面], such as "...;随即...;最后...".
- Repair focus overload: 5-8s max 2 focus anchors, 9-12s max 3, 13-15s max 4. Delete extras.
- Repair physical/period implausibility: do not invent exact wattage, brand, model, numeric distances, or unlikely 1980s public-street details unless source text explicitly provides them.
- Repair audio-visual mismatch: every listed sound must correspond to an action or motivated off-screen source.
- Remove any 镜头接力 / [接力] / next-shot handoff field from the final output. Handoff is hidden planning only and must not affect the current video prompt.
- Preserve same-scene continuity from adjacent-shot context: blocking, wardrobe state, held props, emotional carry-over, and lighting logic must not reset.
- Keep the shot faithful to the supplied storyboard context; do not invent conflicting plot.
- Keep all bracket labels inside one ```text fenced block so the user can freely edit or paste the whole prompt as text.
- Output only the heading and fenced text block. No extra explanation outside them.
"""; """;
private final LlmService llmService; private final LlmService llmService;
...@@ -127,6 +240,7 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -127,6 +240,7 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
private final BillingService billingService; private final BillingService billingService;
@Override @Override
@Transactional
public List<Storyboard> generateStoryboards(Long episodeId, Long projectId, Long tenantId) { public List<Storyboard> generateStoryboards(Long episodeId, Long projectId, Long tenantId) {
Episode episode = episodeMapper.selectById(episodeId); Episode episode = episodeMapper.selectById(episodeId);
if (episode == null) { if (episode == null) {
...@@ -149,6 +263,13 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -149,6 +263,13 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
nullSafe(episode.getTitle()), nullSafe(episode.getTitle()),
cleanStoryboardText(episodeContent) cleanStoryboardText(episodeContent)
); );
userMsg += """
Shot count requirement:
- Generate a complete storyboard sequence for the whole episode, normally 8-12 shots for a 60-second short-drama episode.
- Never collapse the whole episode into one summary shot.
- Each shot must represent one concrete visual beat with its own camera/composition/duration.
""";
log.info("Generating storyboards: episodeId={}, projectId={}", episodeId, projectId); log.info("Generating storyboards: episodeId={}, projectId={}", episodeId, projectId);
...@@ -278,6 +399,12 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -278,6 +399,12 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
@Override @Override
@Transactional @Transactional
public String generatePrompt(Long storyboardId, Long tenantId) { public String generatePrompt(Long storyboardId, Long tenantId) {
return generatePrompt(storyboardId, tenantId, null);
}
@Override
@Transactional
public String generatePrompt(Long storyboardId, Long tenantId, String videoRatio) {
Storyboard storyboard = storyboardMapper.selectById(storyboardId); Storyboard storyboard = storyboardMapper.selectById(storyboardId);
if (storyboard == null || !tenantId.equals(storyboard.getTenantId())) { if (storyboard == null || !tenantId.equals(storyboard.getTenantId())) {
throw new BizException(ErrorCode.NOT_FOUND, "Storyboard not found"); throw new BizException(ErrorCode.NOT_FOUND, "Storyboard not found");
...@@ -303,7 +430,7 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -303,7 +430,7 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
List<Character> characters = characterMapper.findByProject(storyboard.getProjectId(), tenantId); List<Character> characters = characterMapper.findByProject(storyboard.getProjectId(), tenantId);
List<Scene> scenes = sceneMapper.findByProject(storyboard.getProjectId(), tenantId); List<Scene> scenes = sceneMapper.findByProject(storyboard.getProjectId(), tenantId);
StoryboardRefs promptRefs = promptRefsForCurrentShot(storyboard, scenes); StoryboardRefs promptRefs = promptRefsForCurrentShot(storyboard, characters, scenes);
List<Character> contextCharacters = referencedCharacters(characters, promptRefs.characters()); List<Character> contextCharacters = referencedCharacters(characters, promptRefs.characters());
List<Scene> contextScenes = referencedScenes(scenes, promptRefs.sceneRef()); List<Scene> contextScenes = referencedScenes(scenes, promptRefs.sceneRef());
...@@ -335,6 +462,12 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -335,6 +462,12 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
userMsg.append("\n"); userMsg.append("\n");
} }
String shotContinuityContext = buildShotContinuityContext(storyboard, tenantId);
if (hasText(shotContinuityContext)) {
userMsg.append(shotContinuityContext).append("\n\n");
}
userMsg.append(buildAspectRatioCompositionContext(videoRatio)).append("\n\n");
userMsg.append(String.format( userMsg.append(String.format(
"Current storyboard shot:%n" + "Current storyboard shot:%n" +
"- Sequence: %d%n" + "- Sequence: %d%n" +
...@@ -357,13 +490,6 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -357,13 +490,6 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
storyboard.getDurationSeconds() != null ? storyboard.getDurationSeconds() : 5 storyboard.getDurationSeconds() != null ? storyboard.getDurationSeconds() : 5
)); ));
ChatRequest request = ChatRequest.builder()
.messages(List.of(
ChatMessage.system(PROMPT_GEN_SYSTEM),
ChatMessage.user(userMsg.toString())
))
.build();
log.info( log.info(
"Generating storyboard prompt: storyboardId={}, episodeId={}, characters={}, scenes={}", "Generating storyboard prompt: storyboardId={}, episodeId={}, characters={}, scenes={}",
storyboardId, storyboardId,
...@@ -385,7 +511,7 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -385,7 +511,7 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
.refId(String.valueOf(storyboardId)) .refId(String.valueOf(storyboardId))
.build()); .build());
String prompt = cleanStoryboardText(llmService.chat(request)); String prompt = generatePromptWithFormatGuard(storyboard, userMsg.toString());
billingService.charge(BillingChargeRequest.builder() billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId) .tenantId(tenantId)
...@@ -410,11 +536,49 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -410,11 +536,49 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
return prompt; return prompt;
} }
private String generatePromptWithFormatGuard(Storyboard storyboard, String userMessage) {
String prompt = cleanStoryboardText(llmService.chat(ChatRequest.builder()
.messages(List.of(
ChatMessage.system(PROMPT_GEN_SYSTEM),
ChatMessage.user(userMessage)
))
.build()));
if (isValidShotSheetPrompt(prompt)) {
return prompt;
}
String invalidPrompt = prompt;
for (int attempt = 1; attempt <= PROMPT_REPAIR_MAX_ATTEMPTS; attempt++) {
log.warn("Storyboard prompt format invalid, retrying repair: storyboardId={}, attempt={}",
storyboard.getId(), attempt);
prompt = cleanStoryboardText(llmService.chat(ChatRequest.builder()
.messages(List.of(
ChatMessage.system(PROMPT_GEN_REPAIR_SYSTEM),
ChatMessage.user(userMessage + """
Invalid previous output:
""" + invalidPrompt)
))
.build()));
if (isValidShotSheetPrompt(prompt)) {
return prompt;
}
invalidPrompt = prompt;
}
log.warn("Storyboard prompt still invalid after repair, using local shot-sheet fallback: storyboardId={}",
storyboard.getId());
return buildFallbackShotSheet(storyboard, invalidPrompt);
}
@Override @Override
public List<Storyboard> populateMissingVideoPromptsByEpisode(Long episodeId, Long tenantId) { public List<Storyboard> populateMissingVideoPromptsByEpisode(Long episodeId, Long tenantId) {
List<Storyboard> pending = storyboardMapper.findMissingVideoPromptByEpisode(episodeId, tenantId); List<Storyboard> allStoryboards = storyboardMapper.findByEpisode(episodeId, tenantId);
List<Storyboard> pending = allStoryboards.stream()
.filter(this::shouldRefreshVideoPrompt)
.toList();
if (pending.isEmpty()) { if (pending.isEmpty()) {
return storyboardMapper.findByEpisode(episodeId, tenantId); return allStoryboards;
} }
Long userId = UserContext.get(); Long userId = UserContext.get();
...@@ -448,6 +612,163 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -448,6 +612,163 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
return storyboardMapper.findByEpisode(episodeId, tenantId); return storyboardMapper.findByEpisode(episodeId, tenantId);
} }
private boolean shouldRefreshVideoPrompt(Storyboard storyboard) {
String prompt = nullSafe(storyboard.getVideoPrompt()).trim();
if (prompt.isBlank()) {
return true;
}
return isLegacySegmentedPrompt(prompt);
}
private boolean isValidShotSheetPrompt(String prompt) {
if (!hasText(prompt) || isLegacySegmentedPrompt(prompt)) {
return false;
}
return SHOT_SHEET_LABELS.stream().allMatch(prompt::contains)
&& !hasShotGrammarSmell(prompt);
}
private boolean hasShotGrammarSmell(String prompt) {
String normalized = nullSafe(prompt).toLowerCase(Locale.ROOT);
if (normalized.length() > 1800) {
return true;
}
if (prompt.contains("[接力]") || prompt.contains("镜头接力:") || prompt.contains("镜头接力")) {
return true;
}
if (prompt.matches("(?s).*\\d+(?:\\.\\d+)?\\s*(?:公分|厘米|cm|CM).*")) {
return true;
}
if (prompt.contains("拉焦")
&& (prompt.matches("(?s).*起幅[^\\n。;;]*(?:右侧|右边)[^\\n。;;]*落幅[^\\n。;;]*(?:左侧|左边).*")
|| prompt.matches("(?s).*起幅[^\\n。;;]*(?:左侧|左边)[^\\n。;;]*落幅[^\\n。;;]*(?:右侧|右边).*"))) {
return true;
}
if ((prompt.contains("手术刀") || prompt.contains("刀柄") || prompt.contains("刀尖"))
&& prompt.contains("拉焦")
&& !(prompt.contains("手") || prompt.contains("指节") || prompt.contains("袖口") || prompt.contains("攥") || prompt.contains("握"))) {
return true;
}
if ((prompt.contains("横移") || prompt.contains("平移"))
&& (normalized.contains("pan right") || normalized.contains("pan left")
|| prompt.contains("摇摄") || prompt.contains("摇镜"))) {
return true;
}
if (prompt.matches("(?s).*\\d+\\s*[wW瓦].*")) {
return true;
}
boolean wideShot = prompt.contains("景别:全景") || prompt.contains("景别:远景");
boolean microscopicDetail = prompt.contains("冰珠")
|| prompt.contains("起毛")
|| prompt.contains("顺时针")
|| prompt.contains("逆时针")
|| prompt.contains("纤维")
|| prompt.contains("指尖")
|| prompt.contains("裂纹");
return wideShot && microscopicDetail && !prompt.contains("中景落幅") && !prompt.contains("近景落幅");
}
private boolean isLegacySegmentedPrompt(String prompt) {
if (!hasText(prompt)) {
return false;
}
String normalized = prompt.toLowerCase(Locale.ROOT);
if (normalized.contains("light-color mood")
|| normalized.contains("sound-music design")
|| normalized.contains("emotional subtext")
|| normalized.contains("next-shot relay anchor")) {
return true;
}
if (prompt.matches("(?s).*\\d+\\s*-\\s*\\d+\\s*(?:s|秒)[::]?.*")) {
return true;
}
if (prompt.matches("(?s).*【\\s*镜头\\s*\\d+\\s*】.*")
|| prompt.matches("(?s).*\\[\\s*镜头\\s*\\d+\\s*].*")) {
return true;
}
return prompt.contains("视觉:") && prompt.contains("音频:");
}
private String buildFallbackShotSheet(Storyboard storyboard, String invalidPrompt) {
String raw = nullSafe(invalidPrompt).trim();
String visual = extractSegmentContent(raw, "视觉:");
String audio = extractSegmentContent(raw, "音频:");
String pictureBody = hasText(visual)
? visual
: firstNonBlank(
cleanStoryboardText(storyboard.getDetailedDescription()),
cleanStoryboardText(storyboard.getShortDescription()),
raw
);
String audioBody = hasText(audio)
? audio
: "以现场环境音为主,避免空泛配乐描述。";
return """
### 【镜%s】%s — 视频生成提示
```text
[景别] %s
[运镜] %s
[构图] 按当前画幅保留主体安全区,明确前景、中景、背景的空间锚点。
[画面] %s
[灯光] %s
[声音] %s
[时长] %s秒
[台词] %s
```
""".formatted(
storyboard.getSequenceNum() != null ? storyboard.getSequenceNum() : 1,
firstNonBlank(cleanStoryboardText(storyboard.getShortDescription()), "当前分镜"),
firstNonBlank(cleanStoryboardText(storyboard.getCompositionGuide()), "中景(人物全身至膝盖)"),
firstNonBlank(cleanStoryboardText(storyboard.getCameraDirection()), "缓慢推进(slow push in)"),
pictureBody,
inferLightingFallback(storyboard),
audioBody,
storyboard.getDurationSeconds() != null ? storyboard.getDurationSeconds() : 5,
firstNonBlank(cleanStoryboardText(storyboard.getDialogues()), "无")
).trim();
}
private String extractSegmentContent(String prompt, String label) {
if (!hasText(prompt) || !prompt.contains(label)) {
return "";
}
return Arrays.stream(prompt.split("\\R"))
.map(String::trim)
.filter(line -> line.contains(label))
.map(line -> {
int start = line.indexOf(label) + label.length();
int next = line.indexOf(";", start);
if (next < 0) {
next = line.indexOf(";", start);
}
return next > start ? line.substring(start, next).trim() : line.substring(start).trim();
})
.filter(this::hasText)
.collect(Collectors.joining(" "));
}
private String inferLightingFallback(Storyboard storyboard) {
String description = cleanStoryboardText(storyboard.getDetailedDescription());
if (hasText(description)) {
return "沿用当前场景中已出现的主光源与冷暖关系,让光线服务人物压迫感与情绪变化。";
}
return "保持场景既有光线逻辑,用明暗对比承托当前情绪。";
}
private String lastSentence(String value) {
if (!hasText(value)) {
return "";
}
String[] parts = value.split("[。!?!?]");
for (int i = parts.length - 1; i >= 0; i--) {
if (hasText(parts[i])) {
return parts[i].trim();
}
}
return value.trim();
}
private String buildAssetContext(Long projectId, Long tenantId) { private String buildAssetContext(Long projectId, Long tenantId) {
return buildAssetContext(projectId, tenantId, return buildAssetContext(projectId, tenantId,
characterMapper.findByProject(projectId, tenantId), characterMapper.findByProject(projectId, tenantId),
...@@ -534,6 +855,111 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -534,6 +855,111 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
return builder.toString().trim(); return builder.toString().trim();
} }
private String buildShotContinuityContext(Storyboard storyboard, Long tenantId) {
if (storyboard.getEpisodeId() == null) {
return "";
}
List<Storyboard> episodeShots = storyboardMapper.findByEpisode(storyboard.getEpisodeId(), tenantId);
if (episodeShots.isEmpty()) {
return "";
}
Storyboard previous = null;
Storyboard next = null;
for (int i = 0; i < episodeShots.size(); i++) {
Storyboard current = episodeShots.get(i);
if (!Objects.equals(current.getId(), storyboard.getId())) {
continue;
}
previous = i > 0 ? episodeShots.get(i - 1) : null;
next = i + 1 < episodeShots.size() ? episodeShots.get(i + 1) : null;
break;
}
if (previous == null && next == null) {
return "";
}
StringBuilder builder = new StringBuilder("Shot continuity relay / 分镜焦点接力:\n");
if (previous != null) {
builder.append("- Previous shot ending focus:\n")
.append(describeShotForRelay(previous));
if (sameScene(previous, storyboard)) {
builder.append("- Previous shot shares the same scene/location with current shot. Preserve visible continuity of blocking, wardrobe state, held props, emotional level, and light logic unless the script explicitly changes them.\n");
}
}
builder.append("- Current shot must preserve one relay anchor from the previous shot when possible. Choose the most natural relay type: physical / gaze / environmental.\n");
builder.append("- Same-scene continuity checklist for current shot: standing/seated positions, facing directions, body distance, costume state, hand occupancy, prop placement, emotional carry-over, light source direction and color temperature.\n");
if (next != null) {
builder.append("- Next shot handoff target:\n")
.append(describeShotForRelay(next))
.append("- Current shot should end on a relay anchor that can naturally hand off to the next shot.\n");
if (sameScene(next, storyboard)) {
builder.append("- Next shot is in the same scene/location, so hand off a trackable continuity state rather than a reset tableau.\n");
}
}
return builder.toString().trim();
}
private String buildAspectRatioCompositionContext(String videoRatio) {
String ratio = normalizeVideoRatio(videoRatio);
StringBuilder builder = new StringBuilder("Aspect ratio composition contract / 画幅构图约束:\n");
builder.append("- Requested video ratio: ").append(ratio).append("\n");
builder.append("- The prompt must make composition choices that work for this exact ratio. Do not write a shot that only reads in another frame.\n");
switch (ratio) {
case "9:16" -> builder.append("""
- 竖屏构图:优先上/中/下层次、门框/窗框/前景遮挡形成纵深,主体放在中央安全区。
- 避免依赖大范围左右横摇来交代空间;如果必须移动,使用轻微推进、俯仰或纵深调度。
- 画面描述要写清楚竖屏锚点,例如:上方招牌/灯源 -> 中部人物表演 -> 下方道具或脚步。
- 重要人物脸、字幕式标语、门缝光不要贴近上下边缘,给头顶和脚底留安全空间。
""");
case "1:1" -> builder.append("""
- 方形构图:优先中心权重、对角线关系、紧凑空间和均衡留白。
- 一镜最多一个主视觉中心加一个次级锚点;避免横向长空间或竖向过高空间。
- 画面描述要写清楚方形锚点,例如:中心人物/物件 -> 右下角道具 -> 左上角光源。
- 运动宜小而稳定:轻推、轻拉、焦点转移、微调构图,不要长距离横移/摇摄。
""");
default -> builder.append("""
- 横屏构图:优先左/中/右空间关系、横向动线、前景-中景-背景层次。
- 横向摇摄或平移可以使用,但必须明确是 pan/摇摄 还是 dolly/truck/横移。
- 画面描述要写清楚横屏锚点,例如:左侧时代标语 -> 中部人物轮廓 -> 右侧门缝暖光。
- 关键人物脸、标语、门缝光不要贴极端左右边缘,保留横屏安全区和运动余量。
""");
}
return builder.toString().trim();
}
private String normalizeVideoRatio(String videoRatio) {
if ("9:16".equals(videoRatio) || "1:1".equals(videoRatio)) {
return videoRatio;
}
return "16:9";
}
private String describeShotForRelay(Storyboard shot) {
return String.format(
" sequence=%d, short=%s, detail=%s, characters=%s, scene=%s, camera=%s, composition=%s, motion=%s, dialogues=%s%n",
shot.getSequenceNum() != null ? shot.getSequenceNum() : 0,
cleanStoryboardText(shot.getShortDescription()),
cleanStoryboardText(shot.getDetailedDescription()),
nullSafe(shot.getCharacters()),
nullSafe(shot.getSceneRef()),
cleanStoryboardText(shot.getCameraDirection()),
cleanStoryboardText(shot.getCompositionGuide()),
cleanStoryboardText(shot.getMotionScript()),
cleanStoryboardText(shot.getDialogues())
);
}
private boolean sameScene(Storyboard a, Storyboard b) {
if (a == null || b == null) {
return false;
}
String sceneA = normalizeMatchText(a.getSceneRef());
String sceneB = normalizeMatchText(b.getSceneRef());
return hasText(sceneA) && sceneA.equals(sceneB);
}
private String cleanStoryboardText(String value) { private String cleanStoryboardText(String value) {
if (value == null || value.isBlank()) { if (value == null || value.isBlank()) {
return ""; return "";
...@@ -928,7 +1354,9 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -928,7 +1354,9 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
return new StoryboardRefs(joinRefs(characterRefs), sceneRef); return new StoryboardRefs(joinRefs(characterRefs), sceneRef);
} }
private StoryboardRefs promptRefsForCurrentShot(Storyboard storyboard, List<Scene> projectScenes) { private StoryboardRefs promptRefsForCurrentShot(Storyboard storyboard,
List<Character> projectCharacters,
List<Scene> projectScenes) {
Set<String> sceneRefs = projectScenes.stream() Set<String> sceneRefs = projectScenes.stream()
.map(Scene::getName) .map(Scene::getName)
.filter(Objects::nonNull) .filter(Objects::nonNull)
...@@ -937,10 +1365,54 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -937,10 +1365,54 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
.map(name -> "@" + name) .map(name -> "@" + name)
.collect(Collectors.toCollection(LinkedHashSet::new)); .collect(Collectors.toCollection(LinkedHashSet::new));
StoryboardRefs fallbackRefs = splitStoryboardRefs(storyboard.getCharacters(), sceneRefs); StoryboardRefs fallbackRefs = splitStoryboardRefs(storyboard.getCharacters(), sceneRefs);
String sceneRef = storyboard.getSceneRef() != null && !storyboard.getSceneRef().isBlank() String selectedCharacterRefs = selectedCharacterRefsByImageKeys(projectCharacters, storyboard.getCharacterImageKeys());
String selectedSceneRef = selectedSceneRefByImageKey(projectScenes, storyboard.getSceneImageKey());
String sceneRef = hasText(selectedSceneRef)
? selectedSceneRef
: storyboard.getSceneRef() != null && !storyboard.getSceneRef().isBlank()
? storyboard.getSceneRef() ? storyboard.getSceneRef()
: fallbackRefs.sceneRef(); : fallbackRefs.sceneRef();
return new StoryboardRefs(fallbackRefs.characters(), sceneRef); return new StoryboardRefs(hasText(selectedCharacterRefs) ? selectedCharacterRefs : fallbackRefs.characters(), sceneRef);
}
private String selectedCharacterRefsByImageKeys(List<Character> projectCharacters, String characterImageKeysJson) {
List<String> selectedKeys = parseStringList(characterImageKeysJson);
if (selectedKeys.isEmpty() || projectCharacters == null || projectCharacters.isEmpty()) {
return "";
}
return projectCharacters.stream()
.filter(character -> hasText(character.getName()))
.filter(character -> selectedKeys.contains(primaryCharacterImageKey(character)))
.map(character -> "@" + character.getName().trim())
.distinct()
.collect(Collectors.joining(","));
}
private String selectedSceneRefByImageKey(List<Scene> projectScenes, String sceneImageKey) {
if (!hasText(sceneImageKey) || projectScenes == null || projectScenes.isEmpty()) {
return "";
}
return projectScenes.stream()
.filter(scene -> hasText(scene.getName()))
.filter(scene -> sceneImageKey.equals(scene.getImageTosKey()))
.map(scene -> "@" + scene.getName().trim())
.findFirst()
.orElse("");
}
private List<String> parseStringList(String json) {
if (!hasText(json)) {
return List.of();
}
try {
return objectMapper.readValue(json, LIST_STRING_TYPE).stream()
.filter(this::hasText)
.distinct()
.toList();
} catch (Exception e) {
log.warn("Failed to parse storyboard string list: {}", e.getMessage());
return List.of();
}
} }
private List<String> parseRefs(String rawRefs) { private List<String> parseRefs(String rawRefs) {
...@@ -980,6 +1452,10 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -980,6 +1452,10 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
|| hasChanged(patch.getDetailedDescription(), existing.getDetailedDescription()) || hasChanged(patch.getDetailedDescription(), existing.getDetailedDescription())
|| hasChanged(patch.getCharacters(), existing.getCharacters()) || hasChanged(patch.getCharacters(), existing.getCharacters())
|| hasChanged(patch.getSceneRef(), existing.getSceneRef()) || hasChanged(patch.getSceneRef(), existing.getSceneRef())
|| hasChanged(patch.getCharacterImageKeys(), existing.getCharacterImageKeys())
|| hasChanged(patch.getSceneImageKey(), existing.getSceneImageKey())
|| hasChanged(patch.getPropImageKeys(), existing.getPropImageKeys())
|| hasChanged(patch.getStyleImageKey(), existing.getStyleImageKey())
|| hasChanged(patch.getDialogues(), existing.getDialogues()) || hasChanged(patch.getDialogues(), existing.getDialogues())
|| hasChanged(patch.getCameraDirection(), existing.getCameraDirection()) || hasChanged(patch.getCameraDirection(), existing.getCameraDirection())
|| hasChanged(patch.getCompositionGuide(), existing.getCompositionGuide()) || hasChanged(patch.getCompositionGuide(), existing.getCompositionGuide())
......
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