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:
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user