优化住流程和添加复刻视频页面

This commit is contained in:
Azmat@qq.com
2026-08-26 18:44:03 +08:00
parent 0ee498d807
commit c25060c6c6
30 changed files with 2574 additions and 155 deletions
+12
View File
@@ -32,6 +32,7 @@ import {
FreeCreatePage,
QuickCreatePage,
VideoRemixPage,
VideoReplacePage,
ImageWorkbenchPage,
LibraryPage,
MessagesPage,
@@ -1105,6 +1106,17 @@ export function App() {
);
case "videoRemix":
return <VideoRemixPage textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")} onNotify={(type, text) => setNotice({ type, text })} onBack={() => goBack("projects")} navigate={navigate} />;
case "videoReplace":
return (
<VideoReplacePage
products={products}
modelConfigs={modelConfigs}
onNotify={(type, text) => setNotice({ type, text })}
onBack={() => goBack(entryOrigin("projects"))}
onTaskSettled={refreshFreeCreateShell}
navigate={navigate}
/>
);
case "imageOptimize":
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} imageProductId={route.productId} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => goBack("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
case "modelPhoto":
+3 -2
View File
@@ -301,8 +301,9 @@ export const api = {
revokeInvitation(id: string) {
return request<Invitation>(`/api/auth/team/invitations/${id}/revoke/`, { method: "POST" });
},
products() {
return request<Paginated<Product>>("/api/products/");
products(pageSize?: number) {
const query = pageSize ? `?page_size=${pageSize}` : "";
return request<Paginated<Product>>(`/api/products/${query}`);
},
product(id: string) {
return request<Product>(`/api/products/${id}/`);
@@ -31,6 +31,7 @@ const iconPaths: Record<string, string> = {
film: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 3v18"/><path d="M17 3v18"/><path d="M3 7.5h4"/><path d="M3 16.5h4"/><path d="M17 7.5h4"/><path d="M17 16.5h4"/><path d="M3 12h4"/><path d="M17 12h4"/>',
wand: '<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"/>',
scan: '<path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M7 12h10"/>',
replace: '<path d="M14 4a2 2 0 0 1 2-2"/><path d="M16 10a2 2 0 0 1-2-2"/><path d="M20 2a2 2 0 0 1 2 2"/><path d="M22 8a2 2 0 0 1-2 2"/><path d="m3 7 3 3 3-3"/><path d="M6 10V5a3 3 0 0 1 3-3h1"/><rect x="2" y="14" width="8" height="8" rx="2"/>',
swap: '<path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/>',
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>'
};
+3 -1
View File
@@ -18,6 +18,7 @@ const SHELL_COMMANDS: Command[] = [
{ 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: "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" },
{ id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" },
{ id: "account", group: "导航", label: "账单库", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" },
@@ -272,7 +273,7 @@ export function topModuleForPage(page: Page): TopModule | null {
|| page === "modelPhotoDemoB"
|| page === "platformCover"
) return "image";
if (page === "projects" || page === "projectWizard" || page === "quickCreate" || page === "pipeline" || page === "freeCreate" || page === "videoRemix") return "video";
if (page === "projects" || page === "projectWizard" || page === "quickCreate" || page === "pipeline" || page === "freeCreate" || page === "videoRemix" || page === "videoReplace") return "video";
return null;
}
@@ -404,6 +405,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
platformCover: "assetFactory",
freeCreate: "freeCreate",
videoRemix: "projects",
videoReplace: "projects",
library: "library",
team: "team",
account: "account",
@@ -1,14 +1,17 @@
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, DragEvent } from "react";
import { createPortal } from "react-dom";
import { ArrowLeft, Check, ChevronDown, Image as ImageIcon, Plus, ShieldCheck, Trash2, Upload, X } from "lucide-react";
import { ArrowLeft, Check, ChevronDown, ChevronRight, Image as ImageIcon, Plus, ShieldCheck, Trash2, Upload, X } from "lucide-react";
import { api } from "../api";
import { useBodyScrollLock, ConfirmModal } from "./overlays";
import type { Product } from "../types";
import {
BUSINESS_TYPES,
BUSINESS_TYPE_KEYS,
PC_CAT_MORE,
PC_CAT_MORE_LABEL,
PC_CAT_OPTIONS,
PC_CAT_PRIMARY,
catOptionsFor,
type BusinessType,
} from "../product-business";
@@ -20,7 +23,6 @@ export type ProductCreatePayload = {
title: string;
business_type?: BusinessType;
category: string;
target_audience?: string;
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
cover_asset?: string;
images?: Array<{ asset: string; sort_order: number; is_primary?: boolean }>;
@@ -51,7 +53,6 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
const [title, setTitle] = useState("");
const [businessType, setBusinessType] = useState<BusinessType>("ecommerce");
const [category, setCategory] = useState("");
const [target, setTarget] = useState("");
const [bullets, setBullets] = useState<string[]>([""]);
const [images, setImages] = useState<PfImage[]>([]);
const [dragOver, setDragOver] = useState(false);
@@ -60,6 +61,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
const [saving, setSaving] = useState(false);
const [galleryOpen, setGalleryOpen] = useState(false);
const [openSelect, setOpenSelect] = useState<"" | "type" | "cat">("");
const [catMoreOpen, setCatMoreOpen] = useState(false);
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null);
const [confirmExit, setConfirmExit] = useState(false);
const imgInputRef = useRef<HTMLInputElement | null>(null);
@@ -84,7 +86,6 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
setTitle("");
setBusinessType("ecommerce");
setCategory("");
setTarget("");
setBullets([""]);
setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; });
setDragOver(false);
@@ -92,6 +93,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
setCategoryError(false);
setGalleryOpen(false);
setOpenSelect("");
setCatMoreOpen(false);
}
useEffect(() => {
@@ -114,7 +116,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
}, [openSelect]);
function isDirty() {
return Boolean(title.trim() || businessType !== "ecommerce" || category || target.trim() || bullets.some((b) => b.trim()) || images.length);
return Boolean(title.trim() || businessType !== "ecommerce" || category || bullets.some((b) => b.trim()) || images.length);
}
function changeBusinessType(next: BusinessType) {
@@ -227,7 +229,6 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
title: name,
business_type: businessType,
category: category || activeCatOptions[0],
target_audience: target.trim() || undefined,
selling_points: finalBullets.map((item, index) => ({ title: item, detail: item, sort_order: index })),
...(images[0]?.assetId
? {
@@ -299,31 +300,57 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
<div className="pcd-field">
<span className="pcd-label"><span> <span className="pcd-req">*</span></span><small></small></span>
<div className={`pcd-select${openSelect === "cat" ? " open" : ""}`}>
<button type="button" className={`pcd-select-trigger${categoryError ? " is-error" : ""}`} aria-haspopup="listbox" aria-expanded={openSelect === "cat"} onClick={() => setOpenSelect((c) => (c === "cat" ? "" : "cat"))}>
<div className={`pcd-select${openSelect === "cat" ? " open" : ""}${catMoreOpen ? " is-more-open" : ""}`}>
<button
type="button"
className={`pcd-select-trigger${categoryError ? " is-error" : ""}`}
aria-haspopup="listbox"
aria-expanded={openSelect === "cat"}
onClick={() => {
if (openSelect === "cat") {
setOpenSelect("");
return;
}
setCatMoreOpen(PC_CAT_MORE.includes(category));
setOpenSelect("cat");
}}
>
<span className={category ? "" : "is-placeholder"}>{category || "请选择商品品类"}</span>
<ChevronDown />
</button>
<div className="pcd-select-menu" role="listbox">
{activeCatOptions.map((option) => (
{isLocal ? activeCatOptions.map((option) => (
<button type="button" className={`pcd-select-option${category === option ? " selected" : ""}`} key={option} onClick={() => { setCategory(option); setCategoryError(false); setOpenSelect(""); }}>
{option}
</button>
))}
)) : (
<>
{PC_CAT_PRIMARY.map((option) => (
<button type="button" className={`pcd-select-option${category === option ? " selected" : ""}`} key={option} onClick={() => { setCategory(option); setCategoryError(false); setOpenSelect(""); }}>
{option}
</button>
))}
<button
type="button"
className={`pcd-select-option pcd-select-more-toggle${catMoreOpen ? " is-open" : ""}`}
aria-expanded={catMoreOpen}
onClick={() => setCatMoreOpen((open) => !open)}
>
<span>{PC_CAT_MORE_LABEL}</span>
<ChevronRight />
</button>
{catMoreOpen ? PC_CAT_MORE.map((option) => (
<button type="button" className={`pcd-select-option pcd-select-more-item${category === option ? " selected" : ""}`} key={option} onClick={() => { setCategory(option); setCategoryError(false); setOpenSelect(""); }}>
{option}
</button>
)) : null}
</>
)}
</div>
</div>
</div>
</div>
<label className="pcd-field">
<span className="pcd-label"><span></span><small></small></span>
<input
value={target}
onChange={(event) => setTarget(event.target.value)}
placeholder={isLocal ? "例如:附近上班族、带娃家庭、周末聚餐" : "例如:22–32 岁女性、敏感肌、通勤人群"}
/>
</label>
<section className="pcd-block" aria-labelledby="productImageTitle">
<div className="pcd-block-head">
<div>
+1
View File
@@ -15,6 +15,7 @@ import "./settings-page.css";
import "./ai-tools-page.css";
import "./free-create-page.css";
import "./video-remix-page.css";
import "./video-replace-page.css";
import "./product-create-page.css";
import "./project-wizard-page.css";
import "./quick-create-page.css";
+11 -3
View File
@@ -9,11 +9,19 @@ export type BusinessType = keyof typeof BUSINESS_TYPES;
export const BUSINESS_TYPE_KEYS = Object.keys(BUSINESS_TYPES) as BusinessType[];
/** 电商品类 · 新建抽屉 / 详情编辑共用 */
export const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
/** 电商品类 · 常用项,新建抽屉第一层 */
export const PC_CAT_PRIMARY = ["美妆个护", "食品饮料", "服饰鞋包", "家居日用", "数码家电", "母婴玩具", "运动健康"];
/** 电商品类 · 不常用,点「更多品类」再展开 */
export const PC_CAT_MORE = ["珠宝配饰", "汽车用品", "宠物用品", "图书文教", "五金农资", "其他商品"];
export const PC_CAT_MORE_LABEL = "更多品类";
/** 电商品类全集 · 筛选 / 详情编辑 / 提交校验用,不含「更多品类」这个入口文案 */
export const PC_CAT_OPTIONS = [...PC_CAT_PRIMARY, ...PC_CAT_MORE];
/** 本地生活品类 · 团购/到店核销走这条,不跟电商 SKU 混在一个下拉里 */
export const PC_LOCAL_CAT_OPTIONS = ["餐饮美食", "休闲娱乐", "丽人美发", "酒店民宿", "生活服务", "运动健身", "亲子玩乐", "团购套餐"];
export const PC_LOCAL_CAT_OPTIONS = ["餐饮美食", "休闲娱乐", "丽人美发", "酒店民宿", "生活服务", "运动健身", "亲子玩乐", "团购套餐", "更多品类"];
export function isLocalLife(product?: { business_type?: string } | null): boolean {
return product?.business_type === "local_life";
+23
View File
@@ -418,6 +418,8 @@
display: grid;
gap: 3px;
padding: 6px;
max-height: min(360px, 60vh);
overflow-y: auto;
border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: 11px;
background: #fff;
@@ -433,6 +435,10 @@
pointer-events: auto;
transform: translateY(0);
}
.pcd-select.is-more-open .pcd-select-menu {
max-height: min(640px, 78vh);
padding-bottom: 10px;
}
.pcd-select-option {
min-height: 38px;
padding: 0 10px;
@@ -447,6 +453,23 @@
}
.pcd-select-option:hover { background: #e8f2ff; }
.pcd-select-option.selected { color: var(--klein); background: #f3f7ff; font-weight: 600; }
.pcd-select-more-toggle {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--pcd-muted);
}
.pcd-select-more-toggle svg {
width: 14px;
height: 14px;
flex: 0 0 auto;
color: var(--pcd-muted);
transition: transform 180ms ease;
}
.pcd-select-more-toggle.is-open,
.pcd-select-more-toggle.is-open svg { color: var(--accent-black); }
.pcd-select-more-toggle.is-open svg { transform: rotate(90deg); }
.pcd-select-more-item { padding-left: 22px; }
.pcd-block { margin-top: 8px; padding-top: 22px; border-top: 1px solid rgba(34, 42, 54, 0.08); }
.pcd-block-head {
+20 -1
View File
@@ -15,6 +15,25 @@
.quick-create-page .quick-form-copy p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }
.quick-create-page .quick-field { display: grid; gap: 9px; margin-top: 20px; }
.quick-create-page .quick-form-copy + .quick-field { margin-top: 17px; }
.quick-create-page .quick-cat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.quick-create-page .quick-cat-chip {
width: 100%;
justify-content: center;
}
.quick-create-page .quick-cat-chip:disabled {
cursor: not-allowed;
opacity: .55;
transform: none;
}
.quick-create-page .quick-cat-more svg {
flex: 0 0 auto;
transition: transform 180ms ease;
}
.quick-create-page .quick-cat-more.is-open svg { transform: rotate(90deg); }
.quick-create-page .quick-field-label { display: flex; align-items: center; justify-content: space-between; gap: 14px; font-size: 14px; font-weight: 700; }
.quick-create-page .quick-field-label small { color: var(--quick-muted); font-size: 11px; font-weight: 500; }
.quick-create-page .quick-name-input { height: 50px; padding: 0 15px; border: 1px solid rgba(34,42,54,.13); border-radius: 12px; outline: none; background: rgba(248,249,252,.88); transition: border-color 180ms ease, box-shadow 180ms ease, background 180ms ease; }
@@ -76,7 +95,7 @@
.quick-create-page .quick-video-result-grid.is-single { grid-template-columns: minmax(0, 1fr); width: min(320px,100%); justify-self: center; }
.quick-create-page .quick-video-result-card { min-width: 0; padding: 8px 8px 9px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; color: #25282d; background: #fff; font: inherit; text-align: left; cursor: pointer; }
.quick-create-page .quick-video-result-thumb { position: relative; display: block; aspect-ratio: 16/9; overflow: hidden; border-radius: 8px; background: #eef1f5; }
.quick-create-page .quick-video-result-thumb img,.quick-create-page .quick-video-result-thumb video { width: 100%; height: 100%; display: block; object-fit: cover; }
.quick-create-page .quick-video-result-thumb img,.quick-create-page .quick-video-result-thumb video { width: 100%; height: 100%; display: block; object-fit: cover; pointer-events: none; }
.quick-create-page .quick-video-play { position: absolute; inset: 0; display: grid; place-items: center; color: #fff; background: rgba(0,0,0,.22); }
.quick-create-page .quick-video-play svg { width: 42px; height: 42px; fill: currentColor; stroke: currentColor; stroke-width: 1.2; filter: drop-shadow(0 2px 8px rgba(0,0,0,.28)); }
.quick-create-page .quick-video-result-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 3px 0; }
+1 -1
View File
@@ -40,7 +40,7 @@ const CREATE_GROUPS: Array<{
hint: "复用成熟视频的镜头表达与节奏",
cards: [
{ title: "提炼提示词", desc: "上传参考视频,提炼可编辑提示词", tone: "subtle", page: "videoRemix", icon: "scan" },
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容", tone: "subtle", page: "freeCreate", icon: "replace" },
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容", tone: "subtle", page: "videoReplace", icon: "replace" },
],
},
];
+1
View File
@@ -13,4 +13,5 @@ export { AssetFactoryPage, ImageWorkbenchPage, ModelPhotoDemoPage } from "./ai-t
export { FreeCreatePage } from "./free-create";
export { QuickCreatePage } from "./quick-create";
export { VideoRemixPage } from "./video-remix";
export { VideoReplacePage } from "./video-replace";
export { SettingsPage } from "./settings";
+19 -13
View File
@@ -1450,10 +1450,9 @@ export function PipelinePage(props: {
const recommended = useMemo(
() => recommendSetup({
category: setupProduct?.category,
targetAudience: setupProduct?.target_audience,
title: setupProduct?.title,
}),
[setupProduct?.category, setupProduct?.target_audience, setupProduct?.title]
[setupProduct?.category, setupProduct?.title]
);
const wizard = project.metadata?.wizard;
const [setupOpen, setSetupOpen] = useState(false);
@@ -1464,7 +1463,9 @@ export function PipelinePage(props: {
const [setupStructure, setSetupStructure] = useState<VideoStructure>(
coerceVideoStructure(wizard?.video_structure, recommended.structure)
);
const [setupPersona, setSetupPersona] = useState<string>(coercePersona(wizard?.persona, SETUP_PERSONA_KEYS[0] || "urban"));
const [setupPersona, setSetupPersona] = useState<string>(
coercePersona(wizard?.persona, recommended.persona || SETUP_PERSONA_KEYS[0] || "urban")
);
const [setupDuration, setSetupDuration] = useState<number>(() => {
if (typeof wizard?.total_duration === "number") return clampDuration(wizard.total_duration);
// 兼容一期向导存的字符串档位("0-30" → 30)
@@ -1476,12 +1477,16 @@ export function PipelinePage(props: {
const setupTouched = useRef(false);
const wizardHasCombo = Boolean(wizard?.presentation_format && wizard?.video_structure);
const wizardHasDuration = typeof wizard?.total_duration === "number" || Boolean(wizard?.duration);
const wizardHasPersona = Boolean(wizard?.persona);
useEffect(() => {
if (setupTouched.current || wizardHasCombo) return;
setSetupFormat(recommended.format);
setSetupStructure(recommended.structure);
if (!wizardHasDuration) setSetupDuration(recommended.duration);
}, [recommended.format, recommended.structure, recommended.duration, wizardHasCombo, wizardHasDuration]);
if (setupTouched.current) return;
if (!wizardHasCombo) {
setSetupFormat(recommended.format);
setSetupStructure(recommended.structure);
if (!wizardHasDuration) setSetupDuration(recommended.duration);
}
if (!wizardHasPersona) setSetupPersona(recommended.persona);
}, [recommended.format, recommended.structure, recommended.persona, recommended.duration, wizardHasCombo, wizardHasDuration, wizardHasPersona]);
// ── 5.2 换商品重跑 ── 新建向导选的套路模板由后端回填进 metadata.wizard,这里只读不写
const templateName = typeof wizard?.template_name === "string" ? wizard.template_name : "";
@@ -1875,7 +1880,7 @@ export function PipelinePage(props: {
`${base}\n以上是我拆解一条参考视频得到的分镜稿。请照搬它的镜头顺序、每镜时长比例、景别、机位、运镜、人物动作和声音层次,`
+ `把人物、商品、品牌、台词全部换成我自己的商品;参考视频里出现的商品、品牌、人物一律不要保留。`
+ `写 visual 时把每镜的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注折进导演说明书,不要压成一句画面摘要。`
+ `原稿里标注「无 / 不可见 / 听不清 / 存疑」的地方不要编造,由你按我的商品补全。目标人群:${personaLabel}`,
+ `原稿里标注「无 / 不可见 / 听不清 / 存疑」的地方不要编造,由你按我的商品补全。人物设定:${personaLabel}`,
`上传视频提炼 · ${combo}`,
"video",
);
@@ -1891,7 +1896,7 @@ export function PipelinePage(props: {
// 上传脚本:先识别原稿结构,再补齐钩子 / 卖点证明 / 转化,而不是照抄一遍
await runScriptGeneration(
`${base}\n以上是我已有的脚本。请先识别它的结构与叙述顺序,尽量保留原意与原有表达,再据此整理成镜头脚本;`
+ `其中开头钩子、卖点的证明方式、结尾转化引导如有缺失或偏弱,请补齐补强。目标人群:${personaLabel}`,
+ `其中开头钩子、卖点的证明方式、结尾转化引导如有缺失或偏弱,请补齐补强。人物设定:${personaLabel}`,
`上传脚本 · ${combo}`,
"manual",
);
@@ -3326,11 +3331,11 @@ export function PipelinePage(props: {
<div className="setup-rec">{STRUCTURE_HINT[setupStructure]}</div>
<label className="setup-field">
<span className="sf-k"></span>
<select className="setup-select" value={setupPersona} onChange={(e) => setSetupPersona(e.target.value)}>
<select className="setup-select" value={setupPersona} onChange={(e) => { setupTouched.current = true; setSetupPersona(e.target.value); }}>
{SETUP_PERSONA_KEYS.map((k) => <option key={k} value={k}>{WIZ_PERSONA_LABEL[k]}</option>)}
</select>
</label>
<div className="setup-rec"></div>
<div className="setup-rec">{recommended.reason} · {WIZ_PERSONA_LABEL[recommended.persona] || recommended.persona}</div>
{/* 时长:15/30/45/60;每镜固定 15 秒 */}
<label className="setup-field">
<span className="sf-k"></span>
@@ -3343,10 +3348,11 @@ export function PipelinePage(props: {
{/* ← 返回:收起设定卡,回到「脚本辅助生成 / 上传脚本」入口页 */}
<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);
}}></button>
<button type="button" className="btn btn-primary btn-sm" disabled={loading || scriptFileBusy || videoDigestBusy || ((setupSource === "manual" || setupSource === "video") && !chatText.trim())} onClick={() => void runScriptWithSetup()}></button>
+13 -24
View File
@@ -4,10 +4,12 @@ import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react
import { ArrowLeft, Check, ChevronDown, Grid2X2, List, PackagePlus, PackageX, Search, Settings2, Trash2, X } from "lucide-react";
import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import { ProductCreateDrawer, PC_CAT_OPTIONS } from "../components/product-create-drawer";
import { ProductCreateDrawer } from "../components/product-create-drawer";
import {
BUSINESS_TYPES,
BUSINESS_TYPE_KEYS,
PC_CAT_MORE,
PC_CAT_PRIMARY,
catOptionsFor,
isLocalLife,
type BusinessType,
@@ -31,7 +33,6 @@ type ProductPayload = {
title?: string;
brand?: string;
category?: string;
target_audience?: string;
description?: string;
specs?: Record<string, unknown>;
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
@@ -441,7 +442,6 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
const [title, setTitle] = useState("");
const [category, setCategory] = useState("");
const [price, setPrice] = useState("");
const [audience, setAudience] = useState("");
const [points, setPoints] = useState<string[]>([]);
const [pointDraft, setPointDraft] = useState("");
@@ -469,7 +469,6 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
onCreate({
title,
category,
target_audience: audience,
specs: { source: "product-create-upload", ...(price ? { price } : {}) },
selling_points: points.map((item, index) => ({ title: item, detail: item, sort_order: index }))
});
@@ -529,7 +528,12 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
<label className="field-label"><span className="req">*</span></label>
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)} required>
<option value=""> </option>
{PC_CAT_OPTIONS.map((option) => <option key={option}>{option}</option>)}
<optgroup label="常用品类">
{PC_CAT_PRIMARY.map((option) => <option key={option}>{option}</option>)}
</optgroup>
<optgroup label="更多品类">
{PC_CAT_MORE.map((option) => <option key={option}>{option}</option>)}
</optgroup>
</select>
</div>
<div className="field field-last">
@@ -540,15 +544,15 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
</div>
{/* 卖点 & 人群 */}
{/* 卖点 */}
<div className="form-card form-card-wide">
<div className="card-h">
<h3> & </h3>
<h3></h3>
<span className="opt-tag"> · </span>
</div>
<div className="ai-tip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z" /></svg>
<span>, AI (<strong> / </strong> ) ,,</span>
<span>, AI (<strong> / </strong> ) </span>
</div>
<div className="field">
@@ -570,10 +574,6 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
</li>
</ul>
</div>
<div className="field field-last">
<label className="field-label"></label>
<input className="input" value={audience} onChange={(event) => setAudience(event.target.value)} placeholder="例: 22-32 岁女性、敏感肌、办公室通勤" />
</div>
</div>
{/* 底部操作 */}
@@ -598,7 +598,7 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
}
// 商品详情页 · 从 public/exact/product-detail.html 忠实转写。
// 名称 / 品类 / 目标人群 / 卖点 来自 product;商品图网格 / AI 素材卡 / 视频项目卡
// 名称 / 品类 / 卖点 来自 product;商品图网格 / AI 素材卡 / 视频项目卡
// 已接入真实数据(product.images + 团队 assets + 该商品 projects),保持设计稿像素布局。
// 素材审核状态(只读,反映团队审核结果,非用户可点切换)
type PdAssetStatus = "pass" | "fail" | "pending";
@@ -913,13 +913,11 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
const realCat = product.category || "";
const realType = (product.business_type === "local_life" ? "local_life" : "ecommerce") as BusinessType;
const isLocal = realType === "local_life";
const realTarget = product.target_audience || "";
const realBullets = product.selling_points.map((point) => point.title);
const [name, setName] = useState(realName);
const [cat, setCat] = useState(realCat);
const [bizType, setBizType] = useState<BusinessType>(realType);
const [target, setTarget] = useState(realTarget);
const [points, setPoints] = useState<string[]>(realBullets);
// R104:显式「添加卖点」按钮追加一条空的可编辑卖点行(行内回车同样追加),
@@ -939,7 +937,6 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
title: name,
business_type: bizType,
category: cat,
target_audience: target,
// 卖点整体替换(后端 present 即覆盖):发 {title,detail,sort_order} 不带 id;
// 过滤空行(点了「添加卖点」还没填的行),否则后端 title 非空校验会退 400
selling_points: points.map((item) => item.trim()).filter(Boolean).map((item, index) => ({ title: item, detail: item, sort_order: index })) as Product["selling_points"]
@@ -951,7 +948,6 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
setName(realName);
setCat(realCat);
setBizType(realType);
setTarget(realTarget);
setPoints(realBullets);
setEditing(false);
setActionsLockH(null);
@@ -1085,13 +1081,6 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
</select>
</div>
</div>
<div className="row" data-field="target">
<div className="k"></div>
<div className="v">
<span className="v-static">{realTarget}</span>
<input className="v-edit v-input" type="text" value={target} onChange={(event) => setTarget(event.target.value)} />
</div>
</div>
<div className="row" data-field="bullets">
<div className="k"></div>
<div className="v">
+1 -1
View File
@@ -442,7 +442,7 @@ const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/yz/video-free.jpg" },
{ title: "提炼提示词", desc: "从参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/yz/video-remix.jpg" },
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" },
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "videoReplace", image: "/assets/yz/video-replace.jpg" },
];
function isQuickCreateProject(project: Project) {
+81 -18
View File
@@ -4,6 +4,7 @@ import {
ArrowLeft,
Boxes,
Clapperboard,
ChevronRight,
ImagePlus,
LayoutPanelTop,
RefreshCw,
@@ -30,6 +31,12 @@ import {
type BillingRates,
} from "../components/free-create/constants";
import type { NavigateFn } from "./route-config";
import {
PC_CAT_MORE,
PC_CAT_MORE_LABEL,
PC_CAT_OPTIONS,
PC_CAT_PRIMARY,
} from "../product-business";
const PROGRESS_STEPS = [
{ label: "脚本", icon: ScrollText },
@@ -85,6 +92,10 @@ function historyBadge(item: QuickCreateJob) {
return "未完成";
}
function historyCover(item: QuickCreateJob) {
return item.product_images?.[0]?.url || "";
}
function historyVideoUrl(item: QuickCreateJob) {
return item.result?.video_url || item.result?.video_segments?.[0]?.video_url || "";
}
@@ -113,6 +124,8 @@ export function QuickCreatePage({
modelConfigs: ModelConfig[];
}) {
const [name, setName] = useState("");
const [category, setCategory] = useState("");
const [catMoreOpen, setCatMoreOpen] = useState(false);
const [images, setImages] = useState<File[]>([]);
const [savedImages, setSavedImages] = useState<Array<{ asset_id: string; url: string }>>([]);
const [sourceProductId, setSourceProductId] = useState("");
@@ -126,7 +139,7 @@ export function QuickCreatePage({
const [unavailableMessage, setUnavailableMessage] = useState("");
const [pollEpoch, setPollEpoch] = useState(0);
const [history, setHistory] = useState<QuickCreateJob[]>([]);
const [playing, setPlaying] = useState<{ url: string; poster: string; title: string } | null>(null);
const [playing, setPlaying] = useState<{ url: string; title: string } | null>(null);
const videoConfigs = useMemo(
() => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"),
[modelConfigs],
@@ -182,6 +195,10 @@ export function QuickCreatePage({
productPrefillDoneRef.current = true;
setName((current) => current || product.title || "");
setSourceProductId((current) => current || product.id);
if (product.category && PC_CAT_OPTIONS.includes(product.category)) {
setCategory((current) => current || product.category);
setCatMoreOpen(PC_CAT_MORE.includes(product.category));
}
setSavedImages((current) => {
if (current.length) return current;
return (product.images || [])
@@ -299,12 +316,12 @@ export function QuickCreatePage({
return () => window.removeEventListener("keydown", onKey);
}, [playing]);
function playClip(url?: string, poster?: string, title?: string) {
function playClip(url?: string, title?: string) {
if (!url) {
onNotify?.("info", "视频还不能播放,请稍后再试");
return;
}
setPlaying({ url, poster: poster || "", title: title || "预览视频" });
setPlaying({ url, title: title || "预览视频" });
}
function selectImages(files: FileList | null) {
@@ -372,6 +389,10 @@ export function QuickCreatePage({
onNotify?.("info", "请先填写商品名称并上传商品图片");
return;
}
if (!category) {
onNotify?.("info", "请选择商品品类,脚本会按品类来写");
return;
}
setSubmitting(true);
setJob(null);
setServiceUnavailable(false);
@@ -381,6 +402,7 @@ export function QuickCreatePage({
try {
const form = new FormData();
form.append("name", name.trim());
form.append("category", category);
form.append("aspect_ratio", aspectRatio);
form.append("resolution", resolution);
form.append("total_duration", String(totalDuration));
@@ -433,6 +455,8 @@ export function QuickCreatePage({
function clearDraft() {
setName("");
setCategory("");
setCatMoreOpen(false);
setImages([]);
setSavedImages([]);
setSourceProductId("");
@@ -501,6 +525,7 @@ export function QuickCreatePage({
const reviewBlocked = isReviewFailure(job?.error_message);
const displayUrls = [...savedImages.map((image) => image.url), ...imagePreviews];
const imageCount = savedImages.length + images.length;
const canStart = Boolean(name.trim() && category && imageCount && selectedVideoModel) && !reviewBlocked;
const canRetry = Boolean(name.trim() && imageCount && selectedVideoModel) && !reviewBlocked;
const activePhase = submitting ? 0 : Math.max(0, Math.min(4, job?.phase_index ?? 0));
const result = job?.result;
@@ -511,7 +536,6 @@ export function QuickCreatePage({
: [];
const clipUrls = new Set(videoClips.map((clip) => clip.video_url).filter(Boolean));
const mergedUrl = result?.final_video_url || (result?.video_url && !clipUrls.has(result.video_url) ? result.video_url : "");
const posterFallback = displayUrls[0] || "";
const sceneCount = Math.max(1, Math.round(totalDuration / 15));
const videoEstimate = estimateCost(
selectedVideoModel,
@@ -547,6 +571,46 @@ export function QuickCreatePage({
<input className="quick-name-input" autoComplete="off" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:净颜精华、轻醒咖啡" disabled={isGenerating} />
</label>
<div className="quick-field">
<span className="quick-field-label"><span></span><small> · </small></span>
<div className={`quick-cat-grid${catMoreOpen ? " is-more-open" : ""}`} role="listbox" aria-label="商品品类">
{PC_CAT_PRIMARY.map((option) => (
<button
type="button"
className={`chip quick-cat-chip${category === option ? " active" : ""}`}
key={option}
disabled={isGenerating}
aria-selected={category === option}
onClick={() => { setCategory(option); setCatMoreOpen(false); }}
>
{option}
</button>
))}
<button
type="button"
className={`chip quick-cat-chip quick-cat-more${catMoreOpen ? " is-open" : ""}`}
disabled={isGenerating}
aria-expanded={catMoreOpen}
onClick={() => setCatMoreOpen((open) => !open)}
>
<span>{PC_CAT_MORE_LABEL}</span>
<ChevronRight />
</button>
{catMoreOpen ? PC_CAT_MORE.map((option) => (
<button
type="button"
className={`chip quick-cat-chip${category === option ? " active" : ""}`}
key={option}
disabled={isGenerating}
aria-selected={category === option}
onClick={() => setCategory(option)}
>
{option}
</button>
)) : null}
</div>
</div>
<div className="quick-field">
<span className="quick-field-label"><span></span><small> · 9</small></span>
<div className={`quick-upload${imageCount ? " has-images" : ""}`}>
@@ -613,7 +677,7 @@ export function QuickCreatePage({
<X /><span>{cancelling ? "正在取消…" : "取消生成"}</span>
</button>
) : (
<button type="button" className="quick-generate-button" onClick={() => void startGeneration()} disabled={!canRetry}>
<button type="button" className="quick-generate-button" onClick={() => void startGeneration()} disabled={!canStart}>
<WandSparkles /><span>{`立即生成视频 · 消耗 ${estimatedPoints} 积分`}</span>
</button>
)}
@@ -646,24 +710,23 @@ export function QuickCreatePage({
<div className="quick-state quick-state-complete">
<div className={`quick-video-result-grid${videoClips.length === 1 ? " is-single" : ""}`}>
{videoClips.map((clip, index) => (
{videoClips.map((clip, index) => {
const clipUrl = clip.video_url || result?.video_url || "";
return (
<button
key={clip.id}
type="button"
className="quick-video-result-card"
onClick={() => playClip(clip.video_url || result?.video_url, clip.poster_url || result?.poster_url || posterFallback, `${index + 1}场视频`)}
onClick={() => playClip(clipUrl, `${index + 1}场视频`)}
>
<span className="quick-video-result-thumb">
{clip.poster_url || result?.poster_url || posterFallback ? (
<img src={clip.poster_url || result?.poster_url || posterFallback} alt={`${index + 1}场视频首帧`} />
) : clip.video_url ? (
<video src={clip.video_url} muted playsInline preload="metadata" />
) : null}
{clipUrl ? <video src={clipUrl} muted playsInline preload="metadata" /> : null}
<span className="quick-video-play" aria-hidden="true"><Play /></span>
</span>
<span className="quick-video-result-meta"><strong>{index + 1}</strong><small>{clip.duration_seconds || 15}</small></span>
</button>
))}
);
})}
</div>
<div className="quick-result-head">
<div>
@@ -676,7 +739,7 @@ export function QuickCreatePage({
<button type="button" className="secondary-action" onClick={resetResult}><RefreshCw /></button>
{videoClips.length > 1 ? (
mergedUrl ? (
<button type="button" className="secondary-action" onClick={() => playClip(mergedUrl, result?.poster_url || posterFallback, "完整视频")}>
<button type="button" className="secondary-action" onClick={() => playClip(mergedUrl, "完整视频")}>
<Play />
</button>
) : (
@@ -743,7 +806,7 @@ export function QuickCreatePage({
{history.length ? (
<div className="quick-history-list">
{history.map((item) => {
const poster = item.result?.poster_url || "";
const cover = historyCover(item);
const videoUrl = historyVideoUrl(item);
const duration = item.result?.duration_seconds || item.settings?.total_duration || 15;
const scenes = item.result?.video_segments?.length || Math.max(1, Math.round(duration / 15));
@@ -754,11 +817,11 @@ export function QuickCreatePage({
<button
type="button"
className="quick-history-thumb"
onClick={() => playClip(videoUrl, poster, historyTitle(item))}
onClick={() => playClip(videoUrl, historyTitle(item))}
aria-label={canPlay ? `播放${historyTitle(item)}` : historyTitle(item)}
disabled={!canPlay}
>
{poster ? <img src={poster} alt="" /> : <span className="quick-history-thumb-empty">{canPlay ? null : <Play />}</span>}
{cover ? <img src={cover} alt="" /> : <span className="quick-history-thumb-empty">{canPlay ? null : <Play />}</span>}
{canPlay ? <span className="quick-history-play" aria-hidden="true"><Play /></span> : null}
<small>{formatClock(duration)}</small>
</button>
@@ -798,7 +861,7 @@ export function QuickCreatePage({
<strong>{playing.title}</strong>
<button type="button" onClick={() => setPlaying(null)} aria-label="关闭播放"><X /></button>
</div>
<video src={playing.url} poster={playing.poster || undefined} controls autoPlay playsInline controlsList="nodownload" />
<video src={playing.url} controls autoPlay playsInline controlsList="nodownload" />
</div>
</div>
) : null}
+6 -1
View File
@@ -30,6 +30,7 @@ export type Page =
| "freeCreate"
| "quickCreate"
| "videoRemix"
| "videoReplace"
| "imageOptimize"
| "modelPhoto"
| "modelPhotoDemoA"
@@ -109,6 +110,7 @@ export const routeLabels: Record<Page, string> = {
freeCreate: "自由创作",
quickCreate: "极速成片",
videoRemix: "提炼提示词",
videoReplace: "视频复刻",
imageOptimize: "图片创作",
modelPhoto: "模特上身图",
modelPhotoDemoA: "模特图方案 A",
@@ -126,7 +128,7 @@ export function isPage(value: string): value is Page {
export function parentPage(page: Page): Page {
if (["productDetail", "productCreateUpload"].includes(page)) return "products";
if (["projectWizard", "freeCreate", "quickCreate", "videoRemix", "pipeline"].includes(page)) return "projects";
if (["projectWizard", "freeCreate", "quickCreate", "videoRemix", "videoReplace", "pipeline"].includes(page)) return "projects";
if (["imageOptimize", "modelPhoto", "modelPhotoDemoA", "modelPhotoDemoB", "platformCover"].includes(page)) {
return "assetFactory";
}
@@ -173,6 +175,7 @@ export function resolveRoute(): ResolvedRoute {
return { page: "quickCreate", authMode: "login", productId: search.get("product_id") || undefined, hash };
}
if (path === "/video-remix") return { page: "videoRemix", authMode: "login", hash };
if (path === "/video-replace") return { page: "videoReplace", authMode: "login", hash };
if (path === "/image-optimize") {
return { page: "imageOptimize", authMode: "login", productId: search.get("product_id") || undefined, hash };
}
@@ -220,6 +223,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
return options.productId ? `/quick-create?product_id=${encodeURIComponent(options.productId)}` : "/quick-create";
case "videoRemix":
return "/video-remix";
case "videoReplace":
return "/video-replace";
case "imageOptimize":
return options.productId ? `/image-optimize?product_id=${encodeURIComponent(options.productId)}` : "/image-optimize";
case "modelPhoto":
+768
View File
@@ -0,0 +1,768 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowUpRight,
Check,
ChevronRight,
Clapperboard,
Download,
FileVideo2,
ImagePlus,
LibraryBig,
Package,
RefreshCw,
Replace,
Upload,
X,
} from "lucide-react";
import { api, ApiError } from "../api";
import { MediaLightbox } from "../components/overlays";
import {
DEFAULT_BILLING_RATES,
FC_MODELS,
IMAGE_TYPES,
MAX_IMAGES,
checkRefFile,
estimateCost,
isInFlight,
type BillingRates,
} from "../components/free-create/constants";
import type { FreeVideoRef, FreeVideoTask, ModelConfig, Product } from "../types";
import type { NavigateFn } from "./route-config";
const JOB_KEY = "airshelf:video-replace-job";
const REMIX_MARK = "[视频复刻]";
const VIDEO_ACCEPT = "video/mp4,video/quicktime";
type ProductSource = "library" | "temporary" | "";
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 fileKey(file: File) {
return `${file.name}:${file.size}:${file.lastModified}`;
}
function ratioFromSize(width: number, height: number) {
if (!width || !height) return "9:16";
const r = width / height;
if (Math.abs(r - 9 / 16) < 0.08) return "9:16";
if (Math.abs(r - 16 / 9) < 0.08) return "16:9";
if (Math.abs(r - 1) < 0.08) return "1:1";
if (Math.abs(r - 3 / 4) < 0.08) return "3:4";
if (Math.abs(r - 4 / 3) < 0.08) return "4:3";
if (Math.abs(r - 21 / 9) < 0.12) return "21:9";
return width > height ? "16:9" : "9:16";
}
function ratioCopy(ratio: string) {
if (ratio === "9:16") return "竖屏 9:16";
if (ratio === "16:9") return "横屏 16:9";
return ratio;
}
function formatClock(seconds: number) {
const total = Math.max(0, Math.round(Number(seconds) || 0));
const minutes = Math.floor(total / 60);
const rest = total % 60;
return `${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
}
function clampDuration(seconds: number) {
const rounded = Math.round(Number(seconds) || 15);
return Math.min(15, Math.max(4, rounded || 15));
}
function isRemixTask(task: FreeVideoTask) {
return (task.prompt || "").startsWith(REMIX_MARK);
}
function productNameFromPrompt(prompt?: string) {
const match = (prompt || "").match(/商品:([^\n。]+)/);
return (match?.[1] || "").trim();
}
function remixTitle(task?: Partial<FreeVideoTask> | null) {
const name = productNameFromPrompt(task?.prompt);
return name ? `${name}视频复刻` : "视频复刻预览";
}
function remixSourceLabel(task?: Partial<FreeVideoTask> | null) {
const prompt = task?.prompt || "";
const name = productNameFromPrompt(prompt);
if (/商品库/.test(prompt) && name) return `商品库:${name}`;
return "临时商品素材";
}
function productCover(product: Product) {
return product.cover_preview_url || product.images?.find((image) => image.is_primary)?.preview_url || product.images?.[0]?.preview_url || "";
}
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}商品参考图,保留参考视频的人物、场景、镜头运动、剪辑节奏与口播氛围,将画面中的原商品完整替换为该商品。商品外观、材质、包装必须与参考图一致,不要改变原片构图和人物表演。`;
}
export function VideoReplacePage({
products: initialProducts = [],
modelConfigs = [],
onNotify,
onBack,
onTaskSettled,
}: {
products?: Product[];
modelConfigs?: ModelConfig[];
onNotify: (type: "success" | "error" | "info", text: string) => void;
onBack: () => void;
onTaskSettled?: () => void;
navigate?: NavigateFn;
}) {
const [products, setProducts] = useState(initialProducts);
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 [tempFiles, setTempFiles] = useState<File[]>([]);
const [tempPreviews, setTempPreviews] = useState<string[]>([]);
const [libraryOpen, setLibraryOpen] = useState(false);
const [pendingProductId, setPendingProductId] = useState("");
const [jobId, setJobId] = useState(readJobId);
const [job, setJob] = useState<FreeVideoTask | null>(null);
const [history, setHistory] = useState<FreeVideoTask[]>([]);
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("");
const videoConfigs = useMemo(
() => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"),
[modelConfigs],
);
const preferredModel = useMemo(
() => videoConfigs.find((config) => config.name === FC_MODELS[0].name) || videoConfigs[0],
[videoConfigs],
);
const productName = source === "library"
? (selectedProduct?.title || "")
: tempFiles[0]
? (tempFiles.length > 1
? `${tempFiles[0].name.replace(/\.[^.]+$/, "") || "临时商品素材"}${tempFiles.length}张参考图)`
: (tempFiles[0].name.replace(/\.[^.]+$/, "") || "临时商品素材"))
: "";
const productReady = source === "library" ? Boolean(selectedProduct) : tempFiles.length > 0;
const libraryPreview = selectedProduct ? productCover(selectedProduct) : "";
const aspectRatio = ratioFromSize(videoMeta.width, videoMeta.height);
const outputDuration = clampDuration(videoMeta.duration || 15);
const estimated = estimateCost(preferredModel, {
ratio: aspectRatio,
resolution: "720p",
duration: outputDuration,
refs: [
...(videoRef ? [{ type: "video", duration: videoRef.duration || videoMeta.duration }] : []),
...((source === "library" ? (selectedProduct?.images || []).slice(0, MAX_IMAGES) : tempFiles).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 panelClass = [
"video-result-panel replace-result-panel",
generating ? "is-generating" : "",
hasResult ? "has-result" : "",
].filter(Boolean).join(" ");
const generateLabel = generating
? "正在复刻…"
: hasResult
? `再次复刻 · 消耗 ${points} 积分`
: `开始复刻 · 消耗 ${points} 积分`;
const loadHistory = async () => {
try {
const data = await api.freeVideoTasks(0, 50);
setHistory((data.results || []).filter((item) => isRemixTask(item) && item.status === "succeeded"));
} catch {
/* 历史失败不挡当前复刻 */
}
};
useEffect(() => {
void api.products(100).then((payload) => setProducts(payload.results || [])).catch(() => undefined);
void api.billingConfig()
.then((config) => setBillingRates({
margin: Number(config.video_margin_multiplier) || DEFAULT_BILLING_RATES.margin,
rate: Number(config.points_per_yuan) || DEFAULT_BILLING_RATES.rate,
multiplier: Number(config.team_price_multiplier) || 1,
}))
.catch(() => undefined);
void loadHistory();
}, []);
useEffect(() => {
if (!initialProducts.length) return;
setProducts((current) => (current.length ? current : initialProducts));
}, [initialProducts]);
useEffect(() => {
const urls = tempFiles.map((file) => URL.createObjectURL(file));
setTempPreviews(urls);
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]);
useEffect(() => {
if (!jobId) return;
let cancelled = false;
let timer = 0;
const poll = async () => {
try {
const data = await api.pollFreeVideo(jobId);
if (cancelled) return;
setJob(data.task);
if (isInFlight(data.task.status)) {
timer = window.setTimeout(poll, 2500);
return;
}
if (data.task.status === "succeeded") {
if (completedNoticeRef.current !== data.task.id) {
completedNoticeRef.current = data.task.id;
onNotify("success", "视频复刻成片已生成");
onTaskSettled?.();
}
void loadHistory();
} else if (data.task.status === "failed") {
onNotify("error", data.task.error_message || "视频复刻未完成,请重试");
onTaskSettled?.();
}
forgetJob();
} catch (error) {
if (cancelled) return;
if (error instanceof ApiError && error.status === 404) {
setJob(null);
setJobId("");
forgetJob();
return;
}
timer = window.setTimeout(poll, 8000);
}
};
void poll();
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [jobId, onNotify, onTaskSettled]);
const pickVideo = async (file: File | null) => {
if (!file) return;
const check = await checkRefFile(file);
if (!check.ok) {
onNotify("error", check.error);
return;
}
if (check.type !== "video") {
onNotify("error", "只支持 mp4 / mov 视频");
return;
}
setVideoFile(file);
setVideoUploading(true);
setJob((current) => (current && isInFlight(current.status) ? current : null));
if (job && !isInFlight(job.status)) setJob(null);
const form = new FormData();
form.append("file", file);
try {
const uploaded = await api.uploadFreeVideoRef(form);
setVideoRef({
url: uploaded.url,
type: "video",
role: "reference_video",
label: "参考视频",
thumb_url: uploaded.thumb_url,
duration: uploaded.duration || check.duration,
asset_id: uploaded.asset_id,
source: "upload",
});
setVideoMeta({
duration: uploaded.duration || check.duration || 0,
width: uploaded.width || 0,
height: uploaded.height || 0,
});
} catch (error) {
setVideoFile(null);
setVideoRef(null);
onNotify("error", error instanceof Error ? error.message : "参考视频上传失败");
} finally {
setVideoUploading(false);
}
};
const addTempImages = (files: FileList | null) => {
const incoming = [...(files || [])].filter((file) => IMAGE_TYPES.includes(file.type));
if (!incoming.length) {
onNotify("error", "请选择 JPG、PNG 或 WebP 图片");
return;
}
setTempFiles((current) => {
const existing = new Set(current.map(fileKey));
const unique = incoming.filter((file) => {
const key = fileKey(file);
if (existing.has(key)) return false;
existing.add(key);
return true;
});
const room = Math.max(0, MAX_IMAGES - current.length);
if (room === 0) {
onNotify("info", "最多上传9张商品图片");
return current;
}
if (!unique.length) {
onNotify("info", "所选图片已在九宫格中");
return current;
}
if (unique.length > room) onNotify("info", `最多上传9张图片,已保留前${room}`);
return [...current, ...unique.slice(0, room)];
});
setSource("temporary");
setSelectedProduct(null);
if (job && !isInFlight(job.status)) setJob(null);
};
const confirmLibraryProduct = () => {
const product = products.find((item) => item.id === pendingProductId);
if (!product) {
onNotify("info", "请先选择或上传商品素材");
return;
}
setSelectedProduct(product);
setSource("library");
setTempFiles([]);
setLibraryOpen(false);
setPendingProductId("");
if (job && !isInFlight(job.status)) setJob(null);
onNotify("success", `已选择商品:${product.title}`);
};
const startGeneration = async () => {
if (!videoFile || !productReady || generating) return;
if (!preferredModel) {
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",
}];
}
if (!imageRefs.length) {
onNotify("error", "这个商品还没有可用图片");
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",
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);
rememberJob(data.task.id);
completedNoticeRef.current = "";
onNotify("success", "视频复刻任务已开始");
if (!isInFlight(data.task.status) && data.task.status === "succeeded") {
onNotify("success", "视频复刻成片已生成");
void loadHistory();
forgetJob();
}
} catch (error) {
onNotify("error", error instanceof Error ? error.message : "视频复刻提交失败");
} finally {
setSubmitting(false);
}
};
const downloadVideo = (url: string, title: string) => {
if (!url) return;
const link = document.createElement("a");
link.href = url;
link.download = `${title || "视频复刻"}.mp4`;
link.rel = "noopener";
document.body.appendChild(link);
link.click();
link.remove();
onNotify("success", "已开始下载视频复刻成片");
};
const openHistory = (item: FreeVideoTask) => {
setJob(item);
setJobId("");
forgetJob();
};
const cells = Array.from({ length: 9 }, (_, index) => tempFiles[index] || null);
return (
<div className="vrep-page">
<div className="vrep-inner">
<section className="video-tool-page replace-page">
<header className="page-header">
<div className="image-title-row">
<button type="button" className="image-back-button" aria-label="返回上一入口页面" onClick={onBack}>
<ArrowLeft />
</button>
<div className="page-heading">
<h1></h1>
</div>
</div>
</header>
<div className="video-flow-grid">
<section className="video-flow-panel replace-flow-panel">
<h2></h2>
<div className="video-flow-step">
<div className="video-flow-step-head">
<strong>1. </strong>
<span>MP4 / MOV · 60 </span>
</div>
<label className={`video-upload-field${videoFile ? " has-file" : ""}`}>
<input
ref={videoInputRef}
type="file"
accept={VIDEO_ACCEPT}
hidden
onChange={(event) => {
void pickVideo(event.target.files?.[0] || null);
event.target.value = "";
}}
/>
<span>
<FileVideo2 />
<strong>{videoFile ? videoFile.name : "点击上传参考视频"}</strong>
<small>{videoFile ? "视频已就绪,将自动识别原商品区域" : "系统将自动识别需要替换的商品区域"}</small>
</span>
</label>
</div>
<div className="video-flow-step">
<div className="video-flow-step-head">
<strong>2. </strong>
<span></span>
</div>
<div className="product-replace-options">
<button
type="button"
className={`replace-product-method${source === "library" ? " active" : ""}${libraryPreview ? " has-product-preview" : ""}`}
onClick={() => {
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>
</span>
<ChevronRight />
</button>
<label
className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempFiles.length ? " has-images" : ""}`}
>
<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>
</span>
<ImagePlus />
<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 ? (
<>
<img src={tempPreviews[index]} alt={file.name} />
<button
type="button"
className="replace-temporary-remove"
aria-label={`删除第${index + 1}张临时商品图片`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setTempFiles((current) => current.filter((_, itemIndex) => itemIndex !== index));
if (job && !isInFlight(job.status)) setJob(null);
onNotify("info", "已删除临时商品图片");
}}
>
×
</button>
</>
) : null}
</span>
))}
</span>
<span className="replace-temporary-more">
<span><ImagePlus /></span>
<strong></strong>
<span className="replace-temporary-count"> {tempFiles.length} / 9</span>
<button
type="button"
className="replace-temporary-clear"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setTempFiles([]);
if (source === "temporary") setSource("");
if (job && !isInFlight(job.status)) setJob(null);
onNotify("info", "已清空临时商品图片");
}}
>
</button>
</span>
</span>
<input
ref={tempInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
hidden
onChange={(event) => {
addTempImages(event.target.files);
event.target.value = "";
}}
/>
</label>
</div>
</div>
<div className="video-flow-actions replace-generate-action">
<button
type="button"
className="primary-action"
disabled={!videoFile || !productReady || generating}
onClick={() => void startGeneration()}
>
<Replace />
<span>{generateLabel}</span>
</button>
</div>
</section>
<aside className={panelClass} aria-live="polite">
<div className="video-result-placeholder">
<div>
<div className="replace-placeholder-visual">
<span><Replace /></span>
</div>
<strong></strong>
</div>
</div>
<div className="replace-generating-state" role="status" aria-live="polite">
<div className="replace-generating-content">
<div className="replace-generating-visual">
<span className="replace-generating-frame"><Clapperboard /></span>
<span className="replace-generating-product"><Package /></span>
</div>
<strong></strong>
<span></span>
<div className="replace-generating-bar" aria-hidden="true"><span /></div>
</div>
</div>
<div className="video-analysis-result">
<h2></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>
</div>
<div className="video-flow-actions replace-result-actions">
<button type="button" className="secondary-action" onClick={() => void startGeneration()}>
<RefreshCw />
</button>
<button type="button" className="primary-action" onClick={() => downloadVideo(job?.video_url || "", productName || "视频复刻")}>
<Download />
</button>
</div>
</div>
</aside>
</div>
<section className="replace-history-section" aria-labelledby="replaceHistoryTitle">
<div className="replace-history-head">
<h2 id="replaceHistoryTitle"></h2>
<span>{history.length} </span>
</div>
{history.length === 0 ? (
<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>
))}
</div>
)}
</section>
</section>
</div>
<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>
</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>
) : products.map((product) => (
<button
type="button"
className={`asset-library-choice${pendingProductId === product.id ? " selected" : ""}`}
key={product.id}
onClick={() => setPendingProductId(product.id)}
>
<span className="asset-choice-check"><Check /></span>
{productCover(product) ? <img src={productCover(product)} alt={product.title} /> : <img alt={product.title} />}
<span>
<strong>{product.title}</strong>
<small>{product.category || "未分类"} · {productImageCount(product)} </small>
</span>
</button>
))}
</div>
<footer className="asset-library-footer">
<button type="button" className="secondary-action" onClick={() => setLibraryOpen(false)}></button>
<button type="button" className="primary-action" onClick={confirmLibraryProduct}>
<Check />
<span>使</span>
</button>
</footer>
</aside>
</div>
<MediaLightbox
open={Boolean(playing?.url)}
src={playing?.url || ""}
kind="video"
name={playing?.title}
close={() => setPlaying(null)}
/>
</div>
);
}
+52 -26
View File
@@ -106,52 +106,78 @@ export function durationWarning(structure: VideoStructure, seconds: number): str
return `${VIDEO_STRUCTURES[structure]}低于 ${min} 秒会压不住,建议调长`;
}
// ── 2.3 按商品信息推荐默认组合 ──
// ── 2.3 按商品分类推荐默认组合 ──
type Combo = { format: PresentationFormat; structure: VideoStructure };
type Combo = { format: PresentationFormat; structure: VideoStructure; persona: string };
/** 品类关键词 → 推荐组合。命中第一条即用,没命中走兜底。 */
/** 与商品库品类一一对应。改这里时同步 backend script_agent.recommend_script_setup。 */
const CATEGORY_COMBOS: Record<string, Combo> = {
: { format: "oral", structure: "contrast", persona: "bestie" },
: { format: "oral", structure: "scene", persona: "bestie" },
: { format: "oral", structure: "scene", persona: "urban" },
: { format: "oral", structure: "pain", persona: "mom" },
: { format: "oral", structure: "review", persona: "reviewer" },
: { format: "oral", structure: "pain", persona: "mom" },
: { format: "oral", structure: "scene", persona: "urban" },
: { format: "oral", structure: "scene", persona: "ceo" },
: { format: "oral", structure: "review", persona: "reviewer" },
: { format: "oral", structure: "pain", persona: "mom" },
: { format: "oral", structure: "knowledge", persona: "urban" },
: { format: "oral", structure: "review", persona: "reviewer" },
: { format: "oral", structure: "pain", persona: "urban" },
};
/** 旧品类名 / 标题关键词兜底。命中第一条即用。 */
const CATEGORY_RULES: ReadonlyArray<{ keywords: string[]; combo: Combo }> = [
{ keywords: ["美妆", "护肤", "彩妆", "面膜", "精华", "洗护", "个护"], combo: { format: "oral", structure: "contrast" } },
{ keywords: ["保健", "营养", "膳食", "益生菌", "维生素"], combo: { format: "oral", structure: "review" } },
{ keywords: ["数码", "3c", "电子", "电器", "手机", "耳机", "相机", "工具"], combo: { format: "oral", structure: "review" } },
{ keywords: ["食品", "零食", "饮料", "咖啡", "茶", "生鲜", "酒"], combo: { format: "vlog", structure: "scene" } },
{ keywords: ["服饰", "女装", "男装", "服装", "鞋", "包", "配饰", "内衣"], combo: { format: "vlog", structure: "scene" } },
{ keywords: ["家居", "家纺", "收纳", "厨具", "清洁", "日用"], combo: { format: "drama", structure: "pain" } },
{ keywords: ["母婴", "宝宝", "儿童", "宠物"], combo: { format: "drama", structure: "pain" } },
{ keywords: ["户外", "露营", "运动", "健身"], combo: { format: "vlog", structure: "scene" } },
{ keywords: ["美妆", "护肤", "彩妆", "面膜", "精华", "洗护", "个护"], combo: CATEGORY_COMBOS["美妆个护"] },
{ keywords: ["保健", "营养", "膳食", "益生菌", "维生素"], combo: { format: "oral", structure: "review", persona: "reviewer" } },
{ keywords: ["数码", "3c", "电子", "电器", "手机", "耳机", "相机", "工具", "家电"], combo: CATEGORY_COMBOS["数码家电"] },
{ keywords: ["食品", "零食", "饮料", "咖啡", "茶", "生鲜", "酒"], combo: CATEGORY_COMBOS["食品饮料"] },
{ keywords: ["服饰", "女装", "男装", "服装", "鞋", "包", "配饰", "内衣"], combo: CATEGORY_COMBOS["服饰鞋包"] },
{ keywords: ["家居", "家纺", "收纳", "厨具", "清洁", "日用"], combo: CATEGORY_COMBOS["家居日用"] },
{ keywords: ["母婴", "宝宝", "儿童", "玩具"], combo: CATEGORY_COMBOS["母婴玩具"] },
{ keywords: ["宠物"], combo: CATEGORY_COMBOS["宠物用品"] },
{ keywords: ["户外", "露营", "运动", "健身"], combo: CATEGORY_COMBOS["运动健康"] },
];
const FALLBACK_COMBO: Combo = { format: "oral", structure: "pain" };
const FALLBACK_COMBO: Combo = { format: "oral", structure: "pain", persona: "urban" };
/** 按商品品类 + 人群推荐一组「表现形式 × 视频结构 × 时长」。纯启发式,用户随时可改。 */
export function recommendSetup(input: { category?: string; targetAudience?: string; title?: string }): Combo & {
/** 按商品品类推荐「表现形式 × 视频结构 × 人物 × 时长」。用户随时可改。 */
export function recommendSetup(input: { category?: string; title?: string }): Combo & {
duration: number;
reason: string;
} {
const haystack = [input.category, input.title, input.targetAudience]
const category = (input.category || "").trim();
const haystack = [category, input.title]
.filter(Boolean)
.join(" ")
.toLowerCase();
let combo = FALLBACK_COMBO;
let reason = "商品信息不足,先给一组最通用的";
for (const rule of CATEGORY_RULES) {
const hit = rule.keywords.find((word) => haystack.includes(word.toLowerCase()));
if (hit) {
combo = rule.combo;
reason = `按「${hit}」品类推荐`;
break;
if (category && CATEGORY_COMBOS[category]) {
combo = CATEGORY_COMBOS[category];
reason = `按「${category}」品类推荐`;
} else {
for (const rule of CATEGORY_RULES) {
const hit = rule.keywords.find((word) => haystack.includes(word.toLowerCase()));
if (hit) {
combo = rule.combo;
reason = `按「${hit}」品类推荐`;
break;
}
}
}
// 防御:规则表若被改出禁用组合,这里兜住
const structure = isForbidden(combo.format, combo.structure)
? allowedStructures(combo.format)[0]
// 自动推荐只给口播;Vlog / 短剧留给用户自己选。
const format: PresentationFormat = "oral";
const structure = isForbidden(format, combo.structure)
? allowedStructures(format)[0]
: combo.structure;
return {
format: combo.format,
format,
structure,
duration: recommendDuration(combo.format, structure),
persona: combo.persona,
duration: recommendDuration(format, structure),
reason,
};
}
File diff suppressed because it is too large Load Diff