完成极速成品和脚本优化

This commit is contained in:
Azmat@qq.com
2026-08-25 11:13:07 +08:00
parent 00fc454db7
commit e2ec2d14af
46 changed files with 4734 additions and 432 deletions
+22 -6
View File
@@ -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":
+85 -1
View File
@@ -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",
+5
View File
@@ -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";
}
+16
View File
@@ -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;
}
+96 -16
View File
@@ -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; } }
+8 -1
View File
@@ -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" }) {
+74 -29
View File
@@ -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>
+19 -14
View File
@@ -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>
+15 -4
View File
@@ -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>
+602 -31
View File
@@ -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>
);
}
+4 -2
View File
@@ -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":
+218 -81
View File
@@ -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>
);
}
+6 -2
View File
@@ -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]);
+18
View File
@@ -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;
+547
View File
@@ -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;
}
}