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
+3
View File
@@ -24,6 +24,7 @@ import {
AssetFactoryPage,
AuthScreen,
Dashboard,
FreeCreatePage,
ImageWorkbenchPage,
LibraryPage,
MessagesPage,
@@ -878,6 +879,8 @@ export function App() {
);
case "assetFactory":
return <AssetFactoryPage navigate={navigate} />;
case "freeCreate":
return <FreeCreatePage modelConfigs={modelConfigs} onNotify={(type, text) => setNotice({ type, text })} />;
case "imageOptimize":
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
case "modelPhoto":
+66
View File
@@ -17,6 +17,11 @@ import type {
AuthPayload,
BillingSummary,
BillingTrend,
FreeAssetGroup,
FreeAssetItem,
FreeVideoRef,
FreeVideoTask,
FreeVideoUploadResult,
Ledger,
LoginSession,
Invitation,
@@ -660,6 +665,67 @@ export const api = {
generateImageStatus(ids: string[]) {
return request<{ tasks: { id: string; status: string; error_message: string; assets: Asset[] }[] }>(`/api/ai/generate-image/?ids=${encodeURIComponent(ids.join(","))}`);
},
// —— 自由创作·视频生成 ——
// 提交秒回(火山 create 同步、慢轮询交给 worker 兜底 + 前端主动 poll);校验失败 400 带中文 detail。
submitFreeVideo(payload: {
prompt: string;
mode: "universal" | "keyframe";
model?: string;
aspect_ratio?: string;
resolution?: string;
duration?: number;
seed?: number;
generate_audio?: boolean;
references?: FreeVideoRef[];
}) {
return request<{ task: FreeVideoTask }>("/api/ai/free-video/", { method: "POST", body: JSON.stringify(payload) });
},
freeVideoTasks(offset = 0, pageSize = 20) {
return request<{ results: FreeVideoTask[]; total: number; has_more: boolean }>(
`/api/ai/free-video/?offset=${offset}&page_size=${pageSize}`
);
},
// web 进程内单次轮询+终态化(幂等):前端渐进轮询打这里,本地无 worker 也能收尾
pollFreeVideo(id: string) {
return request<{ task: FreeVideoTask }>(`/api/ai/free-video/${id}/poll/`, { method: "POST" });
},
toggleFreeVideoFavorite(id: string) {
return request<{ is_favorited: boolean }>(`/api/ai/free-video/${id}/favorite/`, { method: "POST" });
},
deleteFreeVideo(id: string) {
return request<void>(`/api/ai/free-video/${id}/`, { method: "DELETE" });
},
uploadFreeVideoRef(formData: FormData) {
return request<FreeVideoUploadResult>("/api/ai/free-video/upload/", { method: "POST", body: formData });
},
// 自由创作·人物素材库(火山 Assets API asset:// 引用登记)
freeAssetGroups() {
return request<{ results: FreeAssetGroup[] }>("/api/assets/free-groups/");
},
createFreeAssetGroup(payload: { name: string; description?: string }) {
return request<{ group: FreeAssetGroup }>("/api/assets/free-groups/", { method: "POST", body: JSON.stringify(payload) });
},
freeAssetGroup(id: string) {
return request<{ group: FreeAssetGroup; assets: FreeAssetItem[] }>(`/api/assets/free-groups/${id}/`);
},
updateFreeAssetGroup(id: string, payload: { name?: string; description?: string }) {
return request<{ group: FreeAssetGroup }>(`/api/assets/free-groups/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
deleteFreeAssetGroup(id: string) {
return request<void>(`/api/assets/free-groups/${id}/`, { method: "DELETE" });
},
uploadFreeAsset(groupId: string, formData: FormData) {
return request<{ asset: FreeAssetItem }>(`/api/assets/free-groups/${groupId}/assets/`, { method: "POST", body: formData });
},
renameFreeAsset(id: string, name: string) {
return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/`, { method: "PATCH", body: JSON.stringify({ name }) });
},
deleteFreeAsset(id: string) {
return request<void>(`/api/assets/free-assets/${id}/`, { method: "DELETE" });
},
pollFreeAsset(id: string) {
return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/poll/`, { method: "POST" });
},
recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) {
return request<RechargeResult>("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) });
},
+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
);
}
+568
View File
@@ -0,0 +1,568 @@
/* 自由创作页(free-create)· 私有样式,全部 .fc- 前缀
颜色/圆角/间距全部走 design-restraint token(§8 Don't List:禁裸 hex / >12px 圆角 / 灰阴影) */
.fc-page { display: flex; flex-direction: column; min-height: calc(100vh - 64px - 120px); }
/* —— 任务流 —— */
.fc-feed-wrap { flex: 1; padding-bottom: 24px; }
.fc-feed {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
align-items: start;
}
.fc-loading, .fc-sentinel {
padding: 28px 0;
text-align: center;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--black-alpha-48);
}
.fc-empty { margin: 48px auto; }
/* —— 任务卡 —— */
.fc-card {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
overflow: hidden;
display: flex;
flex-direction: column;
transition: border-color 0.2s;
}
.fc-card.done { cursor: pointer; }
.fc-card.done:hover { border-color: var(--black-alpha-48); }
.fc-card-media {
position: relative;
background: var(--background-base);
max-height: 420px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.fc-card-media video { width: 100%; height: 100%; object-fit: cover; display: block; }
.fc-card-generating {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: linear-gradient(110deg, var(--black-alpha-4) 30%, var(--black-alpha-7) 50%, var(--black-alpha-4) 70%);
background-size: 200% 100%;
animation: fc-shimmer 1.6s linear infinite;
font-size: 11px;
color: var(--black-alpha-56);
}
@keyframes fc-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.fc-progress {
width: 60%;
height: 4px;
border-radius: 2px;
background: var(--black-alpha-12);
overflow: hidden;
}
.fc-progress span { display: block; height: 100%; border-radius: 2px; background: var(--heat); transition: width 0.6s ease; }
.fc-card-failed {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px;
text-align: center;
}
.fc-card-failed p { font-size: 12px; line-height: 1.65; color: var(--black-alpha-64); max-width: 90%; margin: 0; }
.fc-card-hover {
position: absolute;
top: 10px;
right: 10px;
display: flex;
gap: 6px;
opacity: 0;
transition: opacity 0.2s;
}
.fc-card:hover .fc-card-hover { opacity: 1; }
.fc-hover-btn {
width: 30px;
height: 30px;
border-radius: var(--r-md);
border: 1px solid var(--border-faint);
background: var(--surface);
color: var(--black-alpha-56);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.fc-hover-btn:hover { background: var(--black-alpha-4); color: var(--accent-black); }
.fc-hover-btn.fav { color: var(--heat); }
.fc-hover-btn.danger:hover { color: var(--accent-crimson); }
.fc-card-meta { padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; }
.fc-card-prompt {
font-size: 13px;
color: var(--accent-black);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fc-card-sub { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
/* —— 底部输入条 —— */
.fc-inputbar {
position: sticky;
bottom: 16px;
z-index: 5;
background: var(--surface-raised);
border: 1px solid var(--border-muted);
border-radius: var(--r-md);
padding: 14px 16px 12px;
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: var(--shadow-floating);
}
.fc-inputbar.drag { border-color: var(--heat-40); }
.fc-drop-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--heat-12);
border-radius: inherit;
color: var(--heat);
font-size: 12px;
pointer-events: none;
}
.fc-input-top { display: flex; gap: 12px; align-items: flex-start; }
/* 参考素材条(universal) */
.fc-refs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; max-width: 300px; }
.fc-add {
width: 56px;
height: 56px;
border-radius: var(--r-md);
border: 1px dashed var(--black-alpha-24);
background: var(--background-base);
color: var(--black-alpha-56);
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
cursor: pointer;
font-size: 12px;
transition: border-color 0.2s, color 0.2s;
}
.fc-add:hover { border-color: var(--heat-40); color: var(--heat); }
.fc-ref {
position: relative;
width: 56px;
height: 56px;
border-radius: var(--r-md);
border: 1px solid var(--border-faint);
overflow: hidden;
background: var(--background-base);
display: flex;
align-items: center;
justify-content: center;
}
.fc-ref img { width: 100%; height: 100%; object-fit: cover; }
.fc-ref.uploading img { opacity: 0.4; }
.fc-ref .spinner { position: absolute; }
.fc-ref-kind { font-size: 11px; color: var(--black-alpha-48); }
.fc-ref-dur {
position: absolute;
right: 3px;
bottom: 3px;
font-size: 8.5px;
padding: 1px 4px;
border-radius: var(--r-sm);
background: var(--black-alpha-56);
color: var(--surface);
}
.fc-ref-label {
position: absolute;
left: 0;
right: 0;
bottom: 0;
font-size: 9px;
padding: 1px 4px;
background: var(--black-alpha-56);
color: var(--surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fc-ref-x {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
border-radius: 999px;
border: none;
background: var(--black-alpha-56);
color: var(--surface);
font-size: 11px;
line-height: 1;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
}
.fc-ref:hover .fc-ref-x, .fc-kf-slot:hover .fc-ref-x { display: inline-flex; }
/* 首尾帧(keyframe) */
.fc-keyframes { display: flex; align-items: center; gap: 8px; }
.fc-kf-slot {
position: relative;
width: 88px;
height: 66px;
border-radius: var(--r-md);
border: 1px dashed var(--black-alpha-24);
background: var(--background-base);
color: var(--black-alpha-56);
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
cursor: pointer;
font-size: 11px;
overflow: hidden;
transition: border-color 0.2s, color 0.2s;
}
.fc-kf-slot:hover { border-color: var(--heat-40); color: var(--heat); }
.fc-kf-slot.filled { border-style: solid; border-color: var(--border-faint); cursor: default; }
.fc-kf-slot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.fc-kf-slot .spinner { position: absolute; }
.fc-kf-tag {
position: absolute;
left: 3px;
top: 3px;
font-size: 8.5px;
padding: 1px 4px;
border-radius: var(--r-sm);
background: var(--black-alpha-56);
color: var(--surface);
}
.fc-kf-arrow { color: var(--black-alpha-48); font-size: 13px; }
/* 提示词编辑器(contenteditable) */
.fc-prompt-wrap { flex: 1; min-width: 0; }
.fc-prompt {
min-height: 64px;
max-height: 160px;
overflow-y: auto;
font-size: 13px;
line-height: 1.65;
color: var(--accent-black);
outline: none;
padding: 6px 2px;
word-break: break-word;
}
.fc-prompt:empty::before { content: attr(data-placeholder); color: var(--black-alpha-48); pointer-events: none; }
.fc-mention {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
margin: 0 1px;
border-radius: var(--r-sm);
background: var(--heat-12);
border: 1px solid var(--heat-20);
color: var(--heat);
font-size: 12px;
vertical-align: middle;
user-select: none;
white-space: nowrap;
}
.fc-mention img { width: 16px; height: 16px; border-radius: 3px; object-fit: cover; }
/* @ 候选菜单(fixed,朝上弹) */
.fc-mention-menu { position: fixed; z-index: 300; transform: translateY(-100%); }
.fc-mention-menu-inner {
min-width: 220px;
max-width: 280px;
max-height: 260px;
overflow-y: auto;
background: var(--surface-raised);
border: 1px solid var(--border-muted);
border-radius: var(--r-md);
box-shadow: var(--shadow-floating);
padding: 6px;
}
.fc-mention-menu-head { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); padding: 4px 8px; }
.fc-mention-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 8px;
border: none;
border-radius: var(--r-sm);
background: transparent;
cursor: pointer;
font-size: 12.5px;
color: var(--accent-black);
text-align: left;
}
.fc-mention-item.active { background: var(--black-alpha-4); }
.fc-mention-item img { width: 24px; height: 24px; border-radius: var(--r-sm); object-fit: cover; }
.fc-mention-ph { width: 24px; height: 24px; border-radius: var(--r-sm); background: var(--black-alpha-7); }
.fc-mention-item .lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fc-mention-item .lbl.lib { color: var(--black-alpha-56); }
/* —— 工具栏 —— */
.fc-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; border-top: 1px solid var(--border-faint); padding-top: 10px; }
.fc-toolbar-l { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.fc-toolbar-r { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.fc-estimate { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); font-variant-numeric: tabular-nums; }
.fc-dd { position: relative; }
.fc-dd-btn {
height: 28px;
padding: 0 10px;
border-radius: var(--r-md);
border: 1px solid var(--border-faint);
background: var(--surface);
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--accent-black);
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.fc-dd-btn:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); }
.fc-dd-btn:disabled { color: var(--black-alpha-32); cursor: not-allowed; }
.fc-dd-btn svg { color: var(--black-alpha-48); transition: transform 0.2s; }
.fc-dd.open .fc-dd-btn svg { transform: rotate(180deg); }
.fc-dd-lbl { font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
.fc-dd-val { font-weight: 500; }
.fc-dd-menu {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
z-index: 60;
min-width: 180px;
background: var(--surface-raised);
border: 1px solid var(--border-muted);
border-radius: var(--r-md);
box-shadow: var(--shadow-floating);
padding: 6px;
}
.fc-dd-item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: var(--r-sm);
background: transparent;
cursor: pointer;
text-align: left;
}
.fc-dd-item:hover { background: var(--black-alpha-4); }
.fc-dd-item.selected { background: var(--heat-12); }
.fc-dd-item.selected .ti { color: var(--heat); }
.fc-dd-item.disabled { cursor: not-allowed; }
.fc-dd-item.disabled .ti, .fc-dd-item.disabled .de { color: var(--black-alpha-32); }
.fc-dd-item .ti { font-size: 12.5px; font-weight: 500; color: var(--accent-black); }
.fc-dd-item .de { font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
.fc-seed-menu { min-width: 220px; padding: 10px; }
.fc-seed-row { display: flex; gap: 8px; align-items: center; }
.fc-seed-row .input { height: 30px; flex: 1; }
.fc-seed-hint { margin-top: 6px; font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
.fc-fav-on { color: var(--heat); }
/* —— 全屏播放器 —— */
.fc-player-bg {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(21, 20, 15, 0.72);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: 40px 72px;
}
.fc-player-x {
position: absolute;
top: 20px;
right: 24px;
width: 36px;
height: 36px;
border-radius: var(--r-md);
border: none;
background: var(--black-alpha-24);
color: var(--surface);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.fc-player-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 999px;
border: none;
background: var(--black-alpha-24);
color: var(--surface);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.fc-player-nav.prev { left: 20px; }
.fc-player-nav.next { right: 20px; }
.fc-player-nav:hover, .fc-player-x:hover { background: var(--black-alpha-48); }
.fc-player {
width: min(920px, 100%);
max-height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.fc-player-stage {
max-height: 62vh;
margin: 0 auto;
max-width: 100%;
background: #000; /* 视频画布留黑,行业惯例,非界面色 */
border-radius: var(--r-md);
overflow: hidden;
cursor: pointer;
}
.fc-player-stage video { width: 100%; height: 100%; object-fit: contain; display: block; }
.fc-player-controls {
display: flex;
align-items: center;
gap: 10px;
background: var(--surface-raised);
border-radius: var(--r-md);
padding: 8px 12px;
}
.fc-ctl {
width: 30px;
height: 30px;
border: none;
border-radius: var(--r-md);
background: transparent;
color: var(--black-alpha-56);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.fc-ctl:hover { background: var(--black-alpha-4); color: var(--accent-black); }
.fc-time { font-size: 11px; color: var(--black-alpha-56); font-variant-numeric: tabular-nums; white-space: nowrap; }
.fc-seek { flex: 1; accent-color: var(--heat); }
.fc-vol { width: 72px; accent-color: var(--heat); }
.fc-player-info {
background: var(--surface-raised);
border-radius: var(--r-md);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.fc-player-prompt { font-size: 13px; line-height: 1.65; color: var(--accent-black); max-height: 72px; overflow-y: auto; }
.fc-player-sub { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
.fc-player-warn { font-size: 11px; color: var(--accent-honey); }
.fc-player-actions { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
/* —— 素材库弹窗 —— */
.fc-lib-modal { width: min(720px, 92vw); }
.fc-lib-body { max-height: 60vh; overflow-y: auto; }
.fc-lib-back {
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: transparent;
font-size: inherit;
font-weight: inherit;
color: inherit;
cursor: pointer;
padding: 0;
}
.fc-lib-back:hover { color: var(--heat); }
.fc-lib-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.fc-lib-hint { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
.fc-lib-create { display: flex; gap: 8px; align-items: center; flex: 1; }
.fc-lib-create .input { height: 30px; flex: 1; max-width: 260px; }
.fc-lib-groups, .fc-lib-assets {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: 12px;
}
.fc-lib-group, .fc-lib-asset {
position: relative;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.fc-lib-group:hover, .fc-lib-asset.pickable:hover { background: var(--surface); border-color: var(--heat-40); }
.fc-lib-asset:not(.pickable) { cursor: default; }
.fc-lib-thumb {
position: relative;
aspect-ratio: 1;
border-radius: var(--r-sm);
background: var(--black-alpha-4);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: var(--black-alpha-48);
}
.fc-lib-thumb img { width: 100%; height: 100%; object-fit: cover; }
.fc-lib-name { font-size: 12.5px; font-weight: 500; color: var(--accent-black); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fc-lib-count { font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
.fc-lib-err { font-size: 10.5px; color: var(--accent-crimson); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fc-lib-ops { position: absolute; top: 14px; right: 14px; display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s; }
.fc-lib-group:hover .fc-lib-ops, .fc-lib-asset:hover .fc-lib-ops { opacity: 1; }
.fc-lib-ops button {
width: 22px;
height: 22px;
border: none;
border-radius: var(--r-sm);
background: var(--black-alpha-56);
color: var(--surface);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.fc-lib-ops button:hover { background: var(--accent-black); }
/* 移动端:输入条参数换行、任务流单列 */
@media (max-width: 720px) {
.fc-feed { grid-template-columns: 1fr; }
.fc-input-top { flex-direction: column; }
.fc-refs { max-width: none; }
.fc-player-bg { padding: 16px; }
.fc-player-nav.prev { left: 6px; }
.fc-player-nav.next { right: 6px; }
}
+1
View File
@@ -12,6 +12,7 @@ import "./library-page.css";
import "./messages-page.css";
import "./settings-page.css";
import "./ai-tools-page.css";
import "./free-create-page.css";
import "./product-create-page.css";
import "./project-wizard-page.css";
import "./admin-page.css";
+549
View File
@@ -0,0 +1,549 @@
// 自由创作 · AI 视频生成(移植自 jimeng-clone,嫁接 AirShelf 底座)
// 任务流(滚动加载)+ 底部输入条(全能参考/首尾帧 + @mention)+ 渐进轮询(10/30/60s 打后端
// poll 端点,web 进程内查火山,不依赖 worker)+ 平滑进度动画(sessionStorage 续)+ 全屏播放器。
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Users } from "lucide-react";
import { api, ApiError } from "../api";
import type { FreeVideoRef, FreeVideoTask, ModelConfig } from "../types";
import {
FC_MODELS,
FC_STANDARD_MODEL,
MAX_AUDIOS,
MAX_IMAGES,
MAX_VIDEOS,
MAX_VIDEO_TOTAL_SECONDS,
checkRefFile,
isInFlight,
type FreeMode,
type LocalRef
} from "../components/free-create/constants";
import { FreeInputBar } from "../components/free-create/input-bar";
import { GenerationCard } from "../components/free-create/generation-card";
import { VideoDetailModal } from "../components/free-create/video-detail-modal";
import { AssetLibraryModal } from "../components/free-create/asset-library-modal";
import type { PromptInputHandle } from "../components/free-create/prompt-input";
import { ConfirmModal } from "../components/overlays";
const PAGE_SIZE = 20;
const PROGRESS_KEY = "fc-progress";
function loadProgress(): Record<string, number> {
try {
return JSON.parse(sessionStorage.getItem(PROGRESS_KEY) || "{}") as Record<string, number>;
} catch {
return {};
}
}
// 渐进轮询间隔:前 1 分钟 10s,1-3 分钟 30s,之后 60s(视频 5-10 分钟出片,别打爆后端)
function pollDelay(count: number): number {
if (count < 6) return 10000;
if (count < 10) return 30000;
return 60000;
}
let refKeySeq = 0;
const nextRefKey = () => `ref-${Date.now()}-${refKeySeq++}`;
export function FreeCreatePage({ modelConfigs, onNotify }: {
modelConfigs: ModelConfig[];
onNotify: (type: "success" | "error" | "info", text: string) => void;
}) {
// App 每次渲染会重建 onNotify 箭头函数;用 ref 稳定身份,否则依赖它的 effect(首屏拉取/轮询)
// 会随 App 任意 setState(如 30s 未读轮询)反复重跑 → 任务流无谓全量刷新(实测踩坑)。
const onNotifyRef = useRef(onNotify);
onNotifyRef.current = onNotify;
const notify = useCallback((type: "success" | "error" | "info", text: string) => onNotifyRef.current(type, text), []);
const videoConfigs = useMemo(
() => modelConfigs.filter((c) => c.capability === "video" && FC_MODELS.some((m) => m.name === c.name)),
[modelConfigs]
);
// —— 任务流 ——
const [tasks, setTasks] = useState<FreeVideoTask[]>([]);
const [total, setTotal] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const loadingMoreRef = useRef(false);
const sentinelRef = useRef<HTMLDivElement>(null);
// —— 输入条 ——
const [mode, setMode] = useState<FreeMode>("universal");
const [model, setModel] = useState<string>(FC_STANDARD_MODEL);
const [ratio, setRatio] = useState("16:9");
const [resolution, setResolution] = useState("720p");
const [duration, setDuration] = useState(5);
const [seed, setSeed] = useState(-1);
const [refs, setRefs] = useState<LocalRef[]>([]);
const [submitting, setSubmitting] = useState(false);
const promptRef = useRef<PromptInputHandle | null>(null);
// —— 弹窗 ——
const [detailId, setDetailId] = useState<string | null>(null);
const [libraryOpen, setLibraryOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<FreeVideoTask | null>(null);
// —— 轮询/进度 ——
const pollTimersRef = useRef(new Map<string, number>());
const pollCountsRef = useRef(new Map<string, number>());
const [progress, setProgress] = useState<Record<string, number>>(() => loadProgress());
const tasksRef = useRef<FreeVideoTask[]>([]);
tasksRef.current = tasks;
const patchTask = useCallback((task: FreeVideoTask) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t)));
}, []);
const stopPolling = useCallback((id: string) => {
const timer = pollTimersRef.current.get(id);
if (timer) window.clearTimeout(timer);
pollTimersRef.current.delete(id);
pollCountsRef.current.delete(id);
}, []);
const schedulePoll = useCallback((id: string) => {
if (pollTimersRef.current.has(id)) return;
const tick = async () => {
pollTimersRef.current.delete(id);
const current = tasksRef.current.find((t) => t.id === id);
if (!current || !isInFlight(current.status)) { stopPolling(id); return; }
const count = (pollCountsRef.current.get(id) || 0) + 1;
pollCountsRef.current.set(id, count);
try {
const data = await api.pollFreeVideo(id);
patchTask(data.task);
if (isInFlight(data.task.status)) {
pollTimersRef.current.set(id, window.setTimeout(() => void tick(), pollDelay(count)));
} else {
stopPolling(id);
setProgress((prev) => { const next = { ...prev }; delete next[id]; sessionStorage.setItem(PROGRESS_KEY, JSON.stringify(next)); return next; });
if (data.task.status === "succeeded") notify("success", "视频生成完成");
else if (data.task.status === "failed") notify("error", data.task.error_message || "视频生成失败");
}
} catch {
// 单次轮询失败(网络抖动)不终结,下一轮继续
pollTimersRef.current.set(id, window.setTimeout(() => void tick(), pollDelay(count)));
}
};
pollTimersRef.current.set(id, window.setTimeout(() => void tick(), pollDelay(pollCountsRef.current.get(id) || 0)));
}, [patchTask, stopPolling, notify]);
// 平滑进度动画:每 2s 给在途任务 +0.6~1.6%,封顶 95,sessionStorage 持久(刷新可续)
useEffect(() => {
const timer = window.setInterval(() => {
const inflight = tasksRef.current.filter((t) => isInFlight(t.status));
if (inflight.length === 0) return;
setProgress((prev) => {
const next = { ...prev };
for (const t of inflight) {
next[t.id] = Math.min(95, (next[t.id] || 3) + 0.6 + Math.random());
}
sessionStorage.setItem(PROGRESS_KEY, JSON.stringify(next));
return next;
});
}, 2000);
return () => window.clearInterval(timer);
}, []);
// 首屏拉取 + 在途任务恢复轮询(后端持久恢复,换浏览器也不丢)
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const data = await api.freeVideoTasks(0, PAGE_SIZE);
if (cancelled) return;
setTasks(data.results);
setTotal(data.total);
setHasMore(data.has_more);
data.results.filter((t) => isInFlight(t.status)).forEach((t) => schedulePoll(t.id));
} catch (error) {
if (!cancelled) notify("error", error instanceof Error ? error.message : "任务列表加载失败");
} finally {
if (!cancelled) setLoading(false);
}
})();
const timers = pollTimersRef.current;
return () => {
cancelled = true;
timers.forEach((timer) => window.clearTimeout(timer));
timers.clear();
};
}, [schedulePoll, notify]);
// 滚动加载更多(旧任务)
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasMore) return;
const observer = new IntersectionObserver((entries) => {
if (!entries[0].isIntersecting || loadingMoreRef.current) return;
loadingMoreRef.current = true;
void api.freeVideoTasks(tasksRef.current.filter((t) => !t.id.startsWith("local-")).length, PAGE_SIZE)
.then((data) => {
setTasks((prev) => {
const seen = new Set(prev.map((t) => t.id));
return [...prev, ...data.results.filter((t) => !seen.has(t.id))];
});
setTotal(data.total);
setHasMore(data.has_more);
})
.catch(() => undefined)
.finally(() => { loadingMoreRef.current = false; });
}, { rootMargin: "200px" });
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore]);
// —— 上传 ——
const addFiles = useCallback(async (files: File[], role?: "first_frame" | "last_frame") => {
for (const file of files) {
const check = await checkRefFile(file);
if (!check.ok) { notify("error", check.error); continue; }
if (mode === "keyframe") {
if (check.type !== "image") { notify("error", "首尾帧模式仅支持图片素材"); continue; }
} else {
const counts = { image: 0, video: 0, audio: 0 };
let videoSeconds = 0;
for (const r of refs) {
counts[r.type] += 1;
if (r.type === "video") videoSeconds += r.duration || 0;
}
if (check.type === "image" && counts.image >= MAX_IMAGES) { notify("error", `参考图片最多 ${MAX_IMAGES}`); continue; }
if (check.type === "video" && counts.video >= MAX_VIDEOS) { notify("error", `参考视频最多 ${MAX_VIDEOS}`); continue; }
if (check.type === "audio" && counts.audio >= MAX_AUDIOS) { notify("error", `参考音频最多 ${MAX_AUDIOS}`); continue; }
if (check.type === "video" && videoSeconds + (check.duration || 0) > MAX_VIDEO_TOTAL_SECONDS) {
notify("error", `参考视频总时长不能超过 ${MAX_VIDEO_TOTAL_SECONDS}`);
continue;
}
}
const key = nextRefKey();
const blobUrl = URL.createObjectURL(file);
const baseLabel = (file.name.replace(/\.[^.]+$/, "") || "素材").slice(0, 24);
let label = baseLabel;
let n = 2;
// 生成唯一 label(重名素材 @ 引用会歧义)
// eslint-disable-next-line no-loop-func
while (refs.some((r) => r.label === label)) label = `${baseLabel}${n++}`;
const local: LocalRef = {
key,
url: blobUrl,
type: check.type,
role: mode === "keyframe" ? role || "first_frame" : undefined,
label: mode === "keyframe" ? undefined : label,
duration: check.duration,
source: "upload",
uploading: true
};
setRefs((prev) => {
// keyframe:同 role 只留一张(替换)
const cleaned = mode === "keyframe" ? prev.filter((r) => r.role !== local.role) : prev;
return [...cleaned, local];
});
const form = new FormData();
form.append("file", file);
void api.uploadFreeVideoRef(form)
.then((data) => {
URL.revokeObjectURL(blobUrl);
setRefs((prev) => prev.map((r) => (r.key === key ? {
...r,
uploading: false,
url: data.url,
thumb_url: data.thumb_url || (data.type === "image" ? data.url : ""),
duration: data.duration || r.duration,
asset_id: data.asset_id
} : r)));
})
.catch((error) => {
URL.revokeObjectURL(blobUrl);
setRefs((prev) => prev.filter((r) => r.key !== key));
notify("error", error instanceof Error ? error.message : "素材上传失败");
});
}
}, [mode, refs, notify]);
const removeRef = useCallback((key: string) => {
setRefs((prev) => prev.filter((r) => r.key !== key));
}, []);
// 素材集合变化 → 编辑器清孤儿 chip
useEffect(() => {
promptRef.current?.pruneMentions(refs.map((r) => r.label || "").filter(Boolean));
}, [refs]);
// —— 提交 ——
const toPayloadRefs = (items: LocalRef[]): FreeVideoRef[] =>
items.map(({ key: _key, uploading: _uploading, ...rest }) => rest);
const doSubmit = useCallback(async (payload: {
prompt: string;
mode: FreeMode;
model: string;
aspect_ratio: string;
resolution: string;
duration: number;
seed: number;
references: FreeVideoRef[];
}) => {
const localId = `local-${Date.now()}`;
const placeholder: FreeVideoTask = {
id: localId,
status: "submitted",
mode: payload.mode,
model: payload.model,
prompt: payload.prompt,
aspect_ratio: payload.aspect_ratio,
resolution: payload.resolution,
duration: payload.duration,
seed: payload.seed,
generate_audio: true,
references: payload.references,
estimated_tokens: 0,
actual_tokens: 0,
estimated_cost: "0",
actual_cost: "0",
error_message: "",
is_favorited: false,
video_url: "",
thumbnail_url: "",
created_at: new Date().toISOString(),
completed_at: null
};
setTasks((prev) => [placeholder, ...prev]);
setSubmitting(true);
try {
const data = await api.submitFreeVideo(payload);
setTasks((prev) => prev.map((t) => (t.id === localId ? data.task : t)));
setTotal((prev) => prev + 1);
// 进度动画平移到真实任务 id
setProgress((prev) => {
const next = { ...prev, [data.task.id]: prev[localId] || 3 };
delete next[localId];
sessionStorage.setItem(PROGRESS_KEY, JSON.stringify(next));
return next;
});
if (isInFlight(data.task.status)) schedulePoll(data.task.id);
else if (data.task.status === "failed") notify("error", data.task.error_message || "创建任务失败");
return true;
} catch (error) {
setTasks((prev) => prev.filter((t) => t.id !== localId));
notify("error", error instanceof ApiError ? error.message : "提交失败,请重试");
return false;
} finally {
setSubmitting(false);
}
}, [schedulePoll, notify]);
const handleSend = useCallback(async () => {
const prompt = promptRef.current?.getText() || "";
if (!prompt.trim()) { notify("error", "请先输入提示词"); return; }
if (refs.some((r) => r.uploading)) { notify("info", "素材上传中,请稍候"); return; }
if (mode === "keyframe" && !refs.some((r) => r.role === "first_frame")) {
notify("error", "首尾帧模式需要提供首帧图片");
return;
}
if (refs.length === 0 && /@(图片|视频|音频|素材)/.test(prompt)) {
notify("error", "提示词里 @ 引用的素材为空,请补充素材或删除该引用");
return;
}
const audioCount = refs.filter((r) => r.type === "audio").length;
if (audioCount > 0 && refs.length === audioCount) {
notify("error", "音频不能单独作为参考素材,请同时提供图片或视频");
return;
}
const ok = await doSubmit({
prompt: prompt.trim(),
mode,
model,
aspect_ratio: ratio,
resolution,
duration,
seed,
references: toPayloadRefs(refs)
});
if (ok) {
promptRef.current?.clear();
setRefs([]);
}
}, [refs, mode, model, ratio, resolution, duration, seed, doSubmit, notify]);
const handleRetry = useCallback((task: FreeVideoTask) => {
void doSubmit({
prompt: task.prompt,
mode: task.mode,
model: task.model,
aspect_ratio: task.aspect_ratio,
resolution: task.resolution,
duration: task.duration,
seed: task.seed,
references: task.references
});
}, [doSubmit]);
// 再次生成:参数 + 素材 + 提示词(含 mention chip)全部回填输入条
const handleReuse = useCallback((task: FreeVideoTask) => {
setDetailId(null);
setMode(task.mode);
setModel(task.model);
setRatio(task.aspect_ratio);
setResolution(task.resolution);
setDuration(task.duration);
setSeed(task.seed ?? -1);
setRefs(task.references.map((r) => ({ ...r, key: nextRefKey() })));
window.setTimeout(() => promptRef.current?.setContent(task.prompt, task.references), 0);
notify("info", "已回填参数,可修改后重新生成");
}, [notify]);
const handleFavorite = useCallback((task: FreeVideoTask) => {
void api.toggleFreeVideoFavorite(task.id)
.then((data) => patchTask({ ...task, is_favorited: data.is_favorited }))
.catch((error) => notify("error", error instanceof Error ? error.message : "操作失败"));
}, [patchTask, notify]);
const handleDownload = useCallback(async (task: FreeVideoTask) => {
if (!task.video_url) return;
try {
const response = await fetch(task.video_url);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `airshelf-free-${task.id.slice(0, 8)}.mp4`;
a.click();
URL.revokeObjectURL(url);
} catch {
window.open(task.video_url, "_blank");
}
}, []);
const confirmDelete = useCallback(async () => {
const target = deleteTarget;
if (!target) return;
try {
await api.deleteFreeVideo(target.id);
stopPolling(target.id);
setTasks((prev) => prev.filter((t) => t.id !== target.id));
setTotal((prev) => Math.max(0, prev - 1));
if (detailId === target.id) setDetailId(null);
notify("success", "已删除");
} catch (error) {
notify("error", error instanceof Error ? error.message : "删除失败");
} finally {
setDeleteTarget(null);
}
}, [deleteTarget, detailId, stopPolling, notify]);
// 素材库选中 → 注入输入条 + 插 mention chip
const handleLibraryPick = useCallback((ref: FreeVideoRef) => {
if (mode === "keyframe") { notify("info", "首尾帧模式请直接上传图片"); return; }
let label = ref.label || "素材";
let n = 2;
while (refs.some((r) => r.label === label)) label = `${ref.label}${n++}`;
setRefs((prev) => [...prev, { ...ref, label, key: nextRefKey() }]);
window.setTimeout(() => promptRef.current?.insertMention({ label, thumb: ref.thumb_url || (ref.type === "image" ? ref.url : "") }), 0);
}, [mode, refs, notify]);
const clearInput = useCallback(() => {
promptRef.current?.clear();
setRefs([]);
}, []);
// 详情弹窗的上一条/下一条(只在已完成的任务间切换)
const doneTasks = tasks.filter((t) => t.status === "succeeded" && t.video_url);
const detailTask = detailId ? tasks.find((t) => t.id === detailId) || null : null;
const detailIndex = detailTask ? doneTasks.findIndex((t) => t.id === detailTask.id) : -1;
return (
<div className="fc-page">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono">// {total} 个视频</span> · AI 视频生成 · 全能参考 / 首尾帧
</div>
</div>
<div className="actions">
<button type="button" className="btn" onClick={() => setLibraryOpen(true)}>
<Users size={14} />
</button>
</div>
</div>
<div className="fc-feed-wrap">
{loading ? (
<div className="fc-loading mono">// 加载中…</div>
) : tasks.length === 0 ? (
<div className="empty-state show fc-empty">
<span className="ic-empty"><Users size={22} strokeWidth={1.5} /></span>
<h3></h3>
<p>// 在下方输入提示词,生成你的第一条视频</p>
</div>
) : (
<div className="fc-feed">
{tasks.map((task) => (
<GenerationCard
key={task.id}
task={task}
progress={progress[task.id] || 3}
onOpen={() => setDetailId(task.id)}
onRetry={() => handleRetry(task)}
onToggleFavorite={() => handleFavorite(task)}
onDelete={() => setDeleteTarget(task)}
onDownload={() => void handleDownload(task)}
/>
))}
</div>
)}
{hasMore && <div ref={sentinelRef} className="fc-sentinel mono">// 下滑加载更多</div>}
</div>
<FreeInputBar
mode={mode}
model={model}
ratio={ratio}
resolution={resolution}
duration={duration}
seed={seed}
refs={refs}
videoConfigs={videoConfigs}
submitting={submitting}
promptRef={promptRef}
onFiles={(files, role) => void addFiles(files, role)}
onRemoveRef={removeRef}
onModeChange={(next) => { setMode(next); setRefs([]); if (next === "keyframe") { setRatio("16:9"); } }}
onModelChange={setModel}
onRatioChange={setRatio}
onResolutionChange={setResolution}
onDurationChange={setDuration}
onSeedChange={setSeed}
onOpenLibrary={() => setLibraryOpen(true)}
onClear={clearInput}
onSend={() => void handleSend()}
/>
{detailTask && (
<VideoDetailModal
task={detailTask}
hasPrev={detailIndex > 0}
hasNext={detailIndex >= 0 && detailIndex < doneTasks.length - 1}
onPrev={() => { if (detailIndex > 0) setDetailId(doneTasks[detailIndex - 1].id); }}
onNext={() => { if (detailIndex < doneTasks.length - 1) setDetailId(doneTasks[detailIndex + 1].id); }}
onClose={() => setDetailId(null)}
onDownload={() => void handleDownload(detailTask)}
onToggleFavorite={() => handleFavorite(detailTask)}
onReuse={() => handleReuse(detailTask)}
onDelete={() => setDeleteTarget(detailTask)}
/>
)}
<AssetLibraryModal open={libraryOpen} onClose={() => setLibraryOpen(false)} onPick={handleLibraryPick} notify={notify} />
<ConfirmModal
open={deleteTarget !== null}
title="删除视频"
detail={`确定删除这条视频吗?删除后可在资产库垃圾桶找回素材,任务记录不可恢复。`}
confirmText="确认删除"
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void confirmDelete()}
/>
</div>
);
}
+1
View File
@@ -10,4 +10,5 @@ export { AccountPage } from "./account";
export { TeamPage } from "./team";
export { MessagesPage } from "./messages";
export { AssetFactoryPage, ImageWorkbenchPage, ModelPhotoDemoPage } from "./ai-tools";
export { FreeCreatePage } from "./free-create";
export { SettingsPage } from "./settings";
+5
View File
@@ -27,6 +27,7 @@ export type Page =
| "team"
| "messages"
| "assetFactory"
| "freeCreate"
| "imageOptimize"
| "modelPhoto"
| "modelPhotoDemoA"
@@ -92,6 +93,7 @@ export const routeLabels: Record<Page, string> = {
team: "团队",
messages: "消息",
assetFactory: "图片工具",
freeCreate: "自由创作",
imageOptimize: "图片创作",
modelPhoto: "模特上身图",
modelPhotoDemoA: "模特图方案 A",
@@ -151,6 +153,7 @@ export function resolveRoute(): ResolvedRoute {
if (path === "/team") return { page: "team", authMode: "login", hash };
if (path === "/messages") return { page: "messages", authMode: "login", hash };
if (path === "/asset-factory") return { page: "assetFactory", authMode: "login", hash };
if (path === "/free-create") return { page: "freeCreate", authMode: "login", hash };
if (path === "/image-optimize") return { page: "imageOptimize", authMode: "login", hash };
if (path === "/model-photo") return { page: "modelPhoto", authMode: "login", hash };
if (path === "/model-photo/demo-a") return { page: "modelPhotoDemoA", authMode: "login", hash };
@@ -190,6 +193,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
return "/messages";
case "assetFactory":
return "/asset-factory";
case "freeCreate":
return "/free-create";
case "imageOptimize":
return "/image-optimize";
case "modelPhoto":
+74
View File
@@ -534,6 +534,80 @@ export type ModelConfig = {
capability: string;
status: string;
unit_price?: string; // 单位价(每张图/每次调用),前端据此算「预估扣费」与后端实扣一致(PMC#20)
// 模型元数据:自由创作视频模型带 pricing(元/百万tokens 分档价表)/resolutions/durations,前端预估消耗读它
metadata?: Record<string, unknown>;
};
// —— 自由创作·视频生成 ——
export type FreeVideoRef = {
url: string;
type: "image" | "video" | "audio";
role?: string; // universal: reference_image/video/audio;keyframe: first_frame/last_frame
label?: string; // @mention 引用名
thumb_url?: string;
duration?: number; // 视频/音频时长(秒)
asset_id?: string; // 直传素材的 Asset id / 素材库 FreeAsset id
source?: "upload" | "library" | "library_group";
group_id?: string;
};
export type FreeVideoTask = {
id: string;
status: string; // AITask 状态机原样透传:created/reserved/submitted/polling/postprocessing/succeeded/failed/cancelled
mode: "universal" | "keyframe";
model: string;
prompt: string;
aspect_ratio: string;
resolution: string;
duration: number;
seed: number;
seed_used?: number | null;
generate_audio: boolean;
references: FreeVideoRef[];
estimated_tokens: number;
actual_tokens: number;
estimated_cost: string;
actual_cost: string;
error_message: string;
fallback_note?: string;
is_favorited: boolean;
video_url: string;
thumbnail_url: string;
created_at: string | null;
completed_at: string | null;
};
export type FreeVideoUploadResult = {
asset_id: string;
url: string;
type: "image" | "video" | "audio";
name: string;
duration: number | null;
width: number | null;
height: number | null;
thumb_url: string;
};
// 自由创作·人物素材库(火山 Assets API 引用登记)
export type FreeAssetItem = {
id: string;
name: string;
url: string;
type: "image" | "video" | "audio";
thumb_url: string;
duration: number | null;
status: "processing" | "active" | "failed";
error_message: string;
created_at: string;
};
export type FreeAssetGroup = {
id: string;
name: string;
description: string;
thumbnail_url: string;
asset_count: number;
created_at: string;
};
export type AITask = {