完成视频复刻和优化

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
+22 -22
View File
@@ -424,7 +424,7 @@ export function App() {
const locked = lockedQuickCreateProject(listed, detailed);
if (!locked) return;
rememberQuickCreateJob(locked.quick_create_job_id);
setNotice({ type: "info", text: "该项目正在极速成片中,请在极速成片页查看进度" });
setNotice({ type: "info", text: "该项目正在一键成片中,请在一键成片页查看进度" });
navigate("quickCreate", { productId: locked.product, replace: true });
}, [authed, page, activeProjectId, projects, projectDetail]);
@@ -554,12 +554,12 @@ export function App() {
const locked = canForce ? null : lockedQuickCreateProject(listed, detailed);
if (locked) {
rememberQuickCreateJob(locked.quick_create_job_id);
setNotice({ type: "info", text: "该项目正在极速成片中,请在极速成片页查看进度" });
setNotice({ type: "info", text: "该项目正在一键成片中,请在一键成片页查看进度" });
next = "quickCreate";
options = { ...options, productId: locked.product, replace: options.replace };
}
}
// 图片创作 / 极速成片只有显式入口才携带商品;从工作台或视频创作进入时不能继承全局当前商品。
// 图片创作 / 一键成片只有显式入口才携带商品;从工作台或视频创作进入时不能继承全局当前商品。
const productId = next === "imageOptimize" || next === "quickCreate" ? options.productId : (options.productId ?? activeProductId);
const projectId = options.projectId ?? activeProjectId;
if (options.productId !== undefined) setActiveProductId(options.productId);
@@ -951,7 +951,7 @@ export function App() {
);
case "productCreateUpload":
// 设计稿:新建商品是商品库页上的右侧 Drawer(非独立整页),进入即自动打开
// 创建成功后由 ProductsPage 弹「继续创建商品 / 极速成片 / 专业创作」选择弹窗,不再自动跳详情
// 创建成功后由 ProductsPage 弹「继续创建商品 / 一键成片 / 专业创作」选择弹窗,不再自动跳详情
return (
<ProductsPage
products={products}
@@ -1284,26 +1284,26 @@ export function App() {
return (
<div className="app">
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} canManageBilling={isOwner} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} aiUnread={aiUnread} onOpenAdmin={() => navigateAdmin("")} accountOpen={accountAnchor !== null} onOpenAccount={(rect) => setAccountAnchor((prev) => (prev ? null : rect))} />
<header className="topbar">
<ModeTabs active={topModule} navigate={navigate} />
<div className="right">
<button className="top-search" type="button" onClick={() => openCommandPalette()} aria-label="搜索">
<IconKitSvg name="search" />
<span></span>
<span className="kbd">{searchKbd}</span>
</button>
<span className="balance-chip" onClick={() => navigate("account")}>
<IconKitSvg name="creditCard" />
<strong>{money(billing?.account.balance)}</strong>
</span>
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
<IconKitSvg name="bell" />
{unreadCount > 0 && <span className="count-noti">{unreadCount}</span>}
</button>
</div>
</header>
<main>
<Decorations />
<header className="topbar">
<ModeTabs active={topModule} navigate={navigate} />
<div className="right">
<button className="top-search" type="button" onClick={() => openCommandPalette()} aria-label="搜索">
<IconKitSvg name="search" />
<span></span>
<span className="kbd">{searchKbd}</span>
</button>
<span className="balance-chip" onClick={() => navigate("account")}>
<IconKitSvg name="creditCard" />
<strong>{money(billing?.account.balance)}</strong>
</span>
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
<IconKitSvg name="bell" />
{unreadCount > 0 && <span className="count-noti">{unreadCount}</span>}
</button>
</div>
</header>
<div className="content" id="page-content" key={page}>
<CornerMarks />
{notice && <ToastLike notice={notice} />}
+3 -3
View File
@@ -15,7 +15,7 @@
.admin-app .sidebar .nav-panel {
display: flex;
flex-direction: column;
height: calc(100vh - 116px);
height: calc(100vh - var(--topbar-height));
min-height: 0;
padding: 10px 0 0;
overflow: hidden;
@@ -161,8 +161,8 @@
.admin-logout:hover { color: var(--st-text); background: var(--st-hover); }
.admin-app .topbar {
height: 116px;
min-height: 116px;
height: var(--topbar-height);
min-height: var(--topbar-height);
padding: 0 28px;
background: #fff;
border-bottom: 1px solid rgba(27, 32, 40, 0.08);
+29 -21
View File
@@ -44,6 +44,7 @@ import type {
User,
UserPreference,
VideoDigestHistory,
VideoDigestJob,
VoiceoverInfo
} from "./types";
import type { PresentationFormat, VideoStructure } from "./script-setup";
@@ -411,32 +412,18 @@ export const api = {
{ method: "POST", body: formData }
);
},
// 视频提炼页:不绑项目,整段视频交给 Gemini 出中文分镜稿。慢(约 1–2 分钟),固定 30 积分,失败退还
// 视频提炼页:不绑项目。POST 秒回任务,worker 抽帧 + Gemini;离开页面也不中断
extractVideoDigest(formData: FormData) {
return request<{
name: string;
chars: number;
text: string;
frames: number;
duration: number;
input?: string;
estimated_cost?: string;
task_id?: string;
title?: string;
file_name?: string;
ratio?: string;
shots?: number;
cover_url?: string;
video_url?: string;
width?: number;
height?: number;
}>(
return request<VideoDigestJob>(
"/api/ai/video-digest/",
{ method: "POST", body: formData }
);
},
listVideoDigests() {
return request<{ results: VideoDigestHistory[]; total: number }>("/api/ai/video-digest/");
return request<{ results: VideoDigestHistory[]; total: number; inflight?: VideoDigestJob | null }>("/api/ai/video-digest/");
},
getVideoDigest(id: string) {
return request<VideoDigestJob>(`/api/ai/video-digest/${id}/`);
},
saveVideoDigest(id: string, prompt: string) {
return request<VideoDigestHistory>(`/api/ai/video-digest/${id}/`, {
@@ -915,6 +902,27 @@ export const api = {
}) {
return request<{ task: FreeVideoTask }>("/api/ai/free-video/", { method: "POST", body: JSON.stringify(payload) });
},
submitVideoReplace(payload: {
replace_mode: "product" | "character";
video_asset_id: string;
product_id?: string;
model_id?: string;
image_asset_ids?: string[];
model?: string;
aspect_ratio?: string;
resolution?: string;
duration?: number;
}) {
return request<{ task: FreeVideoTask }>("/api/ai/video-replace/", { method: "POST", body: JSON.stringify(payload) });
},
videoReplaceTasks(offset = 0, pageSize = 20) {
return request<{ results: FreeVideoTask[]; total: number; has_more: boolean }>(
`/api/ai/video-replace/?offset=${offset}&page_size=${pageSize}`
);
},
pollVideoReplace(id: string) {
return request<{ task: FreeVideoTask }>(`/api/ai/video-replace/${id}/poll/`, { method: "POST" });
},
freeVideoTasks(offset = 0, pageSize = 20) {
return request<{ results: FreeVideoTask[]; total: number; has_more: boolean }>(
`/api/ai/free-video/?offset=${offset}&page_size=${pageSize}`
@@ -1107,7 +1115,7 @@ export const adminApi = {
);
},
pollReviews(assetIds?: string[]) {
return request<{ polled: number; statuses: Record<string, string> }>(
return request<{ polled: number; submitted?: number; statuses: Record<string, string> }>(
"/api/admin/asset-reviews/poll/",
{ method: "POST", body: JSON.stringify(assetIds ? { asset_ids: assetIds } : {}) }
);
+2 -2
View File
@@ -16,7 +16,7 @@ const SHELL_COMMANDS: Command[] = [
{ 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: "quick-create", group: "导航", label: "极速成片", sub: "上传商品图片,自动完成整条视频", page: "quickCreate", icon: "wand", key: "Q" },
{ id: "quick-create", group: "导航", label: "一键成片", sub: "上传商品图片,自动完成整条视频", page: "quickCreate", icon: "wand", key: "Q" },
{ id: "video-remix", group: "导航", label: "提炼提示词", sub: "上传参考视频,提炼可编辑提示词", page: "videoRemix", icon: "scan", key: "R" },
{ id: "video-replace", group: "导航", label: "视频复刻", sub: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容", page: "videoReplace", icon: "replace", key: "E" },
{ id: "library", group: "导航", label: "成品库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
@@ -26,7 +26,7 @@ const SHELL_COMMANDS: Command[] = [
{ id: "messages", group: "常用动作", label: "消息中心", sub: "任务提醒、协作评论、系统通知", page: "messages", icon: "bell", key: "M" },
{ id: "new-product", group: "常用动作", label: "新建商品", sub: "从商品信息开始生成素材与视频", page: "productCreateUpload", icon: "productPlus" },
{ id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" },
{ id: "quick-create-action", group: "常用动作", label: "极速成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" },
{ id: "quick-create-action", group: "常用动作", label: "一键成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" },
{ id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" },
{ id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "适配电商平台封面与详情图", page: "platformCover", icon: "images" },
{ id: "image-optimize", group: "常用动作", label: "图片创作", sub: "对话式生成、编辑", page: "imageOptimize", icon: "images" }
@@ -33,6 +33,7 @@ export const MAX_IMAGES = 9;
export const MAX_VIDEOS = 3;
export const MAX_AUDIOS = 3;
export const MAX_VIDEO_TOTAL_SECONDS = 15;
export const VIDEO_DURATION_SLACK = 0.5;
// 任务在途状态(继续轮询);终态 = succeeded / failed / cancelled / compensating
export const IN_FLIGHT_STATUSES = ["created", "reserved", "submitted", "polling", "postprocessing"];
@@ -145,8 +146,9 @@ function probeMedia(file: File, kind: "video" | "audio"): Promise<FileCheck> {
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 });
else if (duration < 2 - 0.05 || duration > MAX_VIDEO_TOTAL_SECONDS + VIDEO_DURATION_SLACK) {
resolve({ ok: false, error: `${kind === "video" ? "视频" : "音频"}时长需在 2-15 秒之间` });
} else resolve({ ok: true, type: kind, duration: Math.min(MAX_VIDEO_TOTAL_SECONDS, Math.round(duration * 10) / 10) });
};
el.onerror = () => { URL.revokeObjectURL(url); resolve({ ok: false, error: "媒体文件解析失败,请更换文件" }); };
el.src = url;
+9 -4
View File
@@ -2,6 +2,12 @@ import { useEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { CheckCircle2, Inbox, Music, Shield, Trash2, X } from "lucide-react";
// 抽屉 / 弹窗 / 全屏播放器必须挂到 document.body。
// 写在页面树里会被顶栏(sticky + z-index)盖住:搜索、余额、铃铛会浮在抽屉上。
export function OverlayPortal({ children }: { children: ReactNode }) {
return createPortal(children, document.body);
}
// 浮层打开时锁住 body 滚动(否则滚轮会滚动遮罩后面的页面,体感"遮罩没盖住")。
// 多浮层叠开用计数,最后一个关闭才解锁。
let scrollLockCount = 0;
@@ -64,7 +70,7 @@ export function MediaLightbox({ open, src, kind, name, close }: {
return () => document.removeEventListener("keydown", onKey);
}, [open, close]);
if (!open || !src) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
// 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏
return createPortal(
<div className="np-lightbox show" onClick={close}>
<button className="lb-x" type="button" aria-label="关闭" onClick={close}><X /></button>
@@ -114,7 +120,7 @@ export function TeamModal({ open, title, subtitle = "", icon, close, children, f
useBodyScrollLock(open);
const { mounted, show } = useOverlayTransition(open, close);
if (!mounted) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
// 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏
return createPortal(
<div className={`modal-bg${show ? " show" : ""}`} onClick={dismissable ? close : undefined}>
<div className="modal invite-modal" onClick={(event) => event.stopPropagation()}>
@@ -143,7 +149,7 @@ export function ConfirmModal({ open, title, detail, confirmText, subtitle = "",
useBodyScrollLock(open);
const { mounted, show } = useOverlayTransition(open, onCancel);
if (!mounted) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
// 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏
return createPortal(
<div className={`modal-bg${show ? " show" : ""}`} onClick={dismissable ? onCancel : undefined}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
@@ -185,7 +191,6 @@ export function Drawer({ title, open, close, children, className }: { title: str
useBodyScrollLock(open);
const { mounted, show } = useOverlayTransition(open, close);
if (!mounted) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
<>
<div className={`drawer-bg${show ? " show" : ""}`} onClick={close} />
+40 -18
View File
@@ -51,6 +51,10 @@
:root {
/* 比影擎 164px 略宽:我们子项带数量徽章,164 会挤 */
--sidebar-width: 192px;
--topbar-height: 116px;
/* 壳层:顶栏/侧栏收窄键 < 页面浮层。浮层必须盖住搜索/余额/铃铛 */
--z-topbar: 50;
--z-overlay: 200;
/* ===== Backgrounds (冷灰 · YZ 影擎) ===== */
--background-base: #f7f8fa;
@@ -223,6 +227,7 @@ img, svg, video { display: block; max-width: 100%; }
.app {
display: grid;
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
grid-template-rows: var(--topbar-height) minmax(0, 1fr);
min-height: 100vh;
transition: grid-template-columns var(--t-base);
}
@@ -232,6 +237,7 @@ body.sidebar-collapsed .app { grid-template-columns: 96px minmax(0, 1fr); }
aside.sidebar {
--klein: #002fa7;
--st-black: #101012;
grid-row: 1 / -1;
padding: 0;
border-right: 1px solid rgba(27, 32, 40, 0.08);
background: rgba(28, 34, 43, 0.035);
@@ -252,8 +258,8 @@ aside.sidebar {
justify-content: center;
margin: 0;
padding: 0;
height: 116px;
min-height: 116px;
height: var(--topbar-height);
min-height: var(--topbar-height);
box-sizing: border-box;
overflow: hidden;
background: #fff;
@@ -357,7 +363,7 @@ aside.sidebar {
font-weight: 500;
/* 中文标签用 sans 字体,不用 mono + uppercase */
}
nav { display: flex; flex-direction: column; gap: 2px; }
aside.sidebar nav { display: flex; flex-direction: column; gap: 2px; }
nav a {
display: flex; align-items: center; gap: 11px;
padding: 9px 12px;
@@ -414,7 +420,7 @@ nav a.disabled:hover { background: transparent; color: var(--black-alpha-32); }
}
aside.sidebar .nav-panel {
position: relative;
height: calc(100vh - 116px);
height: calc(100vh - var(--topbar-height));
min-height: 0;
padding-top: 14px;
padding-bottom: 82px;
@@ -710,7 +716,7 @@ aside.sidebar .nav-panel > nav {
}
body.sidebar-collapsed aside.sidebar { padding: 0; }
body.sidebar-collapsed .sidebar-head { gap: 6px; margin: 0; padding: 0; height: 116px; min-height: 116px; }
body.sidebar-collapsed .sidebar-head { gap: 6px; margin: 0; padding: 0; height: var(--topbar-height); min-height: var(--topbar-height); }
body.sidebar-collapsed .brand-clip {
width: 34px;
height: 34px;
@@ -760,13 +766,22 @@ body.sidebar-collapsed .user .em,
body.sidebar-collapsed .user::after { display: none; }
/* ─── Main + grid background ─── */
main { position: relative; background: #fff; min-width: 0; overflow-x: hidden; }
.app > main {
grid-column: 2;
grid-row: 2;
position: relative;
background: #fff;
min-width: 0;
min-height: 0;
overflow-x: clip;
overflow-y: visible;
}
.app:has(.pipeline-page) {
height: 100vh;
overflow: hidden;
}
main:has(.pipeline-page) {
height: 100vh;
.app > main:has(.pipeline-page) {
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
@@ -774,6 +789,7 @@ main:has(.pipeline-page) {
.grid-bg {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(0, 47, 167, 0.16) 1px, transparent 1px),
@@ -828,13 +844,17 @@ main:has(.pipeline-page) {
/* ─── Topbar ─── */
.topbar {
grid-column: 2;
grid-row: 1;
display: flex; align-items: center; gap: 16px;
padding: 0 clamp(36px, 3.2vw, 68px);
border-bottom: 1px solid rgba(27, 32, 40, 0.08);
background: #fff;
position: sticky; top: 0; z-index: 50;
height: 116px;
min-height: 116px;
box-shadow: 0 4px 16px rgba(20, 27, 38, 0.035);
position: sticky; top: 0; z-index: var(--z-topbar);
align-self: start;
height: var(--topbar-height);
min-height: var(--topbar-height);
min-width: 0;
box-sizing: border-box;
flex-wrap: nowrap;
@@ -1013,11 +1033,13 @@ main:has(.pipeline-page) {
/* ─── Content ─── */
.content {
/* 对齐 HTML 设计稿 #page-content(24px 28px 60px):原 48px 顶距使全站标题整体偏低 24px */
/* 对齐 HTML 设计稿 #page-content(24px 28px 60px):原 48px 顶距使全站标题整体偏低 24px
禁止设 z-index:会形成层叠上下文,页面里的 fixed 抽屉/弹窗再高也盖不住顶栏。
网格底 .grid-bg 在前、本节点 position:relative,DOM 顺序即可压住网格。 */
padding: 24px 28px 60px;
position: relative;
z-index: 1;
min-height: calc(100vh - 116px);
z-index: auto;
min-height: calc(100vh - var(--topbar-height));
animation: yz-page-enter 280ms cubic-bezier(0.22, 1, 0.36, 1);
}
.content:has(.pipeline-page) {
@@ -1029,8 +1051,8 @@ main:has(.pipeline-page) {
animation: none;
}
@keyframes yz-page-enter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
from { opacity: 0; }
to { opacity: 1; }
}
.content > .corner-mark { display: none; }
@@ -2637,7 +2659,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
position: fixed; inset: 0;
background: rgba(21, 20, 15, .32);
display: block;
z-index: 90;
z-index: var(--z-overlay);
opacity: 0;
visibility: hidden;
pointer-events: none;
@@ -2649,7 +2671,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
width: 540px; max-width: 100vw;
background: var(--surface);
border-left: 1px solid var(--border-faint);
z-index: 95;
z-index: calc(var(--z-overlay) + 5);
transform: translateX(100%);
transition: transform .25s cubic-bezier(.32, .72, 0, 1);
display: flex; flex-direction: column;
+1 -1
View File
@@ -281,7 +281,7 @@
--pcd-line: rgba(34, 42, 54, 0.12);
position: fixed;
inset: 0;
z-index: 80;
z-index: var(--z-overlay);
visibility: hidden;
opacity: 0;
pointer-events: none;
+9
View File
@@ -1,4 +1,13 @@
const QUICK_JOB_KEY = "airshelf:quick-create-job";
const QUICK_CREATE_SUFFIX = / · (?:极速成片|一键成片)$/;
export function hasQuickCreateSuffix(name?: string) {
return QUICK_CREATE_SUFFIX.test(name || "");
}
export function stripQuickCreateSuffix(name: string) {
return name.replace(QUICK_CREATE_SUFFIX, "");
}
export function isQuickCreateBusy(project?: { quick_create_status?: string } | null) {
return project?.quick_create_status === "queued" || project?.quick_create_status === "running";
+7 -7
View File
@@ -137,13 +137,13 @@
.quick-create-page .quick-history-open:hover { background: rgba(0,47,167,.05); }
.quick-create-page .quick-history-open svg { width: 15px; height: 15px; }
.quick-create-page .quick-history-empty { margin: 0; padding: 28px 8px; color: var(--quick-muted); font-size: 13px; }
.quick-create-page .quick-player-bg { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 24px; background: rgba(22,23,26,.58); }
.quick-create-page .quick-player { width: min(920px,100%); overflow: hidden; border-radius: 8px; background: #111216; }
.quick-create-page .quick-player-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; color: #fff; }
.quick-create-page .quick-player-bar strong { font-size: 13px; font-weight: 600; }
.quick-create-page .quick-player-bar button { width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 8px; color: #fff; background: transparent; cursor: pointer; }
.quick-create-page .quick-player-bar svg { width: 16px; height: 16px; }
.quick-create-page .quick-player video { width: 100%; max-height: min(72vh, 620px); display: block; background: #000; }
.quick-player-bg { position: fixed; inset: 0; z-index: var(--z-overlay); display: grid; place-items: center; padding: 24px; background: rgba(22,23,26,.58); }
.quick-player { width: min(920px,100%); overflow: hidden; border-radius: 8px; background: #111216; }
.quick-player-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; color: #fff; }
.quick-player-bar strong { font-size: 13px; font-weight: 600; }
.quick-player-bar button { width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 8px; color: #fff; background: transparent; cursor: pointer; }
.quick-player-bar svg { width: 16px; height: 16px; }
.quick-player video { width: 100%; max-height: min(72vh, 620px); display: block; background: #000; }
@media (max-width: 980px) { .quick-create-page .quick-history-card { grid-template-columns: 96px minmax(0,1fr); }.quick-create-page .quick-history-open { grid-column: 1 / -1; justify-self: start; } }
@media (min-width: 1753px) { .quick-create-page { position: relative; left: -21.5px; } }
@media (max-width: 1400px) { .quick-create-page .quick-create-shell { grid-template-columns: minmax(0,.9fr) minmax(0,1.1fr); }.quick-create-page .quick-form-panel,.quick-create-page .quick-status-panel { padding: 24px; } }
+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>
);
}
+3 -3
View File
@@ -131,7 +131,7 @@ aside.sidebar {
border: 1px solid var(--border-soft);
}
.nav-section { font-size: 12px; color: var(--ink-3); padding: 14px 12px 6px; letter-spacing: .08em; text-transform: uppercase; font-weight: 600; }
nav { display: flex; flex-direction: column; gap: 1px; }
aside.sidebar nav { display: flex; flex-direction: column; gap: 1px; }
nav a {
display: flex; align-items: center; gap: 11px;
padding: 8px 12px;
@@ -177,7 +177,7 @@ nav a.disabled:hover { background: transparent; color: var(--ink-4); }
.user .em { font-size: 13px; }
/* ─── Main + grid background ─── */
main { position: relative; overflow: hidden; background: #fff; }
main { position: relative; background: #fff; }
.grid-bg {
position: absolute;
inset: 0;
@@ -264,7 +264,7 @@ main { position: relative; overflow: hidden; background: #fff; }
/* ─── Content ─── */
/* 内容区始终紧贴侧栏铺满整个剩余宽度(与生产管线全屏页一致),不限宽、不居中 —— 否则宽屏会右侧留白或内容居中变窄 */
.content { padding: 36px 48px 60px; position: relative; z-index: 1; }
.content { padding: 36px 48px 60px; position: relative; }
.page-head { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 28px; gap: 16px; flex-wrap: wrap; }
.page-head h1 { font-size: 26px; font-weight: 600; letter-spacing: -.018em; line-height: 1.2; }
.page-head .sub { font-size: 14px; color: var(--ink-2); margin-top: 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+28 -1
View File
@@ -512,7 +512,7 @@ export type Project = {
video_segment_count?: number;
// 合成成片地址(最新一次成功拼接):列表播放按钮 / 视频阶段「播放成片」直接用它;没合成过为空串
final_video_url?: string;
// 是否由极速成片入口创建;列表页据此显示「极速成片」而不是「专业创作」
// 是否由一键成片入口创建;列表页据此显示「一键成片」而不是「专业创作」
quick_create?: boolean;
quick_create_status?: string;
quick_create_job_id?: string;
@@ -656,6 +656,12 @@ export type FreeVideoTask = {
seed: number;
seed_used?: number | null;
generate_audio: boolean;
feature?: string;
replace_mode?: "product" | "character" | "";
subject_name?: string;
subject_source?: "library" | "temporary" | "";
product_id?: string;
model_id?: string;
references: FreeVideoRef[];
estimated_tokens: number;
actual_tokens: number;
@@ -673,6 +679,27 @@ export type FreeVideoTask = {
completed_at: string | null;
};
export type VideoDigestJob = {
id: string;
task_id: string;
status: "processing" | "succeeded" | "failed" | string;
text: string;
prompt: string;
chars: number;
duration: number;
shots: number;
file_name: string;
title: string;
ratio: string;
width: number;
height: number;
cover_url: string;
video_url: string;
estimated_cost?: string;
error_message: string;
name?: string;
};
export type VideoDigestHistory = {
id: string;
title: string;
+165 -5
View File
@@ -148,7 +148,8 @@
}
.vr-page .remix-information-panel .video-result-placeholder,
.vr-page .remix-information-panel .video-analysis-result {
.vr-page .remix-information-panel .video-analysis-result,
.vr-page .remix-information-panel .remix-generating-state {
min-height: 286px;
}
@@ -340,11 +341,38 @@
.vr-page .remix-prompt-head {
display: flex;
align-items: flex-start;
justify-content: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 15px;
}
.vr-page .remix-prompt-head-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex: 0 0 auto;
}
.vr-page .remix-prompt-head-actions .remix-prompt-head-btn {
min-width: 0;
height: 36px;
padding: 0 12px;
font-size: 12px;
font-weight: 500;
}
.vr-page .remix-prompt-head-actions .remix-prompt-head-btn svg {
width: 14px;
height: 14px;
}
.vr-page .secondary-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.vr-page .remix-prompt-title {
display: flex;
align-items: flex-start;
@@ -560,6 +588,127 @@
flex-direction: column;
}
.vr-page .remix-generating-state {
display: none;
place-items: center;
text-align: center;
}
.vr-page .remix-information-panel.is-analyzing .video-result-placeholder,
.vr-page .remix-information-panel.is-analyzing .video-analysis-result {
display: none;
}
.vr-page .remix-information-panel.is-analyzing .remix-generating-state {
display: grid;
}
.vr-page .remix-generating-content {
width: min(280px, 100%);
display: grid;
justify-items: center;
gap: 10px;
}
.vr-page .remix-generating-visual {
position: relative;
width: 136px;
height: 104px;
margin-bottom: 10px;
}
.vr-page .remix-generating-frame {
position: absolute;
inset: 4px 18px 18px 4px;
display: grid;
place-items: center;
overflow: hidden;
border: 1px solid rgba(0, 47, 167, 0.14);
border-radius: 16px;
color: var(--klein);
background: rgba(255, 255, 255, 0.94);
}
.vr-page .remix-generating-frame svg {
width: 26px;
height: 26px;
stroke-width: 1.7;
}
.vr-page .remix-generating-frame::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(110deg, transparent 28%, rgba(0, 47, 167, 0.13) 50%, transparent 72%);
transform: translateX(-130%);
animation: remix-frame-scan 1.35s ease-in-out infinite;
}
.vr-page .remix-generating-badge {
position: absolute;
right: 0;
bottom: 0;
width: 44px;
height: 44px;
display: grid;
place-items: center;
border: 3px solid #fff;
border-radius: 14px;
color: #fff;
background: var(--klein);
animation: remix-badge-pulse 1.35s ease-in-out infinite;
}
.vr-page .remix-generating-badge svg {
width: 18px;
height: 18px;
}
.vr-page .remix-generating-content > strong {
color: var(--text);
font-size: 16px;
font-weight: 600;
}
.vr-page .remix-generating-content > span {
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.vr-page .remix-generating-bar {
width: 100%;
height: 4px;
overflow: hidden;
margin-top: 6px;
border-radius: 999px;
background: rgba(0, 47, 167, 0.1);
}
.vr-page .remix-generating-bar span {
display: block;
width: 42%;
height: 100%;
border-radius: inherit;
background: var(--klein);
animation: remix-progress-slide 1.2s ease-in-out infinite;
}
@keyframes remix-frame-scan {
0% { transform: translateX(-130%); }
100% { transform: translateX(130%); }
}
@keyframes remix-badge-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.07); }
}
@keyframes remix-progress-slide {
0% { transform: translateX(-115%); }
100% { transform: translateX(260%); }
}
.vr-page .remix-progress-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -737,6 +886,11 @@
background: rgba(0, 47, 167, 0.92);
}
.vr-page .remix-replace-video.is-disabled {
pointer-events: none;
opacity: 0.45;
}
.vr-page .remix-page .video-upload-field.has-preview:hover,
.vr-page .remix-page .video-upload-field.has-preview:focus-within {
background: #0f1728;
@@ -753,7 +907,8 @@
}
.vr-page .remix-page .remix-information-panel .video-result-placeholder,
.vr-page .remix-page .remix-information-panel .video-analysis-result {
.vr-page .remix-page .remix-information-panel .video-analysis-result,
.vr-page .remix-page .remix-information-panel .remix-generating-state {
flex: 1;
}
@@ -805,6 +960,11 @@
.vr-page .remix-page .remix-prompt-panel.has-result .remix-prompt-result {
animation: none;
}
.vr-page .remix-generating-frame::after,
.vr-page .remix-generating-badge,
.vr-page .remix-generating-bar span {
animation: none;
}
}
.vr-page .remix-history-section {
@@ -1100,7 +1260,7 @@
@media (max-width: 1120px) {
.vr-page .video-flow-grid,
.vr-page .remix-flow-grid { grid-template-columns: 1fr; }
.vr-page .remix-prompt-head { flex-direction: column; }
.vr-page .remix-prompt-head { flex-wrap: wrap; }
.vr-page .video-flow-actions { flex-wrap: wrap; }
.vr-page .remix-progress-step:not(:last-child)::after {
left: calc(50% + 42px);
+232 -104
View File
@@ -136,6 +136,50 @@
color: var(--text);
}
.vrep-page .replace-mode-switch {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin: 16px 0 22px;
padding: 5px;
border: 1px solid rgba(34, 42, 54, 0.09);
border-radius: 14px;
background: rgba(241, 244, 249, 0.88);
}
.vrep-page .replace-mode-button {
min-height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
border: 0;
border-radius: 10px;
color: #69758a;
background: transparent;
font: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: color 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
.vrep-page .replace-mode-button svg {
width: 17px;
height: 17px;
}
.vrep-page .replace-mode-button.active {
color: var(--klein);
background: #fff;
box-shadow: 0 7px 18px rgba(22, 45, 92, 0.09), inset 0 0 0 1px rgba(0, 47, 167, 0.11);
}
.vrep-page .replace-mode-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.vrep-page .video-flow-step {
margin-top: 22px;
}
@@ -216,7 +260,9 @@
}
.vrep-page .primary-action,
.vrep-page .secondary-action {
.vrep-page .secondary-action,
.asset-library-layer .primary-action,
.asset-library-layer .secondary-action {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -227,7 +273,8 @@
transition: transform 180ms ease, background-color 180ms ease, box-shadow 180ms ease;
}
.vrep-page .primary-action {
.vrep-page .primary-action,
.asset-library-layer .primary-action {
min-width: 158px;
height: 50px;
padding: 0 22px;
@@ -237,19 +284,22 @@
box-shadow: 0 9px 18px rgba(0, 47, 167, 0.18);
}
.vrep-page .primary-action:hover:not(:disabled) {
.vrep-page .primary-action:hover:not(:disabled),
.asset-library-layer .primary-action:hover:not(:disabled) {
transform: translateY(-2px);
background: var(--klein-hover);
}
.vrep-page .primary-action:disabled {
.vrep-page .primary-action:disabled,
.asset-library-layer .primary-action:disabled {
opacity: 0.38;
transform: none;
box-shadow: none;
cursor: not-allowed;
}
.vrep-page .secondary-action {
.vrep-page .secondary-action,
.asset-library-layer .secondary-action {
min-width: 132px;
height: 46px;
padding: 0 18px;
@@ -258,12 +308,15 @@
background: rgba(34, 42, 54, 0.05);
}
.vrep-page .secondary-action:hover:not(:disabled) {
.vrep-page .secondary-action:hover:not(:disabled),
.asset-library-layer .secondary-action:hover:not(:disabled) {
background: rgba(34, 42, 54, 0.10);
}
.vrep-page .primary-action svg,
.vrep-page .secondary-action svg {
.vrep-page .secondary-action svg,
.asset-library-layer .primary-action svg,
.asset-library-layer .secondary-action svg {
width: 19px;
height: 19px;
}
@@ -541,6 +594,7 @@
}
.vrep-page .replace-result-panel.has-result .replace-preview,
.vrep-page .replace-result-panel.has-result .replace-preview-meta,
.vrep-page .replace-result-panel.has-result .replace-result-actions {
margin-top: 0;
}
@@ -616,7 +670,8 @@
}
.vrep-page .video-result-panel.has-result .video-analysis-result {
display: block;
display: flex;
flex-direction: column;
}
.vrep-page .replace-generating-state {
@@ -752,47 +807,37 @@
.vrep-page .replace-preview {
position: relative;
min-height: 250px;
min-height: 0;
display: grid;
place-items: center;
margin-top: 18px;
margin-top: 16px;
overflow: hidden;
border-radius: 13px;
color: #fff;
background:
linear-gradient(180deg, rgba(12, 17, 27, 0.08), rgba(12, 17, 27, 0.62)),
#101012;
text-align: center;
border-radius: 10px;
background: #101012;
}
.vrep-page .replace-preview video,
.vrep-page .replace-preview img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
max-height: 420px;
display: block;
object-fit: cover;
object-fit: contain;
background: #101012;
}
.vrep-page .replace-preview-copy {
position: relative;
z-index: 1;
align-self: end;
width: 100%;
padding: 26px;
background: linear-gradient(180deg, transparent, rgba(12, 17, 27, 0.72));
.vrep-page .replace-preview-meta {
display: grid;
gap: 4px;
margin-top: 12px;
}
.vrep-page .replace-preview-copy strong {
display: block;
font-size: 18px;
.vrep-page .replace-preview-meta strong {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.vrep-page .replace-preview-copy span {
display: block;
margin-top: 7px;
color: rgba(255, 255, 255, 0.76);
.vrep-page .replace-preview-meta span {
color: var(--muted);
font-size: 12px;
}
@@ -822,13 +867,13 @@
.vrep-page .replace-history-list {
display: grid;
gap: 12px;
gap: 10px;
}
.vrep-page .replace-history-empty {
padding: 28px 16px;
border: 1px solid rgba(0, 47, 167, 0.12);
border-radius: 15px;
border-radius: 12px;
color: var(--muted);
background: rgba(255, 255, 255, 0.72);
font-size: 13px;
@@ -837,16 +882,11 @@
.vrep-page .replace-history-card {
position: relative;
display: grid;
grid-template-columns: 184px minmax(0, 1fr) auto;
align-items: center;
gap: 20px;
padding: 12px;
overflow: hidden;
border: 1px solid rgba(0, 47, 167, 0.12);
border-radius: 15px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 12px 30px rgba(22, 45, 92, 0.055);
border-radius: 12px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 8px 20px rgba(22, 45, 92, 0.05);
}
.vrep-page .replace-history-card::before {
@@ -857,13 +897,31 @@
background: var(--klein);
}
.vrep-page .replace-history-summary {
width: 100%;
display: grid;
grid-template-columns: 148px minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
padding: 12px 14px 12px 16px;
border: 0;
color: inherit;
background: transparent;
font: inherit;
text-align: left;
cursor: pointer;
}
.vrep-page .replace-history-summary:hover {
background: rgba(0, 47, 167, 0.035);
}
.vrep-page .replace-history-cover {
position: relative;
height: 108px;
height: 84px;
overflow: hidden;
border-radius: 10px;
border-radius: 8px;
background: #e9eef8;
cursor: pointer;
}
.vrep-page .replace-history-cover img,
@@ -878,76 +936,143 @@
position: absolute;
right: 8px;
bottom: 8px;
padding: 4px 7px;
border-radius: 6px;
padding: 3px 6px;
border-radius: 4px;
color: #fff;
background: rgba(16, 16, 18, 0.76);
font-size: 10px;
font-weight: 700;
font-weight: 600;
}
.vrep-page .replace-history-copy {
min-width: 0;
display: grid;
gap: 6px;
}
.vrep-page .replace-history-copy > span {
width: fit-content;
display: inline-flex;
padding: 4px 8px;
padding: 3px 8px;
border-radius: 999px;
color: var(--klein);
background: #eef3ff;
font-size: 10px;
font-weight: 700;
}
.vrep-page .replace-history-copy h3 {
margin: 9px 0 0;
font-size: 16px;
font-weight: 600;
color: #1d2940;
}
.vrep-page .replace-history-copy p {
margin: 7px 0 0;
.vrep-page .replace-history-copy strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.vrep-page .replace-history-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
font-size: 11px;
font-size: 12px;
}
.vrep-page .replace-history-open {
min-height: 38px;
.vrep-page .replace-history-toggle {
min-height: 36px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 13px;
border: 1px solid rgba(0, 47, 167, 0.22);
border-radius: 9px;
gap: 6px;
padding: 0 12px;
border: 1px solid rgba(0, 47, 167, 0.18);
border-radius: 8px;
color: var(--klein);
background: #fff;
font: inherit;
font-size: 11px;
font-weight: 650;
cursor: pointer;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.vrep-page .replace-history-open svg {
width: 14px;
height: 14px;
.vrep-page .replace-history-toggle svg {
width: 16px;
height: 16px;
transition: transform 180ms ease;
}
.vrep-page .asset-library-layer {
.vrep-page .replace-history-card.is-open .replace-history-toggle svg {
transform: rotate(180deg);
}
.vrep-page .replace-history-compare {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 0 14px 14px 16px;
border-top: 1px solid rgba(34, 42, 54, 0.08);
}
.vrep-page .replace-history-compare[hidden] {
display: none;
}
.vrep-page .replace-history-compare-pane {
min-width: 0;
display: grid;
gap: 8px;
padding-top: 12px;
}
.vrep-page .replace-history-compare-pane > span {
color: var(--muted);
font-size: 12px;
font-weight: 500;
}
.vrep-page .replace-history-compare-pane video,
.vrep-page .replace-history-compare-empty {
width: 100%;
aspect-ratio: 9 / 16;
max-height: 360px;
display: grid;
place-items: center;
overflow: hidden;
border-radius: 8px;
background: #101012;
}
.vrep-page .replace-history-compare-pane video {
display: block;
object-fit: contain;
}
.vrep-page .replace-history-compare-empty {
color: rgba(255, 255, 255, 0.62);
font-size: 12px;
}
/* 挂到 body,盖住顶栏。变量写在本层,不依赖 .vrep-page */
.asset-library-layer {
--klein: #002fa7;
--klein-hover: #002680;
--muted: #6f747c;
--text: #17181a;
position: fixed;
inset: 0;
z-index: 90;
z-index: var(--z-overlay);
visibility: hidden;
opacity: 0;
pointer-events: none;
transition: visibility 0s linear 260ms, opacity 220ms ease;
}
.vrep-page .asset-library-layer.open {
.asset-library-layer.open {
visibility: visible;
opacity: 1;
pointer-events: auto;
transition-delay: 0s;
}
.vrep-page .asset-library-scrim {
.asset-library-scrim {
position: absolute;
inset: 0;
border: 0;
@@ -955,7 +1080,7 @@
cursor: default;
}
.vrep-page .asset-library-drawer {
.asset-library-drawer {
position: absolute;
top: 0;
right: 0;
@@ -970,11 +1095,11 @@
transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
}
.vrep-page .asset-library-layer.open .asset-library-drawer {
.asset-library-layer.open .asset-library-drawer {
transform: translateX(0);
}
.vrep-page .asset-library-head {
.asset-library-head {
min-height: 82px;
display: flex;
align-items: center;
@@ -984,18 +1109,18 @@
border-bottom: 1px solid rgba(34, 42, 54, 0.09);
}
.vrep-page .asset-library-head h2 {
.asset-library-head h2 {
margin: 0 0 4px;
font-size: 21px;
}
.vrep-page .asset-library-head p {
.asset-library-head p {
margin: 0;
color: var(--muted);
font-size: 11px;
}
.vrep-page .product-drawer-close {
.asset-library-layer .product-drawer-close {
width: 38px;
height: 38px;
display: grid;
@@ -1008,17 +1133,17 @@
transition: color 160ms ease, background-color 160ms ease;
}
.vrep-page .product-drawer-close:hover {
.asset-library-layer .product-drawer-close:hover {
color: var(--text);
background: rgba(34, 42, 54, 0.09);
}
.vrep-page .product-drawer-close svg {
.asset-library-layer .product-drawer-close svg {
width: 18px;
height: 18px;
}
.vrep-page .asset-library-grid {
.asset-library-grid {
min-height: 0;
flex: 1;
overflow-y: auto;
@@ -1029,7 +1154,7 @@
padding: 22px 24px;
}
.vrep-page .asset-library-choice {
.asset-library-choice {
position: relative;
min-height: 214px;
overflow: hidden;
@@ -1042,18 +1167,18 @@
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.vrep-page .asset-library-choice:hover {
.asset-library-choice:hover {
transform: translateY(-2px);
border-color: rgba(0, 47, 167, 0.32);
box-shadow: 0 10px 24px rgba(20, 27, 38, 0.08);
}
.vrep-page .asset-library-choice.selected {
.asset-library-choice.selected {
border-color: var(--klein);
box-shadow: 0 0 0 2px rgba(0, 47, 167, 0.1);
}
.vrep-page .asset-library-choice img {
.asset-library-choice img {
width: 100%;
height: 164px;
display: block;
@@ -1061,17 +1186,17 @@
background: #e9eef8;
}
.vrep-page .asset-library-choice > span:not(.asset-choice-check) {
.asset-library-choice > span:not(.asset-choice-check) {
display: grid;
gap: 3px;
padding: 10px;
text-align: left;
}
.vrep-page .asset-library-choice strong { font-size: 11px; }
.vrep-page .asset-library-choice small { color: var(--muted); font-size: 9px; }
.asset-library-choice strong { font-size: 11px; }
.asset-library-choice small { color: var(--muted); font-size: 9px; }
.vrep-page .asset-choice-check {
.asset-choice-check {
position: absolute;
top: 9px;
right: 9px;
@@ -1084,10 +1209,10 @@
background: var(--klein);
}
.vrep-page .asset-library-choice.selected .asset-choice-check { display: grid; }
.vrep-page .asset-choice-check svg { width: 13px; height: 13px; }
.asset-library-choice.selected .asset-choice-check { display: grid; }
.asset-choice-check svg { width: 13px; height: 13px; }
.vrep-page .asset-library-footer {
.asset-library-footer {
min-height: 82px;
display: flex;
align-items: center;
@@ -1099,7 +1224,7 @@
box-shadow: 0 -10px 24px rgba(20, 27, 38, 0.045);
}
.vrep-page .asset-library-empty {
.asset-library-empty {
grid-column: 1 / -1;
padding: 36px 12px;
color: var(--muted);
@@ -1110,16 +1235,19 @@
@media (max-width: 1120px) {
.vrep-page .video-flow-grid { grid-template-columns: 1fr; }
.vrep-page .product-replace-options { grid-template-columns: 1fr; }
.vrep-page .replace-history-card {
.vrep-page .replace-history-summary {
grid-template-columns: 1fr;
}
.vrep-page .replace-history-open {
.vrep-page .replace-history-toggle {
justify-self: start;
}
.vrep-page .asset-library-drawer {
.vrep-page .replace-history-compare {
grid-template-columns: 1fr;
}
.asset-library-drawer {
width: min(650px, 100vw);
}
.vrep-page .asset-library-grid {
.asset-library-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}