// 自由创作 · AI 视频生成(移植自 jimeng-clone,嫁接 AirShelf 底座) // 任务流(滚动加载)+ 底部输入条(全能参考/首尾帧 + @mention)+ 渐进轮询(10/30/60s 打后端 // poll 端点,web 进程内查火山,不依赖 worker)+ 平滑进度动画(sessionStorage 续)+ 全屏播放器。 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ArrowLeft, Clapperboard, Images, Users } from "lucide-react"; import { api, ApiError } from "../api"; import type { FreeVideoRef, FreeVideoTask, ModelConfig } from "../types"; import { DEFAULT_BILLING_RATES, FC_MODELS, FC_STANDARD_MODEL, type BillingRates, MAX_AUDIOS, MAX_IMAGES, MAX_VIDEOS, MAX_VIDEO_TOTAL_SECONDS, VIDEO_DURATION_SLACK, checkRefFile, isInFlight, type FreeMode, type LocalRef } from "../components/free-create/constants"; import { FreeInputBar } from "../components/free-create/input-bar"; import { GenerationCard } from "../components/free-create/generation-card"; import { VideoDetailModal } from "../components/free-create/video-detail-modal"; import { AssetLibraryModal } from "../components/free-create/asset-library-modal"; import { PlatformLibraryModal } from "../components/free-create/platform-library-modal"; import type { PromptInputHandle } from "../components/free-create/prompt-input"; import { SystemLoading } from "../components/loading"; import { ConfirmModal } from "../components/overlays"; import { REMIX_PROMPT_KEY } from "./video-remix"; const PAGE_SIZE = 20; const PROGRESS_KEY = "fc-progress"; type FeedFilter = "all" | "inflight" | "succeeded" | "failed"; const FEED_FILTERS: Array<{ key: FeedFilter; label: string }> = [ { key: "all", label: "全部" }, { key: "inflight", label: "生成中" }, { key: "succeeded", label: "已完成" }, { key: "failed", label: "失败" } ]; const GROUP_LABEL: Record<"today" | "yesterday" | "earlier", string> = { today: "今天", yesterday: "昨天", earlier: "更早" }; function chinaDay(iso: string | null) { if (!iso) return ""; return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(iso)); } function dayBucket(iso: string | null): "today" | "yesterday" | "earlier" { const day = chinaDay(iso); if (!day) return "earlier"; const today = chinaDay(new Date().toISOString()); if (day === today) return "today"; const yesterday = chinaDay(new Date(Date.now() - 86400000).toISOString()); return day === yesterday ? "yesterday" : "earlier"; } function matchesFeedFilter(task: FreeVideoTask, filter: FeedFilter) { if (filter === "all") return true; if (filter === "inflight") return isInFlight(task.status); if (filter === "succeeded") return task.status === "succeeded"; return task.status === "failed" || task.status === "cancelled"; } function loadProgress(): Record { try { return JSON.parse(sessionStorage.getItem(PROGRESS_KEY) || "{}") as Record; } catch { return {}; } } // 渐进轮询间隔:前 1 分钟 10s,1-3 分钟 30s,之后 60s(视频 5-10 分钟出片,别打爆后端) function pollDelay(count: number): number { if (count < 6) return 10000; if (count < 10) return 30000; return 60000; } let refKeySeq = 0; const nextRefKey = () => `ref-${Date.now()}-${refKeySeq++}`; export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }: { modelConfigs: ModelConfig[]; onNotify: (type: "success" | "error" | "info", text: string) => void; onTaskSettled: () => void; onBack?: () => void; }) { // App 每次渲染会重建 onNotify 箭头函数;用 ref 稳定身份,否则依赖它的 effect(首屏拉取/轮询) // 会随 App 任意 setState(如 30s 未读轮询)反复重跑 → 任务流无谓全量刷新(实测踩坑)。 const onNotifyRef = useRef(onNotify); onNotifyRef.current = onNotify; const notify = useCallback((type: "success" | "error" | "info", text: string) => onNotifyRef.current(type, text), []); // 同 onNotify:App 外壳重渲染时回调身份会变化,不能让任务列表/轮询 effect 因此重启。 const onTaskSettledRef = useRef(onTaskSettled); onTaskSettledRef.current = onTaskSettled; const refreshGlobalShell = useCallback(() => onTaskSettledRef.current(), []); // 后端返回什么视频模型就给什么,不再拿 FC_MODELS 当白名单过滤 —— // 之前后台新加的模型(如 Seedance 2.5)因为不在这份写死的清单里,永远不会出现在下拉中。 // FC_MODELS 现在只用来给已知模型配好看的标签,认不出的回落 display_name。 // 排序按 FC_MODELS 的先后(2.5 在最前),不在清单里的模型排到后面、保持后端顺序。 const videoConfigs = useMemo( () => modelConfigs .filter((c) => c.capability === "video") .sort((a, b) => { const rank = (name: string) => { const index = FC_MODELS.findIndex((m) => m.name === name); return index === -1 ? FC_MODELS.length : index; }; return rank(a.name) - rank(b.name); }), [modelConfigs] ); // 计费配置(积分汇率/视频毛利):预估口径与后端计价引擎同源;拉不到用首发默认值兜底 const [billingRates, setBillingRates] = useState(DEFAULT_BILLING_RATES); useEffect(() => { void api.billingConfig() .then((cfg) => setBillingRates({ margin: Number(cfg.video_margin_multiplier) || DEFAULT_BILLING_RATES.margin, rate: Number(cfg.points_per_yuan) || DEFAULT_BILLING_RATES.rate, multiplier: Number(cfg.team_price_multiplier) || 1 })) .catch(() => undefined); }, []); // —— 任务流 —— const [tasks, setTasks] = useState([]); const [, setTotal] = useState(0); const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(true); const loadingMoreRef = useRef(false); const sentinelRef = useRef(null); const stageRef = useRef(null); // —— 输入条 —— const [mode, setMode] = useState("universal"); const [model, setModel] = useState(FC_STANDARD_MODEL); const [ratio, setRatio] = useState("16:9"); const [resolution, setResolution] = useState("720p"); const [duration, setDuration] = useState(5); const [seed, setSeed] = useState(-1); const [refs, setRefs] = useState([]); const [submitting, setSubmitting] = useState(false); const promptRef = useRef(null); useEffect(() => { const draft = sessionStorage.getItem(REMIX_PROMPT_KEY); if (!draft) return; sessionStorage.removeItem(REMIX_PROMPT_KEY); window.setTimeout(() => promptRef.current?.setContent(draft, []), 0); notify("info", "已填入分镜稿,可改完再生成"); }, [notify]); // —— 弹窗 —— const [detailId, setDetailId] = useState(null); const [libraryOpen, setLibraryOpen] = useState(false); const [platformOpen, setPlatformOpen] = useState(false); const [libraryTargetRole, setLibraryTargetRole] = useState<"first_frame" | "last_frame" | null>(null); const [deleteTarget, setDeleteTarget] = useState(null); const [feedFilter, setFeedFilter] = useState("all"); // —— 轮询/进度 —— const pollTimersRef = useRef(new Map()); const pollCountsRef = useRef(new Map()); const [progress, setProgress] = useState>(() => loadProgress()); const tasksRef = useRef([]); tasksRef.current = tasks; const patchTask = useCallback((task: FreeVideoTask) => { setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t))); }, []); const stopPolling = useCallback((id: string) => { const timer = pollTimersRef.current.get(id); if (timer) window.clearTimeout(timer); pollTimersRef.current.delete(id); pollCountsRef.current.delete(id); }, []); const schedulePoll = useCallback((id: string) => { if (pollTimersRef.current.has(id)) return; const tick = async () => { pollTimersRef.current.delete(id); const current = tasksRef.current.find((t) => t.id === id); if (!current || !isInFlight(current.status)) { stopPolling(id); return; } const count = (pollCountsRef.current.get(id) || 0) + 1; pollCountsRef.current.set(id, count); try { const data = await api.pollFreeVideo(id); patchTask(data.task); if (isInFlight(data.task.status)) { pollTimersRef.current.set(id, window.setTimeout(() => void tick(), pollDelay(count))); } else { stopPolling(id); setProgress((prev) => { const next = { ...prev }; delete next[id]; sessionStorage.setItem(PROGRESS_KEY, JSON.stringify(next)); return next; }); // 任务结算会改变余额,并产生已有的生成/计费消息;同步 App 外壳即可,页面任务流仍由 patchTask 管理。 refreshGlobalShell(); if (data.task.status === "succeeded") notify("success", "视频生成完成"); else if (data.task.status === "failed") notify("error", data.task.error_message || "视频生成失败"); } } catch { // 单次轮询失败(网络抖动)不终结,下一轮继续 pollTimersRef.current.set(id, window.setTimeout(() => void tick(), pollDelay(count))); } }; pollTimersRef.current.set(id, window.setTimeout(() => void tick(), pollDelay(pollCountsRef.current.get(id) || 0))); }, [patchTask, stopPolling, notify, refreshGlobalShell]); // 平滑进度动画:每 2s 给在途任务 +0.6~1.6%,封顶 95,sessionStorage 持久(刷新可续) useEffect(() => { const timer = window.setInterval(() => { const inflight = tasksRef.current.filter((t) => isInFlight(t.status)); if (inflight.length === 0) return; setProgress((prev) => { const next = { ...prev }; for (const t of inflight) { next[t.id] = Math.min(95, (next[t.id] || 3) + 0.6 + Math.random()); } sessionStorage.setItem(PROGRESS_KEY, JSON.stringify(next)); return next; }); }, 2000); return () => window.clearInterval(timer); }, []); // 首屏拉取 + 在途任务恢复轮询(后端持久恢复,换浏览器也不丢) useEffect(() => { let cancelled = false; void (async () => { try { const data = await api.freeVideoTasks(0, PAGE_SIZE); if (cancelled) return; setTasks(data.results); setTotal(data.total); setHasMore(data.has_more); data.results.filter((t) => isInFlight(t.status)).forEach((t) => schedulePoll(t.id)); } catch (error) { if (!cancelled) notify("error", error instanceof Error ? error.message : "任务列表加载失败"); } finally { if (!cancelled) setLoading(false); } })(); const timers = pollTimersRef.current; return () => { cancelled = true; timers.forEach((timer) => window.clearTimeout(timer)); timers.clear(); }; }, [schedulePoll, notify]); // 滚动加载更多(旧任务)· 滚动容器是中间作品区,不是整页 useEffect(() => { const sentinel = sentinelRef.current; const root = stageRef.current; if (!sentinel || !hasMore) return; const observer = new IntersectionObserver((entries) => { if (!entries[0].isIntersecting || loadingMoreRef.current) return; loadingMoreRef.current = true; void api.freeVideoTasks(tasksRef.current.filter((t) => !t.id.startsWith("local-")).length, PAGE_SIZE) .then((data) => { setTasks((prev) => { const seen = new Set(prev.map((t) => t.id)); return [...prev, ...data.results.filter((t) => !seen.has(t.id))]; }); setTotal(data.total); setHasMore(data.has_more); }) .catch(() => undefined) .finally(() => { loadingMoreRef.current = false; }); }, { root, rootMargin: "200px" }); observer.observe(sentinel); return () => observer.disconnect(); }, [hasMore]); // —— 上传 —— const addFiles = useCallback(async (files: File[], role?: "first_frame" | "last_frame") => { for (const file of files) { const check = await checkRefFile(file); if (!check.ok) { notify("error", check.error); continue; } if (mode === "keyframe") { if (check.type !== "image") { notify("error", "首尾帧模式仅支持图片素材"); continue; } } else { const counts = { image: 0, video: 0, audio: 0 }; let videoSeconds = 0; for (const r of refs) { counts[r.type] += 1; if (r.type === "video") videoSeconds += r.duration || 0; } if (check.type === "image" && counts.image >= MAX_IMAGES) { notify("error", `参考图片最多 ${MAX_IMAGES} 张`); continue; } if (check.type === "video" && counts.video >= MAX_VIDEOS) { notify("error", `参考视频最多 ${MAX_VIDEOS} 条`); continue; } if (check.type === "audio" && counts.audio >= MAX_AUDIOS) { notify("error", `参考音频最多 ${MAX_AUDIOS} 条`); continue; } if (check.type === "video" && videoSeconds + (check.duration || 0) > MAX_VIDEO_TOTAL_SECONDS + VIDEO_DURATION_SLACK) { notify("error", `参考视频总时长不能超过 ${MAX_VIDEO_TOTAL_SECONDS} 秒`); continue; } } const key = nextRefKey(); const blobUrl = URL.createObjectURL(file); const baseLabel = (file.name.replace(/\.[^.]+$/, "") || "素材").slice(0, 24); let label = baseLabel; let n = 2; // 生成唯一 label(重名素材 @ 引用会歧义) // eslint-disable-next-line no-loop-func while (refs.some((r) => r.label === label)) label = `${baseLabel}${n++}`; const local: LocalRef = { key, url: blobUrl, type: check.type, role: mode === "keyframe" ? role || "first_frame" : undefined, label: mode === "keyframe" ? undefined : label, duration: check.duration, source: "upload", uploading: true }; setRefs((prev) => { // keyframe:同 role 只留一张(替换) const cleaned = mode === "keyframe" ? prev.filter((r) => r.role !== local.role) : prev; return [...cleaned, local]; }); const form = new FormData(); form.append("file", file); void api.uploadFreeVideoRef(form) .then((data) => { URL.revokeObjectURL(blobUrl); setRefs((prev) => prev.map((r) => (r.key === key ? { ...r, uploading: false, url: data.url, thumb_url: data.thumb_url || (data.type === "image" ? data.url : ""), duration: data.duration || r.duration, asset_id: data.asset_id } : r))); }) .catch((error) => { URL.revokeObjectURL(blobUrl); setRefs((prev) => prev.filter((r) => r.key !== key)); notify("error", error instanceof Error ? error.message : "素材上传失败"); }); } }, [mode, refs, notify]); const removeRef = useCallback((key: string) => { setRefs((prev) => prev.filter((r) => r.key !== key)); }, []); // 素材集合变化 → 编辑器清孤儿 chip useEffect(() => { promptRef.current?.pruneMentions(refs.map((r) => r.label || "").filter(Boolean)); }, [refs]); // —— 提交 —— const toPayloadRefs = (items: LocalRef[]): FreeVideoRef[] => items.map(({ key: _key, uploading: _uploading, ...rest }) => rest); const doSubmit = useCallback(async (payload: { prompt: string; mode: FreeMode; model: string; aspect_ratio: string; resolution: string; duration: number; seed: number; references: FreeVideoRef[]; }) => { const localId = `local-${Date.now()}`; const placeholder: FreeVideoTask = { id: localId, status: "submitted", mode: payload.mode, model: payload.model, prompt: payload.prompt, aspect_ratio: payload.aspect_ratio, resolution: payload.resolution, duration: payload.duration, seed: payload.seed, generate_audio: true, references: payload.references, estimated_tokens: 0, actual_tokens: 0, estimated_cost: "0", actual_cost: "0", error_message: "", is_favorited: false, video_url: "", thumbnail_url: "", created_at: new Date().toISOString(), completed_at: null }; setTasks((prev) => [placeholder, ...prev]); setSubmitting(true); try { const data = await api.submitFreeVideo(payload); setTasks((prev) => prev.map((t) => (t.id === localId ? data.task : t))); setTotal((prev) => prev + 1); // 进度动画平移到真实任务 id setProgress((prev) => { const next = { ...prev, [data.task.id]: prev[localId] || 3 }; delete next[localId]; sessionStorage.setItem(PROGRESS_KEY, JSON.stringify(next)); return next; }); if (isInFlight(data.task.status)) schedulePoll(data.task.id); else { // 极快失败/成功时不会进入 schedulePoll,也要同步一次全局余额与消息徽标。 refreshGlobalShell(); if (data.task.status === "succeeded") notify("success", "视频生成完成"); else if (data.task.status === "failed") notify("error", data.task.error_message || "创建任务失败"); } return true; } catch (error) { setTasks((prev) => prev.filter((t) => t.id !== localId)); notify("error", error instanceof ApiError ? error.message : "提交失败,请重试"); return false; } finally { setSubmitting(false); } }, [schedulePoll, notify, refreshGlobalShell]); const handleSend = useCallback(async () => { const prompt = promptRef.current?.getText() || ""; if (!prompt.trim()) { notify("error", "请先输入提示词"); return; } if (refs.some((r) => r.uploading)) { notify("info", "素材上传中,请稍候"); return; } if (mode === "keyframe" && !refs.some((r) => r.role === "first_frame")) { notify("error", "首尾帧模式需要提供首帧图片"); return; } if (refs.length === 0 && /@(图片|视频|音频|素材)/.test(prompt)) { notify("error", "提示词里 @ 引用的素材为空,请补充素材或删除该引用"); return; } const audioCount = refs.filter((r) => r.type === "audio").length; if (audioCount > 0 && refs.length === audioCount) { notify("error", "音频不能单独作为参考素材,请同时提供图片或视频"); return; } const ok = await doSubmit({ prompt: prompt.trim(), mode, model, aspect_ratio: ratio, resolution, duration, seed, references: toPayloadRefs(refs) }); if (ok) { promptRef.current?.clear(); setRefs([]); } }, [refs, mode, model, ratio, resolution, duration, seed, doSubmit, notify]); // 失败重试 / 再次生成:不立刻再跑同一条任务,把提示词+参数+素材回填到底部操作区,用户改完再点生成 const handleReuse = useCallback((task: FreeVideoTask) => { setDetailId(null); setMode(task.mode); setModel(task.model); setRatio(task.aspect_ratio); setResolution(task.resolution); setDuration(task.duration); setSeed(task.seed ?? -1); setRefs(task.references.map((r) => ({ ...r, key: nextRefKey() }))); window.setTimeout(() => { promptRef.current?.setContent(task.prompt, task.references); promptRef.current?.focus(); document.querySelector(".fc-composer")?.scrollIntoView({ behavior: "smooth", block: "end" }); }, 0); notify("info", "已回填参数,可修改后重新生成"); }, [notify]); const handleFavorite = useCallback((task: FreeVideoTask) => { void api.toggleFreeVideoFavorite(task.id) .then((data) => patchTask({ ...task, is_favorited: data.is_favorited })) .catch((error) => notify("error", error instanceof Error ? error.message : "操作失败")); }, [patchTask, notify]); const handleDownload = useCallback(async (task: FreeVideoTask) => { if (!task.video_url) return; try { const response = await fetch(task.video_url); const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `airshelf-free-${task.id.slice(0, 8)}.mp4`; a.click(); URL.revokeObjectURL(url); } catch { window.open(task.video_url, "_blank"); } }, []); const confirmDelete = useCallback(async () => { const target = deleteTarget; if (!target) return; try { await api.deleteFreeVideo(target.id); stopPolling(target.id); setTasks((prev) => prev.filter((t) => t.id !== target.id)); setTotal((prev) => Math.max(0, prev - 1)); if (detailId === target.id) setDetailId(null); notify("success", "已移至垃圾桶"); } catch (error) { notify("error", error instanceof Error ? error.message : "删除失败"); } finally { setDeleteTarget(null); } }, [deleteTarget, detailId, stopPolling, notify]); const openLibrary = useCallback((role?: "first_frame" | "last_frame") => { if (mode === "keyframe" && !role) { notify("info", "请点击首帧或尾帧槽位上的人物素材库入口"); return; } setLibraryTargetRole(role || null); setLibraryOpen(true); }, [mode, notify]); const closeLibrary = useCallback(() => { setLibraryOpen(false); setLibraryTargetRole(null); }, []); // 三库(资产库/模特库/商品库)引用:与人物素材库共用槽位逻辑,只是数据源不同。 // 首尾帧模式没指定槽位时按拖拽同一规则落位:首帧空就填首帧,否则填尾帧。 const openPlatformLibrary = useCallback((role?: "first_frame" | "last_frame") => { const target = mode === "keyframe" ? role || (refs.some((r) => r.role === "first_frame") ? "last_frame" : "first_frame") : null; setLibraryTargetRole(target); setPlatformOpen(true); }, [mode, refs]); const closePlatformLibrary = useCallback(() => { setPlatformOpen(false); setLibraryTargetRole(null); }, []); // 素材库选中 → 全能参考插 mention;首尾帧写入对应槽位 const handleLibraryPick = useCallback((ref: FreeVideoRef) => { if (mode === "keyframe") { if (!libraryTargetRole) { notify("info", "请先指定首帧或尾帧槽位"); return; } if (ref.type !== "image") { notify("error", "首尾帧仅支持图片素材"); return; } const local: LocalRef = { ...ref, key: nextRefKey(), role: libraryTargetRole, label: undefined }; setRefs((prev) => [...prev.filter((r) => r.role !== libraryTargetRole), local]); setLibraryTargetRole(null); return; } let label = ref.label || "素材"; let n = 2; while (refs.some((r) => r.label === label)) label = `${ref.label}${n++}`; setRefs((prev) => [...prev, { ...ref, label, key: nextRefKey() }]); window.setTimeout(() => promptRef.current?.insertMention({ label, thumb: ref.thumb_url || (ref.type === "image" ? ref.url : "") }), 0); }, [mode, refs, libraryTargetRole, notify]); const filterCounts = useMemo(() => ({ all: tasks.length, inflight: tasks.filter((t) => isInFlight(t.status)).length, succeeded: tasks.filter((t) => t.status === "succeeded").length, failed: tasks.filter((t) => t.status === "failed" || t.status === "cancelled").length }), [tasks]); const feedGroups = useMemo(() => { const visible = tasks.filter((t) => matchesFeedFilter(t, feedFilter)); const groups: Array<{ key: "today" | "yesterday" | "earlier"; items: FreeVideoTask[] }> = [ { key: "today", items: [] }, { key: "yesterday", items: [] }, { key: "earlier", items: [] } ]; visible.forEach((task) => { groups.find((g) => g.key === dayBucket(task.created_at))?.items.push(task); }); return groups.filter((g) => g.items.length > 0); }, [tasks, feedFilter]); // 详情弹窗的上一条/下一条(只在已完成的任务间切换) const doneTasks = tasks.filter((t) => t.status === "succeeded" && t.video_url); const detailTask = detailId ? tasks.find((t) => t.id === detailId) || null : null; const detailIndex = detailTask ? doneTasks.findIndex((t) => t.id === detailTask.id) : -1; return (
{onBack ? ( ) : null}

自由生成

{tasks.length > 0 && (
{FEED_FILTERS.map((item) => ( ))}
)}
{loading ? ( ) : tasks.length === 0 ? (
准备生成新视频

在下方输入提示词,或从素材库引用参考素材

) : feedGroups.length === 0 ? (
没有符合条件的视频

{hasMore ? "当前已加载的记录里没有,下滑可继续查找" : "换一个状态筛选,或继续在下方生成新视频"}

) : ( feedGroups.map((group) => (
{GROUP_LABEL[group.key]} {group.items.length}
{group.items.map((task) => ( setDetailId(task.id)} onRetry={() => handleReuse(task)} onToggleFavorite={() => handleFavorite(task)} onDelete={() => setDeleteTarget(task)} onDownload={() => void handleDownload(task)} /> ))}
)) )} {hasMore &&
下滑加载更多
}
void addFiles(files, role)} onRemoveRef={removeRef} onModeChange={(next) => { setMode(next); setRefs([]); if (next === "keyframe") { setRatio("16:9"); } }} onModelChange={setModel} onRatioChange={setRatio} onResolutionChange={setResolution} onDurationChange={setDuration} onSeedChange={setSeed} onOpenLibrary={openLibrary} onOpenPlatformLibrary={openPlatformLibrary} onSend={() => void handleSend()} />
{detailTask && ( 0} hasNext={detailIndex >= 0 && detailIndex < doneTasks.length - 1} onPrev={() => { if (detailIndex > 0) setDetailId(doneTasks[detailIndex - 1].id); }} onNext={() => { if (detailIndex < doneTasks.length - 1) setDetailId(doneTasks[detailIndex + 1].id); }} onClose={() => setDetailId(null)} onDownload={() => void handleDownload(detailTask)} onToggleFavorite={() => handleFavorite(detailTask)} onReuse={() => handleReuse(detailTask)} onDelete={() => setDeleteTarget(detailTask)} /> )} setDeleteTarget(null)} onConfirm={() => void confirmDelete()} />
); }