feat: add video node preview

This commit is contained in:
stupid-h4er
2026-05-26 23:10:49 +08:00
parent c9fea71821
commit 1b2d473d9c
8 changed files with 137 additions and 24 deletions
+2 -1
View File
@@ -26,7 +26,8 @@ export async function exportAssets(assets: Asset[]) {
await Promise.all( await Promise.all(
assets.map(async (asset) => { assets.map(async (asset) => {
const storageKey = asset.kind === "image" || asset.kind === "video" ? asset.data.storageKey : undefined; if (asset.kind !== "image" && asset.kind !== "video") return;
const storageKey = asset.data.storageKey;
if (!storageKey) return; if (!storageKey) return;
const blob = asset.kind === "image" ? await getImageBlob(storageKey) : await getMediaBlob(storageKey); const blob = asset.kind === "image" ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
if (!blob) return; if (!blob) return;
@@ -7,7 +7,7 @@ import { Home, ImageIcon, Images, List, Menu, MessageSquare, Plus, Redo2, Settin
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image"; import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
import { requestVideoGeneration } from "@/services/api/video"; import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
import { defaultConfig, type AiConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { defaultConfig, type AiConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage"; import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage";
import { resolveMediaUrl, uploadMediaFile, type UploadedFile } from "@/services/file-storage"; import { resolveMediaUrl, uploadMediaFile, type UploadedFile } from "@/services/file-storage";
@@ -1749,14 +1749,14 @@ function InfiniteCanvasPage() {
position: isEmptyVideoNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y }, position: isEmptyVideoNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y },
width: isEmptyVideoNode ? sourceNode.width : spec.width, width: isEmptyVideoNode ? sourceNode.width : spec.width,
height: isEmptyVideoNode ? sourceNode.height : spec.height, height: isEmptyVideoNode ? sourceNode.height : spec.height,
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, references: generationContext.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)) }, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, references: generationReferenceUrls(generationContext) },
}; };
pendingChildIds = [videoId]; pendingChildIds = [videoId];
setNodes((prev) => (isEmptyVideoNode ? prev.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node)) : [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), videoNode])); setNodes((prev) => (isEmptyVideoNode ? prev.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node)) : [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), videoNode]));
if (!isEmptyVideoNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: videoId }]); if (!isEmptyVideoNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: videoId }]);
const video = await uploadMediaFile(await requestVideoGeneration(generationConfig, effectivePrompt, generationContext.referenceImages), "video"); const video = await storeGeneratedVideo(await requestVideoGeneration(generationConfig, effectivePrompt, generationContext.referenceImages, generationContext.referenceVideos));
const videoSize = fitNodeSize(video.width || spec.width, video.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT); const videoSize = fitNodeSize(video.width || spec.width, video.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
setNodes((prev) => prev.map((node) => (node.id === videoId ? { ...node, width: videoSize.width, height: videoSize.height, position: { x: node.position.x + node.width / 2 - videoSize.width / 2, y: node.position.y + node.height / 2 - videoSize.height / 2 }, metadata: { ...node.metadata, ...videoMetadata(video), prompt: effectivePrompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, references: generationContext.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)) } } : node))); setNodes((prev) => prev.map((node) => (node.id === videoId ? { ...node, width: videoSize.width, height: videoSize.height, position: { x: node.position.x + node.width / 2 - videoSize.width / 2, y: node.position.y + node.height / 2 - videoSize.height / 2 }, metadata: { ...node.metadata, ...videoMetadata(video), prompt: effectivePrompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, references: generationReferenceUrls(generationContext) } } : node)));
return; return;
} }
@@ -1857,6 +1857,7 @@ function InfiniteCanvasPage() {
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: "参考图片已丢失,无法继续重试" } } : item))); setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: "参考图片已丢失,无法继续重试" } } : item)));
return; return;
} }
const retryImages = retryReferenceImages || [];
setRunningNodeId(node.id); setRunningNodeId(node.id);
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined } } : item))); setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined } } : item)));
@@ -1873,19 +1874,19 @@ function InfiniteCanvasPage() {
return; return;
} }
if (node.type === CanvasNodeType.Video) { if (node.type === CanvasNodeType.Video) {
const video = await uploadMediaFile(await requestVideoGeneration(generationConfig, prompt, retryReferenceImages || []), "video"); const video = await storeGeneratedVideo(await requestVideoGeneration(generationConfig, prompt, retryImages, context?.referenceVideos || []));
const videoSize = fitNodeSize(video.width || node.width, video.height || node.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT); const videoSize = fitNodeSize(video.width || node.width, video.height || node.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, width: videoSize.width, height: videoSize.height, position: { x: item.position.x + item.width / 2 - videoSize.width / 2, y: item.position.y + item.height / 2 - videoSize.height / 2 }, metadata: { ...item.metadata, ...videoMetadata(video), prompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality } } : item))); setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, width: videoSize.width, height: videoSize.height, position: { x: item.position.x + item.width / 2 - videoSize.width / 2, y: item.position.y + item.height / 2 - videoSize.height / 2 }, metadata: { ...item.metadata, ...videoMetadata(video), prompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality } } : item)));
return; return;
} }
const image = useReferenceImages ? await requestEdit(generationConfig, prompt, retryReferenceImages).then((items) => items[0]) : await requestGeneration(generationConfig, prompt).then((items) => items[0]); const image = useReferenceImages ? await requestEdit(generationConfig, prompt, retryImages).then((items) => items[0]) : await requestGeneration(generationConfig, prompt).then((items) => items[0]);
const uploadedImage = await uploadImage(image.dataUrl); const uploadedImage = await uploadImage(image.dataUrl);
const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; const imageConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height); const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
const generationMetadata = savedImageMetadata?.generationType const generationMetadata = savedImageMetadata?.generationType
? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, count: savedImageMetadata.count || 1, references: savedImageMetadata.references } ? { generationType: savedImageMetadata.generationType, model: generationConfig.model, size: generationConfig.size, quality: generationConfig.quality, count: savedImageMetadata.count || 1, references: savedImageMetadata.references }
: buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryReferenceImages || []); : buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryImages);
setNodes((prev) => setNodes((prev) =>
prev.map((item) => prev.map((item) =>
item.id === node.id item.id === node.id
@@ -2521,6 +2522,13 @@ function referenceUrl(image: ReferenceImage) {
return image.storageKey || image.url || (!image.dataUrl.startsWith("data:") ? image.dataUrl : undefined); return image.storageKey || image.url || (!image.dataUrl.startsWith("data:") ? image.dataUrl : undefined);
} }
function generationReferenceUrls(context: { referenceImages: ReferenceImage[]; referenceVideos: Array<{ storageKey?: string; url?: string }> }) {
return [
...context.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)),
...context.referenceVideos.map((video) => video.storageKey || video.url).filter((url): url is string => Boolean(url)),
];
}
async function resolveMetadataReferences(metadata: CanvasNodeMetadata) { async function resolveMetadataReferences(metadata: CanvasNodeMetadata) {
if (metadata.generationType !== "edit") return []; if (metadata.generationType !== "edit") return [];
if (!metadata.references?.length) return null; if (!metadata.references?.length) return null;
@@ -2574,9 +2582,10 @@ function getGenerationCount(count: string) {
} }
function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial<CanvasNodeData["metadata"]>) { function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial<CanvasNodeData["metadata"]>) {
const next = { ...node, metadata: { ...node.metadata, ...(patch || {}) } }; const safePatch = patch || {};
const next = { ...node, metadata: { ...node.metadata, ...safePatch } };
const spec = node.type === CanvasNodeType.Video ? NODE_DEFAULT_SIZE[CanvasNodeType.Video] : NODE_DEFAULT_SIZE[CanvasNodeType.Image]; const spec = node.type === CanvasNodeType.Video ? NODE_DEFAULT_SIZE[CanvasNodeType.Video] : NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const size = typeof patch.size === "string" && !node.metadata?.content ? nodeSizeFromRatio(patch.size, spec.width, spec.height) : null; const size = typeof safePatch.size === "string" && !node.metadata?.content ? nodeSizeFromRatio(safePatch.size, spec.width, spec.height) : null;
return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next; return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next;
} }
@@ -2595,6 +2604,7 @@ function getInputSummary(inputs: NodeGenerationInput[]) {
return { return {
textCount: inputs.filter((input) => input.type === "text").length, textCount: inputs.filter((input) => input.type === "text").length,
imageCount: inputs.filter((input) => input.type === "image").length, imageCount: inputs.filter((input) => input.type === "image").length,
videoCount: inputs.filter((input) => input.type === "video").length,
}; };
} }
@@ -18,7 +18,7 @@ import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from ".
type CanvasConfigNodePanelProps = { type CanvasConfigNodePanelProps = {
node: CanvasNodeData; node: CanvasNodeData;
isRunning: boolean; isRunning: boolean;
inputSummary: { textCount: number; imageCount: number }; inputSummary: { textCount: number; imageCount: number; videoCount: number };
inputs: NodeGenerationInput[]; inputs: NodeGenerationInput[];
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void; onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
onTextInputChange: (nodeId: string, content: string) => void; onTextInputChange: (nodeId: string, content: string) => void;
@@ -41,6 +41,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }; const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
const textInputs = inputs.filter((input) => input.type === "text"); const textInputs = inputs.filter((input) => input.type === "text");
const imageInputs = inputs.filter((input) => input.type === "image"); const imageInputs = inputs.filter((input) => input.type === "image");
const videoInputs = inputs.filter((input) => input.type === "video");
const moveInput = (input: NodeGenerationInput, offset: number) => { const moveInput = (input: NodeGenerationInput, offset: number) => {
const sameTypeInputs = inputs.filter((item) => item.type === input.type); const sameTypeInputs = inputs.filter((item) => item.type === input.type);
@@ -112,6 +113,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
<div className="mb-2 flex flex-wrap gap-1.5" onMouseDown={(event) => event.stopPropagation()}> <div className="mb-2 flex flex-wrap gap-1.5" onMouseDown={(event) => event.stopPropagation()}>
<InputChip label="提示词" value={`${inputSummary.textCount}`} style={chipStyle} /> <InputChip label="提示词" value={`${inputSummary.textCount}`} style={chipStyle} />
<InputChip label="参考图" value={`${inputSummary.imageCount}`} style={chipStyle} /> <InputChip label="参考图" value={`${inputSummary.imageCount}`} style={chipStyle} />
<InputChip label="参考视频" value={`${inputSummary.videoCount}`} style={chipStyle} />
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onClick={() => setPreviewOpen(true)}> <button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onClick={() => setPreviewOpen(true)}>
<Eye className="size-3.5" /> <Eye className="size-3.5" />
@@ -130,7 +132,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
<Button <Button
type="primary" type="primary"
className="mt-auto !h-9 !w-full !cursor-pointer !rounded-lg" className="mt-auto !h-9 !w-full !cursor-pointer !rounded-lg"
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount)} disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount && !inputSummary.videoCount)}
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
onClick={() => onGenerate(node.id)} onClick={() => onGenerate(node.id)}
> >
@@ -171,6 +173,15 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
</div> </div>
</PreviewSection> </PreviewSection>
</div> </div>
<div className="shrink-0">
<PreviewSection title="参考视频" count={videoInputs.length} empty="暂无参考视频">
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
{videoInputs.map((input, index) => (
<VideoSortCard key={input.nodeId} input={input} videoIndex={index} videoTotal={videoInputs.length} theme={theme} onMove={moveInput} />
))}
</div>
</PreviewSection>
</div>
<div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-hidden"> <div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-hidden">
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5"> <div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5">
<PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词"> <PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词">
@@ -288,6 +299,31 @@ function ImageSortCard({
); );
} }
function VideoSortCard({
input,
videoIndex,
videoTotal,
theme,
onMove,
}: {
input: NodeGenerationInput;
videoIndex: number;
videoTotal: number;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onMove: (input: NodeGenerationInput, offset: number) => void;
}) {
if (!input.video) return null;
return (
<div className="w-32 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
<div className="relative">
<video src={input.video.url} className="aspect-video w-full bg-black object-cover" muted preload="metadata" />
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{videoIndex + 1}</span>
<HorizontalOrderButtons index={videoIndex} total={videoTotal} onMove={(offset) => onMove(input, offset)} />
</div>
</div>
);
}
function VerticalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) { function VerticalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
return ( return (
<> <>
@@ -1,12 +1,15 @@
import type { ChatCompletionMessage } from "@/services/api/image"; import type { ChatCompletionMessage } from "@/services/api/image";
import type { ReferenceImage } from "@/types/image"; import type { ReferenceImage } from "@/types/image";
import type { ReferenceVideo } from "@/types/media";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types";
export type NodeGenerationContext = { export type NodeGenerationContext = {
prompt: string; prompt: string;
referenceImages: ReferenceImage[]; referenceImages: ReferenceImage[];
referenceVideos: ReferenceVideo[];
textCount: number; textCount: number;
imageCount: number; imageCount: number;
videoCount: number;
}; };
export type NodeGenerationInput = { export type NodeGenerationInput = {
@@ -15,6 +18,7 @@ export type NodeGenerationInput = {
title: string; title: string;
text?: string; text?: string;
image?: ReferenceImage; image?: ReferenceImage;
video?: ReferenceVideo;
}; };
export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string): NodeGenerationContext { export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string): NodeGenerationContext {
@@ -24,12 +28,15 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
const referenceImages = inputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image)); const referenceImages = inputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
const referenceVideos = inputs.map((input) => input.video).filter((video): video is ReferenceVideo => Boolean(video));
return { return {
prompt: upstreamText ? `${prompt}\n\n${upstreamText}` : prompt, prompt: upstreamText ? `${prompt}\n\n${upstreamText}` : prompt,
referenceImages, referenceImages,
referenceVideos,
textCount: inputs.filter((input) => input.type === "text").length, textCount: inputs.filter((input) => input.type === "text").length,
imageCount: referenceImages.length, imageCount: referenceImages.length,
videoCount: referenceVideos.length,
}; };
} }
@@ -37,6 +44,8 @@ export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[
return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => { return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
const image = readReferenceImage(node); const image = readReferenceImage(node);
if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }]; if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }];
const video = readReferenceVideo(node);
if (video) return [{ nodeId: node.id, type: "video" as const, title: node.title, video }];
const text = readNodeTextInput(node); const text = readNodeTextInput(node);
if (text) return [{ nodeId: node.id, type: "text" as const, title: node.title, text }]; if (text) return [{ nodeId: node.id, type: "text" as const, title: node.title, text }];
return []; return [];
@@ -77,6 +86,17 @@ function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
}; };
} }
function readReferenceVideo(node: CanvasNodeData): ReferenceVideo | null {
if (node.type !== CanvasNodeType.Video || !node.metadata?.content) return null;
return {
id: node.id,
name: `${node.title || node.id}.mp4`,
type: node.metadata.mimeType || "video/mp4",
url: node.metadata.content,
storageKey: node.metadata.storageKey,
};
}
function getOrderedUpstreamNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) { function getOrderedUpstreamNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const target = nodes.find((node) => node.id === nodeId); const target = nodes.find((node) => node.id === nodeId);
const upstreamNodes = connections const upstreamNodes = connections
@@ -466,7 +466,7 @@ function VideoNodeContent({ node, theme }: NodeContentRendererProps) {
return ( return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}> <div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
<Video className="size-7 opacity-35" /> <Video className="size-7 opacity-35" />
<span className="text-sm">(使)</span> <span className="text-sm"></span>
</div> </div>
); );
return <video src={node.metadata.content} controls className="h-full w-full rounded-[18px] bg-black object-contain" data-canvas-no-zoom />; return <video src={node.metadata.content} controls className="h-full w-full rounded-[18px] bg-black object-contain" data-canvas-no-zoom />;
+3 -1
View File
@@ -218,9 +218,11 @@ export default function ImagePage() {
const insertPickedAsset = async (payload: InsertAssetPayload) => { const insertPickedAsset = async (payload: InsertAssetPayload) => {
if (payload.kind === "text") { if (payload.kind === "text") {
setPrompt(payload.content); setPrompt(payload.content);
} else { } else if (payload.kind === "image") {
const stored = await uploadImage(payload.dataUrl); const stored = await uploadImage(payload.dataUrl);
setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]); setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }]);
} else {
message.warning("生图工作台只能使用文本或图片素材");
} }
setAssetPickerOpen(false); setAssetPickerOpen(false);
}; };
+53 -9
View File
@@ -15,11 +15,12 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { formatBytes, formatDuration } from "@/lib/image-utils"; import { formatBytes, formatDuration } from "@/lib/image-utils";
import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/file-storage"; import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/file-storage";
import { resolveImageUrl, uploadImage } from "@/services/image-storage"; import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { requestVideoGeneration } from "@/services/api/video"; import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
import { useAssetStore } from "@/stores/use-asset-store"; import { useAssetStore } from "@/stores/use-asset-store";
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
import type { ReferenceImage } from "@/types/image"; import type { ReferenceImage } from "@/types/image";
import type { ReferenceVideo } from "@/types/media";
type GeneratedVideo = { type GeneratedVideo = {
id: string; id: string;
@@ -48,6 +49,7 @@ type GenerationLog = {
model: string; model: string;
config: GenerationLogConfig; config: GenerationLogConfig;
references: ReferenceImage[]; references: ReferenceImage[];
videoReferences: ReferenceVideo[];
durationMs: number; durationMs: number;
size: string; size: string;
resolution: string; resolution: string;
@@ -75,6 +77,7 @@ export default function VideoPage() {
const addAsset = useAssetStore((state) => state.addAsset); const addAsset = useAssetStore((state) => state.addAsset);
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [references, setReferences] = useState<ReferenceImage[]>([]); const [references, setReferences] = useState<ReferenceImage[]>([]);
const [videoReferences, setVideoReferences] = useState<ReferenceVideo[]>([]);
const [results, setResults] = useState<GenerationResult[]>([]); const [results, setResults] = useState<GenerationResult[]>([]);
const [logs, setLogs] = useState<GenerationLog[]>([]); const [logs, setLogs] = useState<GenerationLog[]>([]);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
@@ -102,14 +105,23 @@ export default function VideoPage() {
}, []); }, []);
const addReferences = async (files?: FileList | null) => { const addReferences = async (files?: FileList | null) => {
const imageFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/")).slice(0, 7 - references.length); const selectedFiles = Array.from(files || []);
const imageFiles = selectedFiles.filter((file) => file.type.startsWith("image/")).slice(0, 7 - references.length);
const videoFiles = selectedFiles.filter((file) => file.type.startsWith("video/")).slice(0, 3 - videoReferences.length);
const nextReferences = await Promise.all( const nextReferences = await Promise.all(
imageFiles.map(async (file) => { imageFiles.map(async (file) => {
const image = await uploadImage(file); const image = await uploadImage(file);
return { id: nanoid(), name: file.name, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey }; return { id: nanoid(), name: file.name, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey };
}), }),
); );
const nextVideoReferences = await Promise.all(
videoFiles.map(async (file) => {
const video = await uploadMediaFile(file, "video-reference");
return { id: nanoid(), name: file.name, type: video.mimeType, url: video.url, storageKey: video.storageKey };
}),
);
setReferences((value) => [...value, ...nextReferences].slice(0, 7)); setReferences((value) => [...value, ...nextReferences].slice(0, 7));
setVideoReferences((value) => [...value, ...nextVideoReferences].slice(0, 3));
}; };
const addReferencesFromClipboard = async () => { const addReferencesFromClipboard = async () => {
@@ -143,8 +155,7 @@ export default function VideoPage() {
const batchStartedAt = performance.now(); const batchStartedAt = performance.now();
setStartedAt(batchStartedAt); setStartedAt(batchStartedAt);
try { try {
const blob = await requestVideoGeneration(snapshot.config, snapshot.text, snapshot.references); const stored = await storeGeneratedVideo(await requestVideoGeneration(snapshot.config, snapshot.text, snapshot.references, snapshot.videoReferences));
const stored = await uploadMediaFile(blob, "video");
const nextVideo: GeneratedVideo = { const nextVideo: GeneratedVideo = {
id: nanoid(), id: nanoid(),
url: stored.url, url: stored.url,
@@ -156,12 +167,12 @@ export default function VideoPage() {
mimeType: stored.mimeType, mimeType: stored.mimeType,
}; };
setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]); setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]);
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, durationMs: nextVideo.durationMs, status: "成功", video: nextVideo })); saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, durationMs: nextVideo.durationMs, status: "成功", video: nextVideo }));
message.success("视频已生成"); message.success("视频已生成");
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "生成失败"; const errorMessage = error instanceof Error ? error.message : "生成失败";
setResults([{ id: nanoid(), status: "failed", error: errorMessage }]); setResults([{ id: nanoid(), status: "failed", error: errorMessage }]);
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage })); saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage }));
message.error(errorMessage); message.error(errorMessage);
} finally { } finally {
setRunning(false); setRunning(false);
@@ -179,7 +190,7 @@ export default function VideoPage() {
openConfigDialog(true); openConfigDialog(true);
return null; return null;
} }
return { text, config: buildVideoConfig(effectiveConfig, model), references: [...references] }; return { text, config: buildVideoConfig(effectiveConfig, model), references: [...references], videoReferences: [...videoReferences] };
}; };
const retryResult = () => { const retryResult = () => {
@@ -209,6 +220,8 @@ export default function VideoPage() {
} else if (payload.kind === "image") { } else if (payload.kind === "image") {
const stored = await uploadImage(payload.dataUrl); const stored = await uploadImage(payload.dataUrl);
setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }].slice(0, 7)); setReferences((value) => [...value, { id: nanoid(), name: payload.title, type: stored.mimeType, dataUrl: stored.url, storageKey: stored.storageKey }].slice(0, 7));
} else if (payload.kind === "video") {
setVideoReferences((value) => [...value, { id: nanoid(), name: payload.title, type: "video/mp4", url: payload.url, storageKey: payload.storageKey }].slice(0, 3));
} }
setAssetPickerOpen(false); setAssetPickerOpen(false);
}; };
@@ -216,6 +229,7 @@ export default function VideoPage() {
const createSession = () => { const createSession = () => {
setPrompt(""); setPrompt("");
setReferences([]); setReferences([]);
setVideoReferences([]);
setResults([]); setResults([]);
setElapsedMs(0); setElapsedMs(0);
setStartedAt(0); setStartedAt(0);
@@ -248,6 +262,7 @@ export default function VideoPage() {
setLogsOpen(false); setLogsOpen(false);
setPrompt(log.prompt); setPrompt(log.prompt);
setReferences(log.references || []); setReferences(log.references || []);
setVideoReferences(log.videoReferences || []);
if (log.config.videoModel || log.model) updateConfig("videoModel", log.config.videoModel || log.model); if (log.config.videoModel || log.model) updateConfig("videoModel", log.config.videoModel || log.model);
if (log.config.size) updateConfig("size", log.config.size); if (log.config.size) updateConfig("size", log.config.size);
if (log.config.vquality) updateConfig("vquality", log.config.vquality); if (log.config.vquality) updateConfig("vquality", log.config.vquality);
@@ -317,6 +332,26 @@ export default function VideoPage() {
</div> </div>
</div> </div>
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-base font-semibold"></span>
<Button size="small" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()}>
</Button>
</div>
<div className="hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700">
{videoReferences.map((item) => (
<div key={item.id} className="group relative h-20 w-32 shrink-0 overflow-hidden rounded-md border border-stone-200 bg-black dark:border-stone-800">
<video src={item.url} className="size-full object-cover" muted preload="metadata" />
<button type="button" className="absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" onClick={() => setVideoReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label="移除参考视频">
<Trash2 className="size-3.5" />
</button>
</div>
))}
{!videoReferences.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500"> 3 </div> : null}
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden"> <div className="flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden">
<span className="truncate text-stone-500 dark:text-stone-400"> <span className="truncate text-stone-500 dark:text-stone-400">
{model} · {normalizeResolution(effectiveConfig.vquality)}p · {videoSizeLabel(effectiveConfig.size)} · {normalizeVideoSeconds(effectiveConfig.videoSeconds)}s {model} · {normalizeResolution(effectiveConfig.vquality)}p · {videoSizeLabel(effectiveConfig.size)} · {normalizeVideoSeconds(effectiveConfig.videoSeconds)}s
@@ -359,7 +394,7 @@ export default function VideoPage() {
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/*" accept="image/*,video/mp4,video/quicktime"
multiple multiple
className="hidden" className="hidden"
onChange={(event) => { onChange={(event) => {
@@ -542,6 +577,12 @@ async function readStoredLogs() {
async function normalizeLog(log: Partial<GenerationLog>): Promise<GenerationLog> { async function normalizeLog(log: Partial<GenerationLog>): Promise<GenerationLog> {
const video = log.video?.storageKey ? { ...log.video, url: await resolveMediaUrl(log.video.storageKey, log.video.url) } : log.video; const video = log.video?.storageKey ? { ...log.video, url: await resolveMediaUrl(log.video.storageKey, log.video.url) } : log.video;
const videoReferences = await Promise.all(
(log.videoReferences || []).map(async (item) => ({
...item,
url: item.storageKey ? await resolveMediaUrl(item.storageKey, item.url) : item.url,
})),
);
const references = await Promise.all( const references = await Promise.all(
(log.references || []).map(async (item) => ({ (log.references || []).map(async (item) => ({
...item, ...item,
@@ -558,6 +599,7 @@ async function normalizeLog(log: Partial<GenerationLog>): Promise<GenerationLog>
model: log.model || config.videoModel || "", model: log.model || config.videoModel || "",
config, config,
references, references,
videoReferences,
durationMs: log.durationMs || 0, durationMs: log.durationMs || 0,
size: log.size || config.size || "", size: log.size || config.size || "",
resolution: normalizeResolution(log.resolution || config.vquality || ""), resolution: normalizeResolution(log.resolution || config.vquality || ""),
@@ -572,6 +614,7 @@ function serializeLog(log: GenerationLog): GenerationLog {
return { return {
...log, ...log,
references: log.references.map((item) => ({ ...item, dataUrl: item.storageKey ? "" : item.dataUrl })), references: log.references.map((item) => ({ ...item, dataUrl: item.storageKey ? "" : item.dataUrl })),
videoReferences: log.videoReferences.map((item) => (item.storageKey ? { ...item, url: "" } : item)),
video: log.video?.storageKey ? { ...log.video, url: "" } : log.video, video: log.video?.storageKey ? { ...log.video, url: "" } : log.video,
}; };
} }
@@ -586,7 +629,7 @@ function normalizeLogConfig(log: Partial<GenerationLog>): GenerationLogConfig {
}; };
} }
function buildLog({ prompt, model, config, references, durationMs, status, video, error }: { prompt: string; model: string; config: AiConfig; references: ReferenceImage[]; durationMs: number; status: GenerationLog["status"]; video?: GeneratedVideo; error?: string }): GenerationLog { function buildLog({ prompt, model, config, references, videoReferences, durationMs, status, video, error }: { prompt: string; model: string; config: AiConfig; references: ReferenceImage[]; videoReferences: ReferenceVideo[]; durationMs: number; status: GenerationLog["status"]; video?: GeneratedVideo; error?: string }): GenerationLog {
const logConfig = { const logConfig = {
model: config.model, model: config.model,
videoModel: config.videoModel, videoModel: config.videoModel,
@@ -603,6 +646,7 @@ function buildLog({ prompt, model, config, references, durationMs, status, video
model, model,
config: logConfig, config: logConfig,
references, references,
videoReferences,
durationMs, durationMs,
size: logConfig.size, size: logConfig.size,
resolution: logConfig.vquality, resolution: logConfig.vquality,
+1 -1
View File
@@ -20,7 +20,7 @@ type ModelPickerProps = {
export function ModelPicker({ config, value, onChange, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) { export function ModelPicker({ config, value, onChange, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) {
const pickerId = useId(); const pickerId = useId();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const options = useMemo(() => Array.from(new Set([...(config.channelMode === "local" ? [value] : []), ...config.models].filter(Boolean))), [config.channelMode, config.models, value]); const options = useMemo(() => Array.from(new Set([...(config.channelMode === "local" ? [value] : []), ...config.models].filter((model): model is string => Boolean(model)))), [config.channelMode, config.models, value]);
const current = value || ""; const current = value || "";
useEffect(() => { useEffect(() => {