Commit 2ce18b88 authored by yaoke.yk's avatar yaoke.yk

Agent制作fix6

parent b29011ef
...@@ -95,6 +95,21 @@ function characterFrontUrl(c: Character): string | null { ...@@ -95,6 +95,21 @@ function characterFrontUrl(c: Character): string | null {
return c.frontImageUrl ?? c.imageUrl ?? null; return c.frontImageUrl ?? c.imageUrl ?? null;
} }
function refTokens(value?: string | null): string[] {
if (!value) return [];
return value.split(",").map((token) => token.trim()).filter(Boolean);
}
function inferSceneRefFromCharacters(characters: string | null | undefined, scenes: Scene[]): string | null {
const sceneRefs = new Set(
scenes
.map((scene) => scene.name?.trim())
.filter(Boolean)
.map((name) => `@${name}`)
);
return refTokens(characters).find((token) => sceneRefs.has(token)) ?? null;
}
export function StoryboardWorkspace() { export function StoryboardWorkspace() {
const { episodeId: urlEpisodeId, projectId } = useParams<{ projectId: string; episodeId?: string }>(); const { episodeId: urlEpisodeId, projectId } = useParams<{ projectId: string; episodeId?: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
...@@ -238,7 +253,7 @@ export function StoryboardWorkspace() { ...@@ -238,7 +253,7 @@ export function StoryboardWorkspace() {
setCharacterKeys(initialChars); setCharacterKeys(initialChars);
setSceneKey(lastTask?.sceneImageKey ?? ""); setSceneKey(lastTask?.sceneImageKey ?? "");
setPropKeys(lastTask?.propImageKeys ?? []); setPropKeys(lastTask?.propImageKeys ?? []);
setFreeformPrompt(lastTask?.userPrompt?.freeformPrompt ?? ""); setFreeformPrompt(expanded.videoPrompt?.trim() || (lastTask?.userPrompt?.freeformPrompt ?? ""));
if (typeof lastTask?.videoDuration === "number") setSelectedDuration(lastTask.videoDuration); if (typeof lastTask?.videoDuration === "number") setSelectedDuration(lastTask.videoDuration);
if (lastTask?.videoRatio) setSelectedRatio(lastTask.videoRatio); if (lastTask?.videoRatio) setSelectedRatio(lastTask.videoRatio);
if (typeof lastTask?.generateAudio === "boolean") setAudioOn(lastTask.generateAudio); if (typeof lastTask?.generateAudio === "boolean") setAudioOn(lastTask.generateAudio);
...@@ -373,6 +388,20 @@ export function StoryboardWorkspace() { ...@@ -373,6 +388,20 @@ export function StoryboardWorkspace() {
const previewTask = previewSb ? getTaskForSb(previewSb) : null; const previewTask = previewSb ? getTaskForSb(previewSb) : null;
const promptPreviewText = freeformPrompt.trim(); const promptPreviewText = freeformPrompt.trim();
const sceneRefDisplay = expanded
? expanded.sceneRef?.trim() || inferSceneRefFromCharacters(expanded.characters, scenes) || "未标注"
: "";
const charactersDisplay = expanded?.characters?.trim() || "未标注";
const startFrameDisplay = expanded?.startFramePrompt?.trim() || "未生成";
const motionScriptDisplay = expanded?.motionScript?.trim() || "未生成";
const videoPromptDisplay = expanded?.videoPrompt?.trim() || "未生成";
const storyboardInfoFields = expanded ? [
{ label: "出镜角色", value: charactersDisplay },
{ label: "分镜场景", value: sceneRefDisplay },
{ label: "首帧提示词", value: startFrameDisplay },
{ label: "镜头运动", value: motionScriptDisplay },
{ label: "视频提示词", value: videoPromptDisplay, multiline: true },
] : [];
return ( return (
<div className="h-full flex overflow-hidden bg-[#f4f7fb] text-foreground"> <div className="h-full flex overflow-hidden bg-[#f4f7fb] text-foreground">
...@@ -431,7 +460,7 @@ export function StoryboardWorkspace() { ...@@ -431,7 +460,7 @@ export function StoryboardWorkspace() {
<span className="absolute left-0 top-3 bottom-3 w-[3px] bg-[#1c64ff]/55 rounded-r-full" /> <span className="absolute left-0 top-3 bottom-3 w-[3px] bg-[#1c64ff]/55 rounded-r-full" />
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] font-semibold tracking-[0.12em] uppercase text-muted-foreground">分镜备注</span> <span className="text-[11px] font-semibold tracking-[0.12em] uppercase text-muted-foreground">分镜备注</span>
{promptPreviewText && ( {(promptPreviewText || expanded) && (
<button <button
onClick={() => setShowPromptDetails(true)} onClick={() => setShowPromptDetails(true)}
className="text-[10px] text-[#1c64ff] hover:text-[#164fd0] flex items-center gap-0.5 font-semibold transition-colors" className="text-[10px] text-[#1c64ff] hover:text-[#164fd0] flex items-center gap-0.5 font-semibold transition-colors"
...@@ -789,6 +818,7 @@ export function StoryboardWorkspace() { ...@@ -789,6 +818,7 @@ export function StoryboardWorkspace() {
{showPromptDetails && ( {showPromptDetails && (
<PromptDetailsModal <PromptDetailsModal
text={freeformPrompt} text={freeformPrompt}
agentInfo={storyboardInfoFields}
onClose={() => setShowPromptDetails(false)} onClose={() => setShowPromptDetails(false)}
/> />
)} )}
...@@ -799,6 +829,36 @@ export function StoryboardWorkspace() { ...@@ -799,6 +829,36 @@ export function StoryboardWorkspace() {
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
// StoryboardCard — accordion item with self-contained editor when expanded // StoryboardCard — accordion item with self-contained editor when expanded
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
function StoryboardInfoField({
label,
value,
multiline = false,
compact = false,
}: {
label: string;
value: string;
multiline?: boolean;
compact?: boolean;
}) {
return (
<div className={compact ? "px-3 py-2" : "px-3 py-2.5"}>
<div className="flex items-start gap-2">
<span className="w-16 flex-shrink-0 text-[11px] font-medium text-muted-foreground leading-relaxed">
{label}
</span>
<span
className={`min-w-0 flex-1 text-[11px] leading-relaxed text-foreground/85 ${
multiline ? "line-clamp-3 break-words" : "truncate"
}`}
title={value}
>
{value}
</span>
</div>
</div>
);
}
interface StoryboardCardProps { interface StoryboardCardProps {
sb: Storyboard; sb: Storyboard;
isExpanded: boolean; isExpanded: boolean;
...@@ -1483,9 +1543,10 @@ function AddCharacterModal({ projectId, onClose, onAdded }: AddCharacterModalPro ...@@ -1483,9 +1543,10 @@ function AddCharacterModal({ projectId, onClose, onAdded }: AddCharacterModalPro
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
interface PromptDetailsModalProps { interface PromptDetailsModalProps {
text: string; text: string;
agentInfo: Array<{ label: string; value: string; multiline?: boolean }>;
onClose: () => void; onClose: () => void;
} }
function PromptDetailsModal({ text, onClose }: PromptDetailsModalProps) { function PromptDetailsModal({ text, agentInfo, onClose }: PromptDetailsModalProps) {
const trimmed = text.trim(); const trimmed = text.trim();
return ( return (
<div className="fixed inset-0 bg-slate-950/55 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={onClose}> <div className="fixed inset-0 bg-slate-950/55 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={onClose}>
...@@ -1499,7 +1560,7 @@ function PromptDetailsModal({ text, onClose }: PromptDetailsModalProps) { ...@@ -1499,7 +1560,7 @@ function PromptDetailsModal({ text, onClose }: PromptDetailsModalProps) {
<X className="w-4 h-4 text-muted-foreground" /> <X className="w-4 h-4 text-muted-foreground" />
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto p-5"> <div className="flex-1 overflow-y-auto p-5 space-y-4">
{trimmed ? ( {trimmed ? (
<pre className="text-sm text-foreground leading-relaxed whitespace-pre-wrap font-mono rounded-xl border border-slate-200/80 bg-[#f8fafc] p-4"> <pre className="text-sm text-foreground leading-relaxed whitespace-pre-wrap font-mono rounded-xl border border-slate-200/80 bg-[#f8fafc] p-4">
{trimmed} {trimmed}
...@@ -1507,6 +1568,22 @@ function PromptDetailsModal({ text, onClose }: PromptDetailsModalProps) { ...@@ -1507,6 +1568,22 @@ function PromptDetailsModal({ text, onClose }: PromptDetailsModalProps) {
) : ( ) : (
<p className="text-sm text-muted-foreground italic">(未填写提示词)</p> <p className="text-sm text-muted-foreground italic">(未填写提示词)</p>
)} )}
{agentInfo.length > 0 && (
<div className="rounded-xl border border-slate-200/80 bg-white divide-y divide-slate-100 overflow-hidden">
<div className="px-3 py-2 flex items-center justify-between bg-[#f8fafc]">
<span className="text-xs font-medium text-foreground">Agent 分镜信息</span>
<span className="text-[10px] text-muted-foreground">仅作参考</span>
</div>
{agentInfo.map((item) => (
<StoryboardInfoField
key={item.label}
label={item.label}
value={item.value}
multiline={item.multiline}
/>
))}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
......
...@@ -31,6 +31,8 @@ export interface Storyboard { ...@@ -31,6 +31,8 @@ export interface Storyboard {
shortDescription: string; shortDescription: string;
detailedDescription: string; detailedDescription: string;
characters: string; characters: string;
sceneRef?: string | null;
videoPrompt?: string | null;
dialogues: string; dialogues: string;
cameraDirection: string; cameraDirection: string;
compositionGuide: string; compositionGuide: string;
......
...@@ -187,14 +187,23 @@ public class AgentRunServiceImpl implements AgentRunService { ...@@ -187,14 +187,23 @@ public class AgentRunServiceImpl implements AgentRunService {
executeStep(runId, "storyboard_gen", () -> { executeStep(runId, "storyboard_gen", () -> {
sseManager.emitLog(runId, "agent", "[执行层] 生成分镜..."); sseManager.emitLog(runId, "agent", "[执行层] 生成分镜...");
int total = 0; int total = 0;
int promptTotal = 0;
for (Episode ep : episodes) { for (Episode ep : episodes) {
List<Storyboard> boards = storyboardPipeline.generateStoryboards( List<Storyboard> boards = storyboardPipeline.generateStoryboards(
ep.getId(), projectId, tenantId); ep.getId(), projectId, tenantId);
total += boards.size(); total += boards.size();
List<Storyboard> enriched = storyboardPipeline.populateMissingVideoPromptsByEpisode(
ep.getId(), tenantId);
long promptCount = enriched.stream()
.filter(sb -> sb.getVideoPrompt() != null && !sb.getVideoPrompt().isBlank())
.count();
promptTotal += (int) promptCount;
sseManager.emitLog(runId, "info",
"第 " + ep.getEpisodeNumber() + " 集视频提示词:" + promptCount + " 条已生成");
sseManager.emitLog(runId, "info", sseManager.emitLog(runId, "info",
"第 " + ep.getEpisodeNumber() + " 集分镜:" + boards.size() + " 个镜头"); "第 " + ep.getEpisodeNumber() + " 集分镜:" + boards.size() + " 个镜头");
} }
return "共生成 " + total + " 个分镜"; return "共生成 " + total + " 个分镜,并补全 " + promptTotal + " 条视频提示词";
}); });
checkPaused(runId); checkPaused(runId);
......
package com.yaoai.agent.service.impl;
import com.yaoai.agent.sse.AgentSseManager;
import com.yaoai.ai.core.service.LlmService;
import com.yaoai.domain.entity.Episode;
import com.yaoai.domain.entity.GraphRun;
import com.yaoai.domain.entity.GraphStep;
import com.yaoai.domain.entity.Outline;
import com.yaoai.domain.entity.Storyboard;
import com.yaoai.domain.mapper.EpisodeMapper;
import com.yaoai.domain.mapper.GraphRunMapper;
import com.yaoai.domain.mapper.GraphStepMapper;
import com.yaoai.pipeline.service.AssetGenPipelineService;
import com.yaoai.pipeline.service.OutlinePipelineService;
import com.yaoai.pipeline.service.StoryboardPipelineService;
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 org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AgentRunServiceImplTest {
@Mock
private GraphRunMapper graphRunMapper;
@Mock
private GraphStepMapper graphStepMapper;
@Mock
private EpisodeMapper episodeMapper;
@Mock
private AgentSseManager sseManager;
@Mock
private LlmService llmService;
@Mock
private OutlinePipelineService outlinePipeline;
@Mock
private AssetGenPipelineService assetGenPipeline;
@Mock
private StoryboardPipelineService storyboardPipeline;
@Test
void storyboardGen_shouldPopulateMissingVideoPromptsAfterGeneratingStoryboards() {
AgentRunServiceImpl service = new AgentRunServiceImpl(
graphRunMapper,
graphStepMapper,
episodeMapper,
sseManager,
llmService,
outlinePipeline,
assetGenPipeline,
storyboardPipeline
);
Long runId = 88L;
Long projectId = 22L;
Long tenantId = 33L;
GraphRun runningRun = new GraphRun();
runningRun.setId(runId);
runningRun.setStatus("RUNNING");
when(graphRunMapper.selectById(runId)).thenReturn(runningRun);
when(graphRunMapper.updateById(any(GraphRun.class))).thenReturn(1);
List<GraphStep> steps = List.of(
step(runId, "decision", 0),
step(runId, "outline_gen", 1),
step(runId, "episode_gen", 2),
step(runId, "supervision_1", 3),
step(runId, "asset_gen", 4),
step(runId, "storyboard_gen", 5),
step(runId, "supervision_2", 6)
);
when(graphStepMapper.findByRunId(runId)).thenReturn(steps);
when(graphStepMapper.updateById(any(GraphStep.class))).thenReturn(1);
when(llmService.chat(any())).thenReturn("SCORE: A");
Outline outline = new Outline();
outline.setId(77L);
outline.setTitle("outline");
outline.setEpisodeCount(1);
when(outlinePipeline.generateOutline(projectId, tenantId, "goal")).thenReturn(outline);
Episode episode = new Episode();
episode.setId(101L);
episode.setEpisodeNumber(1);
when(outlinePipeline.generateEpisodes(77L, projectId, tenantId)).thenReturn(List.of(episode));
when(assetGenPipeline.extractCharacters(projectId, tenantId)).thenReturn(List.of());
when(assetGenPipeline.extractScenes(projectId, tenantId)).thenReturn(List.of());
when(storyboardPipeline.generateStoryboards(101L, projectId, tenantId))
.thenReturn(List.of(new Storyboard(), new Storyboard()));
when(storyboardPipeline.populateMissingVideoPromptsByEpisode(101L, tenantId))
.thenReturn(List.of(storyboardWithPrompt(), storyboardWithPrompt()));
ReflectionTestUtils.invokeMethod(
service,
"executeRun",
runId,
projectId,
tenantId,
1001L,
"goal"
);
verify(storyboardPipeline).populateMissingVideoPromptsByEpisode(101L, tenantId);
ArgumentCaptor<GraphStep> stepCaptor = ArgumentCaptor.forClass(GraphStep.class);
verify(graphStepMapper, atLeastOnce()).updateById(stepCaptor.capture());
boolean storyboardOutputMentionedPrompts = stepCaptor.getAllValues().stream()
.anyMatch(step -> "storyboard_gen".equals(step.getStepKey())
&& "DONE".equals(step.getStatus())
&& step.getOutput() != null
&& step.getOutput().contains("视频提示词"));
assertTrue(storyboardOutputMentionedPrompts);
}
private static GraphStep step(Long runId, String stepKey, int seq) {
GraphStep step = new GraphStep();
step.setRunId(runId);
step.setStepKey(stepKey);
step.setStatus("PENDING");
step.setSeq(seq);
return step;
}
private static Storyboard storyboardWithPrompt() {
Storyboard storyboard = new Storyboard();
storyboard.setVideoPrompt("prompt");
return storyboard;
}
}
...@@ -315,7 +315,7 @@ class StoryboardPipelineServiceImplTest { ...@@ -315,7 +315,7 @@ class StoryboardPipelineServiceImplTest {
verify(storyboardMapper, never()).selectById(3L); verify(storyboardMapper, never()).selectById(3L);
ArgumentCaptor<Storyboard> updateCaptor = ArgumentCaptor.forClass(Storyboard.class); ArgumentCaptor<Storyboard> updateCaptor = ArgumentCaptor.forClass(Storyboard.class);
verify(storyboardMapper, times(2)).updateById(updateCaptor.capture()); verify(storyboardMapper, times(2)).updateById(updateCaptor.capture());
assertEquals(List.of(1L, 2L), updateCaptor.getAllValues().stream().map(Storyboard::getId).toList()); assertTrue(updateCaptor.getAllValues().stream().map(Storyboard::getId).toList().containsAll(List.of(1L, 2L)));
} }
private static Scene scene(String name, String sceneType) { private static Scene scene(String name, String sceneType) {
......
...@@ -32,12 +32,18 @@ import java.util.List; ...@@ -32,12 +32,18 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class StoryboardPipelineServiceImpl implements StoryboardPipelineService { public class StoryboardPipelineServiceImpl implements StoryboardPipelineService {
private static final int STORYBOARD_PROMPT_CONCURRENCY = 3;
private static final String SYSTEM_PROMPT_BASE = """ private static final String SYSTEM_PROMPT_BASE = """
You are a professional storyboard artist. You are a professional storyboard artist.
...@@ -363,8 +369,37 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -363,8 +369,37 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
@Override @Override
public List<Storyboard> populateMissingVideoPromptsByEpisode(Long episodeId, Long tenantId) { public List<Storyboard> populateMissingVideoPromptsByEpisode(Long episodeId, Long tenantId) {
List<Storyboard> pending = storyboardMapper.findMissingVideoPromptByEpisode(episodeId, tenantId); List<Storyboard> pending = storyboardMapper.findMissingVideoPromptByEpisode(episodeId, tenantId);
for (Storyboard storyboard : pending) { if (pending.isEmpty()) {
generatePrompt(storyboard.getId(), tenantId); return storyboardMapper.findByEpisode(episodeId, tenantId);
}
Long userId = UserContext.get();
int poolSize = Math.min(STORYBOARD_PROMPT_CONCURRENCY, pending.size());
ExecutorService executor = Executors.newFixedThreadPool(poolSize);
try {
List<CompletableFuture<Void>> futures = pending.stream()
.map(storyboard -> CompletableFuture.runAsync(() -> {
if (userId != null) {
UserContext.set(userId);
}
try {
generatePrompt(storyboard.getId(), tenantId);
} finally {
UserContext.clear();
}
}, executor))
.toList();
for (CompletableFuture<Void> future : futures) {
future.join();
}
} catch (CompletionException e) {
if (e.getCause() instanceof RuntimeException runtimeException) {
throw runtimeException;
}
throw e;
} finally {
executor.shutdown();
} }
return storyboardMapper.findByEpisode(episodeId, tenantId); return storyboardMapper.findByEpisode(episodeId, tenantId);
} }
......
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