Commit a19ab7b9 authored by yaoke.yk's avatar yaoke.yk

feat(storyboard): persist scene refs and video prompts

parent b9d8f02b
...@@ -98,7 +98,7 @@ public class StoryboardController { ...@@ -98,7 +98,7 @@ public class StoryboardController {
public ApiResponse<Map<String, String>> generatePrompt(@PathVariable Long projectId, public ApiResponse<Map<String, String>> generatePrompt(@PathVariable Long projectId,
@PathVariable Long storyboardId) { @PathVariable Long storyboardId) {
Long tenantId = TenantContext.get(); Long tenantId = TenantContext.get();
String prompt = storyboardService.generatePrompt(storyboardId, tenantId); Map<String, String> body = Map.of("prompt", storyboardService.generatePrompt(storyboardId, tenantId));
return ApiResponse.success(Map.of("prompt", prompt)); return ApiResponse.success(body);
} }
} }
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;
}
}
...@@ -30,4 +30,6 @@ public interface StoryboardPipelineService { ...@@ -30,4 +30,6 @@ public interface StoryboardPipelineService {
* AI 根据分镜剧本信息生成视频描述提示词(分时段影视风格) * AI 根据分镜剧本信息生成视频描述提示词(分时段影视风格)
*/ */
String generatePrompt(Long storyboardId, Long tenantId); String generatePrompt(Long storyboardId, Long tenantId);
List<Storyboard> populateMissingVideoPromptsByEpisode(Long episodeId, Long tenantId);
} }
package com.yaoai.pipeline.service.impl; 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.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.ai.core.model.ChatMessage; import com.yaoai.ai.core.model.ChatMessage;
...@@ -24,60 +25,78 @@ import lombok.extern.slf4j.Slf4j; ...@@ -24,60 +25,78 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class StoryboardPipelineServiceImpl implements StoryboardPipelineService { 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 = """ private static final String SYSTEM_PROMPT_BASE = """
你是一位专业的影视分镜师。根据分集脚本,将其拆分为若干个连续镜头(shot)。 You are a professional storyboard artist.
每个镜头时长 4~8 秒。 Split the episode script into a sequence of continuous shots, where each shot lasts 4-8 seconds.
%s %s
请以 JSON 数组格式返回,每个镜头格式如下: Return only a JSON array. Each item must follow this schema:
[ [
{ {
"sequence_num": 镜头序号(从1开始,整数), "sequence_num": 1,
"scene_number": "001", "scene_number": "001",
"short_description": "一句话描述镜头内容(中文,20字以内)", "short_description": "One-sentence shot description in Chinese",
"detailed_description": "详细场景描述(中文,50字以内)", "detailed_description": "Detailed scene description in Chinese",
"characters": "出场的角色/场景,用@名称格式,逗号分隔,例如:@小明,@咖啡馆,无则填空字符串", "characters": "@character or @scene refs separated by commas, e.g. @Alice,@Cafe",
"dialogues": "对白内容,格式:角色名:「台词」,无对白填空字符串", "dialogues": "Dialogue content, empty string when absent",
"camera_direction": "镜头运动方式(如:固定/推进/拉远/横移/跟拍)", "camera_direction": "Camera movement, e.g. static/push/pull/pan/tracking",
"composition_guide": "构图说明(如:近景/中景/远景/特写)", "composition_guide": "Composition guidance, e.g. close-up/medium/wide",
"duration_seconds": 时长秒数(整数,4-8), "duration_seconds": 5,
"start_frame_prompt": "首帧英文 Prompt(供文生图使用,描述画面静态构图,30词以内)", "start_frame_prompt": "English still-frame prompt, under 30 words",
"motion_script": "镜头运动描述(供图生视频使用,中文,20字以内)" "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 @Override
public List<Storyboard> generateStoryboards(Long episodeId, Long projectId, Long tenantId) { public List<Storyboard> generateStoryboards(Long episodeId, Long projectId, Long tenantId) {
Episode episode = episodeMapper.selectById(episodeId); Episode episode = episodeMapper.selectById(episodeId);
if (episode == null) { if (episode == null) {
throw new BizException(ErrorCode.NOT_FOUND, "分集不存在"); throw new BizException(ErrorCode.NOT_FOUND, "Episode not found");
} }
// 构建角色/场景设定上下文
String assetContext = buildAssetContext(projectId, tenantId); String assetContext = buildAssetContext(projectId, tenantId);
String systemPrompt = String.format(SYSTEM_PROMPT_BASE, assetContext); String systemPrompt = String.format(SYSTEM_PROMPT_BASE, assetContext);
String userMsg = String.format(
String userMsg = String.format("集标题:%s\n\n分集脚本:\n%s", "Episode title: %s%n%nEpisode script:%n%s",
episode.getTitle(), episode.getScript() != null ? episode.getScript() : episode.getSummary()); nullSafe(episode.getTitle()),
episode.getScript() != null ? episode.getScript() : nullSafe(episode.getSummary())
);
log.info("Generating storyboards: episodeId={}, projectId={}", episodeId, projectId); log.info("Generating storyboards: episodeId={}, projectId={}", episodeId, projectId);
...@@ -107,28 +126,33 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -107,28 +126,33 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
try { try {
String json = extractJson(raw); String json = extractJson(raw);
List<Map<String, Object>> items = objectMapper.readValue(json, new TypeReference<>() {}); List<Map<String, Object>> items = objectMapper.readValue(json, new TypeReference<>() {});
Set<String> sceneRefs = projectSceneRefs(projectId, tenantId);
List<Storyboard> result = new ArrayList<>(); List<Storyboard> result = new ArrayList<>();
for (Map<String, Object> item : items) { for (Map<String, Object> item : items) {
Storyboard sb = new Storyboard(); StoryboardRefs refs = splitStoryboardRefs((String) item.getOrDefault("characters", ""), sceneRefs);
sb.setProjectId(projectId);
sb.setEpisodeId(episodeId); Storyboard storyboard = new Storyboard();
sb.setTenantId(tenantId); storyboard.setProjectId(projectId);
sb.setSequenceNum(((Number) item.getOrDefault("sequence_num", result.size() + 1)).intValue()); storyboard.setEpisodeId(episodeId);
sb.setSceneNumber((String) item.getOrDefault("scene_number", String.format("%03d", result.size() + 1))); storyboard.setTenantId(tenantId);
sb.setShortDescription((String) item.getOrDefault("short_description", "")); storyboard.setSequenceNum(((Number) item.getOrDefault("sequence_num", result.size() + 1)).intValue());
sb.setDetailedDescription((String) item.getOrDefault("detailed_description", "")); storyboard.setSceneNumber((String) item.getOrDefault("scene_number", String.format("%03d", result.size() + 1)));
sb.setCharacters((String) item.getOrDefault("characters", "")); storyboard.setShortDescription((String) item.getOrDefault("short_description", ""));
sb.setDialogues((String) item.getOrDefault("dialogues", "")); storyboard.setDetailedDescription((String) item.getOrDefault("detailed_description", ""));
sb.setCameraDirection((String) item.getOrDefault("camera_direction", "")); storyboard.setCharacters(refs.characters());
sb.setCompositionGuide((String) item.getOrDefault("composition_guide", "")); storyboard.setSceneRef(refs.sceneRef());
sb.setDurationSeconds(((Number) item.getOrDefault("duration_seconds", 5)).intValue()); storyboard.setDialogues((String) item.getOrDefault("dialogues", ""));
sb.setStartFramePrompt((String) item.getOrDefault("start_frame_prompt", "")); storyboard.setCameraDirection((String) item.getOrDefault("camera_direction", ""));
sb.setMotionScript((String) item.getOrDefault("motion_script", "")); storyboard.setCompositionGuide((String) item.getOrDefault("composition_guide", ""));
sb.setStatus("draft"); storyboard.setDurationSeconds(((Number) item.getOrDefault("duration_seconds", 5)).intValue());
storyboardMapper.insert(sb); storyboard.setStartFramePrompt((String) item.getOrDefault("start_frame_prompt", ""));
result.add(sb); storyboard.setMotionScript((String) item.getOrDefault("motion_script", ""));
storyboard.setStatus("draft");
storyboardMapper.insert(storyboard);
result.add(storyboard);
} }
billingService.charge(BillingChargeRequest.builder() billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId) .tenantId(tenantId)
.userId(UserContext.require()) .userId(UserContext.require())
...@@ -145,7 +169,7 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -145,7 +169,7 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
return result; return result;
} catch (Exception e) { } catch (Exception e) {
log.error("Storyboard parsing failed", 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 ...@@ -172,11 +196,16 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
public Storyboard update(Long id, Long tenantId, Storyboard patch) { public Storyboard update(Long id, Long tenantId, Storyboard patch) {
Storyboard existing = storyboardMapper.selectById(id); Storyboard existing = storyboardMapper.selectById(id);
if (existing == null || !tenantId.equals(existing.getTenantId())) { 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.setId(id);
patch.setTenantId(tenantId); patch.setTenantId(tenantId);
storyboardMapper.updateById(patch); storyboardMapper.updateById(patch);
if (bodyFieldsChanged) {
clearVideoPrompt(id, tenantId);
}
return storyboardMapper.selectById(id); return storyboardMapper.selectById(id);
} }
...@@ -184,7 +213,7 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -184,7 +213,7 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
public void delete(Long id, Long tenantId) { public void delete(Long id, Long tenantId) {
Storyboard existing = storyboardMapper.selectById(id); Storyboard existing = storyboardMapper.selectById(id);
if (existing == null || !tenantId.equals(existing.getTenantId())) { 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); storyboardMapper.deleteById(id);
} }
...@@ -196,97 +225,81 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -196,97 +225,81 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
} }
} }
private static final String PROMPT_GEN_SYSTEM = """
你是一位专业的影视分镜撰写师,擅长根据剧集剧情、角色外观设定和场景设定,将分镜信息转化为精炼的视频生成提示词。
我会提供:① 该分镜所属集数的剧情、② 项目角色和场景的视觉设定、③ 当前分镜的镜头信息。
请严格依据以上内容,生成按时间轴分段的影视风格描述,格式如下:
0-Xs(氛围标签,如:抓眼/转折/高潮/收尾)
镜头类型(特写/中景/远景/近景):场景动作描述
角色名(情绪):"对白内容"(无对白则省略此行)
音效/BGM描述
要求:
- 描述必须忠实于提供的剧情和角色/场景设定,角色外貌、服装等严格参照设定
- 分镜中 @角色名 / @场景名 表示该分镜引用了该角色或场景的视觉设定
- 按时间段分2-4段,总时长等于分镜时长
- 每段包含:时间轴、画面、对白(有则写)、声音
- 文字精炼生动,适合作为视频生成提示词
- 只返回提示词文本,不加任何说明
""";
@Override @Override
public String generatePrompt(Long storyboardId, Long tenantId) { public String generatePrompt(Long storyboardId, Long tenantId) {
Storyboard sb = storyboardMapper.selectById(storyboardId); Storyboard storyboard = storyboardMapper.selectById(storyboardId);
if (sb == null || !tenantId.equals(sb.getTenantId())) { if (storyboard == null || !tenantId.equals(storyboard.getTenantId())) {
throw new BizException(ErrorCode.NOT_FOUND, "分镜不存在"); throw new BizException(ErrorCode.NOT_FOUND, "Storyboard not found");
} }
// ① 所属集数剧情
StringBuilder userMsg = new StringBuilder(); StringBuilder userMsg = new StringBuilder();
if (sb.getEpisodeId() != null) { if (storyboard.getEpisodeId() != null) {
Episode episode = episodeMapper.selectById(sb.getEpisodeId()); Episode episode = episodeMapper.selectById(storyboard.getEpisodeId());
if (episode != null) { if (episode != null) {
String episodeContent = episode.getScript() != null && !episode.getScript().isBlank() String episodeContent = episode.getScript() != null && !episode.getScript().isBlank()
? episode.getScript() : nullSafe(episode.getSummary()); ? episode.getScript()
userMsg.append(String.format(""" : nullSafe(episode.getSummary());
【所属集数剧情】 userMsg.append(String.format(
第%d集《%s》 "Episode context:%nEpisode %d - %s%n%s%n%n",
%s
""",
episode.getEpisodeNumber() != null ? episode.getEpisodeNumber() : 0, episode.getEpisodeNumber() != null ? episode.getEpisodeNumber() : 0,
nullSafe(episode.getTitle()), episodeContent)); nullSafe(episode.getTitle()),
episodeContent
));
} }
} }
// ② 角色视觉设定(本项目全部角色) List<Character> characters = characterMapper.findByProject(storyboard.getProjectId(), tenantId);
List<Character> characters = characterMapper.findByProject(sb.getProjectId(), tenantId);
if (!characters.isEmpty()) { if (!characters.isEmpty()) {
userMsg.append("【角色视觉设定】\n"); userMsg.append("Character visual setup:\n");
for (Character c : characters) { for (Character character : characters) {
userMsg.append(String.format("- @%s(%s):%s,服装:%s,外貌特征:%s\n", userMsg.append(String.format(
nullSafe(c.getName()), "- @%s (%s): personality=%s, costume=%s, visual=%s%n",
nullSafe(c.getRoleType()), nullSafe(character.getName()),
nullSafe(c.getPersonality()), nullSafe(character.getRoleType()),
nullSafe(c.getCostume()), nullSafe(character.getPersonality()),
nullSafe(c.getVisualHint()))); nullSafe(character.getCostume()),
nullSafe(character.getVisualHint())
));
} }
userMsg.append("\n"); userMsg.append("\n");
} }
// ③ 场景视觉设定 List<Scene> scenes = sceneMapper.findByProject(storyboard.getProjectId(), tenantId);
List<Scene> scenes = sceneMapper.findByProject(sb.getProjectId(), tenantId);
if (!scenes.isEmpty()) { if (!scenes.isEmpty()) {
userMsg.append("【场景视觉设定】\n"); userMsg.append("Scene visual setup:\n");
for (Scene s : scenes) { for (Scene scene : scenes) {
userMsg.append(String.format("- @%s(%s):%s\n", userMsg.append(String.format(
nullSafe(s.getName()), "- @%s (%s): %s%n",
s.getSceneType() != null ? ("indoor".equals(s.getSceneType()) ? "室内" : "室外") : "", nullSafe(scene.getName()),
nullSafe(s.getDescription()))); "indoor".equals(scene.getSceneType()) ? "indoor" : "outdoor",
nullSafe(scene.getDescription())
));
} }
userMsg.append("\n"); userMsg.append("\n");
} }
// ④ 当前分镜信息 StoryboardRefs promptRefs = promptRefsForCurrentShot(storyboard, scenes);
userMsg.append(String.format(""" userMsg.append(String.format(
【当前分镜信息】(第%d个镜头) "Current storyboard shot:%n" +
- 镜头描述:%s "- Sequence: %d%n" +
- 详细场景:%s "- Short description: %s%n" +
- 出场角色/场景:%s "- Detailed description: %s%n" +
- 对白内容:%s "- Character refs: %s%n" +
- 镜头运动:%s "- Scene refs: %s%n" +
- 构图方式:%s "- Dialogues: %s%n" +
- 分镜时长:%d 秒 "- Camera direction: %s%n" +
""", "- Composition guide: %s%n" +
sb.getSequenceNum() != null ? sb.getSequenceNum() : 0, "- Duration seconds: %d%n",
nullSafe(sb.getShortDescription()), storyboard.getSequenceNum() != null ? storyboard.getSequenceNum() : 0,
nullSafe(sb.getDetailedDescription()), nullSafe(storyboard.getShortDescription()),
nullSafe(sb.getCharacters()), nullSafe(storyboard.getDetailedDescription()),
nullSafe(sb.getDialogues()), nullSafe(promptRefs.characters()),
nullSafe(sb.getCameraDirection()), nullSafe(promptRefs.sceneRef()),
nullSafe(sb.getCompositionGuide()), nullSafe(storyboard.getDialogues()),
sb.getDurationSeconds() != null ? sb.getDurationSeconds() : 5)); nullSafe(storyboard.getCameraDirection()),
nullSafe(storyboard.getCompositionGuide()),
storyboard.getDurationSeconds() != null ? storyboard.getDurationSeconds() : 5
));
ChatRequest request = ChatRequest.builder() ChatRequest request = ChatRequest.builder()
.messages(List.of( .messages(List.of(
...@@ -295,12 +308,18 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -295,12 +308,18 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
)) ))
.build(); .build();
log.info("Generating storyboard prompt: storyboardId={}, episodeId={}, characters={}, scenes={}", log.info(
storyboardId, sb.getEpisodeId(), characters.size(), scenes.size()); "Generating storyboard prompt: storyboardId={}, episodeId={}, characters={}, scenes={}",
storyboardId,
storyboard.getEpisodeId(),
characters.size(),
scenes.size()
);
billingService.checkBalance(BillingChargeRequest.builder() billingService.checkBalance(BillingChargeRequest.builder()
.tenantId(tenantId) .tenantId(tenantId)
.userId(UserContext.require()) .userId(UserContext.require())
.projectId(sb.getProjectId()) .projectId(storyboard.getProjectId())
.operation("storyboard_prompt_generate") .operation("storyboard_prompt_generate")
.modality("TEXT") .modality("TEXT")
.modelProvider(llmService.getProvider()) .modelProvider(llmService.getProvider())
...@@ -309,11 +328,18 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -309,11 +328,18 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
.unitCount(1) .unitCount(1)
.refId(String.valueOf(storyboardId)) .refId(String.valueOf(storyboardId))
.build()); .build());
String prompt = llmService.chat(request); String prompt = llmService.chat(request);
Storyboard promptUpdate = new Storyboard();
promptUpdate.setId(storyboard.getId());
promptUpdate.setVideoPrompt(prompt);
storyboardMapper.updateById(promptUpdate);
billingService.charge(BillingChargeRequest.builder() billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId) .tenantId(tenantId)
.userId(UserContext.require()) .userId(UserContext.require())
.projectId(sb.getProjectId()) .projectId(storyboard.getProjectId())
.operation("storyboard_prompt_generate") .operation("storyboard_prompt_generate")
.modality("TEXT") .modality("TEXT")
.modelProvider(llmService.getProvider()) .modelProvider(llmService.getProvider())
...@@ -325,47 +351,163 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -325,47 +351,163 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
return prompt; return prompt;
} }
private static String nullSafe(String s) { @Override
return s != null ? s : ""; 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) { private String buildAssetContext(Long projectId, Long tenantId) {
StringBuilder sb = new StringBuilder(); StringBuilder context = new StringBuilder();
List<Character> characters = characterMapper.findByProject(projectId, tenantId); List<Character> characters = characterMapper.findByProject(projectId, tenantId);
List<Scene> scenes = sceneMapper.findByProject(projectId, tenantId); List<Scene> scenes = sceneMapper.findByProject(projectId, tenantId);
if (characters.isEmpty() && scenes.isEmpty()) { if (characters.isEmpty() && scenes.isEmpty()) {
return "(本项目暂无角色/场景设定,请根据脚本自行设计)"; return "No project character or scene setup exists yet. Infer reasonably from the script.";
} }
sb.append("【项目角色设定】(请在 characters 字段用 @名称 引用)\n"); if (!characters.isEmpty()) {
for (Character c : characters) { context.append("Project characters (refer to them as @Name in the characters field):\n");
sb.append(String.format("- @%s:%s,%s,服装:%s\n", for (Character character : characters) {
nullSafe(c.getName()), context.append(String.format(
nullSafe(c.getPersonality()), "- @%s: personality=%s, visual=%s, costume=%s%n",
nullSafe(c.getVisualHint()), nullSafe(character.getName()),
nullSafe(c.getCostume()))); nullSafe(character.getPersonality()),
nullSafe(character.getVisualHint()),
nullSafe(character.getCostume())
));
}
} }
if (!scenes.isEmpty()) { if (!scenes.isEmpty()) {
sb.append("\n【项目场景设定】(请在 characters 字段用 @名称 引用场景)\n"); context.append("\nProject scenes (they may also appear as @Name refs in the characters field):\n");
for (Scene s : scenes) { for (Scene scene : scenes) {
sb.append(String.format("- @%s(%s):%s\n", context.append(String.format(
nullSafe(s.getName()), "- @%s (%s): %s%n",
"indoor".equals(s.getSceneType()) ? "室内" : "室外", nullSafe(scene.getName()),
nullSafe(s.getDescription()))); "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) { private String extractJson(String raw) {
String s = raw.strip(); String text = raw.strip();
if (s.startsWith("```")) { if (text.startsWith("```")) {
int start = s.indexOf('\n') + 1; int start = text.indexOf('\n') + 1;
int end = s.lastIndexOf("```"); int end = text.lastIndexOf("```");
if (end > start) s = s.substring(start, end).strip(); 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) {
} }
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment