Browse Source

视频节点需要支持封面图

feature/remove
TianYun 3 weeks ago
parent
commit
37531e0780
  1. 5
      src/app/api/generate/providers/__tests__/popiserver.test.ts
  2. 32
      src/app/api/generate/providers/popiserver.ts
  3. 1
      src/app/api/generate/route.ts
  4. 59
      src/app/api/generate/task/poll/__tests__/route.test.ts
  5. 16
      src/app/api/generate/task/poll/route.ts
  6. 14
      src/components/GlobalImageHistory.tsx
  7. 5
      src/components/MyCreationPickerModal.tsx
  8. 3
      src/components/WorkflowCanvas.tsx
  9. 93
      src/components/nodes/GenerateVideoNode.tsx
  10. 23
      src/components/nodes/SmartVideoNode.tsx
  11. 2
      src/lib/providers/types.ts
  12. 3
      src/store/execution/generateVideoExecutor.ts
  13. 1
      src/types/api.ts
  14. 2
      src/types/nodes.ts

5
src/app/api/generate/providers/__tests__/popiserver.test.ts

@ -669,6 +669,8 @@ describe("popiserver generation provider", () => {
resultList: [
{
images: ["https://example.com/result.png"],
originImages: ["https://example.com/origin.png"],
thumbs: ["https://example.com/result_thumb.webp"],
},
],
},
@ -687,7 +689,8 @@ describe("popiserver generation provider", () => {
if (result.status === "completed") {
expect(result.output.outputs?.[0]).toMatchObject({
type: "image",
url: "https://example.com/result.png",
url: "https://example.com/origin.png",
previewImg: "https://example.com/result_thumb.webp",
});
}
});

32
src/app/api/generate/providers/popiserver.ts

@ -64,9 +64,11 @@ type PopiMediaUploadData =
type PopiTaskResult = {
images?: string[] | null;
thumbs?: string[] | null;
video?: string | null;
voice?: string | null;
cover?: string | null;
coverThumb?: string | null;
originImages?: string[] | null;
};
@ -652,9 +654,10 @@ export async function submitPopiTask(
function extractOutputFromTask(task: PopiTask, mediaType: string): GenerationOutput {
const firstResult = Array.isArray(task.resultList) ? task.resultList[0] : null;
if (mediaType === "video") {
const url = firstResult?.video || firstResult?.cover || firstString(task.images || []) || null;
const url = firstResult?.video || null;
const videoCover = firstResult?.cover || firstResult?.coverThumb || null;
return url
? { success: true, outputs: [{ type: "video", data: "", url }] }
? { success: true, outputs: [{ type: "video", data: "", url, videoCover: videoCover || undefined }] }
: { success: false, error: "PopiServer video task completed without output" };
}
if (mediaType === "audio") {
@ -665,20 +668,17 @@ function extractOutputFromTask(task: PopiTask, mediaType: string): GenerationOut
}
const images: { image: string; previewImg?: string }[] = [];
task.resultList?.forEach((res) => {
if (Array.isArray(res?.originImages) && res.originImages.length > 0) {
images.push(
...res.originImages
.filter((item): item is string => typeof item === "string" && item.length > 0)
.map((item, index) => ({
image: item,
previewImg: res.images?.[index],
})),
);
return;
}
collectStrings(res?.images ?? []).forEach((image) => {
images.push({ image });
const originImages = collectStrings(res?.originImages ?? []);
const resultImages = collectStrings(res?.images ?? []);
const thumbs = collectStrings(res?.thumbs ?? []);
const sourceImages = originImages.length > 0 ? originImages : resultImages;
sourceImages.forEach((image, index) => {
const resultImage = resultImages[index];
const previewImg =
thumbs[index] ||
(originImages.length > 0 && resultImage && resultImage !== image ? resultImage : undefined);
images.push({ image, previewImg });
});
});
// task.images is the original input/reference image list in Popiserver task

1
src/app/api/generate/route.ts

@ -169,6 +169,7 @@ export async function buildMediaResponse(
return NextResponse.json<GenerateResponse>({
success: true,
videoUrl,
videoCover: output.videoCover,
contentType: "video",
});
}

59
src/app/api/generate/task/poll/__tests__/route.test.ts

@ -0,0 +1,59 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { GET } from "../route";
import { checkPopiTaskOnce } from "@/app/api/generate/providers/popiserver";
vi.mock("@/app/api/_auth", () => ({
requireLogin: vi.fn(() => ({ token: "login-token" })),
}));
vi.mock("@/app/api/generate/providers/popiserver", () => ({
checkPopiTaskOnce: vi.fn(),
}));
describe("GET /api/generate/task/poll", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns image task results only in the images array with thumbnails", async () => {
vi.mocked(checkPopiTaskOnce).mockResolvedValue({
status: "completed",
output: {
success: true,
outputs: [
{
type: "image",
data: "",
url: "https://statictest.popi.art/media/image/2026/0625/8320.png",
previewImg: "https://statictest.popi.art/media/image/2026/0625/8320_thumb.webp",
},
],
},
});
const request = new NextRequest(
"http://localhost:3000/api/generate/task/poll?taskId=3020&mediaType=image"
);
const response = await GET(request);
const body = await response.json();
expect(response.status).toBe(200);
expect(checkPopiTaskOnce).toHaveBeenCalledWith(request, "login-token", "3020", "image");
expect(body).toEqual({
status: "completed",
result: {
success: true,
contentType: "image",
images: [
{
url: "https://statictest.popi.art/media/image/2026/0625/8320.png",
previewImg: "https://statictest.popi.art/media/image/2026/0625/8320_thumb.webp",
},
],
},
});
expect(body.result.image).toBeUndefined();
});
});

16
src/app/api/generate/task/poll/route.ts

@ -15,6 +15,22 @@ async function buildResultResponse(
output: GenerationOutputItem,
outputs?: GenerationOutputItem[]
): Promise<GenerateResponse> {
if (output.type === "image") {
const images = (outputs && outputs.length > 0 ? outputs : [output])
.filter((item) => item.type === "image")
.map((item) => ({
url: item.data || item.url || "",
previewImg: item.previewImg,
}))
.filter((item) => item.url.length > 0);
return {
success: true,
images,
contentType: "image",
};
}
const response = await buildMediaResponse(request, token, output, outputs);
return (await response.json()) as GenerateResponse;
}

14
src/components/GlobalImageHistory.tsx

@ -19,6 +19,7 @@ type WorkflowGenerationHistoryItem = {
historyIndex: number;
mediaType: "image" | "video";
mediaUrl?: string;
coverUrl?: string;
timestamp: number;
prompt: string;
model: string;
@ -79,6 +80,7 @@ export function collectWorkflowGenerationHistory(nodes: WorkflowNode[]): Workflo
historyIndex,
mediaType: "video",
mediaUrl: item.video,
coverUrl: item.videoCover,
timestamp: item.timestamp,
prompt: item.prompt,
model: item.model,
@ -109,6 +111,17 @@ function MediaThumb({
);
}
if (item.mediaType === "video" && item.coverUrl) {
return (
<img
src={item.coverUrl}
alt=""
className={className}
draggable={false}
/>
);
}
return item.mediaType === "video" ? (
<video
src={src}
@ -359,6 +372,7 @@ export function GlobalImageHistory({
const payload = JSON.stringify({
[item.mediaType]: mediaSrc,
...(item.mediaType === "video" && item.coverUrl ? { cover: item.coverUrl } : {}),
prompt: item.prompt,
timestamp: item.timestamp,
});

5
src/components/MyCreationPickerModal.tsx

@ -42,6 +42,7 @@ interface HistoryItem {
video?: string | null;
voice?: string | null;
cover?: string | null;
coverThumb?: string | null;
}[] | null;
width?: number;
height?: number;
@ -168,7 +169,7 @@ function getHistoryMediaUrl(item: HistoryItem, kind: HistoryTabKey): string | nu
if (mediaKind === "image") {
return firstString(result?.images || undefined) || firstString(result?.originImages || undefined) || firstString(item.images) || null;
}
if (mediaKind === "video") return result?.video || item.video || result?.cover || item.cover || null;
if (mediaKind === "video") return result?.video || item.video || null;
return result?.voice || item.voice || item.audio || firstString(item.audios) || null;
}
@ -178,7 +179,7 @@ function getHistoryPreviewUrl(item: HistoryItem, kind: HistoryTabKey): string |
if (mediaKind === "image") {
return firstString(result?.thumbs || undefined) || firstString(item.thumbs) || firstString(result?.images || undefined) || firstString(item.images) || null;
}
if (mediaKind === "video") return result?.cover || item.coverThumb || item.cover || result?.video || item.video || null;
if (mediaKind === "video") return result?.coverThumb || result?.cover || item.coverThumb || item.cover || result?.video || item.video || null;
return null;
}

3
src/components/WorkflowCanvas.tsx

@ -2783,7 +2783,7 @@ export function WorkflowCanvas() {
const historyVideoData = event.dataTransfer.getData("application/history-video");
if (historyVideoData) {
try {
const { video } = JSON.parse(historyVideoData) as { video?: string };
const { video, cover } = JSON.parse(historyVideoData) as { video?: string; cover?: string };
if (!video) return;
const position = screenToFlowPosition({
x: event.clientX,
@ -2794,6 +2794,7 @@ export function WorkflowCanvas() {
kind: "video",
url: video,
filename: `history-${Date.now()}.mp4`,
previewUrl: cover || null,
},
position,
addNode,

93
src/components/nodes/GenerateVideoNode.tsx

@ -129,6 +129,7 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
const nodeData = data;
const { t } = useI18n();
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const isNodeHovered = useWorkflowStore((state) => state.hoveredNodeId === id);
const updateVideoPreference = useGenerationPreferenceStore((state) => state.updateVideoPreference);
const popiserverVideoModels = useModelStore((state) => state.byKind.video);
const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false);
@ -136,14 +137,28 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isSaveToAssetLibraryOpen, setIsSaveToAssetLibraryOpen] = useState(false);
const [videoRetryNonce, setVideoRetryNonce] = useState(0);
const videoBlobUrl = useVideoBlobUrl(nodeData.outputVideo ?? null);
const [hasRequestedVideoPlayback, setHasRequestedVideoPlayback] = useState(false);
const [isVideoReady, setIsVideoReady] = useState(false);
const currentVideoHistoryItem = nodeData.videoHistory?.[nodeData.selectedVideoHistoryIndex || 0];
const previewVideoPoster = (nodeData as GenerateVideoNodeData & { previewVideoPoster?: string }).previewVideoPoster;
const videoPoster = nodeData.outputVideoCover || currentVideoHistoryItem?.videoCover || previewVideoPoster;
const shouldRequestVideoPlayback = Boolean(nodeData.outputVideo && (!videoPoster || selected || isNodeHovered || hasRequestedVideoPlayback));
const videoBlobUrl = useVideoBlobUrl(shouldRequestVideoPlayback ? nodeData.outputVideo ?? null : null);
const videoAutoplayRef = useVideoAutoplay(id, selected);
usePreventVideoFullscreen(videoAutoplayRef);
useEffect(() => {
setVideoRetryNonce(0);
}, [videoBlobUrl]);
setHasRequestedVideoPlayback(false);
setIsVideoReady(false);
}, [nodeData.outputVideo]);
useEffect(() => {
if (nodeData.outputVideo && (selected || isNodeHovered || !videoPoster)) {
setHasRequestedVideoPlayback(true);
}
}, [isNodeHovered, nodeData.outputVideo, videoPoster, selected]);
// Inline parameters infrastructure
const { inlineParametersEnabled } = useInlineParameters();
@ -269,6 +284,7 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
if (history.length === 0) {
updateNodeData(id, {
outputVideo: null,
outputVideoCover: undefined,
outputVideoRef: undefined,
outputVideoRemoteUrl: undefined,
outputVideoRemoteExpiresAt: undefined,
@ -286,6 +302,7 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
if (nextHistory.length === 0) {
updateNodeData(id, {
outputVideo: null,
outputVideoCover: undefined,
outputVideoRef: undefined,
outputVideoRemoteUrl: undefined,
outputVideoRemoteExpiresAt: undefined,
@ -306,6 +323,7 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
updateNodeData(id, {
outputVideo: nextVideo,
outputVideoCover: nextItem.videoCover,
outputVideoRef: undefined,
outputVideoRemoteUrl: nextVideo && /^https?:\/\//i.test(nextVideo) ? nextVideo : undefined,
outputVideoRemoteExpiresAt: undefined,
@ -333,6 +351,7 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
if (video) {
updateNodeData(id, {
outputVideo: video,
outputVideoCover: videoItem.videoCover,
selectedVideoHistoryIndex: newIndex,
status: "idle",
error: null,
@ -355,6 +374,7 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
if (video) {
updateNodeData(id, {
outputVideo: video,
outputVideoCover: videoItem.videoCover,
selectedVideoHistoryIndex: newIndex,
status: "idle",
error: null,
@ -429,6 +449,7 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
}, [nodeData.outputVideoStorageStatus]);
const hasCarouselVideos = (nodeData.videoHistory || []).length > 1;
const shouldRenderVideoElement = Boolean(nodeData.outputVideo && (!videoPoster || hasRequestedVideoPlayback));
// Track previous status to detect error transitions
const prevStatusRef = useRef(nodeData.status);
@ -603,30 +624,48 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
{/* Preview area */}
{nodeData.outputVideo ? (
<>
<video
ref={videoAutoplayRef}
key={`${nodeData.videoHistory?.[nodeData.selectedVideoHistoryIndex || 0]?.id ?? nodeData.outputVideo}-${videoRetryNonce}`}
src={videoBlobUrl ?? undefined}
controls
controlsList="nofullscreen noremoteplayback"
disablePictureInPicture
loop
muted
className="w-full h-full object-contain"
playsInline
onDoubleClickCapture={preventVideoDoubleClick}
onDoubleClick={preventVideoDoubleClick}
onLoadedMetadata={() => setVideoRetryNonce(0)}
onError={() => {
const retryIndex = videoRetryNonce;
const retryDelay = VIDEO_LOAD_RETRY_DELAYS_MS[retryIndex];
if (retryDelay !== undefined && videoBlobUrl) {
window.setTimeout(() => {
setVideoRetryNonce((current) => current === retryIndex ? current + 1 : current);
}, retryDelay);
}
}}
/>
{videoPoster && (
<img
src={videoPoster}
alt=""
className={`absolute inset-0 h-full w-full object-contain bg-black transition-opacity duration-150 ${
isVideoReady ? "opacity-0" : "opacity-100"
}`}
draggable={false}
/>
)}
{shouldRenderVideoElement && (
<video
ref={videoAutoplayRef}
key={`${currentVideoHistoryItem?.id ?? nodeData.outputVideo}-${videoRetryNonce}`}
src={videoBlobUrl ?? undefined}
poster={videoPoster}
controls
controlsList="nofullscreen noremoteplayback"
disablePictureInPicture
loop
muted
className={`${videoPoster ? "absolute inset-0 " : ""}w-full h-full object-contain`}
playsInline
preload={videoPoster ? "auto" : undefined}
onDoubleClickCapture={preventVideoDoubleClick}
onDoubleClick={preventVideoDoubleClick}
onLoadedMetadata={() => {
setVideoRetryNonce(0);
setIsVideoReady(true);
}}
onError={() => {
setIsVideoReady(false);
const retryIndex = videoRetryNonce;
const retryDelay = VIDEO_LOAD_RETRY_DELAYS_MS[retryIndex];
if (retryDelay !== undefined && videoBlobUrl) {
window.setTimeout(() => {
setVideoRetryNonce((current) => current === retryIndex ? current + 1 : current);
}, retryDelay);
}
}}
/>
)}
{storageBadge && (
<div
className={`absolute top-7 right-1 px-1.5 py-0.5 rounded text-[9px] font-medium pointer-events-auto z-10 ${storageBadge.className}`}

23
src/components/nodes/SmartVideoNode.tsx

@ -58,7 +58,7 @@ function SmartVideoUploadToolbar({
const showSelectedActions = Boolean(selected && selectedNodeCount === 1);
const buildUploadedVideoPatch = useCallback(
(videoUrl: string): Partial<SmartVideoNodeData> => {
(videoUrl: string, videoCover?: string): Partial<SmartVideoNodeData> => {
if (!addToGenerationHistory) return {};
const currentNode = useWorkflowStore.getState().getNodeById(id);
@ -66,10 +66,12 @@ function SmartVideoUploadToolbar({
const timestamp = Date.now();
return {
outputVideo: videoUrl,
outputVideoCover: videoCover,
videoHistory: [
{
id: `${timestamp}`,
video: videoUrl,
videoCover,
timestamp,
prompt: currentData.inputPrompt || "",
model: currentData.selectedModel?.modelId || "",
@ -85,19 +87,26 @@ function SmartVideoUploadToolbar({
);
const applyVideo = useCallback(
(videoSrc: string, filename: string, format: string, duration: number | null, dimensions: { width: number; height: number } | null) => {
(
videoSrc: string,
filename: string,
format: string,
duration: number | null,
dimensions: { width: number; height: number } | null,
videoCover?: string
) => {
updateNodeData(id, {
video: videoSrc,
videoRef: undefined,
assetId: undefined,
previewVideoPoster: undefined,
previewVideoPoster: videoCover,
assetDetailLoading: false,
assetDetailError: null,
filename,
format,
duration,
dimensions,
...buildUploadedVideoPatch(videoSrc),
...buildUploadedVideoPatch(videoSrc, videoCover),
});
},
[buildUploadedVideoPatch, id, updateNodeData]
@ -137,7 +146,8 @@ function SmartVideoUploadToolbar({
uploaded.filename,
uploaded.contentType,
metadata.duration,
metadata.dimensions
metadata.dimensions,
uploaded.previewUrl
);
})
.catch((error) => {
@ -195,13 +205,14 @@ function SmartVideoUploadToolbar({
videoRef: undefined,
assetId: resolved.assetId,
previewVideoPoster: resolved.previewPosterUrl || undefined,
outputVideoCover: addToGenerationHistory ? resolved.previewPosterUrl || undefined : undefined,
assetDetailLoading: false,
assetDetailError: null,
filename: resolved.filename,
format: "video/asset",
duration: resolved.duration || metadata.duration,
dimensions: resolved.dimensions || metadata.dimensions,
...buildUploadedVideoPatch(videoUrl),
...buildUploadedVideoPatch(videoUrl, resolved.previewPosterUrl || undefined),
});
};

2
src/lib/providers/types.ts

@ -121,6 +121,8 @@ export interface GenerationOutputItem {
data: string;
/** Original URL if applicable (e.g., from provider CDN) */
url?: string;
/** Cover/poster image for video outputs */
videoCover?: string;
previewImg?: string; // 为图片时封面图片
}

3
src/store/execution/generateVideoExecutor.ts

@ -274,6 +274,7 @@ export async function executeGenerateVideo(
// Handle video response (video or videoUrl field)
const videoData = result.video || result.videoUrl;
const videoCover = result.videoCover;
const imageData = getGeneratedImageUrl(result.image);
if (result.success && (videoData || imageData)) {
const outputContent = videoData || imageData;
@ -286,6 +287,7 @@ export async function executeGenerateVideo(
const newHistoryItem = {
id: videoId,
video: outputContent,
videoCover,
timestamp,
prompt: text || "",
model: usedModelId,
@ -301,6 +303,7 @@ export async function executeGenerateVideo(
updateNodeData(node.id, {
outputVideo: outputContent,
outputVideoCover: videoCover,
outputVideoRef: undefined,
outputVideoRemoteUrl: isRemoteVideo ? outputContent : undefined,
outputVideoStorageStatus: storageStatus,

1
src/types/api.ts

@ -33,6 +33,7 @@ export interface GenerateResponse {
}[];
video?: string;
videoUrl?: string; // For large videos, return URL directly
videoCover?: string; // Cover/poster image for generated videos
audio?: string; // Base64 audio data
audioUrl?: string; // For large audio, return URL directly
model3dUrl?: string; // For 3D models, return GLB URL directly

2
src/types/nodes.ts

@ -204,6 +204,7 @@ export interface CarouselImageItem {
export interface CarouselVideoItem {
id: string;
video?: string; // Current-session fallback; stripped when externalizing workflow media
videoCover?: string;
timestamp: number;
prompt: string;
model: string; // Model ID for video (not ModelType since external providers)
@ -263,6 +264,7 @@ export interface GenerateVideoNodeData extends BaseNodeData {
inputImageRefs?: string[]; // External image references for storage optimization
inputPrompt: string | null;
outputVideo: string | null; // Video data URL or URL
outputVideoCover?: string;
outputVideoRef?: string; // External video reference for storage optimization
outputVideoRemoteUrl?: string; // Original provider URL retained for fallback/re-download
outputVideoRemoteExpiresAt?: string; // Best-effort expiry timestamp for signed provider URLs

Loading…
Cancel
Save