完成极速成品和脚本优化
This commit is contained in:
@@ -72,9 +72,16 @@ function dashStageLabel(project: Project): string {
|
||||
};
|
||||
return map[project.current_stage] || "视频生成";
|
||||
}
|
||||
function isQuickCreateProject(project: Project) {
|
||||
if (project.quick_create) return true;
|
||||
if (project.metadata?.quick_create) return true;
|
||||
return / · 极速成片$/.test(project.name || "");
|
||||
}
|
||||
|
||||
function dashCardMeta(project: Project, productTitle: string): string {
|
||||
const shots = project.video_segment_count ?? project.video_segments?.length ?? 0;
|
||||
return ["专业创作", productTitle, shots ? `${shots} 镜` : null].filter(Boolean).join(" / ");
|
||||
const mode = isQuickCreateProject(project) ? "极速成片" : "专业创作";
|
||||
return [mode, productTitle, shots ? `${shots} 镜` : null].filter(Boolean).join(" / ");
|
||||
}
|
||||
|
||||
function EntryIcon({ name }: { name: "wand" | "folder" | "scan" | "replace" }) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } fr
|
||||
import { ArrowLeft, ArrowRight, Check, ChevronRight, Image, Info, LayoutList, Play, RefreshCw, Route, Sparkles, Upload, UsersRound, X } from "lucide-react";
|
||||
import { api, ApiError } from "../api";
|
||||
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, StoryboardShot, Team, TimelineSavePayload, User } from "../types";
|
||||
import { publicModelDisplayName } from "../model-display";
|
||||
import { isHiddenFromScriptPicker, publicModelDisplayName } from "../model-display";
|
||||
import { isPublicGenerationError, presentGenerationError } from "../generation-error";
|
||||
import type { Notice, Page } from "./route-config";
|
||||
import { stageOrder, statusPill } from "./stage-config";
|
||||
@@ -516,7 +516,8 @@ export function PipelinePage(props: {
|
||||
logout: () => void;
|
||||
onNotify?: (type: "success" | "error", text: string) => void;
|
||||
scriptModelName: string;
|
||||
textModels?: ModelConfig[]; onAdoptScript: (scriptId: string) => void | Promise<unknown>;
|
||||
textModels?: ModelConfig[];
|
||||
onAdoptScript: (scriptId: string) => void | Promise<unknown>;
|
||||
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
|
||||
onAddShot: (afterSegmentId: string, content?: { narration?: string; visual_prompt?: string }) => Promise<unknown>;
|
||||
onDeleteShot: (segmentId: string) => Promise<unknown>;
|
||||
@@ -1399,18 +1400,53 @@ export function PipelinePage(props: {
|
||||
} catch { /* localStorage 不可用则忽略 */ }
|
||||
}, [chatKey, chatMsgs]);
|
||||
const pushMsg = (role: "ai" | "user", text: string) => setChatMsgs((list) => [...list, { id: nextMsgId(), role, text, time: nowHm() }]);
|
||||
// 脚本模型下拉:用户可选 豆包/GPT-5.5/Gemini(空 = 用后端默认文本模型)
|
||||
// 脚本模型下拉:用户可选 豆包/GPT 等;Gemini 3.1(AirShelf Script)只给视频提炼,不出现在这里
|
||||
const [scriptModelId, setScriptModelId] = useState<string>("");
|
||||
const activeScriptModelId = scriptModelId || textModels?.[0]?.id || "";
|
||||
const scriptPickerModels = useMemo(
|
||||
() => (textModels || []).filter((model) => !isHiddenFromScriptPicker(model)),
|
||||
[textModels],
|
||||
);
|
||||
const activeScriptModelId = (
|
||||
scriptPickerModels.some((model) => model.id === scriptModelId) ? scriptModelId : ""
|
||||
) || scriptPickerModels[0]?.id || "";
|
||||
// 模型选择小按钮(输入框下方)· 自建 restraint 下拉(幽灵触发 + popover 菜单),不再用原生 select + inline
|
||||
const [modelMenuOpen, setModelMenuOpen] = useState(false);
|
||||
const activeModel = textModels?.find((m) => m.id === activeScriptModelId);
|
||||
const [modelMenuPos, setModelMenuPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
|
||||
const modelPickRef = useRef<HTMLDivElement>(null);
|
||||
const activeModel = scriptPickerModels.find((m) => m.id === activeScriptModelId);
|
||||
const activeModelName = publicModelDisplayName(activeModel);
|
||||
useEffect(() => {
|
||||
if (!modelMenuOpen) return;
|
||||
const close = (e: MouseEvent) => { if (!(e.target as HTMLElement).closest(".chat-model-pick")) setModelMenuOpen(false); };
|
||||
if (!modelMenuOpen) {
|
||||
setModelMenuPos(null);
|
||||
return;
|
||||
}
|
||||
const place = () => {
|
||||
const trigger = modelPickRef.current?.querySelector("button.chip");
|
||||
if (!(trigger instanceof HTMLElement)) return;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
setModelMenuPos({
|
||||
left: Math.min(rect.left, window.innerWidth - Math.max(200, rect.width) - 8),
|
||||
bottom: window.innerHeight - rect.top + 4,
|
||||
width: Math.max(200, rect.width),
|
||||
});
|
||||
};
|
||||
place();
|
||||
const close = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest(".chat-model-pick") || target.closest(".chat-model-menu")) return;
|
||||
setModelMenuOpen(false);
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setModelMenuOpen(false); };
|
||||
document.addEventListener("click", close);
|
||||
return () => document.removeEventListener("click", close);
|
||||
document.addEventListener("keydown", onKey);
|
||||
window.addEventListener("resize", place);
|
||||
window.addEventListener("scroll", place, true);
|
||||
return () => {
|
||||
document.removeEventListener("click", close);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("resize", place);
|
||||
window.removeEventListener("scroll", place, true);
|
||||
};
|
||||
}, [modelMenuOpen]);
|
||||
// 删除分镜的两步确认(行内变红,3 秒不二次点击自动复位;不用原生 confirm)
|
||||
const [armedDelete, setArmedDelete] = useState<string | null>(null);
|
||||
@@ -1660,9 +1696,10 @@ export function PipelinePage(props: {
|
||||
setChatText("");
|
||||
// 上传视频提炼:参考的是**结构与节奏**,画面里的商品是别人的,必须换成用户自己的商品重写
|
||||
await runScriptGeneration(
|
||||
`${base}\n以上是我拆解一条参考视频得到的分镜稿。请照搬它的叙事结构、镜头节奏和每镜的作用顺序,`
|
||||
+ `把内容整体换成我自己的商品重写一遍;参考视频里出现的商品、品牌、人物一律不要保留。`
|
||||
+ `原稿里标注「看不清 / 缺失 / 存疑」的地方,由你按我的商品补全。目标人群:${personaLabel}`,
|
||||
`${base}\n以上是我拆解一条参考视频得到的分镜稿。请照搬它的镜头顺序、每镜时长比例、景别、机位、运镜、人物动作和声音层次,`
|
||||
+ `把人物、商品、品牌、台词全部换成我自己的商品;参考视频里出现的商品、品牌、人物一律不要保留。`
|
||||
+ `写 visual 时把每镜的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注折进导演说明书,不要压成一句画面摘要。`
|
||||
+ `原稿里标注「无 / 不可见 / 听不清 / 存疑」的地方不要编造,由你按我的商品补全。目标人群:${personaLabel}`,
|
||||
`上传视频提炼 · ${combo}`,
|
||||
"video",
|
||||
);
|
||||
@@ -1782,8 +1819,8 @@ export function PipelinePage(props: {
|
||||
pushMsg(
|
||||
"ai",
|
||||
digest.input === "native_video"
|
||||
? `已拆出 ${digest.duration} 秒整段视频。拆解稿在下面输入框里,请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。`
|
||||
: `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。`
|
||||
? `已拆出 ${digest.duration} 秒整段视频。拆解稿在下面输入框里,请先逐镜核对景别、运镜、台词和声音再点确定。确认后我按你的商品改写脚本,镜头节奏会跟稿子对齐。`
|
||||
: `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,请先逐镜核对景别、运镜、台词和声音再点确定。确认后我按你的商品改写脚本,镜头节奏会跟稿子对齐。`
|
||||
);
|
||||
chatTextareaRef.current?.focus();
|
||||
} catch (error) {
|
||||
@@ -3195,22 +3232,30 @@ export function PipelinePage(props: {
|
||||
: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m22 8-6 4 6 4V8Z" /><rect x="2" y="6" width="14" height="12" rx="2" /></svg>}
|
||||
</button>
|
||||
{/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 复用 restraint chip 下拉,向上展开 */}
|
||||
{textModels && textModels.length > 0 ? (
|
||||
<div className={`chip-wrap chat-model-pick${modelMenuOpen ? " open" : ""}`}>
|
||||
<button type="button" className="chip" title="选择脚本生成模型" onClick={() => setModelMenuOpen((v) => !v)}>
|
||||
{scriptPickerModels.length > 0 ? (
|
||||
<div ref={modelPickRef} className={`chip-wrap chat-model-pick${modelMenuOpen ? " open" : ""}`}>
|
||||
<button type="button" className="chip" title="选择脚本生成模型" aria-expanded={modelMenuOpen} aria-haspopup="listbox" onClick={() => setModelMenuOpen((v) => !v)}>
|
||||
{activeModelName}
|
||||
<svg className="caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu align-up">
|
||||
{textModels.map((m) => (
|
||||
<div key={m.id} className={`mi${m.id === activeScriptModelId ? " selected" : ""}`} role="menuitemradio" aria-checked={m.id === activeScriptModelId} tabIndex={0}
|
||||
onClick={() => { setScriptModelId(m.id); setModelMenuOpen(false); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setScriptModelId(m.id); setModelMenuOpen(false); } }}>
|
||||
{publicModelDisplayName(m)}
|
||||
<svg className="mi-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{modelMenuOpen && modelMenuPos && createPortal(
|
||||
<div
|
||||
className="chip-menu chat-model-menu"
|
||||
role="listbox"
|
||||
aria-label="脚本生成模型"
|
||||
style={{ left: modelMenuPos.left, bottom: modelMenuPos.bottom, minWidth: modelMenuPos.width }}
|
||||
>
|
||||
{scriptPickerModels.map((m) => (
|
||||
<div key={m.id} className={`mi${m.id === activeScriptModelId ? " selected" : ""}`} role="option" aria-selected={m.id === activeScriptModelId} tabIndex={0}
|
||||
onClick={() => { setScriptModelId(m.id); setModelMenuOpen(false); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setScriptModelId(m.id); setModelMenuOpen(false); } }}>
|
||||
{publicModelDisplayName(m)}
|
||||
<svg className="mi-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<span className="spacer"></span>
|
||||
@@ -3876,9 +3921,9 @@ export function PipelinePage(props: {
|
||||
)}
|
||||
{/* AI 生成片段不需单卡上传(自定义替换走 queue-bar 全局上传);移除多余「上传」按钮 */}
|
||||
<span className="spacer"></span>
|
||||
{url
|
||||
? <a className="btn btn-ghost btn-sm" href={url} target="_blank" rel="noreferrer" data-vstop>下载</a>
|
||||
: <button className="btn btn-ghost btn-sm" type="button" data-vstop disabled>下载</button>}
|
||||
{url && !showBusy ? (
|
||||
<a className="btn btn-ghost btn-sm" href={url} target="_blank" rel="noreferrer" data-vstop>下载</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ const PROD_PAGE_SIZE = 10;
|
||||
// 商品详情「AI 素材」grid 每页条数(4 列 → 3 整行)
|
||||
const MAT_PAGE_SIZE = 12;
|
||||
import type { Asset, Product, ProductMaterials, Project } from "../types";
|
||||
import type { NavigateFn, Page } from "./route-config";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
import "../product-create-page.css";
|
||||
|
||||
const PC_PHOTO_SLOTS = ["主图", "细节 02", "细节 03", "细节 04", "细节 05"];
|
||||
@@ -87,7 +87,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
products: Product[];
|
||||
projects?: Project[];
|
||||
loading?: boolean;
|
||||
navigate: (page: Page) => void;
|
||||
navigate: NavigateFn;
|
||||
openProduct: (productId: string, tab?: "assets" | "videos") => void;
|
||||
onCreate: (payload: ProductPayload) => Promise<Product | null | undefined> | void;
|
||||
onUploadImage?: (productId: string, formData: FormData) => Promise<unknown> | void;
|
||||
@@ -132,8 +132,8 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
}
|
||||
};
|
||||
const [drawer, setDrawer] = useState(Boolean(autoOpenCreate));
|
||||
// 创建成功弹窗:存刚创建的商品名,非空即展示「继续创建 / 去新建项目」选择
|
||||
const [createdName, setCreatedName] = useState<string | null>(null);
|
||||
// 创建成功弹窗:存刚创建的商品,非空即展示「继续创建 / 极速成片 / 专业创作」
|
||||
const [createdProduct, setCreatedProduct] = useState<Product | null>(null);
|
||||
const [openChip, setOpenChip] = useState<"" | "cat" | "date" | "type">("");
|
||||
// 商品分类筛选改回多选:Set 存已选分类(空 = 全部),菜单含每项计数 + chip 上橙色计数徽标
|
||||
const [catFilter, setCatFilter] = useState<Set<string>>(new Set());
|
||||
@@ -351,14 +351,15 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
/>
|
||||
|
||||
<SuccessModal
|
||||
open={Boolean(createdName)}
|
||||
open={Boolean(createdProduct)}
|
||||
title="商品创建成功"
|
||||
detail={`「${createdName}」已加入商品库。你可以继续创建商品,或直接为它新建一个视频项目。`}
|
||||
close={() => setCreatedName(null)}
|
||||
detail={`「${createdProduct?.title}」已加入商品库。你可以继续创建商品,或直接为它生成视频。`}
|
||||
close={() => setCreatedProduct(null)}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn" type="button" onClick={() => { setCreatedName(null); setDrawer(true); }}>继续创建商品</button>
|
||||
<button className="btn btn-primary" type="button" onClick={() => { setCreatedName(null); navigate("projectWizard"); }}>去新建项目</button>
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -369,8 +370,8 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
close={() => setDrawer(false)}
|
||||
onCreate={onCreate}
|
||||
onUploadImage={onUploadImage}
|
||||
// 创建成功 → 弹「继续创建商品 / 去新建项目」选择弹窗(替代纯 toast)
|
||||
onCreated={(product) => setCreatedName(product.title)}
|
||||
// 创建成功 → 弹「继续创建商品 / 极速成片 / 专业创作」选择弹窗(替代纯 toast)
|
||||
onCreated={(product) => setCreatedProduct(product)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
@@ -1208,10 +1209,14 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
</div>
|
||||
<div className="qa-section">
|
||||
<div className="qa-section-h">视频生成</div>
|
||||
<div className="qa-row-1">
|
||||
<div className="qa-item primary" data-go="projects-new" role="button" tabIndex={0} onClick={() => navigate("projectWizard", { productId: product.id })}>
|
||||
<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>
|
||||
生成视频
|
||||
专业创作
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -435,7 +435,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: "projectWizard", 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 }> = [
|
||||
@@ -444,13 +444,24 @@ const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: strin
|
||||
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" },
|
||||
];
|
||||
|
||||
function isQuickCreateProject(project: Project) {
|
||||
if (project.quick_create) return true;
|
||||
if (project.metadata?.quick_create) return true;
|
||||
return / · 极速成片$/.test(project.name || "");
|
||||
}
|
||||
|
||||
function projectModeLabel(project: Project) {
|
||||
return isQuickCreateProject(project) ? "极速成片" : "专业创作";
|
||||
}
|
||||
|
||||
function projCardSub(project: Project, productTitle: string): string {
|
||||
if (project.status === "failed") return project.failure_reason || "生成失败,可重新拍摄";
|
||||
const mode = projectModeLabel(project);
|
||||
const shots = projShotMeta(project);
|
||||
const stage = projStageLabel(project);
|
||||
if (project.status === "completed") return `专业创作 · ${productTitle} · ${shots}`;
|
||||
if (project.status === "completed") return `${mode} · ${productTitle} · ${shots}`;
|
||||
const no = projStageNo(project);
|
||||
return `专业创作 · ${stage} · ${no} / ${PROJ_STAGE_TOTAL}`;
|
||||
return `${mode} · ${stage} · ${no} / ${PROJ_STAGE_TOTAL}`;
|
||||
}
|
||||
function projStageLabel(project: Project): string {
|
||||
if (project.status === "failed") return "失败";
|
||||
@@ -741,7 +752,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
||||
<p>{projCardSub(project, productTitle(project.product))}</p>
|
||||
<div className="vc-line">
|
||||
<span className={`vc-tag ${bucket}`}>{statusLabel}</span>
|
||||
<span className="vc-tag type">专业创作</span>
|
||||
<span className="vc-tag type">{projectModeLabel(project)}</span>
|
||||
</div>
|
||||
{bucket === "wip" && (
|
||||
<div className="vc-progress" aria-hidden="true"><span style={{ width: `${Math.round((no / PROJ_STAGE_TOTAL) * 100)}%` }} /></div>
|
||||
|
||||
@@ -1,78 +1,649 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowLeft, Sparkles, Trash2, Upload, WandSparkles } from "lucide-react";
|
||||
import type { Page } from "./route-config";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Clapperboard,
|
||||
Download,
|
||||
ImagePlus,
|
||||
RefreshCw,
|
||||
ScanSearch,
|
||||
ScrollText,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Play,
|
||||
Upload,
|
||||
UsersRound,
|
||||
WandSparkles,
|
||||
X,
|
||||
Columns2,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import { api, ApiError, type QuickCreateJob } from "../api";
|
||||
import type { ModelConfig } from "../types";
|
||||
import {
|
||||
DEFAULT_BILLING_RATES,
|
||||
estimateCost,
|
||||
FC_MODELS,
|
||||
modelLabel,
|
||||
type BillingRates,
|
||||
} from "../components/free-create/constants";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
|
||||
export function QuickCreatePage({ onBack }: { onBack: () => void; navigate?: (page: Page) => void }) {
|
||||
const QUICK_JOB_KEY = "airshelf:quick-create-job";
|
||||
const PROGRESS_STEPS = [
|
||||
{ label: "识别商品与卖点", icon: ScanSearch },
|
||||
{ label: "推荐脚本方向", icon: ScrollText },
|
||||
{ label: "匹配模特与场景", icon: UsersRound },
|
||||
{ label: "生成故事板与视频", icon: Clapperboard },
|
||||
];
|
||||
const QUICK_RATIOS = [
|
||||
{ value: "9:16", label: "9:16 竖屏" },
|
||||
{ value: "16:9", label: "16:9 横屏" },
|
||||
{ value: "1:1", label: "1:1 方形" },
|
||||
{ value: "3:4", label: "3:4 竖版" },
|
||||
{ value: "4:3", label: "4:3 横版" },
|
||||
{ value: "21:9", label: "21:9 超宽" },
|
||||
];
|
||||
const QUICK_RESOLUTIONS = [
|
||||
{ value: "480p", label: "480p 流畅" },
|
||||
{ value: "720p", label: "720p 高清" },
|
||||
{ value: "1080p", label: "1080p 超清" },
|
||||
{ value: "4k", label: "4K 超清" },
|
||||
];
|
||||
const QUICK_DURATIONS = [15, 30, 45, 60];
|
||||
|
||||
function modelResolutions(config: ModelConfig | undefined) {
|
||||
const capabilities = (config?.metadata?.capabilities || {}) as Record<string, unknown>;
|
||||
const nested = Array.isArray(capabilities.resolutions) ? capabilities.resolutions : [];
|
||||
const legacy = Array.isArray(config?.metadata?.resolutions) ? config.metadata.resolutions : [];
|
||||
return (nested.length ? nested : legacy).map(String);
|
||||
}
|
||||
|
||||
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 historyTitle(item: QuickCreateJob) {
|
||||
return (item.title || item.product_name || "极速成片").replace(/ · 极速成片$/, "");
|
||||
}
|
||||
|
||||
function savedJobId() {
|
||||
try {
|
||||
return localStorage.getItem(QUICK_JOB_KEY) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function QuickCreatePage({
|
||||
onBack,
|
||||
backLabel = "返回视频创作",
|
||||
initialProductId,
|
||||
navigate,
|
||||
onNotify,
|
||||
onProjectCreated,
|
||||
modelConfigs,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
backLabel?: string;
|
||||
initialProductId?: string;
|
||||
navigate: NavigateFn;
|
||||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||
onProjectCreated?: () => void;
|
||||
modelConfigs: ModelConfig[];
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
const [savedImages, setSavedImages] = useState<Array<{ asset_id: string; url: string }>>([]);
|
||||
const [sourceProductId, setSourceProductId] = useState("");
|
||||
const [preview, setPreview] = useState("");
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||
const [jobId, setJobId] = useState(savedJobId);
|
||||
const [job, setJob] = useState<QuickCreateJob | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [serviceUnavailable, setServiceUnavailable] = useState(false);
|
||||
const [unavailableMessage, setUnavailableMessage] = useState("");
|
||||
const [history, setHistory] = useState<QuickCreateJob[]>([]);
|
||||
const [playing, setPlaying] = useState<{ url: string; poster: string; title: string } | null>(null);
|
||||
const videoConfigs = useMemo(
|
||||
() => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"),
|
||||
[modelConfigs],
|
||||
);
|
||||
const preferredModel = useMemo(
|
||||
() => videoConfigs.find((config) => config.name === FC_MODELS[1].name) || videoConfigs[0],
|
||||
[videoConfigs],
|
||||
);
|
||||
const [aspectRatio, setAspectRatio] = useState("9:16");
|
||||
const [resolution, setResolution] = useState("720p");
|
||||
const [totalDuration, setTotalDuration] = useState(15);
|
||||
const [videoModelId, setVideoModelId] = useState("");
|
||||
const [billingRates, setBillingRates] = useState<BillingRates>(DEFAULT_BILLING_RATES);
|
||||
const completedNoticeRef = useRef("");
|
||||
const cancelRequestedRef = useRef(false);
|
||||
const notifyRef = useRef(onNotify);
|
||||
const projectCreatedRef = useRef(onProjectCreated);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!images[0]) {
|
||||
setPreview("");
|
||||
notifyRef.current = onNotify;
|
||||
projectCreatedRef.current = onProjectCreated;
|
||||
}, [onNotify, onProjectCreated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoModelId && preferredModel) setVideoModelId(preferredModel.id);
|
||||
}, [preferredModel, videoModelId]);
|
||||
|
||||
useEffect(() => {
|
||||
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 api.quickCreateHistory()
|
||||
.then((payload) => setHistory(payload.results || []))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialProductId || jobId) return;
|
||||
let cancelled = false;
|
||||
void api.product(initialProductId)
|
||||
.then((product) => {
|
||||
if (cancelled) return;
|
||||
setName((current) => current || product.title || "");
|
||||
setSourceProductId((current) => current || product.id);
|
||||
setSavedImages((current) => {
|
||||
if (current.length) return current;
|
||||
return (product.images || [])
|
||||
.filter((image) => image.asset)
|
||||
.slice(0, 9)
|
||||
.map((image) => ({ asset_id: image.asset, url: image.preview_url || "" }));
|
||||
});
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [initialProductId, jobId]);
|
||||
|
||||
const selectedVideoModel = useMemo(
|
||||
() => videoConfigs.find((config) => config.id === videoModelId) || preferredModel,
|
||||
[preferredModel, videoConfigs, videoModelId],
|
||||
);
|
||||
const supportedResolutions = useMemo(
|
||||
() => modelResolutions(selectedVideoModel),
|
||||
[selectedVideoModel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportedResolutions.length || supportedResolutions.includes(resolution)) return;
|
||||
setResolution(supportedResolutions.includes("720p") ? "720p" : supportedResolutions[0]);
|
||||
}, [resolution, supportedResolutions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!images.length) {
|
||||
setImagePreviews([]);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(images[0]);
|
||||
setPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
const urls = images.map((image) => URL.createObjectURL(image));
|
||||
setImagePreviews(urls);
|
||||
return () => urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
}, [images]);
|
||||
|
||||
function selectImages(files: FileList | null) {
|
||||
setImages(Array.from(files || []).slice(0, 9));
|
||||
useEffect(() => {
|
||||
setPreview(imagePreviews[0] || savedImages.find((image) => image.url)?.url || "");
|
||||
}, [imagePreviews, savedImages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) return;
|
||||
let cancelled = false;
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = await api.quickCreateStatus(jobId);
|
||||
if (cancelled) return;
|
||||
setJob(next);
|
||||
if (next.product_name) setName((current) => current || next.product_name);
|
||||
if (next.product_images?.length) {
|
||||
setSavedImages(next.product_images.filter((image) => image.asset_id));
|
||||
if (next.product_id) setSourceProductId(next.product_id);
|
||||
setImages([]);
|
||||
}
|
||||
if (next.settings) {
|
||||
setAspectRatio(next.settings.aspect_ratio);
|
||||
setResolution(next.settings.resolution);
|
||||
setTotalDuration(next.settings.total_duration);
|
||||
if (next.settings.video_model_config_id) setVideoModelId(next.settings.video_model_config_id);
|
||||
}
|
||||
if (next.status === "succeeded") {
|
||||
if (completedNoticeRef.current !== next.id) {
|
||||
completedNoticeRef.current = next.id;
|
||||
notifyRef.current?.("success", "极速成片已生成");
|
||||
projectCreatedRef.current?.();
|
||||
void api.quickCreateHistory().then((payload) => setHistory(payload.results || [])).catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (next.status === "failed" || next.status === "cancelled") return;
|
||||
timer = window.setTimeout(poll, 2500);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
// 极速任务按团队隔离。本机切换账号后,localStorage 里可能仍保留上个团队的任务 ID;
|
||||
// 404 不是生成失败,清掉这条旧记录即可,不能把“任务不存在”不断弹给新账号。
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
setJob(null);
|
||||
setJobId("");
|
||||
try {
|
||||
localStorage.removeItem(QUICK_JOB_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
notifyRef.current?.("info", "已清除其他账号的极速成片记录");
|
||||
return;
|
||||
}
|
||||
notifyRef.current?.("error", error instanceof Error ? error.message : "读取极速成片进度失败");
|
||||
timer = window.setTimeout(poll, 8000);
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setPlaying(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [playing]);
|
||||
|
||||
function playClip(url?: string, poster?: string, title?: string) {
|
||||
if (!url) {
|
||||
onNotify?.("info", "视频还不能播放,请稍后再试");
|
||||
return;
|
||||
}
|
||||
setPlaying({ url, poster: poster || "", title: title || "预览视频" });
|
||||
}
|
||||
|
||||
function selectImages(files: FileList | null) {
|
||||
const selected = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
|
||||
setImages((current) => {
|
||||
const room = Math.max(0, 9 - savedImages.length - current.length);
|
||||
const accepted = selected.slice(0, room);
|
||||
if (selected.length > room) onNotify?.("info", "最多上传9张图片,已保留前9张");
|
||||
return [...current, ...accepted];
|
||||
});
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
if (index < savedImages.length) {
|
||||
setSavedImages((current) => current.filter((_, imageIndex) => imageIndex !== index));
|
||||
return;
|
||||
}
|
||||
setImages((current) => current.filter((_, imageIndex) => imageIndex !== index - savedImages.length));
|
||||
}
|
||||
|
||||
function clearImages() {
|
||||
setSavedImages([]);
|
||||
setImages([]);
|
||||
}
|
||||
|
||||
async function startGeneration() {
|
||||
if (!name.trim() || (!images.length && !savedImages.length)) {
|
||||
onNotify?.("info", "请先填写商品名称并上传商品图片");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setJob(null);
|
||||
setServiceUnavailable(false);
|
||||
setUnavailableMessage("");
|
||||
cancelRequestedRef.current = false;
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("name", name.trim());
|
||||
form.append("aspect_ratio", aspectRatio);
|
||||
form.append("resolution", resolution);
|
||||
form.append("total_duration", String(totalDuration));
|
||||
if (videoModelId) form.append("video_model_config_id", videoModelId);
|
||||
if (sourceProductId && savedImages.length) {
|
||||
form.append("source_product_id", sourceProductId);
|
||||
savedImages.forEach((image) => form.append("image_asset_ids", image.asset_id));
|
||||
}
|
||||
images.forEach((image) => form.append("images", image));
|
||||
const created = await api.startQuickCreate(form);
|
||||
if (cancelRequestedRef.current) {
|
||||
try {
|
||||
await api.cancelQuickCreate(created.id);
|
||||
} catch {
|
||||
/* 启动刚成功但用户已点取消时,尽量停掉后台任务 */
|
||||
}
|
||||
resetResult();
|
||||
notifyRef.current?.("info", "已取消本次生成");
|
||||
return;
|
||||
}
|
||||
setJob(created);
|
||||
setJobId(created.id);
|
||||
setImages([]);
|
||||
setSavedImages(created.product_images || []);
|
||||
setSourceProductId(created.product_id || "");
|
||||
try {
|
||||
localStorage.setItem(QUICK_JOB_KEY, created.id);
|
||||
} catch {
|
||||
/* 本地存储不可用时只影响刷新恢复,不影响本次生成 */
|
||||
}
|
||||
onNotify?.("success", "极速成片任务已启动");
|
||||
onProjectCreated?.();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 503) {
|
||||
setServiceUnavailable(true);
|
||||
setUnavailableMessage(error.message || "极速成片服务暂不可用,请稍后再试");
|
||||
onNotify?.("info", error.message || "极速成片服务暂不可用,请稍后再试");
|
||||
} else {
|
||||
onNotify?.("error", error instanceof Error ? error.message : "极速成片启动失败");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetResult() {
|
||||
setJob(null);
|
||||
setJobId("");
|
||||
setCancelling(false);
|
||||
setServiceUnavailable(false);
|
||||
setUnavailableMessage("");
|
||||
completedNoticeRef.current = "";
|
||||
try {
|
||||
localStorage.removeItem(QUICK_JOB_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelGeneration() {
|
||||
if (cancelling) return;
|
||||
cancelRequestedRef.current = true;
|
||||
if (!jobId) {
|
||||
setSubmitting(false);
|
||||
resetResult();
|
||||
notifyRef.current?.("info", "已取消本次生成");
|
||||
return;
|
||||
}
|
||||
setCancelling(true);
|
||||
try {
|
||||
const next = await api.cancelQuickCreate(jobId);
|
||||
setJob(next);
|
||||
notifyRef.current?.("info", "已取消本次生成");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
resetResult();
|
||||
notifyRef.current?.("info", "已退出本次生成");
|
||||
return;
|
||||
}
|
||||
// 接口失败也不能把人锁在转圈里:清掉本地任务,允许重新开始。
|
||||
resetResult();
|
||||
notifyRef.current?.("info", error instanceof Error ? error.message : "已停止当前生成");
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isGenerating = submitting || job?.status === "queued" || job?.status === "running";
|
||||
const isComplete = job?.status === "succeeded";
|
||||
const isCancelled = job?.status === "cancelled";
|
||||
const isFailed = job?.status === "failed" || isCancelled || serviceUnavailable;
|
||||
const displayUrls = [...savedImages.map((image) => image.url), ...imagePreviews];
|
||||
const imageCount = savedImages.length + images.length;
|
||||
const canRetry = Boolean(name.trim() && imageCount && selectedVideoModel);
|
||||
const activePhase = submitting ? 0 : Math.max(0, Math.min(3, job?.phase_index ?? 0));
|
||||
const result = job?.result;
|
||||
const videoClips = result?.video_segments?.length
|
||||
? result.video_segments
|
||||
: result?.video_url
|
||||
? [{ id: "final", sort_order: 0, duration_seconds: result.duration_seconds, video_url: result.video_url, poster_url: result.poster_url }]
|
||||
: [];
|
||||
const sceneCount = Math.max(1, Math.round(totalDuration / 15));
|
||||
const videoEstimate = estimateCost(
|
||||
selectedVideoModel,
|
||||
{ ratio: aspectRatio, resolution, duration: totalDuration, refs: [] },
|
||||
billingRates,
|
||||
);
|
||||
// 商品理解、基础资产和每镜故事板在脚本生成前无法精确报价;按每场约60积分给出透明预估,最终按成功任务结算。
|
||||
const estimatedPoints = videoEstimate.points > 0 ? videoEstimate.points + sceneCount * 60 : sceneCount * 240;
|
||||
const shellClass = [
|
||||
"quick-create-shell",
|
||||
isGenerating ? "is-generating" : "",
|
||||
isComplete ? "is-complete" : "",
|
||||
isFailed ? "is-failed" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div className="quick-create-page">
|
||||
<header className="project-builder-header quick-create-header">
|
||||
<div className="project-builder-title">
|
||||
<button type="button" className="image-back-button" onClick={onBack} aria-label="返回工作台"><ArrowLeft /></button>
|
||||
<div><h1>极速成片</h1><p>输入商品名称并上传图片,系统将自动完成从商品理解到视频生成的全部流程</p></div>
|
||||
<button type="button" className="image-back-button" onClick={onBack} aria-label={backLabel}><ArrowLeft /></button>
|
||||
<div><h1>极速成片</h1></div>
|
||||
</div>
|
||||
<div className="project-builder-status"><strong>AI 自动编排</strong><span>· 无需逐步配置</span></div>
|
||||
</header>
|
||||
|
||||
<div className="quick-create-shell" id="quickCreateShell">
|
||||
<div className={shellClass} id="quickCreateShell">
|
||||
<section className="quick-create-panel quick-form-panel">
|
||||
<div className="quick-form-copy">
|
||||
<h2>告诉我们这是什么商品</h2>
|
||||
<p>系统会识别商品信息,自动选择带货结构、表现形式、模特和场景,并完成15秒竖屏视频。</p>
|
||||
</div>
|
||||
|
||||
<label className="quick-field">
|
||||
<span className="quick-field-label"><span>商品名称</span><small>必填</small></span>
|
||||
<input className="quick-name-input" autoComplete="off" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:净颜精华、轻醒咖啡" />
|
||||
<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>必填 · 最多9张</small></span>
|
||||
<label className={`quick-upload${preview ? " has-image" : ""}`}>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={(event) => selectImages(event.target.files)} />
|
||||
<span className="quick-upload-copy"><span className="quick-upload-icon"><Upload /></span><strong>上传商品图片</strong><small>建议包含清晰主图、包装和使用细节</small></span>
|
||||
<span className="quick-upload-preview">
|
||||
<img src={preview} alt="极速成片商品预览" />
|
||||
<span className="quick-image-count">{images.length}张图片</span>
|
||||
<button type="button" className="quick-image-clear" onClick={(event) => { event.preventDefault(); setImages([]); }} aria-label="删除已上传图片"><Trash2 /></button>
|
||||
</span>
|
||||
<div className={`quick-upload${imageCount ? " has-images" : ""}`}>
|
||||
<input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={(event) => { selectImages(event.target.files); event.currentTarget.value = ""; }} />
|
||||
{imageCount ? (
|
||||
<div className="quick-upload-filled">
|
||||
<div className="quick-image-grid" aria-label={`已上传 ${imageCount} 张商品图片`}>
|
||||
{Array.from({ length: 9 }, (_, index) => {
|
||||
const image = displayUrls[index];
|
||||
return <div key={image || savedImages[index]?.asset_id || `empty-${index}`} className={`quick-image-tile${index < imageCount ? " is-filled" : ""}`}>
|
||||
{index < imageCount ? <>{image ? <img src={image} alt={`商品图片 ${index + 1}`} /> : null}<button type="button" disabled={isGenerating} onClick={() => removeImage(index)} aria-label={`删除商品图片 ${index + 1}`}><X /></button></> : null}
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
<div className="quick-upload-more">
|
||||
<button type="button" className="quick-upload-trigger" onClick={() => imageInputRef.current?.click()} disabled={isGenerating || imageCount >= 9}>
|
||||
<span className="quick-upload-more-icon"><ImagePlus /></span>
|
||||
<strong>继续上传</strong>
|
||||
<small>已上传 {imageCount} / 9</small>
|
||||
</button>
|
||||
<button type="button" className="quick-clear-all" onClick={clearImages} disabled={isGenerating}>清空全部</button>
|
||||
</div>
|
||||
</div>
|
||||
) : <button type="button" className="quick-upload-copy" onClick={() => imageInputRef.current?.click()} disabled={isGenerating}><span className="quick-upload-icon"><Upload /></span><strong>上传商品图片</strong><small>建议包含清晰主图、包装和使用细节</small></button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quick-parameter-grid" aria-label="视频核心参数">
|
||||
<label className="quick-parameter-field">
|
||||
<span>视频比例</span>
|
||||
<select value={aspectRatio} onChange={(event) => setAspectRatio(event.target.value)} disabled={isGenerating}>
|
||||
{QUICK_RATIOS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="quick-parameter-field">
|
||||
<span>分辨率</span>
|
||||
<select value={resolution} onChange={(event) => setResolution(event.target.value)} disabled={isGenerating}>
|
||||
{QUICK_RESOLUTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value} disabled={Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value))}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="quick-parameter-field">
|
||||
<span>视频时长</span>
|
||||
<select value={totalDuration} onChange={(event) => setTotalDuration(Number(event.target.value))} disabled={isGenerating}>
|
||||
{QUICK_DURATIONS.map((duration) => <option key={duration} value={duration}>{duration / 15} 场({duration}s)</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="quick-parameter-field">
|
||||
<span>视频模型</span>
|
||||
<select value={videoModelId} onChange={(event) => setVideoModelId(event.target.value)} disabled={isGenerating || !videoConfigs.length}>
|
||||
{videoConfigs.length ? videoConfigs.map((config) => (
|
||||
<option key={config.id} value={config.id}>{FC_MODELS.find((item) => item.name === config.name)?.label || config.display_name || modelLabel(config.name)}</option>
|
||||
)) : <option value="">暂无可用视频模型</option>}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="quick-auto-note" aria-label="系统自动完成内容"><span>识别商品与卖点</span><span>推荐脚本方向</span><span>匹配模特与场景</span><span>生成故事板与视频</span></div>
|
||||
|
||||
<div className="quick-form-footer">
|
||||
<div className="quick-cost"><span>仅在视频生成成功后扣费</span><strong>预计 240 积分</strong></div>
|
||||
<button type="button" className="quick-generate-button" disabled><WandSparkles /><span>立即生成视频</span></button>
|
||||
{isGenerating ? (
|
||||
<button type="button" className="quick-cancel-button" onClick={() => void cancelGeneration()} disabled={cancelling}>
|
||||
<X /><span>{cancelling ? "正在取消…" : "取消生成"}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="quick-generate-button" onClick={() => void startGeneration()} disabled={!canRetry}>
|
||||
<WandSparkles /><span>{`立即生成视频 · 消耗 ${estimatedPoints} 积分`}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="quick-create-panel quick-status-panel" aria-live="polite">
|
||||
<div className="quick-state quick-state-ready">
|
||||
<div className="quick-ready-orbit"><span className="quick-ready-icon"><Sparkles /></span></div>
|
||||
<h2>系统会替你完成所有选择</h2>
|
||||
<p>上传商品后,AI 将根据品类、图片信息和适用场景自动编排完整视频,不需要理解复杂的制作参数。</p>
|
||||
<div className="quick-ready-tags"><span>自动推荐结构</span><span>自动选择表现形式</span><span>自动匹配资产</span><span>自动质量检查</span></div>
|
||||
<h2>核心参数可选,其余自动完成</h2>
|
||||
</div>
|
||||
|
||||
<div className="quick-state quick-state-generating">
|
||||
<div className="quick-generating-preview" aria-label="视频生成中"><div className="quick-preview-spinner" /></div>
|
||||
<div className="quick-generating-copy"><h2>正在为商品生成视频</h2><p>{job?.message || "正在生成视频并进行质量检查…"}</p></div>
|
||||
<div className="quick-progress-track">
|
||||
{PROGRESS_STEPS.map((step, index) => {
|
||||
const Icon = step.icon;
|
||||
const done = index < activePhase;
|
||||
const active = isGenerating && index === activePhase;
|
||||
return <div key={step.label} className={`quick-progress-node${active ? " active" : ""}${done ? " done" : ""}`}><span className="quick-progress-dot"><Icon /></span><strong>{step.label}</strong></div>;
|
||||
})}
|
||||
</div>
|
||||
<div className="quick-generating-actions">
|
||||
<button type="button" className="secondary-action" onClick={() => void cancelGeneration()} disabled={cancelling}>
|
||||
<X />{cancelling ? "正在取消…" : "取消生成"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quick-state quick-state-complete">
|
||||
<div className={`quick-video-result-grid${videoClips.length === 1 ? " is-single" : ""}`}>
|
||||
{videoClips.map((clip, index) => (
|
||||
<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 || preview, `第${index + 1}场视频`)}
|
||||
>
|
||||
<span className="quick-video-result-thumb">
|
||||
{clip.poster_url || preview ? <img src={clip.poster_url || preview} alt={`第${index + 1}场视频首帧`} /> : clip.video_url ? <video src={clip.video_url} 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><h2>{videoClips.length || 1} 个视频已生成</h2><p>{videoClips.length || 1}场 · 每场{videoClips[0]?.duration_seconds || 15}秒 · {result?.aspect_ratio || "9:16"} · {(result?.resolution || "720p").toUpperCase()} · {result?.video_model || "智能模型"}</p></div><span className="quick-result-badge">质量检查通过</span></div>
|
||||
<div className={`quick-result-actions${videoClips.length > 1 ? "" : " is-single"}`}>
|
||||
<button type="button" className="secondary-action" onClick={resetResult}><RefreshCw />重新生成</button>
|
||||
{videoClips.length > 1 ? (
|
||||
<button type="button" className="secondary-action" onClick={() => job && navigate("pipeline", { projectId: job.project_id })}><Columns2 />合并视频</button>
|
||||
) : null}
|
||||
{result?.video_url ? <a className="primary-action quick-download-action" href={result.video_url} target="_blank" rel="noreferrer" download><Download />下载视频</a> : <button type="button" className="primary-action" disabled><Download />下载视频</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quick-state quick-state-failed">
|
||||
<span className="quick-failed-icon"><AlertCircle /></span>
|
||||
<h2>{serviceUnavailable ? "极速成片暂不可用" : isCancelled ? "已取消本次生成" : "本次生成未完成"}</h2>
|
||||
<p>{serviceUnavailable ? (unavailableMessage || "后台生成服务未就绪,请确认 Celery worker 已启动后再试。") : job?.error_message || "生成过程中遇到问题,可以重试或重新开始。"}</p>
|
||||
<div className="quick-failed-actions">
|
||||
{canRetry && !serviceUnavailable ? (
|
||||
<button type="button" className="primary-action" onClick={() => void startGeneration()}><RefreshCw />重试</button>
|
||||
) : null}
|
||||
<button type="button" className={canRetry && !serviceUnavailable ? "secondary-action" : "primary-action"} onClick={resetResult}>
|
||||
<RefreshCw />重新开始
|
||||
</button>
|
||||
{job?.project_id ? (
|
||||
<button type="button" className="secondary-action" onClick={() => navigate("pipeline", { projectId: job.project_id })}>
|
||||
<SlidersHorizontal />进入专业模式
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="quick-history" aria-label="过往极速成片项目">
|
||||
<div className="quick-history-head">
|
||||
<h2>过往极速成片项目</h2>
|
||||
<span>{history.length}个项目</span>
|
||||
</div>
|
||||
{history.length ? (
|
||||
<div className="quick-history-list">
|
||||
{history.map((item) => {
|
||||
const poster = item.result?.poster_url || "";
|
||||
const videoUrl = item.result?.video_url || item.result?.video_segments?.[0]?.video_url || "";
|
||||
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));
|
||||
return (
|
||||
<article key={item.id} className="quick-history-card">
|
||||
<button
|
||||
type="button"
|
||||
className="quick-history-thumb"
|
||||
onClick={() => playClip(videoUrl, poster, historyTitle(item))}
|
||||
aria-label={`播放${historyTitle(item)}`}
|
||||
>
|
||||
{poster ? <img src={poster} alt="" /> : <span className="quick-history-thumb-empty"><Play /></span>}
|
||||
<small>{formatClock(duration)}</small>
|
||||
</button>
|
||||
<div className="quick-history-copy">
|
||||
<span className="quick-history-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>
|
||||
</div>
|
||||
<button type="button" className="quick-history-open" onClick={() => navigate("pipeline", { projectId: item.project_id })}>
|
||||
<ArrowUpRight />查看项目
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="quick-history-empty">还没有完成的极速成片,生成成功后会出现在这里。</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{playing ? (
|
||||
<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">
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,7 +165,9 @@ export function resolveRoute(): ResolvedRoute {
|
||||
if (path === "/messages") return { page: "messages", authMode: "login", hash };
|
||||
if (path === "/asset-factory") return { page: "assetFactory", authMode: "login", hash };
|
||||
if (path === "/free-create") return { page: "freeCreate", authMode: "login", hash };
|
||||
if (path === "/quick-create") return { page: "quickCreate", authMode: "login", hash };
|
||||
if (path === "/quick-create") {
|
||||
return { page: "quickCreate", authMode: "login", productId: search.get("product_id") || undefined, hash };
|
||||
}
|
||||
if (path === "/video-remix") return { page: "videoRemix", authMode: "login", hash };
|
||||
if (path === "/image-optimize") {
|
||||
return { page: "imageOptimize", authMode: "login", productId: search.get("product_id") || undefined, hash };
|
||||
@@ -211,7 +213,7 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
|
||||
case "freeCreate":
|
||||
return "/free-create";
|
||||
case "quickCreate":
|
||||
return "/quick-create";
|
||||
return options.productId ? `/quick-create?product_id=${encodeURIComponent(options.productId)}` : "/quick-create";
|
||||
case "videoRemix":
|
||||
return "/video-remix";
|
||||
case "imageOptimize":
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
ArrowRight,
|
||||
BadgeCheck,
|
||||
Clock3,
|
||||
Copy,
|
||||
FileText,
|
||||
FileVideo,
|
||||
FileVideo2,
|
||||
PanelsTopLeft,
|
||||
Play,
|
||||
RectangleVertical,
|
||||
Save,
|
||||
ScanLine,
|
||||
@@ -15,45 +18,15 @@ import {
|
||||
TextCursorInput,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import type { ModelConfig } from "../types";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
import type { ModelConfig, VideoDigestHistory } from "../types";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
|
||||
export const REMIX_PROMPT_KEY = "fc-remix-prompt";
|
||||
const REMIX_DRAFT_KEY = "vr-digest-draft";
|
||||
const VIDEO_DIGEST_POINTS = 30;
|
||||
|
||||
type RemixDraft = {
|
||||
prompt: string;
|
||||
duration: number;
|
||||
shots: number;
|
||||
ratio: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fileKind: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function loadDraft(): RemixDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(REMIX_DRAFT_KEY);
|
||||
if (!raw) return null;
|
||||
const draft = JSON.parse(raw) as RemixDraft;
|
||||
if (typeof draft.prompt !== "string" || !draft.prompt.trim()) return null;
|
||||
return draft;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveDraft(draft: RemixDraft) {
|
||||
try {
|
||||
localStorage.setItem(REMIX_DRAFT_KEY, JSON.stringify(draft));
|
||||
localStorage.setItem(REMIX_PROMPT_KEY, draft.prompt);
|
||||
} catch {
|
||||
/* 隐私模式写满时静默,内存态仍可用 */
|
||||
}
|
||||
}
|
||||
type ProgressStage = "upload" | "analyze" | "prompt";
|
||||
|
||||
const VIDEO_DIGEST_MAX_BYTES = 200 * 1024 * 1024;
|
||||
|
||||
@@ -116,6 +89,20 @@ function fileMetaCopy(kind: string, size: number, width: number, height: number)
|
||||
return kind;
|
||||
}
|
||||
|
||||
function progressClass(step: ProgressStage, stage: ProgressStage) {
|
||||
const order: ProgressStage[] = ["upload", "analyze", "prompt"];
|
||||
const active = order.indexOf(stage);
|
||||
const index = order.indexOf(step);
|
||||
if (index < active) return "remix-progress-step done";
|
||||
if (index === active) return "remix-progress-step active";
|
||||
return "remix-progress-step";
|
||||
}
|
||||
|
||||
function historySummary(item: VideoDigestHistory) {
|
||||
const bits = [item.ratio || "—", item.shots ? `${item.shots} 个镜头` : "—", item.file_name || "参考视频.mp4"];
|
||||
return bits.join(" · ");
|
||||
}
|
||||
|
||||
export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: {
|
||||
textModels?: ModelConfig[];
|
||||
onNotify: (type: "success" | "error" | "info", text: string) => void;
|
||||
@@ -124,18 +111,22 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [draft] = useState(loadDraft);
|
||||
const [prompt, setPrompt] = useState(draft?.prompt || "");
|
||||
const [duration, setDuration] = useState(draft?.duration || 0);
|
||||
const [shots, setShots] = useState(draft?.shots || 0);
|
||||
const [ratio, setRatio] = useState(draft?.ratio || "");
|
||||
const [fileName, setFileName] = useState(draft?.fileName || "");
|
||||
const [fileSize, setFileSize] = useState(draft?.fileSize || 0);
|
||||
const [kind, setKind] = useState(draft?.fileKind || "MP4");
|
||||
const [width, setWidth] = useState(draft?.width || 0);
|
||||
const [height, setHeight] = useState(draft?.height || 0);
|
||||
const [hasResult, setHasResult] = useState(Boolean(draft?.prompt.trim()));
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [shots, setShots] = useState(0);
|
||||
const [ratio, setRatio] = useState("");
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [fileSize, setFileSize] = useState(0);
|
||||
const [kind, setKind] = useState("MP4");
|
||||
const [width, setWidth] = useState(0);
|
||||
const [height, setHeight] = useState(0);
|
||||
const [taskId, setTaskId] = useState("");
|
||||
const [hasResult, setHasResult] = useState(false);
|
||||
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 digestModels = useMemo(
|
||||
() => textModels.filter((model) => model.status === "active" && isGemini31(model)),
|
||||
@@ -150,20 +141,27 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
? VIDEO_DIGEST_POINTS
|
||||
: Math.max(1, Math.round(Number((VIDEO_DIGEST_POINTS * priceMultiplier).toFixed(6))));
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const data = await api.listVideoDigests();
|
||||
setHistory(data.results || []);
|
||||
} catch {
|
||||
/* 历史失败不挡当前拆解 */
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!prompt.trim()) return;
|
||||
saveDraft({
|
||||
prompt,
|
||||
duration,
|
||||
shots,
|
||||
ratio,
|
||||
fileName: file?.name || fileName,
|
||||
fileSize: file?.size || fileSize,
|
||||
fileKind: kind,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}, [prompt, duration, shots, ratio, file, fileName, fileSize, kind, width, height]);
|
||||
try {
|
||||
localStorage.removeItem(REMIX_DRAFT_KEY);
|
||||
} catch {
|
||||
/* 无痕模式忽略 */
|
||||
}
|
||||
void loadHistory();
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
}, [previewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = promptRef.current;
|
||||
@@ -192,6 +190,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
setRatio(ratioLabel(meta.width, meta.height));
|
||||
if (meta.duration) setDuration(Math.round(meta.duration));
|
||||
setHasResult(false);
|
||||
setTaskId("");
|
||||
};
|
||||
|
||||
const analyze = async () => {
|
||||
@@ -205,11 +204,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
const text = digest.text.trim();
|
||||
setPrompt(text);
|
||||
setDuration(digest.duration || duration);
|
||||
setShots(shotCount(text, digest.frames));
|
||||
setFileName(file.name);
|
||||
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();
|
||||
} catch (error) {
|
||||
onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
|
||||
} finally {
|
||||
@@ -221,6 +225,15 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
const text = prompt.trim();
|
||||
if (!text) return;
|
||||
sessionStorage.setItem(REMIX_PROMPT_KEY, text);
|
||||
if (taskId) {
|
||||
try {
|
||||
const saved = await api.saveVideoDigest(taskId, text);
|
||||
setHistory((items) => items.map((item) => (item.id === saved.id ? saved : item)));
|
||||
} catch {
|
||||
onNotify("error", "提示词保存失败,请重试");
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
onNotify("success", "提示词已保存,可继续生成或直接粘贴");
|
||||
@@ -236,10 +249,21 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
navigate("freeCreate");
|
||||
};
|
||||
|
||||
const copyHistoryPrompt = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
onNotify("success", "提示词已复制");
|
||||
} catch {
|
||||
onNotify("error", "复制失败,请手动选择文本");
|
||||
}
|
||||
};
|
||||
|
||||
const analyzeLabel = busy
|
||||
? "正在拆解…"
|
||||
: `生成这个需要 ${estimatedPoints} 积分`;
|
||||
|
||||
const stage: ProgressStage = busy ? "analyze" : hasResult ? "prompt" : file ? "analyze" : "upload";
|
||||
|
||||
return (
|
||||
<div className="vr-page">
|
||||
<div className="vr-inner">
|
||||
@@ -256,6 +280,21 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="remix-progress-strip" aria-label="提示词提炼进度">
|
||||
<div className={progressClass("upload", stage)} data-remix-progress="upload">
|
||||
<span className="remix-progress-dot">1</span>
|
||||
<span>上传视频</span>
|
||||
</div>
|
||||
<div className={progressClass("analyze", stage)} data-remix-progress="analyze">
|
||||
<span className="remix-progress-dot">2</span>
|
||||
<span>智能拆解</span>
|
||||
</div>
|
||||
<div className={progressClass("prompt", stage)} data-remix-progress="prompt">
|
||||
<span className="remix-progress-dot">3</span>
|
||||
<span>生成提示词</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="video-flow-grid remix-flow-grid">
|
||||
<section className="video-flow-panel video-remix-upload-panel">
|
||||
<h2>上传参考视频</h2>
|
||||
@@ -264,28 +303,40 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
<strong>参考视频</strong>
|
||||
<span>MP4 / MOV · 最长 60 秒</span>
|
||||
</div>
|
||||
<label className={`video-upload-field${file ? " has-file" : ""}`}>
|
||||
<input
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
void pickFile(event.target.files?.[0] || null);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<FileVideo />
|
||||
<strong>{file?.name || fileName || "点击上传参考视频"}</strong>
|
||||
<small>
|
||||
{file
|
||||
? "视频已就绪,可开始拆解"
|
||||
: fileName
|
||||
? "上次拆解还在,刷新不会丢。要重拆请再选一次文件"
|
||||
: "上传后自动识别镜头结构与内容节奏"}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
{file && previewUrl ? (
|
||||
<div className="video-upload-field has-file has-preview">
|
||||
<video src={previewUrl} controls playsInline preload="metadata" />
|
||||
<label className="remix-replace-video">
|
||||
<input
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/webm"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
void pickFile(event.target.files?.[0] || null);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
更换视频
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<label className="video-upload-field">
|
||||
<input
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/webm"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
void pickFile(event.target.files?.[0] || null);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<FileVideo />
|
||||
<strong>点击上传参考视频</strong>
|
||||
<small>上传后自动识别镜头结构与内容节奏</small>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="video-flow-actions remix-analyze-actions">
|
||||
<button type="button" className="primary-action" disabled={!file || busy} onClick={() => void analyze()}>
|
||||
@@ -363,8 +414,94 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="remix-history-section" aria-labelledby="remixHistoryTitle">
|
||||
<div className="remix-history-head">
|
||||
<h2 id="remixHistoryTitle">提取过的项目</h2>
|
||||
<span className="remix-history-count">{history.length} 个项目</span>
|
||||
</div>
|
||||
{history.length === 0 ? (
|
||||
<div className="remix-history-empty">还没有提取过的项目</div>
|
||||
) : (
|
||||
<div className="remix-history-list">
|
||||
{history.map((item) => {
|
||||
const open = openHistoryId === item.id;
|
||||
const promptId = `remix-history-prompt-${item.id}`;
|
||||
return (
|
||||
<article className="remix-history-card" key={item.id}>
|
||||
<div
|
||||
className="remix-history-cover is-playable"
|
||||
onClick={() => {
|
||||
if (item.video_url) {
|
||||
setPlaying(item);
|
||||
return;
|
||||
}
|
||||
onNotify("info", "这条没有保存原片,重新上传拆一次就能播放");
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.click();
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`播放 ${item.title}`}
|
||||
>
|
||||
{item.cover_url ? (
|
||||
<img src={item.cover_url} alt={`${item.title}封面`} />
|
||||
) : null}
|
||||
<span className="remix-history-play" aria-hidden="true"><Play /></span>
|
||||
<span className="remix-history-duration">{item.duration_label || "00:00"}</span>
|
||||
</div>
|
||||
<div className="remix-history-content">
|
||||
<div className="remix-history-copy">
|
||||
<span className="remix-history-status">{item.status || "已完成"}</span>
|
||||
<h3>{item.title}</h3>
|
||||
<p>{historySummary(item)}</p>
|
||||
</div>
|
||||
<div className="remix-history-actions">
|
||||
<time dateTime={item.created_date}>{item.created_date}</time>
|
||||
<button
|
||||
type="button"
|
||||
className="remix-history-open"
|
||||
aria-expanded={open}
|
||||
aria-controls={promptId}
|
||||
onClick={() => setOpenHistoryId(open ? "" : item.id)}
|
||||
>
|
||||
<FileText />
|
||||
<span>{open ? "收起提示词" : "查看提示词"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="remix-history-prompt" id={promptId} hidden={!open}>
|
||||
<div className="remix-history-prompt-head">
|
||||
<strong>提示词</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="remix-history-copy-button"
|
||||
onClick={() => void copyHistoryPrompt(item.prompt)}
|
||||
>
|
||||
<Copy />
|
||||
复制提示词
|
||||
</button>
|
||||
</div>
|
||||
<textarea readOnly value={item.prompt} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
<MediaLightbox
|
||||
open={Boolean(playing?.video_url)}
|
||||
src={playing?.video_url || ""}
|
||||
kind="video"
|
||||
name={playing?.title}
|
||||
close={() => setPlaying(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user