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