优化脚本

This commit is contained in:
Azmat@qq.com
2026-08-21 10:28:35 +08:00
parent 6c588a7fee
commit c2d3a786a3
23 changed files with 1461 additions and 287 deletions
+1
View File
@@ -374,6 +374,7 @@ export const api = {
prompt?: string;
model_config_id?: string;
selling_point_ids?: string[];
persona?: string;
base_version_id?: string;
aspect_ratio?: string;
total_duration?: number;
+41 -20
View File
@@ -81,16 +81,18 @@
--honey-bg: rgba(236, 183, 48, .08);
--honey-bd: rgba(236, 183, 48, .20);
/* ===== Heat · 单 hue + 8 档 alpha · 丁香蓝(对齐 YZ 主 CTA) ===== */
--heat: #A5B4FC;
--heat-hover: #8ea0f8;
--heat-90: rgba(165, 180, 252, .90);
--heat-40: rgba(165, 180, 252, .40);
--heat-20: rgba(165, 180, 252, .20);
--heat-16: rgba(165, 180, 252, .16);
--heat-12: rgba(165, 180, 252, .12);
--heat-8: rgba(165, 180, 252, .08);
--heat-4: rgba(165, 180, 252, .04);
/* ===== Heat · 单 hue + 8 档 alpha · 克莱因蓝(对齐 YZ 主 CTA,别用浅紫以免像禁用) ===== */
--klein: #002fa7;
--klein-hover: #002680;
--heat: var(--klein);
--heat-hover: var(--klein-hover);
--heat-90: rgba(0, 47, 167, .90);
--heat-40: rgba(0, 47, 167, .40);
--heat-20: rgba(0, 47, 167, .20);
--heat-16: rgba(0, 47, 167, .16);
--heat-12: rgba(0, 47, 167, .12);
--heat-8: rgba(0, 47, 167, .08);
--heat-4: rgba(0, 47, 167, .04);
/* ===== Black-alpha 阶梯 (20 档) ===== */
--black-alpha-1: rgba(0, 0, 0, .01);
@@ -133,13 +135,13 @@
/* ===== Shadows ===== */
--shadow-cta:
0 1px 2px rgba(129, 140, 248, .16),
0 6px 16px rgba(165, 180, 252, .28);
0 1px 2px rgba(0, 47, 167, .16),
0 6px 16px rgba(0, 47, 167, .24);
--shadow-cta-hover:
0 2px 4px rgba(129, 140, 248, .20),
0 10px 22px rgba(165, 180, 252, .34);
0 2px 4px rgba(0, 47, 167, .20),
0 10px 22px rgba(0, 47, 167, .28);
--shadow-cta-active:
0 1px 2px rgba(129, 140, 248, .18);
0 1px 2px rgba(0, 47, 167, .18);
--shadow-floating: 0 8px 28px rgba(17, 19, 24, .06);
}
@@ -220,11 +222,11 @@ img, svg, video { display: block; max-width: 100%; }
/* ─── App shell ─── */
.app {
display: grid;
grid-template-columns: var(--sidebar-width) 1fr;
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
min-height: 100vh;
transition: grid-template-columns var(--t-base);
}
body.sidebar-collapsed .app { grid-template-columns: 96px 1fr; }
body.sidebar-collapsed .app { grid-template-columns: 96px minmax(0, 1fr); }
/* ─── Sidebar ─── */
aside.sidebar {
@@ -758,7 +760,17 @@ body.sidebar-collapsed .user .em,
body.sidebar-collapsed .user::after { display: none; }
/* ─── Main + grid background ─── */
main { position: relative; background: #fff; min-width: 0; }
main { position: relative; background: #fff; min-width: 0; overflow-x: hidden; }
.app:has(.pipeline-page) {
height: 100vh;
overflow: hidden;
}
main:has(.pipeline-page) {
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.grid-bg {
position: absolute;
inset: 0;
@@ -817,12 +829,13 @@ main { position: relative; background: #fff; min-width: 0; }
/* ─── Topbar ─── */
.topbar {
display: flex; align-items: center; gap: 16px;
padding: 0 24px;
padding: 0 clamp(36px, 3.2vw, 68px);
border-bottom: 1px solid rgba(27, 32, 40, 0.08);
background: #fff;
position: sticky; top: 0; z-index: 50;
height: 116px;
min-height: 116px;
min-width: 0;
box-sizing: border-box;
flex-wrap: nowrap;
}
@@ -926,7 +939,7 @@ main { position: relative; background: #fff; min-width: 0; }
color: var(--black-alpha-40);
letter-spacing: .02em;
}
.topbar .right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.topbar .right { margin-left: auto; display: flex; align-items: center; gap: 10px; min-width: 0; flex: 0 1 auto; }
.balance-chip {
display: inline-flex; align-items: center; gap: 7px;
height: 40px;
@@ -1007,6 +1020,14 @@ main { position: relative; background: #fff; min-width: 0; }
min-height: calc(100vh - 116px);
animation: yz-page-enter 280ms cubic-bezier(0.22, 1, 0.36, 1);
}
.content:has(.pipeline-page) {
padding: 0;
flex: 1 1 auto;
height: auto;
min-height: 0;
overflow: hidden;
animation: none;
}
@keyframes yz-page-enter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
+311 -8
View File
@@ -7,17 +7,14 @@
width: 100%;
max-width: none;
box-sizing: border-box;
margin: -24px -28px -60px;
margin: 0;
padding: 28px clamp(40px, 3.2vw, 68px) 32px;
height: calc(100vh - 116px);
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
@media (max-width: 1100px) {
.pipeline-page { margin: -28px -24px -48px; }
}
.pipeline-page .pl-head {
min-height: 54px;
@@ -1223,9 +1220,10 @@
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--border-faint);
border: 1px solid rgba(0, 47, 167, 0.13);
border-radius: 12px;
background: var(--surface);
box-shadow: 0 12px 28px rgba(20, 27, 38, 0.07);
cursor: pointer;
}
.video-card:hover { border-color: rgba(0, 47, 167, 0.28); }
@@ -1235,7 +1233,55 @@
aspect-ratio: 9 / 16;
max-height: 280px;
overflow: hidden;
background: #e5e6e9;
background: #fff;
}
.video-thumb.pending::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background-color: #fff;
background-image:
radial-gradient(ellipse 4px 1px at center, rgba(0, 47, 167, 0.28) 98%, transparent),
radial-gradient(ellipse 1px 4px at center, rgba(0, 47, 167, 0.28) 98%, transparent);
background-position: center;
background-size: 10px 10px;
-webkit-mask-image: radial-gradient(ellipse 42% 28% at center, #000 0 18%, rgba(0, 0, 0, 0.78) 40%, rgba(0, 0, 0, 0.24) 72%, transparent 100%);
mask-image: radial-gradient(ellipse 42% 28% at center, #000 0 18%, rgba(0, 0, 0, 0.78) 40%, rgba(0, 0, 0, 0.24) 72%, transparent 100%);
}
.video-thumb.pending::after,
.video-thumb.generating::after {
content: "待生成";
position: absolute;
top: 50%;
left: 50%;
z-index: 2;
pointer-events: none;
min-width: 72px;
height: 32px;
display: grid;
place-items: center;
padding: 0 14px;
border: 1px solid rgba(0, 47, 167, 0.26);
border-radius: 8px;
color: var(--klein);
background: #fff;
box-shadow: 0 8px 22px rgba(0, 47, 167, 0.11);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
transform: translate(-50%, -50%);
}
.video-thumb.generating::after { content: "生成中"; }
.video-thumb .video-loader {
position: absolute;
inset: 0;
z-index: 1;
overflow: hidden;
background:
linear-gradient(120deg, transparent 20%, rgba(255, 255, 255, 0.88) 43%, transparent 66%) 0 0 / 220% 100%,
linear-gradient(145deg, #eaf1fb, #f8fbff 48%, #e9eef7);
animation: videoShimmer 2.2s linear infinite;
}
.video-thumb video {
position: absolute;
@@ -1531,8 +1577,265 @@
.vd-history-thumb.tri .placeholder { aspect-ratio: 16/9; }
}
/* 弹窗 portal 到 body,选择器必须顶层,否则会被工作区 overflow 裁掉顶栏 */
.asset-modal-bg {
--klein: #002fa7;
position: fixed;
inset: 0;
z-index: 1800;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 24px;
background: rgba(21, 20, 15, 0.42);
}
.asset-modal {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: 12px;
width: min(960px, 100%);
max-height: calc(100vh - 48px);
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
}
.asset-modal-h {
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
padding: 12px 20px;
border-bottom: 1px solid var(--border-faint);
flex-shrink: 0;
}
.asset-modal-h h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--accent-black);
}
.asset-modal-h .ad-tag,
.asset-modal-h .mono {
font-family: var(--font-mono);
font-size: 12px;
color: var(--black-alpha-48);
letter-spacing: .02em;
}
.asset-modal-h .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;
}
.asset-modal-h .x:hover { background: var(--black-alpha-08); color: var(--accent-black); }
.asset-modal-body { padding: 20px 24px 24px; overflow-y: auto; flex: 1; min-height: 0; }
.asset-modal-f {
padding: 14px 20px;
border-top: 1px solid var(--border-faint);
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.asset-detail-grid { display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 24px; align-items: start; }
.asset-detail-lead { display: flex; flex-direction: column; gap: 10px; }
.asset-detail-lead .ad-lead-wrap { position: relative; }
.asset-detail-lead .placeholder.ad-lead-img { aspect-ratio: 9/16; border-radius: var(--r-md); }
.ad-zoom-btn {
position: absolute;
right: 8px;
bottom: 8px;
width: 32px;
height: 32px;
padding: 0;
background: rgba(21, 20, 15, .7);
color: #fff;
border: 0;
border-radius: var(--r-sm);
display: grid;
place-items: center;
cursor: pointer;
opacity: 0;
transition: opacity var(--t-base), background var(--t-base);
z-index: 3;
}
.ad-zoom-btn:hover { background: rgba(21, 20, 15, .92); }
.ad-zoom-btn svg { width: 14px; height: 14px; }
.asset-detail-lead .ad-lead-wrap:hover .ad-zoom-btn,
.asset-detail-tri-row .ad-tri-wrap:hover .ad-zoom-btn { opacity: 1; }
.asset-detail-tri-row .ad-tri-wrap { position: relative; }
.asset-detail-lead .ad-thumbs { display: flex; gap: 8px; flex-wrap: wrap; }
.asset-detail-lead .ad-thumbs .thumb {
flex: 0 0 56px;
width: 56px;
aspect-ratio: 3/4;
border-radius: var(--r-sm);
border: 1px solid var(--border-faint);
cursor: pointer;
overflow: hidden;
transition: border-color var(--t-base);
}
.asset-detail-lead .ad-thumbs .thumb:hover { border-color: var(--heat-40); }
.asset-detail-lead .ad-thumbs .thumb.active { border-color: var(--heat); border-width: 2px; }
.asset-detail-right .ad-section + .ad-section { margin-top: 18px; }
.asset-detail-section-h {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: var(--accent-black);
margin-bottom: 10px;
}
.asset-detail-section-h .ic { width: 14px; height: 14px; color: var(--klein); flex-shrink: 0; display: grid; place-items: center; }
.asset-detail-section-h .ic svg { width: 14px; height: 14px; }
.asset-detail-section-h .ad-ratio-chip {
margin-left: auto;
font-family: var(--font-mono);
font-size: 12px;
padding: 2px 8px;
border-radius: var(--r-sm);
background: var(--background-lighter);
border: 1px solid var(--border-faint);
color: var(--black-alpha-56);
letter-spacing: .02em;
}
.asset-detail-section-h .ad-icon-btn {
width: 28px;
height: 28px;
display: grid;
place-items: center;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-56);
cursor: pointer;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.asset-detail-section-h .ad-icon-btn:hover { background: var(--klein); color: #fff; border-color: var(--klein); }
.asset-detail-section-h .ad-icon-btn svg { width: 12px; height: 12px; }
.asset-detail-section-h .ad-icon-gap { margin-left: 8px; }
.asset-detail-tri-row .placeholder { aspect-ratio: 16 / 9; border-radius: var(--r-md); }
.md-view-versions { display: flex; gap: 6px; overflow-x: auto; padding: 2px; margin-top: 8px; }
.md-view-versions .v-thumb {
flex: 0 0 auto;
width: 72px;
aspect-ratio: 16/9;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
position: relative;
cursor: pointer;
display: grid;
place-items: center;
overflow: hidden;
transition: border-color var(--t-base);
}
.md-view-versions .v-thumb:hover { border-color: var(--heat-40); }
.md-view-versions .v-thumb.active { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); }
.md-view-versions .v-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; }
.md-view-versions .v-thumb.active .v { color: var(--heat); font-weight: 600; }
.asset-detail-tip {
margin-top: 10px;
padding: 10px 12px;
background: var(--heat-12);
border: 1px solid var(--heat-20);
border-radius: var(--r-sm);
font-size: 12px;
color: var(--accent-black);
display: flex;
align-items: center;
gap: 8px;
line-height: 1.5;
}
.asset-detail-tip svg { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; }
.asset-detail-tip .ai-gen-btn {
margin-left: auto;
height: 26px;
padding: 0 10px;
background: var(--heat);
color: var(--accent-white);
border: 1px solid var(--heat);
border-radius: var(--r-sm);
font-size: 12px;
cursor: pointer;
font-family: inherit;
flex-shrink: 0;
}
.asset-modal-f .ad-foot-stats { display: flex; gap: 6px; margin-right: auto; }
.asset-modal-f .ad-stat-btn {
height: 32px;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-72);
font-size: 12px;
font-family: inherit;
cursor: pointer;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.asset-modal-f .ad-stat-btn:hover { background: var(--background-lighter); color: var(--accent-black); }
.asset-modal-f .ad-stat-btn svg { width: 13px; height: 13px; }
.ad-detail-prompt {
width: 100%;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
color: var(--black-alpha-72);
background: var(--background-base);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
padding: 10px 12px;
outline: none;
resize: none;
height: 88px;
transition: border-color var(--t-base), background var(--t-base), box-shadow var(--t-base);
}
.ad-thumbs .thumb.has-mock-media,
.md-view-versions .v-thumb.has-mock-media,
.prod-preview-history .h-thumb.has-mock-media {
background-image: var(--mock-media-url);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.ad-detail-prompt:hover { border-color: var(--heat-20); }
.ad-detail-prompt:focus { border-color: var(--heat); background: var(--surface); color: var(--accent-black); box-shadow: 0 0 0 3px var(--heat-12); }
.vd-main-wrap { display: flex; gap: 18px; align-items: flex-start; }
.vd-main { flex: 0 0 280px; aspect-ratio: 9/16; max-height: 460px; background: #000; border-radius: var(--r-md); overflow: hidden; position: relative; }
.vd-main video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
.vd-info { flex: 1; min-width: 0; }
.vd-section-h { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--accent-black); margin-bottom: 10px; }
.vd-kv { display: grid; grid-template-columns: 72px 1fr; gap: 6px 10px; font-size: 13px; }
.vd-kv .k { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .03em; padding-top: 1px; }
.vd-kv .v { color: var(--accent-black); min-width: 0; overflow-wrap: anywhere; }
.vd-history-h { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 10px; }
.vd-history-row { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; }
.vd-history-thumb { flex: 0 0 64px; min-width: 64px; display: flex; flex-direction: column; gap: 4px; padding: 4px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); cursor: pointer; transition: border-color var(--t-base); position: relative; }
.vd-history-thumb:hover { border-color: var(--heat); }
.vd-history-thumb.current { border-color: var(--heat); background: var(--heat-12); }
.vd-history-thumb .placeholder { aspect-ratio: 9/16; }
.vd-history-thumb .ts { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); text-align: center; }
.vd-prompt-field { margin-top: 16px; }
.vd-prompt-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 6px; }
.vd-prompt-head .label { font-family: var(--font-mono); font-size: 12px; font-weight: 500; color: var(--black-alpha-56); letter-spacing: .04em; }
.vd-prompt-edit { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px 14px; font-family: var(--font-mono); font-size: 12px; line-height: 1.7; color: var(--accent-black); white-space: pre-wrap; min-height: 96px; outline: none; letter-spacing: .01em; cursor: text; }
.vd-modal-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
/* 流程步骤4 · 基础资产 loading 转圈(顶层 @keyframes 才注册) */
@keyframes assetSpin { to { transform: rotate(360deg); } }
@keyframes videoShimmer { to { background-position: -220% 0, 0 0; } }
/* 进度流逐条滚入(顶层 @keyframes 才注册) */
@keyframes psRowIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
@@ -1646,7 +1949,7 @@
.ref-gate-mask {
position: fixed;
inset: 0;
z-index: 60;
z-index: 1800;
display: flex;
align-items: center;
justify-content: center;
+48 -32
View File
@@ -1,4 +1,5 @@
import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { ArrowLeft, ArrowRight, Check, ChevronRight, Image, Info, LayoutList, Play, RefreshCw, Route, Sparkles, Upload, UsersRound, X } from "lucide-react";
import { api, ApiError } from "../api";
@@ -352,7 +353,9 @@ function PromptBox({ value, onChange, className = "prompt-box", id, ariaLabel, s
// 分镜「旁白 / 画面」可编辑框:非受控 textarea,落库走 onBlur。
// 聚焦中绝不覆盖,避免提交后父组件用旧值把正在输入的字冲掉。
function ShotArea({ value, placeholder, ariaLabel, onCommit }: { value: string; placeholder: string; ariaLabel: string; onCommit: (text: string) => void }) {
function ShotArea({ value, placeholder, ariaLabel, onCommit, rows = 2 }: {
value: string; placeholder: string; ariaLabel: string; onCommit: (text: string) => void; rows?: number;
}) {
const ref = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const el = ref.current;
@@ -366,7 +369,7 @@ function ShotArea({ value, placeholder, ariaLabel, onCommit }: { value: string;
defaultValue={value}
placeholder={placeholder}
aria-label={ariaLabel}
rows={2}
rows={rows}
onBlur={(e) => {
const text = e.currentTarget.value.trim();
if (text !== value) onCommit(text);
@@ -404,7 +407,7 @@ function DraftShotCard({ draft, onCommit, onCancel }: {
</label>
<label className="shot-edit">
<span></span>
<textarea className="shot-area" rows={2} placeholder="输入这一镜的画面描述…" value={visual}
<textarea className="shot-area" rows={6} placeholder="0-3s:近景……(按秒写出每一镜)" value={visual}
onChange={(e) => setVisual(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); commit(); } else if (e.key === "Escape") { onCancel?.(draft.id); } }} />
</label>
@@ -599,7 +602,7 @@ export function PipelinePage(props: {
try { return new Set<string>(JSON.parse(localStorage.getItem(`airshelf:pending-del:${project.id}`) || "[]")); } catch { return new Set(); }
});
const shots = [...(currentScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order).filter((s) => !pendingDeletes.has(s.id));
// 单镜时长自二期起是变长的(415s),缺值时按上限兜底,跟后端 script_agent 一致
// 单镜时长主流程固定 15s;缺值时按上限兜底,跟后端 script_agent 一致
const shotSeconds = (s: { duration_seconds?: number | null }) => s.duration_seconds || SEGMENT_DURATION_MAX;
const scriptTotalSeconds = shots.reduce((sum, s) => sum + shotSeconds(s), 0);
// 对白 speaker(entity id)→ 角色名;null = 旁白。entities 在 ScriptVersion.metadata
@@ -1534,9 +1537,11 @@ export function PipelinePage(props: {
mode: agentMode,
prompt,
model_config_id: activeScriptModelId || undefined,
selling_point_ids: Array.isArray(wizard?.selling_point_ids) ? wizard.selling_point_ids : undefined,
persona: setupPersona,
base_version_id: baseVersionId,
aspect_ratio: "9:16",
// 设定卡的三个参数一路传到后端:后端据此挑套路提示词 + 按节奏切镜(不再每镜写死 15 秒)
// 设定卡参数一路传到后端:每镜固定 15 秒,总时长 15/30/45/60
total_duration: setupDuration,
presentation_format: setupFormat,
video_structure: setupStructure,
@@ -1625,8 +1630,7 @@ export function PipelinePage(props: {
pushMsg("ai", summaryText || "镜头脚本已生成,左侧已刷新。可继续输入修改意见,或点「确认脚本」进入下一步。");
}
}
// 1.5 · 确认设定后真正发起生成。表现形式/结构/时长走结构化参数(后端挑套路),
// 人物没有对应的后端字段,仍并进 prompt 让模型读。
// 1.5 · 确认设定后真正发起生成。形式/结构/时长/人物/勾选卖点都走结构化参数,不再塞一句空主题。
async function runScriptWithSetup() {
const format = coercePresentationFormat(setupFormat);
const structure = coerceVideoStructure(setupStructure);
@@ -1684,12 +1688,12 @@ export function PipelinePage(props: {
// 5.2 换商品重跑:新建项目时选了套路模板 → 把模板的镜序/节奏/转化写法并进 prompt,
// 但明确要求文案按当前商品重写(模板里不含旧文案,转化写法可能带旧品牌词,这里再挡一道)。
const tplLine = templateOutline
? `\n请照这个套路重出一版:\n${templateOutline}\n`
+ `镜数、每镜作用与时长按上面来,文案全部按我当前的商品重新写,`
? `请照这个套路重出一版:\n${templateOutline}\n`
+ `镜数、每镜作用按上面来(每镜 15 秒),文案全部按我当前的商品重新写,`
+ `参考写法里出现的旧商品、品牌、价格一律不要保留。`
: "";
await runScriptGeneration(
`目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放${tplLine}`,
tplLine,
templateName ? `套用模板「${templateName}」:${combo}` : `${sourceLabel}:${combo}`,
"ai",
);
@@ -3004,8 +3008,9 @@ export function PipelinePage(props: {
<span></span>
<ShotArea
value={visualShown}
placeholder="点击编辑画面内容"
placeholder="0-3s:近景……(按秒写出每一镜)"
ariaLabel={`场景 ${index + 1} 画面内容`}
rows={6}
onCommit={(text) => { void onUpdateShot({ segment_id: shot.id, visual_prompt: text }); }}
/>
</label>
@@ -3102,7 +3107,7 @@ export function PipelinePage(props: {
</select>
</label>
<div className="setup-rec"></div>
{/* 1.9 时长自由化:5–60 秒、5 秒一档;每镜切多长由后端按表现形式的节奏决定 */}
{/* 时长:15/30/45/60;每镜固定 15 秒 */}
<label className="setup-field">
<span className="sf-k"></span>
<select className="setup-select" value={setupDuration} onChange={(e) => { setupTouched.current = true; setSetupDuration(clampDuration(Number(e.target.value))); }}>
@@ -3453,7 +3458,7 @@ export function PipelinePage(props: {
return (
<div className="as-gen" data-asset-kind="scene" key={dk}>
<div className={`as-gen-preview${busy ? " generating" : " pending"}`}>
{busy ? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span> · 8s</span></span> : null}
{busy ? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span></span></span> : null}
</div>
<div className="as-gen-meta">
<input type="text" value={draft.title} placeholder="场景图待生成" aria-label="场景名" disabled={busy} data-stop
@@ -3498,7 +3503,7 @@ export function PipelinePage(props: {
onClick={() => openSeedDetail(kind, tag, promptValue)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openSeedDetail(kind, tag, promptValue); } }}
>
{busy ? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span> · 8s</span></span> : null}
{busy ? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span></span></span> : null}
</div>
<div className="as-gen-meta">
<h4 onClick={() => openSeedDetail(kind, tag, promptValue)}>{tag || (kind === "person" ? "角色图待生成" : "场景图待生成")}</h4>
@@ -3823,11 +3828,17 @@ export function PipelinePage(props: {
const showBusy = busy || pending;
const verCount = seg.versions?.length ?? 0;
return (
<div className="video-card" key={seg.id} data-video-id={seg.id}>
<div className="video-thumb" role="button" tabIndex={0} title="点击查看详情" onClick={() => openVideoDetail(seg.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openVideoDetail(seg.id); } }}>
{url
? <video src={`${url}#t=0.1`} muted playsInline preload="metadata" />
: <span className="ph-frame"> {seg.sort_order + 1}</span>}
<div className={`video-card${url ? " is-ready" : showBusy ? " is-generating" : " is-pending"}`} key={seg.id} data-video-id={seg.id}>
<div
className={`video-thumb${url ? " ready" : ""}${showBusy ? " generating" : ""}${!url && !showBusy ? " pending" : ""}`}
role="button"
tabIndex={0}
title="点击查看详情"
aria-label={showBusy ? `${seg.sort_order + 1} 生成中` : url ? `${seg.sort_order + 1}` : `${seg.sort_order + 1} 待生成`}
onClick={() => openVideoDetail(seg.id)}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openVideoDetail(seg.id); } }}
>
{url ? <video src={`${url}#t=0.1`} muted playsInline preload="metadata" /> : null}
{url && !showBusy && (
<button
className="btn-play"
@@ -3838,7 +3849,7 @@ export function PipelinePage(props: {
<Play size={16} fill="currentColor" />
</button>
)}
{showBusy && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
{showBusy ? <span className="video-loader" aria-hidden="true" /> : null}
</div>
<div className="body">
<div className="video-card-head">
@@ -4282,7 +4293,7 @@ export function PipelinePage(props: {
const showBusy = busy || rerunPending.has(vdSeg.id);
const hint = (shots[vdSeg.sort_order]?.visual_prompt || shots[vdSeg.sort_order]?.narration || "").trim();
const rerunPrompt = vdPrompt.trim() || `${videoPrompt}${vdSeg.sort_order + 1} 段,时长 ${vdSeg.target_duration_seconds}`;
return (
return createPortal(
<div className="asset-modal-bg" role="dialog" aria-modal="true" aria-label="视频详情" onClick={(event) => { if (event.target === event.currentTarget) setVdSegId(null); }}>
<div className="asset-modal">
<div className="asset-modal-h">
@@ -4349,9 +4360,10 @@ export function PipelinePage(props: {
{showBusy ? <><span className="spinner btn-spin" aria-hidden="true" />{busy ? "生成中…" : "提交中…"}</> : "↻ 重跑本场"}
</button>
</div>
</div>
</div>
</div>
</div>,
document.body
);
})()}
{/* ── 流程步骤4/5 · 兜底弹窗拦截:参考图不齐时拦住生成,让用户去补 / 仍要生成(随他便) ── */}
@@ -4359,7 +4371,7 @@ export function PipelinePage(props: {
// 拆两类:noref=连参考图都没有;notri=有立绘但缺三视图。文案分别告诉用户「去哪里点什么」。
const noRef = refGate.missing.filter((m) => m.reason === "noref");
const noTri = refGate.missing.filter((m) => m.reason === "notri");
return (
return createPortal(
<div className="ref-gate-mask" onClick={() => setRefGate(null)}>
<div className="ref-gate-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<div className="rg-title">,?</div>
@@ -4398,11 +4410,12 @@ export function PipelinePage(props: {
<button type="button" className="btn btn-ghost rg-force" onClick={() => { const go = refGate.proceed; setRefGate(null); go(); }}> <span className="rg-warn"></span></button>
</div>
</div>
</div>
</div>,
document.body
);
})()}
{/* ── 流程步骤5 · 过审闸弹窗:含真人脸的人物/分镜未过审 → 列出哪一镜/哪个,先过审再生成(火山合规要求) ── */}
{reviewGate && (
{reviewGate && createPortal(
<div className="ref-gate-mask" onClick={() => setReviewGate(null)}>
<div className="ref-gate-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<div className="rg-title">,</div>
@@ -4440,7 +4453,8 @@ export function PipelinePage(props: {
<button type="button" className="btn btn-ghost" onClick={() => setReviewGate(null)}></button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* ── 流程步骤4 · 人物/场景详情弹窗:立绘 + 三视图(人物)+ 提示词重获 + 版本历史 + 应用到当前项目 ── */}
{adDetail && (() => {
@@ -4494,12 +4508,12 @@ export function PipelinePage(props: {
setAdTriId(null);
}
const zoomSvg = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5" /></svg>;
return (
return createPortal(
<div className="asset-modal-bg" role="dialog" aria-modal="true" aria-label={`${KIND_LABEL[adDetail.kind]}详情`} onClick={(event) => { if (event.target === event.currentTarget) setAdDetail(null); }}>
<div className="asset-modal">
<div className="asset-modal-h">
<h2></h2>
<span className="ad-tag">/ {KIND_LABEL[adDetail.kind]} · {entity.name}</span>
<h2>{entity.name}</h2>
<span className="ad-tag">/ {KIND_LABEL[adDetail.kind]}</span>
<button className="x" type="button" aria-label="关闭" onClick={() => setAdDetail(null)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
@@ -4629,11 +4643,12 @@ export function PipelinePage(props: {
}}></button>
</div>
</div>
</div>
</div>,
document.body
);
})()}
{/* 问题5(a):用已有素材作三视图——选商品已有图 / 上传本地图,attach 成商品采用版(不走平台 AI 生成、不计费) */}
{assetPick && (
{assetPick && createPortal(
<div className="asset-modal-bg" role="dialog" aria-modal="true" aria-label={assetPick.title} onClick={(e) => { if (e.target === e.currentTarget && !assetPickBusy) setAssetPick(null); }}>
<div className="asset-modal">
<div className="asset-modal-h">
@@ -4675,7 +4690,8 @@ export function PipelinePage(props: {
)}
</div>
</div>
</div>
</div>,
document.body
)}
{/* 流程步骤4 · 模特库:浏览 / 添加模特(AI 生成·本地上传)/ 替换回填 */}
<ModelLibrary
+7 -4
View File
@@ -79,7 +79,7 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
useEffect(() => {
const p = products.find((item) => item.id === productId);
const seed: Record<string, boolean> = {};
(p?.selling_points || []).forEach((sp) => { seed[sp.title] = false; });
(p?.selling_points || []).forEach((sp) => { seed[sp.id || sp.title] = false; });
setPoints(seed);
}, [productId, products]);
@@ -344,12 +344,15 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
<div className="nw-field">
<span><small></small></span>
<div className="nw-pills">
{Object.entries(points).map(([key, on]) => (
{Object.entries(points).map(([key, on]) => {
const label = product?.selling_points?.find((sp) => (sp.id || sp.title) === key)?.title || key;
return (
<button className={`nw-pill${on ? " on" : ""}`} type="button" key={key} aria-pressed={on} onClick={() => setPoints((prev) => ({ ...prev, [key]: !prev[key] }))}>
{on && <Check size={13} />}
<span>{key}</span>
<span>{label}</span>
</button>
))}
);
})}
</div>
</div>
)}
+5 -5
View File
@@ -69,12 +69,12 @@ export function allowedStructures(format: PresentationFormat): VideoStructure[]
return STRUCTURE_KEYS.filter((key) => !isForbidden(format, key));
}
// ── 时长:总时长 5–60 秒、5 秒步进;单镜 4–15 秒由后端切,前端只管总时长 ──
// ── 时长:总时长 15/30/45/60;主流程每镜固定 15 秒 ──
export const SEGMENT_DURATION_MIN = 4;
export const SEGMENT_DURATION_MAX = 15;
export const TOTAL_DURATION_MIN = 5;
export const TOTAL_DURATION_MIN = 15;
export const TOTAL_DURATION_MAX = 60;
export const TOTAL_DURATION_STEP = 5;
export const TOTAL_DURATION_STEP = 15;
export const DURATION_OPTIONS: number[] = Array.from(
{ length: (TOTAL_DURATION_MAX - TOTAL_DURATION_MIN) / TOTAL_DURATION_STEP + 1 },
(_, i) => TOTAL_DURATION_MIN + i * TOTAL_DURATION_STEP
@@ -82,8 +82,8 @@ export const DURATION_OPTIONS: number[] = Array.from(
/** 表现形式推荐的默认总时长:口播短平快 / 短剧要装下三幕 / Vlog 要铺氛围。 */
const FORMAT_DEFAULT_DURATION: Record<PresentationFormat, number> = { oral: 30, drama: 45, vlog: 30 };
/** 各结构能压到的最短总时长 —— 低于它证据或氛围就不成立了。 */
const STRUCTURE_MIN_DURATION: Record<VideoStructure, number> = { pain: 15, contrast: 10, review: 20, scene: 20 };
/** 各结构能压到的最短总时长 —— 低于它证据或氛围就不成立了。须落在 15 秒步进上。 */
const STRUCTURE_MIN_DURATION: Record<VideoStructure, number> = { pain: 15, contrast: 15, review: 30, scene: 30 };
export function recommendDuration(format: PresentationFormat, structure: VideoStructure): number {
return Math.max(FORMAT_DEFAULT_DURATION[format], STRUCTURE_MIN_DURATION[structure]);
+8 -8
View File
@@ -56,10 +56,10 @@
--ink-2: #5A5F6A;
--ink-3: #8B909A;
--ink-4: #C5C9D2;
--orange: #A5B4FC;
--orange-soft: #E4E9FE;
--orange-tint: #F0F3FF;
--orange-hover: #8B9CF7;
--orange: #002fa7;
--orange-soft: rgba(0, 47, 167, .18);
--orange-tint: rgba(0, 47, 167, .06);
--orange-hover: #002680;
--green: #3F6B3F;
--green-bg: #EAF2EA;
--green-bd: #D5E5D5;
@@ -393,7 +393,7 @@ main { position: relative; overflow: hidden; background: #fff; }
font-family: inherit;
color: var(--ink);
}
.input:focus, .textarea:focus, .select:focus { border-color: var(--orange); box-shadow: 0 0 0 3px rgba(165, 180, 252, 0.18); }
.input:focus, .textarea:focus, .select:focus { border-color: var(--orange); box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.18); }
.input::placeholder, .textarea::placeholder { color: var(--ink-3); }
.textarea { height: auto; min-height: 80px; padding: 10px 12px; line-height: 1.55; resize: vertical; }
.select { appearance: none; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 16 16' fill='none'><path d='M4 6l4 4 4-4' stroke='%239C988C' stroke-width='1.4'/></svg>"); background-repeat: no-repeat; background-position: right 10px center; padding-right: 30px; }
@@ -714,8 +714,8 @@ table.t tbody tr:hover { background: var(--bg-soft); }
--accent-crimson: #eb3424;
--accent-forest: #42c366;
--accent-honey: #ecb730;
--heat: #A5B4FC;
--heat-12: rgba(165, 180, 252, .12);
--heat: #002fa7;
--heat-12: rgba(0, 47, 167, .12);
--black-alpha-32: rgba(38, 38, 38, .32);
--black-alpha-48: rgba(38, 38, 38, .48);
--black-alpha-56: rgba(38, 38, 38, .56);
@@ -1020,7 +1020,7 @@ table.t tbody tr:hover { background: var(--bg-soft); }
justify-content: center;
gap: 8px;
}
.auth-exact-page .btn-cta:hover { box-shadow: 0 4px 14px rgba(165, 180, 252, .28); }
.auth-exact-page .btn-cta:hover { box-shadow: 0 4px 14px rgba(0, 47, 167, .28); }
.auth-exact-page .btn-cta:active { transform: translateY(1px); }
.auth-exact-page .btn-cta:disabled { opacity: .82; cursor: wait; }
.auth-exact-page .btn-cta .busy-copy {