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

Agent制作fix6

parent b29011ef
......@@ -95,6 +95,21 @@ function characterFrontUrl(c: Character): string | 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() {
const { episodeId: urlEpisodeId, projectId } = useParams<{ projectId: string; episodeId?: string }>();
const navigate = useNavigate();
......@@ -238,7 +253,7 @@ export function StoryboardWorkspace() {
setCharacterKeys(initialChars);
setSceneKey(lastTask?.sceneImageKey ?? "");
setPropKeys(lastTask?.propImageKeys ?? []);
setFreeformPrompt(lastTask?.userPrompt?.freeformPrompt ?? "");
setFreeformPrompt(expanded.videoPrompt?.trim() || (lastTask?.userPrompt?.freeformPrompt ?? ""));
if (typeof lastTask?.videoDuration === "number") setSelectedDuration(lastTask.videoDuration);
if (lastTask?.videoRatio) setSelectedRatio(lastTask.videoRatio);
if (typeof lastTask?.generateAudio === "boolean") setAudioOn(lastTask.generateAudio);
......@@ -373,6 +388,20 @@ export function StoryboardWorkspace() {
const previewTask = previewSb ? getTaskForSb(previewSb) : null;
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 (
<div className="h-full flex overflow-hidden bg-[#f4f7fb] text-foreground">
......@@ -431,7 +460,7 @@ export function StoryboardWorkspace() {
<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">
<span className="text-[11px] font-semibold tracking-[0.12em] uppercase text-muted-foreground">分镜备注</span>
{promptPreviewText && (
{(promptPreviewText || expanded) && (
<button
onClick={() => setShowPromptDetails(true)}
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() {
{showPromptDetails && (
<PromptDetailsModal
text={freeformPrompt}
agentInfo={storyboardInfoFields}
onClose={() => setShowPromptDetails(false)}
/>
)}
......@@ -799,6 +829,36 @@ export function StoryboardWorkspace() {
// ────────────────────────────────────────────────────────────────────────────
// 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 {
sb: Storyboard;
isExpanded: boolean;
......@@ -1483,9 +1543,10 @@ function AddCharacterModal({ projectId, onClose, onAdded }: AddCharacterModalPro
// ────────────────────────────────────────────────────────────────────────────
interface PromptDetailsModalProps {
text: string;
agentInfo: Array<{ label: string; value: string; multiline?: boolean }>;
onClose: () => void;
}
function PromptDetailsModal({ text, onClose }: PromptDetailsModalProps) {
function PromptDetailsModal({ text, agentInfo, onClose }: PromptDetailsModalProps) {
const trimmed = text.trim();
return (
<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) {
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5">
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{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">
{trimmed}
......@@ -1507,6 +1568,22 @@ function PromptDetailsModal({ text, onClose }: PromptDetailsModalProps) {
) : (
<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>
......
......@@ -31,6 +31,8 @@ export interface Storyboard {
shortDescription: string;
detailedDescription: string;
characters: string;
sceneRef?: string | null;
videoPrompt?: string | null;
dialogues: string;
cameraDirection: string;
compositionGuide: string;
......
......@@ -187,14 +187,23 @@ public class AgentRunServiceImpl implements AgentRunService {
executeStep(runId, "storyboard_gen", () -> {
sseManager.emitLog(runId, "agent", "[执行层] 生成分镜...");
int total = 0;
int promptTotal = 0;
for (Episode ep : episodes) {
List<Storyboard> boards = storyboardPipeline.generateStoryboards(
ep.getId(), projectId, tenantId);
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",
"第 " + ep.getEpisodeNumber() + " 集分镜:" + boards.size() + " 个镜头");
}
return "共生成 " + total + " 个分镜";
return "共生成 " + total + " 个分镜,并补全 " + promptTotal + " 条视频提示词";
});
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 {
verify(storyboardMapper, never()).selectById(3L);
ArgumentCaptor<Storyboard> updateCaptor = ArgumentCaptor.forClass(Storyboard.class);
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) {
......
......@@ -32,12 +32,18 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
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;
@Slf4j
@Service
@RequiredArgsConstructor
public class StoryboardPipelineServiceImpl implements StoryboardPipelineService {
private static final int STORYBOARD_PROMPT_CONCURRENCY = 3;
private static final String SYSTEM_PROMPT_BASE = """
You are a professional storyboard artist.
......@@ -363,8 +369,37 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
@Override
public List<Storyboard> populateMissingVideoPromptsByEpisode(Long episodeId, Long tenantId) {
List<Storyboard> pending = storyboardMapper.findMissingVideoPromptByEpisode(episodeId, tenantId);
for (Storyboard storyboard : pending) {
generatePrompt(storyboard.getId(), tenantId);
if (pending.isEmpty()) {
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);
}
......
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