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
+2 -1
View File
@@ -27,7 +27,8 @@ const iconPaths: Record<string, string> = {
server: '<rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><path d="M6 7h.01"/><path d="M6 17h.01"/>',
gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
sliders: '<line x1="4" x2="4" y1="21" y2="14"/><line x1="4" x2="4" y1="10" y2="3"/><line x1="12" x2="12" y1="21" y2="12"/><line x1="12" x2="12" y1="8" y2="3"/><line x1="20" x2="20" y1="21" y2="16"/><line x1="20" x2="20" y1="12" y2="3"/><line x1="2" x2="6" y1="14" y2="14"/><line x1="10" x2="14" y1="8" y2="8"/><line x1="18" x2="22" y1="16" y2="16"/>',
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>'
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>',
film: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 3v18"/><path d="M17 3v18"/><path d="M3 7.5h4"/><path d="M3 16.5h4"/><path d="M17 7.5h4"/><path d="M17 16.5h4"/><path d="M3 12h4"/><path d="M17 12h4"/>'
};
const iconAliases: Record<string, string> = {
@@ -17,6 +17,7 @@ const SHELL_COMMANDS: Command[] = [
{ id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" },
{ id: "projects", group: "导航", label: "视频项目", sub: "查看五阶段短视频流水线", page: "projects", icon: "clapperboard", key: "V" },
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" },
{ id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" },
{ id: "library", group: "导航", label: "资产库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
{ id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" },
{ id: "account", group: "导航", label: "消费", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" },
@@ -197,6 +198,7 @@ const NAV: NavDef[] = [
{ id: "models", page: "models", label: "模特库", icon: "model" },
{ id: "projects", page: "projects", label: "视频项目", icon: "clapperboard" },
{ id: "asset-factory", page: "assetFactory", label: "图片生成", icon: "sparkles" },
{ id: "free-create", page: "freeCreate", label: "自由创作", icon: "film" },
{ id: "library", page: "library", label: "资产库", icon: "library" },
{ id: "team", page: "team", label: "团队", icon: "users" },
{ id: "account", page: "account", label: "消费", icon: "creditCard" },
@@ -219,6 +221,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
modelPhotoDemoA: "assetFactory",
modelPhotoDemoB: "assetFactory",
platformCover: "assetFactory",
freeCreate: "freeCreate",
library: "library",
team: "team",
account: "account",
@@ -0,0 +1,289 @@
// 自由创作·人物素材库弹窗:组网格 → 组内素材列表(审核状态徽章)→ 建组/传素材/改名/删除;
// 选中 active 素材注入输入条(source=library,生成时后端换 asset:// 引用)。
// processing 素材每 8s 轮询火山刷新状态(与基础资产送审轮询同节奏)。
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronLeft, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react";
import { api } from "../../api";
import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types";
import { useBodyScrollLock } from "../overlays";
const STATUS_PILL: Record<FreeAssetItem["status"], { cls: string; label: string }> = {
processing: { cls: "pill-info", label: "审核中" },
active: { cls: "pill-ok", label: "可用" },
failed: { cls: "pill-err", label: "未通过" }
};
export function AssetLibraryModal({ open, onClose, onPick, notify }: {
open: boolean;
onClose: () => void;
onPick: (ref: FreeVideoRef) => void;
notify: (type: "success" | "error" | "info", text: string) => void;
}) {
const [groups, setGroups] = useState<FreeAssetGroup[]>([]);
const [activeGroup, setActiveGroup] = useState<FreeAssetGroup | null>(null);
const [assets, setAssets] = useState<FreeAssetItem[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
useBodyScrollLock(open);
const loadGroups = useCallback(async () => {
setLoading(true);
try {
const data = await api.freeAssetGroups();
setGroups(data.results);
} catch (error) {
notify("error", error instanceof Error ? error.message : "素材组加载失败");
} finally {
setLoading(false);
}
}, [notify]);
const loadGroupDetail = useCallback(async (group: FreeAssetGroup) => {
setLoading(true);
try {
const data = await api.freeAssetGroup(group.id);
setActiveGroup(data.group);
setAssets(data.assets);
} catch (error) {
notify("error", error instanceof Error ? error.message : "素材加载失败");
} finally {
setLoading(false);
}
}, [notify]);
useEffect(() => {
if (!open) return;
setActiveGroup(null);
setAssets([]);
void loadGroups();
}, [open, loadGroups]);
// processing 素材每 8s 轮询刷新
useEffect(() => {
if (!open || !activeGroup) return;
const pending = assets.filter((a) => a.status === "processing");
if (pending.length === 0) return;
const timer = window.setInterval(() => {
pending.forEach((item) => {
void api.pollFreeAsset(item.id).then((data) => {
setAssets((prev) => prev.map((a) => (a.id === data.asset.id ? data.asset : a)));
}).catch(() => undefined);
});
}, 8000);
return () => window.clearInterval(timer);
}, [open, activeGroup, assets]);
if (!open) return null;
const createGroup = async () => {
const name = newName.trim();
if (!name) return;
try {
const data = await api.createFreeAssetGroup({ name });
setGroups((prev) => [data.group, ...prev]);
setCreating(false);
setNewName("");
notify("success", `素材组「${name}」已创建`);
} catch (error) {
notify("error", error instanceof Error ? error.message : "创建失败");
}
};
const uploadAsset = async (file: File) => {
if (!activeGroup) return;
setUploading(true);
try {
const form = new FormData();
form.append("file", file);
const data = await api.uploadFreeAsset(activeGroup.id, form);
setAssets((prev) => [data.asset, ...prev]);
notify("success", "素材已上传,审核中");
} catch (error) {
notify("error", error instanceof Error ? error.message : "上传失败");
} finally {
setUploading(false);
}
};
const renameAsset = async (item: FreeAssetItem) => {
const name = window.prompt("素材名称(用于 @ 引用)", item.name);
if (!name || name.trim() === item.name) return;
try {
const data = await api.renameFreeAsset(item.id, name.trim());
setAssets((prev) => prev.map((a) => (a.id === item.id ? data.asset : a)));
} catch (error) {
notify("error", error instanceof Error ? error.message : "重命名失败");
}
};
const removeAsset = async (item: FreeAssetItem) => {
if (!window.confirm(`删除素材「${item.name}」?`)) return;
try {
await api.deleteFreeAsset(item.id);
setAssets((prev) => prev.filter((a) => a.id !== item.id));
} catch (error) {
notify("error", error instanceof Error ? error.message : "删除失败");
}
};
const renameGroup = async (group: FreeAssetGroup) => {
const name = window.prompt("素材组名称", group.name);
if (!name || name.trim() === group.name) return;
try {
const data = await api.updateFreeAssetGroup(group.id, { name: name.trim() });
setGroups((prev) => prev.map((g) => (g.id === group.id ? data.group : g)));
if (activeGroup?.id === group.id) setActiveGroup(data.group);
} catch (error) {
notify("error", error instanceof Error ? error.message : "重命名失败");
}
};
const removeGroup = async (group: FreeAssetGroup) => {
if (!window.confirm(`删除素材组「${group.name}」及其全部素材?此操作不可恢复。`)) return;
try {
await api.deleteFreeAssetGroup(group.id);
setGroups((prev) => prev.filter((g) => g.id !== group.id));
if (activeGroup?.id === group.id) { setActiveGroup(null); setAssets([]); }
notify("success", "素材组已删除");
} catch (error) {
notify("error", error instanceof Error ? error.message : "删除失败");
}
};
const pick = (item: FreeAssetItem) => {
if (item.status !== "active") {
notify("info", item.status === "processing" ? "素材还在审核中,请稍候" : "素材未通过审核,无法引用");
return;
}
onPick({
url: item.url,
type: item.type,
label: item.name,
thumb_url: item.thumb_url,
duration: item.duration || undefined,
asset_id: item.id,
source: "library"
});
onClose();
};
return createPortal(
<div className="modal-bg show" onClick={onClose}>
<div className="modal fc-lib-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Users size={16} /></div>
<div className="ti">
{activeGroup ? (
<button type="button" className="fc-lib-back" onClick={() => { setActiveGroup(null); setAssets([]); void loadGroups(); }}>
<ChevronLeft size={14} /> {activeGroup.name}
</button>
) : "人物素材库"}
<span>// 火山素材登记 · @ 引用生成</span>
</div>
<button className="x modal-x" type="button" onClick={onClose} aria-label="关闭"><X size={14} /></button>
</div>
<div className="modal-b fc-lib-body">
{!activeGroup ? (
<>
<div className="fc-lib-toolbar">
{creating ? (
<div className="fc-lib-create">
<input
className="input"
autoFocus
placeholder="素材组名称(如:碧碧)"
value={newName}
onChange={(event) => setNewName(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") void createGroup(); if (event.key === "Escape") setCreating(false); }}
/>
<button type="button" className="btn btn-sm btn-primary" onClick={() => void createGroup()}></button>
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setCreating(false)}></button>
</div>
) : (
<button type="button" className="btn btn-sm" onClick={() => setCreating(true)}><FolderPlus size={13} /> </button>
)}
</div>
{groups.length === 0 && !loading ? (
<div className="empty-state show">
<span className="ic-empty"><Users size={22} strokeWidth={1.5} /></span>
<h3></h3>
<p>// 一个角色一组:登记后可在提示词里 @ 引用,规避真人脸拦截</p>
</div>
) : (
<div className="fc-lib-groups">
{groups.map((group) => (
<div key={group.id} className="fc-lib-group" role="button" tabIndex={0} onClick={() => void loadGroupDetail(group)} onKeyDown={(event) => { if (event.key === "Enter") void loadGroupDetail(group); }}>
<div className="fc-lib-thumb">
{group.thumbnail_url ? <img src={group.thumbnail_url} alt={group.name} /> : <Users size={20} />}
</div>
<div className="fc-lib-name">{group.name}</div>
<div className="fc-lib-count mono">// {group.asset_count} 个素材</div>
<div className="fc-lib-ops">
<button type="button" title="重命名" onClick={(event) => { event.stopPropagation(); void renameGroup(group); }}><Pencil size={12} /></button>
<button type="button" title="删除" onClick={(event) => { event.stopPropagation(); void removeGroup(group); }}><Trash2 size={12} /></button>
</div>
</div>
))}
</div>
)}
</>
) : (
<>
<div className="fc-lib-toolbar">
<input
ref={fileRef}
type="file"
hidden
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,audio/mpeg,audio/wav"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void uploadAsset(file);
}}
/>
<button type="button" className="btn btn-sm" disabled={uploading} onClick={() => fileRef.current?.click()}>
<Upload size={13} /> {uploading ? "上传中…" : "上传素材"}
</button>
<span className="mono fc-lib-hint">// 审核通过(可用)后才能被生成引用</span>
</div>
{assets.length === 0 && !loading ? (
<div className="empty-state show">
<span className="ic-empty"><Upload size={22} strokeWidth={1.5} /></span>
<h3></h3>
<p>// 上传该角色的图片/视频/音频</p>
</div>
) : (
<div className="fc-lib-assets">
{assets.map((item) => {
const pill = STATUS_PILL[item.status];
return (
<div key={item.id} className={`fc-lib-asset${item.status === "active" ? " pickable" : ""}`} role="button" tabIndex={0} onClick={() => pick(item)} onKeyDown={(event) => { if (event.key === "Enter") pick(item); }}>
<div className="fc-lib-thumb">
{item.thumb_url ? <img src={item.thumb_url} alt={item.name} /> : <span className="mono">{item.type === "audio" ? "♪" : item.type.toUpperCase()}</span>}
{item.duration ? <span className="fc-ref-dur mono">{item.duration}s</span> : null}
</div>
<div className="fc-lib-name" title={item.name}>{item.name}</div>
<span className={`pill pill-l3 ${pill.cls}`}><span className="dot" />{pill.label}</span>
{item.status === "failed" && item.error_message && <div className="fc-lib-err mono" title={item.error_message}>// {item.error_message}</div>}
<div className="fc-lib-ops">
<button type="button" title="重命名" onClick={(event) => { event.stopPropagation(); void renameAsset(item); }}><Pencil size={12} /></button>
<button type="button" title="删除" onClick={(event) => { event.stopPropagation(); void removeAsset(item); }}><Trash2 size={12} /></button>
</div>
</div>
);
})}
</div>
)}
</>
)}
</div>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,154 @@
// 自由创作·常量与预估计价(与后端 apps/ai/video_pricing.py 同一张表/同一公式,口径必须一致)
import type { FreeVideoRef, ModelConfig } from "../../types";
export type FreeMode = "universal" | "keyframe";
// 本地输入条里的参考素材(在 FreeVideoRef 之上带上传中间态)
export type LocalRef = FreeVideoRef & {
key: string; // 本地唯一键(删除/替换用)
uploading?: boolean; // 上传中(url 是 blob: 预览,禁止提交)
};
export const FC_MODELS = [
{ name: "doubao-seedance-2-0-260128", label: "Seedance 2.0", desc: "标准档 · 支持 1080P / 4K" },
{ name: "doubao-seedance-2-0-fast-260128", label: "Seedance 2.0 Fast", desc: "更快出片 · 480P / 720P" },
{ name: "doubao-seedance-2-0-mini-260615", label: "Seedance 2.0 Mini", desc: "轻量便宜 · 480P / 720P" }
] as const;
export const FC_STANDARD_MODEL = FC_MODELS[0].name;
export const FC_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"] as const;
export const FC_RESOLUTIONS = ["480p", "720p", "1080p", "4k"] as const;
export const FC_DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] as const;
export const MODE_LABELS: Record<FreeMode, string> = { universal: "全能参考", keyframe: "首尾帧" };
// 上传素材限制(与后端 FreeVideoUploadView / jimeng inputBar 对齐)
export const IMAGE_MAX_BYTES = 30 * 1024 * 1024;
export const VIDEO_MAX_BYTES = 50 * 1024 * 1024;
export const AUDIO_MAX_BYTES = 15 * 1024 * 1024;
export const IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"];
export const VIDEO_TYPES = ["video/mp4", "video/quicktime"];
export const AUDIO_TYPES = ["audio/mpeg", "audio/wav", "audio/x-wav", "audio/wave"];
export const MAX_IMAGES = 9;
export const MAX_VIDEOS = 3;
export const MAX_AUDIOS = 3;
export const MAX_VIDEO_TOTAL_SECONDS = 15;
// 任务在途状态(继续轮询);终态 = succeeded / failed / cancelled / compensating
export const IN_FLIGHT_STATUSES = ["created", "reserved", "submitted", "polling", "postprocessing"];
export function isInFlight(status: string) {
return IN_FLIGHT_STATUSES.includes(status);
}
export const STATUS_LABELS: Record<string, string> = {
created: "排队中",
reserved: "排队中",
submitted: "生成中",
polling: "生成中",
postprocessing: "处理中",
succeeded: "已完成",
failed: "失败",
cancelled: "已取消",
compensating: "补偿中"
};
// 分辨率像素表(火山 Seedance 2.0 文档,键 = `${resolution}:${ratio}`)
const RESOLUTION_MAP: Record<string, [number, number]> = {
"720p:16:9": [1280, 720], "720p:9:16": [720, 1280], "720p:4:3": [1112, 834],
"720p:1:1": [960, 960], "720p:3:4": [834, 1112], "720p:21:9": [1470, 630],
"480p:16:9": [864, 496], "480p:9:16": [496, 864], "480p:4:3": [752, 560],
"480p:1:1": [640, 640], "480p:3:4": [560, 752], "480p:21:9": [992, 432],
"1080p:16:9": [1920, 1080], "1080p:9:16": [1080, 1920], "1080p:4:3": [1664, 1248],
"1080p:1:1": [1440, 1440], "1080p:3:4": [1248, 1664], "1080p:21:9": [2206, 946],
"4k:16:9": [3840, 2160], "4k:9:16": [2160, 3840], "4k:4:3": [3326, 2494],
"4k:1:1": [2880, 2880], "4k:3:4": [2494, 3326], "4k:21:9": [4398, 1886]
};
// 火山官方公式:(输入视频时长 + 输出时长) × 宽 × 高 × 24fps / 1024
export function estimateTokens(ratio: string, resolution: string, duration: number, inputVideoSeconds = 0): number {
const size = RESOLUTION_MAP[`${resolution}:${ratio}`];
if (!size) return 0;
const [w, h] = size;
return Math.round((w * h * 24 * (duration + inputVideoSeconds)) / 1024);
}
type PricingTier = { no_ref_video?: number; with_ref_video?: number };
type PricingTable = { default?: PricingTier } & Record<string, PricingTier | string | undefined>;
// 从 ModelConfig.metadata.pricing 取单价(元/百万tokens):resolution 精确键 → 回落 default
export function tokenPrice(config: ModelConfig | undefined, resolution: string, hasVideoRef: boolean): number {
const pricing = (config?.metadata?.pricing || {}) as PricingTable;
const tier = (pricing[resolution] as PricingTier | undefined) || pricing.default;
if (!tier) return 0;
return (hasVideoRef ? tier.with_ref_video : tier.no_ref_video) || 0;
}
export function estimateCost(
config: ModelConfig | undefined,
params: { ratio: string; resolution: string; duration: number; refs: { type: string; duration?: number }[] }
): { tokens: number; cost: number } {
const inputVideoSeconds = params.refs
.filter((r) => r.type === "video")
.reduce((sum, r) => sum + (r.duration || 0), 0);
const tokens = estimateTokens(params.ratio, params.resolution, params.duration, inputVideoSeconds);
const hasVideoRef = params.refs.some((r) => r.type === "video");
const price = tokenPrice(config, params.resolution, hasVideoRef);
return { tokens, cost: Math.round(((tokens * price) / 1e6) * 100) / 100 };
}
export function modelLabel(name: string): string {
return FC_MODELS.find((m) => m.name === name)?.label || name;
}
// —— 上传前的本地校验(后端仍会兜底) ——
export type FileCheck = { ok: true; type: "image" | "video" | "audio"; duration?: number } | { ok: false; error: string };
function probeImage(file: File): Promise<FileCheck> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const { naturalWidth: w, naturalHeight: h } = img;
if (w < 300 || w > 6000 || h < 300 || h > 6000) resolve({ ok: false, error: "图片边长需在 300-6000 像素之间" });
else if (w / h < 0.4 || w / h > 2.5) resolve({ ok: false, error: "图片宽高比需在 0.4-2.5 之间" });
else resolve({ ok: true, type: "image" });
};
img.onerror = () => { URL.revokeObjectURL(url); resolve({ ok: false, error: "图片解析失败,请更换文件" }); };
img.src = url;
});
}
function probeMedia(file: File, kind: "video" | "audio"): Promise<FileCheck> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
const el = document.createElement(kind);
el.preload = "metadata";
el.onloadedmetadata = () => {
URL.revokeObjectURL(url);
const duration = el.duration;
if (!isFinite(duration)) resolve({ ok: true, type: kind });
else if (duration < 2 || duration > 15) resolve({ ok: false, error: `${kind === "video" ? "视频" : "音频"}时长需在 2-15 秒之间` });
else resolve({ ok: true, type: kind, duration: Math.round(duration * 10) / 10 });
};
el.onerror = () => { URL.revokeObjectURL(url); resolve({ ok: false, error: "媒体文件解析失败,请更换文件" }); };
el.src = url;
});
}
export async function checkRefFile(file: File): Promise<FileCheck> {
const type = (file.type || "").toLowerCase();
if (IMAGE_TYPES.includes(type)) {
if (file.size > IMAGE_MAX_BYTES) return { ok: false, error: "图片大小不能超过 30MB" };
return probeImage(file);
}
if (VIDEO_TYPES.includes(type)) {
if (file.size > VIDEO_MAX_BYTES) return { ok: false, error: "视频大小不能超过 50MB" };
return probeMedia(file, "video");
}
if (AUDIO_TYPES.includes(type)) {
if (file.size > AUDIO_MAX_BYTES) return { ok: false, error: "音频大小不能超过 15MB" };
return probeMedia(file, "audio");
}
return { ok: false, error: "不支持的文件格式(图片 JPG/PNG/WebP,视频 MP4/MOV,音频 MP3/WAV)" };
}
@@ -0,0 +1,93 @@
// 自由创作·任务卡:生成中(shimmer+平滑进度) / 失败(中文错误+重试) / 完成(悬停播放+操作)。
import { useRef } from "react";
import { Download, Heart, RotateCcw, Trash2 } from "lucide-react";
import type { FreeVideoTask } from "../../types";
import { MODE_LABELS, STATUS_LABELS, isInFlight, modelLabel } from "./constants";
function ratioStyle(ratio: string) {
const [w, h] = ratio.split(":").map(Number);
return { aspectRatio: w && h ? `${w} / ${h}` : "16 / 9" };
}
export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavorite, onDelete, onDownload }: {
task: FreeVideoTask;
progress: number;
onOpen: () => void;
onRetry: () => void;
onToggleFavorite: () => void;
onDelete: () => void;
onDownload: () => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const inFlight = isInFlight(task.status);
const failed = task.status === "failed" || task.status === "cancelled";
const done = task.status === "succeeded" && !!task.video_url;
return (
<div className={`fc-card${done ? " done" : ""}`}>
<div
className="fc-card-media"
style={ratioStyle(task.aspect_ratio)}
role={done ? "button" : undefined}
tabIndex={done ? 0 : undefined}
onClick={done ? onOpen : undefined}
onKeyDown={done ? (event) => { if (event.key === "Enter") onOpen(); } : undefined}
onMouseEnter={() => { if (done) void videoRef.current?.play().catch(() => undefined); }}
onMouseLeave={() => { videoRef.current?.pause(); if (videoRef.current) videoRef.current.currentTime = 0; }}
>
{inFlight && (
<div className="fc-card-generating">
<span className="spinner" />
<div className="fc-progress"><span style={{ width: `${Math.min(progress, 95)}%` }} /></div>
<span className="mono">{STATUS_LABELS[task.status] || "生成中"} · {Math.round(Math.min(progress, 95))}%</span>
</div>
)}
{failed && (
<div className="fc-card-failed">
<span className="pill pill-l2 pill-err"><span className="dot" /></span>
<p>{task.error_message || "生成失败,请重试"}</p>
<button type="button" className="btn btn-sm" onClick={(event) => { event.stopPropagation(); onRetry(); }}>
<RotateCcw size={13} />
</button>
</div>
)}
{done && (
<>
<video
ref={videoRef}
src={task.video_url}
poster={task.thumbnail_url || undefined}
muted
loop
playsInline
preload="metadata"
/>
<div className="fc-card-hover">
<button type="button" className="fc-hover-btn" title="下载" onClick={(event) => { event.stopPropagation(); onDownload(); }}>
<Download size={14} />
</button>
<button type="button" className={`fc-hover-btn${task.is_favorited ? " fav" : ""}`} title={task.is_favorited ? "取消收藏" : "收藏"} onClick={(event) => { event.stopPropagation(); onToggleFavorite(); }}>
<Heart size={14} fill={task.is_favorited ? "currentColor" : "none"} />
</button>
<button type="button" className="fc-hover-btn danger" title="删除" onClick={(event) => { event.stopPropagation(); onDelete(); }}>
<Trash2 size={14} />
</button>
</div>
</>
)}
{task.status === "succeeded" && !task.video_url && (
<div className="fc-card-failed">
<p>{task.fallback_note ? `(${task.fallback_note})` : ""}</p>
</div>
)}
</div>
<div className="fc-card-meta">
<div className="fc-card-prompt" title={task.prompt}>{task.prompt}</div>
<div className="fc-card-sub mono">
// {MODE_LABELS[task.mode] || task.mode} · {modelLabel(task.model)} · {task.resolution.toUpperCase()} · {task.duration}s
{task.status === "succeeded" && ` · ¥${Number(task.actual_cost || 0).toFixed(2)}`}
</div>
</div>
</div>
);
}
@@ -0,0 +1,173 @@
// 自由创作·底部输入条:参考素材上传区(universal 混排 / keyframe 首尾帧两格)+ @mention 提示词 + 工具栏。
// 拖拽/点击上传;素材选中即传后端(blob 预览 → 服务端 URL 替换),blob 状态禁止提交。
import { useRef, useState, type RefObject } from "react";
import { IconKitSvg } from "../IconKitSvg";
import type { ModelConfig } from "../../types";
import { MODE_LABELS, type FreeMode, type LocalRef } from "./constants";
import { PromptInput, type PromptInputHandle } from "./prompt-input";
import { FreeToolbar } from "./toolbar";
const ACCEPT_UNIVERSAL = "image/jpeg,image/png,image/webp,video/mp4,video/quicktime,audio/mpeg,audio/wav";
const ACCEPT_IMAGE = "image/jpeg,image/png,image/webp";
function RefThumb({ item, onRemove }: { item: LocalRef; onRemove: () => void }) {
return (
<div className={`fc-ref${item.uploading ? " uploading" : ""}`} title={item.label || ""}>
{item.type === "image" ? (
<img src={item.thumb_url || item.url} alt={item.label || "参考图"} />
) : item.type === "video" ? (
item.thumb_url ? <img src={item.thumb_url} alt={item.label || "参考视频"} /> : <span className="fc-ref-kind mono">MP4</span>
) : (
<span className="fc-ref-kind mono"></span>
)}
{item.type !== "image" && item.duration ? <span className="fc-ref-dur mono">{item.duration}s</span> : null}
{item.uploading && <span className="spinner" />}
{item.label ? <span className="fc-ref-label">@{item.label}</span> : null}
<button type="button" className="fc-ref-x" aria-label="删除素材" onClick={onRemove}>×</button>
</div>
);
}
function KeyframeSlot({ role, item, onPick, onRemove }: {
role: "first_frame" | "last_frame";
item: LocalRef | undefined;
onPick: () => void;
onRemove: () => void;
}) {
const label = role === "first_frame" ? "首帧" : "尾帧(可选)";
if (!item) {
return (
<button type="button" className="fc-kf-slot" onClick={onPick}>
<IconKitSvg name="images" size={20} />
<span>{label}</span>
</button>
);
}
return (
<div className={`fc-kf-slot filled${item.uploading ? " uploading" : ""}`}>
<img src={item.thumb_url || item.url} alt={label} />
{item.uploading && <span className="spinner" />}
<span className="fc-kf-tag mono">{role === "first_frame" ? "首" : "尾"}</span>
<button type="button" className="fc-ref-x" aria-label={`删除${label}`} onClick={onRemove}>×</button>
</div>
);
}
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onClear, onSend }: {
mode: FreeMode;
model: string;
ratio: string;
resolution: string;
duration: number;
seed: number;
refs: LocalRef[];
videoConfigs: ModelConfig[];
submitting: boolean;
promptRef: RefObject<PromptInputHandle | null>;
/** 用户选择/拖入文件;keyframe 模式下带目标 role */
onFiles: (files: File[], role?: "first_frame" | "last_frame") => void;
onRemoveRef: (key: string) => void;
onModeChange: (mode: FreeMode) => void;
onModelChange: (model: string) => void;
onRatioChange: (ratio: string) => void;
onResolutionChange: (resolution: string) => void;
onDurationChange: (duration: number) => void;
onSeedChange: (seed: number) => void;
onOpenLibrary: () => void;
onClear: () => void;
onSend: () => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const pendingRoleRef = useRef<"first_frame" | "last_frame" | undefined>(undefined);
const [dragOver, setDragOver] = useState(false);
const [hasPrompt, setHasPrompt] = useState(false);
const pickFiles = (role?: "first_frame" | "last_frame") => {
pendingRoleRef.current = role;
if (fileRef.current) {
fileRef.current.accept = mode === "keyframe" ? ACCEPT_IMAGE : ACCEPT_UNIVERSAL;
fileRef.current.multiple = mode === "universal";
fileRef.current.click();
}
};
const firstFrame = refs.find((r) => r.role === "first_frame");
const lastFrame = refs.find((r) => r.role === "last_frame");
return (
<div
className={`fc-inputbar${dragOver ? " drag" : ""}`}
onDragOver={(event) => { event.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(event) => {
event.preventDefault();
setDragOver(false);
const files = Array.from(event.dataTransfer.files || []);
if (files.length) onFiles(files, mode === "keyframe" ? (firstFrame ? "last_frame" : "first_frame") : undefined);
}}
>
<input
ref={fileRef}
type="file"
hidden
onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
if (files.length) onFiles(files, pendingRoleRef.current);
pendingRoleRef.current = undefined;
}}
/>
<div className="fc-input-top">
{mode === "universal" ? (
<div className="fc-refs">
<button type="button" className="fc-add" title="上传参考素材(图≤9 / 视频≤3 / 音频≤3)" onClick={() => pickFiles()}>
<IconKitSvg name="images" size={18} />
<span>+</span>
</button>
<button type="button" className="fc-add fc-lib" title="人物素材库" onClick={onOpenLibrary}>
<IconKitSvg name="users" size={18} />
</button>
{refs.map((item) => (
<RefThumb key={item.key} item={item} onRemove={() => onRemoveRef(item.key)} />
))}
</div>
) : (
<div className="fc-keyframes">
<KeyframeSlot role="first_frame" item={firstFrame} onPick={() => pickFiles("first_frame")} onRemove={() => firstFrame && onRemoveRef(firstFrame.key)} />
<span className="fc-kf-arrow mono"></span>
<KeyframeSlot role="last_frame" item={lastFrame} onPick={() => pickFiles("last_frame")} onRemove={() => lastFrame && onRemoveRef(lastFrame.key)} />
</div>
)}
<PromptInput
ref={promptRef}
refs={refs}
onSubmit={onSend}
onOpenLibrary={onOpenLibrary}
onTextChange={setHasPrompt}
placeholder={mode === "keyframe" ? "描述首尾帧之间的运动与变化…" : "描述你想生成的视频,@ 可引用参考素材…"}
/>
</div>
<FreeToolbar
mode={mode}
model={model}
ratio={ratio}
resolution={resolution}
duration={duration}
seed={seed}
refs={refs}
videoConfigs={videoConfigs}
hasPrompt={hasPrompt}
submitting={submitting}
onModeChange={onModeChange}
onModelChange={onModelChange}
onRatioChange={onRatioChange}
onResolutionChange={onResolutionChange}
onDurationChange={onDurationChange}
onSeedChange={onSeedChange}
onClear={onClear}
onSend={onSend}
/>
{dragOver && <div className="fc-drop-hint mono">{MODE_LABELS[mode]}</div>}
</div>
);
}
@@ -0,0 +1,349 @@
// 自由创作·提示词输入(contenteditable @mention 编辑器)
// 移植 jimeng-clone PromptInput 的核心机制:输入 @ 弹素材候选、插入不可编辑 mention chip、
// 删素材自动清孤儿 chip、序列化时 chip → "@label" 纯文本(「图片N」替换在后端做)。
// IME 注意:中文输入法 composition 期间不触发 @ 菜单/快捷键。
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { FreeVideoRef } from "../../types";
import type { LocalRef } from "./constants";
export type PromptInputHandle = {
/** 序列化为纯文本(chip → @label) */
getText(): string;
clear(): void;
/** 再次生成回填:纯文本里的 @label 命中 refs 则还原成带缩略图的 chip */
setContent(text: string, refs: FreeVideoRef[]): void;
/** 在光标处(未聚焦则在末尾)插入一个 mention chip */
insertMention(ref: { label: string; thumb?: string }): void;
/** 删除素材后清理孤儿 chip */
pruneMentions(validLabels: string[]): void;
focus(): void;
};
type Props = {
refs: LocalRef[];
placeholder?: string;
disabled?: boolean;
onSubmit: () => void;
onOpenLibrary: () => void;
onTextChange?: (hasText: boolean) => void;
};
function chipHtml(label: string, thumb?: string): HTMLSpanElement {
const chip = document.createElement("span");
chip.className = "fc-mention";
chip.setAttribute("data-fc-mention", "1");
chip.setAttribute("data-label", label);
chip.setAttribute("contenteditable", "false");
if (thumb) {
const img = document.createElement("img");
img.src = thumb;
img.alt = "";
chip.appendChild(img);
}
chip.appendChild(document.createTextNode(`@${label}`));
return chip;
}
function serializeNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) return node.textContent || "";
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const el = node as HTMLElement;
if (el.hasAttribute("data-fc-mention")) return `@${el.getAttribute("data-label") || ""}`;
if (el.tagName === "BR") return "\n";
const inner = Array.from(el.childNodes).map(serializeNode).join("");
// contenteditable 换行会包 div:块级元素前补换行(首个除外,由调用方 trim)
if (el.tagName === "DIV" || el.tagName === "P") return `\n${inner}`;
return inner;
}
export const PromptInput = forwardRef<PromptInputHandle, Props>(function PromptInput(
{ refs, placeholder = "描述你想生成的视频,@ 可引用参考素材…", disabled, onSubmit, onOpenLibrary, onTextChange },
handleRef
) {
const editorRef = useRef<HTMLDivElement>(null);
const composingRef = useRef(false);
const savedRangeRef = useRef<Range | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [menuQuery, setMenuQuery] = useState("");
const [menuIndex, setMenuIndex] = useState(0);
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
const labeledRefs = refs.filter((r) => (r.label || "").trim());
const candidates = labeledRefs.filter((r) => !menuQuery || (r.label || "").toLowerCase().includes(menuQuery.toLowerCase()));
const emitChange = () => {
const text = editorRef.current ? Array.from(editorRef.current.childNodes).map(serializeNode).join("").trim() : "";
onTextChange?.(text.length > 0);
};
const saveRange = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0 && editorRef.current?.contains(sel.anchorNode)) {
savedRangeRef.current = sel.getRangeAt(0).cloneRange();
}
};
const restoreRange = (): Range | null => {
const editor = editorRef.current;
if (!editor) return null;
const sel = window.getSelection();
if (!sel) return null;
let range = savedRangeRef.current;
if (!range || !editor.contains(range.startContainer)) {
range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
}
sel.removeAllRanges();
sel.addRange(range);
return range;
};
/** 找到光标前最近的 "@query" 触发串(同一文本节点内、不含空白),返回可删除的 Range */
const findTrigger = (): { range: Range; query: string } | null => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
if (!range.collapsed || range.startContainer.nodeType !== Node.TEXT_NODE) return null;
const textNode = range.startContainer as Text;
const upto = (textNode.textContent || "").slice(0, range.startOffset);
const at = upto.lastIndexOf("@");
if (at === -1) return null;
const query = upto.slice(at + 1);
if (/[\s]/.test(query)) return null;
const del = document.createRange();
del.setStart(textNode, at);
del.setEnd(textNode, range.startOffset);
return { range: del, query };
};
const closeMenu = () => { setMenuOpen(false); setMenuQuery(""); setMenuIndex(0); };
const openMenuAtCaret = () => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const rect = sel.getRangeAt(0).getBoundingClientRect();
const editorRect = editorRef.current?.getBoundingClientRect();
const left = rect.left || editorRect?.left || 0;
const top = rect.top || editorRect?.top || 0;
setMenuPos({ left, top });
setMenuIndex(0);
setMenuOpen(true);
};
const insertChipAtTrigger = (label: string, thumb?: string) => {
const editor = editorRef.current;
if (!editor) return;
editor.focus();
const trigger = findTrigger();
const sel = window.getSelection();
const chip = chipHtml(label, thumb);
const space = document.createTextNode(" ");
if (trigger) {
trigger.range.deleteContents();
trigger.range.insertNode(space);
trigger.range.insertNode(chip);
} else if (sel && sel.rangeCount > 0 && editor.contains(sel.anchorNode)) {
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(space);
range.insertNode(chip);
} else {
editor.appendChild(chip);
editor.appendChild(space);
}
// 光标移到空格后
const after = document.createRange();
after.setStartAfter(space);
after.collapse(true);
sel?.removeAllRanges();
sel?.addRange(after);
saveRange();
closeMenu();
emitChange();
};
useImperativeHandle(handleRef, () => ({
getText() {
const editor = editorRef.current;
if (!editor) return "";
return Array.from(editor.childNodes).map(serializeNode).join("").replace(/ /g, " ").trim();
},
clear() {
if (editorRef.current) editorRef.current.innerHTML = "";
savedRangeRef.current = null;
emitChange();
},
setContent(text, contentRefs) {
const editor = editorRef.current;
if (!editor) return;
editor.innerHTML = "";
// 按 label 长度降序切分,防「碧」吞「碧碧」(与后端替换同原则)
const labeled = contentRefs.filter((r) => (r.label || "").trim()).sort((a, b) => (b.label || "").length - (a.label || "").length);
let rest = text;
const parts: (string | FreeVideoRef)[] = [];
while (rest.length > 0) {
let hitIdx = -1;
let hitRef: FreeVideoRef | null = null;
for (const r of labeled) {
const idx = rest.indexOf(`@${r.label}`);
if (idx !== -1 && (hitIdx === -1 || idx < hitIdx)) { hitIdx = idx; hitRef = r; }
}
if (hitIdx === -1 || !hitRef) { parts.push(rest); break; }
if (hitIdx > 0) parts.push(rest.slice(0, hitIdx));
parts.push(hitRef);
rest = rest.slice(hitIdx + `@${hitRef.label}`.length);
}
for (const part of parts) {
if (typeof part === "string") editor.appendChild(document.createTextNode(part));
else editor.appendChild(chipHtml(part.label || "", part.thumb_url || (part.type === "image" ? part.url : "")));
}
emitChange();
},
insertMention(ref) {
insertChipAtTrigger(ref.label, ref.thumb);
},
pruneMentions(validLabels) {
const editor = editorRef.current;
if (!editor) return;
editor.querySelectorAll("[data-fc-mention]").forEach((chip) => {
if (!validLabels.includes(chip.getAttribute("data-label") || "")) chip.remove();
});
emitChange();
},
focus() { editorRef.current?.focus(); }
}));
// 素材集合变化 → 清孤儿 chip(删素材后 prompt 里的引用即时消失)
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const valid = labeledRefs.map((r) => r.label || "");
let changed = false;
editor.querySelectorAll("[data-fc-mention]").forEach((chip) => {
if (!valid.includes(chip.getAttribute("data-label") || "")) { chip.remove(); changed = true; }
});
if (changed) emitChange();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refs.map((r) => r.label).join("")]);
// 菜单开着时点外部关闭
useEffect(() => {
if (!menuOpen) return;
const onDown = (event: MouseEvent) => {
const target = event.target as Element;
if (target.closest?.(".fc-mention-menu") || editorRef.current?.contains(target as Node)) return;
closeMenu();
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [menuOpen]);
const menuItems: { key: string; label: string; thumb?: string; kind: "ref" | "library" }[] = [
...candidates.map((r) => ({
key: r.key,
label: r.label || "",
thumb: r.thumb_url || (r.type === "image" ? r.url : ""),
kind: "ref" as const
})),
{ key: "__library__", label: "从素材库选择…", kind: "library" as const }
];
const pickMenuItem = (item: (typeof menuItems)[number]) => {
if (item.kind === "library") {
// 先删掉触发串,再开素材库(选中后由页面 insertMention 插 chip)
const trigger = findTrigger();
trigger?.range.deleteContents();
closeMenu();
onOpenLibrary();
return;
}
insertChipAtTrigger(item.label, item.thumb);
};
return (
<div className="fc-prompt-wrap">
<div
ref={editorRef}
className="fc-prompt"
contentEditable={!disabled}
role="textbox"
aria-multiline="true"
aria-label="视频提示词"
data-placeholder={placeholder}
suppressContentEditableWarning
onInput={() => {
saveRange();
emitChange();
if (menuOpen) {
const trigger = findTrigger();
if (!trigger) closeMenu();
else setMenuQuery(trigger.query);
}
}}
onKeyUp={saveRange}
onMouseUp={saveRange}
onBlur={saveRange}
onCompositionStart={() => { composingRef.current = true; }}
onCompositionEnd={() => { composingRef.current = false; }}
onPaste={(event) => {
// 只贴纯文本,防外部富文本污染编辑器结构
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
document.execCommand("insertText", false, text);
}}
onKeyDown={(event) => {
if (composingRef.current) return;
if (menuOpen) {
if (event.key === "ArrowDown") { event.preventDefault(); setMenuIndex((i) => Math.min(i + 1, menuItems.length - 1)); return; }
if (event.key === "ArrowUp") { event.preventDefault(); setMenuIndex((i) => Math.max(i - 1, 0)); return; }
if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); if (menuItems[menuIndex]) pickMenuItem(menuItems[menuIndex]); return; }
if (event.key === "Escape") { event.preventDefault(); closeMenu(); return; }
}
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
onSubmit();
return;
}
if (event.key === "@" || (event.key === "2" && event.shiftKey)) {
// 等字符落进 DOM 后再定位菜单
window.setTimeout(() => {
const trigger = findTrigger();
if (trigger) { setMenuQuery(trigger.query); openMenuAtCaret(); }
}, 0);
}
}}
/>
{menuOpen && menuPos && createPortal(
<div
className="fc-mention-menu"
style={{ left: Math.min(menuPos.left, window.innerWidth - 292), top: Math.max(12, menuPos.top - 8) }}
>
<div className="fc-mention-menu-inner">
<div className="fc-mention-menu-head mono">// 引用素材</div>
{menuItems.map((item, i) => (
<button
key={item.key}
type="button"
className={`fc-mention-item${i === menuIndex ? " active" : ""}`}
onMouseEnter={() => setMenuIndex(i)}
onMouseDown={(event) => { event.preventDefault(); pickMenuItem(item); }}
>
{item.kind === "ref" ? (
<>
{item.thumb ? <img src={item.thumb} alt="" /> : <span className="fc-mention-ph" />}
<span className="lbl">@{item.label}</span>
</>
) : (
<span className="lbl lib">{item.label}</span>
)}
</button>
))}
</div>
</div>,
document.body
)}
</div>
);
});
@@ -0,0 +1,177 @@
// 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估消耗 + 清空 + 生成。
// 约束联动(与后端校验一致):1080P/4K 仅标准档;切非标准档时分辨率自动回落 720P。
import { useEffect, useRef, useState } from "react";
import type { ModelConfig } from "../../types";
import {
FC_DURATIONS,
FC_MODELS,
FC_RATIOS,
FC_RESOLUTIONS,
FC_STANDARD_MODEL,
MODE_LABELS,
estimateCost,
type FreeMode,
type LocalRef
} from "./constants";
type MenuItem = { value: string; label: string; desc?: string; disabled?: boolean; hint?: string };
function FcDropdown({ label, display, items, onSelect, disabled }: {
label: string;
display: string;
items: MenuItem[];
onSelect: (value: string) => void;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
};
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); };
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); };
}, [open]);
return (
<div className={`fc-dd${open ? " open" : ""}`} ref={wrapRef}>
<button type="button" className="fc-dd-btn" disabled={disabled} onClick={() => setOpen((v) => !v)} title={label}>
<span className="fc-dd-lbl mono">{label}</span>
<span className="fc-dd-val">{display}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m18 15-6-6-6 6" /></svg>
</button>
{open && (
<div className="fc-dd-menu">
{items.map((item) => (
<button
key={item.value}
type="button"
className={`fc-dd-item${item.value === display || item.label === display ? " selected" : ""}${item.disabled ? " disabled" : ""}`}
disabled={item.disabled}
title={item.disabled ? item.hint : undefined}
onClick={() => { if (!item.disabled) { onSelect(item.value); setOpen(false); } }}
>
<span className="ti">{item.label}</span>
{item.desc && <span className="de mono">{item.desc}</span>}
</button>
))}
</div>
)}
</div>
);
}
export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, hasPrompt, submitting, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onClear, onSend }: {
mode: FreeMode;
model: string;
ratio: string;
resolution: string;
duration: number;
seed: number;
refs: LocalRef[];
videoConfigs: ModelConfig[];
hasPrompt: boolean;
submitting: boolean;
onModeChange: (mode: FreeMode) => void;
onModelChange: (model: string) => void;
onRatioChange: (ratio: string) => void;
onResolutionChange: (resolution: string) => void;
onDurationChange: (duration: number) => void;
onSeedChange: (seed: number) => void;
onClear: () => void;
onSend: () => void;
}) {
const isStandard = model === FC_STANDARD_MODEL;
const config = videoConfigs.find((c) => c.name === model);
const { tokens, cost } = estimateCost(config, { ratio, resolution, duration, refs });
const [seedOpen, setSeedOpen] = useState(false);
const seedRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!seedOpen) return;
const onDown = (event: MouseEvent) => { if (!seedRef.current?.contains(event.target as Node)) setSeedOpen(false); };
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [seedOpen]);
const uploading = refs.some((r) => r.uploading);
const canSend = hasPrompt && !submitting && !uploading;
return (
<div className="fc-toolbar">
<div className="fc-toolbar-l">
<FcDropdown
label="模型"
display={FC_MODELS.find((m) => m.name === model)?.label || model}
items={FC_MODELS.map((m) => ({ value: m.name, label: m.label, desc: m.desc }))}
onSelect={(value) => {
onModelChange(value);
// 非标准档不支持 1080P/4K:自动回落 720P(与 jimeng 行为一致)
if (value !== FC_STANDARD_MODEL && (resolution === "1080p" || resolution === "4k")) onResolutionChange("720p");
}}
/>
<FcDropdown
label="模式"
display={MODE_LABELS[mode]}
items={[
{ value: "universal", label: "全能参考", desc: "文生 / 图·视频·音频参考" },
{ value: "keyframe", label: "首尾帧", desc: "首帧必填 · 尾帧可选" }
]}
onSelect={(value) => onModeChange(value as FreeMode)}
/>
<FcDropdown label="比例" display={ratio} items={FC_RATIOS.map((r) => ({ value: r, label: r }))} onSelect={onRatioChange} />
<FcDropdown
label="分辨率"
display={resolution.toUpperCase()}
items={FC_RESOLUTIONS.map((r) => ({
value: r,
label: r.toUpperCase(),
disabled: (r === "1080p" || r === "4k") && !isStandard,
hint: "仅 Seedance 2.0 标准档支持"
}))}
onSelect={onResolutionChange}
/>
<FcDropdown
label="时长"
display={`${duration}s`}
items={FC_DURATIONS.map((d) => ({ value: String(d), label: `${d}s` }))}
onSelect={(value) => onDurationChange(Number(value))}
/>
<div className={`fc-dd${seedOpen ? " open" : ""}`} ref={seedRef}>
<button type="button" className="fc-dd-btn" onClick={() => setSeedOpen((v) => !v)} title="种子值(-1 随机;相同种子 + 相同参数可复现相似结果)">
<span className="fc-dd-lbl mono"></span>
<span className="fc-dd-val">{seed === -1 ? "随机" : seed}</span>
</button>
{seedOpen && (
<div className="fc-dd-menu fc-seed-menu">
<div className="fc-seed-row">
<input
className="input"
type="number"
value={seed}
min={-1}
onChange={(event) => {
const v = parseInt(event.target.value, 10);
onSeedChange(Number.isNaN(v) ? -1 : v);
}}
/>
<button type="button" className="btn btn-sm" onClick={() => { onSeedChange(-1); setSeedOpen(false); }}></button>
</div>
<div className="fc-seed-hint mono">// -1 = 随机;相同种子可复现相似结果</div>
</div>
)}
</div>
</div>
<div className="fc-toolbar-r">
<span className="fc-estimate mono" title="预估消耗(实际按火山返回用量结算)">
{tokens.toLocaleString()} tokens · ¥{cost.toFixed(2)}
</span>
<button type="button" className="btn btn-sm btn-ghost" onClick={onClear}></button>
<button type="button" className="btn btn-sm btn-primary" disabled={!canSend} onClick={onSend} title="Ctrl/Cmd + Enter">
{submitting ? "提交中…" : uploading ? "素材上传中…" : "生成 →"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,161 @@
// 自由创作·视频详情弹窗:自研全屏播放器(播放/暂停、seek、音量、全屏)+ 上下切换 +
// 下载 / 再次生成(回填输入条) / 收藏 / 删除。ESC / 点遮罩关闭。
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronLeft, ChevronRight, Download, Heart, Maximize, Pause, Play, RotateCcw, Trash2, Volume2, VolumeX, X } from "lucide-react";
import type { FreeVideoTask } from "../../types";
import { useBodyScrollLock } from "../overlays";
import { MODE_LABELS, modelLabel } from "./constants";
function fmt(seconds: number): string {
if (!isFinite(seconds)) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
export function VideoDetailModal({ task, hasPrev, hasNext, onPrev, onNext, onClose, onDownload, onToggleFavorite, onReuse, onDelete }: {
task: FreeVideoTask;
hasPrev: boolean;
hasNext: boolean;
onPrev: () => void;
onNext: () => void;
onClose: () => void;
onDownload: () => void;
onToggleFavorite: () => void;
onReuse: () => void;
onDelete: () => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const [playing, setPlaying] = useState(true);
const [muted, setMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [current, setCurrent] = useState(0);
const [total, setTotal] = useState(0);
useBodyScrollLock(true);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
else if (event.key === "ArrowLeft" && hasPrev) onPrev();
else if (event.key === "ArrowRight" && hasNext) onNext();
else if (event.key === " ") { event.preventDefault(); togglePlay(); }
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasPrev, hasNext, onPrev, onNext, onClose]);
// 切换任务时重置播放状态
useEffect(() => {
setPlaying(true);
setCurrent(0);
setTotal(0);
}, [task.id]);
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
if (video.paused) { void video.play().catch(() => undefined); } else { video.pause(); }
};
const [w, h] = task.aspect_ratio.split(":").map(Number);
return createPortal(
<div className="fc-player-bg" onClick={onClose}>
<button type="button" className="fc-player-x" aria-label="关闭" onClick={onClose}><X size={18} /></button>
{hasPrev && (
<button type="button" className="fc-player-nav prev" aria-label="上一条" onClick={(event) => { event.stopPropagation(); onPrev(); }}>
<ChevronLeft size={22} />
</button>
)}
{hasNext && (
<button type="button" className="fc-player-nav next" aria-label="下一条" onClick={(event) => { event.stopPropagation(); onNext(); }}>
<ChevronRight size={22} />
</button>
)}
<div className="fc-player" onClick={(event) => event.stopPropagation()}>
<div className="fc-player-stage" ref={stageRef} style={{ aspectRatio: w && h ? `${w} / ${h}` : "16 / 9" }} onClick={togglePlay}>
<video
key={task.id}
ref={videoRef}
src={task.video_url}
poster={task.thumbnail_url || undefined}
autoPlay
playsInline
muted={muted}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onTimeUpdate={(event) => setCurrent(event.currentTarget.currentTime)}
onLoadedMetadata={(event) => setTotal(event.currentTarget.duration)}
onVolumeChange={(event) => { setMuted(event.currentTarget.muted); setVolume(event.currentTarget.volume); }}
/>
</div>
<div className="fc-player-controls">
<button type="button" className="fc-ctl" aria-label={playing ? "暂停" : "播放"} onClick={togglePlay}>
{playing ? <Pause size={16} /> : <Play size={16} />}
</button>
<span className="fc-time mono">{fmt(current)} / {fmt(total || task.duration)}</span>
<input
className="fc-seek"
type="range"
min={0}
max={total || task.duration || 0}
step={0.05}
value={current}
aria-label="进度"
onChange={(event) => {
const t = Number(event.target.value);
if (videoRef.current) videoRef.current.currentTime = t;
setCurrent(t);
}}
/>
<button type="button" className="fc-ctl" aria-label={muted ? "取消静音" : "静音"} onClick={() => { if (videoRef.current) videoRef.current.muted = !muted; }}>
{muted || volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
<input
className="fc-vol"
type="range"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
aria-label="音量"
onChange={(event) => {
const v = Number(event.target.value);
if (videoRef.current) { videoRef.current.volume = v; videoRef.current.muted = v === 0; }
}}
/>
<button
type="button"
className="fc-ctl"
aria-label="全屏"
onClick={() => { void stageRef.current?.requestFullscreen?.().catch(() => undefined); }}
>
<Maximize size={16} />
</button>
</div>
<div className="fc-player-info">
<div className="fc-player-prompt">{task.prompt}</div>
<div className="fc-player-sub mono">
// {MODE_LABELS[task.mode] || task.mode} · {modelLabel(task.model)} · {task.aspect_ratio} · {task.resolution.toUpperCase()} · {task.duration}s
{task.seed_used != null && ` · seed ${task.seed_used}`}
{` · ¥${Number(task.actual_cost || 0).toFixed(2)}`}
</div>
{task.fallback_note && <div className="fc-player-warn mono">// {task.fallback_note}</div>}
<div className="fc-player-actions">
<button type="button" className="btn btn-sm" onClick={onDownload}><Download size={13} /> </button>
<button type="button" className="btn btn-sm" onClick={onReuse}><RotateCcw size={13} /> </button>
<button type="button" className={`btn btn-sm${task.is_favorited ? " fc-fav-on" : ""}`} onClick={onToggleFavorite}>
<Heart size={13} fill={task.is_favorited ? "currentColor" : "none"} /> {task.is_favorited ? "已收藏" : "收藏"}
</button>
<button type="button" className="btn btn-sm" onClick={onDelete}><Trash2 size={13} /> </button>
</div>
</div>
</div>
</div>,
document.body
);
}