完成极速成品和脚本优化
This commit is contained in:
@@ -45,7 +45,7 @@ import {
|
||||
TeamPage
|
||||
} from "./routes";
|
||||
import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./routes/route-config";
|
||||
import { isOwnerOnlyPage, parentPage, pathForPage, resolveRoute } from "./routes/route-config";
|
||||
import { isOwnerOnlyPage, isPage, parentPage, pathForPage, resolveRoute, routeLabels } from "./routes/route-config";
|
||||
import { AdminApp } from "./routes/admin/admin-app";
|
||||
import { TrashPage } from "./routes/trash";
|
||||
import { ModelsPage } from "./routes/models";
|
||||
@@ -522,8 +522,8 @@ export function App() {
|
||||
setNotice({ type: "info", text: "当前账号暂无访问权限" });
|
||||
return;
|
||||
}
|
||||
// 图片创作只有显式入口才携带商品;从图片生成首页进入时不能继承全局当前商品。
|
||||
const productId = next === "imageOptimize" ? options.productId : (options.productId ?? activeProductId);
|
||||
// 图片创作 / 极速成片只有显式入口才携带商品;从工作台或视频创作进入时不能继承全局当前商品。
|
||||
const productId = next === "imageOptimize" || next === "quickCreate" ? options.productId : (options.productId ?? activeProductId);
|
||||
const projectId = options.projectId ?? activeProjectId;
|
||||
if (options.productId !== undefined) setActiveProductId(options.productId);
|
||||
if (options.projectId !== undefined) setActiveProjectId(options.projectId);
|
||||
@@ -566,8 +566,14 @@ export function App() {
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
}
|
||||
|
||||
function entryOrigin(fallback: Page = parentPage(page)): Page {
|
||||
const fromPage = readNavState(window.history.state)?.from?.page;
|
||||
return fromPage && isPage(fromPage) ? fromPage : fallback;
|
||||
}
|
||||
|
||||
function goBack(fallback: Page = parentPage(page)) {
|
||||
if (readNavState(window.history.state)?.from?.page) {
|
||||
const from = readNavState(window.history.state)?.from;
|
||||
if (from?.page && isPage(from.page)) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
@@ -900,7 +906,7 @@ export function App() {
|
||||
);
|
||||
case "productCreateUpload":
|
||||
// 设计稿:新建商品是商品库页上的右侧 Drawer(非独立整页),进入即自动打开
|
||||
// 创建成功后由 ProductsPage 弹「继续创建商品 / 去新建项目」选择弹窗,不再自动跳详情
|
||||
// 创建成功后由 ProductsPage 弹「继续创建商品 / 极速成片 / 专业创作」选择弹窗,不再自动跳详情
|
||||
return (
|
||||
<ProductsPage
|
||||
products={products}
|
||||
@@ -1048,7 +1054,17 @@ export function App() {
|
||||
case "freeCreate":
|
||||
return <FreeCreatePage modelConfigs={modelConfigs} onNotify={(type, text) => setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} onBack={() => goBack("projects")} />;
|
||||
case "quickCreate":
|
||||
return <QuickCreatePage onBack={() => goBack("projects")} />;
|
||||
return (
|
||||
<QuickCreatePage
|
||||
onBack={() => goBack(entryOrigin("projects"))}
|
||||
backLabel={`返回${routeLabels[entryOrigin("projects")]}`}
|
||||
initialProductId={route.productId}
|
||||
navigate={navigate}
|
||||
modelConfigs={modelConfigs}
|
||||
onNotify={(type, text) => setNotice({ type, text })}
|
||||
onProjectCreated={() => { void loadData(); }}
|
||||
/>
|
||||
);
|
||||
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 "imageOptimize":
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
TeamMember,
|
||||
User,
|
||||
UserPreference,
|
||||
VideoDigestHistory,
|
||||
VoiceoverInfo
|
||||
} from "./types";
|
||||
import type { PresentationFormat, VideoStructure } from "./script-setup";
|
||||
@@ -59,6 +60,51 @@ const FRIENDLY_AUTH_MISSING_MESSAGE = "登录状态异常,请刷新页面后
|
||||
|
||||
export type RememberInfo = { username: string; expireAt: number };
|
||||
|
||||
export type QuickCreateJob = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
product_id: string;
|
||||
product_name: string;
|
||||
title: string;
|
||||
product_images: Array<{ asset_id: string; url: string }>;
|
||||
status: "queued" | "running" | "succeeded" | "failed" | "cancelled";
|
||||
phase: "product" | "script" | "assets" | "production" | "complete";
|
||||
phase_index: number;
|
||||
progress: number;
|
||||
message: string;
|
||||
error_message: string;
|
||||
settings: {
|
||||
aspect_ratio: string;
|
||||
resolution: string;
|
||||
total_duration: number;
|
||||
video_model_config_id: string;
|
||||
video_model_name: string;
|
||||
video_model_label: string;
|
||||
};
|
||||
result: null | {
|
||||
video_url: string;
|
||||
poster_url: string;
|
||||
duration_seconds: number;
|
||||
aspect_ratio: string;
|
||||
resolution: string;
|
||||
video_model: string;
|
||||
structure: string;
|
||||
presentation: string;
|
||||
person: string;
|
||||
scene: string;
|
||||
video_segments: Array<{
|
||||
id: string;
|
||||
sort_order: number;
|
||||
duration_seconds: number;
|
||||
video_url: string;
|
||||
poster_url: string;
|
||||
}>;
|
||||
};
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at: string | null;
|
||||
};
|
||||
|
||||
export function getRemember(): RememberInfo | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(REMEMBER_KEY);
|
||||
@@ -317,6 +363,18 @@ export const api = {
|
||||
createProject(payload: { name: string; product: string; metadata?: Record<string, unknown> }) {
|
||||
return request<Project>("/api/projects/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
startQuickCreate(formData: FormData) {
|
||||
return request<QuickCreateJob>("/api/projects/quick-create/", { method: "POST", body: formData });
|
||||
},
|
||||
quickCreateStatus(jobId: string) {
|
||||
return request<QuickCreateJob>(`/api/projects/quick-create-status/${jobId}/`);
|
||||
},
|
||||
cancelQuickCreate(jobId: string) {
|
||||
return request<QuickCreateJob>(`/api/projects/quick-create-cancel/${jobId}/`, { method: "POST" });
|
||||
},
|
||||
quickCreateHistory() {
|
||||
return request<{ count: number; results: QuickCreateJob[] }>("/api/projects/quick-create-history/");
|
||||
},
|
||||
// 整体替换 metadata —— 调用方务必先展开现有 project.metadata 再合并,别把别的 key 冲掉
|
||||
updateProject(id: string, payload: { name?: string; metadata?: Record<string, unknown> }) {
|
||||
return request<Project>(`/api/projects/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
@@ -350,11 +408,37 @@ export const api = {
|
||||
},
|
||||
// 视频提炼页:不绑项目,整段视频交给 Gemini 出中文分镜稿。慢(约 1–2 分钟),固定 30 积分,失败退还。
|
||||
extractVideoDigest(formData: FormData) {
|
||||
return request<{ name: string; chars: number; text: string; frames: number; duration: number; input?: string; estimated_cost?: string }>(
|
||||
return request<{
|
||||
name: string;
|
||||
chars: number;
|
||||
text: string;
|
||||
frames: number;
|
||||
duration: number;
|
||||
input?: string;
|
||||
estimated_cost?: string;
|
||||
task_id?: string;
|
||||
title?: string;
|
||||
file_name?: string;
|
||||
ratio?: string;
|
||||
shots?: number;
|
||||
cover_url?: string;
|
||||
video_url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>(
|
||||
"/api/ai/video-digest/",
|
||||
{ method: "POST", body: formData }
|
||||
);
|
||||
},
|
||||
listVideoDigests() {
|
||||
return request<{ results: VideoDigestHistory[]; total: number }>("/api/ai/video-digest/");
|
||||
},
|
||||
saveVideoDigest(id: string, prompt: string) {
|
||||
return request<VideoDigestHistory>(`/api/ai/video-digest/${id}/`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ prompt }),
|
||||
});
|
||||
},
|
||||
// ── 套路模板(5.1 存 / 5.2 换商品重跑)· 团队隔离 ──
|
||||
// 存模板只抽套路不抽文案:后端从这版脚本抽镜数/每镜作用/节奏/商品露出/转化写法。
|
||||
saveProjectAsTemplate(projectId: string, payload: { name: string; script_version_id?: string }) {
|
||||
|
||||
@@ -16,6 +16,7 @@ const SHELL_COMMANDS: Command[] = [
|
||||
{ id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" },
|
||||
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" },
|
||||
{ id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" },
|
||||
{ id: "quick-create", group: "导航", label: "极速成片", sub: "上传商品图片,自动完成整条视频", page: "quickCreate", icon: "wand", key: "Q" },
|
||||
{ id: "video-remix", group: "导航", label: "提炼提示词", sub: "上传参考视频,提炼可编辑提示词", page: "videoRemix", icon: "scan", key: "R" },
|
||||
{ id: "library", group: "导航", label: "成品库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
|
||||
{ id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" },
|
||||
@@ -24,6 +25,7 @@ const SHELL_COMMANDS: Command[] = [
|
||||
{ id: "messages", group: "常用动作", label: "消息中心", sub: "任务提醒、协作评论、系统通知", page: "messages", icon: "bell", key: "M" },
|
||||
{ id: "new-product", group: "常用动作", label: "新建商品", sub: "从商品信息开始生成素材与视频", page: "productCreateUpload", icon: "productPlus" },
|
||||
{ id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" },
|
||||
{ id: "quick-create-action", group: "常用动作", label: "极速成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" },
|
||||
{ id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" },
|
||||
{ id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "适配电商平台封面与详情图", page: "platformCover", icon: "images" },
|
||||
{ id: "image-optimize", group: "常用动作", label: "图片创作", sub: "对话式生成、编辑", page: "imageOptimize", icon: "images" }
|
||||
@@ -392,6 +394,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
|
||||
models: "models",
|
||||
projects: "projects",
|
||||
projectWizard: "projects",
|
||||
quickCreate: "projects",
|
||||
pipeline: "projects",
|
||||
assetFactory: "assetFactory",
|
||||
imageOptimize: "assetFactory",
|
||||
|
||||
@@ -24,3 +24,8 @@ export function publicModelDisplayName(
|
||||
if (!model) return fallback;
|
||||
return publicModelRouteName(model.name, model.display_name?.trim() || model.name || fallback);
|
||||
}
|
||||
|
||||
/** Gemini 3.1 只给「提炼提示词」用,脚本助手下拉里不出现。 */
|
||||
export function isHiddenFromScriptPicker(model: Pick<ModelConfig, "name" | "display_name">) {
|
||||
return model.name === "gemini-3.1-pro-preview" || publicModelDisplayName(model) === "AirShelf Script";
|
||||
}
|
||||
|
||||
@@ -2120,3 +2120,19 @@
|
||||
.tpl-capture .v { flex: 1; color: var(--accent-black); word-break: break-word; }
|
||||
.tpl-note { margin: 10px 0 0; font-size: 12px; line-height: 1.7; color: var(--black-alpha-56); }
|
||||
.tpl-note .mono { font-family: var(--font-mono); font-size: 10px; color: var(--heat); margin-right: 4px; }
|
||||
|
||||
/* 脚本模型菜单挂到 body:输入卡 overflow:hidden 会裁掉向上展开的列表 */
|
||||
.chat-model-menu.chip-menu {
|
||||
--klein: #002fa7;
|
||||
position: fixed;
|
||||
top: auto;
|
||||
display: block;
|
||||
z-index: 9999;
|
||||
}
|
||||
.chat-model-menu.chip-menu .mi.selected {
|
||||
color: var(--klein) !important;
|
||||
background: rgba(0, 47, 167, 0.10) !important;
|
||||
}
|
||||
.chat-model-menu.chip-menu .mi.selected .mi-check {
|
||||
color: var(--klein) !important;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,117 @@
|
||||
/* 极速成片:按用户提供的「影擎」页面逐项转写,仅作用于该页面。 */
|
||||
.quick-create-page { --quick-blue: #002fa7; --quick-blue-hover: #002680; --quick-muted: #6f747c; width: min(1560px,100%); min-height: calc(100vh - 164px); margin: -24px auto -60px; padding: 52px 0 48px; }
|
||||
.quick-create-page { --quick-blue: #002fa7; --quick-blue-hover: #002680; --quick-muted: #6f747c; width: min(1560px,100%); min-height: calc(100vh - 164px); margin: -24px auto -60px; padding: 59px 0 48px; }
|
||||
.quick-create-page .project-builder-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 20px; }
|
||||
.quick-create-page .project-builder-title { display: flex; align-items: center; gap: 14px; }
|
||||
.quick-create-page .project-builder-title h1 { margin: 0 0 5px; color: #17181a; font-size: 28px; line-height: 1.2; font-weight: 700; }
|
||||
.quick-create-page .project-builder-title p { margin: 0; color: var(--quick-muted); font-size: 13px; }
|
||||
.quick-create-page .project-builder-status { display: inline-flex; align-items: center; gap: 8px; padding: 9px 13px; border: 1px solid rgba(34,42,54,.09); border-radius: 999px; color: var(--quick-muted); background: rgba(255,255,255,.76); font-size: 12px; }
|
||||
.quick-create-page .project-builder-title p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 18px; }
|
||||
.quick-create-page .project-builder-status { display: inline-flex; align-items: center; gap: 8px; margin-right: 5px; padding: 9px 15.6px; border: 1px solid rgba(34,42,54,.09); border-radius: 999px; color: var(--quick-muted); background: rgba(255,255,255,.76); font-size: 12px; line-height: 18px; }
|
||||
.quick-create-page .project-builder-status strong { color: var(--quick-blue); font-size: 13px; }
|
||||
.quick-create-page .image-back-button { width: 38px; height: 38px; display: grid; place-items: center; flex: 0 0 auto; margin-top: 2px; border: 1px solid rgba(0,47,167,.46); border-radius: 12px; color: var(--quick-blue); background: rgba(255,255,255,.82); cursor: pointer; box-shadow: 0 5px 14px rgba(0,47,167,.07); transition: background-color 180ms ease, transform 180ms ease; }
|
||||
.quick-create-page .image-back-button:hover { transform: translateX(-2px); border-color: var(--quick-blue); background: rgba(0,47,167,.055); }.quick-create-page .image-back-button svg { width: 19px; height: 19px; }
|
||||
.quick-create-header { margin-bottom: 22px !important; }
|
||||
.quick-create-header { margin-bottom: 16px !important; }
|
||||
.quick-create-page .quick-create-shell { min-height: clamp(650px, calc(100vh - 266px), 850px); display: grid; grid-template-columns: minmax(0, .92fr) minmax(0, 1.08fr); gap: 22px; }
|
||||
.quick-create-page .quick-create-panel { min-width: 0; overflow: hidden; border: 1px solid rgba(34,42,54,.10); border-radius: 18px; background: rgba(255,255,255,.88); box-shadow: 0 16px 40px rgba(20,27,38,.09); }
|
||||
.quick-create-page .quick-form-panel { display: flex; flex-direction: column; padding: 30px; }
|
||||
.quick-create-page .quick-form-copy h2 { margin: 0 0 8px; font-size: 26px; }
|
||||
.quick-create-page .quick-form-panel { display: flex; flex-direction: column; padding: 25px 30px 30px; border-top: 3px solid var(--quick-blue); }
|
||||
.quick-create-page .quick-status-panel { border-top: 3px solid #111216; }
|
||||
.quick-create-page .quick-form-copy h2 { margin: 0; font-size: 26px; }
|
||||
.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: 24px; }
|
||||
.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-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; }
|
||||
.quick-create-page .quick-name-input:focus { border-color: rgba(0,47,167,.55); background: #fff; box-shadow: 0 0 0 4px rgba(0,47,167,.08); }
|
||||
.quick-create-page .quick-upload { position: relative; min-height: 190px; display: grid; place-items: center; overflow: hidden; border: 1px dashed rgba(0,47,167,.28); border-radius: 14px; background: radial-gradient(circle at center,rgba(0,47,167,.07),transparent 47%),rgba(248,249,252,.86); cursor: pointer; }
|
||||
.quick-create-page .quick-upload { position: relative; min-height: 190px; display: grid; place-items: center; overflow: hidden; border: 1px dashed rgba(0,47,167,.28); border-radius: 14px; background: radial-gradient(circle at center,rgba(0,47,167,.07),transparent 47%),rgba(248,249,252,.86); }
|
||||
.quick-create-page .quick-upload input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.quick-create-page .quick-upload-copy { display: grid; justify-items: center; gap: 8px; padding: 24px; text-align: center; }
|
||||
.quick-create-page .quick-upload-copy { display: grid; justify-items: center; gap: 8px; padding: 24px; border: 0; color: inherit; background: transparent; font: inherit; text-align: center; cursor: pointer; }
|
||||
.quick-create-page .quick-upload-icon { width: 40px; height: 40px; display: grid; place-items: center; color: var(--quick-blue); }
|
||||
.quick-create-page .quick-upload-icon svg { width: 34px; height: 34px; stroke-width: 1.75; }
|
||||
.quick-create-page .quick-upload-copy strong { font-size: 15px; }.quick-create-page .quick-upload-copy small { color: var(--quick-muted); font-size: 11px; }
|
||||
.quick-create-page .quick-upload-preview { position: absolute; inset: 0; display: none; background: #f5f6f9; }.quick-create-page .quick-upload.has-image .quick-upload-copy { display: none; }.quick-create-page .quick-upload.has-image .quick-upload-preview { display: block; }
|
||||
.quick-create-page .quick-upload-preview img { width: 100%; height: 100%; object-fit: cover; }.quick-create-page .quick-image-count { position: absolute; right: 12px; bottom: 12px; padding: 6px 9px; border-radius: 999px; color: #fff; background: rgba(16,16,18,.72); font-size: 11px; }
|
||||
.quick-create-page .quick-image-clear { position: absolute; top: 12px; right: 12px; width: 34px; height: 34px; display: grid; place-items: center; border: 0; border-radius: 10px; color: #fff; background: rgba(16,16,18,.72); cursor: pointer; }.quick-create-page .quick-image-clear svg { width: 16px; height: 16px; }
|
||||
.quick-create-page .quick-auto-note { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 8px; margin-top: 20px; }.quick-create-page .quick-auto-note span { min-height: 54px; display: grid; place-items: center; padding: 8px; border-radius: 10px; color: #4f5662; background: rgba(34,42,54,.045); text-align: center; font-size: 11px; line-height: 1.45; }
|
||||
.quick-create-page .quick-form-footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-top: auto; padding-top: 26px; }.quick-create-page .quick-cost strong,.quick-create-page .quick-cost span { display: block; }.quick-create-page .quick-cost span { color: var(--quick-muted); font-size: 11px; }.quick-create-page .quick-cost strong { margin-top: 5px; font-size: 14px; }
|
||||
.quick-create-page .quick-generate-button { min-width: 220px; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 12px; color: #fff; background: var(--quick-blue); font: inherit; font-weight: 700; cursor: pointer; box-shadow: 0 12px 24px rgba(0,47,167,.22); }.quick-create-page .quick-generate-button:disabled { color: #99a1ae; background: #e5e8ee; box-shadow: none; cursor: not-allowed; }.quick-create-page .quick-generate-button svg { width: 20px; height: 20px; }
|
||||
.quick-create-page .quick-status-panel { position: relative; display: grid; place-items: center; min-height: 0; padding: 34px; background: radial-gradient(circle at 50% 45%,rgba(0,47,167,.075),transparent 34%),rgba(250,251,253,.88); }.quick-create-page .quick-state { width: min(560px,100%); }.quick-create-page .quick-state-ready { display: grid; justify-items: center; text-align: center; }
|
||||
.quick-create-page .quick-upload.has-images { min-height: 268px; padding: 14px; background: rgba(248,249,252,.86); }
|
||||
.quick-create-page .quick-upload-filled { width: min(100%,492px); display: grid; grid-template-columns: 240px 240px; gap: 12px; }
|
||||
.quick-create-page .quick-image-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); grid-template-rows: repeat(3,minmax(0,1fr)); gap: 6px; padding: 8px; border-radius: 13px; background: rgba(255,255,255,.92); }
|
||||
.quick-create-page .quick-image-tile { position: relative; min-width: 0; aspect-ratio: 1; overflow: hidden; border: 1px dashed rgba(0,47,167,.15); border-radius: 8px; background: rgba(255,255,255,.48); }
|
||||
.quick-create-page .quick-image-tile img { width: 100%; height: 100%; display: block; object-fit: cover; }
|
||||
.quick-create-page .quick-image-tile button { position: absolute; top: 3px; right: 3px; width: 19px; height: 19px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 50%; color: #fff; background: rgba(25,28,34,.72); cursor: pointer; }.quick-create-page .quick-image-tile button svg { width: 12px; height: 12px; }
|
||||
.quick-create-page .quick-upload-more { min-height: 240px; display: grid; place-items: center; align-content: center; gap: 8px; border: 1px dashed rgba(0,47,167,.35); border-radius: 13px; background: rgba(243,247,255,.78); }
|
||||
.quick-create-page .quick-upload-trigger { display: grid; justify-items: center; gap: 8px; padding: 0; border: 0; color: #303746; background: transparent; font: inherit; cursor: pointer; }.quick-create-page .quick-upload-trigger strong { font-size: 14px; font-weight: 700; }.quick-create-page .quick-upload-trigger small { color: var(--quick-muted); font-size: 12px; }
|
||||
.quick-create-page .quick-upload-more-icon { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 12px; color: var(--quick-blue); background: #fff; box-shadow: 0 5px 16px rgba(0,47,167,.08); }.quick-create-page .quick-upload-more-icon svg { width: 24px; height: 24px; stroke-width: 1.75; }
|
||||
.quick-create-page .quick-clear-all { padding: 5px 9px; border: 0; border-radius: 7px; color: var(--quick-muted); background: rgba(255,255,255,.72); font: inherit; font-size: 11px; cursor: pointer; }
|
||||
.quick-create-page .quick-parameter-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; margin-top: 20px; }
|
||||
.quick-create-page .quick-parameter-field { min-width: 0; display: grid; gap: 9px; padding: 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 12px; background: rgba(248,249,252,.72); }
|
||||
.quick-create-page .quick-parameter-field > span { color: #30343a; font-size: 12px; font-weight: 700; }
|
||||
.quick-create-page .quick-parameter-field select { width: 100%; height: 38px; padding: 0 36px 0 13px; border: 1px solid rgba(34,42,54,.13); border-radius: 10px; outline: none; color: #272a30; background-color: rgba(255,255,255,.92); font: inherit; font-size: 12px; cursor: pointer; }
|
||||
.quick-create-page .quick-parameter-field select:focus { border-color: rgba(0,47,167,.55); box-shadow: 0 0 0 3px rgba(0,47,167,.08); }
|
||||
.quick-create-page .quick-parameter-field select:disabled { cursor: not-allowed; opacity: .65; }
|
||||
.quick-create-page .quick-form-footer { display: flex; margin-top: 26px; }
|
||||
.quick-create-page .quick-generate-button { width: 100%; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 12px; color: #fff; background: var(--quick-blue); font: inherit; font-weight: 700; cursor: pointer; box-shadow: 0 12px 24px rgba(0,47,167,.22); }.quick-create-page .quick-generate-button:disabled { color: #99a1ae; background: #e5e8ee; box-shadow: none; cursor: not-allowed; }.quick-create-page .quick-generate-button svg { width: 20px; height: 20px; }
|
||||
.quick-create-page .quick-cancel-button { width: 100%; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 1px solid rgba(34,42,54,.16); border-radius: 12px; color: #272a30; background: #fff; font: inherit; font-weight: 700; cursor: pointer; }.quick-create-page .quick-cancel-button:hover:not(:disabled) { border-color: rgba(200,61,77,.35); color: #c83d4d; background: rgba(200,61,77,.06); }.quick-create-page .quick-cancel-button:disabled { opacity: .55; cursor: not-allowed; }.quick-create-page .quick-cancel-button svg { width: 18px; height: 18px; }
|
||||
.quick-create-page .quick-create-shell.is-generating .quick-form-panel { opacity: .82; }
|
||||
.quick-create-page .quick-create-shell.is-generating .quick-form-footer { pointer-events: auto; opacity: 1; }
|
||||
.quick-create-page .quick-status-panel { position: relative; display: grid; place-items: center; min-height: 0; padding: 34px; background: radial-gradient(circle at 50% 45%,rgba(0,47,167,.075),transparent 34%),rgba(250,251,253,.88); }.quick-create-page .quick-state { width: min(560px,100%); display: none; }.quick-create-page .quick-state-ready { display: grid; justify-items: center; text-align: center; }
|
||||
.quick-create-page .quick-create-shell.is-generating .quick-state-ready,.quick-create-page .quick-create-shell.is-complete .quick-state-ready,.quick-create-page .quick-create-shell.is-failed .quick-state-ready { display: none; }
|
||||
.quick-create-page .quick-create-shell.is-generating .quick-state-generating { display: grid; }
|
||||
.quick-create-page .quick-create-shell.is-complete .quick-state-complete { display: grid; }
|
||||
.quick-create-page .quick-create-shell.is-failed .quick-state-failed { display: grid; }
|
||||
.quick-create-page .quick-ready-orbit { position: relative; width: 176px; height: 176px; display: grid; place-items: center; margin-bottom: 24px; border: 1px solid rgba(0,47,167,.14); border-radius: 50%; }.quick-create-page .quick-ready-orbit::before,.quick-create-page .quick-ready-orbit::after { content: ""; position: absolute; border: 1px solid rgba(0,47,167,.07); border-radius: 50%; }.quick-create-page .quick-ready-orbit::before { inset: 20px; }.quick-create-page .quick-ready-orbit::after { inset: -28px; }.quick-create-page .quick-ready-icon { width: 54px; height: 54px; display: grid; place-items: center; color: var(--quick-blue); }.quick-create-page .quick-ready-icon svg { width: 42px; height: 42px; stroke-width: 1.7; }
|
||||
.quick-create-page .quick-state-ready { position: relative; top: -10px; }
|
||||
.quick-create-page .quick-state-ready h2 { margin: 0 0 9px; font-size: 23px; }.quick-create-page .quick-state-ready > p { max-width: 430px; margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }.quick-create-page .quick-ready-tags { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 20px; }.quick-create-page .quick-ready-tags span { padding: 7px 10px; border: 1px solid rgba(34,42,54,.08); border-radius: 999px; color: #505762; background: rgba(255,255,255,.72); font-size: 11px; }
|
||||
.quick-create-page .quick-state-generating { width: min(460px,100%); justify-items: center; text-align: center; }
|
||||
.quick-create-page .quick-generating-preview { position: relative; width: 460px; max-width: 100%; height: 258px; display: grid; place-items: center; margin-bottom: 27px; border: 1px solid rgba(0,47,167,.16); border-radius: 15px; background: #edf4ff; box-shadow: 0 18px 34px rgba(0,47,167,.08); }.quick-create-page .quick-generating-preview::before { content: ""; position: absolute; inset: 18px; border-radius: 10px; background: #fff; }
|
||||
.quick-create-page .quick-preview-spinner { position: relative; z-index: 1; width: 62px; height: 62px; border-radius: 50%; background: conic-gradient(from 160deg,transparent 0deg,transparent 136deg,var(--quick-blue) 300deg,rgba(0,47,167,.12) 360deg); -webkit-mask: radial-gradient(circle,transparent 0 66%,#000 69%); mask: radial-gradient(circle,transparent 0 66%,#000 69%); animation: quick-spinner-rotate 1.1s linear infinite; }
|
||||
.quick-create-page .quick-generating-copy { width: 100%; text-align: center; }.quick-create-page .quick-generating-copy h2 { margin: 0 0 8px; font-size: 23px; }.quick-create-page .quick-generating-copy p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }
|
||||
.quick-create-page .quick-progress-track { width: 100%; display: flex; align-items: flex-start; margin-top: 28px; }.quick-create-page .quick-progress-node { position: relative; z-index: 0; flex: 1 1 0; display: grid; justify-items: center; color: #9aa1ab; font-size: 12px; }.quick-create-page .quick-progress-node:not(:last-child)::after { content: ""; position: absolute; z-index: -1; top: 23px; left: 50%; width: 100%; height: 2px; background: rgba(34,42,54,.12); }.quick-create-page .quick-progress-node.done:not(:last-child)::after { background: var(--quick-blue); }.quick-create-page .quick-progress-dot { width: 46px; height: 46px; display: grid; place-items: center; border: 5px solid rgba(0,47,167,.10); border-radius: 50%; color: #fff; background: var(--quick-blue); }.quick-create-page .quick-progress-dot svg { width: 18px; height: 18px; stroke-width: 1.8; }.quick-create-page .quick-progress-node strong { margin-top: 9px; max-width: 8em; text-align: center; font-size: 12px; font-weight: 700; line-height: 1.35; }.quick-create-page .quick-progress-node.done,.quick-create-page .quick-progress-node.active { color: var(--quick-blue); }.quick-create-page .quick-progress-node.active { color: #16171a; }.quick-create-page .quick-progress-node.active .quick-progress-dot { border-color: rgba(22,23,26,.12); background: #16171a; }
|
||||
.quick-create-page .quick-generating-actions { display: flex; justify-content: center; margin-top: 22px; }.quick-create-page .quick-generating-actions button { min-width: 132px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }.quick-create-page .quick-generating-actions svg { width: 17px; height: 17px; }
|
||||
.quick-create-page .quick-state-complete { width: min(560px,100%); gap: 17px; }
|
||||
.quick-create-page .quick-video-result-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; }
|
||||
.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-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; }
|
||||
.quick-create-page .quick-video-result-meta strong { font-size: 12px; font-weight: 600; }
|
||||
.quick-create-page .quick-video-result-meta small { color: var(--quick-muted); font-size: 11px; }
|
||||
.quick-create-page .quick-result-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.quick-create-page .quick-result-head h2,.quick-create-page .quick-result-head p { margin: 0; }.quick-create-page .quick-result-head h2 { font-size: 22px; }.quick-create-page .quick-result-head p { margin-top: 5px; color: var(--quick-muted); font-size: 11px; }
|
||||
.quick-create-page .quick-result-badge { padding: 7px 10px; border-radius: 999px; color: #168d43; background: rgba(22,184,78,.10); font-size: 11px; font-weight: 700; white-space: nowrap; }
|
||||
.quick-create-page .quick-result-actions { display: grid; grid-template-columns: auto auto minmax(160px,1fr); gap: 10px; }
|
||||
.quick-create-page .quick-result-actions.is-single { grid-template-columns: auto minmax(160px,1fr); }.quick-create-page .quick-result-actions button,.quick-create-page .quick-result-actions a { min-height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; text-decoration: none; }.quick-create-page .quick-result-actions svg { width: 17px; height: 17px; }
|
||||
.quick-create-page .primary-action,.quick-create-page .secondary-action { min-height: 44px; padding: 0 15px; border-radius: 8px; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; transition: background-color 180ms ease,border-color 180ms ease,color 180ms ease; }
|
||||
.quick-create-page .primary-action { border: 1px solid var(--quick-blue); color: #fff; background: var(--quick-blue); }.quick-create-page .primary-action:hover:not(:disabled) { border-color: var(--quick-blue-hover); background: var(--quick-blue-hover); }.quick-create-page .primary-action:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.quick-create-page .secondary-action { border: 1px solid rgba(34,42,54,.12); color: #272a30; background: rgba(255,255,255,.78); }.quick-create-page .secondary-action:hover:not(:disabled) { color: var(--quick-blue); border-color: rgba(0,47,167,.24); background: rgba(0,47,167,.05); }
|
||||
.quick-create-page .quick-download-action { border-radius: 8px; }
|
||||
.quick-create-page .quick-state-failed { justify-items: center; gap: 14px; text-align: center; }.quick-create-page .quick-state-failed h2,.quick-create-page .quick-state-failed p { margin: 0; }.quick-create-page .quick-state-failed p { max-width: 430px; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }.quick-create-page .quick-failed-icon { width: 82px; height: 82px; display: grid; place-items: center; border-radius: 50%; color: #c83d4d; background: rgba(200,61,77,.08); }.quick-create-page .quick-failed-icon svg { width: 36px; height: 36px; }.quick-create-page .quick-failed-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 8px; }.quick-create-page .quick-failed-actions button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }.quick-create-page .quick-failed-actions svg { width: 17px; height: 17px; }
|
||||
@keyframes quick-spinner-rotate { to { transform: rotate(360deg); } }
|
||||
.quick-create-page .quick-history { margin-top: 28px; }
|
||||
.quick-create-page .quick-history-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
||||
.quick-create-page .quick-history-head h2 { margin: 0; font-size: 20px; font-weight: 600; }
|
||||
.quick-create-page .quick-history-head span { color: var(--quick-muted); font-size: 13px; }
|
||||
.quick-create-page .quick-history-list { display: grid; gap: 10px; }
|
||||
.quick-create-page .quick-history-card { display: grid; grid-template-columns: 112px minmax(0,1fr) auto; align-items: center; gap: 16px; padding: 12px 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; background: #fff; box-shadow: inset 3px 0 0 var(--quick-blue); }
|
||||
.quick-create-page .quick-history-thumb { position: relative; width: 112px; aspect-ratio: 16/9; overflow: hidden; padding: 0; border: 0; border-radius: 8px; background: #eef1f5; cursor: pointer; }
|
||||
.quick-create-page .quick-history-thumb img { width: 100%; height: 100%; display: block; object-fit: cover; }
|
||||
.quick-create-page .quick-history-thumb-empty { width: 100%; height: 100%; display: grid; place-items: center; color: var(--quick-blue); }
|
||||
.quick-create-page .quick-history-thumb-empty svg { width: 18px; height: 18px; }
|
||||
.quick-create-page .quick-history-thumb small { position: absolute; right: 6px; bottom: 6px; padding: 1px 6px; border-radius: 4px; color: #fff; background: rgba(0,0,0,.62); font-size: 10px; line-height: 16px; }
|
||||
.quick-create-page .quick-history-copy { min-width: 0; }
|
||||
.quick-create-page .quick-history-badge { display: inline-flex; padding: 2px 8px; border-radius: 999px; color: var(--quick-blue); background: rgba(0,47,167,.08); font-size: 11px; font-weight: 600; }
|
||||
.quick-create-page .quick-history-copy h3 { margin: 6px 0 4px; font-size: 16px; font-weight: 600; }
|
||||
.quick-create-page .quick-history-copy p { margin: 0; color: var(--quick-muted); font-size: 12px; }
|
||||
.quick-create-page .quick-history-open { min-height: 36px; display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; border: 1px solid rgba(34,42,54,.12); border-radius: 8px; color: var(--quick-blue); background: #fff; font: inherit; font-size: 13px; cursor: pointer; }
|
||||
.quick-create-page .quick-history-open:hover { background: rgba(0,47,167,.05); }
|
||||
.quick-create-page .quick-history-open svg { width: 15px; height: 15px; }
|
||||
.quick-create-page .quick-history-empty { margin: 0; padding: 28px 8px; color: var(--quick-muted); font-size: 13px; }
|
||||
.quick-create-page .quick-player-bg { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 24px; background: rgba(22,23,26,.58); }
|
||||
.quick-create-page .quick-player { width: min(920px,100%); overflow: hidden; border-radius: 8px; background: #111216; }
|
||||
.quick-create-page .quick-player-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; color: #fff; }
|
||||
.quick-create-page .quick-player-bar strong { font-size: 13px; font-weight: 600; }
|
||||
.quick-create-page .quick-player-bar button { width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 8px; color: #fff; background: transparent; cursor: pointer; }
|
||||
.quick-create-page .quick-player-bar svg { width: 16px; height: 16px; }
|
||||
.quick-create-page .quick-player video { width: 100%; max-height: min(72vh, 620px); display: block; background: #000; }
|
||||
@media (max-width: 980px) { .quick-create-page .quick-history-card { grid-template-columns: 96px minmax(0,1fr); }.quick-create-page .quick-history-open { grid-column: 1 / -1; justify-self: start; } }
|
||||
@media (min-width: 1753px) { .quick-create-page { position: relative; left: -21.5px; } }
|
||||
@media (max-width: 1400px) { .quick-create-page .quick-create-shell { grid-template-columns: minmax(0,.9fr) minmax(0,1.1fr); }.quick-create-page .quick-form-panel,.quick-create-page .quick-status-panel { padding: 24px; } }
|
||||
@media (max-width: 980px) { .quick-create-page { padding-inline: 18px; }.quick-create-page .quick-create-shell { grid-template-columns: 1fr; }.quick-create-page .quick-create-panel { min-height: 620px; }.quick-create-page .quick-result-actions { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 620px) { .quick-create-page .quick-parameter-grid { grid-template-columns: 1fr; }.quick-create-page .quick-upload-filled { width: min(100%,240px); grid-template-columns: 1fr; }.quick-create-page .quick-upload-more { min-height: 148px; } }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
export type PresentationFormat = "oral" | "drama" | "vlog";
|
||||
export type VideoStructure = "pain" | "contrast" | "review" | "scene";
|
||||
export type VideoStructure = "pain" | "contrast" | "review" | "scene" | "promo" | "knowledge";
|
||||
|
||||
export const PRESENTATION_FORMATS: Record<PresentationFormat, string> = {
|
||||
oral: "口播",
|
||||
@@ -18,6 +18,8 @@ export const VIDEO_STRUCTURES: Record<VideoStructure, string> = {
|
||||
contrast: "前后对比",
|
||||
review: "测评验证",
|
||||
scene: "场景种草",
|
||||
promo: "促销抢购",
|
||||
knowledge: "知识分享",
|
||||
};
|
||||
|
||||
export const PRESENTATION_KEYS = Object.keys(PRESENTATION_FORMATS) as PresentationFormat[];
|
||||
@@ -55,6 +57,8 @@ export const STRUCTURE_HINT: Record<VideoStructure, string> = {
|
||||
contrast: "用前后差距说话,视觉冲击最强",
|
||||
review: "当场举证验证,适合高客单价",
|
||||
scene: "把商品放进向往的场景里种草",
|
||||
promo: "优惠机制清楚时,直接讲怎么买最划算",
|
||||
knowledge: "先教用户怎么判断,再自然带出商品",
|
||||
};
|
||||
|
||||
/** 唯一禁用组合:短剧 × 测评验证 —— 演出来的实测没有可信度。 */
|
||||
@@ -83,7 +87,7 @@ export const DURATION_OPTIONS: number[] = Array.from(
|
||||
/** 表现形式推荐的默认总时长:口播短平快 / 短剧要装下三幕 / Vlog 要铺氛围。 */
|
||||
const FORMAT_DEFAULT_DURATION: Record<PresentationFormat, number> = { oral: 30, drama: 45, vlog: 30 };
|
||||
/** 各结构能压到的最短总时长 —— 低于它证据或氛围就不成立了。须落在 15 秒步进上。 */
|
||||
const STRUCTURE_MIN_DURATION: Record<VideoStructure, number> = { pain: 15, contrast: 15, review: 30, scene: 30 };
|
||||
const STRUCTURE_MIN_DURATION: Record<VideoStructure, number> = { pain: 15, contrast: 15, review: 30, scene: 30, promo: 15, knowledge: 30 };
|
||||
|
||||
export function recommendDuration(format: PresentationFormat, structure: VideoStructure): number {
|
||||
return Math.max(FORMAT_DEFAULT_DURATION[format], STRUCTURE_MIN_DURATION[structure]);
|
||||
|
||||
@@ -512,6 +512,8 @@ export type Project = {
|
||||
video_segment_count?: number;
|
||||
// 合成成片地址(最新一次成功拼接):列表播放按钮 / 视频阶段「播放成片」直接用它;没合成过为空串
|
||||
final_video_url?: string;
|
||||
// 是否由极速成片入口创建;列表页据此显示「极速成片」而不是「专业创作」
|
||||
quick_create?: boolean;
|
||||
stages: ProjectStage[];
|
||||
script_versions: ScriptVersion[];
|
||||
video_segments: VideoSegment[];
|
||||
@@ -664,6 +666,22 @@ export type FreeVideoTask = {
|
||||
completed_at: string | null;
|
||||
};
|
||||
|
||||
export type VideoDigestHistory = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
duration: number;
|
||||
duration_label: string;
|
||||
ratio: string;
|
||||
shots: number;
|
||||
file_name: string;
|
||||
cover_url: string;
|
||||
video_url: string;
|
||||
prompt: string;
|
||||
created_at: string;
|
||||
created_date: string;
|
||||
};
|
||||
|
||||
export type FreeVideoUploadResult = {
|
||||
asset_id: string;
|
||||
url: string;
|
||||
|
||||
@@ -560,9 +560,556 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vr-page .remix-progress-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
margin-top: 22px;
|
||||
padding: 15px 20px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.12);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 10px 28px rgba(22, 45, 92, 0.055);
|
||||
}
|
||||
|
||||
.vr-page .remix-progress-step {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
color: #8893a7;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vr-page .remix-progress-step:not(:last-child)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: calc(50% + 52px);
|
||||
width: calc(100% - 104px);
|
||||
height: 1px;
|
||||
background: rgba(0, 47, 167, 0.13);
|
||||
}
|
||||
|
||||
.vr-page .remix-progress-dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(0, 47, 167, 0.16);
|
||||
border-radius: 50%;
|
||||
color: #78849a;
|
||||
background: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
transition: color 180ms ease, border-color 180ms ease, background-color 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.vr-page .remix-progress-step.active,
|
||||
.vr-page .remix-progress-step.done {
|
||||
color: #23314a;
|
||||
}
|
||||
|
||||
.vr-page .remix-progress-step.active .remix-progress-dot {
|
||||
border-color: var(--klein);
|
||||
color: #fff;
|
||||
background: var(--klein);
|
||||
box-shadow: 0 0 0 5px rgba(0, 47, 167, 0.08);
|
||||
}
|
||||
|
||||
.vr-page .remix-progress-step.done .remix-progress-dot {
|
||||
border-color: rgba(0, 47, 167, 0.28);
|
||||
color: var(--klein);
|
||||
background: #eef3ff;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-flow-grid {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-remix-upload-panel,
|
||||
.vr-page .remix-page .remix-information-panel,
|
||||
.vr-page .remix-page .remix-prompt-panel {
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-remix-upload-panel:hover,
|
||||
.vr-page .remix-page .remix-information-panel:hover,
|
||||
.vr-page .remix-page .remix-prompt-panel:hover {
|
||||
border-color: rgba(0, 47, 167, 0.22);
|
||||
box-shadow: 0 18px 42px rgba(22, 45, 92, 0.09), 0 3px 10px rgba(34, 42, 54, 0.04);
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-remix-upload-panel {
|
||||
min-height: 354px;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-remix-upload-panel > h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-remix-upload-panel > h2::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--klein);
|
||||
box-shadow: 0 0 0 5px rgba(0, 47, 167, 0.075);
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-flow-step {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field {
|
||||
flex: 1;
|
||||
min-height: 196px;
|
||||
border-width: 1px;
|
||||
border-color: rgba(0, 47, 167, 0.28);
|
||||
background: #f5f8ff;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field:hover,
|
||||
.vr-page .remix-page .video-upload-field:focus-within {
|
||||
border-color: var(--klein);
|
||||
background: #f0f5ff;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 47, 167, 0.05);
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field > span {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field svg {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 8px;
|
||||
box-sizing: content-box;
|
||||
border: 1px solid rgba(0, 47, 167, 0.12);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 20px rgba(0, 47, 167, 0.08);
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field strong {
|
||||
color: #1d2940;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field.has-preview {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field.has-preview video {
|
||||
width: 100%;
|
||||
height: 196px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
background: #0f1728;
|
||||
}
|
||||
|
||||
.vr-page .remix-replace-video {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
background: rgba(13, 19, 31, 0.72);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vr-page .remix-replace-video:hover {
|
||||
background: rgba(0, 47, 167, 0.92);
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field.has-preview:hover,
|
||||
.vr-page .remix-page .video-upload-field.has-preview:focus-within {
|
||||
background: #0f1728;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field strong {
|
||||
color: #1d2940;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-information-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-information-panel .video-result-placeholder,
|
||||
.vr-page .remix-page .remix-information-panel .video-analysis-result {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-information-panel.has-result .video-analysis-result,
|
||||
.vr-page .remix-page .remix-prompt-panel.has-result .remix-prompt-result {
|
||||
animation: remixReveal 260ms ease both;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-info-item {
|
||||
box-shadow: 0 5px 16px rgba(25, 49, 95, 0.045);
|
||||
transition: border-color 160ms ease, transform 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-info-item:hover {
|
||||
border-color: rgba(0, 47, 167, 0.2);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 20px rgba(25, 49, 95, 0.075);
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-prompt-panel .analysis-prompt {
|
||||
border-left: 3px solid var(--klein);
|
||||
background: #fff;
|
||||
box-shadow: inset 0 1px 3px rgba(27, 45, 83, 0.03), 0 8px 22px rgba(22, 45, 92, 0.045);
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-prompt-title h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-prompt-actions {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
@keyframes remixReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vr-page .remix-page .remix-information-panel.has-result .video-analysis-result,
|
||||
.vr-page .remix-page .remix-prompt-panel.has-result .remix-prompt-result {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.vr-page .remix-history-section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-head h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-count {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-empty {
|
||||
padding: 28px 16px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.12);
|
||||
border-radius: 15px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 184px minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 47, 167, 0.13);
|
||||
border-radius: 15px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 12px 30px rgba(22, 45, 92, 0.06);
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 3px;
|
||||
background: var(--klein);
|
||||
}
|
||||
|
||||
.vr-page .remix-history-card:hover {
|
||||
border-color: rgba(0, 47, 167, 0.24);
|
||||
box-shadow: 0 16px 36px rgba(22, 45, 92, 0.09);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.vr-page .remix-history-cover {
|
||||
position: relative;
|
||||
height: 108px;
|
||||
overflow: hidden;
|
||||
border-radius: 11px;
|
||||
background: #e9eef8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-cover img,
|
||||
.vr-page .remix-history-cover video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-cover.is-playable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: rgba(0, 47, 167, 0.88);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-play svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-cover.is-playing .remix-history-play {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-duration {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
padding: 4px 7px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
background: rgba(13, 19, 31, 0.78);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-content {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 8px 8px 8px 0;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.12);
|
||||
border-radius: 99px;
|
||||
color: var(--klein);
|
||||
background: #eef3ff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-status::before {
|
||||
content: "";
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--klein);
|
||||
}
|
||||
|
||||
.vr-page .remix-history-copy h3 {
|
||||
margin: 9px 0 0;
|
||||
overflow: hidden;
|
||||
color: #1d2940;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-copy p {
|
||||
margin: 7px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-actions {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-actions time {
|
||||
color: #8b96a8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-open {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 9px 13px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.24);
|
||||
border-radius: 9px;
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-open:hover {
|
||||
border-color: var(--klein);
|
||||
color: #fff;
|
||||
background: var(--klein);
|
||||
}
|
||||
|
||||
.vr-page .remix-history-open svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-prompt {
|
||||
grid-column: 1 / -1;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.1);
|
||||
border-radius: 11px;
|
||||
background: #f7f9fd;
|
||||
animation: remixReveal 220ms ease both;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-prompt[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-prompt-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-prompt-head strong {
|
||||
color: #334058;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-prompt textarea {
|
||||
width: 100%;
|
||||
min-height: 108px;
|
||||
display: block;
|
||||
resize: none;
|
||||
padding: 13px 14px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||
border-radius: 9px;
|
||||
outline: none;
|
||||
color: #27334a;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-copy-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 11px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.2);
|
||||
border-radius: 8px;
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 160ms ease, border-color 160ms ease, background-color 160ms ease;
|
||||
}
|
||||
|
||||
.vr-page .remix-history-copy-button:hover {
|
||||
border-color: var(--klein);
|
||||
color: #fff;
|
||||
background: var(--klein);
|
||||
}
|
||||
|
||||
.vr-page .remix-history-copy-button svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.vr-page .video-flow-grid,
|
||||
.vr-page .remix-flow-grid { grid-template-columns: 1fr; }
|
||||
.vr-page .remix-prompt-head { flex-direction: column; }
|
||||
.vr-page .video-flow-actions { flex-wrap: wrap; }
|
||||
.vr-page .remix-progress-step:not(:last-child)::after {
|
||||
left: calc(50% + 42px);
|
||||
width: calc(100% - 84px);
|
||||
}
|
||||
.vr-page .remix-history-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.vr-page .remix-history-content {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user