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