feat(ai): 自由创作视频生成全量移植(/free-create)——Seedance 文/图生视频+按时长计费+人物素材库

后端:
- free_video.py: 提交/轮询/收藏/软删全链路,复用 AITask(新增 is_deleted/is_favorited, migration 0022)
- video_pricing.py: 按时长 token 计费(×1.10 buffer+clamp); video_errors.py 错误归一; media_probe.py 时长探测
- catalog/volcano: Seedance free-video 模型接入+seed(migration 0023)
- 素材库: FreeAssetGroup/FreeAsset(火山 Assets API 引用登记, migration 0009)+ 上传/轮询/删除接口
- settings: FREE_VIDEO_MAX_CONCURRENT 团队并发闸(默认3); CELERY_TASK_ALWAYS_EAGER 本地联调开关(生产恒关)
- 测试: test_free_video.py 新增; billing/products/projects tests 配套调整

前端:
- /free-create 页面+components/free-create/ 全套(输入栏/@mention 素材引用/生成卡/视频详情弹窗/素材库弹窗)
- api.ts/types.ts 扩展 free-video 与 free-assets 接口; 路由/侧边栏入口接入

bug/: 测试清单 (11)(12) 与截图归档

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-07-06 10:58:36 +08:00
co-authored by Claude Fable 5
parent 94202e7bce
commit c5f7eb2acc
45 changed files with 5186 additions and 21 deletions
+549
View File
@@ -0,0 +1,549 @@
// 自由创作 · AI 视频生成(移植自 jimeng-clone,嫁接 AirShelf 底座)
// 任务流(滚动加载)+ 底部输入条(全能参考/首尾帧 + @mention)+ 渐进轮询(10/30/60s 打后端
// poll 端点,web 进程内查火山,不依赖 worker)+ 平滑进度动画(sessionStorage 续)+ 全屏播放器。
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Users } from "lucide-react";
import { api, ApiError } from "../api";
import type { FreeVideoRef, FreeVideoTask, ModelConfig } from "../types";
import {
FC_MODELS,
FC_STANDARD_MODEL,
MAX_AUDIOS,
MAX_IMAGES,
MAX_VIDEOS,
MAX_VIDEO_TOTAL_SECONDS,
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 type { PromptInputHandle } from "../components/free-create/prompt-input";
import { ConfirmModal } from "../components/overlays";
const PAGE_SIZE = 20;
const PROGRESS_KEY = "fc-progress";
function loadProgress(): Record<string, number> {
try {
return JSON.parse(sessionStorage.getItem(PROGRESS_KEY) || "{}") as Record<string, number>;
} 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 }: {
modelConfigs: ModelConfig[];
onNotify: (type: "success" | "error" | "info", text: string) => 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), []);
const videoConfigs = useMemo(
() => modelConfigs.filter((c) => c.capability === "video" && FC_MODELS.some((m) => m.name === c.name)),
[modelConfigs]
);
// —— 任务流 ——
const [tasks, setTasks] = useState<FreeVideoTask[]>([]);
const [total, setTotal] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const loadingMoreRef = useRef(false);
const sentinelRef = useRef<HTMLDivElement>(null);
// —— 输入条 ——
const [mode, setMode] = useState<FreeMode>("universal");
const [model, setModel] = useState<string>(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<LocalRef[]>([]);
const [submitting, setSubmitting] = useState(false);
const promptRef = useRef<PromptInputHandle | null>(null);
// —— 弹窗 ——
const [detailId, setDetailId] = useState<string | null>(null);
const [libraryOpen, setLibraryOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<FreeVideoTask | null>(null);
// —— 轮询/进度 ——
const pollTimersRef = useRef(new Map<string, number>());
const pollCountsRef = useRef(new Map<string, number>());
const [progress, setProgress] = useState<Record<string, number>>(() => loadProgress());
const tasksRef = useRef<FreeVideoTask[]>([]);
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; });
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]);
// 平滑进度动画:每 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;
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; });
}, { 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) {
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 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]);
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 handleRetry = useCallback((task: FreeVideoTask) => {
void doSubmit({
prompt: task.prompt,
mode: task.mode,
model: task.model,
aspect_ratio: task.aspect_ratio,
resolution: task.resolution,
duration: task.duration,
seed: task.seed,
references: task.references
});
}, [doSubmit]);
// 再次生成:参数 + 素材 + 提示词(含 mention chip)全部回填输入条
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), 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]);
// 素材库选中 → 注入输入条 + 插 mention chip
const handleLibraryPick = useCallback((ref: FreeVideoRef) => {
if (mode === "keyframe") { notify("info", "首尾帧模式请直接上传图片"); 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, notify]);
const clearInput = useCallback(() => {
promptRef.current?.clear();
setRefs([]);
}, []);
// 详情弹窗的上一条/下一条(只在已完成的任务间切换)
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 (
<div className="fc-page">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono">// {total} 个视频</span> · AI 视频生成 · 全能参考 / 首尾帧
</div>
</div>
<div className="actions">
<button type="button" className="btn" onClick={() => setLibraryOpen(true)}>
<Users size={14} />
</button>
</div>
</div>
<div className="fc-feed-wrap">
{loading ? (
<div className="fc-loading mono">// 加载中…</div>
) : tasks.length === 0 ? (
<div className="empty-state show fc-empty">
<span className="ic-empty"><Users size={22} strokeWidth={1.5} /></span>
<h3></h3>
<p>// 在下方输入提示词,生成你的第一条视频</p>
</div>
) : (
<div className="fc-feed">
{tasks.map((task) => (
<GenerationCard
key={task.id}
task={task}
progress={progress[task.id] || 3}
onOpen={() => setDetailId(task.id)}
onRetry={() => handleRetry(task)}
onToggleFavorite={() => handleFavorite(task)}
onDelete={() => setDeleteTarget(task)}
onDownload={() => void handleDownload(task)}
/>
))}
</div>
)}
{hasMore && <div ref={sentinelRef} className="fc-sentinel mono">// 下滑加载更多</div>}
</div>
<FreeInputBar
mode={mode}
model={model}
ratio={ratio}
resolution={resolution}
duration={duration}
seed={seed}
refs={refs}
videoConfigs={videoConfigs}
submitting={submitting}
promptRef={promptRef}
onFiles={(files, role) => 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={() => setLibraryOpen(true)}
onClear={clearInput}
onSend={() => void handleSend()}
/>
{detailTask && (
<VideoDetailModal
task={detailTask}
hasPrev={detailIndex > 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)}
/>
)}
<AssetLibraryModal open={libraryOpen} onClose={() => setLibraryOpen(false)} onPick={handleLibraryPick} notify={notify} />
<ConfirmModal
open={deleteTarget !== null}
title="删除视频"
detail={`确定删除这条视频吗?删除后可在资产库垃圾桶找回素材,任务记录不可恢复。`}
confirmText="确认删除"
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void confirmDelete()}
/>
</div>
);
}
+1
View File
@@ -10,4 +10,5 @@ export { AccountPage } from "./account";
export { TeamPage } from "./team";
export { MessagesPage } from "./messages";
export { AssetFactoryPage, ImageWorkbenchPage, ModelPhotoDemoPage } from "./ai-tools";
export { FreeCreatePage } from "./free-create";
export { SettingsPage } from "./settings";
+5
View File
@@ -27,6 +27,7 @@ export type Page =
| "team"
| "messages"
| "assetFactory"
| "freeCreate"
| "imageOptimize"
| "modelPhoto"
| "modelPhotoDemoA"
@@ -92,6 +93,7 @@ export const routeLabels: Record<Page, string> = {
team: "团队",
messages: "消息",
assetFactory: "图片工具",
freeCreate: "自由创作",
imageOptimize: "图片创作",
modelPhoto: "模特上身图",
modelPhotoDemoA: "模特图方案 A",
@@ -151,6 +153,7 @@ export function resolveRoute(): ResolvedRoute {
if (path === "/team") return { page: "team", authMode: "login", hash };
if (path === "/messages") return { page: "messages", authMode: "login", hash };
if (path === "/asset-factory") return { page: "assetFactory", authMode: "login", hash };
if (path === "/free-create") return { page: "freeCreate", authMode: "login", hash };
if (path === "/image-optimize") return { page: "imageOptimize", authMode: "login", hash };
if (path === "/model-photo") return { page: "modelPhoto", authMode: "login", hash };
if (path === "/model-photo/demo-a") return { page: "modelPhotoDemoA", authMode: "login", hash };
@@ -190,6 +193,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
return "/messages";
case "assetFactory":
return "/asset-factory";
case "freeCreate":
return "/free-create";
case "imageOptimize":
return "/image-optimize";
case "modelPhoto":