740 lines
29 KiB
TypeScript
740 lines
29 KiB
TypeScript
// 自由创作 · 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<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, 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<BillingRates>(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<FreeVideoTask[]>([]);
|
|
const [, setTotal] = useState(0);
|
|
const [hasMore, setHasMore] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const loadingMoreRef = useRef(false);
|
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
const stageRef = 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);
|
|
|
|
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<string | null>(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<FreeVideoTask | null>(null);
|
|
const [feedFilter, setFeedFilter] = useState<FeedFilter>("all");
|
|
|
|
// —— 轮询/进度 ——
|
|
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; });
|
|
// 任务结算会改变余额,并产生已有的生成/计费消息;同步 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 (
|
|
<div className="fc-page">
|
|
<div className="fc-inner">
|
|
<header className="fc-head">
|
|
<div className="fc-title-row">
|
|
{onBack ? (
|
|
<button type="button" className="fc-back" aria-label="返回上一入口页面" onClick={onBack}>
|
|
<ArrowLeft />
|
|
</button>
|
|
) : null}
|
|
<h1>自由生成</h1>
|
|
</div>
|
|
<div className="fc-material">
|
|
<button type="button" className="fc-ghost" onClick={() => openLibrary()}>
|
|
<Users />
|
|
<span>人物素材库</span>
|
|
</button>
|
|
<button type="button" className="fc-ghost" onClick={() => openPlatformLibrary()}>
|
|
<Images />
|
|
<span>引用平台素材</span>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{tasks.length > 0 && (
|
|
<div className="fc-feedbar">
|
|
{FEED_FILTERS.map((item) => (
|
|
<button
|
|
key={item.key}
|
|
type="button"
|
|
className={`fc-filter${feedFilter === item.key ? " active" : ""}`}
|
|
onClick={() => setFeedFilter(item.key)}
|
|
>
|
|
{item.label}
|
|
<span>{filterCounts[item.key]}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="fc-stage" ref={stageRef}>
|
|
{loading ? (
|
|
<SystemLoading title="正在加载作品" description="正在同步最新数据,请稍候。" icon="film" />
|
|
) : tasks.length === 0 ? (
|
|
<div className="fc-empty">
|
|
<div className="fc-empty-icon"><Clapperboard /></div>
|
|
<strong>准备生成新视频</strong>
|
|
<p>在下方输入提示词,或从素材库引用参考素材</p>
|
|
</div>
|
|
) : feedGroups.length === 0 ? (
|
|
<div className={`fc-empty${hasMore ? " is-soft" : ""}`}>
|
|
<div className="fc-empty-icon"><Clapperboard /></div>
|
|
<strong>没有符合条件的视频</strong>
|
|
<p>{hasMore ? "当前已加载的记录里没有,下滑可继续查找" : "换一个状态筛选,或继续在下方生成新视频"}</p>
|
|
</div>
|
|
) : (
|
|
feedGroups.map((group) => (
|
|
<section className="fc-group" key={group.key}>
|
|
<div className="fc-group-h">
|
|
{GROUP_LABEL[group.key]}
|
|
<span>{group.items.length}</span>
|
|
</div>
|
|
<div className="fc-feed">
|
|
{group.items.map((task) => (
|
|
<GenerationCard
|
|
key={task.id}
|
|
task={task}
|
|
progress={progress[task.id] || 3}
|
|
onOpen={() => setDetailId(task.id)}
|
|
onRetry={() => handleReuse(task)}
|
|
onToggleFavorite={() => handleFavorite(task)}
|
|
onDelete={() => setDeleteTarget(task)}
|
|
onDownload={() => void handleDownload(task)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
))
|
|
)}
|
|
{hasMore && <div ref={sentinelRef} className="fc-sentinel">下滑加载更多</div>}
|
|
</div>
|
|
|
|
<FreeInputBar
|
|
mode={mode}
|
|
model={model}
|
|
ratio={ratio}
|
|
resolution={resolution}
|
|
duration={duration}
|
|
seed={seed}
|
|
refs={refs}
|
|
videoConfigs={videoConfigs}
|
|
billingRates={billingRates}
|
|
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={openLibrary}
|
|
onOpenPlatformLibrary={openPlatformLibrary}
|
|
onSend={() => void handleSend()}
|
|
/>
|
|
</div>
|
|
|
|
{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={closeLibrary} onPick={handleLibraryPick} notify={notify} />
|
|
|
|
<PlatformLibraryModal
|
|
open={platformOpen}
|
|
imageOnly={mode === "keyframe"}
|
|
onClose={closePlatformLibrary}
|
|
onPick={handleLibraryPick}
|
|
notify={notify}
|
|
/>
|
|
|
|
<ConfirmModal
|
|
open={deleteTarget !== null}
|
|
title="删除视频"
|
|
detail={`确定删除这条视频吗?删除后可在垃圾桶找回素材,任务记录不可恢复。`}
|
|
confirmText="确认删除"
|
|
onCancel={() => setDeleteTarget(null)}
|
|
onConfirm={() => void confirmDelete()}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|