feat: add seedance media params and model visibility
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, LoaderCircle, Plus, SlidersHorizontal, Sparkles, Trash2, Upload, VideoIcon } from "lucide-react";
|
||||
import { BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, LoaderCircle, Music2, Plus, SlidersHorizontal, Sparkles, Trash2, Upload, VideoIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { App, Button, Checkbox, Drawer, Empty, Input, Modal, Tag, Typography } from "antd";
|
||||
import localforage from "localforage";
|
||||
@@ -13,6 +13,7 @@ import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
|
||||
import { VideoSettingsPanel, normalizeVideoResolutionValue, normalizeVideoSizeValue, videoSizeLabel } from "@/components/video-settings-panel";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { formatBytes, formatDuration } from "@/lib/image-utils";
|
||||
import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceRatio, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
|
||||
import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/file-storage";
|
||||
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
|
||||
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
|
||||
@@ -20,7 +21,7 @@ import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceVideo } from "@/types/media";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
|
||||
type GeneratedVideo = {
|
||||
id: string;
|
||||
@@ -50,6 +51,7 @@ type GenerationLog = {
|
||||
config: GenerationLogConfig;
|
||||
references: ReferenceImage[];
|
||||
videoReferences: ReferenceVideo[];
|
||||
audioReferences: ReferenceAudio[];
|
||||
durationMs: number;
|
||||
size: string;
|
||||
resolution: string;
|
||||
@@ -59,7 +61,7 @@ type GenerationLog = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type GenerationLogConfig = Pick<AiConfig, "model" | "videoModel" | "size" | "vquality" | "videoSeconds">;
|
||||
type GenerationLogConfig = Pick<AiConfig, "model" | "videoModel" | "size" | "vquality" | "videoSeconds" | "videoGenerateAudio" | "videoWatermark">;
|
||||
|
||||
type UpdateAiConfig = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
||||
|
||||
@@ -78,6 +80,7 @@ export default function VideoPage() {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [references, setReferences] = useState<ReferenceImage[]>([]);
|
||||
const [videoReferences, setVideoReferences] = useState<ReferenceVideo[]>([]);
|
||||
const [audioReferences, setAudioReferences] = useState<ReferenceAudio[]>([]);
|
||||
const [results, setResults] = useState<GenerationResult[]>([]);
|
||||
const [logs, setLogs] = useState<GenerationLog[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
@@ -106,8 +109,14 @@ export default function VideoPage() {
|
||||
|
||||
const addReferences = async (files?: FileList | null) => {
|
||||
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 unsupported = selectedFiles.filter((file) => !file.type.startsWith("image/") && !file.type.startsWith("video/") && !isSupportedAudioFile(file));
|
||||
if (unsupported.length) message.warning("已忽略不支持的参考素材,请使用图片、mp4/mov 视频或 mp3/wav 音频");
|
||||
const imageFiles = selectedFiles.filter((file) => file.type.startsWith("image/") && file.size <= SEEDANCE_REFERENCE_LIMITS.imageMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.images - references.length);
|
||||
const videoFiles = selectedFiles.filter((file) => file.type.startsWith("video/") && file.size <= SEEDANCE_REFERENCE_LIMITS.videoMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.videos - videoReferences.length);
|
||||
const audioFiles = selectedFiles.filter((file) => isSupportedAudioFile(file) && file.size <= SEEDANCE_REFERENCE_LIMITS.audioMaxBytes).slice(0, SEEDANCE_REFERENCE_LIMITS.audios - audioReferences.length);
|
||||
if (selectedFiles.some((file) => file.type.startsWith("image/") && file.size > SEEDANCE_REFERENCE_LIMITS.imageMaxBytes)) message.warning("已忽略超过 30MB 的参考图");
|
||||
if (selectedFiles.some((file) => file.type.startsWith("video/") && file.size > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes)) message.warning("已忽略超过 50MB 的参考视频");
|
||||
if (selectedFiles.some((file) => isSupportedAudioFile(file) && file.size > SEEDANCE_REFERENCE_LIMITS.audioMaxBytes)) message.warning("已忽略超过 15MB 的参考音频");
|
||||
const nextReferences = await Promise.all(
|
||||
imageFiles.map(async (file) => {
|
||||
const image = await uploadImage(file);
|
||||
@@ -117,11 +126,22 @@ export default function VideoPage() {
|
||||
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 };
|
||||
return { id: nanoid(), name: file.name, type: video.mimeType, url: video.url, storageKey: video.storageKey, durationMs: video.durationMs };
|
||||
}),
|
||||
);
|
||||
setReferences((value) => [...value, ...nextReferences].slice(0, 7));
|
||||
setVideoReferences((value) => [...value, ...nextVideoReferences].slice(0, 3));
|
||||
const nextAudioReferences = filterAudioReferencesByDuration(
|
||||
audioReferences,
|
||||
await Promise.all(
|
||||
audioFiles.map(async (file) => {
|
||||
const audio = await uploadMediaFile(file, "audio-reference");
|
||||
return { id: nanoid(), name: file.name, type: audio.mimeType, url: audio.url, storageKey: audio.storageKey, durationMs: audio.durationMs };
|
||||
}),
|
||||
),
|
||||
message.warning,
|
||||
);
|
||||
setReferences((value) => [...value, ...nextReferences].slice(0, SEEDANCE_REFERENCE_LIMITS.images));
|
||||
setVideoReferences((value) => [...value, ...nextVideoReferences].slice(0, SEEDANCE_REFERENCE_LIMITS.videos));
|
||||
setAudioReferences((value) => [...value, ...nextAudioReferences].slice(0, SEEDANCE_REFERENCE_LIMITS.audios));
|
||||
};
|
||||
|
||||
const addReferencesFromClipboard = async () => {
|
||||
@@ -133,18 +153,17 @@ export default function VideoPage() {
|
||||
return;
|
||||
}
|
||||
const nextReferences = await Promise.all(
|
||||
blobs.slice(0, 7 - references.length).map(async (blob, index) => {
|
||||
blobs.slice(0, SEEDANCE_REFERENCE_LIMITS.images - references.length).map(async (blob, index) => {
|
||||
const image = await uploadImage(blob);
|
||||
return { id: nanoid(), name: `clipboard-${index + 1}.png`, type: image.mimeType, dataUrl: image.url, storageKey: image.storageKey };
|
||||
}),
|
||||
);
|
||||
setReferences((value) => [...value, ...nextReferences].slice(0, 7));
|
||||
setReferences((value) => [...value, ...nextReferences].slice(0, SEEDANCE_REFERENCE_LIMITS.images));
|
||||
message.success(`已读取 ${nextReferences.length} 张参考图`);
|
||||
} catch {
|
||||
message.error("剪切板里没有可读取的图片");
|
||||
}
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
const snapshot = buildRequestSnapshot();
|
||||
if (!snapshot) return;
|
||||
@@ -155,7 +174,7 @@ export default function VideoPage() {
|
||||
const batchStartedAt = performance.now();
|
||||
setStartedAt(batchStartedAt);
|
||||
try {
|
||||
const stored = await storeGeneratedVideo(await requestVideoGeneration(snapshot.config, snapshot.text, snapshot.references, snapshot.videoReferences));
|
||||
const stored = await storeGeneratedVideo(await requestVideoGeneration(snapshot.config, snapshot.text, snapshot.references, snapshot.videoReferences, snapshot.audioReferences));
|
||||
const nextVideo: GeneratedVideo = {
|
||||
id: nanoid(),
|
||||
url: stored.url,
|
||||
@@ -167,12 +186,12 @@ export default function VideoPage() {
|
||||
mimeType: stored.mimeType,
|
||||
};
|
||||
setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]);
|
||||
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, durationMs: nextVideo.durationMs, status: "成功", video: nextVideo }));
|
||||
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: nextVideo.durationMs, status: "成功", video: nextVideo }));
|
||||
message.success("视频已生成");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "生成失败";
|
||||
setResults([{ id: nanoid(), status: "failed", error: errorMessage }]);
|
||||
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage }));
|
||||
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage }));
|
||||
message.error(errorMessage);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
@@ -190,7 +209,7 @@ export default function VideoPage() {
|
||||
openConfigDialog(true);
|
||||
return null;
|
||||
}
|
||||
return { text, config: buildVideoConfig(effectiveConfig, model), references: [...references], videoReferences: [...videoReferences] };
|
||||
return { text, config: buildVideoConfig(effectiveConfig, model), references: [...references], videoReferences: [...videoReferences], audioReferences: [...audioReferences] };
|
||||
};
|
||||
|
||||
const retryResult = () => {
|
||||
@@ -219,9 +238,9 @@ export default function VideoPage() {
|
||||
setPrompt(payload.content);
|
||||
} else if (payload.kind === "image") {
|
||||
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, SEEDANCE_REFERENCE_LIMITS.images));
|
||||
} 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));
|
||||
setVideoReferences((value) => [...value, { id: nanoid(), name: payload.title, type: "video/mp4", url: payload.url, storageKey: payload.storageKey }].slice(0, SEEDANCE_REFERENCE_LIMITS.videos));
|
||||
}
|
||||
setAssetPickerOpen(false);
|
||||
};
|
||||
@@ -230,6 +249,7 @@ export default function VideoPage() {
|
||||
setPrompt("");
|
||||
setReferences([]);
|
||||
setVideoReferences([]);
|
||||
setAudioReferences([]);
|
||||
setResults([]);
|
||||
setElapsedMs(0);
|
||||
setStartedAt(0);
|
||||
@@ -263,10 +283,13 @@ export default function VideoPage() {
|
||||
setPrompt(log.prompt);
|
||||
setReferences(log.references || []);
|
||||
setVideoReferences(log.videoReferences || []);
|
||||
setAudioReferences(log.audioReferences || []);
|
||||
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.vquality) updateConfig("vquality", log.config.vquality);
|
||||
if (log.config.videoSeconds) updateConfig("videoSeconds", log.config.videoSeconds);
|
||||
if (log.config.videoGenerateAudio) updateConfig("videoGenerateAudio", log.config.videoGenerateAudio);
|
||||
if (log.config.videoWatermark) updateConfig("videoWatermark", log.config.videoWatermark);
|
||||
setResults(log.video ? [{ id: log.video.id, status: "success", video: log.video }] : [{ id: log.id, status: "failed", error: log.error || "生成失败" }]);
|
||||
};
|
||||
|
||||
@@ -328,7 +351,7 @@ export default function VideoPage() {
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">暂无参考图,最多 7 张</div> : null}
|
||||
{!references.length ? <div className="flex min-w-full items-center justify-center text-sm text-stone-500">暂无参考图,最多 9 张</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -352,6 +375,30 @@ export default function VideoPage() {
|
||||
</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">
|
||||
{audioReferences.map((item) => (
|
||||
<div key={item.id} className="group relative flex h-20 w-48 shrink-0 flex-col justify-center gap-2 rounded-md border border-stone-200 bg-stone-50 px-2 dark:border-stone-800 dark:bg-stone-900">
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs text-stone-500 dark:text-stone-400">
|
||||
<Music2 className="size-4 shrink-0" />
|
||||
<span className="truncate">{item.name}</span>
|
||||
</div>
|
||||
<audio src={item.url} controls className="h-8 w-full" 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={() => setAudioReferences((value) => value.filter((ref) => ref.id !== item.id))} aria-label="移除参考音频">
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!audioReferences.length ? <div className="flex min-w-full items-center justify-center text-center text-sm text-stone-500">暂无参考音频,最多 3 个,mp3/wav,单个 15MB 内</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">
|
||||
<span className="truncate text-stone-500 dark:text-stone-400">
|
||||
{model} · {normalizeResolution(effectiveConfig.vquality)}p · {videoSizeLabel(effectiveConfig.size)} · {normalizeVideoSeconds(effectiveConfig.videoSeconds)}s
|
||||
@@ -394,7 +441,7 @@ export default function VideoPage() {
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/mp4,video/quicktime"
|
||||
accept="image/*,video/mp4,video/quicktime,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
@@ -583,6 +630,12 @@ async function normalizeLog(log: Partial<GenerationLog>): Promise<GenerationLog>
|
||||
url: item.storageKey ? await resolveMediaUrl(item.storageKey, item.url) : item.url,
|
||||
})),
|
||||
);
|
||||
const audioReferences = await Promise.all(
|
||||
(log.audioReferences || []).map(async (item) => ({
|
||||
...item,
|
||||
url: item.storageKey ? await resolveMediaUrl(item.storageKey, item.url) : item.url,
|
||||
})),
|
||||
);
|
||||
const references = await Promise.all(
|
||||
(log.references || []).map(async (item) => ({
|
||||
...item,
|
||||
@@ -600,6 +653,7 @@ async function normalizeLog(log: Partial<GenerationLog>): Promise<GenerationLog>
|
||||
config,
|
||||
references,
|
||||
videoReferences,
|
||||
audioReferences,
|
||||
durationMs: log.durationMs || 0,
|
||||
size: log.size || config.size || "",
|
||||
resolution: normalizeResolution(log.resolution || config.vquality || ""),
|
||||
@@ -615,10 +669,35 @@ function serializeLog(log: GenerationLog): GenerationLog {
|
||||
...log,
|
||||
references: log.references.map((item) => ({ ...item, dataUrl: item.storageKey ? "" : item.dataUrl })),
|
||||
videoReferences: log.videoReferences.map((item) => (item.storageKey ? { ...item, url: "" } : item)),
|
||||
audioReferences: log.audioReferences.map((item) => (item.storageKey ? { ...item, url: "" } : item)),
|
||||
video: log.video?.storageKey ? { ...log.video, url: "" } : log.video,
|
||||
};
|
||||
}
|
||||
|
||||
function isSupportedAudioFile(file: File) {
|
||||
return file.type === "audio/mpeg" || file.type === "audio/mp3" || file.type === "audio/wav" || file.type === "audio/x-wav" || /\.(mp3|wav)$/i.test(file.name);
|
||||
}
|
||||
|
||||
function filterAudioReferencesByDuration(existing: ReferenceAudio[], next: ReferenceAudio[], warn: (content: string) => void) {
|
||||
let total = existing.reduce((sum, item) => sum + (item.durationMs || 0), 0);
|
||||
const accepted: ReferenceAudio[] = [];
|
||||
let skipped = false;
|
||||
for (const item of next) {
|
||||
if (item.durationMs && (item.durationMs < 2000 || item.durationMs > 15000)) {
|
||||
skipped = true;
|
||||
continue;
|
||||
}
|
||||
if (item.durationMs && total + item.durationMs > 15000) {
|
||||
skipped = true;
|
||||
continue;
|
||||
}
|
||||
total += item.durationMs || 0;
|
||||
accepted.push(item);
|
||||
}
|
||||
if (skipped) warn("已忽略不符合时长要求的参考音频:单个 2-15 秒,总时长不超过 15 秒");
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function normalizeLogConfig(log: Partial<GenerationLog>): GenerationLogConfig {
|
||||
return {
|
||||
model: log.config?.model || log.model || "",
|
||||
@@ -626,16 +705,20 @@ function normalizeLogConfig(log: Partial<GenerationLog>): GenerationLogConfig {
|
||||
size: log.config?.size || log.size || "",
|
||||
vquality: normalizeResolution(log.config?.vquality || log.resolution || ""),
|
||||
videoSeconds: log.config?.videoSeconds || log.seconds || "",
|
||||
videoGenerateAudio: log.config?.videoGenerateAudio || "true",
|
||||
videoWatermark: log.config?.videoWatermark || "false",
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
function buildLog({ prompt, model, config, references, videoReferences, audioReferences, durationMs, status, video, error }: { prompt: string; model: string; config: AiConfig; references: ReferenceImage[]; videoReferences: ReferenceVideo[]; audioReferences: ReferenceAudio[]; durationMs: number; status: GenerationLog["status"]; video?: GeneratedVideo; error?: string }): GenerationLog {
|
||||
const logConfig = {
|
||||
model: config.model,
|
||||
videoModel: config.videoModel,
|
||||
size: config.size,
|
||||
vquality: normalizeResolution(config.vquality),
|
||||
videoSeconds: config.videoSeconds,
|
||||
videoGenerateAudio: config.videoGenerateAudio,
|
||||
videoWatermark: config.videoWatermark,
|
||||
};
|
||||
return {
|
||||
id: nanoid(),
|
||||
@@ -647,6 +730,7 @@ function buildLog({ prompt, model, config, references, videoReferences, duration
|
||||
config: logConfig,
|
||||
references,
|
||||
videoReferences,
|
||||
audioReferences,
|
||||
durationMs,
|
||||
size: logConfig.size,
|
||||
resolution: logConfig.vquality,
|
||||
@@ -658,17 +742,21 @@ function buildLog({ prompt, model, config, references, videoReferences, duration
|
||||
}
|
||||
|
||||
function buildVideoConfig(config: AiConfig, model: string): AiConfig {
|
||||
const seedance = isSeedanceVideoConfig({ ...config, model });
|
||||
return {
|
||||
...config,
|
||||
model,
|
||||
videoModel: model,
|
||||
size: normalizeVideoSize(config.size),
|
||||
size: seedance ? normalizeSeedanceRatio(config.size) : normalizeVideoSize(config.size),
|
||||
videoSeconds: normalizeVideoSeconds(config.videoSeconds),
|
||||
vquality: normalizeResolution(config.vquality),
|
||||
videoGenerateAudio: String(boolConfig(config.videoGenerateAudio, true)),
|
||||
videoWatermark: String(boolConfig(config.videoWatermark, false)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVideoSeconds(value: string) {
|
||||
if (String(value).trim() === "-1") return "-1";
|
||||
const seconds = Math.floor(Number(value) || 6);
|
||||
return String(Math.max(1, Math.min(20, seconds)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user