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
7a80307a
Commit
7a80307a
authored
May 29, 2026
by
yaoke.yk
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
项目封面手动上传
parent
38e3bbe5
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
79 additions
and
20 deletions
+79
-20
ProjectDetail.tsx
doc/html/src/app/pages/ProjectDetail.tsx
+46
-4
StoryboardWorkspace.tsx
doc/html/src/app/pages/StoryboardWorkspace.tsx
+22
-7
StructuredVideoGenerateRequest.java
.../com/yaoai/api/dto/ai/StructuredVideoGenerateRequest.java
+5
-5
VideoTaskAsyncProcessor.java
...ava/com/yaoai/pipeline/async/VideoTaskAsyncProcessor.java
+1
-1
VideoTaskPipelineServiceImpl.java
...i/pipeline/service/impl/VideoTaskPipelineServiceImpl.java
+5
-3
No files found.
doc/html/src/app/pages/ProjectDetail.tsx
View file @
7a80307a
import
{
use
State
}
from
"react"
;
import
{
use
Ref
,
useState
,
type
ChangeEvent
}
from
"react"
;
import
{
useParams
,
useNavigate
}
from
"react-router"
;
import
{
Film
,
...
...
@@ -16,7 +16,7 @@ import {
Plus
,
X
,
}
from
"lucide-react"
;
import
{
useProject
}
from
"../../hooks/useProjects"
;
import
{
useProject
,
useUploadAsset
}
from
"../../hooks/useProjects"
;
import
{
useOutline
,
useEpisodes
,
...
...
@@ -236,12 +236,33 @@ function ProjectScriptView({
];
const
displayCharacters
=
characters
.
length
>
0
?
characters
.
slice
(
0
,
4
)
:
fallbackCharacters
;
const
nextPath
=
episodes
.
length
===
0
?
`/project/
${
projectId
}
/outline`
:
`/project/
${
projectId
}
/characters`
;
const
coverInputRef
=
useRef
<
HTMLInputElement
|
null
>
(
null
);
const
uploadCover
=
useUploadAsset
(
projectId
??
""
);
const
coverActionLabel
=
project
?.
coverUrl
?
"更换封面"
:
"上传封面"
;
const
handleChooseCover
=
()
=>
{
if
(
!
projectId
||
uploadCover
.
isPending
)
return
;
coverInputRef
.
current
?.
click
();
};
const
handleCoverFileChange
=
(
event
:
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
file
=
event
.
target
.
files
?.[
0
];
if
(
!
file
||
!
projectId
)
return
;
uploadCover
.
mutate
({
file
,
assetType
:
"cover"
});
event
.
target
.
value
=
""
;
};
return
(
<
div
className=
"relative h-full overflow-auto bg-[#f7f8fb] text-[#111827]"
>
<
main
className=
"mx-auto max-w-[860px] px-6 pb-32 pt-12"
>
<
section
className=
"flex flex-col gap-8 sm:flex-row sm:items-start"
>
<
div
className=
"h-[218px] w-[154px] flex-shrink-0 overflow-hidden rounded-[3px] bg-[#dfe3ea] shadow-[0_18px_48px_rgba(15,23,42,0.14)]"
>
<
button
type=
"button"
onClick=
{
handleChooseCover
}
disabled=
{
!
projectId
||
uploadCover
.
isPending
}
title=
{
coverActionLabel
}
className=
"group relative h-[218px] w-[154px] flex-shrink-0 overflow-hidden rounded-[3px] bg-[#dfe3ea] text-left shadow-[0_18px_48px_rgba(15,23,42,0.14)] outline-none transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_58px_rgba(15,23,42,0.18)] focus-visible:ring-2 focus-visible:ring-[#6d5cff] focus-visible:ring-offset-2 disabled:cursor-wait"
>
{
project
?.
coverUrl
?
(
<
img
src=
{
project
.
coverUrl
}
alt=
"项目封面"
className=
"h-full w-full object-cover"
/>
)
:
(
...
...
@@ -252,7 +273,28 @@ function ProjectScriptView({
</
span
>
</
div
>
)
}
</
div
>
<
div
className=
{
`pointer-events-none absolute inset-x-0 bottom-0 flex min-h-14 items-end justify-center bg-gradient-to-t from-black/72 via-black/30 to-transparent px-3 pb-3 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 ${
project?.coverUrl && !uploadCover.isPending ? "opacity-0" : "opacity-100"
}`
}
>
<
span
className=
"inline-flex items-center gap-1.5 rounded-full bg-white/92 px-3 py-1.5 text-xs font-semibold text-[#111827] shadow-sm"
>
{
uploadCover
.
isPending
?
(
<
Loader2
className=
"h-3.5 w-3.5 animate-spin"
/>
)
:
(
<
ImageIcon
className=
"h-3.5 w-3.5 text-[#6254ff]"
/>
)
}
{
uploadCover
.
isPending
?
"上传中..."
:
coverActionLabel
}
</
span
>
</
div
>
</
button
>
<
input
ref=
{
coverInputRef
}
type=
"file"
accept=
"image/*"
className=
"hidden"
onChange=
{
handleCoverFileChange
}
/>
<
div
className=
"min-w-0 flex-1 pt-1"
>
<
h1
className=
"mb-4 text-[28px] font-semibold leading-[1.22] tracking-normal text-[#0b1220]"
>
...
...
doc/html/src/app/pages/StoryboardWorkspace.tsx
View file @
7a80307a
...
...
@@ -28,15 +28,21 @@ import {
import
type
{
Storyboard
,
Character
,
CharacterState
,
Scene
,
Episode
,
AiTask
}
from
"../../lib/api/ai"
;
import
{
useOperationCost
}
from
"../../hooks/useUsage"
;
const
DURATION_OPTIONS
=
[
5
,
10
,
11
,
15
,
20
,
30
];
const
MIN_VIDEO_DURATION
=
5
;
const
MAX_VIDEO_DURATION
=
15
;
const
DEFAULT_VIDEO_DURATION
=
11
;
const
DURATION_OPTIONS
=
Array
.
from
(
{
length
:
MAX_VIDEO_DURATION
-
MIN_VIDEO_DURATION
+
1
},
(
_
,
index
)
=>
MIN_VIDEO_DURATION
+
index
);
const
RATIO_OPTIONS
=
[
{
value
:
"16:9"
,
label
:
"16:9 横屏"
},
{
value
:
"9:16"
,
label
:
"9:16 竖屏"
},
{
value
:
"1:1"
,
label
:
"1:1 方形"
},
];
const
VIDEO_MODEL_OPTIONS
=
[
{
value
:
"doubao-seedance-2-0-fast-260128"
,
label
:
"Doubao-Seedance-2-0"
},
{
value
:
"doubao-seedance-2-0-260128"
,
label
:
"Doubao-Seedance-2-0
Pro
"
},
{
value
:
"doubao-seedance-2-0-fast-260128"
,
label
:
"Doubao-Seedance-2-0
fast
"
},
{
value
:
"doubao-seedance-2-0-260128"
,
label
:
"Doubao-Seedance-2-0"
},
];
const
DEFAULT_VIDEO_MODEL
=
VIDEO_MODEL_OPTIONS
[
0
].
value
;
const
MAX_CHARACTERS
=
5
;
...
...
@@ -82,6 +88,13 @@ const FOCUS_RING = "focus:outline-none focus:ring-2 focus:ring-[#1c64ff]/20 focu
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)]"
;
function
normalizeVideoDuration
(
value
:
number
|
null
|
undefined
)
{
if
(
typeof
value
!==
"number"
||
Number
.
isNaN
(
value
))
{
return
DEFAULT_VIDEO_DURATION
;
}
return
Math
.
min
(
MAX_VIDEO_DURATION
,
Math
.
max
(
MIN_VIDEO_DURATION
,
Math
.
round
(
value
)));
}
const
FREEFORM_PLACEHOLDER
=
`【镜头1】 时长2s | 蒙太奇快切 | 固定机位快速切出
画面定格在角色刚转头的瞬间,背景虚化为暖色光斑
...
...
@@ -300,7 +313,7 @@ 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(
11
);
const [selectedDuration, setSelectedDuration] = useState(
DEFAULT_VIDEO_DURATION
);
const [selectedRatio, setSelectedRatio] = useState<string>("16:9");
const [selectedModel, setSelectedModel] = useState<string>(DEFAULT_VIDEO_MODEL);
const [audioOn, setAudioOn] = useState(true);
...
...
@@ -399,7 +412,7 @@ export function StoryboardWorkspace() {
setSceneKey(nextSceneKey);
setPropKeys(draft.propKeys);
setFreeformPrompt(draft.freeformPrompt);
setSelectedDuration(
draft.selectedDuration
);
setSelectedDuration(
normalizeVideoDuration(draft.selectedDuration)
);
setSelectedRatio(draft.selectedRatio);
setSelectedModel(draft.selectedModel);
setAudioOn(draft.audioOn);
...
...
@@ -431,7 +444,9 @@ export function StoryboardWorkspace() {
}
setPropKeys(storyboardPropKeys.length > 0 ? storyboardPropKeys : (lastTask?.propImageKeys ?? []));
setFreeformPrompt(expanded.videoPrompt?.trim() || (lastTask?.userPrompt?.freeformPrompt ?? ""));
if (typeof lastTask?.videoDuration === "number") setSelectedDuration(lastTask.videoDuration);
if (typeof lastTask?.videoDuration === "number") {
setSelectedDuration(normalizeVideoDuration(lastTask.videoDuration));
}
if (lastTask?.videoRatio) setSelectedRatio(lastTask.videoRatio);
if (typeof lastTask?.generateAudio === "boolean") setAudioOn(lastTask.generateAudio);
}, [
...
...
@@ -900,7 +915,7 @@ export function StoryboardWorkspace() {
<div className="relative">
<select
value={selectedDuration}
onChange={(e) => setSelectedDuration(
Number(e.target.value
))}
onChange={(e) => setSelectedDuration(
normalizeVideoDuration(Number(e.target.value)
))}
disabled={!expanded}
className={`
appearance
-
none
pl
-
2
pr
-
6
py
-
2
rounded
-
md
bg
-
white
text
-
xs
text
-
[
#
111827
]
cursor
-
pointer
w
-
16
disabled
:
opacity
-
50
transition
-
colors
tabular
-
nums
$
{
CONTROL_SURFACE
}
$
{
FOCUS_RING
}
`}
>
...
...
yaoai-comic-studio/yaoai-api/src/main/java/com/yaoai/api/dto/ai/StructuredVideoGenerateRequest.java
View file @
7a80307a
...
...
@@ -11,7 +11,7 @@ import java.util.List;
/**
* 分镜视频结构化生成请求。
* 替代旧的
「prompt 内夹 @图N + image_keys[] 顺序绑定」
方式。
* 替代旧的
“prompt 内嵌 @图N + image_keys[] 顺序绑定”
方式。
*/
@Data
public
class
StructuredVideoGenerateRequest
{
...
...
@@ -25,21 +25,21 @@ public class StructuredVideoGenerateRequest {
/** @图1 角色图(旧版单角色字段,characterImageKeys 为空时使用) */
private
String
characterImageKey
;
/** V16:多角色出镜
(@图1..@图N,按顺序),与
characterImageKey 至少传一个 */
/** V16:多角色出镜
,按 @图1..@图N 顺序,和
characterImageKey 至少传一个 */
@Size
(
max
=
5
,
message
=
"出镜角色最多 5 个"
)
private
List
<
String
>
characterImageKeys
;
/** 场景图(可选)。角色/场景/道具至少传一种参考图,由 service 层校验。 */
private
String
sceneImageKey
;
/** 用户结构化提示词(旧版 5 段,
与
freeformPrompt 二选一,至少一项非空) */
/** 用户结构化提示词(旧版 5 段,
和
freeformPrompt 二选一,至少一项非空) */
@Valid
private
UserPrompt
userPrompt
;
/** V17:单段 freeform 提示词(前端简化为单输入框后的主要入口) */
private
String
freeformPrompt
;
/** @图
3+ 道具图(可选,按顺序对应 @图3、@图4...,最多
5 张) */
/** @图
+ 道具图(可选,按顺序对应 @图N、@图...,最大
5 张) */
@Size
(
max
=
5
,
message
=
"道具图最多 5 张"
)
private
List
<
String
>
propImageKeys
;
...
...
@@ -48,7 +48,7 @@ public class StructuredVideoGenerateRequest {
/** 视频时长(秒) */
@Min
(
value
=
5
,
message
=
"视频时长最短 5 秒"
)
@Max
(
value
=
30
,
message
=
"视频时长最长 30
秒"
)
@Max
(
value
=
15
,
message
=
"视频时长最长 15
秒"
)
private
Integer
duration
=
15
;
/** 16:9 / 9:16 / 1:1 */
...
...
yaoai-comic-studio/yaoai-pipeline/src/main/java/com/yaoai/pipeline/async/VideoTaskAsyncProcessor.java
View file @
7a80307a
...
...
@@ -94,7 +94,7 @@ public class VideoTaskAsyncProcessor {
}
// 提交 Seedance,传入所有参考图 + 时长
int
duration
=
durationSeconds
>
0
?
durationSeconds
:
5
;
int
duration
=
durationSeconds
>
0
?
Math
.
min
(
15
,
Math
.
max
(
5
,
durationSeconds
))
:
5
;
String
externalTaskId
=
seedanceService
.
submitVideoTask
(
presignedUrls
,
videoPrompt
,
duration
,
generateAudio
,
ratio
,
model
);
log
.
info
(
"Seedance task submitted: taskId={}, externalId={}, images={}, duration={}s"
,
taskId
,
externalTaskId
,
presignedUrls
.
size
(),
duration
);
...
...
yaoai-comic-studio/yaoai-pipeline/src/main/java/com/yaoai/pipeline/service/impl/VideoTaskPipelineServiceImpl.java
View file @
7a80307a
...
...
@@ -267,8 +267,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
task
.
getId
(),
characterKeys
.
size
(),
orderedKeys
.
size
(),
s
.
getDurationSeconds
(),
s
.
getRatio
());
// 3. 复用 async processor:将拼接好的 prompt + 有序 key 列表交给后台
int
duration
=
s
.
getDurationSeconds
()
!=
null
&&
s
.
getDurationSeconds
()
>
0
?
s
.
getDurationSeconds
()
:
15
;
int
duration
=
durationOrDefault
(
s
.
getDurationSeconds
());
String
ratio
=
s
.
getRatio
()
!=
null
&&
!
s
.
getRatio
().
isBlank
()
?
s
.
getRatio
()
:
"16:9"
;
boolean
generateAudio
=
Boolean
.
TRUE
.
equals
(
s
.
getGenerateAudio
());
asyncProcessor
.
processTextToVideo
(
task
.
getId
(),
tenantId
,
s
.
getUserId
(),
projectId
,
...
...
@@ -297,7 +296,10 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
}
private
Integer
durationOrDefault
(
Integer
durationSeconds
)
{
return
durationSeconds
!=
null
&&
durationSeconds
>
0
?
durationSeconds
:
5
;
if
(
durationSeconds
==
null
||
durationSeconds
<=
0
)
{
return
5
;
}
return
Math
.
min
(
15
,
Math
.
max
(
5
,
durationSeconds
));
}
private
static
String
toJson
(
Object
value
)
{
...
...
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