完成视频复刻和优化

This commit is contained in:
Azmat@qq.com
2026-08-27 11:54:47 +08:00
parent c25060c6c6
commit 51620bf25b
46 changed files with 2575 additions and 663 deletions
+9 -9
View File
@@ -162,17 +162,17 @@ export function AdminApp({ section, user, team, navigateAdmin, navigate, logout
</div>
</div>
</aside>
<header className="topbar">
<div className="admin-crumb">
<button type="button" onClick={() => navigateAdmin("")}></button>
{active.slug ? <span>{active.label}</span> : null}
</div>
<div className="right">
<span className="admin-mode"></span>
</div>
</header>
<main>
<Decorations />
<header className="topbar">
<div className="admin-crumb">
<button type="button" onClick={() => navigateAdmin("")}></button>
{active.slug ? <span>{active.label}</span> : null}
</div>
<div className="right">
<span className="admin-mode"></span>
</div>
</header>
<div className="content" id="page-content">
<CornerMarks />
{toast && <ToastLike notice={toast} />}
@@ -32,48 +32,54 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [tab, setTab] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
const load = useCallback(async () => {
setLoading(true);
setSelected(new Set());
const load = useCallback(async ({ silent = false } = {}) => {
if (!silent) setLoading(true);
try {
const res = await adminApi.assetReviews({ review_status: tab || undefined, page, page_size: PAGE_SIZE });
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
setAssets(res.results);
setCount(res.count);
} catch {
notify("error", "加载审核队列失败");
if (!silent) notify("error", "加载审核队列失败");
} finally {
setLoading(false);
if (!silent) setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, page]);
useEffect(() => { void load(); }, [load]);
function toggleOne(id: string) {
setSelected((s) => {
const next = new Set(s);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
function toggleAll() {
setSelected((s) => (s.size === assets.length ? new Set() : new Set(assets.map((a) => a.id))));
}
useEffect(() => {
let alive = true;
const tick = async () => {
if (document.hidden) return;
try {
await adminApi.pollReviews();
if (alive) await load({ silent: true });
} catch {
/* 自动送审失败不挡列表 */
}
};
void tick();
const timer = window.setInterval(tick, 12000);
return () => {
alive = false;
window.clearInterval(timer);
};
}, [load]);
async function submit(ids: string[]) {
if (busy || ids.length === 0) return;
async function retry(id: string) {
if (busy) return;
setBusy(true);
try {
const res = await adminApi.submitReviews(ids);
notify("success", `已提交 ${res.submitted} 个资产送审`);
await load();
await adminApi.submitReviews([id]);
notify("success", "已重新送审");
await load({ silent: true });
} catch {
notify("error", "送审失败");
notify("error", "重试失败");
} finally {
setBusy(false);
}
@@ -84,8 +90,17 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
setBusy(true);
try {
const res = await adminApi.pollReviews();
notify("success", res.polled > 0 ? `已刷新 ${res.polled} 个审核中资产` : "暂无审核中的资产");
await load();
const submitted = Number(res.submitted || 0);
const polled = Number(res.polled || 0);
if (submitted || polled) {
notify("success", [
submitted ? `自动送审 ${submitted}` : "",
polled ? `刷新 ${polled} 个审核中` : "",
].filter(Boolean).join(" · "));
} else {
notify("success", "暂无待处理审核");
}
await load({ silent: true });
} catch {
notify("error", "刷新失败");
} finally {
@@ -98,7 +113,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">{count} </span> · 绿 / </div>
<div className="sub"><span className="mono">{count} </span> · </div>
</div>
<div className="actions">
<button className="btn" type="button" disabled={busy} onClick={() => void poll()}>
@@ -124,7 +139,6 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
<table className="t admin-table">
<thead>
<tr>
<th className="col-check"><input type="checkbox" checked={selected.size === assets.length && assets.length > 0} onChange={toggleAll} aria-label="全选" /></th>
<th></th>
<th></th>
<th></th>
@@ -135,7 +149,6 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
<tbody>
{assets.map((a) => (
<tr key={a.id}>
<td className="col-check"><input type="checkbox" checked={selected.has(a.id)} onChange={() => toggleOne(a.id)} aria-label="选择" /></td>
<td>
{a.preview_url
? (
@@ -158,11 +171,11 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
{a.review_status === "failed" && a.review_error && <span className="admin-review-err mono" title={a.review_error}>!</span>}
</td>
<td className="col-actions">
{(a.review_status === "failed" || a.review_status === "") && (
<button className="btn btn-sm btn-ghost" type="button" disabled={busy} onClick={() => void submit([a.id])}>
{a.review_status === "failed" ? "重试" : "送审"}
{a.review_status === "failed" ? (
<button className="btn btn-sm btn-ghost" type="button" disabled={busy} onClick={() => void retry(a.id)}>
</button>
)}
) : null}
</td>
</tr>
))}
@@ -172,14 +185,6 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
</div>
)}
{selected.size > 0 && (
<div className="admin-bulk-bar" role="toolbar" aria-label="批量送审">
<span className="admin-bulk-count"> {selected.size} </span>
<button className="btn btn-sm" type="button" onClick={() => setSelected(new Set())}></button>
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void submit([...selected])}></button>
</div>
)}
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
</>
);
+4 -4
View File
@@ -11,7 +11,7 @@ import {
import type { BillingSummary, Product, Project } from "../types";
import type { NavigateFn, Page } from "./route-config";
import { ConfirmModal } from "../components/overlays";
import { isQuickCreateBusy } from "../quick-create-lock";
import { hasQuickCreateSuffix, isQuickCreateBusy } from "../quick-create-lock";
type DashTab = "all" | "wip" | "done";
type EntryTone = "primary" | "subtle";
@@ -31,7 +31,7 @@ const CREATE_GROUPS: Array<{
label: "从商品开始",
hint: "围绕商品卖点生成完整带货内容",
cards: [
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频", tone: "primary", page: "quickCreate", icon: "wand" },
{ title: "一键成片", desc: "上传商品图片,自动完成整条视频", tone: "primary", page: "quickCreate", icon: "wand" },
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成", tone: "primary", page: "projectWizard", icon: "folder" },
],
},
@@ -78,12 +78,12 @@ function dashStageLabel(project: Project): string {
function isQuickCreateProject(project: Project) {
if (project.quick_create) return true;
if (project.metadata?.quick_create) return true;
return / · 极速成片$/.test(project.name || "");
return hasQuickCreateSuffix(project.name);
}
function dashCardMeta(project: Project, productTitle: string): string {
const shots = project.video_segment_count ?? project.video_segments?.length ?? 0;
const mode = isQuickCreateBusy(project) ? "极速成片生成中" : isQuickCreateProject(project) ? "极速成片" : "专业创作";
const mode = isQuickCreateBusy(project) ? "一键成片生成中" : isQuickCreateProject(project) ? "一键成片" : "专业创作";
return [mode, productTitle, shots ? `${shots}` : null].filter(Boolean).join(" / ");
}
+2 -1
View File
@@ -14,6 +14,7 @@ import {
MAX_IMAGES,
MAX_VIDEOS,
MAX_VIDEO_TOTAL_SECONDS,
VIDEO_DURATION_SLACK,
checkRefFile,
isInFlight,
type FreeMode,
@@ -280,7 +281,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
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) {
if (check.type === "video" && videoSeconds + (check.duration || 0) > MAX_VIDEO_TOTAL_SECONDS + VIDEO_DURATION_SLACK) {
notify("error", `参考视频总时长不能超过 ${MAX_VIDEO_TOTAL_SECONDS}`);
continue;
}
+1 -1
View File
@@ -26,7 +26,7 @@ const formatPoints = (value: string) => {
};
// 模特详情弹窗:大图形象图 + 名字 + 官方模板/来源标签 + 三视图(16:9 单容器,无则占位)。
// 复用 overlays.tsx 的 .modal 家族机制(useBodyScrollLock + useOverlayTransition + createPortal)。
// 复用 overlays.tsx 的 .modal 家族机制(useBodyScrollLock + useOverlayTransition + OverlayPortal)。
function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingChanged }: {
model: ModelEntity | null;
close: () => void;
+24 -58
View File
@@ -13,23 +13,16 @@ import { DEFAULT_BILLING_RATES, estimateCost, FC_MODELS, modelLabel } from "../c
import { ModelLibrary } from "../components/model-library";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import {
allowedStructures,
clampDuration,
coercePresentationFormat,
coerceVideoStructure,
DURATION_OPTIONS,
durationWarning,
isForbidden,
PRESENTATION_FORMATS,
PRESENTATION_HINT,
PRESENTATION_KEYS,
recommendDuration,
recommendSetup,
SEGMENT_DURATION_MAX,
STRUCTURE_HINT,
TOTAL_DURATION_MIN,
VIDEO_STRUCTURES,
type PresentationFormat,
type VideoStructure,
} from "../script-setup";
import { isLocalLife } from "../product-business";
@@ -50,8 +43,7 @@ const VO_VOICES = [
{ key: "BV102_streaming", label: "儒雅青年 · 解说男声" },
{ key: "BV002_streaming", label: "通用男声" },
];
// 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_PERSONAS)
// 一期的「风格」(真实测评/痛点种草/…)已被二期的「视频结构」取代,见 script-setup.ts
const FIXED_PRESENTATION_FORMAT = "oral" as const;
const WIZ_PERSONA_LABEL: Record<string, string> = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" };
const PERSONA_KEY_BY_LABEL: Record<string, string> = {
...Object.fromEntries(Object.entries(WIZ_PERSONA_LABEL).map(([key, label]) => [label, key])),
@@ -1442,10 +1434,10 @@ export function PipelinePage(props: {
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
const [chatMode, setChatMode] = useState<"ai" | "manual" | "video">("ai");
const [chatAttachments, setChatAttachments] = useState<Array<{ name: string; chars: number }>>([]);
// ── Stage 1 · 生成前「表现形式 × 视频结构 × 人物 × 时长」设定(1.5–1.9):向导那边已删,这里补上 ──
// setupOpen:两个入口之一被选中后,展示四栏选择 + 确认/重新推荐;确认后才真正发起生成。
// ── Stage 1 · 生成前「视频结构 × 人物 × 时长」设定。表现形式固定为口播。 ──
// setupOpen:入口被选中后展示三栏设定,确认后才真正发起生成。
const SETUP_PERSONA_KEYS = Object.keys(WIZ_PERSONA_LABEL);
// 2.3 按商品品类/人群推荐一组默认值;用户随时可改,推荐只是省掉「从零开始选」
// 2.3 按商品品类推荐默认结构、人物与时长,用户仍可调整。
const setupProduct = products.find((item) => item.id === project.product);
const recommended = useMemo(
() => recommendSetup({
@@ -1457,9 +1449,6 @@ export function PipelinePage(props: {
const wizard = project.metadata?.wizard;
const [setupOpen, setSetupOpen] = useState(false);
const [setupSource, setSetupSource] = useState<"ai" | "manual" | "video">("ai");
const [setupFormat, setSetupFormat] = useState<PresentationFormat>(
coercePresentationFormat(wizard?.presentation_format, recommended.format)
);
const [setupStructure, setSetupStructure] = useState<VideoStructure>(
coerceVideoStructure(wizard?.video_structure, recommended.structure)
);
@@ -1475,18 +1464,17 @@ export function PipelinePage(props: {
// 商品是异步拉回来的,首帧 setupProduct 还是 undefined → 上面的初值只能拿到兜底组合。
// 等商品到位后补一次推荐,但只在「用户没动过 + 项目也没存过设定」时才覆盖。
const setupTouched = useRef(false);
const wizardHasCombo = Boolean(wizard?.presentation_format && wizard?.video_structure);
const wizardHasStructure = Boolean(wizard?.video_structure);
const wizardHasDuration = typeof wizard?.total_duration === "number" || Boolean(wizard?.duration);
const wizardHasPersona = Boolean(wizard?.persona);
useEffect(() => {
if (setupTouched.current) return;
if (!wizardHasCombo) {
setSetupFormat(recommended.format);
if (!wizardHasStructure) {
setSetupStructure(recommended.structure);
if (!wizardHasDuration) setSetupDuration(recommended.duration);
}
if (!wizardHasPersona) setSetupPersona(recommended.persona);
}, [recommended.format, recommended.structure, recommended.persona, recommended.duration, wizardHasCombo, wizardHasDuration, wizardHasPersona]);
}, [recommended.structure, recommended.persona, recommended.duration, wizardHasStructure, wizardHasDuration, wizardHasPersona]);
// ── 5.2 换商品重跑 ── 新建向导选的套路模板由后端回填进 metadata.wizard,这里只读不写
const templateName = typeof wizard?.template_name === "string" ? wizard.template_name : "";
@@ -1497,9 +1485,8 @@ export function PipelinePage(props: {
const [tplName, setTplName] = useState("");
const [tplSaving, setTplSaving] = useState(false);
function openSaveTemplate() {
const formatLabel = PRESENTATION_FORMATS[setupFormat];
const structureLabel = VIDEO_STRUCTURES[setupStructure];
setTplName(`${formatLabel} · ${structureLabel} · ${shots.length}`);
setTplName(`口播 · ${structureLabel} · ${shots.length}`);
setTplOpen(true);
}
async function saveTemplate() {
@@ -1517,16 +1504,10 @@ export function PipelinePage(props: {
}
}
// 1.8 组合联动:表现形式为主,视频结构按它筛;当前选中的若被筛掉就自动落到第一个合法项
const structureOptions = useMemo(() => allowedStructures(setupFormat), [setupFormat]);
function pickFormat(next: PresentationFormat) {
setupTouched.current = true;
setSetupFormat(next);
if (isForbidden(next, setupStructure)) setSetupStructure(allowedStructures(next)[0]);
}
const structureOptions = useMemo(() => Object.keys(VIDEO_STRUCTURES) as VideoStructure[], []);
const durationHint = durationWarning(setupStructure, setupDuration);
// 建议时长跟着**当前选中**的组合走,不是跟着推荐组合走(否则选了短剧还提示口播的 30 秒)
const durationSuggest = recommendDuration(setupFormat, setupStructure);
// 建议时长跟着当前选中的口播结构走。
const durationSuggest = recommendDuration(FIXED_PRESENTATION_FORMAT, setupStructure);
const chatTextareaRef = useRef<HTMLTextAreaElement | null>(null);
// 输入框随内容长高(封顶 260px 后内部滚动)。上传脚本 / 上传视频提炼灌进来的是整篇稿子,
// 固定两行的框没法逐镜校对 —— 而「可人工逐镜编辑」正是这两个入口的硬要求。
@@ -1760,7 +1741,7 @@ export function PipelinePage(props: {
aspect_ratio: "9:16",
// 设定卡参数一路传到后端:每镜固定 15 秒,总时长 15/30/45/60
total_duration: setupDuration,
presentation_format: setupFormat,
presentation_format: FIXED_PRESENTATION_FORMAT,
video_structure: setupStructure,
target_index: targetIndex,
source: source === "manual" || source === "video" ? source : undefined
@@ -1847,27 +1828,22 @@ export function PipelinePage(props: {
pushMsg("ai", summaryText || "镜头脚本已生成,左侧已刷新。可继续输入修改意见,或点「确认脚本」进入下一步。");
}
}
// 1.5 · 确认设定后真正发起生成。形式/结构/时长/人物/勾选卖点走结构化参数,不再塞一句空主题
// 确认设定后真正发起生成。表现形式固定口播,人物、结构时长勾选卖点走结构化参数。
async function runScriptWithSetup() {
const format = coercePresentationFormat(setupFormat);
const structure = coerceVideoStructure(setupStructure);
const persona = coercePersona(setupPersona);
const formatLabel = PRESENTATION_FORMATS[format];
const structureLabel = VIDEO_STRUCTURES[structure];
const personaLabel = WIZ_PERSONA_LABEL[persona] || persona;
// 持久化到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled),
// 存完再「关卡」一帧切换,不出现空窗 → 不闪回入口菜单。
await onSaveProjectMeta?.({
wizard: {
...(project.metadata?.wizard ?? {}),
presentation_format: format,
video_structure: structure,
total_duration: setupDuration,
persona,
},
});
const nextWizard = { ...(project.metadata?.wizard ?? {}) };
nextWizard.presentation_format = FIXED_PRESENTATION_FORMAT;
nextWizard.video_structure = structure;
nextWizard.total_duration = setupDuration;
nextWizard.persona = persona;
await onSaveProjectMeta?.({ wizard: nextWizard });
setSetupOpen(false);
const combo = `${formatLabel} · ${structureLabel} · ${setupDuration}s · ${personaLabel}`;
const personaLabel = WIZ_PERSONA_LABEL[persona] || persona;
const combo = `口播 · ${structureLabel} · ${setupDuration}s · ${personaLabel}`;
if (setupSource === "video") {
const base = chatText.trim();
if (!base) {
@@ -3122,7 +3098,6 @@ export function PipelinePage(props: {
<div className="script-brief-summary" aria-label="当前创作方向">
{/* 真实创作方向:来源=已有脚本的 source(无脚本时跟随所选模式),其余=设定卡确认时存进 metadata.wizard 的 */}
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-source">{currentScript ? (SOURCE_LABEL[currentScript.source || "ai"] || "脚本辅助生成") : setupOpen ? SOURCE_LABEL[setupSource] : "未选择"}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-format">{wizard?.presentation_format ? PRESENTATION_FORMATS[coercePresentationFormat(wizard.presentation_format)] : "待确认"}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-structure">{wizard?.video_structure ? VIDEO_STRUCTURES[coerceVideoStructure(wizard.video_structure)] : "待确认"}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-duration">{(() => {
// 有脚本时按各镜真实秒数加总(镜可以不等长了);没脚本就显示设定卡里选的
@@ -3302,7 +3277,7 @@ export function PipelinePage(props: {
<time className="chat-time">{msg.time}</time>
</div>
))}
{/* 1.5 · 选定生成方式后的「表现形式 × 视频结构 × 人物 × 时长」设定卡(确认后再生成) */}
{/* 选定生成方式后的「视频结构 × 人物 × 时长」设定卡(确认后再生成) */}
{setupOpen && (
<div className="chat-msg ai">
<div className="chat-bubble setup-card">
@@ -3314,14 +3289,6 @@ export function PipelinePage(props: {
沿,{setupProduct?.title || "当前商品"}
</div>
)}
<label className="setup-field">
<span className="sf-k"></span>
<select className="setup-select" value={setupFormat} onChange={(e) => pickFormat(e.target.value as PresentationFormat)}>
{PRESENTATION_KEYS.map((k) => <option key={k} value={k}>{PRESENTATION_FORMATS[k]}</option>)}
</select>
</label>
<div className="setup-rec">{PRESENTATION_HINT[setupFormat]}</div>
{/* 1.8 组合联动:短剧下拉里没有「测评验证」—— 演出来的实测没有可信度 */}
<label className="setup-field">
<span className="sf-k"></span>
<select className="setup-select" value={setupStructure} onChange={(e) => { setupTouched.current = true; setSetupStructure(e.target.value as VideoStructure); }}>
@@ -3343,14 +3310,13 @@ export function PipelinePage(props: {
{DURATION_OPTIONS.map((s) => <option key={s} value={s}>{s} </option>)}
</select>
</label>
<div className={`setup-rec${durationHint ? " warn" : ""}`}>{durationHint || `${PRESENTATION_FORMATS[setupFormat]} × ${VIDEO_STRUCTURES[setupStructure]}建议 ${durationSuggest} 秒左右`}</div>
<div className={`setup-rec${durationHint ? " warn" : ""}`}>{durationHint || `口播 × ${VIDEO_STRUCTURES[setupStructure]}建议 ${durationSuggest} 秒左右`}</div>
<div className="setup-foot">
{/* ← 返回:收起设定卡,回到「脚本辅助生成 / 上传脚本」入口页 */}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setSetupOpen(false)}> </button>
<button type="button" className="btn btn-ghost btn-sm" title={recommended.reason} onClick={() => {
// 重新推荐:回到按商品品类算出的那一组(不是随机换,随机换等于没推荐)
setupTouched.current = false;
setSetupFormat(recommended.format);
setSetupStructure(recommended.structure);
setSetupPersona(recommended.persona);
setSetupDuration(recommended.duration);
@@ -5002,7 +4968,7 @@ export function PipelinePage(props: {
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"></label>
<div className="tpl-capture">
<div className="row"><span className="k"></span><span className="v">{PRESENTATION_FORMATS[setupFormat]} · {VIDEO_STRUCTURES[setupStructure]}</span></div>
<div className="row"><span className="k"></span><span className="v"> · {VIDEO_STRUCTURES[setupStructure]}</span></div>
<div className="row"><span className="k"></span><span className="v">{shots.map((s) => s.role || "叙述").join(" → ") || "—"}</span></div>
<div className="row"><span className="k"></span><span className="v">{shots.length} · {shots.map((s) => `${s.duration_seconds}s`).join(" / ") || "—"}</span></div>
<div className="row"><span className="k"></span><span className="v">{WIZ_PERSONA_LABEL[setupPersona] || setupPersona}</span></div>
+5 -5
View File
@@ -134,7 +134,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
}
};
const [drawer, setDrawer] = useState(Boolean(autoOpenCreate));
// 创建成功弹窗:存刚创建的商品,非空即展示「继续创建 / 极速成片 / 专业创作」
// 创建成功弹窗:存刚创建的商品,非空即展示「继续创建 / 一键成片 / 专业创作」
const [createdProduct, setCreatedProduct] = useState<Product | null>(null);
const [openChip, setOpenChip] = useState<"" | "cat" | "date" | "type">("");
// 商品分类筛选改回多选:Set 存已选分类(空 = 全部),菜单含每项计数 + chip 上橙色计数徽标
@@ -361,7 +361,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
<>
<button className="btn" type="button" onClick={() => { setCreatedProduct(null); setDrawer(true); }}></button>
<button className="btn" type="button" onClick={() => { const productId = createdProduct?.id; setCreatedProduct(null); navigate("projectWizard", { productId }); }}></button>
<button className="btn btn-primary" type="button" onClick={() => { const productId = createdProduct?.id; setCreatedProduct(null); navigate("quickCreate", { productId }); }}></button>
<button className="btn btn-primary" type="button" onClick={() => { const productId = createdProduct?.id; setCreatedProduct(null); navigate("quickCreate", { productId }); }}></button>
</>
}
/>
@@ -372,7 +372,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
close={() => setDrawer(false)}
onCreate={onCreate}
onUploadImage={onUploadImage}
// 创建成功 → 弹「继续创建商品 / 极速成片 / 专业创作」选择弹窗(替代纯 toast)
// 创建成功 → 弹「继续创建商品 / 一键成片 / 专业创作」选择弹窗(替代纯 toast)
onCreated={(product) => setCreatedProduct(product)}
/>
</section>
@@ -664,7 +664,7 @@ function pdAssetTypeLabel(asset: Asset): string {
// 项目状态 → 分桶 / 友好标签 / pill 类(对齐 projects.tsx 语义,组件内自洽)
function pdProjBucket(project: Project) { return project.status === "completed" ? "done" : project.status === "failed" ? "fail" : "wip"; }
function pdProjStatusLabel(project: Project) {
if (isQuickCreateBusy(project)) return "极速成片生成中";
if (isQuickCreateBusy(project)) return "一键成片生成中";
return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record<string, string>)[project.status] || "进行中";
}
function pdProjPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "err" : "info"; }
@@ -1203,7 +1203,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<div className="qa-row-2">
<div className="qa-item primary" data-go="quick-create" role="button" tabIndex={0} onClick={() => navigate("quickCreate", { productId: product.id })}>
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m21.64 3-1.28 1.28a5.5 5.5 0 0 0-7.78 7.78l-8.5 8.5a2.12 2.12 0 0 0 3 3l8.5-8.5a5.5 5.5 0 0 0 7.78-7.78Z"/><path d="m14 7 3 3"/></svg></span>
</div>
<div className="qa-item" data-go="projects-new" role="button" tabIndex={0} onClick={() => navigate("projectWizard", { productId: product.id })}>
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M16 10l6-3v10l-6-3z" /></svg></span>
+5 -5
View File
@@ -10,7 +10,7 @@ import { isLocalLife } from "../product-business";
import { Pager } from "../components/pager";
import { SkeletonRows } from "../components/loading";
import { useViewMode } from "../components/use-view-mode";
import { isQuickCreateBusy } from "../quick-create-lock";
import { hasQuickCreateSuffix, isQuickCreateBusy } from "../quick-create-lock";
import "../project-wizard-page.css";
const PROJ_PAGE_SIZE = 8; // 4 列网格两行
@@ -436,7 +436,7 @@ const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; labe
type ProjTab = (typeof PROJ_TABS)[number]["filter"];
const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string; primary?: boolean }> = [
{ title: "极速成片", desc: "输入商品名称并上传图片,自动完成脚本、场景、故事板和视频生成。", page: "quickCreate", image: "/assets/yz/video-quick.jpg", primary: true },
{ title: "一键成片", desc: "输入商品名称并上传图片,自动完成脚本、场景、故事板和视频生成。", page: "quickCreate", image: "/assets/yz/video-quick.jpg", primary: true },
{ title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产、故事板和最终视频。", page: "projectWizard", image: "/assets/yz/video-pro.jpg", primary: true },
];
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [
@@ -448,12 +448,12 @@ const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: strin
function isQuickCreateProject(project: Project) {
if (project.quick_create) return true;
if (project.metadata?.quick_create) return true;
return / · 极速成片$/.test(project.name || "");
return hasQuickCreateSuffix(project.name);
}
function projectModeLabel(project: Project) {
if (isQuickCreateBusy(project)) return "极速成片生成中";
return isQuickCreateProject(project) ? "极速成片" : "专业创作";
if (isQuickCreateBusy(project)) return "一键成片生成中";
return isQuickCreateProject(project) ? "一键成片" : "专业创作";
}
function projCardSub(project: Project, productTitle: string): string {
+23 -20
View File
@@ -20,8 +20,8 @@ import {
ArrowUpRight,
} from "lucide-react";
import { api, ApiError, type QuickCreateJob } from "../api";
import { ConfirmModal } from "../components/overlays";
import { forgetQuickCreateJob, readQuickCreateJobId, rememberQuickCreateJob } from "../quick-create-lock";
import { ConfirmModal, OverlayPortal, useBodyScrollLock } from "../components/overlays";
import { forgetQuickCreateJob, readQuickCreateJobId, rememberQuickCreateJob, stripQuickCreateSuffix } from "../quick-create-lock";
import type { ModelConfig } from "../types";
import {
DEFAULT_BILLING_RATES,
@@ -75,7 +75,7 @@ function formatClock(seconds: number) {
}
function historyTitle(item: QuickCreateJob) {
return (item.title || item.product_name || "极速成片").replace(/ · 极速成片$/, "");
return stripQuickCreateSuffix(item.title || item.product_name || "一键成片");
}
function jobIsComplete(item: QuickCreateJob) {
@@ -140,6 +140,7 @@ export function QuickCreatePage({
const [pollEpoch, setPollEpoch] = useState(0);
const [history, setHistory] = useState<QuickCreateJob[]>([]);
const [playing, setPlaying] = useState<{ url: string; title: string } | null>(null);
useBodyScrollLock(Boolean(playing));
const videoConfigs = useMemo(
() => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"),
[modelConfigs],
@@ -261,7 +262,7 @@ export function QuickCreatePage({
if (next.status === "succeeded") {
if (watchedGeneratingRef.current && completedNoticeRef.current !== next.id) {
completedNoticeRef.current = next.id;
notifyRef.current?.("success", "极速成片已生成");
notifyRef.current?.("success", "一键成片已生成");
projectCreatedRef.current?.();
}
if (next.project_id) quickCreateStatusRef.current?.(next.project_id, next.status);
@@ -274,8 +275,8 @@ export function QuickCreatePage({
if (terminalNoticeRef.current !== next.id) {
terminalNoticeRef.current = next.id;
const message = next.status === "cancelled"
? "极速成片已取消"
: next.error_message || "极速成片未完成,已完成的步骤会保留,可重试继续";
? "一键成片已取消"
: next.error_message || "一键成片未完成,已完成的步骤会保留,可重试继续";
notifyRef.current?.(next.status === "cancelled" ? "info" : "error", message);
}
if (next.project_id) quickCreateStatusRef.current?.(next.project_id, next.status);
@@ -293,10 +294,10 @@ export function QuickCreatePage({
setJobId("");
clearDraft();
forgetQuickCreateJob();
notifyRef.current?.("info", "已清除其他账号的极速成片记录");
notifyRef.current?.("info", "已清除其他账号的一键成片记录");
return;
}
notifyRef.current?.("error", error instanceof Error ? error.message : "读取极速成片进度失败");
notifyRef.current?.("error", error instanceof Error ? error.message : "读取一键成片进度失败");
timer = window.setTimeout(poll, 8000);
}
};
@@ -369,7 +370,7 @@ export function QuickCreatePage({
if (next.status === "queued" || next.status === "running") {
onNotify?.("success", "已从上次进度继续生成");
} else if (next.status === "succeeded") {
onNotify?.("success", "极速成片已生成");
onNotify?.("success", "一键成片已生成");
} else {
onNotify?.("error", next.error_message || "继续生成失败,已完成的步骤会保留");
}
@@ -429,15 +430,15 @@ export function QuickCreatePage({
setSavedImages(created.product_images || []);
setSourceProductId(created.product_id || "");
rememberQuickCreateJob(created.id);
onNotify?.("success", "极速成片任务已启动");
onNotify?.("success", "一键成片任务已启动");
onProjectCreated?.();
} catch (error) {
if (error instanceof ApiError && error.status === 503) {
setServiceUnavailable(true);
setUnavailableMessage(error.message || "极速成片服务暂不可用,请稍后再试");
onNotify?.("info", error.message || "极速成片服务暂不可用,请稍后再试");
setUnavailableMessage(error.message || "一键成片服务暂不可用,请稍后再试");
onNotify?.("info", error.message || "一键成片服务暂不可用,请稍后再试");
} else {
onNotify?.("error", error instanceof Error ? error.message : "极速成片启动失败");
onNotify?.("error", error instanceof Error ? error.message : "一键成片启动失败");
}
} finally {
setSubmitting(false);
@@ -556,7 +557,7 @@ export function QuickCreatePage({
<header className="project-builder-header quick-create-header">
<div className="project-builder-title">
<button type="button" className="image-back-button" onClick={onBack} aria-label={backLabel}><ArrowLeft /></button>
<div><h1></h1></div>
<div><h1></h1></div>
</div>
</header>
@@ -760,7 +761,7 @@ export function QuickCreatePage({
<span className="quick-failed-icon"><AlertCircle /></span>
<h2>
{serviceUnavailable
? "极速成片暂不可用"
? "一键成片暂不可用"
: isCancelled
? "已取消本次生成"
: reviewBlocked
@@ -798,9 +799,9 @@ export function QuickCreatePage({
</section>
</div>
<section className="quick-history" aria-label="过往极速成片项目">
<section className="quick-history" aria-label="过往一键成片项目">
<div className="quick-history-head">
<h2></h2>
<h2></h2>
<span>{history.length}</span>
</div>
{history.length ? (
@@ -828,7 +829,7 @@ export function QuickCreatePage({
<div className="quick-history-copy">
<span className={`quick-history-badge${badge === "已完成" ? "" : " is-wait"}`}>{badge}</span>
<h3>{historyTitle(item)}</h3>
<p> · {scenes} · {item.result?.aspect_ratio || item.settings?.aspect_ratio || "9:16"} · {(item.result?.resolution || item.settings?.resolution || "720p").toUpperCase()} · {item.result?.video_model || item.settings?.video_model_label || "智能模型"}</p>
<p> · {scenes} · {item.result?.aspect_ratio || item.settings?.aspect_ratio || "9:16"} · {(item.result?.resolution || item.settings?.resolution || "720p").toUpperCase()} · {item.result?.video_model || item.settings?.video_model_label || "智能模型"}</p>
</div>
<button type="button" className="quick-history-open" onClick={() => openProfessional(item.project_id, item.status)}>
<ArrowUpRight />
@@ -838,7 +839,7 @@ export function QuickCreatePage({
})}
</div>
) : (
<p className="quick-history-empty"></p>
<p className="quick-history-empty"></p>
)}
</section>
@@ -846,7 +847,7 @@ export function QuickCreatePage({
open={confirmCancel}
title="确认取消生成?"
subtitle="当前任务将停止"
detail="取消后,本次极速成片不会继续生成;已完成的商品和素材会保留在专业创作项目中。"
detail="取消后,本次一键成片不会继续生成;已完成的商品和素材会保留在专业创作项目中。"
confirmText="确认取消"
icon={<AlertCircle size={16} />}
dismissable={!cancelling}
@@ -855,6 +856,7 @@ export function QuickCreatePage({
/>
{playing ? (
<OverlayPortal>
<div className="quick-player-bg" onClick={() => setPlaying(null)} role="presentation">
<div className="quick-player" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label={playing.title}>
<div className="quick-player-bar">
@@ -864,6 +866,7 @@ export function QuickCreatePage({
<video src={playing.url} controls autoPlay playsInline controlsList="nodownload" />
</div>
</div>
</OverlayPortal>
) : null}
</div>
);
+3 -3
View File
@@ -59,9 +59,9 @@ export type NavigateOptions = {
hash?: string;
// 视频项目页初始 tab(all/wip/done/fail):仅经 route state 透传给 ProjectsPage,不进 URL path。
tab?: string;
// 极速成片已结束时仍要进专业模式,跳过「生成中」锁。
// 一键成片已结束时仍要进专业模式,跳过「生成中」锁。
forcePipeline?: boolean;
// 与 forcePipeline 一起用:立刻把列表里的极速成片状态改成非生成中,避免被旧 running 弹回。
// 与 forcePipeline 一起用:立刻把列表里的一键成片状态改成非生成中,避免被旧 running 弹回。
quickCreateStatus?: string;
};
export type NavigateFn = (page: Page, options?: NavigateOptions) => void;
@@ -108,7 +108,7 @@ export const routeLabels: Record<Page, string> = {
messages: "消息",
assetFactory: "图片工具",
freeCreate: "自由创作",
quickCreate: "极速成片",
quickCreate: "一键成片",
videoRemix: "提炼提示词",
videoReplace: "视频复刻",
imageOptimize: "图片创作",
+194 -37
View File
@@ -6,6 +6,7 @@ import {
BadgeCheck,
Clock3,
Copy,
Download,
FileText,
FileVideo,
FileVideo2,
@@ -17,13 +18,14 @@ import {
ScanSearch,
TextCursorInput,
} from "lucide-react";
import { api } from "../api";
import { api, ApiError } from "../api";
import { MediaLightbox } from "../components/overlays";
import type { ModelConfig, VideoDigestHistory } from "../types";
import type { ModelConfig, VideoDigestHistory, VideoDigestJob } from "../types";
import type { NavigateFn } from "./route-config";
export const REMIX_PROMPT_KEY = "fc-remix-prompt";
const REMIX_DRAFT_KEY = "vr-digest-draft";
const JOB_KEY = "airshelf:video-remix-job";
const VIDEO_DIGEST_POINTS = 30;
type ProgressStage = "upload" | "analyze" | "prompt";
@@ -103,6 +105,34 @@ function historySummary(item: VideoDigestHistory) {
return bits.join(" · ");
}
function readJobId() {
try {
return localStorage.getItem(JOB_KEY) || "";
} catch {
return "";
}
}
function rememberJob(id: string) {
try {
localStorage.setItem(JOB_KEY, id);
} catch {
/* 无痕模式忽略 */
}
}
function forgetJob() {
try {
localStorage.removeItem(JOB_KEY);
} catch {
/* 无痕模式忽略 */
}
}
function jobIdOf(job: Pick<VideoDigestJob, "id" | "task_id">) {
return job.task_id || job.id || "";
}
export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: {
textModels?: ModelConfig[];
onNotify: (type: "success" | "error" | "info", text: string) => void;
@@ -110,7 +140,8 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
navigate: NavigateFn;
}) {
const [file, setFile] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const [jobId, setJobId] = useState("");
const [submitting, setSubmitting] = useState(false);
const [prompt, setPrompt] = useState("");
const [duration, setDuration] = useState(0);
const [shots, setShots] = useState(0);
@@ -122,11 +153,15 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
const [height, setHeight] = useState(0);
const [taskId, setTaskId] = useState("");
const [hasResult, setHasResult] = useState(false);
const [remoteVideoUrl, setRemoteVideoUrl] = useState("");
const [history, setHistory] = useState<VideoDigestHistory[]>([]);
const [openHistoryId, setOpenHistoryId] = useState("");
const [playing, setPlaying] = useState<VideoDigestHistory | null>(null);
const promptRef = useRef<HTMLTextAreaElement>(null);
const previewUrl = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]);
const completedNoticeRef = useRef("");
const blobPreviewUrl = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]);
const previewUrl = blobPreviewUrl || remoteVideoUrl;
const analyzing = submitting || Boolean(jobId);
const digestModels = useMemo(
() => textModels.filter((model) => model.status === "active" && isGemini31(model)),
@@ -145,23 +180,59 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
try {
const data = await api.listVideoDigests();
setHistory(data.results || []);
return data;
} catch {
/* 历史失败不挡当前拆解 */
return null;
}
};
const applyJobMeta = (job: VideoDigestJob) => {
if (job.duration) setDuration(job.duration);
if (job.file_name) {
setFileName(job.file_name);
setKind(fileKind(null, job.file_name));
}
if (job.ratio) setRatio(job.ratio);
if (job.width) setWidth(job.width);
if (job.height) setHeight(job.height);
if (job.video_url) setRemoteVideoUrl(job.video_url);
};
const applySucceededJob = (job: VideoDigestJob) => {
const text = (job.text || job.prompt || "").trim();
applyJobMeta(job);
setPrompt(text);
setShots(job.shots || shotCount(text, 0));
setTaskId(jobIdOf(job));
setHasResult(Boolean(text));
};
useEffect(() => {
try {
localStorage.removeItem(REMIX_DRAFT_KEY);
} catch {
/* 无痕模式忽略 */
}
void loadHistory();
let cancelled = false;
void (async () => {
const data = await loadHistory();
if (cancelled) return;
const stored = readJobId();
const inflightId = data?.inflight ? jobIdOf(data.inflight) : "";
const next = stored || inflightId;
if (!next) return;
rememberJob(next);
if (data?.inflight && jobIdOf(data.inflight) === next) applyJobMeta(data.inflight);
setJobId(next);
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
}, [previewUrl]);
if (blobPreviewUrl) URL.revokeObjectURL(blobPreviewUrl);
}, [blobPreviewUrl]);
useEffect(() => {
const el = promptRef.current;
@@ -170,8 +241,50 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
el.style.height = `${Math.max(132, el.scrollHeight)}px`;
}, [prompt, hasResult]);
useEffect(() => {
if (!jobId) return;
let cancelled = false;
let timer = 0;
const poll = async () => {
try {
const job = await api.getVideoDigest(jobId);
if (cancelled) return;
applyJobMeta(job);
if (job.status === "processing") {
timer = window.setTimeout(poll, 2500);
return;
}
if (job.status === "succeeded") {
applySucceededJob(job);
if (completedNoticeRef.current !== jobIdOf(job)) {
completedNoticeRef.current = jobIdOf(job);
onNotify("success", "视频拆解完成,已生成提示词");
}
void loadHistory();
} else {
onNotify("error", job.error_message || "视频拆解失败,请重试");
}
setJobId("");
forgetJob();
} catch (error) {
if (cancelled) return;
if (error instanceof ApiError && error.status === 404) {
setJobId("");
forgetJob();
return;
}
timer = window.setTimeout(poll, 8000);
}
};
void poll();
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [jobId, onNotify]);
const pickFile = async (next: File | null) => {
if (!next) return;
if (!next || analyzing) return;
if (!/\.(mp4|mov|m4v|webm)$/i.test(next.name)) {
onNotify("error", "只支持 mp4 / mov / m4v / webm 四种视频格式");
return;
@@ -191,33 +304,38 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
if (meta.duration) setDuration(Math.round(meta.duration));
setHasResult(false);
setTaskId("");
setRemoteVideoUrl("");
setPrompt("");
};
const analyze = async () => {
if (!file || busy) return;
setBusy(true);
if (!file || analyzing) return;
setSubmitting(true);
setHasResult(false);
setPrompt("");
try {
const fd = new FormData();
fd.append("file", file);
if (activeModel?.id) fd.append("model_config_id", activeModel.id);
const digest = await api.extractVideoDigest(fd);
const text = digest.text.trim();
setPrompt(text);
setDuration(digest.duration || duration);
setShots(digest.shots || shotCount(text, digest.frames));
setFileName(digest.file_name || file.name);
setFileSize(file.size);
if (digest.ratio) setRatio(digest.ratio);
if (digest.width) setWidth(digest.width);
if (digest.height) setHeight(digest.height);
setTaskId(digest.task_id || "");
setHasResult(true);
onNotify("success", "视频拆解完成,已生成提示词");
void loadHistory();
const job = await api.extractVideoDigest(fd);
const id = jobIdOf(job);
applyJobMeta(job);
if (job.status === "succeeded") {
applySucceededJob(job);
onNotify("success", "视频拆解完成,已生成提示词");
void loadHistory();
return;
}
if (!id) {
onNotify("error", "视频拆解失败,请重试");
return;
}
rememberJob(id);
setJobId(id);
} catch (error) {
onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
} finally {
setBusy(false);
setSubmitting(false);
}
};
@@ -242,6 +360,21 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
}
};
const downloadVideo = () => {
if (!remoteVideoUrl) {
onNotify("info", "这条没有保存原片,重新上传拆一次就能下载");
return;
}
const link = document.createElement("a");
link.href = remoteVideoUrl;
link.download = `${(fileName || "参考视频").replace(/\.[^.]+$/, "") || "参考视频"}.mp4`;
link.rel = "noopener";
document.body.appendChild(link);
link.click();
link.remove();
onNotify("success", "已开始下载参考视频");
};
const continueGenerate = () => {
const text = prompt.trim();
if (!text) return;
@@ -258,11 +391,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
}
};
const analyzeLabel = busy
const analyzeLabel = analyzing
? "正在拆解…"
: `生成这个需要 ${estimatedPoints} 积分`;
const stage: ProgressStage = busy ? "analyze" : hasResult ? "prompt" : file ? "analyze" : "upload";
const stage: ProgressStage = analyzing ? "analyze" : hasResult ? "prompt" : file || remoteVideoUrl ? "analyze" : "upload";
const panelClass = [
"video-result-panel remix-information-panel",
hasResult ? "has-result" : "",
analyzing ? "is-analyzing" : "",
].filter(Boolean).join(" ");
return (
<div className="vr-page">
@@ -303,14 +441,15 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
<strong></strong>
<span>MP4 / MOV · 60 </span>
</div>
{file && previewUrl ? (
{previewUrl ? (
<div className="video-upload-field has-file has-preview">
<video src={previewUrl} controls playsInline preload="metadata" />
<label className="remix-replace-video">
<label className={`remix-replace-video${analyzing ? " is-disabled" : ""}`}>
<input
type="file"
accept="video/mp4,video/quicktime,video/webm"
hidden
disabled={analyzing}
onChange={(event) => {
void pickFile(event.target.files?.[0] || null);
event.target.value = "";
@@ -325,6 +464,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
type="file"
accept="video/mp4,video/quicktime,video/webm"
hidden
disabled={analyzing}
onChange={(event) => {
void pickFile(event.target.files?.[0] || null);
event.target.value = "";
@@ -339,20 +479,31 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
)}
</div>
<div className="video-flow-actions remix-analyze-actions">
<button type="button" className="primary-action" disabled={!file || busy} onClick={() => void analyze()}>
<button type="button" className="primary-action" disabled={!file || analyzing} onClick={() => void analyze()}>
<ScanSearch />
<span>{analyzeLabel}</span>
</button>
</div>
</section>
<aside className={`video-result-panel remix-information-panel${hasResult ? " has-result" : ""}`} aria-live="polite">
<aside className={panelClass} aria-live="polite">
<div className="video-result-placeholder">
<div>
<span className="remix-placeholder-icon"><ScanLine /></span>
<strong></strong>
</div>
</div>
<div className="remix-generating-state" role="status">
<div className="remix-generating-content">
<div className="remix-generating-visual">
<span className="remix-generating-frame"><ScanSearch /></span>
<span className="remix-generating-badge"><FileVideo2 /></span>
</div>
<strong></strong>
<span></span>
<div className="remix-generating-bar" aria-hidden="true"><span /></div>
</div>
</div>
<div className="video-analysis-result">
<div className="remix-info-heading">
<span className="remix-status-icon"><BadgeCheck /></span>
@@ -387,13 +538,23 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
<section className={`remix-prompt-panel${hasResult ? " has-result" : ""}`} aria-live="polite">
<div className="remix-prompt-placeholder">
<span><TextCursorInput /></span>
<div><strong></strong></div>
<div><strong>{analyzing ? "正在生成提示词" : "等待生成提示词"}</strong></div>
</div>
<div className="remix-prompt-result">
<div className="remix-prompt-head">
<div className="remix-prompt-title">
<div><h2></h2></div>
</div>
<div className="remix-prompt-head-actions">
<button type="button" className="secondary-action remix-prompt-head-btn" onClick={downloadVideo} disabled={!remoteVideoUrl}>
<Download />
</button>
<button type="button" className="secondary-action remix-prompt-head-btn" onClick={() => void savePrompt()}>
<Save />
</button>
</div>
</div>
<textarea
ref={promptRef}
@@ -403,10 +564,6 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
onChange={(event) => setPrompt(event.target.value)}
/>
<div className="video-flow-actions remix-prompt-actions">
<button type="button" className="secondary-action" onClick={() => void savePrompt()}>
<Save />
</button>
<button type="button" className="primary-action" onClick={continueGenerate}>
<span></span>
<ArrowRight />
+439 -169
View File
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowUpRight,
Check,
ChevronDown,
ChevronRight,
Clapperboard,
Download,
@@ -13,10 +13,11 @@ import {
RefreshCw,
Replace,
Upload,
UserRound,
X,
} from "lucide-react";
import { api, ApiError } from "../api";
import { MediaLightbox } from "../components/overlays";
import { OverlayPortal, useBodyScrollLock } from "../components/overlays";
import {
DEFAULT_BILLING_RATES,
FC_MODELS,
@@ -27,14 +28,64 @@ import {
isInFlight,
type BillingRates,
} from "../components/free-create/constants";
import type { FreeVideoRef, FreeVideoTask, ModelConfig, Product } from "../types";
import type { FreeVideoRef, FreeVideoTask, ModelConfig, ModelEntity, Product } from "../types";
import type { NavigateFn } from "./route-config";
const JOB_KEY = "airshelf:video-replace-job";
const REMIX_MARK = "[视频复刻]";
const CHARACTER_MARK = "[视频复刻·角色]";
const VIDEO_ACCEPT = "video/mp4,video/quicktime";
type ProductSource = "library" | "temporary" | "";
type ReplaceMode = "product" | "character";
const REPLACE_MODE_COPY = {
product: {
modeLabel: "商品复刻",
targetLabel: "商品",
targetStep: "2. 选择自己的商品",
videoEmpty: "系统将自动识别需要替换的商品区域",
videoReady: "视频已就绪,将自动识别原商品区域",
libraryTitle: "从商品库选择",
libraryEmpty: "选择已创建的商品",
temporaryTitle: "临时上传商品",
temporaryEmpty: "仅用于本次任务 · 最多 9 张",
temporaryNoun: "商品图",
temporaryFallback: "临时商品素材",
generatingTitle: "正在进行商品复刻",
generatingCopy: "正在匹配商品外观与原片镜头",
resultTitle: "商品复刻已完成",
resultPreview: "商品复刻预览",
consistency: "商品一致性检查通过",
drawerTitle: "选择商品",
drawerDescription: "从已经创建的商品中选择一个用于本次视频复刻。",
drawerEmpty: "还没有商品,先去商品库创建一个",
historyKind: "商品",
pickToast: "已选择商品",
},
character: {
modeLabel: "角色复刻",
targetLabel: "角色",
targetStep: "2. 选择自己的角色",
videoEmpty: "系统将自动识别需要替换的原片角色",
videoReady: "视频已就绪,将自动识别原片角色",
libraryTitle: "从人物库选择",
libraryEmpty: "选择已创建的人物",
temporaryTitle: "临时上传角色",
temporaryEmpty: "仅用于本次任务 · 最多 9 张参考图",
temporaryNoun: "角色参考图",
temporaryFallback: "临时角色素材",
generatingTitle: "正在进行角色复刻",
generatingCopy: "正在匹配角色外观、表情与原片动作",
resultTitle: "角色复刻已完成",
resultPreview: "角色复刻预览",
consistency: "角色一致性检查通过",
drawerTitle: "选择模特",
drawerDescription: "从人物库中选择一个角色用于本次视频复刻。",
drawerEmpty: "还没有人物,先去模特库添加",
historyKind: "角色",
pickToast: "已选择角色",
},
} as const;
function readJobId() {
try {
@@ -94,27 +145,59 @@ function clampDuration(seconds: number) {
return Math.min(15, Math.max(4, rounded || 15));
}
function isRemixTask(task: FreeVideoTask) {
return (task.prompt || "").startsWith(REMIX_MARK);
function isCharacterRemix(task?: Partial<FreeVideoTask> | null) {
if (task?.replace_mode === "character") return true;
if (task?.replace_mode === "product") return false;
return (task?.prompt || "").startsWith(CHARACTER_MARK);
}
function productNameFromPrompt(prompt?: string) {
const match = (prompt || "").match(/商品:([^\n。]+)/);
function modeFromTask(task?: Partial<FreeVideoTask> | null): ReplaceMode {
return isCharacterRemix(task) ? "character" : "product";
}
function subjectNameFromTask(task?: Partial<FreeVideoTask> | null) {
const named = (task?.subject_name || "").trim();
if (named) return named;
const match = (task?.prompt || "").match(/(?:商品|角色)([^\n。]+)/);
return (match?.[1] || "").trim();
}
function remixTitle(task?: Partial<FreeVideoTask> | null) {
const name = productNameFromPrompt(task?.prompt);
return name ? `${name}视频复刻` : "视频复刻预览";
const copy = REPLACE_MODE_COPY[modeFromTask(task)];
const name = subjectNameFromTask(task);
if (name) return `${name}${copy.modeLabel}`;
return copy.resultPreview;
}
function remixSourceLabel(task?: Partial<FreeVideoTask> | null) {
const prompt = task?.prompt || "";
const name = productNameFromPrompt(prompt);
if (/商品库/.test(prompt) && name) return `商品库:${name}`;
const name = subjectNameFromTask(task);
const source = task?.subject_source;
if (modeFromTask(task) === "character") {
if ((source === "library" || /人物库/.test(prompt)) && name) return `人物库:${name}`;
return "临时角色素材";
}
if ((source === "library" || /商品库/.test(prompt)) && name) return `商品库:${name}`;
return "临时商品素材";
}
function videoRefFromTask(task?: Partial<FreeVideoTask> | null) {
return (task?.references || []).find((item) => item.type === "video") || null;
}
function imageRefsFromTask(task?: Partial<FreeVideoTask> | null) {
return (task?.references || []).filter((item) => item.type === "image" && (item.url || item.asset_id));
}
function sizeFromRatio(ratio: string) {
if (ratio === "16:9") return { width: 1280, height: 720 };
if (ratio === "1:1") return { width: 1080, height: 1080 };
if (ratio === "3:4") return { width: 834, height: 1112 };
if (ratio === "4:3") return { width: 1112, height: 834 };
if (ratio === "21:9") return { width: 1470, height: 630 };
return { width: 720, height: 1280 };
}
function productCover(product: Product) {
return product.cover_preview_url || product.images?.find((image) => image.is_primary)?.preview_url || product.images?.[0]?.preview_url || "";
}
@@ -123,9 +206,12 @@ function productImageCount(product: Product) {
return product.images?.filter((image) => image.asset || image.preview_url).length || 0;
}
function buildPrompt(productName: string, fromLibrary: boolean) {
const source = fromLibrary ? "商品库中的" : "本次上传的";
return `${REMIX_MARK} 商品:${productName}。使用${source}商品参考图,保留参考视频的人物、场景、镜头运动、剪辑节奏与口播氛围,将画面中的原商品完整替换为该商品。商品外观、材质、包装必须与参考图一致,不要改变原片构图和人物表演。`;
function modelCover(model: ModelEntity) {
return model.portrait || model.triview || "";
}
function modelImageCount(model: ModelEntity) {
return [model.portrait, model.triview].filter(Boolean).length;
}
export function VideoReplacePage({
@@ -143,22 +229,28 @@ export function VideoReplacePage({
navigate?: NavigateFn;
}) {
const [products, setProducts] = useState(initialProducts);
const [models, setModels] = useState<ModelEntity[]>([]);
const [replaceMode, setReplaceMode] = useState<ReplaceMode>("product");
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoRef, setVideoRef] = useState<FreeVideoRef | null>(null);
const [videoUploading, setVideoUploading] = useState(false);
const [videoMeta, setVideoMeta] = useState({ duration: 0, width: 0, height: 0 });
const [source, setSource] = useState<ProductSource>("");
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [selectedModel, setSelectedModel] = useState<ModelEntity | null>(null);
const [tempFiles, setTempFiles] = useState<File[]>([]);
const [tempPreviews, setTempPreviews] = useState<string[]>([]);
const [tempAssetRefs, setTempAssetRefs] = useState<FreeVideoRef[]>([]);
const [filledSubjectName, setFilledSubjectName] = useState("");
const [libraryOpen, setLibraryOpen] = useState(false);
const [pendingProductId, setPendingProductId] = useState("");
const [pendingModelId, setPendingModelId] = useState("");
const [jobId, setJobId] = useState(readJobId);
const [job, setJob] = useState<FreeVideoTask | null>(null);
const [history, setHistory] = useState<FreeVideoTask[]>([]);
const [expandedHistoryId, setExpandedHistoryId] = useState("");
const [submitting, setSubmitting] = useState(false);
const [billingRates, setBillingRates] = useState<BillingRates>(DEFAULT_BILLING_RATES);
const [playing, setPlaying] = useState<{ url: string; title: string } | null>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
const tempInputRef = useRef<HTMLInputElement>(null);
const completedNoticeRef = useRef("");
@@ -172,15 +264,33 @@ export function VideoReplacePage({
[videoConfigs],
);
const copy = REPLACE_MODE_COPY[replaceMode];
const videoReady = Boolean(videoRef?.asset_id);
const productName = source === "library"
? (selectedProduct?.title || "")
: tempFiles[0]
? (tempFiles.length > 1
? `${tempFiles[0].name.replace(/\.[^.]+$/, "") || "临时商品素材"}${tempFiles.length}张参考图)`
: (tempFiles[0].name.replace(/\.[^.]+$/, "") || "临时商品素材"))
? (replaceMode === "character" ? (selectedModel?.name || "") : (selectedProduct?.title || ""))
: source === "temporary"
? (tempFiles[0]
? (tempFiles.length > 1
? `${tempFiles[0].name.replace(/\.[^.]+$/, "") || copy.temporaryFallback}${tempFiles.length}张参考图)`
: (tempFiles[0].name.replace(/\.[^.]+$/, "") || copy.temporaryFallback))
: (filledSubjectName || copy.temporaryFallback))
: "";
const productReady = source === "library" ? Boolean(selectedProduct) : tempFiles.length > 0;
const libraryPreview = selectedProduct ? productCover(selectedProduct) : "";
const productReady = source === "library"
? (replaceMode === "character" ? Boolean(selectedModel) : Boolean(selectedProduct))
: (tempFiles.length > 0 || tempAssetRefs.length > 0);
const libraryPreview = replaceMode === "character"
? (selectedModel ? modelCover(selectedModel) : "")
: (selectedProduct ? productCover(selectedProduct) : "");
const libraryImageCount = replaceMode === "character"
? (selectedModel ? modelImageCount(selectedModel) : 0)
: (selectedProduct ? productImageCount(selectedProduct) : 0);
const tempDisplay = tempFiles.length
? tempFiles.map((file, index) => ({ key: fileKey(file), src: tempPreviews[index], name: file.name }))
: tempAssetRefs.map((item, index) => ({
key: item.asset_id || `${item.url}-${index}`,
src: item.url || item.thumb_url || "",
name: item.label || `${copy.temporaryNoun}${index + 1}`,
}));
const aspectRatio = ratioFromSize(videoMeta.width, videoMeta.height);
const outputDuration = clampDuration(videoMeta.duration || 15);
const estimated = estimateCost(preferredModel, {
@@ -189,12 +299,16 @@ export function VideoReplacePage({
duration: outputDuration,
refs: [
...(videoRef ? [{ type: "video", duration: videoRef.duration || videoMeta.duration }] : []),
...((source === "library" ? (selectedProduct?.images || []).slice(0, MAX_IMAGES) : tempFiles).map(() => ({ type: "image" }))),
...((source === "library"
? Array.from({ length: Math.max(1, libraryImageCount) }, () => ({ type: "image" as const }))
: (tempFiles.length ? tempFiles : tempAssetRefs)
).map(() => ({ type: "image" }))),
],
}, billingRates);
const points = estimated.points || 220;
const generating = Boolean(job && isInFlight(job.status)) || submitting || videoUploading;
const hasResult = Boolean(job && job.status === "succeeded" && job.video_url);
const resultCopy = hasResult && job ? REPLACE_MODE_COPY[modeFromTask(job)] : copy;
const panelClass = [
"video-result-panel replace-result-panel",
generating ? "is-generating" : "",
@@ -203,13 +317,13 @@ export function VideoReplacePage({
const generateLabel = generating
? "正在复刻…"
: hasResult
? `再次复刻 · 消耗 ${points} 积分`
: `开始复刻 · 消耗 ${points} 积分`;
? `再次${copy.modeLabel} · 消耗 ${points} 积分`
: `开始${copy.modeLabel} · 消耗 ${points} 积分`;
const loadHistory = async () => {
try {
const data = await api.freeVideoTasks(0, 50);
setHistory((data.results || []).filter((item) => isRemixTask(item) && item.status === "succeeded"));
const data = await api.videoReplaceTasks(0, 50);
setHistory((data.results || []).filter((item) => item.status === "succeeded"));
} catch {
/* 历史失败不挡当前复刻 */
}
@@ -217,6 +331,7 @@ export function VideoReplacePage({
useEffect(() => {
void api.products(100).then((payload) => setProducts(payload.results || [])).catch(() => undefined);
void api.listModels({ pageSize: 200 }).then((payload) => setModels((payload.results || []).filter((item) => !item.is_deleted))).catch(() => undefined);
void api.billingConfig()
.then((config) => setBillingRates({
margin: Number(config.video_margin_multiplier) || DEFAULT_BILLING_RATES.margin,
@@ -238,14 +353,7 @@ export function VideoReplacePage({
return () => urls.forEach((url) => URL.revokeObjectURL(url));
}, [tempFiles]);
useEffect(() => {
if (!libraryOpen) return;
const previous = document.body.classList.contains("asset-library-open");
document.body.classList.add("asset-library-open");
return () => {
if (!previous) document.body.classList.remove("asset-library-open");
};
}, [libraryOpen]);
useBodyScrollLock(libraryOpen);
useEffect(() => {
if (!jobId) return;
@@ -253,7 +361,7 @@ export function VideoReplacePage({
let timer = 0;
const poll = async () => {
try {
const data = await api.pollFreeVideo(jobId);
const data = await api.pollVideoReplace(jobId);
if (cancelled) return;
setJob(data.task);
if (isInFlight(data.task.status)) {
@@ -349,7 +457,7 @@ export function VideoReplacePage({
});
const room = Math.max(0, MAX_IMAGES - current.length);
if (room === 0) {
onNotify("info", "最多上传9张商品图片");
onNotify("info", `最多上传9张${copy.temporaryNoun}`);
return current;
}
if (!unique.length) {
@@ -361,92 +469,107 @@ export function VideoReplacePage({
});
setSource("temporary");
setSelectedProduct(null);
setSelectedModel(null);
setTempAssetRefs([]);
setFilledSubjectName("");
if (job && !isInFlight(job.status)) setJob(null);
};
const confirmLibraryProduct = () => {
const switchReplaceMode = (next: ReplaceMode) => {
if (next === replaceMode || generating) return;
setReplaceMode(next);
setSource("");
setSelectedProduct(null);
setSelectedModel(null);
setTempFiles([]);
setTempAssetRefs([]);
setFilledSubjectName("");
setPendingProductId("");
setPendingModelId("");
setLibraryOpen(false);
if (job && !isInFlight(job.status)) setJob(null);
onNotify("info", `已切换为${REPLACE_MODE_COPY[next].modeLabel}`);
};
const confirmLibrarySelection = () => {
if (replaceMode === "character") {
const model = models.find((item) => item.id === pendingModelId);
if (!model) {
onNotify("info", "请先选择一个角色");
return;
}
if (!modelCover(model)) {
onNotify("error", "这个角色还没有可用图片");
return;
}
setSelectedModel(model);
setSelectedProduct(null);
setSource("library");
setTempFiles([]);
setTempAssetRefs([]);
setFilledSubjectName("");
setLibraryOpen(false);
setPendingModelId("");
if (job && !isInFlight(job.status)) setJob(null);
onNotify("success", `${copy.pickToast}${model.name}`);
return;
}
const product = products.find((item) => item.id === pendingProductId);
if (!product) {
onNotify("info", "请先选择或上传商品素材");
return;
}
setSelectedProduct(product);
setSelectedModel(null);
setSource("library");
setTempFiles([]);
setTempAssetRefs([]);
setFilledSubjectName("");
setLibraryOpen(false);
setPendingProductId("");
if (job && !isInFlight(job.status)) setJob(null);
onNotify("success", `已选择商品${product.title}`);
onNotify("success", `${copy.pickToast}${product.title}`);
};
const startGeneration = async () => {
if (!videoFile || !productReady || generating) return;
if (!videoReady || !productReady || generating) return;
if (!preferredModel) {
onNotify("error", "暂无可用视频模型");
return;
}
if (!videoRef?.asset_id) {
onNotify("error", "请先上传参考视频");
return;
}
setSubmitting(true);
try {
let imageRefs: FreeVideoRef[] = [];
if (source === "library" && selectedProduct) {
imageRefs = (selectedProduct.images || [])
.filter((image) => image.asset || image.preview_url)
.slice(0, MAX_IMAGES)
.map((image, index) => ({
url: image.preview_url || "",
type: "image" as const,
role: "reference_image",
label: `${selectedProduct.title}${index + 1}`,
asset_id: image.asset,
source: "asset" as const,
}));
if (!imageRefs.length && (selectedProduct.cover_asset || productCover(selectedProduct))) {
imageRefs = [{
url: productCover(selectedProduct),
type: "image",
role: "reference_image",
label: selectedProduct.title,
asset_id: selectedProduct.cover_asset || undefined,
source: selectedProduct.cover_asset ? "asset" : "upload",
}];
let imageAssetIds: string[] = [];
if (source === "temporary") {
if (tempFiles.length) {
for (const file of tempFiles.slice(0, MAX_IMAGES)) {
const form = new FormData();
form.append("file", file);
const data = await api.uploadFreeVideoRef(form);
if (data.asset_id) imageAssetIds.push(data.asset_id);
}
} else {
imageAssetIds = tempAssetRefs.map((item) => item.asset_id || "").filter(Boolean);
}
if (!imageRefs.length) {
onNotify("error", "这个商品还没有可用图片");
if (!imageAssetIds.length) {
onNotify("error", replaceMode === "character" ? "请上传角色参考图" : "请上传商品参考图");
return;
}
} else {
const uploaded: FreeVideoRef[] = [];
for (const file of tempFiles.slice(0, MAX_IMAGES)) {
const form = new FormData();
form.append("file", file);
const data = await api.uploadFreeVideoRef(form);
uploaded.push({
url: data.url,
type: "image",
role: "reference_image",
label: data.name || file.name,
thumb_url: data.thumb_url || data.url,
asset_id: data.asset_id,
source: "upload",
});
}
imageRefs = uploaded;
}
if (!videoRef) {
onNotify("error", "请先上传参考视频");
return;
}
const prompt = buildPrompt(productName.replace(/\d+张参考图)$/, ""), source === "library");
const data = await api.submitFreeVideo({
prompt,
mode: "universal",
const data = await api.submitVideoReplace({
replace_mode: replaceMode,
video_asset_id: videoRef.asset_id,
product_id: source === "library" && replaceMode === "product" ? selectedProduct?.id : undefined,
model_id: source === "library" && replaceMode === "character" ? selectedModel?.id : undefined,
image_asset_ids: source === "temporary" ? imageAssetIds : undefined,
model: preferredModel.name,
aspect_ratio: aspectRatio,
resolution: "720p",
duration: outputDuration,
seed: -1,
generate_audio: true,
references: [videoRef, ...imageRefs],
});
setJob(data.task);
setJobId(data.task.id);
@@ -465,6 +588,68 @@ export function VideoReplacePage({
}
};
const fillFormFromTask = (task: FreeVideoTask) => {
if (generating) return;
const mode = modeFromTask(task);
const video = videoRefFromTask(task);
if (!video?.asset_id) {
onNotify("error", "这条记录没有可用的参考视频,请重新上传");
return;
}
const images = imageRefsFromTask(task);
const subject = subjectNameFromTask(task);
setReplaceMode(mode);
setVideoFile(null);
setVideoRef({
...video,
type: "video",
role: "reference_video",
label: video.label || "参考视频",
});
setVideoMeta({
duration: Number(video.duration || task.duration || 0),
...sizeFromRatio(task.aspect_ratio || "9:16"),
});
setFilledSubjectName(subject);
if (mode === "character") {
const model = models.find((item) => item.id === task.model_id)
|| models.find((item) => item.name === subject);
if (model) {
setSelectedModel(model);
setSelectedProduct(null);
setSource("library");
setTempFiles([]);
setTempAssetRefs([]);
} else {
setSelectedModel(null);
setSelectedProduct(null);
setSource(images.length ? "temporary" : "");
setTempFiles([]);
setTempAssetRefs(images);
if (!model && task.subject_source === "library") onNotify("info", "原角色已不在人物库,请重新选择");
}
} else {
const product = products.find((item) => item.id === task.product_id)
|| products.find((item) => item.title === subject);
if (product) {
setSelectedProduct(product);
setSelectedModel(null);
setSource("library");
setTempFiles([]);
setTempAssetRefs([]);
} else {
setSelectedProduct(null);
setSelectedModel(null);
setSource(images.length ? "temporary" : "");
setTempFiles([]);
setTempAssetRefs(images);
if (!product && task.subject_source === "library") onNotify("info", "原商品已不在商品库,请重新选择");
}
}
onNotify("success", "已填入上次素材,确认后可再次生成");
document.querySelector(".replace-flow-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const downloadVideo = (url: string, title: string) => {
if (!url) return;
const link = document.createElement("a");
@@ -477,13 +662,11 @@ export function VideoReplacePage({
onNotify("success", "已开始下载视频复刻成片");
};
const openHistory = (item: FreeVideoTask) => {
setJob(item);
setJobId("");
forgetJob();
const toggleHistory = (id: string) => {
setExpandedHistoryId((current) => (current === id ? "" : id));
};
const cells = Array.from({ length: 9 }, (_, index) => tempFiles[index] || null);
const cells = Array.from({ length: 9 }, (_, index) => tempDisplay[index] || null);
return (
<div className="vrep-page">
@@ -503,12 +686,38 @@ export function VideoReplacePage({
<div className="video-flow-grid">
<section className="video-flow-panel replace-flow-panel">
<h2></h2>
<div className="replace-mode-switch" role="tablist" aria-label="选择视频复刻功能">
<button
type="button"
className={`replace-mode-button${replaceMode === "product" ? " active" : ""}`}
data-replace-mode="product"
role="tab"
aria-selected={replaceMode === "product"}
disabled={generating}
onClick={() => switchReplaceMode("product")}
>
<Package />
<span></span>
</button>
<button
type="button"
className={`replace-mode-button${replaceMode === "character" ? " active" : ""}`}
data-replace-mode="character"
role="tab"
aria-selected={replaceMode === "character"}
disabled={generating}
onClick={() => switchReplaceMode("character")}
>
<UserRound />
<span></span>
</button>
</div>
<div className="video-flow-step">
<div className="video-flow-step-head">
<strong>1. </strong>
<span>MP4 / MOV · 60 </span>
<span>MP4 / MOV · 15 </span>
</div>
<label className={`video-upload-field${videoFile ? " has-file" : ""}`}>
<label className={`video-upload-field${videoReady ? " has-file" : ""}`}>
<input
ref={videoInputRef}
type="file"
@@ -521,15 +730,15 @@ export function VideoReplacePage({
/>
<span>
<FileVideo2 />
<strong>{videoFile ? videoFile.name : "点击上传参考视频"}</strong>
<small>{videoFile ? "视频已就绪,将自动识别原商品区域" : "系统将自动识别需要替换的商品区域"}</small>
<strong>{videoFile?.name || (videoReady ? "参考视频已就绪" : "点击上传参考视频")}</strong>
<small>{videoReady ? copy.videoReady : copy.videoEmpty}</small>
</span>
</label>
</div>
<div className="video-flow-step">
<div className="video-flow-step-head">
<strong>2. </strong>
<strong>{copy.targetStep}</strong>
<span></span>
</div>
<div className="product-replace-options">
@@ -537,46 +746,65 @@ export function VideoReplacePage({
type="button"
className={`replace-product-method${source === "library" ? " active" : ""}${libraryPreview ? " has-product-preview" : ""}`}
onClick={() => {
setPendingProductId(selectedProduct?.id || "");
if (replaceMode === "character") setPendingModelId(selectedModel?.id || "");
else setPendingProductId(selectedProduct?.id || "");
setLibraryOpen(true);
}}
>
{libraryPreview ? <img className="replace-product-method-background" src={libraryPreview} alt="" aria-hidden="true" /> : <img className="replace-product-method-background" alt="" aria-hidden="true" />}
<span className="replace-product-method-icon"><LibraryBig /></span>
<span className="replace-product-method-copy">
<strong></strong>
<small>{source === "library" && selectedProduct ? `已选择 · ${selectedProduct.title}` : "选择已创建的商品"}</small>
<strong>{copy.libraryTitle}</strong>
<small>
{source === "library" && (replaceMode === "character" ? selectedModel : selectedProduct)
? `已选择 · ${replaceMode === "character" ? selectedModel?.name : selectedProduct?.title}`
: copy.libraryEmpty}
</small>
</span>
<ChevronRight />
</button>
<label
className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempFiles.length ? " has-images" : ""}`}
<div
className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempDisplay.length ? " has-images" : ""}`}
role="button"
tabIndex={0}
onClick={() => tempInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
tempInputRef.current?.click();
}
}}
>
<img className="replace-product-method-background" alt="" aria-hidden="true" />
<span className="replace-product-method-icon"><Upload /></span>
<span className="replace-product-method-copy">
<strong></strong>
<small>{tempFiles.length ? `已上传 ${tempFiles.length}商品图` : "仅用于本次任务 · 最多 9 张"}</small>
<strong>{copy.temporaryTitle}</strong>
<small>{tempDisplay.length ? `已上传 ${tempDisplay.length}${copy.temporaryNoun}` : copy.temporaryEmpty}</small>
</span>
<ImagePlus />
{tempDisplay.length ? (
<span className="replace-temporary-preview">
<span className="replace-temporary-grid" aria-label="临时上传的商品图片">
{cells.map((file, index) => (
<span className={`replace-temporary-cell${file ? "" : " empty"}`} key={`temp-${index}`}>
{file ? (
<span className="replace-temporary-grid" aria-label={`临时上传的${copy.temporaryNoun}`}>
{cells.map((item, index) => (
<span className={`replace-temporary-cell${item ? "" : " empty"}`} key={item?.key || `temp-${index}`}>
{item ? (
<>
<img src={tempPreviews[index]} alt={file.name} />
<img src={item.src} alt={item.name} />
<button
type="button"
className="replace-temporary-remove"
aria-label={`删除第${index + 1}张临时商品图片`}
aria-label={`删除第${index + 1}张临时${copy.temporaryNoun}`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setTempFiles((current) => current.filter((_, itemIndex) => itemIndex !== index));
if (tempFiles.length) {
setTempFiles((current) => current.filter((_, itemIndex) => itemIndex !== index));
} else {
setTempAssetRefs((current) => current.filter((_, itemIndex) => itemIndex !== index));
}
if (job && !isInFlight(job.status)) setJob(null);
onNotify("info", "已删除临时商品图片");
onNotify("info", `已删除临时${copy.temporaryNoun}`);
}}
>
×
@@ -589,35 +817,40 @@ export function VideoReplacePage({
<span className="replace-temporary-more">
<span><ImagePlus /></span>
<strong></strong>
<span className="replace-temporary-count"> {tempFiles.length} / 9</span>
<span className="replace-temporary-count"> {tempDisplay.length} / 9</span>
<button
type="button"
className="replace-temporary-clear"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (!tempFiles.length && !tempAssetRefs.length) return;
setTempFiles([]);
setTempAssetRefs([]);
setFilledSubjectName("");
if (source === "temporary") setSource("");
if (job && !isInFlight(job.status)) setJob(null);
onNotify("info", "已清空临时商品图片");
onNotify("info", `已清空临时${copy.temporaryNoun}`);
}}
>
</button>
</span>
</span>
) : null}
<input
ref={tempInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
hidden
onClick={(event) => event.stopPropagation()}
onChange={(event) => {
addTempImages(event.target.files);
event.target.value = "";
}}
/>
</label>
</div>
</div>
</div>
@@ -625,7 +858,7 @@ export function VideoReplacePage({
<button
type="button"
className="primary-action"
disabled={!videoFile || !productReady || generating}
disabled={!videoReady || !productReady || generating}
onClick={() => void startGeneration()}
>
<Replace />
@@ -647,28 +880,30 @@ export function VideoReplacePage({
<div className="replace-generating-content">
<div className="replace-generating-visual">
<span className="replace-generating-frame"><Clapperboard /></span>
<span className="replace-generating-product"><Package /></span>
<span className="replace-generating-product">{replaceMode === "character" ? <UserRound /> : <Package />}</span>
</div>
<strong></strong>
<span></span>
<strong>{copy.generatingTitle}</strong>
<span>{copy.generatingCopy}</span>
<div className="replace-generating-bar" aria-hidden="true"><span /></div>
</div>
</div>
<div className="video-analysis-result">
<h2></h2>
<h2>{resultCopy.resultTitle}</h2>
<div className="replace-preview">
{job?.video_url ? <video src={job.video_url} poster={job.thumbnail_url || undefined} muted playsInline /> : null}
<div className="replace-preview-copy">
<strong>{productName ? `${productName}视频复刻预览` : remixTitle(job)}</strong>
<span>{outputDuration} · {ratioCopy(job?.aspect_ratio || aspectRatio)} · </span>
</div>
{job?.video_url ? (
<video src={job.video_url} poster={job.thumbnail_url || undefined} controls playsInline />
) : null}
</div>
<div className="replace-preview-meta">
<strong>{productName ? `${productName}${resultCopy.modeLabel}` : remixTitle(job)}</strong>
<span>{(job?.duration || outputDuration)} · {ratioCopy(job?.aspect_ratio || aspectRatio)} · {job?.resolution || "720p"}</span>
</div>
<div className="video-flow-actions replace-result-actions">
<button type="button" className="secondary-action" onClick={() => void startGeneration()}>
<button type="button" className="secondary-action" onClick={() => job && fillFormFromTask(job)}>
<RefreshCw />
</button>
<button type="button" className="primary-action" onClick={() => downloadVideo(job?.video_url || "", productName || "视频复刻")}>
<button type="button" className="primary-action" onClick={() => downloadVideo(job?.video_url || "", productName || remixTitle(job) || "视频复刻")}>
<Download />
</button>
@@ -686,50 +921,92 @@ export function VideoReplacePage({
<div className="replace-history-empty"></div>
) : (
<div className="replace-history-list">
{history.map((item) => (
<article className="replace-history-card" key={item.id}>
<div
className="replace-history-cover"
onClick={() => {
if (!item.video_url) return;
setPlaying({ url: item.video_url, title: remixTitle(item) });
}}
>
{item.thumbnail_url ? <img src={item.thumbnail_url} alt={`${remixTitle(item)}封面`} /> : null}
<span>{formatClock(item.duration)}</span>
</div>
<div className="replace-history-copy">
<span></span>
<h3>{remixTitle(item)}</h3>
<p> {item.duration} · {remixSourceLabel(item)} · {item.aspect_ratio} · {item.resolution}</p>
</div>
<button type="button" className="replace-history-open" onClick={() => openHistory(item)}>
<ArrowUpRight />
</button>
</article>
))}
{history.map((item) => {
const open = expandedHistoryId === item.id;
const sourceVideo = videoRefFromTask(item);
return (
<article className={`replace-history-card${open ? " is-open" : ""}`} key={item.id}>
<button
type="button"
className="replace-history-summary"
aria-expanded={open}
onClick={() => toggleHistory(item.id)}
>
<span className="replace-history-cover">
{item.thumbnail_url ? <img src={item.thumbnail_url} alt="" /> : null}
<span>{formatClock(item.duration)}</span>
</span>
<span className="replace-history-copy">
<span> · {REPLACE_MODE_COPY[modeFromTask(item)].historyKind}</span>
<strong>{remixTitle(item)}</strong>
<small> {item.duration} · {remixSourceLabel(item)} · {item.resolution}</small>
</span>
<span className="replace-history-toggle">
<ChevronDown />
{open ? "收起" : "展开对比"}
</span>
</button>
<div className="replace-history-compare" hidden={!open}>
<div className="replace-history-compare-pane">
<span></span>
{sourceVideo?.url ? (
<video src={sourceVideo.url} poster={sourceVideo.thumb_url || undefined} controls playsInline preload="metadata" />
) : (
<div className="replace-history-compare-empty"></div>
)}
</div>
<div className="replace-history-compare-pane">
<span></span>
{item.video_url ? (
<video src={item.video_url} poster={item.thumbnail_url || undefined} controls playsInline preload="metadata" />
) : (
<div className="replace-history-compare-empty"></div>
)}
</div>
</div>
</article>
);
})}
</div>
)}
</section>
</section>
</div>
<OverlayPortal>
<div className={`asset-library-layer${libraryOpen ? " open" : ""}`} aria-hidden={!libraryOpen}>
<button type="button" className="asset-library-scrim" aria-label="关闭资产库" onClick={() => setLibraryOpen(false)} />
<aside className="asset-library-drawer" role="dialog" aria-modal="true" aria-labelledby="assetLibraryTitle">
<header className="asset-library-head">
<div>
<h2 id="assetLibraryTitle"></h2>
<p></p>
<h2 id="assetLibraryTitle">{copy.drawerTitle}</h2>
<p>{copy.drawerDescription}</p>
</div>
<button type="button" className="product-drawer-close" aria-label="关闭" onClick={() => setLibraryOpen(false)}>
<X />
</button>
</header>
<div className="asset-library-grid">
{products.length === 0 ? (
<div className="asset-library-empty"></div>
{replaceMode === "character" ? (
models.length === 0 ? (
<div className="asset-library-empty">{copy.drawerEmpty}</div>
) : models.map((model) => (
<button
type="button"
className={`asset-library-choice${pendingModelId === model.id ? " selected" : ""}`}
key={model.id}
onClick={() => setPendingModelId(model.id)}
>
<span className="asset-choice-check"><Check /></span>
{modelCover(model) ? <img src={modelCover(model)} alt={model.name} /> : <img alt={model.name} />}
<span>
<strong>{model.name}</strong>
<small>{model.is_official ? "官方模板" : "我的模特"} · {modelImageCount(model)} </small>
</span>
</button>
))
) : products.length === 0 ? (
<div className="asset-library-empty">{copy.drawerEmpty}</div>
) : products.map((product) => (
<button
type="button"
@@ -748,21 +1025,14 @@ export function VideoReplacePage({
</div>
<footer className="asset-library-footer">
<button type="button" className="secondary-action" onClick={() => setLibraryOpen(false)}></button>
<button type="button" className="primary-action" onClick={confirmLibraryProduct}>
<button type="button" className="primary-action" onClick={confirmLibrarySelection}>
<Check />
<span>使</span>
</button>
</footer>
</aside>
</div>
<MediaLightbox
open={Boolean(playing?.url)}
src={playing?.url || ""}
kind="video"
name={playing?.title}
close={() => setPlaying(null)}
/>
</OverlayPortal>
</div>
);
}