极速成片页面和提炼提示词

This commit is contained in:
Azmat@qq.com
2026-08-24 15:52:05 +08:00
parent 2f5b7304b0
commit 00fc454db7
29 changed files with 1424 additions and 483 deletions
+3
View File
@@ -29,6 +29,7 @@ import {
AuthScreen,
Dashboard,
FreeCreatePage,
QuickCreatePage,
VideoRemixPage,
ImageWorkbenchPage,
LibraryPage,
@@ -1046,6 +1047,8 @@ export function App() {
return <AssetFactoryPage navigate={navigate} />;
case "freeCreate":
return <FreeCreatePage modelConfigs={modelConfigs} onNotify={(type, text) => setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} onBack={() => goBack("projects")} />;
case "quickCreate":
return <QuickCreatePage onBack={() => goBack("projects")} />;
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":
+15 -5
View File
@@ -433,14 +433,17 @@
.asset-factory .list-pager { margin-top: 20px; }
.asset-factory .af-empty {
box-sizing: border-box;
width: 100%;
min-height: 190px;
display: grid;
grid-column: 1 / -1;
place-items: center;
align-content: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
grid-column: 1 / -1;
margin-top: 18px;
padding: 28px;
padding: 28px 24px;
border: 1px dashed rgba(0, 47, 167, 0.20);
border-radius: 16px;
color: var(--af-muted);
@@ -448,11 +451,18 @@
text-align: center;
}
.asset-factory .af-empty svg {
display: block;
flex-shrink: 0;
width: 28px;
height: 28px;
color: var(--klein);
opacity: 0.72;
}
.asset-factory .af-empty strong,
.asset-factory .af-empty span {
display: block;
max-width: 28em;
}
.asset-factory .af-empty strong {
color: var(--accent-black);
font-size: 14px;
+5 -5
View File
@@ -340,17 +340,17 @@ export const api = {
{ method: "POST", body: formData }
);
},
// 1.11 上传视频提炼:后端抽帧 → 多模态读帧 → 中文分镜稿。与上传脚本同形状(只回文本不落库),
// 差别是真调模型,慢(整条 30~60 秒)且计一次费,故前端要给等待反馈。
// 1.11 上传视频提炼:整段视频交给 Gemini → 中文分镜稿。与上传脚本同形状(只回文本不落库),
// 差别是真调模型,慢(约 12 分钟)且计一次费,故前端要给等待反馈。
extractScriptVideo(projectId: string, formData: FormData) {
return request<{ name: string; chars: number; text: string; frames: number; duration: number }>(
return request<{ name: string; chars: number; text: string; frames: number; duration: number; input?: string }>(
`/api/projects/${projectId}/extract-script-video/`,
{ method: "POST", body: formData }
);
},
// 视频复刻页:不绑项目,抽帧 + 多模态读帧出中文分镜稿。慢(30~60 秒),计一次文本费
// 视频提炼页:不绑项目,整段视频交给 Gemini 出中文分镜稿。慢(约 1–2 分钟),固定 30 积分,失败退还
extractVideoDigest(formData: FormData) {
return request<{ name: string; chars: number; text: string; frames: number; duration: number; estimated_cost?: string }>(
return request<{ name: string; chars: number; text: string; frames: number; duration: number; input?: string; estimated_cost?: string }>(
"/api/ai/video-digest/",
{ method: "POST", body: formData }
);
+2 -2
View File
@@ -16,7 +16,7 @@ const SHELL_COMMANDS: Command[] = [
{ id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" },
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" },
{ id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" },
{ id: "video-remix", group: "导航", label: "视频复刻", sub: "上传参考视频,拆解镜头并生成提示词", page: "videoRemix", icon: "scan", key: "R" },
{ 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" },
{ id: "account", group: "导航", label: "账单库", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" },
@@ -270,7 +270,7 @@ export function topModuleForPage(page: Page): TopModule | null {
|| page === "modelPhotoDemoB"
|| page === "platformCover"
) return "image";
if (page === "projects" || page === "projectWizard" || page === "pipeline" || page === "freeCreate" || page === "videoRemix") return "video";
if (page === "projects" || page === "projectWizard" || page === "quickCreate" || page === "pipeline" || page === "freeCreate" || page === "videoRemix") return "video";
return null;
}
+76 -11
View File
@@ -359,19 +359,34 @@
.library-page.manage-mode .lib-card .lib-count { left: 44px; }
.library-page .lib-empty {
box-sizing: border-box;
width: 100%;
min-height: 190px;
display: grid;
place-items: center;
align-content: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 28px;
padding: 28px 24px;
border: 1px dashed rgba(0, 47, 167, 0.20);
border-radius: 16px;
color: var(--lib-muted);
background: rgba(0, 47, 167, 0.03);
text-align: center;
}
.library-page .lib-empty svg { width: 28px; height: 28px; color: var(--klein); opacity: 0.72; }
.library-page .lib-empty svg {
display: block;
flex-shrink: 0;
width: 28px;
height: 28px;
color: var(--klein);
opacity: 0.72;
}
.library-page .lib-empty strong,
.library-page .lib-empty span {
display: block;
max-width: 28em;
}
.library-page .lib-empty strong { color: var(--accent-black); font-size: 14px; font-weight: 600; }
.library-page .lib-empty span { font-size: 12px; }
@@ -450,13 +465,46 @@
.adx-x { width: 30px; height: 30px; display: grid; place-items: center; background: transparent; border: 0; cursor: pointer; color: var(--black-alpha-56); border-radius: var(--r-sm); margin-left: auto; flex-shrink: 0; transition: background var(--t-base), color var(--t-base); }
.adx-x:hover { background: var(--black-alpha-8); color: var(--accent-crimson); }
.adx-body { padding: 20px 24px 24px; overflow-y: auto; flex: 1; min-height: 0; }
.adx-grid { display: grid; grid-template-columns: 320px 1fr; gap: 24px; }
.adx-body { padding: 20px 24px 24px; overflow-x: hidden; overflow-y: auto; flex: 1; min-width: 0; min-height: 0; }
.adx-grid {
display: flex;
align-items: flex-start;
gap: 24px;
width: 100%;
min-width: 0;
}
/* 左:立绘 / 主图 */
.adx-lead { display: flex; flex-direction: column; gap: 10px; }
.adx-lead-wrap { position: relative; }
.adx-lead-img { aspect-ratio: 9 / 16; border-radius: var(--r-md); overflow: hidden; display: grid; place-items: center; background: var(--background-lighter); }
.adx-lead {
display: flex;
flex-direction: column;
gap: 10px;
flex: 0 1 240px;
width: 240px;
max-width: 34%;
min-width: 0;
contain: inline-size;
}
.adx-right {
flex: 1 1 0%;
min-width: min(280px, 52%);
overflow: hidden;
}
.adx-lead-wrap { position: relative; width: 100%; min-width: 0; overflow: hidden; }
.adx-lead-img {
width: 100%;
max-width: 100%;
min-width: 0;
aspect-ratio: 9 / 16;
max-height: min(420px, 48vh);
height: auto;
box-sizing: border-box;
border-radius: var(--r-md);
overflow: hidden;
display: grid;
place-items: center;
background: var(--background-lighter);
}
.asset-detail-modal.product-mode .adx-lead-img { aspect-ratio: 1; }
.adx-lead-img[role="button"] { cursor: zoom-in; }
.adx-lead-img img,
@@ -516,9 +564,26 @@
.adx-foot-meta + .btn { margin-left: 0; }
@media (max-width: 1100px) {
.adx-grid { grid-template-columns: 260px 1fr; }
.adx-grid { flex-wrap: wrap; }
.adx-lead {
flex: 0 1 200px;
width: 200px;
max-width: 42%;
}
.adx-gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.adx-grid { flex-direction: column; }
.adx-lead,
.adx-right {
flex: none;
width: 100%;
max-width: none;
min-width: 0;
contain: none;
}
.adx-lead { max-width: 200px; }
}
/*
上传资产 · 居中 Modal · 类型分段 + 拖拽区 + 预览
+1
View File
@@ -17,6 +17,7 @@ import "./free-create-page.css";
import "./video-remix-page.css";
import "./product-create-page.css";
import "./project-wizard-page.css";
import "./quick-create-page.css";
import "./admin-page.css";
createRoot(document.getElementById("root")!).render(<App />);
+20 -5
View File
@@ -204,19 +204,34 @@
.models-page.manage-mode .ml-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }
.models-page .ml-empty {
box-sizing: border-box;
width: 100%;
min-height: 190px;
display: grid;
place-items: center;
align-content: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 28px;
padding: 28px 24px;
border: 1px dashed rgba(0, 47, 167, 0.20);
border-radius: 16px;
color: var(--ml-muted);
background: rgba(0, 47, 167, 0.03);
text-align: center;
}
.models-page .ml-empty svg { width: 28px; height: 28px; color: var(--klein); opacity: 0.72; }
.models-page .ml-empty svg {
display: block;
flex-shrink: 0;
width: 28px;
height: 28px;
color: var(--klein);
opacity: 0.72;
}
.models-page .ml-empty strong,
.models-page .ml-empty span {
display: block;
max-width: 28em;
}
.models-page .ml-empty strong { color: var(--accent-black); font-size: 14px; font-weight: 600; }
.models-page .ml-empty span { font-size: 12px; }
+15 -5
View File
@@ -423,13 +423,16 @@
}
.pl-empty {
box-sizing: border-box;
width: 100%;
min-height: 190px;
display: grid;
grid-column: 1 / -1;
place-items: center;
align-content: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 28px;
grid-column: 1 / -1;
padding: 28px 24px;
border: 1px dashed rgba(0, 47, 167, 0.20);
border-radius: 16px;
color: var(--pl-muted);
@@ -437,11 +440,18 @@
text-align: center;
}
.pl-empty svg {
display: block;
flex-shrink: 0;
width: 28px;
height: 28px;
color: var(--klein);
opacity: 0.72;
}
.pl-empty strong,
.pl-empty span {
display: block;
max-width: 28em;
}
.pl-empty strong {
color: var(--accent-black);
font-size: 14px;
+12 -4
View File
@@ -450,19 +450,27 @@
}
.project-wizard-page .nw-empty {
box-sizing: border-box;
width: 100%;
grid-column: 1 / -1;
min-height: 190px;
display: grid;
place-items: center;
align-content: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 28px;
padding: 28px 24px;
border: 1px dashed rgba(0, 47, 167, 0.20);
border-radius: 16px;
color: var(--nw-muted);
background: rgba(0, 47, 167, 0.03);
text-align: center;
}
.project-wizard-page .nw-empty strong,
.project-wizard-page .nw-empty span {
display: block;
max-width: 28em;
}
.project-wizard-page .nw-empty strong { color: var(--accent-black); font-size: 14px; }
.project-wizard-page .nw-empty-reset {
height: 36px;
+21 -5
View File
@@ -441,6 +441,7 @@
height: 40px;
}
.projects-page .vc-grid.list .vc-play svg { width: 18px; height: 18px; }
.projects-page .vc-grid.list .vc-meta {
display: flex;
min-width: 0;
padding: 18px 22px;
@@ -449,20 +450,35 @@
}
.projects-page .vc-empty {
box-sizing: border-box;
width: 100%;
min-height: 190px;
display: grid;
place-items: center;
align-content: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 18px;
padding: 28px;
padding: 28px 24px;
border: 1px dashed rgba(0, 47, 167, 0.20);
border-radius: 16px;
color: var(--vc-muted);
background: rgba(0, 47, 167, 0.03);
text-align: center;
}
.projects-page .vc-empty svg { width: 28px; height: 28px; color: var(--klein); opacity: 0.72; }
.projects-page .vc-empty svg {
display: block;
flex-shrink: 0;
width: 28px;
height: 28px;
color: var(--klein);
opacity: 0.72;
}
.projects-page .vc-empty strong,
.projects-page .vc-empty span {
display: block;
max-width: 28em;
}
.projects-page .vc-empty strong { color: var(--accent-black); font-size: 14px; font-weight: 600; }
.projects-page .vc-empty span { font-size: 12px; }
+37
View File
@@ -0,0 +1,37 @@
/* 极速成片:按用户提供的「影擎」页面逐项转写,仅作用于该页面。 */
.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 .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-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-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-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-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 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-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-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 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; }
@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; } }
+3 -3
View File
@@ -28,7 +28,7 @@ const CREATE_GROUPS: Array<{
label: "从商品开始",
hint: "围绕商品卖点生成完整带货内容",
cards: [
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频", tone: "primary", page: "projectWizard", icon: "wand" },
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频", tone: "primary", page: "quickCreate", icon: "wand" },
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成", tone: "primary", page: "projectWizard", icon: "folder" },
],
},
@@ -36,8 +36,8 @@ const CREATE_GROUPS: Array<{
label: "从参考视频开始",
hint: "复用成熟视频的镜头表达与节奏",
cards: [
{ title: "视频复刻", desc: "拆解参考视频提炼可编辑提示词", tone: "subtle", page: "videoRemix", icon: "scan" },
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品", tone: "subtle", page: "freeCreate", icon: "replace" },
{ title: "提炼提示词", desc: "上传参考视频提炼可编辑提示词", tone: "subtle", page: "videoRemix", icon: "scan" },
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容", tone: "subtle", page: "freeCreate", icon: "replace" },
],
},
];
+1 -1
View File
@@ -139,7 +139,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
if (!draft) return;
sessionStorage.removeItem(REMIX_PROMPT_KEY);
window.setTimeout(() => promptRef.current?.setContent(draft, []), 0);
notify("info", "已填入复刻提示词,可改完再生成");
notify("info", "已填入分镜稿,可改完再生成");
}, [notify]);
// —— 弹窗 ——
+1
View File
@@ -11,5 +11,6 @@ export { TeamPage } from "./team";
export { MessagesPage } from "./messages";
export { AssetFactoryPage, ImageWorkbenchPage, ModelPhotoDemoPage } from "./ai-tools";
export { FreeCreatePage } from "./free-create";
export { QuickCreatePage } from "./quick-create";
export { VideoRemixPage } from "./video-remix";
export { SettingsPage } from "./settings";
+9 -5
View File
@@ -1356,7 +1356,7 @@ export function PipelinePage(props: {
}, [chatText]);
const chatFileRef = useRef<HTMLInputElement | null>(null);
const [scriptFileBusy, setScriptFileBusy] = useState(false); // 脚本文件读取中(docx 走后端,有网络往返)
// 1.11 上传视频提炼:抽帧 + 读帧要 30~60 秒,比读 docx 慢一个量级,单独一个 busy 位并在聊天区留一条进度消息
// 1.11 上传视频提炼:整段视频交给 Gemini,大约 1–2 分钟,单独一个 busy 位并在聊天区留一条进度消息
const videoFileRef = useRef<HTMLInputElement | null>(null);
const [videoDigestBusy, setVideoDigestBusy] = useState(false);
const chatBodyRef = useRef<HTMLDivElement | null>(null);
@@ -1751,7 +1751,7 @@ export function PipelinePage(props: {
window.setTimeout(() => chatTextareaRef.current?.focus(), 0);
}
}
// 1.11 上传视频提炼:后端抽帧读帧出分镜稿 → 填进输入框。
// 1.11 上传视频提炼:整段视频(含口播)交给 Gemini → 中文分镜稿填进输入框。
// 稿子**故意落在可编辑的输入框里而不是直接出脚本**:AI 拆解必然有错,用户先逐镜改完再交给脚本 agent。
async function onPickVideoFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
@@ -1769,7 +1769,7 @@ export function PipelinePage(props: {
}
setVideoDigestBusy(true);
pushMsg("user", `已上传视频《${file.name}`);
pushMsg("ai", "正在抽帧拆解这条视频,大约需要分钟…");
pushMsg("ai", "正在把整段视频交给模型,大约需要一到两分钟…");
try {
const fd = new FormData();
fd.append("file", file);
@@ -1779,8 +1779,12 @@ export function PipelinePage(props: {
setChatText((prev) => (prev ? `${prev}\n${text}` : text));
setChatAttachments((list) => [...list, { name: file.name, chars: text.length }]);
setChatMode("video");
pushMsg("ai", `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,`
+ "请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。");
pushMsg(
"ai",
digest.input === "native_video"
? `已拆出 ${digest.duration} 秒整段视频。拆解稿在下面输入框里,请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。`
: `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。`
);
chatTextareaRef.current?.focus();
} catch (error) {
pushMsg("ai", "这条视频没能拆开,可以换一条再试。");
+2 -2
View File
@@ -440,8 +440,8 @@ const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string
];
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/yz/video-free.jpg" },
{ title: "视频复刻", desc: "上传参考视频,拆解镜头、动作和节奏,生成可编辑复刻提示词。", page: "videoRemix", image: "/assets/yz/video-remix.jpg" },
{ title: "商品替换", desc: "保留参考视频的人物、场景与节奏,将原商品替换为自己的商品。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" },
{ title: "提炼提示词", desc: "参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/yz/video-remix.jpg" },
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" },
];
function projCardSub(project: Project, productTitle: string): string {
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useState } from "react";
import { ArrowLeft, Sparkles, Trash2, Upload, WandSparkles } from "lucide-react";
import type { Page } from "./route-config";
export function QuickCreatePage({ onBack }: { onBack: () => void; navigate?: (page: Page) => void }) {
const [name, setName] = useState("");
const [images, setImages] = useState<File[]>([]);
const [preview, setPreview] = useState("");
useEffect(() => {
if (!images[0]) {
setPreview("");
return;
}
const url = URL.createObjectURL(images[0]);
setPreview(url);
return () => URL.revokeObjectURL(url);
}, [images]);
function selectImages(files: FileList | null) {
setImages(Array.from(files || []).slice(0, 9));
}
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>
</div>
<div className="project-builder-status"><strong>AI </strong><span>· </span></div>
</header>
<div className="quick-create-shell" 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="例如:净颜精华、轻醒咖啡" />
</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>
</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>
</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>
</div>
</section>
</div>
</div>
);
}
+7 -2
View File
@@ -28,6 +28,7 @@ export type Page =
| "messages"
| "assetFactory"
| "freeCreate"
| "quickCreate"
| "videoRemix"
| "imageOptimize"
| "modelPhoto"
@@ -102,7 +103,8 @@ export const routeLabels: Record<Page, string> = {
messages: "消息",
assetFactory: "图片工具",
freeCreate: "自由创作",
videoRemix: "视频复刻",
quickCreate: "极速成片",
videoRemix: "提炼提示词",
imageOptimize: "图片创作",
modelPhoto: "模特上身图",
modelPhotoDemoA: "模特图方案 A",
@@ -120,7 +122,7 @@ export function isPage(value: string): value is Page {
export function parentPage(page: Page): Page {
if (["productDetail", "productCreateUpload"].includes(page)) return "products";
if (["projectWizard", "freeCreate", "videoRemix", "pipeline"].includes(page)) return "projects";
if (["projectWizard", "freeCreate", "quickCreate", "videoRemix", "pipeline"].includes(page)) return "projects";
if (["imageOptimize", "modelPhoto", "modelPhotoDemoA", "modelPhotoDemoB", "platformCover"].includes(page)) {
return "assetFactory";
}
@@ -163,6 +165,7 @@ 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 === "/video-remix") return { page: "videoRemix", authMode: "login", hash };
if (path === "/image-optimize") {
return { page: "imageOptimize", authMode: "login", productId: search.get("product_id") || undefined, hash };
@@ -207,6 +210,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
return "/asset-factory";
case "freeCreate":
return "/free-create";
case "quickCreate":
return "/quick-create";
case "videoRemix":
return "/video-remix";
case "imageOptimize":
+219 -173
View File
@@ -1,23 +1,37 @@
// 视频复刻 · 上传参考视频 → 视频提炼接口 → 可编辑提示词。
// 拆解跟生成脚本同一套接口口径:模型下拉 + Gemini 3.1 Pro 官转看图。
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, ArrowRight, FileVideo, Save, ScanLine, ScanSearch } from "lucide-react";
// 提炼提示词 · 上传参考视频 → 拆解接口 → 可编辑提示词。对照影擎 remix-page。
import { useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
ArrowRight,
BadgeCheck,
Clock3,
FileVideo,
FileVideo2,
PanelsTopLeft,
RectangleVertical,
Save,
ScanLine,
ScanSearch,
TextCursorInput,
} from "lucide-react";
import { api } from "../api";
import { publicModelDisplayName } from "../model-display";
import type { ModelConfig } 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;
rhythm: string;
ratio: string;
fileName: string;
fileSize: number;
modelId: string;
fileKind: string;
width: number;
height: number;
};
function loadDraft(): RemixDraft | null {
@@ -43,24 +57,11 @@ function saveDraft(draft: RemixDraft) {
const VIDEO_DIGEST_MAX_BYTES = 200 * 1024 * 1024;
function formatSize(bytes: number) {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function shotCount(text: string, frames: number) {
const marks = text.match(/【第\s*\d+\s*镜】/g);
return marks?.length || frames || 0;
}
function rhythmCopy(text: string) {
if (/快节奏|紧凑/.test(text)) return "快节奏种草";
if (/慢节奏|舒缓/.test(text)) return "慢节奏展示";
const form = text.match(/形式[:]\s*([^\n]+)/);
if (form?.[1]) return form[1].trim().slice(0, 12);
return "按参考片节奏";
}
function isGemini31(model: ModelConfig) {
const blob = `${model.name} ${model.display_name}`.toLowerCase();
return blob.includes("gemini-3.1") || blob.includes("gemini 3.1") || model.name === "gemini-3.1-pro-preview";
@@ -76,6 +77,45 @@ function pickDigestModel(models: ModelConfig[]) {
);
}
function fileKind(file: File | null, name: string) {
if (file?.type === "video/quicktime" || /\.mov$/i.test(file?.name || name)) return "MOV";
if (/\.webm$/i.test(file?.name || name)) return "WEBM";
return "MP4";
}
function ratioLabel(width: number, height: number) {
if (!width || !height) return "—";
const r = width / height;
if (Math.abs(r - 9 / 16) < 0.08) return "9:16 竖屏";
if (Math.abs(r - 16 / 9) < 0.08) return "16:9 横屏";
if (Math.abs(r - 1) < 0.08) return "1:1";
return width > height ? "横屏" : "竖屏";
}
function readVideoMeta(file: File): Promise<{ duration: number; width: number; height: number }> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
const video = document.createElement("video");
video.preload = "metadata";
video.onloadedmetadata = () => {
const meta = { duration: video.duration || 0, width: video.videoWidth || 0, height: video.videoHeight || 0 };
URL.revokeObjectURL(url);
resolve(meta);
};
video.onerror = () => {
URL.revokeObjectURL(url);
resolve({ duration: 0, width: 0, height: 0 });
};
video.src = url;
});
}
function fileMetaCopy(kind: string, size: number, width: number, height: number) {
if (width && height) return `${kind} · ${width} × ${height}`;
if (size > 0) return `${kind} · ${(size / (1024 * 1024)).toFixed(1)} MB`;
return kind;
}
export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: {
textModels?: ModelConfig[];
onNotify: (type: "success" | "error" | "info", text: string) => void;
@@ -88,31 +128,27 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
const [prompt, setPrompt] = useState(draft?.prompt || "");
const [duration, setDuration] = useState(draft?.duration || 0);
const [shots, setShots] = useState(draft?.shots || 0);
const [rhythm, setRhythm] = useState(draft?.rhythm || "");
const [ratio, setRatio] = useState(draft?.ratio || "");
const [fileName, setFileName] = useState(draft?.fileName || "");
const [fileSize, setFileSize] = useState(draft?.fileSize || 0);
const [modelId, setModelId] = useState(draft?.modelId || "");
const [modelMenuOpen, setModelMenuOpen] = useState(false);
const done = prompt.length > 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 promptRef = useRef<HTMLTextAreaElement>(null);
const digestModels = useMemo(
() => textModels.filter((model) => model.status === "active" && isGemini31(model)),
[textModels]
);
const defaultModel = useMemo(() => pickDigestModel(digestModels), [digestModels]);
const activeModelId = modelId || defaultModel?.id || "";
const activeModel = digestModels.find((model) => model.id === activeModelId) || defaultModel;
const activeModelName = publicModelDisplayName(activeModel, "Gemini 3.1 Pro 官转");
const estimatedPoints = Math.round(Number(activeModel?.unit_price || 0));
const activeModel = useMemo(() => pickDigestModel(digestModels), [digestModels]);
const [priceMultiplier, setPriceMultiplier] = useState(1);
useEffect(() => {
if (!modelMenuOpen) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".vr-model-pick")) setModelMenuOpen(false);
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [modelMenuOpen]);
void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
}, []);
const estimatedPoints = priceMultiplier === 1
? VIDEO_DIGEST_POINTS
: Math.max(1, Math.round(Number((VIDEO_DIGEST_POINTS * priceMultiplier).toFixed(6))));
useEffect(() => {
if (!prompt.trim()) return;
@@ -120,14 +156,23 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
prompt,
duration,
shots,
rhythm,
ratio,
fileName: file?.name || fileName,
fileSize: file?.size || fileSize,
modelId: activeModelId,
fileKind: kind,
width,
height,
});
}, [prompt, duration, shots, rhythm, file, fileName, fileSize, activeModelId]);
}, [prompt, duration, shots, ratio, file, fileName, fileSize, kind, width, height]);
const pickFile = (next: File | null) => {
useEffect(() => {
const el = promptRef.current;
if (!el || !hasResult) return;
el.style.height = "auto";
el.style.height = `${Math.max(132, el.scrollHeight)}px`;
}, [prompt, hasResult]);
const pickFile = async (next: File | null) => {
if (!next) return;
if (!/\.(mp4|mov|m4v|webm)$/i.test(next.name)) {
onNotify("error", "只支持 mp4 / mov / m4v / webm 四种视频格式");
@@ -137,9 +182,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
onNotify("error", "视频不能超过 200MB,请压缩后再传");
return;
}
const meta = await readVideoMeta(next);
setFile(next);
setFileName(next.name);
setFileSize(next.size);
setKind(fileKind(next, next.name));
setWidth(meta.width);
setHeight(meta.height);
setRatio(ratioLabel(meta.width, meta.height));
if (meta.duration) setDuration(Math.round(meta.duration));
setHasResult(false);
};
const analyze = async () => {
@@ -148,16 +200,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
try {
const fd = new FormData();
fd.append("file", file);
if (activeModelId) fd.append("model_config_id", activeModelId);
if (activeModel?.id) fd.append("model_config_id", activeModel.id);
const digest = await api.extractVideoDigest(fd);
const text = digest.text.trim();
setPrompt(text);
setDuration(digest.duration);
setDuration(digest.duration || duration);
setShots(shotCount(text, digest.frames));
setRhythm(rhythmCopy(text));
setFileName(file.name);
setFileSize(file.size);
onNotify("success", `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧,请先逐镜核对`);
setHasResult(true);
onNotify("success", "视频拆解完成,已生成提示词");
} catch (error) {
onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
} finally {
@@ -184,140 +236,134 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
navigate("freeCreate");
};
const analyzeLabel = busy
? "正在拆解…"
: `生成这个需要 ${estimatedPoints} 积分`;
return (
<div className="vr-page">
<div className="vr-inner">
<header className="page-head">
<div className="vr-title-row">
<button type="button" className="btn btn-ghost vr-back" aria-label="返回上一入口页面" onClick={onBack}>
<ArrowLeft />
</button>
<div>
<h1></h1>
<div className="sub">
<span className="mono">[ /remix ]</span>
</div>
</div>
</div>
</header>
<div className="vr-grid">
<section className="vr-panel">
<div className="section-h">
<h2></h2>
<span className="more">// 视频提炼</span>
</div>
<p className="vr-lead">使稿</p>
<div className="vr-step">
<div className="vr-step-h">
<strong></strong>
<span>MP4 / MOV · 3 · 200MB</span>
</div>
<label className={`vr-upload${file ? " has-file" : ""}`}>
<input
type="file"
accept="video/mp4,video/quicktime,.mp4,.mov,.m4v,.webm"
hidden
onChange={(event) => {
pickFile(event.target.files?.[0] || null);
event.target.value = "";
}}
/>
<span>
<FileVideo />
<strong>{file?.name || fileName || "点击上传参考视频"}</strong>
<small>
{file
? `${formatSize(file.size)} · 视频已就绪,可开始拆解`
: fileName
? `${formatSize(fileSize)} · 上次拆解还在,刷新不会丢。要重拆请再选一次文件`
: "上传后抽帧拆解镜头结构与节奏"}
</small>
</span>
</label>
</div>
<div className="vr-actions">
<div className="vr-meta">
{digestModels.length > 0 ? (
<div className={`chip-wrap vr-model-pick${modelMenuOpen ? " open" : ""}`}>
<button type="button" className="chip" title="选择视频提炼模型" onClick={() => setModelMenuOpen((open) => !open)}>
{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">
{digestModels.map((model) => (
<div
key={model.id}
className={`mi${model.id === activeModelId ? " selected" : ""}`}
role="menuitemradio"
aria-checked={model.id === activeModelId}
tabIndex={0}
onClick={() => { setModelId(model.id); setModelMenuOpen(false); }}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setModelId(model.id);
setModelMenuOpen(false);
}
}}
>
{publicModelDisplayName(model)}
<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>
</div>
) : null}
<span>{estimatedPoints > 0 ? `视频解析预计消耗 ${estimatedPoints} 积分` : "视频解析按一次文本模型计费"}</span>
</div>
<button type="button" className="btn btn-primary" disabled={!file || busy} onClick={() => void analyze()}>
{busy ? <span className="spinner" aria-hidden="true" /> : <ScanSearch />}
<span>{busy ? "正在拆解…" : done ? "重新拆解" : "开始拆解"}</span>
<section className="video-tool-page remix-page">
<header className="page-header">
<div className="image-title-row">
<button type="button" className="image-back-button" aria-label="返回上一入口页面" onClick={onBack}>
<ArrowLeft />
</button>
<div className="page-heading">
<h1></h1>
<p></p>
</div>
</div>
</header>
<div className="video-flow-grid remix-flow-grid">
<section className="video-flow-panel video-remix-upload-panel">
<h2></h2>
<div className="video-flow-step">
<div className="video-flow-step-head">
<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>
</div>
<div className="video-flow-actions remix-analyze-actions">
<button type="button" className="primary-action" disabled={!file || busy} onClick={() => void analyze()}>
<ScanSearch />
<span>{analyzeLabel}</span>
</button>
</div>
</section>
<aside className={`video-result-panel remix-information-panel${hasResult ? " has-result" : ""}`} aria-live="polite">
<div className="video-result-placeholder">
<div>
<span className="remix-placeholder-icon"><ScanLine /></span>
<strong></strong>
</div>
</div>
<div className="video-analysis-result">
<div className="remix-info-heading">
<span className="remix-status-icon"><BadgeCheck /></span>
<div>
<span className="remix-eyebrow">Analysis complete</span>
<h2></h2>
</div>
</div>
<div className="remix-info-list">
<div className="remix-info-item">
<span><Clock3 /></span>
<strong>{duration ? `${duration}` : "—"}</strong>
</div>
<div className="remix-info-item">
<span><RectangleVertical /></span>
<strong>{ratio || "—"}</strong>
</div>
<div className="remix-info-item">
<span><PanelsTopLeft /></span>
<strong>{shots ? `${shots} 个镜头` : "—"}</strong>
</div>
<div className="remix-info-item remix-info-file">
<span><FileVideo2 /></span>
<strong>{fileName || "参考视频.mp4"}</strong>
<small>{fileMetaCopy(kind, fileSize, width, height)}</small>
</div>
</div>
</div>
</aside>
</div>
<section className={`remix-prompt-panel${hasResult ? " has-result" : ""}`} aria-live="polite">
<div className="remix-prompt-placeholder">
<span><TextCursorInput /></span>
<div><strong></strong></div>
</div>
<div className="remix-prompt-result">
<div className="remix-prompt-head">
<div className="remix-prompt-title">
<div><h2></h2></div>
</div>
</div>
<textarea
ref={promptRef}
className="analysis-prompt"
aria-label="复刻提示词"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
/>
<div className="video-flow-actions remix-prompt-actions">
<button type="button" className="secondary-action" onClick={() => void savePrompt()}>
<Save />
</button>
<button type="button" className="primary-action" onClick={continueGenerate}>
<span></span>
<ArrowRight />
</button>
</div>
</div>
</section>
<aside className={`vr-result${done ? " has-result" : ""}`}>
{!done ? (
<div className="vr-placeholder">
<div>
<ScanLine />
<strong>{busy ? "正在抽帧拆解这条视频" : "等待视频解析"}</strong>
<span>{busy ? "大约需要半分钟。拆解稿会落在这里,请先逐镜核对再去生成。" : "完成后将在这里展示镜头摘要和复刻提示词"}</span>
</div>
</div>
) : (
<div className="vr-analysis">
<div className="section-h">
<h2></h2>
<span className="more">[ {shots} SHOTS ]</span>
</div>
<p className="vr-lead"> {shots} </p>
<div className="vr-summary">
<span><strong>{duration} </strong></span>
<span><strong>{shots} </strong></span>
<span><strong>{rhythm}</strong></span>
</div>
<textarea
className="textarea vr-prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
/>
<div className="vr-actions">
<button type="button" className="btn" onClick={() => void savePrompt()}>
<Save />
</button>
<button type="button" className="btn btn-primary" onClick={continueGenerate}>
<span></span>
<ArrowRight />
</button>
</div>
</div>
)}
</aside>
</div>
</section>
</div>
</div>
);
+9 -1
View File
@@ -4,7 +4,15 @@
margin: 0 auto;
}
.trash-empty { min-height: 220px; flex-direction: column; gap: 10px; }
.trash-empty {
min-height: 220px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-align: center;
}
/* 分区(商品 / 资产):mono 小标题 + 各自列表 */
.trash-section { margin-bottom: 22px; }
+477 -136
View File
@@ -1,119 +1,465 @@
/* 视频复刻页 · 两栏工作台。不重写共享 .btn/.pill/.input/.chip/.textarea。 */
/* 提炼提示词 · 对照影擎 remix-page。不重写共享 .btn/.pill/.input/.chip/.textarea。 */
.vr-page {
--klein: #002fa7;
--klein-hover: #002680;
--muted: #6f747c;
--text: #17181a;
--shadow-card: 0 8px 20px rgba(20, 27, 38, 0.09);
width: 100%;
max-width: none;
box-sizing: border-box;
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
}
@media (max-width: 1100px) {
.vr-page { margin: -28px -24px -48px; }
}
.vr-page .vr-inner {
width: min(1320px, 100%);
width: min(1560px, 100%);
margin: 0 auto;
}
.vr-page .vr-title-row {
.vr-page .video-tool-page {
min-height: calc(100vh - 116px - 100px);
}
.vr-page .page-header {
position: relative;
min-height: 76px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 30px;
margin-bottom: 34px;
}
.vr-page .image-title-row {
display: flex;
align-items: flex-start;
gap: 13px;
}
.vr-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, 0.46);
border-radius: 12px;
color: var(--klein);
background: rgba(255, 255, 255, 0.82);
cursor: pointer;
box-shadow: 0 5px 14px rgba(0, 47, 167, 0.07);
transition: background-color 180ms ease, transform 180ms ease;
}
.vr-page .image-back-button:hover {
transform: translateX(-2px);
border-color: var(--klein);
background: rgba(0, 47, 167, 0.055);
}
.vr-page .image-back-button svg { width: 19px; height: 19px; }
.vr-page .page-heading h1 {
margin: 0 0 10px;
font-size: 32px;
line-height: 1.2;
font-weight: 600;
letter-spacing: -.02em;
color: var(--text);
}
.vr-page .page-heading p {
margin: 0;
color: var(--muted);
font-size: 15px;
}
.vr-page .video-flow-grid {
display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(360px, 0.92fr);
gap: 22px;
margin-top: 26px;
}
.vr-page .remix-flow-grid {
grid-template-columns: minmax(0, 1.42fr) minmax(310px, 0.58fr);
}
.vr-page .video-flow-panel,
.vr-page .video-result-panel {
padding: 24px;
border: 1px solid rgba(34, 42, 54, 0.1);
border-radius: 16px;
background: rgba(255, 255, 255, 0.92);
box-shadow: var(--shadow-card);
}
.vr-page .video-remix-upload-panel,
.vr-page .remix-information-panel,
.vr-page .remix-prompt-panel {
position: relative;
overflow: hidden;
border-color: rgba(0, 47, 167, 0.13);
background-color: rgba(251, 253, 255, 0.96);
box-shadow: 0 16px 38px rgba(22, 45, 92, 0.07), 0 2px 8px rgba(34, 42, 54, 0.035);
}
.vr-page .video-remix-upload-panel {
display: flex;
flex-direction: column;
}
.vr-page .video-remix-upload-panel::before,
.vr-page .remix-prompt-panel::before {
content: "";
position: absolute;
inset: 0 auto auto 0;
width: 100%;
height: 3px;
background: var(--klein);
}
.vr-page .remix-information-panel {
isolation: isolate;
background-color: rgba(249, 252, 255, 0.96);
}
.vr-page .remix-information-panel::before {
content: "";
position: absolute;
inset: 0 0 auto;
z-index: -1;
height: 3px;
background: var(--klein);
transition: height 220ms ease;
}
.vr-page .remix-information-panel.has-result::before {
height: 86px;
background: var(--klein);
}
.vr-page .remix-information-panel > * {
position: relative;
z-index: 1;
}
.vr-page .remix-information-panel .video-result-placeholder,
.vr-page .remix-information-panel .video-analysis-result {
min-height: 286px;
}
.vr-page .remix-information-panel .video-result-placeholder {
color: var(--muted);
}
.vr-page .remix-information-panel .video-result-placeholder strong {
color: var(--text);
font-size: 16px;
}
.vr-page .remix-information-panel .video-result-placeholder svg {
color: var(--klein);
}
.vr-page .remix-placeholder-icon {
width: 54px;
height: 54px;
display: grid;
place-items: center;
margin-bottom: 4px;
border: 1px solid rgba(0, 47, 167, 0.13);
border-radius: 16px;
background: rgba(0, 47, 167, 0.055);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.vr-page .remix-info-heading {
min-height: 62px;
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
color: #fff;
}
.vr-page .vr-back {
width: 36px;
height: 36px;
padding: 0;
.vr-page .remix-status-icon {
width: 38px;
height: 38px;
flex: 0 0 auto;
margin-top: 2px;
}
.vr-page .vr-grid {
display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr);
gap: 16px;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 12px;
background: rgba(255, 255, 255, 0.12);
}
.vr-page .vr-panel,
.vr-page .vr-result {
position: relative;
padding: 22px 24px;
border-radius: var(--r-md);
background: var(--surface);
.vr-page .remix-status-icon svg { width: 19px; height: 19px; }
.vr-page .remix-eyebrow {
display: block;
margin-bottom: 3px;
color: rgba(255, 255, 255, 0.66);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.vr-page .vr-panel::before,
.vr-page .vr-result::before {
content: "";
position: absolute;
inset: 0;
border: 1px solid var(--border-faint);
border-radius: inherit;
pointer-events: none;
.vr-page .remix-info-heading h2 {
margin: 0;
color: #fff;
font-size: 18px;
font-weight: 600;
}
.vr-page .vr-lead {
margin: 0 0 18px;
color: var(--black-alpha-56);
.vr-page .remix-info-heading p {
margin: 5px 0 0;
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
}
.vr-page .remix-info-list {
flex: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 9px;
margin-top: 16px;
}
.vr-page .remix-info-item {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding: 11px 12px;
border: 1px solid rgba(0, 47, 167, 0.09);
border-radius: 11px;
background: rgba(255, 255, 255, 0.76);
}
.vr-page .remix-info-item span,
.vr-page .remix-info-file span {
display: flex;
align-items: center;
gap: 7px;
color: #72809a;
font-size: 12px;
font-weight: 500;
}
.vr-page .remix-info-item span svg,
.vr-page .remix-info-file span svg {
width: 15px;
height: 15px;
color: var(--klein);
}
.vr-page .remix-info-item strong {
display: block;
margin-top: 9px;
color: #1f2b42;
font-size: 16px;
line-height: 1.3;
font-weight: 600;
}
.vr-page .remix-info-list .remix-info-file {
margin-top: 0;
padding: 11px 12px;
}
.vr-page .remix-info-list .remix-info-file strong { font-size: 14px; }
.vr-page .remix-info-list .remix-info-file small { font-size: 11px; }
.vr-page .remix-info-file strong,
.vr-page .remix-info-file small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vr-page .remix-info-file strong {
margin-top: 6px;
color: #1f2b42;
font-size: 12px;
font-weight: 600;
}
.vr-page .remix-info-file small {
margin-top: 3px;
color: #8a95a9;
font-size: 10px;
}
.vr-page .remix-prompt-panel {
min-height: 188px;
margin-top: 22px;
padding: 24px;
border: 1px solid rgba(0, 47, 167, 0.13);
border-radius: 16px;
}
.vr-page .remix-prompt-placeholder {
min-height: 138px;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
color: var(--muted);
text-align: left;
}
.vr-page .remix-prompt-placeholder > span {
width: 48px;
height: 48px;
flex: 0 0 auto;
display: grid;
place-items: center;
border: 1px solid rgba(0, 47, 167, 0.13);
border-radius: 14px;
color: var(--klein);
background: rgba(0, 47, 167, 0.055);
}
.vr-page .remix-prompt-placeholder svg { width: 22px; height: 22px; }
.vr-page .remix-prompt-placeholder strong {
display: block;
color: var(--text);
font-size: 15px;
font-weight: 600;
}
.vr-page .remix-prompt-result { display: none; }
.vr-page .remix-prompt-panel.has-result .remix-prompt-placeholder { display: none; }
.vr-page .remix-prompt-panel.has-result .remix-prompt-result { display: block; }
.vr-page .remix-prompt-head {
display: flex;
align-items: flex-start;
justify-content: flex-start;
margin-bottom: 15px;
}
.vr-page .remix-prompt-title {
display: flex;
align-items: flex-start;
}
.vr-page .remix-prompt-title h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text);
}
.vr-page .remix-prompt-panel .analysis-prompt {
width: 100%;
min-height: 132px;
overflow-y: hidden;
resize: none;
padding: 15px;
border: 1px solid rgba(0, 47, 167, 0.14);
border-radius: 11px;
outline: none;
color: var(--text);
background: rgba(255, 255, 255, 0.82);
box-shadow: inset 0 1px 2px rgba(27, 45, 83, 0.025);
font: inherit;
font-size: 13px;
line-height: 1.65;
box-sizing: border-box;
}
.vr-page .vr-step { margin-top: 4px; }
.vr-page .remix-prompt-panel .analysis-prompt:focus {
border-color: var(--klein);
box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.08);
}
.vr-page .vr-step-h {
.vr-page .video-flow-actions.remix-prompt-actions {
justify-content: flex-end;
gap: 12px;
}
.vr-page .video-flow-actions.remix-analyze-actions {
justify-content: flex-end;
margin-top: auto;
padding-top: 22px;
}
.vr-page .video-flow-panel h2,
.vr-page .video-result-panel h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text);
}
.vr-page .remix-information-panel.has-result .remix-info-heading h2 {
color: #fff;
font-size: 18px;
}
.vr-page .video-flow-step { margin-top: 22px; }
.vr-page .video-flow-step-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
margin-bottom: 9px;
}
.vr-page .vr-step-h strong {
font-size: 13px;
font-weight: 500;
color: var(--accent-black);
.vr-page .video-flow-step-head strong {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.vr-page .vr-step-h span {
color: var(--black-alpha-48);
font-family: var(--font-mono);
.vr-page .video-flow-step-head span {
color: var(--muted);
font-size: 11px;
letter-spacing: .04em;
}
.vr-page .vr-upload {
.vr-page .video-upload-field {
min-height: 148px;
display: grid;
place-items: center;
padding: 22px;
border: 1px dashed var(--border-muted);
border-radius: var(--r-md);
color: var(--black-alpha-56);
background: var(--background-lighter);
border: 1px dashed rgba(0, 47, 167, 0.3);
border-radius: 13px;
color: #42506a;
background: rgba(0, 47, 167, 0.04);
text-align: center;
cursor: pointer;
transition: border-color 160ms ease, background 160ms ease;
}
.vr-page .vr-upload:hover,
.vr-page .vr-upload.has-file {
border-color: var(--heat-40);
background: var(--heat-12);
color: var(--heat);
.vr-page .video-upload-field:hover,
.vr-page .video-upload-field.has-file {
border-color: var(--klein);
background: rgba(0, 47, 167, 0.075);
}
.vr-page .vr-upload > span {
.vr-page .video-upload-field > span {
display: grid;
place-items: center;
gap: 8px;
min-width: 0;
}
.vr-page .vr-upload svg {
width: 22px;
height: 22px;
.vr-page .video-upload-field svg {
width: 28px;
height: 28px;
color: var(--klein);
}
.vr-page .vr-upload strong {
color: var(--accent-black);
.vr-page .video-upload-field strong {
color: var(--text);
font-size: 14px;
font-weight: 500;
overflow: hidden;
@@ -122,12 +468,12 @@
max-width: 100%;
}
.vr-page .vr-upload small {
color: var(--black-alpha-48);
.vr-page .video-upload-field small {
color: var(--muted);
font-size: 12px;
}
.vr-page .vr-actions {
.vr-page .video-flow-actions {
display: flex;
align-items: center;
justify-content: space-between;
@@ -135,93 +481,88 @@
margin-top: 22px;
}
.vr-page .vr-meta {
display: flex;
.vr-page .primary-action,
.vr-page .secondary-action {
display: inline-flex;
align-items: center;
gap: 12px;
min-width: 0;
justify-content: center;
gap: 9px;
border-radius: 11px;
cursor: pointer;
font: inherit;
transition: transform 180ms ease, background-color 180ms ease, box-shadow 180ms ease;
}
.vr-page .vr-meta > span {
color: var(--black-alpha-48);
font-size: 12px;
.vr-page .primary-action {
min-width: 158px;
height: 50px;
padding: 0 22px;
color: #fff;
background: var(--klein);
border: 0;
box-shadow: 0 9px 18px rgba(0, 47, 167, 0.18);
}
.vr-page .vr-placeholder,
.vr-page .vr-analysis { min-height: 360px; }
.vr-page .primary-action:hover:not(:disabled) {
transform: translateY(-2px);
background: var(--klein-hover);
}
.vr-page .vr-placeholder {
.vr-page .primary-action:disabled {
opacity: 0.38;
transform: none;
box-shadow: none;
cursor: not-allowed;
}
.vr-page .secondary-action {
min-width: 132px;
height: 46px;
padding: 0 18px;
color: var(--text);
border: 1px solid rgba(28, 34, 43, 0.12);
background: rgba(34, 42, 54, 0.05);
}
.vr-page .secondary-action:hover:not(:disabled) {
background: rgba(34, 42, 54, 0.10);
}
.vr-page .primary-action svg,
.vr-page .secondary-action svg {
width: 19px;
height: 19px;
}
.vr-page .video-result-placeholder,
.vr-page .video-analysis-result {
min-height: 360px;
}
.vr-page .video-result-placeholder {
display: grid;
place-items: center;
color: var(--black-alpha-48);
text-align: left;
color: var(--muted);
text-align: center;
}
.vr-page .vr-placeholder > div {
.vr-page .video-result-placeholder > div {
display: grid;
justify-items: start;
gap: 8px;
max-width: 260px;
place-items: center;
gap: 10px;
}
.vr-page .vr-placeholder svg {
width: 22px;
height: 22px;
color: var(--black-alpha-32);
.vr-page .video-analysis-result { display: none; }
.vr-page .video-result-panel.has-result .video-result-placeholder { display: none; }
.vr-page .video-result-panel.has-result .video-analysis-result { display: block; }
.vr-page .remix-information-panel.has-result .video-analysis-result {
display: flex;
flex-direction: column;
}
.vr-page .vr-placeholder strong {
font-size: 14px;
font-weight: 500;
color: var(--accent-black);
}
.vr-page .vr-placeholder span {
font-size: 13px;
line-height: 1.65;
color: var(--black-alpha-56);
}
.vr-page .vr-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
margin: 0 0 16px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
overflow: hidden;
}
.vr-page .vr-summary span {
padding: 12px 14px;
color: var(--black-alpha-48);
background: var(--background-lighter);
font-size: 11px;
}
.vr-page .vr-summary span + span {
border-left: 1px solid var(--border-faint);
}
.vr-page .vr-summary strong {
display: block;
margin-top: 4px;
color: var(--accent-black);
font-size: 14px;
font-weight: 500;
}
.vr-page .vr-prompt {
min-height: 174px;
}
.vr-page .vr-analysis .vr-actions {
justify-content: flex-end;
}
@media (max-width: 1100px) {
.vr-page .vr-grid { grid-template-columns: 1fr; }
.vr-page .vr-summary { grid-template-columns: 1fr; }
.vr-page .vr-summary span + span { border-left: 0; border-top: 1px solid var(--border-faint); }
.vr-page .vr-actions { flex-wrap: wrap; }
@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; }
}
+4 -1
View File
@@ -10,7 +10,10 @@ export default defineConfig({
proxy: {
"/api": {
target: process.env.VITE_API_TARGET || "http://127.0.0.1:8010",
changeOrigin: true
changeOrigin: true,
// 视频提炼抽帧更密,模型写全片分镜稿可达 1–2 分钟;默认代理超时会先断
timeout: 300_000,
proxyTimeout: 300_000,
}
}
}