Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
Y
yaoai-video
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
姚珂
yaoai-video
Commits
787ae772
Commit
787ae772
authored
Jun 03, 2026
by
yaoke.yk
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
客户端加入首未帧和道具修改
parent
232de8ef
Show whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
411 additions
and
17 deletions
+411
-17
StoryboardWorkspace.tsx
doc/html/src/app/pages/StoryboardWorkspace.tsx
+222
-7
useAi.ts
doc/html/src/hooks/useAi.ts
+9
-0
ai.ts
doc/html/src/lib/api/ai.ts
+21
-0
SeedancePromptBuilder.java
...va/com/yaoai/ai/providers/util/SeedancePromptBuilder.java
+46
-3
StoryboardController.java
...n/java/com/yaoai/api/controller/StoryboardController.java
+50
-0
VideoTaskController.java
...in/java/com/yaoai/api/controller/VideoTaskController.java
+6
-2
StoryboardDTO.java
...api/src/main/java/com/yaoai/api/dto/ai/StoryboardDTO.java
+8
-0
StructuredVideoGenerateRequest.java
.../com/yaoai/api/dto/ai/StructuredVideoGenerateRequest.java
+6
-0
V21__storyboard_frame_reference_images.sql
...s/db/migration/V21__storyboard_frame_reference_images.sql
+11
-0
Storyboard.java
...ain/src/main/java/com/yaoai/domain/entity/Storyboard.java
+4
-0
StructuredVideoSubmission.java
...ava/com/yaoai/pipeline/dto/StructuredVideoSubmission.java
+4
-0
VideoTaskPipelineServiceImpl.java
...i/pipeline/service/impl/VideoTaskPipelineServiceImpl.java
+24
-5
No files found.
doc/html/src/app/pages/StoryboardWorkspace.tsx
View file @
787ae772
import
{
useState
,
useEffect
,
useMemo
,
useRef
}
from
"react"
;
import
{
useState
,
useEffect
,
useMemo
,
useRef
,
type
ChangeEvent
}
from
"react"
;
import
{
useParams
,
useNavigate
}
from
"react-router"
;
import
{
useParams
,
useNavigate
}
from
"react-router"
;
import
{
import
{
Plus
,
Wand2
,
Play
,
Trash2
,
Plus
,
Wand2
,
Play
,
Trash2
,
...
@@ -16,6 +16,7 @@ import {
...
@@ -16,6 +16,7 @@ import {
useStartAssembly
,
useStartAssembly
,
useGenerateStructuredVideo
,
useGenerateStructuredVideo
,
useUpdateStoryboard
,
useUpdateStoryboard
,
useUploadStoryboardFrameImage
,
useVideoTasks
,
useVideoTasks
,
useCharacters
,
useCharacters
,
useAllCharacterStates
,
useAllCharacterStates
,
...
@@ -61,6 +62,10 @@ interface SbDraft {
...
@@ -61,6 +62,10 @@ interface SbDraft {
characterKeys
:
string
[];
characterKeys
:
string
[];
sceneKey
:
string
;
sceneKey
:
string
;
propKeys
:
string
[];
propKeys
:
string
[];
firstFrameImageKey
:
string
;
firstFrameImageUrl
:
string
;
lastFrameImageKey
:
string
;
lastFrameImageUrl
:
string
;
freeformPrompt
:
string
;
freeformPrompt
:
string
;
selectedDuration
:
number
;
selectedDuration
:
number
;
selectedRatio
:
string
;
selectedRatio
:
string
;
...
@@ -301,6 +306,7 @@ export function StoryboardWorkspace() {
...
@@ -301,6 +306,7 @@ export function StoryboardWorkspace() {
const createSb = useCreateStoryboard(pid, eid);
const createSb = useCreateStoryboard(pid, eid);
const deleteSb = useDeleteStoryboard(pid, eid);
const deleteSb = useDeleteStoryboard(pid, eid);
const updateSb = useUpdateStoryboard(pid, eid);
const updateSb = useUpdateStoryboard(pid, eid);
const uploadFrameImage = useUploadStoryboardFrameImage(pid, eid);
const generateStructured = useGenerateStructuredVideo(pid);
const generateStructured = useGenerateStructuredVideo(pid);
const startAssembly = useStartAssembly(pid, eid);
const startAssembly = useStartAssembly(pid, eid);
const videoCredits = useOperationCost("video_generate");
const videoCredits = useOperationCost("video_generate");
...
@@ -310,6 +316,10 @@ export function StoryboardWorkspace() {
...
@@ -310,6 +316,10 @@ export function StoryboardWorkspace() {
const [characterKeys, setCharacterKeys] = useState<string[]>([]);
const [characterKeys, setCharacterKeys] = useState<string[]>([]);
const [sceneKey, setSceneKey] = useState<string>("");
const [sceneKey, setSceneKey] = useState<string>("");
const [propKeys, setPropKeys] = useState<string[]>([]);
const [propKeys, setPropKeys] = useState<string[]>([]);
const [firstFrameImageKey, setFirstFrameImageKey] = useState<string>("");
const [firstFrameImageUrl, setFirstFrameImageUrl] = useState<string>("");
const [lastFrameImageKey, setLastFrameImageKey] = useState<string>("");
const [lastFrameImageUrl, setLastFrameImageUrl] = useState<string>("");
const [styleKey] = useState<string>(""); // reserved; not exposed in this UI
const [styleKey] = useState<string>(""); // reserved; not exposed in this UI
const [shortDescription, setShortDescription] = useState<string>("");
const [shortDescription, setShortDescription] = useState<string>("");
const [freeformPrompt, setFreeformPrompt] = useState<string>("");
const [freeformPrompt, setFreeformPrompt] = useState<string>("");
...
@@ -378,6 +388,10 @@ export function StoryboardWorkspace() {
...
@@ -378,6 +388,10 @@ export function StoryboardWorkspace() {
setCharacterKeys([]);
setCharacterKeys([]);
setSceneKey("");
setSceneKey("");
setPropKeys([]);
setPropKeys([]);
setFirstFrameImageKey("");
setFirstFrameImageUrl("");
setLastFrameImageKey("");
setLastFrameImageUrl("");
setShortDescription("");
setShortDescription("");
setActiveVideoSbId(null);
setActiveVideoSbId(null);
lastSyncedExpandedIdRef.current = null;
lastSyncedExpandedIdRef.current = null;
...
@@ -411,6 +425,10 @@ export function StoryboardWorkspace() {
...
@@ -411,6 +425,10 @@ export function StoryboardWorkspace() {
setCharacterKeys(draft.characterKeys);
setCharacterKeys(draft.characterKeys);
setSceneKey(nextSceneKey);
setSceneKey(nextSceneKey);
setPropKeys(draft.propKeys);
setPropKeys(draft.propKeys);
setFirstFrameImageKey(draft.firstFrameImageKey || expanded.firstFrameImageKey || "");
setFirstFrameImageUrl(draft.firstFrameImageUrl || expanded.firstFrameImageUrl || "");
setLastFrameImageKey(draft.lastFrameImageKey || expanded.lastFrameImageKey || "");
setLastFrameImageUrl(draft.lastFrameImageUrl || expanded.lastFrameImageUrl || "");
setFreeformPrompt(draft.freeformPrompt);
setFreeformPrompt(draft.freeformPrompt);
setSelectedDuration(normalizeVideoDuration(draft.selectedDuration));
setSelectedDuration(normalizeVideoDuration(draft.selectedDuration));
setSelectedRatio(draft.selectedRatio);
setSelectedRatio(draft.selectedRatio);
...
@@ -443,6 +461,10 @@ export function StoryboardWorkspace() {
...
@@ -443,6 +461,10 @@ export function StoryboardWorkspace() {
updateSb.mutate({ id: expanded.id, patch: { sceneImageKey: nextSceneKey } });
updateSb.mutate({ id: expanded.id, patch: { sceneImageKey: nextSceneKey } });
}
}
setPropKeys(storyboardPropKeys.length > 0 ? storyboardPropKeys : (lastTask?.propImageKeys ?? []));
setPropKeys(storyboardPropKeys.length > 0 ? storyboardPropKeys : (lastTask?.propImageKeys ?? []));
setFirstFrameImageKey(expanded.firstFrameImageKey ?? "");
setFirstFrameImageUrl(expanded.firstFrameImageUrl ?? "");
setLastFrameImageKey(expanded.lastFrameImageKey ?? "");
setLastFrameImageUrl(expanded.lastFrameImageUrl ?? "");
setFreeformPrompt(expanded.videoPrompt?.trim() || (lastTask?.userPrompt?.freeformPrompt ?? ""));
setFreeformPrompt(expanded.videoPrompt?.trim() || (lastTask?.userPrompt?.freeformPrompt ?? ""));
if (typeof lastTask?.videoDuration === "number") {
if (typeof lastTask?.videoDuration === "number") {
setSelectedDuration(normalizeVideoDuration(lastTask.videoDuration));
setSelectedDuration(normalizeVideoDuration(lastTask.videoDuration));
...
@@ -467,6 +489,10 @@ export function StoryboardWorkspace() {
...
@@ -467,6 +489,10 @@ export function StoryboardWorkspace() {
characterKeys,
characterKeys,
sceneKey,
sceneKey,
propKeys,
propKeys,
firstFrameImageKey,
firstFrameImageUrl,
lastFrameImageKey,
lastFrameImageUrl,
freeformPrompt,
freeformPrompt,
selectedDuration,
selectedDuration,
selectedRatio,
selectedRatio,
...
@@ -475,7 +501,9 @@ export function StoryboardWorkspace() {
...
@@ -475,7 +501,9 @@ export function StoryboardWorkspace() {
});
});
}, [
}, [
expandedId, pid, eid,
expandedId, pid, eid,
shortDescription, characterKeys, sceneKey, propKeys, freeformPrompt,
shortDescription, characterKeys, sceneKey, propKeys,
firstFrameImageKey, firstFrameImageUrl, lastFrameImageKey, lastFrameImageUrl,
freeformPrompt,
selectedDuration, selectedRatio, selectedModel, audioOn,
selectedDuration, selectedRatio, selectedModel, audioOn,
]);
]);
...
@@ -496,6 +524,10 @@ export function StoryboardWorkspace() {
...
@@ -496,6 +524,10 @@ export function StoryboardWorkspace() {
characterImageKeys: characterKeys.length > 0 ? JSON.stringify(characterKeys) : "",
characterImageKeys: characterKeys.length > 0 ? JSON.stringify(characterKeys) : "",
sceneImageKey: sceneKey,
sceneImageKey: sceneKey,
propImageKeys: propKeys.length > 0 ? JSON.stringify(propKeys) : "",
propImageKeys: propKeys.length > 0 ? JSON.stringify(propKeys) : "",
firstFrameImageKey,
firstFrameImageUrl,
lastFrameImageKey,
lastFrameImageUrl,
videoPrompt: freeformPrompt,
videoPrompt: freeformPrompt,
durationSeconds: selectedDuration,
durationSeconds: selectedDuration,
},
},
...
@@ -508,7 +540,9 @@ export function StoryboardWorkspace() {
...
@@ -508,7 +540,9 @@ export function StoryboardWorkspace() {
};
};
}, [
}, [
expandedId, pid, eid,
expandedId, pid, eid,
shortDescription, characterKeys, sceneKey, propKeys, freeformPrompt, selectedDuration,
shortDescription, characterKeys, sceneKey, propKeys,
firstFrameImageKey, firstFrameImageUrl, lastFrameImageKey, lastFrameImageUrl,
freeformPrompt, selectedDuration,
]);
]);
const handleEpisodeSelect = (ep: Episode) => {
const handleEpisodeSelect = (ep: Episode) => {
...
@@ -563,6 +597,28 @@ export function StoryboardWorkspace() {
...
@@ -563,6 +597,28 @@ export function StoryboardWorkspace() {
});
});
};
};
const handleFrameUpload = async (frameType: "first" | "last", file: File) => {
if (!expanded) return;
const updated = await uploadFrameImage.mutateAsync({ id: expanded.id, frameType, file });
if (frameType === "first") {
setFirstFrameImageKey(updated.firstFrameImageKey ?? "");
setFirstFrameImageUrl(updated.firstFrameImageUrl ?? "");
} else {
setLastFrameImageKey(updated.lastFrameImageKey ?? "");
setLastFrameImageUrl(updated.lastFrameImageUrl ?? "");
}
};
const handleFrameClear = (frameType: "first" | "last") => {
if (frameType === "first") {
setFirstFrameImageKey("");
setFirstFrameImageUrl("");
return;
}
setLastFrameImageKey("");
setLastFrameImageUrl("");
};
const removeCharacter = (key: string) => {
const removeCharacter = (key: string) => {
setCharacterKeys((prev) => prev.filter((k) => k !== key));
setCharacterKeys((prev) => prev.filter((k) => k !== key));
};
};
...
@@ -578,11 +634,12 @@ export function StoryboardWorkspace() {
...
@@ -578,11 +634,12 @@ export function StoryboardWorkspace() {
};
};
const promptHasContent = freeformPrompt.trim().length > 0;
const promptHasContent = freeformPrompt.trim().length > 0;
// 至少一张参考图(角色/场景/道具任一)+ 提示词非空
const frameReferenceCount = (firstFrameImageKey ? 1 : 0) + (lastFrameImageKey ? 1 : 0);
const hasAnyReferenceImage = characterKeys.length > 0 || !!sceneKey || propKeys.length > 0;
// 至少一张参考图(角色/场景/道具/首尾帧任一)+ 提示词非空
const hasAnyReferenceImage = characterKeys.length > 0 || !!sceneKey || propKeys.length > 0 || frameReferenceCount > 0;
const canGenerate = !!expanded && hasAnyReferenceImage
const canGenerate = !!expanded && hasAnyReferenceImage
&& promptHasContent && !generateStructured.isPending;
&& promptHasContent && !generateStructured.isPending;
const referenceCount = characterKeys.length + (sceneKey ? 1 : 0) + propKeys.length;
const referenceCount = characterKeys.length + (sceneKey ? 1 : 0) + propKeys.length
+ frameReferenceCount
;
const handleGenerate = async () => {
const handleGenerate = async () => {
if (!expanded || !canGenerate) return;
if (!expanded || !canGenerate) return;
...
@@ -595,6 +652,10 @@ export function StoryboardWorkspace() {
...
@@ -595,6 +652,10 @@ export function StoryboardWorkspace() {
characterImageKeys: JSON.stringify(characterKeys),
characterImageKeys: JSON.stringify(characterKeys),
sceneImageKey: sceneKey,
sceneImageKey: sceneKey,
propImageKeys: propKeys.length > 0 ? JSON.stringify(propKeys) : "",
propImageKeys: propKeys.length > 0 ? JSON.stringify(propKeys) : "",
firstFrameImageKey,
firstFrameImageUrl,
lastFrameImageKey,
lastFrameImageUrl,
videoPrompt: freeformPrompt.trim(),
videoPrompt: freeformPrompt.trim(),
durationSeconds: selectedDuration,
durationSeconds: selectedDuration,
},
},
...
@@ -605,6 +666,8 @@ export function StoryboardWorkspace() {
...
@@ -605,6 +666,8 @@ export function StoryboardWorkspace() {
characterImageKeys: characterKeys,
characterImageKeys: characterKeys,
sceneImageKey: sceneKey,
sceneImageKey: sceneKey,
propImageKeys: propKeys.length > 0 ? propKeys : null,
propImageKeys: propKeys.length > 0 ? propKeys : null,
firstFrameImageKey: firstFrameImageKey || null,
lastFrameImageKey: lastFrameImageKey || null,
styleImageKey: styleKey || null,
styleImageKey: styleKey || null,
freeformPrompt: freeformPrompt.trim(),
freeformPrompt: freeformPrompt.trim(),
duration: selectedDuration,
duration: selectedDuration,
...
@@ -837,6 +900,13 @@ export function StoryboardWorkspace() {
...
@@ -837,6 +900,13 @@ export function StoryboardWorkspace() {
onSelectScene={setSceneKey}
onSelectScene={setSceneKey}
propKeys={expandedId === sb.id ? propKeys : []}
propKeys={expandedId === sb.id ? propKeys : []}
onTogglePropKey={togglePropKey}
onTogglePropKey={togglePropKey}
firstFrameImageKey={expandedId === sb.id ? firstFrameImageKey : sb.firstFrameImageKey ?? ""}
firstFrameImageUrl={expandedId === sb.id ? firstFrameImageUrl : sb.firstFrameImageUrl ?? ""}
lastFrameImageKey={expandedId === sb.id ? lastFrameImageKey : sb.lastFrameImageKey ?? ""}
lastFrameImageUrl={expandedId === sb.id ? lastFrameImageUrl : sb.lastFrameImageUrl ?? ""}
onUploadFrame={handleFrameUpload}
onClearFrame={handleFrameClear}
frameUploading={uploadFrameImage.isPending}
scenes={scenes}
scenes={scenes}
sceneByKey={sceneByKey}
sceneByKey={sceneByKey}
/>
/>
...
@@ -942,7 +1012,7 @@ export function StoryboardWorkspace() {
...
@@ -942,7 +1012,7 @@ export function StoryboardWorkspace() {
<button
<button
onClick={handleGenerate}
onClick={handleGenerate}
disabled={!canGenerate || generatingVideoId === expanded?.id}
disabled={!canGenerate || generatingVideoId === expanded?.id}
title={!canGenerate ? "需要:至少 1 张参考图(角色/场景/道具任一)+ 提示词" : ""}
title={!canGenerate ? "需要:至少 1 张参考图(角色/场景/道具
/首尾帧
任一)+ 提示词" : ""}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-md bg-[#7657ff] text-white text-xs font-semibold tracking-wide shadow-[0_9px_20px_rgba(118,87,255,0.24)] transition-all duration-150 hover:bg-[#6747f4] hover:-translate-y-px disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none whitespace-nowrap"
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-md bg-[#7657ff] text-white text-xs font-semibold tracking-wide shadow-[0_9px_20px_rgba(118,87,255,0.24)] transition-all duration-150 hover:bg-[#6747f4] hover:-translate-y-px disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none whitespace-nowrap"
>
>
{generatingVideoId === expanded?.id ? (
{generatingVideoId === expanded?.id ? (
...
@@ -1205,6 +1275,13 @@ interface StoryboardCardProps {
...
@@ -1205,6 +1275,13 @@ interface StoryboardCardProps {
onSelectScene
:
(
key
:
string
)
=>
void
;
onSelectScene
:
(
key
:
string
)
=>
void
;
propKeys
:
string
[];
propKeys
:
string
[];
onTogglePropKey
:
(
key
:
string
)
=>
void
;
onTogglePropKey
:
(
key
:
string
)
=>
void
;
firstFrameImageKey
:
string
;
firstFrameImageUrl
:
string
;
lastFrameImageKey
:
string
;
lastFrameImageUrl
:
string
;
onUploadFrame
:
(
frameType
:
"first"
|
"last"
,
file
:
File
)
=>
void
|
Promise
<
void
>
;
onClearFrame
:
(
frameType
:
"first"
|
"last"
)
=>
void
;
frameUploading
:
boolean
;
scenes
:
Scene
[];
scenes
:
Scene
[];
sceneByKey
:
Map
<
string
,
Scene
>
;
sceneByKey
:
Map
<
string
,
Scene
>
;
}
}
...
@@ -1278,6 +1355,17 @@ function StoryboardCard(props: StoryboardCardProps) {
...
@@ -1278,6 +1355,17 @@ function StoryboardCard(props: StoryboardCardProps) {
onToggle=
{
props
.
onTogglePropKey
}
onToggle=
{
props
.
onTogglePropKey
}
/>
/>
{
/* 首尾帧图 */
}
<
FrameReferenceSection
firstFrameImageKey=
{
props
.
firstFrameImageKey
}
firstFrameImageUrl=
{
props
.
firstFrameImageUrl
}
lastFrameImageKey=
{
props
.
lastFrameImageKey
}
lastFrameImageUrl=
{
props
.
lastFrameImageUrl
}
onUpload=
{
props
.
onUploadFrame
}
onClear=
{
props
.
onClearFrame
}
uploading=
{
props
.
frameUploading
}
/>
{
/* 删除按钮 */
}
{
/* 删除按钮 */
}
<
div
className=
"pt-1 flex justify-end"
>
<
div
className=
"pt-1 flex justify-end"
>
<
button
<
button
...
@@ -1658,6 +1746,133 @@ function PropGrid({ scenes, sceneKey, selectedKeys, onToggle }: PropGridProps) {
...
@@ -1658,6 +1746,133 @@ function PropGrid({ scenes, sceneKey, selectedKeys, onToggle }: PropGridProps) {
}
}
// ────────────────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────────────────
// FrameReferenceSection — first/last frame images used as extra video references
// ────────────────────────────────────────────────────────────────────────────
interface FrameReferenceSectionProps
{
firstFrameImageKey
:
string
;
firstFrameImageUrl
:
string
;
lastFrameImageKey
:
string
;
lastFrameImageUrl
:
string
;
onUpload
:
(
frameType
:
"first"
|
"last"
,
file
:
File
)
=>
void
|
Promise
<
void
>
;
onClear
:
(
frameType
:
"first"
|
"last"
)
=>
void
;
uploading
:
boolean
;
}
function FrameReferenceSection(
{
firstFrameImageKey
,
firstFrameImageUrl
,
lastFrameImageKey
,
lastFrameImageUrl
,
onUpload
,
onClear
,
uploading
,
}
: FrameReferenceSectionProps)
{
return
(
<
div
className=
"rounded-md bg-white p-0"
>
<
div
className=
"flex items-center justify-between mb-2"
>
<
span
className=
"text-xs font-medium text-foreground"
>
首尾帧图
</
span
>
<
span
className=
"text-[10px] text-muted-foreground"
>
作为视频参考图
</
span
>
</
div
>
<
div
className=
"grid grid-cols-2 gap-2"
>
<
FrameReferenceTile
label=
"上传首帧"
imageKey=
{
firstFrameImageKey
}
imageUrl=
{
firstFrameImageUrl
}
frameType=
"first"
uploading=
{
uploading
}
onUpload=
{
onUpload
}
onClear=
{
onClear
}
/>
<
FrameReferenceTile
label=
"上传尾帧"
imageKey=
{
lastFrameImageKey
}
imageUrl=
{
lastFrameImageUrl
}
frameType=
"last"
uploading=
{
uploading
}
onUpload=
{
onUpload
}
onClear=
{
onClear
}
/>
</
div
>
<
p
className=
"mt-2 rounded-md border border-[#dbeafe] bg-[#eff6ff] px-2 py-1 text-[10px] leading-4 text-[#1d4ed8]"
>
关键帧会按首帧、尾帧顺序参与视频生成,可随时替换。
</
p
>
</
div
>
);
}
interface FrameReferenceTileProps
{
label
:
string
;
imageKey
:
string
;
imageUrl
:
string
;
frameType
:
"first"
|
"last"
;
uploading
:
boolean
;
onUpload
:
(
frameType
:
"first"
|
"last"
,
file
:
File
)
=>
void
|
Promise
<
void
>
;
onClear
:
(
frameType
:
"first"
|
"last"
)
=>
void
;
}
function FrameReferenceTile(
{
label
,
imageKey
,
imageUrl
,
frameType
,
uploading
,
onUpload
,
onClear
,
}
: FrameReferenceTileProps)
{
const
inputRef
=
useRef
<
HTMLInputElement
>
(
null
);
const
handleSelect
=
async
(
event
:
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
file
=
event
.
target
.
files
?.[
0
];
event
.
target
.
value
=
""
;
if
(
!
file
)
return
;
await
onUpload
(
frameType
,
file
);
};
return
(
<
div
className=
"relative rounded-md border border-[#dfe3ee] bg-white p-1.5"
>
<
button
type=
"button"
onClick=
{
()
=>
inputRef
.
current
?.
click
()
}
disabled=
{
uploading
}
className=
"relative block w-full aspect-[4/3] overflow-hidden rounded-md border border-[#e5e7ef] bg-slate-100 disabled:opacity-60"
title=
{
imageKey
?
"点击替换图片"
:
label
}
>
{
imageUrl
?
(
<
img
src=
{
imageUrl
}
alt=
{
label
}
className=
"h-full w-full object-cover"
/>
)
:
(
<
div
className=
"flex h-full w-full flex-col items-center justify-center gap-1 text-muted-foreground"
>
{
uploading
?
<
Loader2
className=
"h-5 w-5 animate-spin"
/>
:
<
ImageIcon
className=
"h-5 w-5"
/>
}
<
span
className=
"text-[11px] font-medium"
>
{
label
}
</
span
>
</
div
>
)
}
</
button
>
<
div
className=
"mt-1 flex items-center justify-between gap-1"
>
<
button
type=
"button"
onClick=
{
()
=>
inputRef
.
current
?.
click
()
}
disabled=
{
uploading
}
className=
"min-w-0 flex-1 truncate rounded-md px-1.5 py-1 text-left text-[10px] font-medium text-[#344054] hover:bg-[#f6f9ff] disabled:opacity-50"
>
{
imageKey
?
(
frameType
===
"first"
?
"首帧图"
:
"尾帧图"
)
:
label
}
</
button
>
{
imageKey
&&
(
<
button
type=
"button"
onClick=
{
()
=>
onClear
(
frameType
)
}
disabled=
{
uploading
}
className=
"rounded-md p-1 text-muted-foreground hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
title=
"清除"
>
<
Trash2
className=
"h-3 w-3"
/>
</
button
>
)
}
</
div
>
<
input
ref=
{
inputRef
}
type=
"file"
accept=
"image/*"
className=
"hidden"
onChange=
{
handleSelect
}
/>
</
div
>
);
}
// ────────────────────────────────────────────────────────────────────────────
// CharacterPickerModal — pick base characters or generated character states
// CharacterPickerModal — pick base characters or generated character states
// ────────────────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────────────────
interface CharacterPickerModalProps
{
interface CharacterPickerModalProps
{
...
...
doc/html/src/hooks/useAi.ts
View file @
787ae772
...
@@ -340,6 +340,15 @@ export function useUpdateStoryboard(projectId: string, episodeId: string) {
...
@@ -340,6 +340,15 @@ export function useUpdateStoryboard(projectId: string, episodeId: string) {
});
});
}
}
export
function
useUploadStoryboardFrameImage
(
projectId
:
string
,
episodeId
:
string
)
{
const
qc
=
useQueryClient
();
return
useMutation
({
mutationFn
:
({
id
,
frameType
,
file
}:
{
id
:
string
;
frameType
:
"first"
|
"last"
;
file
:
File
})
=>
aiApi
.
uploadStoryboardFrameImage
(
projectId
,
id
,
frameType
,
file
),
onSuccess
:
()
=>
qc
.
invalidateQueries
({
queryKey
:
storyboardsKey
(
projectId
,
episodeId
)
}),
});
}
export
function
useDeleteStoryboard
(
projectId
:
string
,
episodeId
:
string
)
{
export
function
useDeleteStoryboard
(
projectId
:
string
,
episodeId
:
string
)
{
const
qc
=
useQueryClient
();
const
qc
=
useQueryClient
();
return
useMutation
({
return
useMutation
({
...
...
doc/html/src/lib/api/ai.ts
View file @
787ae772
...
@@ -47,6 +47,10 @@ export interface Storyboard {
...
@@ -47,6 +47,10 @@ export interface Storyboard {
sceneImageKey
?:
string
|
null
;
sceneImageKey
?:
string
|
null
;
propImageKeys
?:
string
[]
|
null
;
propImageKeys
?:
string
[]
|
null
;
styleImageKey
?:
string
|
null
;
styleImageKey
?:
string
|
null
;
firstFrameImageKey
?:
string
|
null
;
firstFrameImageUrl
?:
string
|
null
;
lastFrameImageKey
?:
string
|
null
;
lastFrameImageUrl
?:
string
|
null
;
}
}
export
interface
ShotAsset
{
export
interface
ShotAsset
{
...
@@ -157,6 +161,8 @@ export interface StructuredVideoRequest {
...
@@ -157,6 +161,8 @@ export interface StructuredVideoRequest {
sceneImageKey
?:
string
|
null
;
sceneImageKey
?:
string
|
null
;
propImageKeys
?:
string
[]
|
null
;
propImageKeys
?:
string
[]
|
null
;
styleImageKey
?:
string
|
null
;
styleImageKey
?:
string
|
null
;
firstFrameImageKey
?:
string
|
null
;
lastFrameImageKey
?:
string
|
null
;
/** V17:单段 freeform 提示词(与 userPrompt 二选一,优先使用) */
/** V17:单段 freeform 提示词(与 userPrompt 二选一,优先使用) */
freeformPrompt
?:
string
|
null
;
freeformPrompt
?:
string
|
null
;
userPrompt
?:
{
userPrompt
?:
{
...
@@ -234,6 +240,21 @@ export const aiApi = {
...
@@ -234,6 +240,21 @@ export const aiApi = {
const
r
=
await
apiClient
.
put
(
`/projects/
${
projectId
}
/storyboards/
${
storyboardId
}
`
,
patch
);
const
r
=
await
apiClient
.
put
(
`/projects/
${
projectId
}
/storyboards/
${
storyboardId
}
`
,
patch
);
return
r
.
data
.
data
;
return
r
.
data
.
data
;
},
},
uploadStoryboardFrameImage
:
async
(
projectId
:
string
,
storyboardId
:
string
,
frameType
:
"first"
|
"last"
,
file
:
File
):
Promise
<
Storyboard
>
=>
{
const
form
=
new
FormData
();
form
.
append
(
"file"
,
file
);
const
r
=
await
apiClient
.
post
(
`/projects/
${
projectId
}
/storyboards/
${
storyboardId
}
/frame-image?frameType=
${
frameType
}
`
,
form
,
{
headers
:
{
"Content-Type"
:
"multipart/form-data"
},
timeout
:
600
_000
}
);
return
r
.
data
.
data
;
},
deleteStoryboard
:
async
(
projectId
:
string
,
storyboardId
:
string
):
Promise
<
void
>
=>
{
deleteStoryboard
:
async
(
projectId
:
string
,
storyboardId
:
string
):
Promise
<
void
>
=>
{
await
apiClient
.
delete
(
`/projects/
${
projectId
}
/storyboards/
${
storyboardId
}
`
);
await
apiClient
.
delete
(
`/projects/
${
projectId
}
/storyboards/
${
storyboardId
}
`
);
},
},
...
...
yaoai-comic-studio/yaoai-ai-providers/src/main/java/com/yaoai/ai/providers/util/SeedancePromptBuilder.java
View file @
787ae772
...
@@ -8,7 +8,7 @@ import java.util.List;
...
@@ -8,7 +8,7 @@ import java.util.List;
*
*
* 图片顺序固定(即 @图N 标号):
* 图片顺序固定(即 @图N 标号):
* @图1..@图K = 角色(K = 角色数量),@图K+1 = 场景,
* @图1..@图K = 角色(K = 角色数量),@图K+1 = 场景,
* @图K+2.. = 道具(按顺序),最后一张 = 风格图(如有)
* @图K+2.. = 道具(按顺序),
之后是首/尾帧参考图,
最后一张 = 风格图(如有)
*/
*/
public
final
class
SeedancePromptBuilder
{
public
final
class
SeedancePromptBuilder
{
...
@@ -24,8 +24,14 @@ public final class SeedancePromptBuilder {
...
@@ -24,8 +24,14 @@ public final class SeedancePromptBuilder {
/** V17:单段 freeform 提示词模式(前端单输入框后的主要入口)。 */
/** V17:单段 freeform 提示词模式(前端单输入框后的主要入口)。 */
public
static
String
buildPromptFreeform
(
String
freeformBody
,
int
characterCount
,
public
static
String
buildPromptFreeform
(
String
freeformBody
,
int
characterCount
,
boolean
hasSceneImage
,
int
propCount
,
boolean
hasStyleImage
)
{
boolean
hasSceneImage
,
int
propCount
,
boolean
hasStyleImage
)
{
return
buildPromptFreeform
(
freeformBody
,
characterCount
,
hasSceneImage
,
propCount
,
0
,
hasStyleImage
);
}
public
static
String
buildPromptFreeform
(
String
freeformBody
,
int
characterCount
,
boolean
hasSceneImage
,
int
propCount
,
int
frameCount
,
boolean
hasStyleImage
)
{
StringBuilder
sb
=
new
StringBuilder
();
StringBuilder
sb
=
new
StringBuilder
();
appendImageHeader
(
sb
,
characterCount
,
hasSceneImage
,
propCount
,
hasStyleImage
);
appendImageHeader
(
sb
,
characterCount
,
hasSceneImage
,
propCount
,
frameCount
,
hasStyleImage
);
sb
.
append
(
"\n分镜内容:\n"
);
sb
.
append
(
"\n分镜内容:\n"
);
if
(
freeformBody
!=
null
&&
!
freeformBody
.
isBlank
())
{
if
(
freeformBody
!=
null
&&
!
freeformBody
.
isBlank
())
{
sb
.
append
(
freeformBody
.
trim
()).
append
(
'\n'
);
sb
.
append
(
freeformBody
.
trim
()).
append
(
'\n'
);
...
@@ -36,8 +42,14 @@ public final class SeedancePromptBuilder {
...
@@ -36,8 +42,14 @@ public final class SeedancePromptBuilder {
public
static
String
buildPrompt
(
UserPromptParts
parts
,
int
characterCount
,
public
static
String
buildPrompt
(
UserPromptParts
parts
,
int
characterCount
,
boolean
hasSceneImage
,
int
propCount
,
boolean
hasStyleImage
)
{
boolean
hasSceneImage
,
int
propCount
,
boolean
hasStyleImage
)
{
return
buildPrompt
(
parts
,
characterCount
,
hasSceneImage
,
propCount
,
0
,
hasStyleImage
);
}
public
static
String
buildPrompt
(
UserPromptParts
parts
,
int
characterCount
,
boolean
hasSceneImage
,
int
propCount
,
int
frameCount
,
boolean
hasStyleImage
)
{
StringBuilder
sb
=
new
StringBuilder
();
StringBuilder
sb
=
new
StringBuilder
();
appendImageHeader
(
sb
,
characterCount
,
hasSceneImage
,
propCount
,
hasStyleImage
);
appendImageHeader
(
sb
,
characterCount
,
hasSceneImage
,
propCount
,
frameCount
,
hasStyleImage
);
sb
.
append
(
"\n分镜内容:\n"
);
sb
.
append
(
"\n分镜内容:\n"
);
appendIfPresent
(
sb
,
"人物动作"
,
parts
.
characterAction
());
appendIfPresent
(
sb
,
"人物动作"
,
parts
.
characterAction
());
...
@@ -56,6 +68,12 @@ public final class SeedancePromptBuilder {
...
@@ -56,6 +68,12 @@ public final class SeedancePromptBuilder {
*/
*/
private
static
void
appendImageHeader
(
StringBuilder
sb
,
int
characterCount
,
private
static
void
appendImageHeader
(
StringBuilder
sb
,
int
characterCount
,
boolean
hasSceneImage
,
int
propCount
,
boolean
hasStyleImage
)
{
boolean
hasSceneImage
,
int
propCount
,
boolean
hasStyleImage
)
{
appendImageHeader
(
sb
,
characterCount
,
hasSceneImage
,
propCount
,
0
,
hasStyleImage
);
}
private
static
void
appendImageHeader
(
StringBuilder
sb
,
int
characterCount
,
boolean
hasSceneImage
,
int
propCount
,
int
frameCount
,
boolean
hasStyleImage
)
{
int
idx
=
0
;
int
idx
=
0
;
if
(
characterCount
==
1
)
{
if
(
characterCount
==
1
)
{
sb
.
append
(
"@图"
).
append
(
idx
+
1
)
sb
.
append
(
"@图"
).
append
(
idx
+
1
)
...
@@ -83,6 +101,16 @@ public final class SeedancePromptBuilder {
...
@@ -83,6 +101,16 @@ public final class SeedancePromptBuilder {
}
}
idx
+=
propCount
;
idx
+=
propCount
;
}
}
if
(
frameCount
>
0
)
{
if
(
frameCount
==
1
)
{
sb
.
append
(
"@图"
).
append
(
idx
+
1
)
.
append
(
" 是分镜首帧或尾帧参考图,请作为画面起止状态参考,不要生硬复制成静态画面。\n"
);
}
else
{
sb
.
append
(
"@图"
).
append
(
idx
+
1
).
append
(
" 是首帧参考图,@图"
).
append
(
idx
+
2
)
.
append
(
" 是尾帧参考图,请参考起始和结束画面状态,让视频动作自然衔接。\n"
);
}
idx
+=
frameCount
;
}
if
(
hasStyleImage
)
{
if
(
hasStyleImage
)
{
sb
.
append
(
"@图"
).
append
(
idx
+
1
)
sb
.
append
(
"@图"
).
append
(
idx
+
1
)
.
append
(
" 是风格参考图,请参考整体美术风格、色彩和质感。\n"
);
.
append
(
" 是风格参考图,请参考整体美术风格、色彩和质感。\n"
);
...
@@ -111,6 +139,14 @@ public final class SeedancePromptBuilder {
...
@@ -111,6 +139,14 @@ public final class SeedancePromptBuilder {
String
sceneImageKey
,
String
sceneImageKey
,
List
<
String
>
propImageKeys
,
List
<
String
>
propImageKeys
,
String
styleImageKey
)
{
String
styleImageKey
)
{
return
buildOrderedImageKeys
(
characterImageKeys
,
sceneImageKey
,
propImageKeys
,
null
,
styleImageKey
);
}
public
static
List
<
String
>
buildOrderedImageKeys
(
List
<
String
>
characterImageKeys
,
String
sceneImageKey
,
List
<
String
>
propImageKeys
,
List
<
String
>
frameImageKeys
,
String
styleImageKey
)
{
List
<
String
>
keys
=
new
ArrayList
<>();
List
<
String
>
keys
=
new
ArrayList
<>();
if
(
characterImageKeys
!=
null
)
{
if
(
characterImageKeys
!=
null
)
{
for
(
String
k
:
characterImageKeys
)
{
for
(
String
k
:
characterImageKeys
)
{
...
@@ -129,6 +165,13 @@ public final class SeedancePromptBuilder {
...
@@ -129,6 +165,13 @@ public final class SeedancePromptBuilder {
}
}
}
}
}
}
if
(
frameImageKeys
!=
null
)
{
for
(
String
k
:
frameImageKeys
)
{
if
(
k
!=
null
&&
!
k
.
isBlank
())
{
keys
.
add
(
k
);
}
}
}
if
(
styleImageKey
!=
null
&&
!
styleImageKey
.
isBlank
())
{
if
(
styleImageKey
!=
null
&&
!
styleImageKey
.
isBlank
())
{
keys
.
add
(
styleImageKey
);
keys
.
add
(
styleImageKey
);
}
}
...
...
yaoai-comic-studio/yaoai-api/src/main/java/com/yaoai/api/controller/StoryboardController.java
View file @
787ae772
...
@@ -2,16 +2,23 @@ package com.yaoai.api.controller;
...
@@ -2,16 +2,23 @@ package com.yaoai.api.controller;
import
com.yaoai.api.dto.ai.StoryboardDTO
;
import
com.yaoai.api.dto.ai.StoryboardDTO
;
import
com.yaoai.api.dto.ai.ShotAssetDTO
;
import
com.yaoai.api.dto.ai.ShotAssetDTO
;
import
com.yaoai.common.exception.BizException
;
import
com.yaoai.common.exception.ErrorCode
;
import
com.yaoai.common.response.ApiResponse
;
import
com.yaoai.common.response.ApiResponse
;
import
com.yaoai.domain.entity.Storyboard
;
import
com.yaoai.domain.entity.Storyboard
;
import
com.yaoai.domain.mapper.StoryboardMapper
;
import
com.yaoai.pipeline.service.ShotAssetService
;
import
com.yaoai.pipeline.service.ShotAssetService
;
import
com.yaoai.pipeline.service.StoryboardPipelineService
;
import
com.yaoai.pipeline.service.StoryboardPipelineService
;
import
com.yaoai.security.context.TenantContext
;
import
com.yaoai.security.context.TenantContext
;
import
com.yaoai.storage.service.TosService
;
import
io.swagger.v3.oas.annotations.Operation
;
import
io.swagger.v3.oas.annotations.Operation
;
import
io.swagger.v3.oas.annotations.tags.Tag
;
import
io.swagger.v3.oas.annotations.tags.Tag
;
import
lombok.RequiredArgsConstructor
;
import
lombok.RequiredArgsConstructor
;
import
org.springframework.http.MediaType
;
import
org.springframework.web.bind.annotation.*
;
import
org.springframework.web.bind.annotation.*
;
import
org.springframework.web.multipart.MultipartFile
;
import
java.io.IOException
;
import
java.util.List
;
import
java.util.List
;
import
java.util.Map
;
import
java.util.Map
;
import
java.util.stream.Collectors
;
import
java.util.stream.Collectors
;
...
@@ -24,6 +31,8 @@ public class StoryboardController {
...
@@ -24,6 +31,8 @@ public class StoryboardController {
private
final
StoryboardPipelineService
storyboardService
;
private
final
StoryboardPipelineService
storyboardService
;
private
final
ShotAssetService
shotAssetService
;
private
final
ShotAssetService
shotAssetService
;
private
final
StoryboardMapper
storyboardMapper
;
private
final
TosService
tosService
;
@Operation
(
summary
=
"AI 批量生成分集分镜"
)
@Operation
(
summary
=
"AI 批量生成分集分镜"
)
@PostMapping
(
"/episodes/{episodeId}/storyboards/generate"
)
@PostMapping
(
"/episodes/{episodeId}/storyboards/generate"
)
...
@@ -79,6 +88,47 @@ public class StoryboardController {
...
@@ -79,6 +88,47 @@ public class StoryboardController {
return
ApiResponse
.
success
(
StoryboardDTO
.
from
(
storyboardService
.
update
(
storyboardId
,
tenantId
,
req
)));
return
ApiResponse
.
success
(
StoryboardDTO
.
from
(
storyboardService
.
update
(
storyboardId
,
tenantId
,
req
)));
}
}
@Operation
(
summary
=
"上传分镜首尾帧参考图"
)
@PostMapping
(
value
=
"/storyboards/{storyboardId}/frame-image"
,
consumes
=
MediaType
.
MULTIPART_FORM_DATA_VALUE
)
public
ApiResponse
<
StoryboardDTO
>
uploadFrameImage
(
@PathVariable
Long
projectId
,
@PathVariable
Long
storyboardId
,
@RequestParam
(
"frameType"
)
String
frameType
,
@RequestPart
(
"file"
)
MultipartFile
file
)
throws
IOException
{
Long
tenantId
=
TenantContext
.
get
();
Storyboard
storyboard
=
storyboardMapper
.
selectById
(
storyboardId
);
if
(
storyboard
==
null
||
!
tenantId
.
equals
(
storyboard
.
getTenantId
())
||
!
projectId
.
equals
(
storyboard
.
getProjectId
()))
{
throw
new
BizException
(
ErrorCode
.
NOT_FOUND
,
"分镜不存在"
);
}
if
(!
"first"
.
equals
(
frameType
)
&&
!
"last"
.
equals
(
frameType
))
{
throw
new
BizException
(
ErrorCode
.
INVALID_PARAM
,
"frameType 仅支持 first / last"
);
}
if
(
file
==
null
||
file
.
isEmpty
())
{
throw
new
BizException
(
ErrorCode
.
INVALID_PARAM
,
"图片文件不能为空"
);
}
String
contentType
=
file
.
getContentType
()
!=
null
?
file
.
getContentType
()
:
"application/octet-stream"
;
if
(!
contentType
.
startsWith
(
"image/"
))
{
throw
new
BizException
(
ErrorCode
.
INVALID_PARAM
,
"请上传图片文件"
);
}
String
key
=
TosService
.
buildKey
(
tenantId
,
projectId
,
"storyboard-frames/"
+
frameType
,
file
.
getOriginalFilename
());
tosService
.
upload
(
key
,
file
.
getInputStream
(),
file
.
getSize
(),
contentType
);
String
url
=
tosService
.
publicUrl
(
key
);
Storyboard
patch
=
new
Storyboard
();
patch
.
setId
(
storyboardId
);
if
(
"first"
.
equals
(
frameType
))
{
patch
.
setFirstFrameImageKey
(
key
);
patch
.
setFirstFrameImageUrl
(
url
);
}
else
{
patch
.
setLastFrameImageKey
(
key
);
patch
.
setLastFrameImageUrl
(
url
);
}
storyboardMapper
.
updateById
(
patch
);
return
ApiResponse
.
success
(
StoryboardDTO
.
from
(
storyboardMapper
.
selectById
(
storyboardId
)));
}
@Operation
(
summary
=
"删除分镜"
)
@Operation
(
summary
=
"删除分镜"
)
@DeleteMapping
(
"/storyboards/{storyboardId}"
)
@DeleteMapping
(
"/storyboards/{storyboardId}"
)
public
ApiResponse
<
Void
>
delete
(
@PathVariable
Long
projectId
,
public
ApiResponse
<
Void
>
delete
(
@PathVariable
Long
projectId
,
...
...
yaoai-comic-studio/yaoai-api/src/main/java/com/yaoai/api/controller/VideoTaskController.java
View file @
787ae772
...
@@ -46,8 +46,10 @@ public class VideoTaskController {
...
@@ -46,8 +46,10 @@ public class VideoTaskController {
||
(
req
.
getCharacterImageKey
()
!=
null
&&
!
req
.
getCharacterImageKey
().
isBlank
());
||
(
req
.
getCharacterImageKey
()
!=
null
&&
!
req
.
getCharacterImageKey
().
isBlank
());
boolean
hasScene
=
req
.
getSceneImageKey
()
!=
null
&&
!
req
.
getSceneImageKey
().
isBlank
();
boolean
hasScene
=
req
.
getSceneImageKey
()
!=
null
&&
!
req
.
getSceneImageKey
().
isBlank
();
boolean
hasProp
=
req
.
getPropImageKeys
()
!=
null
&&
!
req
.
getPropImageKeys
().
isEmpty
();
boolean
hasProp
=
req
.
getPropImageKeys
()
!=
null
&&
!
req
.
getPropImageKeys
().
isEmpty
();
if
(!
hasCharacter
&&
!
hasScene
&&
!
hasProp
)
{
boolean
hasFrame
=
(
req
.
getFirstFrameImageKey
()
!=
null
&&
!
req
.
getFirstFrameImageKey
().
isBlank
())
throw
new
BizException
(
ErrorCode
.
INVALID_PARAM
,
"请至少选择一张参考图(角色/场景/道具任一)"
);
||
(
req
.
getLastFrameImageKey
()
!=
null
&&
!
req
.
getLastFrameImageKey
().
isBlank
());
if
(!
hasCharacter
&&
!
hasScene
&&
!
hasProp
&&
!
hasFrame
)
{
throw
new
BizException
(
ErrorCode
.
INVALID_PARAM
,
"请至少选择一张参考图(角色/场景/道具/首尾帧任一)"
);
}
}
Long
tenantId
=
TenantContext
.
get
();
Long
tenantId
=
TenantContext
.
get
();
...
@@ -60,6 +62,8 @@ public class VideoTaskController {
...
@@ -60,6 +62,8 @@ public class VideoTaskController {
.
sceneImageKey
(
req
.
getSceneImageKey
())
.
sceneImageKey
(
req
.
getSceneImageKey
())
.
propImageKeys
(
req
.
getPropImageKeys
())
.
propImageKeys
(
req
.
getPropImageKeys
())
.
styleImageKey
(
req
.
getStyleImageKey
())
.
styleImageKey
(
req
.
getStyleImageKey
())
.
firstFrameImageKey
(
req
.
getFirstFrameImageKey
())
.
lastFrameImageKey
(
req
.
getLastFrameImageKey
())
.
characterAction
(
up
!=
null
?
up
.
getCharacterAction
()
:
null
)
.
characterAction
(
up
!=
null
?
up
.
getCharacterAction
()
:
null
)
.
sceneEvent
(
up
!=
null
?
up
.
getSceneEvent
()
:
null
)
.
sceneEvent
(
up
!=
null
?
up
.
getSceneEvent
()
:
null
)
.
cameraMovement
(
up
!=
null
?
up
.
getCameraMovement
()
:
null
)
.
cameraMovement
(
up
!=
null
?
up
.
getCameraMovement
()
:
null
)
...
...
yaoai-comic-studio/yaoai-api/src/main/java/com/yaoai/api/dto/ai/StoryboardDTO.java
View file @
787ae772
...
@@ -41,6 +41,10 @@ public class StoryboardDTO {
...
@@ -41,6 +41,10 @@ public class StoryboardDTO {
private
String
sceneImageKey
;
private
String
sceneImageKey
;
private
List
<
String
>
propImageKeys
;
private
List
<
String
>
propImageKeys
;
private
String
styleImageKey
;
private
String
styleImageKey
;
private
String
firstFrameImageKey
;
private
String
firstFrameImageUrl
;
private
String
lastFrameImageKey
;
private
String
lastFrameImageUrl
;
public
static
StoryboardDTO
from
(
Storyboard
s
)
{
public
static
StoryboardDTO
from
(
Storyboard
s
)
{
StoryboardDTO
dto
=
new
StoryboardDTO
();
StoryboardDTO
dto
=
new
StoryboardDTO
();
...
@@ -68,6 +72,10 @@ public class StoryboardDTO {
...
@@ -68,6 +72,10 @@ public class StoryboardDTO {
dto
.
setSceneImageKey
(
s
.
getSceneImageKey
());
dto
.
setSceneImageKey
(
s
.
getSceneImageKey
());
dto
.
setPropImageKeys
(
parseList
(
s
.
getPropImageKeys
(),
"propImageKeys"
,
s
.
getId
()));
dto
.
setPropImageKeys
(
parseList
(
s
.
getPropImageKeys
(),
"propImageKeys"
,
s
.
getId
()));
dto
.
setStyleImageKey
(
s
.
getStyleImageKey
());
dto
.
setStyleImageKey
(
s
.
getStyleImageKey
());
dto
.
setFirstFrameImageKey
(
s
.
getFirstFrameImageKey
());
dto
.
setFirstFrameImageUrl
(
s
.
getFirstFrameImageUrl
());
dto
.
setLastFrameImageKey
(
s
.
getLastFrameImageKey
());
dto
.
setLastFrameImageUrl
(
s
.
getLastFrameImageUrl
());
return
dto
;
return
dto
;
}
}
...
...
yaoai-comic-studio/yaoai-api/src/main/java/com/yaoai/api/dto/ai/StructuredVideoGenerateRequest.java
View file @
787ae772
...
@@ -46,6 +46,12 @@ public class StructuredVideoGenerateRequest {
...
@@ -46,6 +46,12 @@ public class StructuredVideoGenerateRequest {
/** 风格图(可选,固定为最后一张) */
/** 风格图(可选,固定为最后一张) */
private
String
styleImageKey
;
private
String
styleImageKey
;
/** 首帧参考图(可选,作为视频生成参考图参与,不强制模型首帧锁定) */
private
String
firstFrameImageKey
;
/** 尾帧参考图(可选,作为视频生成参考图参与,不强制模型尾帧锁定) */
private
String
lastFrameImageKey
;
/** 视频时长(秒) */
/** 视频时长(秒) */
@Min
(
value
=
5
,
message
=
"视频时长最短 5 秒"
)
@Min
(
value
=
5
,
message
=
"视频时长最短 5 秒"
)
@Max
(
value
=
15
,
message
=
"视频时长最长 15 秒"
)
@Max
(
value
=
15
,
message
=
"视频时长最长 15 秒"
)
...
...
yaoai-comic-studio/yaoai-bootstrap/src/main/resources/db/migration/V21__storyboard_frame_reference_images.sql
0 → 100644
View file @
787ae772
-- V21 分镜首尾帧参考图
ALTER
TABLE
storyboards
ADD
COLUMN
first_frame_image_key
VARCHAR
(
512
)
DEFAULT
NULL
COMMENT
'该分镜首帧参考图 TOS key'
,
ADD
COLUMN
first_frame_image_url
VARCHAR
(
1024
)
DEFAULT
NULL
COMMENT
'该分镜首帧参考图公开 URL'
,
ADD
COLUMN
last_frame_image_key
VARCHAR
(
512
)
DEFAULT
NULL
COMMENT
'该分镜尾帧参考图 TOS key'
,
ADD
COLUMN
last_frame_image_url
VARCHAR
(
1024
)
DEFAULT
NULL
COMMENT
'该分镜尾帧参考图公开 URL'
;
yaoai-comic-studio/yaoai-domain/src/main/java/com/yaoai/domain/entity/Storyboard.java
View file @
787ae772
...
@@ -43,6 +43,10 @@ public class Storyboard {
...
@@ -43,6 +43,10 @@ public class Storyboard {
private
String
sceneImageKey
;
private
String
sceneImageKey
;
private
String
propImageKeys
;
private
String
propImageKeys
;
private
String
styleImageKey
;
private
String
styleImageKey
;
private
String
firstFrameImageKey
;
private
String
firstFrameImageUrl
;
private
String
lastFrameImageKey
;
private
String
lastFrameImageUrl
;
/** draft / review / approved / generating / generated / failed */
/** draft / review / approved / generating / generated / failed */
private
String
status
;
private
String
status
;
...
...
yaoai-comic-studio/yaoai-pipeline/src/main/java/com/yaoai/pipeline/dto/StructuredVideoSubmission.java
View file @
787ae772
...
@@ -29,6 +29,10 @@ public class StructuredVideoSubmission {
...
@@ -29,6 +29,10 @@ public class StructuredVideoSubmission {
private
List
<
String
>
propImageKeys
;
private
List
<
String
>
propImageKeys
;
/** 风格图 TOS key(可空,永远是最后一张) */
/** 风格图 TOS key(可空,永远是最后一张) */
private
String
styleImageKey
;
private
String
styleImageKey
;
/** 首帧参考图 TOS key(可空,作为额外参考图参与) */
private
String
firstFrameImageKey
;
/** 尾帧参考图 TOS key(可空,作为额外参考图参与) */
private
String
lastFrameImageKey
;
/** 用户原始结构化输入(5 段或单段 freeform),用于落库存证 */
/** 用户原始结构化输入(5 段或单段 freeform),用于落库存证 */
private
Map
<
String
,
String
>
userPromptRaw
;
private
Map
<
String
,
String
>
userPromptRaw
;
...
...
yaoai-comic-studio/yaoai-pipeline/src/main/java/com/yaoai/pipeline/service/impl/VideoTaskPipelineServiceImpl.java
View file @
787ae772
...
@@ -23,6 +23,7 @@ import lombok.RequiredArgsConstructor;
...
@@ -23,6 +23,7 @@ import lombok.RequiredArgsConstructor;
import
lombok.extern.slf4j.Slf4j
;
import
lombok.extern.slf4j.Slf4j
;
import
org.springframework.stereotype.Service
;
import
org.springframework.stereotype.Service
;
import
java.util.ArrayList
;
import
java.util.Collections
;
import
java.util.Collections
;
import
java.util.List
;
import
java.util.List
;
...
@@ -188,18 +189,20 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
...
@@ -188,18 +189,20 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
}
}
boolean
hasScene
=
s
.
getSceneImageKey
()
!=
null
&&
!
s
.
getSceneImageKey
().
isBlank
();
boolean
hasScene
=
s
.
getSceneImageKey
()
!=
null
&&
!
s
.
getSceneImageKey
().
isBlank
();
int
propCount
=
s
.
getPropImageKeys
()
!=
null
?
s
.
getPropImageKeys
().
size
()
:
0
;
int
propCount
=
s
.
getPropImageKeys
()
!=
null
?
s
.
getPropImageKeys
().
size
()
:
0
;
List
<
String
>
frameImageKeys
=
frameImageKeys
(
s
);
int
frameCount
=
frameImageKeys
.
size
();
boolean
hasStyle
=
s
.
getStyleImageKey
()
!=
null
&&
!
s
.
getStyleImageKey
().
isBlank
();
boolean
hasStyle
=
s
.
getStyleImageKey
()
!=
null
&&
!
s
.
getStyleImageKey
().
isBlank
();
// 至少要有一张参考图(角色/场景/道具任一),否则前端不应允许提交
// 至少要有一张参考图(角色/场景/道具
/首尾帧
任一),否则前端不应允许提交
if
(
characterKeys
.
isEmpty
()
&&
!
hasScene
&&
propCount
==
0
)
{
if
(
characterKeys
.
isEmpty
()
&&
!
hasScene
&&
propCount
==
0
&&
frameCount
==
0
)
{
throw
new
BizException
(
ErrorCode
.
INVALID_PARAM
,
"至少需要一张参考图(角色/场景/道具任一)"
);
throw
new
BizException
(
ErrorCode
.
INVALID_PARAM
,
"至少需要一张参考图(角色/场景/道具
/首尾帧
任一)"
);
}
}
// 1. 拼装 final prompt + 图片顺序
// 1. 拼装 final prompt + 图片顺序
String
finalPrompt
;
String
finalPrompt
;
if
(
s
.
getFreeformPrompt
()
!=
null
&&
!
s
.
getFreeformPrompt
().
isBlank
())
{
if
(
s
.
getFreeformPrompt
()
!=
null
&&
!
s
.
getFreeformPrompt
().
isBlank
())
{
finalPrompt
=
SeedancePromptBuilder
.
buildPromptFreeform
(
finalPrompt
=
SeedancePromptBuilder
.
buildPromptFreeform
(
s
.
getFreeformPrompt
(),
characterKeys
.
size
(),
hasScene
,
propCount
,
hasStyle
);
s
.
getFreeformPrompt
(),
characterKeys
.
size
(),
hasScene
,
propCount
,
frameCount
,
hasStyle
);
}
else
{
}
else
{
finalPrompt
=
SeedancePromptBuilder
.
buildPrompt
(
finalPrompt
=
SeedancePromptBuilder
.
buildPrompt
(
new
SeedancePromptBuilder
.
UserPromptParts
(
new
SeedancePromptBuilder
.
UserPromptParts
(
...
@@ -212,6 +215,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
...
@@ -212,6 +215,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
characterKeys
.
size
(),
characterKeys
.
size
(),
hasScene
,
hasScene
,
propCount
,
propCount
,
frameCount
,
hasStyle
hasStyle
);
);
}
}
...
@@ -219,6 +223,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
...
@@ -219,6 +223,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
characterKeys
,
characterKeys
,
s
.
getSceneImageKey
(),
s
.
getSceneImageKey
(),
s
.
getPropImageKeys
(),
s
.
getPropImageKeys
(),
frameImageKeys
,
s
.
getStyleImageKey
()
s
.
getStyleImageKey
()
);
);
...
@@ -260,7 +265,8 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
...
@@ -260,7 +265,8 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
java
.
util
.
Map
.
of
(
java
.
util
.
Map
.
of
(
"durationSeconds"
,
durationOrDefault
(
s
.
getDurationSeconds
()),
"durationSeconds"
,
durationOrDefault
(
s
.
getDurationSeconds
()),
"ratio"
,
s
.
getRatio
()
!=
null
?
s
.
getRatio
()
:
"16:9"
,
"ratio"
,
s
.
getRatio
()
!=
null
?
s
.
getRatio
()
:
"16:9"
,
"generateAudio"
,
Boolean
.
TRUE
.
equals
(
s
.
getGenerateAudio
())
"generateAudio"
,
Boolean
.
TRUE
.
equals
(
s
.
getGenerateAudio
()),
"frameImageKeys"
,
frameImageKeys
)
)
);
);
log
.
info
(
"Structured video task created: id={}, characters={}, images={}, duration={}s, ratio={}"
,
log
.
info
(
"Structured video task created: id={}, characters={}, images={}, duration={}s, ratio={}"
,
...
@@ -287,6 +293,8 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
...
@@ -287,6 +293,8 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
patch
.
setSceneImageKey
(
s
.
getSceneImageKey
());
patch
.
setSceneImageKey
(
s
.
getSceneImageKey
());
patch
.
setPropImageKeys
(
toJson
(
s
.
getPropImageKeys
()));
patch
.
setPropImageKeys
(
toJson
(
s
.
getPropImageKeys
()));
patch
.
setStyleImageKey
(
s
.
getStyleImageKey
());
patch
.
setStyleImageKey
(
s
.
getStyleImageKey
());
patch
.
setFirstFrameImageKey
(
s
.
getFirstFrameImageKey
());
patch
.
setLastFrameImageKey
(
s
.
getLastFrameImageKey
());
patch
.
setDurationSeconds
(
durationOrDefault
(
s
.
getDurationSeconds
()));
patch
.
setDurationSeconds
(
durationOrDefault
(
s
.
getDurationSeconds
()));
String
editablePrompt
=
s
.
getFreeformPrompt
()
!=
null
&&
!
s
.
getFreeformPrompt
().
isBlank
()
String
editablePrompt
=
s
.
getFreeformPrompt
()
!=
null
&&
!
s
.
getFreeformPrompt
().
isBlank
()
?
s
.
getFreeformPrompt
()
?
s
.
getFreeformPrompt
()
...
@@ -295,6 +303,17 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
...
@@ -295,6 +303,17 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
storyboardMapper
.
updateById
(
patch
);
storyboardMapper
.
updateById
(
patch
);
}
}
private
List
<
String
>
frameImageKeys
(
StructuredVideoSubmission
s
)
{
List
<
String
>
keys
=
new
ArrayList
<>(
2
);
if
(
s
.
getFirstFrameImageKey
()
!=
null
&&
!
s
.
getFirstFrameImageKey
().
isBlank
())
{
keys
.
add
(
s
.
getFirstFrameImageKey
());
}
if
(
s
.
getLastFrameImageKey
()
!=
null
&&
!
s
.
getLastFrameImageKey
().
isBlank
())
{
keys
.
add
(
s
.
getLastFrameImageKey
());
}
return
keys
;
}
private
Integer
durationOrDefault
(
Integer
durationSeconds
)
{
private
Integer
durationOrDefault
(
Integer
durationSeconds
)
{
if
(
durationSeconds
==
null
||
durationSeconds
<=
0
)
{
if
(
durationSeconds
==
null
||
durationSeconds
<=
0
)
{
return
5
;
return
5
;
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment