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
a19ab7b9
Commit
a19ab7b9
authored
Apr 28, 2026
by
yaoke.yk
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(storyboard): persist scene refs and video prompts
parent
b9d8f02b
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
561 additions
and
150 deletions
+561
-150
StoryboardController.java
...n/java/com/yaoai/api/controller/StoryboardController.java
+2
-2
StoryboardPipelineServiceImplTest.java
...eline/service/impl/StoryboardPipelineServiceImplTest.java
+267
-0
StoryboardPipelineService.java
...com/yaoai/pipeline/service/StoryboardPipelineService.java
+2
-0
StoryboardPipelineServiceImpl.java
.../pipeline/service/impl/StoryboardPipelineServiceImpl.java
+290
-148
No files found.
yaoai-comic-studio/yaoai-api/src/main/java/com/yaoai/api/controller/StoryboardController.java
View file @
a19ab7b9
...
...
@@ -98,7 +98,7 @@ public class StoryboardController {
public
ApiResponse
<
Map
<
String
,
String
>>
generatePrompt
(
@PathVariable
Long
projectId
,
@PathVariable
Long
storyboardId
)
{
Long
tenantId
=
TenantContext
.
get
();
String
prompt
=
storyboardService
.
generatePrompt
(
storyboardId
,
tenantId
);
return
ApiResponse
.
success
(
Map
.
of
(
"prompt"
,
prompt
)
);
Map
<
String
,
String
>
body
=
Map
.
of
(
"prompt"
,
storyboardService
.
generatePrompt
(
storyboardId
,
tenantId
)
);
return
ApiResponse
.
success
(
body
);
}
}
yaoai-comic-studio/yaoai-bootstrap/src/test/java/com/yaoai/pipeline/service/impl/StoryboardPipelineServiceImplTest.java
0 → 100644
View file @
a19ab7b9
package
com
.
yaoai
.
pipeline
.
service
.
impl
;
import
com.fasterxml.jackson.databind.ObjectMapper
;
import
com.yaoai.ai.core.model.ChatRequest
;
import
com.yaoai.ai.core.service.LlmService
;
import
com.yaoai.billing.service.BillingService
;
import
com.yaoai.common.context.UserContext
;
import
com.yaoai.domain.entity.Episode
;
import
com.yaoai.domain.entity.Scene
;
import
com.yaoai.domain.entity.Storyboard
;
import
com.yaoai.domain.mapper.CharacterMapper
;
import
com.yaoai.domain.mapper.EpisodeMapper
;
import
com.yaoai.domain.mapper.SceneMapper
;
import
com.yaoai.domain.mapper.StoryboardMapper
;
import
org.junit.jupiter.api.AfterEach
;
import
org.junit.jupiter.api.BeforeEach
;
import
org.junit.jupiter.api.Test
;
import
org.junit.jupiter.api.extension.ExtendWith
;
import
org.mockito.ArgumentCaptor
;
import
org.mockito.Mock
;
import
org.mockito.junit.jupiter.MockitoExtension
;
import
java.util.List
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertEquals
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertNull
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertSame
;
import
static
org
.
junit
.
jupiter
.
api
.
Assertions
.
assertTrue
;
import
static
org
.
mockito
.
ArgumentMatchers
.
any
;
import
static
org
.
mockito
.
ArgumentMatchers
.
anyLong
;
import
static
org
.
mockito
.
Mockito
.
never
;
import
static
org
.
mockito
.
Mockito
.
times
;
import
static
org
.
mockito
.
Mockito
.
verify
;
import
static
org
.
mockito
.
Mockito
.
when
;
@ExtendWith
(
MockitoExtension
.
class
)
class
StoryboardPipelineServiceImplTest
{
@Mock
private
LlmService
llmService
;
@Mock
private
StoryboardMapper
storyboardMapper
;
@Mock
private
EpisodeMapper
episodeMapper
;
@Mock
private
CharacterMapper
characterMapper
;
@Mock
private
SceneMapper
sceneMapper
;
@Mock
private
BillingService
billingService
;
private
StoryboardPipelineServiceImpl
service
;
@BeforeEach
void
setUp
()
{
service
=
new
StoryboardPipelineServiceImpl
(
llmService
,
storyboardMapper
,
episodeMapper
,
characterMapper
,
sceneMapper
,
new
ObjectMapper
(),
billingService
);
UserContext
.
set
(
1001L
);
}
@AfterEach
void
tearDown
()
{
UserContext
.
clear
();
}
@Test
void
generateStoryboards_shouldSplitSceneRefFromCharacters
()
{
Episode
episode
=
new
Episode
();
episode
.
setId
(
11L
);
episode
.
setTitle
(
"ep-1"
);
episode
.
setSummary
(
"summary"
);
when
(
episodeMapper
.
selectById
(
11L
)).
thenReturn
(
episode
);
when
(
characterMapper
.
findByProject
(
22L
,
33L
)).
thenReturn
(
List
.
of
());
when
(
sceneMapper
.
findByProject
(
22L
,
33L
)).
thenReturn
(
List
.
of
(
scene
(
"cafe"
),
scene
(
"street"
)));
when
(
llmService
.
chat
(
any
(
ChatRequest
.
class
))).
thenReturn
(
"""
[{
"
sequence_num
": 1,
"
scene_number
": "
001
",
"
short_description
": "
shot
",
"
detailed_description
": "
detail
",
"
characters
": "
@hero
,
@cafe
,
@friend
",
"
dialogues
": "",
"
camera_direction
": "
push
",
"
composition_guide
": "
medium
",
"
duration_seconds
": 5,
"
start_frame_prompt
": "
prompt
",
"
motion_script
": "
move
"
}]
"""
);
List
<
Storyboard
>
storyboards
=
service
.
generateStoryboards
(
11L
,
22L
,
33L
);
assertEquals
(
1
,
storyboards
.
size
());
assertEquals
(
"@cafe"
,
storyboards
.
get
(
0
).
getSceneRef
());
assertEquals
(
"@hero,@friend"
,
storyboards
.
get
(
0
).
getCharacters
());
}
@Test
void
generatePrompt_shouldPersistVideoPrompt
()
{
Storyboard
storyboard
=
new
Storyboard
();
storyboard
.
setId
(
7L
);
storyboard
.
setTenantId
(
33L
);
storyboard
.
setProjectId
(
22L
);
storyboard
.
setEpisodeId
(
11L
);
storyboard
.
setSequenceNum
(
1
);
storyboard
.
setShortDescription
(
"short"
);
storyboard
.
setDetailedDescription
(
"detail"
);
storyboard
.
setCharacters
(
"@hero"
);
storyboard
.
setSceneRef
(
"@cafe"
);
storyboard
.
setDialogues
(
"hero: hello"
);
storyboard
.
setCameraDirection
(
"push"
);
storyboard
.
setCompositionGuide
(
"medium"
);
storyboard
.
setDurationSeconds
(
5
);
when
(
storyboardMapper
.
selectById
(
7L
)).
thenReturn
(
storyboard
);
when
(
episodeMapper
.
selectById
(
11L
)).
thenReturn
(
new
Episode
());
when
(
characterMapper
.
findByProject
(
22L
,
33L
)).
thenReturn
(
List
.
of
());
when
(
sceneMapper
.
findByProject
(
22L
,
33L
)).
thenReturn
(
List
.
of
());
when
(
llmService
.
chat
(
any
(
ChatRequest
.
class
))).
thenReturn
(
"persisted prompt"
);
String
prompt
=
service
.
generatePrompt
(
7L
,
33L
);
assertEquals
(
"persisted prompt"
,
prompt
);
ArgumentCaptor
<
Storyboard
>
updateCaptor
=
ArgumentCaptor
.
forClass
(
Storyboard
.
class
);
verify
(
storyboardMapper
).
updateById
(
updateCaptor
.
capture
());
Storyboard
persisted
=
updateCaptor
.
getValue
();
assertEquals
(
7L
,
persisted
.
getId
());
assertEquals
(
"persisted prompt"
,
persisted
.
getVideoPrompt
());
ArgumentCaptor
<
ChatRequest
>
requestCaptor
=
ArgumentCaptor
.
forClass
(
ChatRequest
.
class
);
verify
(
llmService
).
chat
(
requestCaptor
.
capture
());
String
userMessage
=
requestCaptor
.
getValue
().
getMessages
().
get
(
1
).
getContent
();
assertTrue
(
userMessage
.
contains
(
"@cafe"
));
}
@Test
void
generatePrompt_shouldFallbackToLegacySceneRefsStoredInCharacters
()
{
Storyboard
storyboard
=
new
Storyboard
();
storyboard
.
setId
(
8L
);
storyboard
.
setTenantId
(
33L
);
storyboard
.
setProjectId
(
22L
);
storyboard
.
setEpisodeId
(
11L
);
storyboard
.
setSequenceNum
(
2
);
storyboard
.
setShortDescription
(
"legacy"
);
storyboard
.
setDetailedDescription
(
"legacy detail"
);
storyboard
.
setCharacters
(
"@hero,@cafe"
);
storyboard
.
setDialogues
(
""
);
storyboard
.
setCameraDirection
(
"static"
);
storyboard
.
setCompositionGuide
(
"wide"
);
storyboard
.
setDurationSeconds
(
6
);
when
(
storyboardMapper
.
selectById
(
8L
)).
thenReturn
(
storyboard
);
when
(
episodeMapper
.
selectById
(
11L
)).
thenReturn
(
new
Episode
());
when
(
characterMapper
.
findByProject
(
22L
,
33L
)).
thenReturn
(
List
.
of
());
when
(
sceneMapper
.
findByProject
(
22L
,
33L
)).
thenReturn
(
List
.
of
(
scene
(
"cafe"
)));
when
(
llmService
.
chat
(
any
(
ChatRequest
.
class
))).
thenReturn
(
"legacy prompt"
);
service
.
generatePrompt
(
8L
,
33L
);
ArgumentCaptor
<
ChatRequest
>
requestCaptor
=
ArgumentCaptor
.
forClass
(
ChatRequest
.
class
);
verify
(
llmService
).
chat
(
requestCaptor
.
capture
());
String
userMessage
=
requestCaptor
.
getValue
().
getMessages
().
get
(
1
).
getContent
();
assertTrue
(
userMessage
.
contains
(
"Character refs: @hero"
));
assertTrue
(
userMessage
.
contains
(
"Scene refs: @cafe"
));
}
@Test
void
update_shouldClearVideoPromptWhenStoryboardBodyChanges
()
{
Storyboard
existing
=
new
Storyboard
();
existing
.
setId
(
9L
);
existing
.
setTenantId
(
33L
);
existing
.
setShortDescription
(
"old"
);
existing
.
setVideoPrompt
(
"stale prompt"
);
Storyboard
refreshed
=
new
Storyboard
();
refreshed
.
setId
(
9L
);
refreshed
.
setTenantId
(
33L
);
when
(
storyboardMapper
.
selectById
(
9L
)).
thenReturn
(
existing
,
refreshed
);
Storyboard
patch
=
new
Storyboard
();
patch
.
setShortDescription
(
"new"
);
Storyboard
result
=
service
.
update
(
9L
,
33L
,
patch
);
assertSame
(
refreshed
,
result
);
ArgumentCaptor
<
Storyboard
>
updateCaptor
=
ArgumentCaptor
.
forClass
(
Storyboard
.
class
);
verify
(
storyboardMapper
).
updateById
(
updateCaptor
.
capture
());
Storyboard
updated
=
updateCaptor
.
getValue
();
assertEquals
(
9L
,
updated
.
getId
());
assertEquals
(
33L
,
updated
.
getTenantId
());
ArgumentCaptor
<
com
.
baomidou
.
mybatisplus
.
core
.
conditions
.
Wrapper
<
Storyboard
>>
wrapperCaptor
=
ArgumentCaptor
.
forClass
(
com
.
baomidou
.
mybatisplus
.
core
.
conditions
.
Wrapper
.
class
);
verify
(
storyboardMapper
).
update
(
org
.
mockito
.
ArgumentMatchers
.
isNull
(),
wrapperCaptor
.
capture
());
assertTrue
(
wrapperCaptor
.
getValue
().
getSqlSet
().
contains
(
"video_prompt"
));
}
@Test
void
update_shouldKeepVideoPromptWhenStoryboardBodyDoesNotChange
()
{
Storyboard
existing
=
new
Storyboard
();
existing
.
setId
(
9L
);
existing
.
setTenantId
(
33L
);
existing
.
setShortDescription
(
"same"
);
when
(
storyboardMapper
.
selectById
(
9L
)).
thenReturn
(
existing
,
existing
);
Storyboard
patch
=
new
Storyboard
();
patch
.
setShortDescription
(
"same"
);
Storyboard
result
=
service
.
update
(
9L
,
33L
,
patch
);
assertSame
(
existing
,
result
);
verify
(
storyboardMapper
).
updateById
(
any
(
Storyboard
.
class
));
verify
(
storyboardMapper
,
never
()).
update
(
org
.
mockito
.
ArgumentMatchers
.
isNull
(),
any
());
}
@Test
void
populateMissingVideoPromptsByEpisode_shouldFillOnlyMissingRows
()
{
Storyboard
missingA
=
new
Storyboard
();
missingA
.
setId
(
1L
);
Storyboard
missingB
=
new
Storyboard
();
missingB
.
setId
(
2L
);
Storyboard
full
=
new
Storyboard
();
full
.
setId
(
3L
);
when
(
storyboardMapper
.
findMissingVideoPromptByEpisode
(
44L
,
33L
)).
thenReturn
(
List
.
of
(
missingA
,
missingB
));
when
(
storyboardMapper
.
findByEpisode
(
44L
,
33L
)).
thenReturn
(
List
.
of
(
missingA
,
missingB
,
full
));
when
(
storyboardMapper
.
selectById
(
1L
)).
thenReturn
(
promptStoryboard
(
1L
,
44L
,
22L
,
33L
));
when
(
storyboardMapper
.
selectById
(
2L
)).
thenReturn
(
promptStoryboard
(
2L
,
44L
,
22L
,
33L
));
when
(
episodeMapper
.
selectById
(
44L
)).
thenReturn
(
new
Episode
());
when
(
characterMapper
.
findByProject
(
anyLong
(),
anyLong
())).
thenReturn
(
List
.
of
());
when
(
sceneMapper
.
findByProject
(
anyLong
(),
anyLong
())).
thenReturn
(
List
.
of
());
when
(
llmService
.
chat
(
any
(
ChatRequest
.
class
))).
thenReturn
(
"prompt-1"
,
"prompt-2"
);
List
<
Storyboard
>
result
=
service
.
populateMissingVideoPromptsByEpisode
(
44L
,
33L
);
assertEquals
(
3
,
result
.
size
());
verify
(
storyboardMapper
,
times
(
2
)).
updateById
(
any
(
Storyboard
.
class
));
}
private
static
Scene
scene
(
String
name
)
{
Scene
scene
=
new
Scene
();
scene
.
setName
(
name
);
return
scene
;
}
private
static
Storyboard
promptStoryboard
(
Long
id
,
Long
episodeId
,
Long
projectId
,
Long
tenantId
)
{
Storyboard
storyboard
=
new
Storyboard
();
storyboard
.
setId
(
id
);
storyboard
.
setEpisodeId
(
episodeId
);
storyboard
.
setProjectId
(
projectId
);
storyboard
.
setTenantId
(
tenantId
);
storyboard
.
setSequenceNum
(
id
.
intValue
());
storyboard
.
setShortDescription
(
"short-"
+
id
);
storyboard
.
setDetailedDescription
(
"detail-"
+
id
);
storyboard
.
setCharacters
(
"@hero"
);
storyboard
.
setDurationSeconds
(
5
);
storyboard
.
setDialogues
(
""
);
storyboard
.
setCameraDirection
(
"push"
);
storyboard
.
setCompositionGuide
(
"medium"
);
return
storyboard
;
}
}
yaoai-comic-studio/yaoai-pipeline/src/main/java/com/yaoai/pipeline/service/StoryboardPipelineService.java
View file @
a19ab7b9
...
...
@@ -30,4 +30,6 @@ public interface StoryboardPipelineService {
* AI 根据分镜剧本信息生成视频描述提示词(分时段影视风格)
*/
String
generatePrompt
(
Long
storyboardId
,
Long
tenantId
);
List
<
Storyboard
>
populateMissingVideoPromptsByEpisode
(
Long
episodeId
,
Long
tenantId
);
}
yaoai-comic-studio/yaoai-pipeline/src/main/java/com/yaoai/pipeline/service/impl/StoryboardPipelineServiceImpl.java
View file @
a19ab7b9
package
com
.
yaoai
.
pipeline
.
service
.
impl
;
import
com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper
;
import
com.fasterxml.jackson.core.type.TypeReference
;
import
com.fasterxml.jackson.databind.ObjectMapper
;
import
com.yaoai.ai.core.model.ChatMessage
;
...
...
@@ -24,60 +25,78 @@ import lombok.extern.slf4j.Slf4j;
import
org.springframework.stereotype.Service
;
import
java.util.ArrayList
;
import
java.util.Arrays
;
import
java.util.LinkedHashSet
;
import
java.util.List
;
import
java.util.Map
;
import
java.util.Objects
;
import
java.util.Set
;
import
java.util.stream.Collectors
;
@Slf4j
@Service
@RequiredArgsConstructor
public
class
StoryboardPipelineServiceImpl
implements
StoryboardPipelineService
{
private
final
LlmService
llmService
;
private
final
StoryboardMapper
storyboardMapper
;
private
final
EpisodeMapper
episodeMapper
;
private
final
CharacterMapper
characterMapper
;
private
final
SceneMapper
sceneMapper
;
private
final
ObjectMapper
objectMapper
;
private
final
BillingService
billingService
;
private
static
final
String
SYSTEM_PROMPT_BASE
=
"""
你是一位专业的影视分镜师。根据分集脚本,将其拆分为若干个连续镜头(shot)。
每个镜头时长 4~8 秒。
You are a professional storyboard artist.
Split the episode script into a sequence of continuous shots, where each shot lasts 4-8 seconds.
%s
请以 JSON 数组格式返回,每个镜头格式如下:
Return only a JSON array. Each item must follow this schema:
[
{
"
sequence_num
":
镜头序号(从1开始,整数)
,
"
sequence_num
":
1
,
"
scene_number
": "
001
",
"
short_description
": "
一句话描述镜头内容(中文,
20
字以内)
",
"
detailed_description
": "
详细场景描述(中文,
50
字以内)
",
"
characters
": "
出场的角色
/
场景,用@名称格式,逗号分隔,例如:@小明
,
@咖啡馆,无则填空字符串
",
"
dialogues
": "
对白内容,格式:角色名:「台词」,无对白填空字符串
",
"
camera_direction
": "
镜头运动方式(如:固定
/
推进
/
拉远
/
横移
/
跟拍)
",
"
composition_guide
": "
构图说明(如:近景
/
中景
/
远景
/
特写)
",
"
duration_seconds
":
时长秒数(整数,4-8)
,
"
start_frame_prompt
": "
首帧英文
Prompt
(供文生图使用,描述画面静态构图,
30
词以内)
",
"
motion_script
": "
镜头运动描述(供图生视频使用,中文,
20
字以内)
"
"
short_description
": "
One
-
sentence
shot
description
in
Chinese
",
"
detailed_description
": "
Detailed
scene
description
in
Chinese
",
"
characters
": "
@character
or
@scene
refs
separated
by
commas
,
e
.
g
.
@Alice
,
@Cafe
",
"
dialogues
": "
Dialogue
content
,
empty
string
when
absent
",
"
camera_direction
": "
Camera
movement
,
e
.
g
.
static
/
push
/
pull
/
pan
/
tracking
",
"
composition_guide
": "
Composition
guidance
,
e
.
g
.
close
-
up
/
medium
/
wide
",
"
duration_seconds
":
5
,
"
start_frame_prompt
": "
English
still
-
frame
prompt
,
under
30
words
",
"
motion_script
": "
Short
Chinese
motion
description
for
video
generation
"
}
]
只返回 JSON 数组,不要有其他说明文字。镜头数量根据脚本长度决定,通常 5~15 个。
Do not include any explanation outside the JSON array.
"""
;
private
static
final
String
PROMPT_GEN_SYSTEM
=
"""
You are a professional storyboard prompt writer.
Based on episode context, character appearance, scene setup, and the current storyboard shot,
turn the shot into a concise multi-segment video-generation prompt.
Requirements:
- Stay faithful to the provided plot and the visual setup.
- Respect character appearance, costume, and scene details.
- Use 2-4 time segments whose total duration matches the storyboard duration.
- Each segment should include timing, visuals, dialogue when present, and audio mood.
- Return only the prompt text with no extra explanation.
"""
;
private
final
LlmService
llmService
;
private
final
StoryboardMapper
storyboardMapper
;
private
final
EpisodeMapper
episodeMapper
;
private
final
CharacterMapper
characterMapper
;
private
final
SceneMapper
sceneMapper
;
private
final
ObjectMapper
objectMapper
;
private
final
BillingService
billingService
;
@Override
public
List
<
Storyboard
>
generateStoryboards
(
Long
episodeId
,
Long
projectId
,
Long
tenantId
)
{
Episode
episode
=
episodeMapper
.
selectById
(
episodeId
);
if
(
episode
==
null
)
{
throw
new
BizException
(
ErrorCode
.
NOT_FOUND
,
"
分集不存在
"
);
throw
new
BizException
(
ErrorCode
.
NOT_FOUND
,
"
Episode not found
"
);
}
// 构建角色/场景设定上下文
String
assetContext
=
buildAssetContext
(
projectId
,
tenantId
);
String
systemPrompt
=
String
.
format
(
SYSTEM_PROMPT_BASE
,
assetContext
);
String
userMsg
=
String
.
format
(
"集标题:%s\n\n分集脚本:\n%s"
,
episode
.
getTitle
(),
episode
.
getScript
()
!=
null
?
episode
.
getScript
()
:
episode
.
getSummary
());
String
userMsg
=
String
.
format
(
"Episode title: %s%n%nEpisode script:%n%s"
,
nullSafe
(
episode
.
getTitle
()),
episode
.
getScript
()
!=
null
?
episode
.
getScript
()
:
nullSafe
(
episode
.
getSummary
())
);
log
.
info
(
"Generating storyboards: episodeId={}, projectId={}"
,
episodeId
,
projectId
);
...
...
@@ -107,28 +126,33 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
try
{
String
json
=
extractJson
(
raw
);
List
<
Map
<
String
,
Object
>>
items
=
objectMapper
.
readValue
(
json
,
new
TypeReference
<>()
{});
Set
<
String
>
sceneRefs
=
projectSceneRefs
(
projectId
,
tenantId
);
List
<
Storyboard
>
result
=
new
ArrayList
<>();
for
(
Map
<
String
,
Object
>
item
:
items
)
{
Storyboard
sb
=
new
Storyboard
();
sb
.
setProjectId
(
projectId
);
sb
.
setEpisodeId
(
episodeId
);
sb
.
setTenantId
(
tenantId
);
sb
.
setSequenceNum
(((
Number
)
item
.
getOrDefault
(
"sequence_num"
,
result
.
size
()
+
1
)).
intValue
());
sb
.
setSceneNumber
((
String
)
item
.
getOrDefault
(
"scene_number"
,
String
.
format
(
"%03d"
,
result
.
size
()
+
1
)));
sb
.
setShortDescription
((
String
)
item
.
getOrDefault
(
"short_description"
,
""
));
sb
.
setDetailedDescription
((
String
)
item
.
getOrDefault
(
"detailed_description"
,
""
));
sb
.
setCharacters
((
String
)
item
.
getOrDefault
(
"characters"
,
""
));
sb
.
setDialogues
((
String
)
item
.
getOrDefault
(
"dialogues"
,
""
));
sb
.
setCameraDirection
((
String
)
item
.
getOrDefault
(
"camera_direction"
,
""
));
sb
.
setCompositionGuide
((
String
)
item
.
getOrDefault
(
"composition_guide"
,
""
));
sb
.
setDurationSeconds
(((
Number
)
item
.
getOrDefault
(
"duration_seconds"
,
5
)).
intValue
());
sb
.
setStartFramePrompt
((
String
)
item
.
getOrDefault
(
"start_frame_prompt"
,
""
));
sb
.
setMotionScript
((
String
)
item
.
getOrDefault
(
"motion_script"
,
""
));
sb
.
setStatus
(
"draft"
);
storyboardMapper
.
insert
(
sb
);
result
.
add
(
sb
);
StoryboardRefs
refs
=
splitStoryboardRefs
((
String
)
item
.
getOrDefault
(
"characters"
,
""
),
sceneRefs
);
Storyboard
storyboard
=
new
Storyboard
();
storyboard
.
setProjectId
(
projectId
);
storyboard
.
setEpisodeId
(
episodeId
);
storyboard
.
setTenantId
(
tenantId
);
storyboard
.
setSequenceNum
(((
Number
)
item
.
getOrDefault
(
"sequence_num"
,
result
.
size
()
+
1
)).
intValue
());
storyboard
.
setSceneNumber
((
String
)
item
.
getOrDefault
(
"scene_number"
,
String
.
format
(
"%03d"
,
result
.
size
()
+
1
)));
storyboard
.
setShortDescription
((
String
)
item
.
getOrDefault
(
"short_description"
,
""
));
storyboard
.
setDetailedDescription
((
String
)
item
.
getOrDefault
(
"detailed_description"
,
""
));
storyboard
.
setCharacters
(
refs
.
characters
());
storyboard
.
setSceneRef
(
refs
.
sceneRef
());
storyboard
.
setDialogues
((
String
)
item
.
getOrDefault
(
"dialogues"
,
""
));
storyboard
.
setCameraDirection
((
String
)
item
.
getOrDefault
(
"camera_direction"
,
""
));
storyboard
.
setCompositionGuide
((
String
)
item
.
getOrDefault
(
"composition_guide"
,
""
));
storyboard
.
setDurationSeconds
(((
Number
)
item
.
getOrDefault
(
"duration_seconds"
,
5
)).
intValue
());
storyboard
.
setStartFramePrompt
((
String
)
item
.
getOrDefault
(
"start_frame_prompt"
,
""
));
storyboard
.
setMotionScript
((
String
)
item
.
getOrDefault
(
"motion_script"
,
""
));
storyboard
.
setStatus
(
"draft"
);
storyboardMapper
.
insert
(
storyboard
);
result
.
add
(
storyboard
);
}
billingService
.
charge
(
BillingChargeRequest
.
builder
()
.
tenantId
(
tenantId
)
.
userId
(
UserContext
.
require
())
...
...
@@ -145,7 +169,7 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
return
result
;
}
catch
(
Exception
e
)
{
log
.
error
(
"Storyboard parsing failed"
,
e
);
throw
new
BizException
(
ErrorCode
.
INTERNAL_ERROR
,
"
分镜解析失败
: "
+
e
.
getMessage
());
throw
new
BizException
(
ErrorCode
.
INTERNAL_ERROR
,
"
Storyboard parsing failed
: "
+
e
.
getMessage
());
}
}
...
...
@@ -172,11 +196,16 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
public
Storyboard
update
(
Long
id
,
Long
tenantId
,
Storyboard
patch
)
{
Storyboard
existing
=
storyboardMapper
.
selectById
(
id
);
if
(
existing
==
null
||
!
tenantId
.
equals
(
existing
.
getTenantId
()))
{
throw
new
BizException
(
ErrorCode
.
NOT_FOUND
,
"
分镜不存在
"
);
throw
new
BizException
(
ErrorCode
.
NOT_FOUND
,
"
Storyboard not found
"
);
}
boolean
bodyFieldsChanged
=
hasStoryboardBodyChanges
(
existing
,
patch
);
patch
.
setId
(
id
);
patch
.
setTenantId
(
tenantId
);
storyboardMapper
.
updateById
(
patch
);
if
(
bodyFieldsChanged
)
{
clearVideoPrompt
(
id
,
tenantId
);
}
return
storyboardMapper
.
selectById
(
id
);
}
...
...
@@ -184,7 +213,7 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
public
void
delete
(
Long
id
,
Long
tenantId
)
{
Storyboard
existing
=
storyboardMapper
.
selectById
(
id
);
if
(
existing
==
null
||
!
tenantId
.
equals
(
existing
.
getTenantId
()))
{
throw
new
BizException
(
ErrorCode
.
NOT_FOUND
,
"
分镜不存在
"
);
throw
new
BizException
(
ErrorCode
.
NOT_FOUND
,
"
Storyboard not found
"
);
}
storyboardMapper
.
deleteById
(
id
);
}
...
...
@@ -196,97 +225,81 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
}
}
private
static
final
String
PROMPT_GEN_SYSTEM
=
"""
你是一位专业的影视分镜撰写师,擅长根据剧集剧情、角色外观设定和场景设定,将分镜信息转化为精炼的视频生成提示词。
我会提供:① 该分镜所属集数的剧情、② 项目角色和场景的视觉设定、③ 当前分镜的镜头信息。
请严格依据以上内容,生成按时间轴分段的影视风格描述,格式如下:
0-Xs(氛围标签,如:抓眼/转折/高潮/收尾)
镜头类型(特写/中景/远景/近景):场景动作描述
角色名(情绪):"
对白内容
"(无对白则省略此行)
音效/BGM描述
要求:
- 描述必须忠实于提供的剧情和角色/场景设定,角色外貌、服装等严格参照设定
- 分镜中 @角色名 / @场景名 表示该分镜引用了该角色或场景的视觉设定
- 按时间段分2-4段,总时长等于分镜时长
- 每段包含:时间轴、画面、对白(有则写)、声音
- 文字精炼生动,适合作为视频生成提示词
- 只返回提示词文本,不加任何说明
"""
;
@Override
public
String
generatePrompt
(
Long
storyboardId
,
Long
tenantId
)
{
Storyboard
s
b
=
storyboardMapper
.
selectById
(
storyboardId
);
if
(
s
b
==
null
||
!
tenantId
.
equals
(
sb
.
getTenantId
()))
{
throw
new
BizException
(
ErrorCode
.
NOT_FOUND
,
"
分镜不存在
"
);
Storyboard
s
toryboard
=
storyboardMapper
.
selectById
(
storyboardId
);
if
(
s
toryboard
==
null
||
!
tenantId
.
equals
(
storyboard
.
getTenantId
()))
{
throw
new
BizException
(
ErrorCode
.
NOT_FOUND
,
"
Storyboard not found
"
);
}
// ① 所属集数剧情
StringBuilder
userMsg
=
new
StringBuilder
();
if
(
s
b
.
getEpisodeId
()
!=
null
)
{
Episode
episode
=
episodeMapper
.
selectById
(
s
b
.
getEpisodeId
());
if
(
s
toryboard
.
getEpisodeId
()
!=
null
)
{
Episode
episode
=
episodeMapper
.
selectById
(
s
toryboard
.
getEpisodeId
());
if
(
episode
!=
null
)
{
String
episodeContent
=
episode
.
getScript
()
!=
null
&&
!
episode
.
getScript
().
isBlank
()
?
episode
.
getScript
()
:
nullSafe
(
episode
.
getSummary
());
userMsg
.
append
(
String
.
format
(
"""
【所属集数剧情】
第%d集《%s》
%s
"""
,
?
episode
.
getScript
()
:
nullSafe
(
episode
.
getSummary
());
userMsg
.
append
(
String
.
format
(
"Episode context:%nEpisode %d - %s%n%s%n%n"
,
episode
.
getEpisodeNumber
()
!=
null
?
episode
.
getEpisodeNumber
()
:
0
,
nullSafe
(
episode
.
getTitle
()),
episodeContent
));
nullSafe
(
episode
.
getTitle
()),
episodeContent
));
}
}
// ② 角色视觉设定(本项目全部角色)
List
<
Character
>
characters
=
characterMapper
.
findByProject
(
sb
.
getProjectId
(),
tenantId
);
List
<
Character
>
characters
=
characterMapper
.
findByProject
(
storyboard
.
getProjectId
(),
tenantId
);
if
(!
characters
.
isEmpty
())
{
userMsg
.
append
(
"【角色视觉设定】\n"
);
for
(
Character
c
:
characters
)
{
userMsg
.
append
(
String
.
format
(
"- @%s(%s):%s,服装:%s,外貌特征:%s\n"
,
nullSafe
(
c
.
getName
()),
nullSafe
(
c
.
getRoleType
()),
nullSafe
(
c
.
getPersonality
()),
nullSafe
(
c
.
getCostume
()),
nullSafe
(
c
.
getVisualHint
())));
userMsg
.
append
(
"Character visual setup:\n"
);
for
(
Character
character
:
characters
)
{
userMsg
.
append
(
String
.
format
(
"- @%s (%s): personality=%s, costume=%s, visual=%s%n"
,
nullSafe
(
character
.
getName
()),
nullSafe
(
character
.
getRoleType
()),
nullSafe
(
character
.
getPersonality
()),
nullSafe
(
character
.
getCostume
()),
nullSafe
(
character
.
getVisualHint
())
));
}
userMsg
.
append
(
"\n"
);
}
// ③ 场景视觉设定
List
<
Scene
>
scenes
=
sceneMapper
.
findByProject
(
sb
.
getProjectId
(),
tenantId
);
List
<
Scene
>
scenes
=
sceneMapper
.
findByProject
(
storyboard
.
getProjectId
(),
tenantId
);
if
(!
scenes
.
isEmpty
())
{
userMsg
.
append
(
"【场景视觉设定】\n"
);
for
(
Scene
s
:
scenes
)
{
userMsg
.
append
(
String
.
format
(
"- @%s(%s):%s\n"
,
nullSafe
(
s
.
getName
()),
s
.
getSceneType
()
!=
null
?
(
"indoor"
.
equals
(
s
.
getSceneType
())
?
"室内"
:
"室外"
)
:
""
,
nullSafe
(
s
.
getDescription
())));
userMsg
.
append
(
"Scene visual setup:\n"
);
for
(
Scene
scene
:
scenes
)
{
userMsg
.
append
(
String
.
format
(
"- @%s (%s): %s%n"
,
nullSafe
(
scene
.
getName
()),
"indoor"
.
equals
(
scene
.
getSceneType
())
?
"indoor"
:
"outdoor"
,
nullSafe
(
scene
.
getDescription
())
));
}
userMsg
.
append
(
"\n"
);
}
// ④ 当前分镜信息
userMsg
.
append
(
String
.
format
(
"""
【当前分镜信息】(第%d个镜头)
- 镜头描述:%s
- 详细场景:%s
- 出场角色/场景:%s
- 对白内容:%s
- 镜头运动:%s
- 构图方式:%s
- 分镜时长:%d 秒
"""
,
sb
.
getSequenceNum
()
!=
null
?
sb
.
getSequenceNum
()
:
0
,
nullSafe
(
sb
.
getShortDescription
()),
nullSafe
(
sb
.
getDetailedDescription
()),
nullSafe
(
sb
.
getCharacters
()),
nullSafe
(
sb
.
getDialogues
()),
nullSafe
(
sb
.
getCameraDirection
()),
nullSafe
(
sb
.
getCompositionGuide
()),
sb
.
getDurationSeconds
()
!=
null
?
sb
.
getDurationSeconds
()
:
5
));
StoryboardRefs
promptRefs
=
promptRefsForCurrentShot
(
storyboard
,
scenes
);
userMsg
.
append
(
String
.
format
(
"Current storyboard shot:%n"
+
"- Sequence: %d%n"
+
"- Short description: %s%n"
+
"- Detailed description: %s%n"
+
"- Character refs: %s%n"
+
"- Scene refs: %s%n"
+
"- Dialogues: %s%n"
+
"- Camera direction: %s%n"
+
"- Composition guide: %s%n"
+
"- Duration seconds: %d%n"
,
storyboard
.
getSequenceNum
()
!=
null
?
storyboard
.
getSequenceNum
()
:
0
,
nullSafe
(
storyboard
.
getShortDescription
()),
nullSafe
(
storyboard
.
getDetailedDescription
()),
nullSafe
(
promptRefs
.
characters
()),
nullSafe
(
promptRefs
.
sceneRef
()),
nullSafe
(
storyboard
.
getDialogues
()),
nullSafe
(
storyboard
.
getCameraDirection
()),
nullSafe
(
storyboard
.
getCompositionGuide
()),
storyboard
.
getDurationSeconds
()
!=
null
?
storyboard
.
getDurationSeconds
()
:
5
));
ChatRequest
request
=
ChatRequest
.
builder
()
.
messages
(
List
.
of
(
...
...
@@ -295,12 +308,18 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
))
.
build
();
log
.
info
(
"Generating storyboard prompt: storyboardId={}, episodeId={}, characters={}, scenes={}"
,
storyboardId
,
sb
.
getEpisodeId
(),
characters
.
size
(),
scenes
.
size
());
log
.
info
(
"Generating storyboard prompt: storyboardId={}, episodeId={}, characters={}, scenes={}"
,
storyboardId
,
storyboard
.
getEpisodeId
(),
characters
.
size
(),
scenes
.
size
()
);
billingService
.
checkBalance
(
BillingChargeRequest
.
builder
()
.
tenantId
(
tenantId
)
.
userId
(
UserContext
.
require
())
.
projectId
(
s
b
.
getProjectId
())
.
projectId
(
s
toryboard
.
getProjectId
())
.
operation
(
"storyboard_prompt_generate"
)
.
modality
(
"TEXT"
)
.
modelProvider
(
llmService
.
getProvider
())
...
...
@@ -309,11 +328,18 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
.
unitCount
(
1
)
.
refId
(
String
.
valueOf
(
storyboardId
))
.
build
());
String
prompt
=
llmService
.
chat
(
request
);
Storyboard
promptUpdate
=
new
Storyboard
();
promptUpdate
.
setId
(
storyboard
.
getId
());
promptUpdate
.
setVideoPrompt
(
prompt
);
storyboardMapper
.
updateById
(
promptUpdate
);
billingService
.
charge
(
BillingChargeRequest
.
builder
()
.
tenantId
(
tenantId
)
.
userId
(
UserContext
.
require
())
.
projectId
(
s
b
.
getProjectId
())
.
projectId
(
s
toryboard
.
getProjectId
())
.
operation
(
"storyboard_prompt_generate"
)
.
modality
(
"TEXT"
)
.
modelProvider
(
llmService
.
getProvider
())
...
...
@@ -325,47 +351,163 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
return
prompt
;
}
private
static
String
nullSafe
(
String
s
)
{
return
s
!=
null
?
s
:
""
;
@Override
public
List
<
Storyboard
>
populateMissingVideoPromptsByEpisode
(
Long
episodeId
,
Long
tenantId
)
{
List
<
Storyboard
>
pending
=
storyboardMapper
.
findMissingVideoPromptByEpisode
(
episodeId
,
tenantId
);
for
(
Storyboard
storyboard
:
pending
)
{
generatePrompt
(
storyboard
.
getId
(),
tenantId
);
}
return
storyboardMapper
.
findByEpisode
(
episodeId
,
tenantId
);
}
private
String
buildAssetContext
(
Long
projectId
,
Long
tenantId
)
{
StringBuilder
sb
=
new
StringBuilder
();
StringBuilder
context
=
new
StringBuilder
();
List
<
Character
>
characters
=
characterMapper
.
findByProject
(
projectId
,
tenantId
);
List
<
Scene
>
scenes
=
sceneMapper
.
findByProject
(
projectId
,
tenantId
);
if
(
characters
.
isEmpty
()
&&
scenes
.
isEmpty
())
{
return
"
(本项目暂无角色/场景设定,请根据脚本自行设计)
"
;
return
"
No project character or scene setup exists yet. Infer reasonably from the script.
"
;
}
sb
.
append
(
"【项目角色设定】(请在 characters 字段用 @名称 引用)\n"
);
for
(
Character
c
:
characters
)
{
sb
.
append
(
String
.
format
(
"- @%s:%s,%s,服装:%s\n"
,
nullSafe
(
c
.
getName
()),
nullSafe
(
c
.
getPersonality
()),
nullSafe
(
c
.
getVisualHint
()),
nullSafe
(
c
.
getCostume
())));
if
(!
characters
.
isEmpty
())
{
context
.
append
(
"Project characters (refer to them as @Name in the characters field):\n"
);
for
(
Character
character
:
characters
)
{
context
.
append
(
String
.
format
(
"- @%s: personality=%s, visual=%s, costume=%s%n"
,
nullSafe
(
character
.
getName
()),
nullSafe
(
character
.
getPersonality
()),
nullSafe
(
character
.
getVisualHint
()),
nullSafe
(
character
.
getCostume
())
));
}
}
if
(!
scenes
.
isEmpty
())
{
sb
.
append
(
"\n【项目场景设定】(请在 characters 字段用 @名称 引用场景)\n"
);
for
(
Scene
s
:
scenes
)
{
sb
.
append
(
String
.
format
(
"- @%s(%s):%s\n"
,
nullSafe
(
s
.
getName
()),
"indoor"
.
equals
(
s
.
getSceneType
())
?
"室内"
:
"室外"
,
nullSafe
(
s
.
getDescription
())));
context
.
append
(
"\nProject scenes (they may also appear as @Name refs in the characters field):\n"
);
for
(
Scene
scene
:
scenes
)
{
context
.
append
(
String
.
format
(
"- @%s (%s): %s%n"
,
nullSafe
(
scene
.
getName
()),
"indoor"
.
equals
(
scene
.
getSceneType
())
?
"indoor"
:
"outdoor"
,
nullSafe
(
scene
.
getDescription
())
));
}
}
sb
.
append
(
"\n重要:characters 字段必须使用 @名称 格式引用以上角色/场景,例如:@小明,@咖啡馆"
);
return
sb
.
toString
();
context
.
append
(
"\nThe characters field must use @Name refs, separated by commas."
);
return
context
.
toString
();
}
private
Set
<
String
>
projectSceneRefs
(
Long
projectId
,
Long
tenantId
)
{
return
sceneMapper
.
findByProject
(
projectId
,
tenantId
)
.
stream
()
.
map
(
Scene:
:
getName
)
.
filter
(
Objects:
:
nonNull
)
.
map
(
String:
:
trim
)
.
filter
(
name
->
!
name
.
isEmpty
())
.
map
(
name
->
"@"
+
name
)
.
collect
(
Collectors
.
toCollection
(
LinkedHashSet:
:
new
));
}
private
StoryboardRefs
splitStoryboardRefs
(
String
rawRefs
,
Set
<
String
>
sceneRefs
)
{
List
<
String
>
refs
=
parseRefs
(
rawRefs
);
List
<
String
>
characterRefs
=
new
ArrayList
<>();
List
<
String
>
matchedSceneRefs
=
new
ArrayList
<>();
for
(
String
ref
:
refs
)
{
if
(
sceneRefs
.
contains
(
ref
))
{
matchedSceneRefs
.
add
(
ref
);
}
else
{
characterRefs
.
add
(
ref
);
}
}
return
new
StoryboardRefs
(
joinRefs
(
characterRefs
),
joinRefs
(
matchedSceneRefs
));
}
private
StoryboardRefs
promptRefsForCurrentShot
(
Storyboard
storyboard
,
List
<
Scene
>
projectScenes
)
{
Set
<
String
>
sceneRefs
=
projectScenes
.
stream
()
.
map
(
Scene:
:
getName
)
.
filter
(
Objects:
:
nonNull
)
.
map
(
String:
:
trim
)
.
filter
(
name
->
!
name
.
isEmpty
())
.
map
(
name
->
"@"
+
name
)
.
collect
(
Collectors
.
toCollection
(
LinkedHashSet:
:
new
));
StoryboardRefs
fallbackRefs
=
splitStoryboardRefs
(
storyboard
.
getCharacters
(),
sceneRefs
);
String
sceneRef
=
storyboard
.
getSceneRef
()
!=
null
&&
!
storyboard
.
getSceneRef
().
isBlank
()
?
storyboard
.
getSceneRef
()
:
fallbackRefs
.
sceneRef
();
return
new
StoryboardRefs
(
fallbackRefs
.
characters
(),
sceneRef
);
}
private
List
<
String
>
parseRefs
(
String
rawRefs
)
{
if
(
rawRefs
==
null
||
rawRefs
.
isBlank
())
{
return
List
.
of
();
}
return
Arrays
.
stream
(
rawRefs
.
split
(
","
))
.
map
(
String:
:
trim
)
.
filter
(
token
->
!
token
.
isBlank
())
.
distinct
()
.
toList
();
}
private
String
joinRefs
(
List
<
String
>
refs
)
{
if
(
refs
.
isEmpty
())
{
return
null
;
}
return
String
.
join
(
","
,
refs
);
}
private
void
clearVideoPrompt
(
Long
storyboardId
,
Long
tenantId
)
{
storyboardMapper
.
update
(
null
,
new
UpdateWrapper
<
Storyboard
>()
.
eq
(
"id"
,
storyboardId
)
.
eq
(
"tenant_id"
,
tenantId
)
.
set
(
"video_prompt"
,
null
)
);
}
private
boolean
hasStoryboardBodyChanges
(
Storyboard
existing
,
Storyboard
patch
)
{
return
hasChanged
(
patch
.
getSceneNumber
(),
existing
.
getSceneNumber
())
||
hasChanged
(
patch
.
getShortDescription
(),
existing
.
getShortDescription
())
||
hasChanged
(
patch
.
getDetailedDescription
(),
existing
.
getDetailedDescription
())
||
hasChanged
(
patch
.
getCharacters
(),
existing
.
getCharacters
())
||
hasChanged
(
patch
.
getSceneRef
(),
existing
.
getSceneRef
())
||
hasChanged
(
patch
.
getDialogues
(),
existing
.
getDialogues
())
||
hasChanged
(
patch
.
getCameraDirection
(),
existing
.
getCameraDirection
())
||
hasChanged
(
patch
.
getCompositionGuide
(),
existing
.
getCompositionGuide
())
||
hasChanged
(
patch
.
getDurationSeconds
(),
existing
.
getDurationSeconds
())
||
hasChanged
(
patch
.
getStartFramePrompt
(),
existing
.
getStartFramePrompt
())
||
hasChanged
(
patch
.
getEndFramePrompt
(),
existing
.
getEndFramePrompt
())
||
hasChanged
(
patch
.
getMotionScript
(),
existing
.
getMotionScript
())
||
hasChanged
(
patch
.
getNotes
(),
existing
.
getNotes
());
}
private
boolean
hasChanged
(
String
patchValue
,
String
existingValue
)
{
return
patchValue
!=
null
&&
!
Objects
.
equals
(
patchValue
,
existingValue
);
}
private
boolean
hasChanged
(
Integer
patchValue
,
Integer
existingValue
)
{
return
patchValue
!=
null
&&
!
Objects
.
equals
(
patchValue
,
existingValue
);
}
private
String
extractJson
(
String
raw
)
{
String
s
=
raw
.
strip
();
if
(
s
.
startsWith
(
"```"
))
{
int
start
=
s
.
indexOf
(
'\n'
)
+
1
;
int
end
=
s
.
lastIndexOf
(
"```"
);
if
(
end
>
start
)
s
=
s
.
substring
(
start
,
end
).
strip
();
String
text
=
raw
.
strip
();
if
(
text
.
startsWith
(
"```"
))
{
int
start
=
text
.
indexOf
(
'\n'
)
+
1
;
int
end
=
text
.
lastIndexOf
(
"```"
);
if
(
end
>
start
)
{
text
=
text
.
substring
(
start
,
end
).
strip
();
}
}
return
s
;
return
text
;
}
private
static
String
nullSafe
(
String
value
)
{
return
value
!=
null
?
value
:
""
;
}
private
record
StoryboardRefs
(
String
characters
,
String
sceneRef
)
{
}
}
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