优化脚本
This commit is contained in:
@@ -462,28 +462,6 @@ export function App() {
|
||||
}
|
||||
}, [activeProjectId, refreshBilling]);
|
||||
|
||||
// 静默轮询故事板分镜生成(对标 pollVideosQuiet):有 queued/running 的场就驱动后端出图并刷新,不占全局 loading、不弹 toast。
|
||||
// 这样单场重跑不会把别的场按钮也锁住——生成在后台跑,UI 只按每场自身状态转圈。
|
||||
const pollStoryboardQuiet = useCallback(async () => {
|
||||
if (!activeProjectId) return;
|
||||
let detail = projectDetailRef.current;
|
||||
if (!detail || detail.id !== activeProjectId) {
|
||||
detail = await api.project(activeProjectId).catch(() => null);
|
||||
if (!detail) return;
|
||||
setProjectDetail(detail);
|
||||
}
|
||||
const active = (detail.storyboard_shots ?? []).filter((s) => ["queued", "running"].includes(s.status));
|
||||
if (active.length === 0) return;
|
||||
await api.pollStoryboard(activeProjectId).catch(() => undefined);
|
||||
const next = await api.project(activeProjectId).catch(() => null);
|
||||
if (next && next.id === activeProjectIdRef.current) {
|
||||
setProjectDetail((prev) => (prev && JSON.stringify(prev) === JSON.stringify(next) ? prev : next));
|
||||
// 分镜图成功落库时后端同时结算积分;只在成功终态刷新顶栏余额。
|
||||
const charged = active.some((shot) => next.storyboard_shots?.find((item) => item.id === shot.id)?.status === "succeeded");
|
||||
if (charged) void refreshBilling();
|
||||
}
|
||||
}, [activeProjectId, refreshBilling]);
|
||||
|
||||
// 静默刷新导出任务状态(进入拼接页 / 导出后回填成片),不弹 toast。
|
||||
const refreshExport = useCallback(async () => {
|
||||
if (!activeProjectId) return;
|
||||
@@ -1210,16 +1188,6 @@ export function App() {
|
||||
if (!assetId) return null;
|
||||
return { adopted_asset: assetId };
|
||||
}}
|
||||
onGenerateStoryboard={(prompt) =>
|
||||
// 只「提交」(秒回);出图由后台 pollStoryboardQuiet 驱动 —— 不占全局 loading,不锁其它场按钮(对标视频「开始生成」)
|
||||
action(() => api.generateStoryboard(pipelineProject.id, { prompt }), "故事板已开始生成")
|
||||
}
|
||||
onRerunStoryboardShot={(shotId, prompt) =>
|
||||
// 单场重跑也只「提交」(秒回);后台轮询出图。不阻塞 → 可同时重跑别的场
|
||||
action(() => api.rerunStoryboardShot(pipelineProject.id, shotId, prompt), "本场已开始重跑")
|
||||
}
|
||||
onAdoptStoryboardShotVersion={(shotId, versionId) => action(() => api.adoptStoryboardShotVersion(pipelineProject.id, shotId, versionId), "已采用该版本")}
|
||||
onPollStoryboardQuiet={pollStoryboardQuiet}
|
||||
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
|
||||
onSetAdoptState={(groupId, state) => action(() => api.setBaseAssetAdopt(pipelineProject.id, { group_id: groupId, state }), state === "adopted" ? "已采用" : "已标为未采用")}
|
||||
onDeleteBaseAsset={(groupId) => action(() => api.deleteBaseAsset(pipelineProject.id, groupId), "已删除")}
|
||||
@@ -1241,7 +1209,6 @@ export function App() {
|
||||
}}
|
||||
// 流程步骤4 · 添加模特工作台命名:同步写回形象资产名称。
|
||||
onRenameModel={(assetId, name) => action(() => api.updateAsset(assetId, { name }), "")}
|
||||
onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")}
|
||||
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交,生成中…")}
|
||||
onSubmitAllVideos={(prompt) =>
|
||||
action(async () => {
|
||||
|
||||
@@ -599,32 +599,12 @@ export const api = {
|
||||
pollReviews(projectId: string) {
|
||||
return request<{ reviews: Record<string, string> }>(`/api/projects/${projectId}/poll-reviews/`, { method: "POST" });
|
||||
},
|
||||
generateStoryboard(projectId: string, payload: { prompt: string }) {
|
||||
return request(`/api/projects/${projectId}/generate-storyboard/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
pollStoryboard(projectId: string) {
|
||||
return request<{ status: "generating" | "succeeded" | "failed"; done: number; total: number; version_id: string; error?: string }>(
|
||||
`/api/projects/${projectId}/poll-storyboard/`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
},
|
||||
skipStoryboard(projectId: string) {
|
||||
return request<Project>(`/api/projects/${projectId}/skip-storyboard/`, { method: "POST" });
|
||||
},
|
||||
// 单场重跑:只重出该 shot 的分镜图(新增一条历史版本并采用)
|
||||
rerunStoryboardShot(projectId: string, shotId: string, prompt?: string) {
|
||||
return request(`/api/projects/${projectId}/rerun-storyboard-shot/`, { method: "POST", body: JSON.stringify({ shot_id: shotId, prompt: prompt || "" }) });
|
||||
},
|
||||
// 采用某场的某个历史版本(切换该场分镜图)
|
||||
adoptStoryboardShotVersion(projectId: string, shotId: string, versionId: string) {
|
||||
return request<Project>(`/api/projects/${projectId}/adopt-storyboard-shot-version/`, { method: "POST", body: JSON.stringify({ shot_id: shotId, version_id: versionId }) });
|
||||
},
|
||||
submitVideo(projectId: string, payload: { video_segment_id: string; prompt: string }) {
|
||||
return request<Project>(`/api/projects/${projectId}/submit-video-segment/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
// 点生成视频前的过审闸:列出未过审的人物立绘/故事板分镜。video_segment_id 给定=只校验该段,不给=全部未出片段。
|
||||
// 点生成视频前的过审闸:列出未过审的人物立绘。video_segment_id 给定=只校验该段,不给=全部未出片段。
|
||||
videoReviewPrecheck(projectId: string, videoSegmentId?: string) {
|
||||
return request<{ blockers: Array<{ video_segment_id: string; sort_order: number; scene_no: number; kind: "person" | "storyboard"; name: string; asset_id: string; review_status: string }> }>(
|
||||
return request<{ blockers: Array<{ video_segment_id: string; sort_order: number; scene_no: number; kind: "person"; name: string; asset_id: string; review_status: string }> }>(
|
||||
`/api/projects/${projectId}/video-review-precheck/`,
|
||||
{ method: "POST", body: JSON.stringify(videoSegmentId ? { video_segment_id: videoSegmentId } : {}) },
|
||||
);
|
||||
@@ -841,7 +821,7 @@ export const api = {
|
||||
},
|
||||
aiTasks() {
|
||||
// 生图工作室的任务中心:只看生图任务(模特上身图/平台套图/图片创作 = person_image /
|
||||
// product_image),不掺脚本/实体抽取/故事板等流水线内部任务。取一大页(后端上限 200)
|
||||
// product_image),不掺脚本/实体抽取等流水线内部任务。取一大页(后端上限 200)
|
||||
// 以便 全部/已完成/失败 三个 tab 数按真实总量算。
|
||||
return request<Paginated<AITask>>("/api/ai/tasks/?page_size=200&task_type=person_image,product_image");
|
||||
},
|
||||
|
||||
@@ -231,21 +231,88 @@
|
||||
.login-page .login-card > * { position: relative; z-index: 1; }
|
||||
.login-page .login-card.shake { animation: login-shake 360ms ease; }
|
||||
|
||||
.login-page .login-card.is-register {
|
||||
height: auto;
|
||||
min-height: 548px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.login-page .login-form-panel:has(.is-register) {
|
||||
overflow: auto;
|
||||
align-content: start;
|
||||
.login-page .login-form-panel.is-register-panel {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
overflow: auto;
|
||||
padding: 40px 36px 32px;
|
||||
}
|
||||
.login-page .login-form-panel:has(.is-register) .login-extras {
|
||||
.login-page .login-register-stack {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: min(100%, 480px);
|
||||
height: fit-content;
|
||||
align-self: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.login-page .login-card.is-register {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
padding: 32px 36px 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.login-page .login-card.is-register .login-card-heading {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.login-page .login-card.is-register .login-step {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.login-page .login-card.is-register .login-card-heading h2 {
|
||||
font-size: 28px;
|
||||
}
|
||||
.login-page .login-card.is-register .login-card-heading p {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.login-page .login-card.is-register .login-form {
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.login-page .login-card.is-register .login-field {
|
||||
min-width: 0;
|
||||
}
|
||||
.login-page .login-card.is-register .login-field:has(.login-note) {
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.login-page .login-card.is-register .login-note {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.login-page .login-card.is-register .login-error {
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.login-page .login-card.is-register .login-error:not(.show) {
|
||||
display: none;
|
||||
}
|
||||
.login-page .login-card.is-register .login-agree {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.login-page .login-card.is-register .login-submit {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.login-page .login-card.is-register .login-card-footer {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.login-page .login-card.is-register .login-pass-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.login-page .login-form-panel.is-register-panel .login-extras {
|
||||
position: static;
|
||||
left: auto;
|
||||
bottom: auto;
|
||||
width: 100%;
|
||||
transform: none;
|
||||
margin-top: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-page .login-card-heading { margin-bottom: 36px; }
|
||||
@@ -499,14 +566,19 @@
|
||||
.login-page .login-note.err { color: #c83d4d; }
|
||||
|
||||
.login-page .login-verify {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.login-page .login-verify .login-input-wrap { flex: 1; }
|
||||
.login-page .login-verify .login-input-wrap { min-width: 0; width: auto; }
|
||||
.login-page .login-verify-btn {
|
||||
height: 54px;
|
||||
padding: 0 16px;
|
||||
padding: 0 14px;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 11px;
|
||||
color: #fff;
|
||||
@@ -521,8 +593,10 @@
|
||||
.login-page .login-pass-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.login-page .login-pass-row .login-field { min-width: 0; }
|
||||
|
||||
.login-page .login-agree {
|
||||
display: flex;
|
||||
@@ -578,12 +652,24 @@
|
||||
overflow: auto;
|
||||
padding: 32px 20px 28px;
|
||||
}
|
||||
.login-page .login-form-panel.is-register-panel {
|
||||
padding: 36px 20px 28px;
|
||||
}
|
||||
.login-page .login-register-stack {
|
||||
width: min(100%, 480px);
|
||||
}
|
||||
.login-page .login-card {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
width: min(100%, 480px);
|
||||
padding: 32px 24px 28px;
|
||||
}
|
||||
.login-page .login-card.is-register {
|
||||
padding: 32px 24px 28px;
|
||||
}
|
||||
.login-page .login-pass-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.login-page .login-extras {
|
||||
position: static;
|
||||
width: min(100%, 480px);
|
||||
|
||||
@@ -1193,61 +1193,8 @@
|
||||
/* 外层人物/场景卡提示词框:固定 5 行高(行高 1.55 + 内边距/边框),超长不撑高、框内上下滚动 */
|
||||
.prompt-box { height: calc(5 * 1.55em + 22px); overflow-y: auto; box-sizing: border-box; }
|
||||
|
||||
/* ============= STAGE 3 · 故事板 ============= */
|
||||
.stage[data-stage-pane="3"].active > .stage-storyboard { flex: 1 1 0; min-height: 0; overflow-y: auto; }
|
||||
.stage[data-stage-pane="3"].active > .stage-storyboard { gap: 0; }
|
||||
.stage[data-stage-pane="3"].active > .stage-storyboard > .sb-canvas { border: 0; border-radius: 0; background: var(--surface); padding: 18px 14px 18px 28px; align-items: center; }
|
||||
.stage[data-stage-pane="3"].active > .stage-storyboard > .sb-canvas > .sb-main-img { width: 100%; }
|
||||
/* 有真图时大图占满画布整个高度(不再 16/9 居中留上下空白) */
|
||||
.stage[data-stage-pane="3"].active > .stage-storyboard > .sb-canvas > .sb-main-img.has-mock-media { align-self: stretch; aspect-ratio: auto; }
|
||||
/* 2:3 空占位:别被上面的 width:100% 撑成超高满框 —— 给个合理高度、按 2:3 推宽、居中 */
|
||||
.stage[data-stage-pane="3"].active > .stage-storyboard > .sb-canvas > .sb-main-img:not(.has-mock-media) { width: auto; height: min(70vh, 540px); aspect-ratio: 2/3; justify-self: center; }
|
||||
.stage[data-stage-pane="3"].active > .stage-storyboard > .sb-side { display: flex; flex-direction: column; min-height: 0; }
|
||||
.stage[data-stage-pane="3"].active > .stage-storyboard > .sb-side > .pane { flex: 1 1 0; min-height: 0; overflow-y: auto; border: 0; border-radius: 0; background: var(--surface); padding: 18px 28px; }
|
||||
.stage[data-stage-pane="3"].active .sb-scenes-col { max-height: none; }
|
||||
|
||||
.stage-storyboard { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 16px; align-items: stretch; }
|
||||
.sb-canvas { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 14px; display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: 14px; }
|
||||
.sb-scenes-col { display: flex; flex-direction: column; gap: 10px; overflow-y: auto; overflow-x: hidden; max-height: 560px; padding-right: 6px; scrollbar-width: thin; }
|
||||
.sb-scenes-col::-webkit-scrollbar { width: 6px; }
|
||||
.sb-scenes-col::-webkit-scrollbar-thumb { background: var(--border-faint); border-radius: 4px; }
|
||||
.sb-scene-thumb { flex: 0 0 auto; cursor: pointer; display: flex; flex-direction: column; gap: 6px; padding: 6px; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); transition: border-color var(--t-base), background var(--t-base); }
|
||||
.sb-scene-thumb:hover { background: var(--background-lighter); }
|
||||
.sb-scene-thumb.selected { border-color: var(--heat); background: var(--heat-12); }
|
||||
.sb-scene-thumb .placeholder { aspect-ratio: 1; }
|
||||
.sb-scene-thumb .nm { font-size: 11.5px; font-weight: 500; color: var(--accent-black); }
|
||||
.sb-scene-thumb .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); }
|
||||
/* 整张故事板出图是竖屏 2:3(1024×1536),空占位也用 2:3,跟成品同形(不再 16:9 横框) */
|
||||
.sb-main-img { aspect-ratio: 2/3; min-height: 0; }
|
||||
/* 整张故事板是竖长拼图,cover 会裁掉上下 —— 用 contain 完整显示;
|
||||
留白底色用白(--surface)而非占位浅灰,避免「灰底大框」(对齐 HTML 干净白底) */
|
||||
.sb-main-img.has-mock-media { background-size: contain; background-color: var(--surface); }
|
||||
/* 整张生成中:每张分镜占位上叠一层半透明遮罩 + 转圈 spinner(不全屏遮挡;样式同基础资产趴) */
|
||||
/* 生成中遮罩(资产/三视图占位复用) */
|
||||
.sb-gen-veil { position: absolute; inset: 0; z-index: 3; display: grid; place-items: center; border-radius: inherit; pointer-events: none; background: color-mix(in srgb, var(--surface) 62%, transparent); }
|
||||
.sb-scene-thumb .sb-gen-veil .spinner { width: 16px; height: 16px; }
|
||||
.sb-main-img .sb-gen-veil .spinner { width: 28px; height: 28px; }
|
||||
/* 分镜图火山人像审核盾:缩略图角上只留盾(省地方),大图左上挂完整徽章 */
|
||||
.sb-scene-thumb .placeholder .sb-frame-rv { position: absolute; top: 4px; left: 4px; z-index: 4; transform: scale(.82); transform-origin: top left; }
|
||||
.sb-scene-thumb .placeholder .sb-frame-rv .rv-label { display: none; }
|
||||
.sb-main-img .sb-main-rv { position: absolute; top: 10px; left: 10px; z-index: 4; }
|
||||
|
||||
.sb-rerun-note { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; margin-bottom: 14px; background: rgba(180,83,9,.08); border: 1px solid rgba(180,83,9,.20); border-radius: var(--r-md); color: #7C3A05; line-height: 1.55; }
|
||||
.sb-rerun-note .warn-ic { width: 22px; height: 22px; border-radius: var(--r-sm); background: rgba(180,83,9,.12); color: #B45309; display: grid; place-items: center; flex: 0 0 22px; }
|
||||
.sb-rerun-note .warn-ic svg { width: 14px; height: 14px; }
|
||||
.sb-rerun-note .note-copy { min-width: 0; font-size: 12px; }
|
||||
.sb-rerun-note strong { color: #B45309; }
|
||||
.sb-rerun-note a { color: #B45309; text-decoration: underline; text-underline-offset: 2px; }
|
||||
|
||||
.sb-stage-actions { display: flex; gap: 8px; margin-top: 14px; margin-bottom: 12px; }
|
||||
.sb-history { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-faint); }
|
||||
.sb-history-h { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 10px; }
|
||||
.sb-history-row { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; }
|
||||
.sb-history-thumb { flex: 0 0 80px; min-width: 80px; 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); }
|
||||
.sb-history-thumb:hover { border-color: var(--heat); }
|
||||
.sb-history-thumb.current { border-color: var(--heat); background: var(--heat-12); }
|
||||
.sb-history-thumb .placeholder { aspect-ratio: 1; }
|
||||
.sb-history-thumb .ts { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); text-align: center; }
|
||||
.sb-history-thumb.current .ts { color: var(--heat); font-weight: 600; }
|
||||
|
||||
.pill-cta { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 14px; border-radius: 999px; font-size: 13px; cursor: pointer; font-family: inherit; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
|
||||
.pill-cta.heat { background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); }
|
||||
@@ -1256,7 +1203,6 @@
|
||||
.pill-cta.ghost:hover { background: var(--background-lighter); border-color: var(--heat-20); color: var(--heat); }
|
||||
.pill-cta svg { width: 13px; height: 13px; }
|
||||
|
||||
.sb-side .pane { padding: 18px; }
|
||||
.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: 200px; outline: none; letter-spacing: .01em; cursor: text; transition: border-color var(--t-base), background var(--t-base), box-shadow var(--t-base); }
|
||||
.prompt-edit:hover { border-color: var(--heat-20); }
|
||||
.prompt-edit:focus { border-color: var(--heat); background: var(--surface); box-shadow: 0 0 0 3px var(--heat-12); }
|
||||
@@ -1265,9 +1211,9 @@
|
||||
/* 绑定资产缩略图(替代占位圆点 · 一眼看出引用了哪张资产) */
|
||||
.asset-tag .asset-thumb { width: 22px; height: 22px; flex: 0 0 22px; border-radius: var(--r-pill); background-size: cover; background-position: center; border: 1px solid var(--border-faint); }
|
||||
|
||||
/* ============= STAGE 4 · 视频 ============= */
|
||||
.stage[data-stage-pane="4"].active > .queue-bar { border: 0; border-radius: 0; border-bottom: 1px solid var(--border-faint); margin: 0; padding: 14px 28px; flex: 0 0 auto; }
|
||||
.stage[data-stage-pane="4"].active > .video-grid {
|
||||
/* ============= STAGE 3 · 视频 ============= */
|
||||
.stage[data-stage-pane="3"].active > .queue-bar { border: 0; border-radius: 0; border-bottom: 1px solid var(--border-faint); margin: 0; padding: 14px 28px; flex: 0 0 auto; }
|
||||
.stage[data-stage-pane="3"].active > .video-grid {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
@@ -1395,12 +1341,12 @@
|
||||
.video-ver-badge { background: none; border: 0; padding: 0; cursor: pointer; font-family: var(--font-mono); font-size: 12px; letter-spacing: .02em; color: var(--black-alpha-48); transition: color var(--t-base); }
|
||||
.video-ver-badge:hover { color: var(--heat); }
|
||||
|
||||
/* ============= STAGE 5 · 拼接编辑器 ============= */
|
||||
.stage[data-stage-pane="5"].active > .editor { flex: 1 1 0; min-height: 0; overflow-y: auto; height: auto; }
|
||||
.stage[data-stage-pane="5"].active > .editor { border: 0; border-radius: 0; }
|
||||
.stage[data-stage-pane="5"].active > .editor > .editor-preview { padding-left: 28px; }
|
||||
.stage[data-stage-pane="5"].active > .editor > .editor-props { padding-right: 28px; }
|
||||
.stage[data-stage-pane="5"].active > .editor > .timeline { padding-left: 28px; padding-right: 28px; }
|
||||
/* ============= STAGE 4 · 拼接编辑器 ============= */
|
||||
.stage[data-stage-pane="4"].active > .editor { flex: 1 1 0; min-height: 0; overflow-y: auto; height: auto; }
|
||||
.stage[data-stage-pane="4"].active > .editor { border: 0; border-radius: 0; }
|
||||
.stage[data-stage-pane="4"].active > .editor > .editor-preview { padding-left: 28px; }
|
||||
.stage[data-stage-pane="4"].active > .editor > .editor-props { padding-right: 28px; }
|
||||
.stage[data-stage-pane="4"].active > .editor > .timeline { padding-left: 28px; padding-right: 28px; }
|
||||
|
||||
.editor { display: grid; grid-template-columns: 1fr 280px; grid-template-rows: 1fr auto; gap: 0; height: 580px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); }
|
||||
.editor-preview { padding: 16px; border-right: 1px solid var(--border-faint); border-bottom: 1px solid var(--border-faint); display: flex; flex-direction: column; gap: 12px; }
|
||||
@@ -1562,14 +1508,6 @@
|
||||
.tri-missing-pop .pop-tip { font-size: 12px; line-height: 1.5; color: var(--black-alpha-56); }
|
||||
.tri-missing-pop b { color: var(--heat); }
|
||||
|
||||
/* ── 底栏「确认故事板」点击气泡:没生成故事板时点确认 → 朝上弹提示(复用 pop 视觉) ── */
|
||||
.sb-confirm-wrap { position: relative; display: inline-flex; }
|
||||
.sb-confirm-pop { position: absolute; bottom: calc(100% + 10px); right: 0; width: 248px; padding: 12px 14px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); box-shadow: 0 12px 32px rgba(0,0,0,.14); display: flex; flex-direction: column; gap: 6px; z-index: 30; animation: sb-confirm-in var(--t-base); }
|
||||
.sb-confirm-pop .pop-h { display: inline-flex; align-items: center; gap: 5px; font-family: var(--font-mono); font-size: 12px; letter-spacing: .04em; color: var(--heat); }
|
||||
.sb-confirm-pop .pop-body { font-size: 12px; line-height: 1.55; color: var(--accent-black); }
|
||||
.sb-confirm-pop b { color: var(--heat); }
|
||||
@keyframes sb-confirm-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* ── 行39 · 三视图选择弹窗 ── */
|
||||
.tri-modal { width: min(720px, 100%); }
|
||||
.tri-modal .tri-modal-tip { font-size: 13px; line-height: 1.55; color: var(--black-alpha-72); padding: 10px 12px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-md); margin-bottom: 16px; }
|
||||
|
||||
@@ -31,7 +31,8 @@ function projBucket(p: Project): "wip" | "ok" | "fail" {
|
||||
}
|
||||
const PROJ_STATUS_LABEL: Record<"wip" | "ok" | "fail", string> = { wip: "进行中", ok: "已完成", fail: "失败 · 待重跑" };
|
||||
// 阶段 → 编号 + 名称 + 进度百分比(5 段流水线;completed = 100%)
|
||||
const STAGE_PCT: Record<string, number> = { script: 20, base_assets: 40, storyboard: 60, video: 80, export: 100 };
|
||||
// 故事板已下线;storyboard 保留映射只为老项目的历史 current_stage 兜底
|
||||
const STAGE_PCT: Record<string, number> = { script: 25, base_assets: 50, storyboard: 50, video: 75, export: 100 };
|
||||
function projStage(p: Project): { label: string; pct: number } {
|
||||
if (p.status === "completed") return { label: "Stage 5 拼接导出", pct: 100 };
|
||||
const meta = stageMeta[p.current_stage];
|
||||
|
||||
@@ -64,7 +64,7 @@ export function AdminPromptsPage({ notify }: { notify: Notify }) {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>提示词</h1>
|
||||
<div className="sub"><span className="mono">视频线 6 条</span> · 生图/视频提示词正文 + 比例可改;留空或停用 → 回落写死默认</div>
|
||||
<div className="sub"><span className="mono">视频线 5 条</span> · 生图/视频提示词正文 + 比例可改;留空或停用 → 回落写死默认</div>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
|
||||
@@ -273,7 +273,7 @@ export function AuthScreen({
|
||||
<span className="login-hero-line">让每一次商品表达,</span>
|
||||
<span className="login-hero-line login-hero-accent">更快成为内容。</span>
|
||||
</h1>
|
||||
<p>从商品资产、脚本、故事板到视频生成,以统一的创作工作流连接品牌内容生产的每一个环节。</p>
|
||||
<p>从商品资产、脚本到视频生成,以统一的创作工作流连接品牌内容生产的每一个环节。</p>
|
||||
</div>
|
||||
<div className="login-capabilities" aria-label="平台能力">
|
||||
<div className="login-capability">
|
||||
@@ -311,8 +311,9 @@ export function AuthScreen({
|
||||
return (
|
||||
<section className="login-page" aria-labelledby="loginTitle">
|
||||
{brand}
|
||||
<main className="login-form-panel">
|
||||
<main className="login-form-panel is-register-panel">
|
||||
<div className="login-plus-pattern" aria-hidden="true">{LOGIN_PLUS_MARKS}</div>
|
||||
<div className="login-register-stack">
|
||||
<div className="login-card is-register">
|
||||
<div className="login-card-heading">
|
||||
<span className="login-step"><ShieldCheck />安全访问</span>
|
||||
@@ -490,6 +491,7 @@ export function AuthScreen({
|
||||
已有账号? <a href="/login" onClick={(event) => { event.preventDefault(); switchMode("login"); }}>去登录 →</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{toastNode}
|
||||
</section>
|
||||
|
||||
@@ -32,7 +32,7 @@ const CREATE_GROUPS: Array<{
|
||||
hint: "围绕商品卖点生成完整带货内容",
|
||||
cards: [
|
||||
{ title: "一键成片", desc: "上传商品图片,自动完成整条视频", tone: "primary", page: "quickCreate", icon: "wand" },
|
||||
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成", tone: "primary", page: "projectWizard", icon: "folder" },
|
||||
{ title: "专业创作", desc: "控制脚本、资产与视频生成", tone: "primary", page: "projectWizard", icon: "folder" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -46,7 +46,8 @@ const CREATE_GROUPS: Array<{
|
||||
];
|
||||
|
||||
const DASH_STAGE_TOTAL = 5;
|
||||
const DASH_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 5 };
|
||||
// 故事板已下线;storyboard 保留映射只为老项目的历史 current_stage 兜底
|
||||
const DASH_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 2, video: 3, export: 4 };
|
||||
const DASH_TABS: Array<{ filter: DashTab; label: string }> = [
|
||||
{ filter: "all", label: "全部" },
|
||||
{ filter: "wip", label: "进行中" },
|
||||
@@ -69,7 +70,7 @@ function dashStageLabel(project: Project): string {
|
||||
const map: Record<string, string> = {
|
||||
script: "脚本",
|
||||
base_assets: "资产创建",
|
||||
storyboard: "故事板",
|
||||
storyboard: "资产创建",
|
||||
video: "视频生成",
|
||||
export: "视频生成",
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ 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";
|
||||
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, StoryboardShot, Team, TimelineSavePayload, User } from "../types";
|
||||
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, Team, TimelineSavePayload, User } from "../types";
|
||||
import { isHiddenFromScriptPicker, publicModelDisplayName } from "../model-display";
|
||||
import { isPublicGenerationError, presentGenerationError } from "../generation-error";
|
||||
import type { Notice, Page } from "./route-config";
|
||||
@@ -275,8 +275,7 @@ const PIPELINE_RAIL = [
|
||||
{ n: "01", title: "选择商品", desc: "确定本次内容生产的商品主体" },
|
||||
{ n: "02", title: "脚本创建", desc: "生成并确认镜头脚本" },
|
||||
{ n: "03", title: "资产选择", desc: "准备商品、角色与场景资产" },
|
||||
{ n: "04", title: "故事板", desc: "生成并确认视频分镜画面" },
|
||||
{ n: "05", title: "视频生成", desc: "按故事板生成视频片段" },
|
||||
{ n: "04", title: "视频生成", desc: "按脚本的秒级分镜直接生成视频" },
|
||||
];
|
||||
const OUTPUT_RATIOS = [
|
||||
{ value: "9:16", label: "9:16 竖屏" },
|
||||
@@ -302,11 +301,10 @@ function modelResolutions(config: ModelConfig | undefined) {
|
||||
}
|
||||
|
||||
const PIPELINE_HEAD: Record<number, { title: string; desc: string; status: string }> = {
|
||||
1: { title: "脚本创建", desc: "围绕商品卖点组织镜头脚本,确认后进入下一步内容生产", status: "镜头脚本" },
|
||||
2: { title: "资产选择", desc: "准备故事板所需的商品、角色与场景资产,确保后续画面保持一致", status: "资产选择" },
|
||||
3: { title: "故事板", desc: "检查每个镜头的画面、台词、运镜和节奏,确认后进入视频生成。", status: "故事板" },
|
||||
4: { title: "视频生成", desc: "系统已按故事板场次自动开始生成,各场视频会并行完成。", status: "视频生成" },
|
||||
5: { title: "视频生成", desc: "系统已按故事板场次自动开始生成,各场视频会并行完成。", status: "视频生成" },
|
||||
1: { title: "脚本创建", desc: "围绕商品卖点组织镜头脚本,画面按秒写清景别、机位、运镜,确认后进入下一步", status: "镜头脚本" },
|
||||
2: { title: "资产选择", desc: "准备商品、角色与场景参考图,用来锁住出片时的脸、商品和场景", status: "资产选择" },
|
||||
3: { title: "视频生成", desc: "系统已按脚本的秒级分镜逐镜生成,各场视频会并行完成。", status: "视频生成" },
|
||||
4: { title: "视频生成", desc: "系统已按脚本的秒级分镜逐镜生成,各场视频会并行完成。", status: "视频生成" },
|
||||
};
|
||||
|
||||
// 行34 · 行内「添加标签」:点 + 展开输入框,回车 / 失焦提交(空则收起)
|
||||
@@ -557,12 +555,7 @@ export function PipelinePage(props: {
|
||||
onGenerateTriview: (portraitGroupId: string) => Promise<{ id: string } | null>;
|
||||
// 流程步骤4 · 添加模特工作台命名:同步写回形象资产名称。
|
||||
onRenameModel?: (assetId: string, name: string) => Promise<unknown>;
|
||||
onGenerateStoryboard: (prompt: string) => void | Promise<unknown>;
|
||||
// 单场重跑(只重出该 shot 的分镜图)/ 采用某场某历史版本
|
||||
onRerunStoryboardShot?: (shotId: string, prompt?: string) => Promise<unknown>;
|
||||
onAdoptStoryboardShotVersion?: (shotId: string, versionId: string) => void | Promise<unknown>;
|
||||
onPollStoryboardQuiet?: () => void | Promise<void>;
|
||||
onSkipStoryboard: () => Promise<unknown>;
|
||||
onSubmitVideo: (segmentId: string, prompt: string) => void | Promise<unknown>;
|
||||
onSubmitAllVideos: (prompt: string) => void | Promise<unknown>;
|
||||
onPollVideosQuiet: () => void | Promise<void>;
|
||||
@@ -578,7 +571,7 @@ export function PipelinePage(props: {
|
||||
const {
|
||||
project, loading, navigate, products, assets, onNotify,
|
||||
textModels, videoModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
||||
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateModel, onUploadModel, onGenerateTriview, onRenameModel, onGenerateStoryboard, onRerunStoryboardShot, onAdoptStoryboardShotVersion, onPollStoryboardQuiet, onSkipStoryboard,
|
||||
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateModel, onUploadModel, onGenerateTriview, onRenameModel,
|
||||
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject, onRefreshBilling,
|
||||
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
||||
} = props;
|
||||
@@ -983,26 +976,13 @@ export function PipelinePage(props: {
|
||||
setExtractErr(err instanceof Error ? err.message : "提取失败,请重试");
|
||||
}
|
||||
}
|
||||
// ── 流程步骤4/5 · 兜底弹窗拦截:生成故事板/视频前,查被引用的角色/场景是否都有「按名字采用」的参考图 ──
|
||||
// ── 流程步骤3 · 兜底弹窗拦截:生成视频前,查被引用的角色/场景是否都有「按名字采用」的参考图 ──
|
||||
// 缺了就弹窗(去补齐 / 仍要生成),不静默退化文生图、不让用户花冤枉钱。商品永远有主图、不算缺。
|
||||
// reason:"noref" = 还没参考图(立绘/场景图);"notri" = 角色有立绘但缺三视图(故事板合成需多角度参考)。
|
||||
type RefMiss = { name: string; type: string; reason: "noref" | "notri" };
|
||||
// reason:"noref" = 还没参考图(立绘/场景图)。原来还拦「缺三视图」,那是故事板多图合成的要求;
|
||||
// 故事板下线后视频只取立绘锁脸,不再拦三视图。
|
||||
type RefMiss = { name: string; type: string; reason: "noref" };
|
||||
const [refGate, setRefGate] = useState<{ missing: RefMiss[]; proceed: () => void } | null>(null);
|
||||
useBodyScrollLock(Boolean(refGate));
|
||||
// 某角色(按名字)已采用的立绘有没有配套三视图:取该角色代表组的 adopted_asset → 查它的三视图组。
|
||||
// 用与详情弹窗同款判定(triview 组有候选 / 资产 metadata 标记 / 模特库正面图)。
|
||||
function personHasTriview(name: string): boolean {
|
||||
const ent = buildEntities("person").find((e) => (e.name || "").trim() === (name || "").trim());
|
||||
const portrait = ent?.group.adopted_asset;
|
||||
if (!portrait) return false;
|
||||
const tri = triGroupForAsset(portrait);
|
||||
if (tri && (tri.adopted_asset || (tri.candidate_assets?.length ?? 0) > 0)) return true;
|
||||
const m: Record<string, unknown> = byId.get(portrait)?.metadata || {};
|
||||
const has = m.tri_view ?? m.triview ?? m.has_triview ?? m.three_view ?? m.tri_views;
|
||||
if (has === true || (Array.isArray(has) && has.length > 0) || (typeof has === "string" && (has as string).trim())) return true;
|
||||
if (m.view === "frontal") return true; // 模特库正面图与三视图同批生成
|
||||
return false;
|
||||
}
|
||||
function missingRefsFor(segs: Array<{ entity_refs?: string[] }>): RefMiss[] {
|
||||
const ents = project.metadata?.script_entities;
|
||||
if (!Array.isArray(ents) || !ents.length) return []; // 没提取过实体就不拦(交给提取闸门)
|
||||
@@ -1019,9 +999,7 @@ export function PipelinePage(props: {
|
||||
const kind = ent.type === "character" ? "person" : "scene";
|
||||
const name = (ent.name || "").trim();
|
||||
if (!name) continue;
|
||||
if (!adopted[kind].has(name)) { miss.set(name, { name, type: ent.type, reason: "noref" }); continue; }
|
||||
// 已有立绘/场景图 → 角色再查三视图:故事板 @图 合成需正/侧/背多角度参考,缺则拦(ZWQ#3 防呆)
|
||||
if (kind === "person" && !personHasTriview(name)) miss.set(name, { name, type: ent.type, reason: "notri" });
|
||||
if (!adopted[kind].has(name)) miss.set(name, { name, type: ent.type, reason: "noref" });
|
||||
}
|
||||
}
|
||||
return [...miss.values()];
|
||||
@@ -1032,9 +1010,9 @@ export function PipelinePage(props: {
|
||||
if (missing.length) setRefGate({ missing, proceed: action });
|
||||
else action();
|
||||
}
|
||||
// ── 流程步骤5 · 生成视频前的「过审闸」:含真人脸的人物立绘/故事板分镜必须先过审(火山要素材库已过审引用),
|
||||
// 否则火山直接报 InputImageSensitiveContentDetected。未过审 → 弹窗指明哪一镜/哪个人物/哪张分镜,挡住不生成。
|
||||
type ReviewBlocker = { video_segment_id: string; sort_order: number; scene_no: number; kind: "person" | "storyboard"; name: string; asset_id: string; review_status: string };
|
||||
// ── 流程步骤3 · 生成视频前的「过审闸」:含真人脸的人物立绘必须先过审(火山要素材库已过审引用),
|
||||
// 否则火山直接报 InputImageSensitiveContentDetected。未过审 → 弹窗指明哪一镜/哪个人物,挡住不生成。
|
||||
type ReviewBlocker = { video_segment_id: string; sort_order: number; scene_no: number; kind: "person"; name: string; asset_id: string; review_status: string };
|
||||
const [reviewGate, setReviewGate] = useState<{ blockers: ReviewBlocker[]; submitting: boolean } | null>(null);
|
||||
useBodyScrollLock(Boolean(reviewGate));
|
||||
// 过审闸:有未过审项 → 弹窗(去送审/等过审),不放行;全过审 → 执行生成。segmentId 给定=只校验该段。
|
||||
@@ -1136,63 +1114,12 @@ export function PipelinePage(props: {
|
||||
}, [adDetail]);
|
||||
// 三视图改为「手动生成」(用户点详情弹窗里的「生成三视图」按钮),不再打开角色详情就自动出图、自动扣费。
|
||||
|
||||
// ── Stage 3:分镜制(对标视频段)—— 每镜一个 StoryboardShot,各自采用版图 + 历史版本,可单场重跑/切版 ──
|
||||
const sbShots = [...(project.storyboard_shots ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const [sbSelected, setSbSelected] = useState(0);
|
||||
const sbActiveShot = sbShots[Math.min(sbSelected, Math.max(0, sbShots.length - 1))] || null;
|
||||
// 「查看的版本」与「采用的版本」分离(对标视频详情弹窗):点历史缩略图只切预览(纯本地态),
|
||||
// 绝不动后端状态 → 重跑在制时切版本不会把 RUNNING 状态冲掉、不丢任务;采用要点显式「采用此版本」。
|
||||
const [sbViewVerId, setSbViewVerId] = useState<string | null>(null);
|
||||
// 切换场 / 切项目 / 采用版变化(重跑落地或手动采用)时复位预览到「采用版」,这样重跑出片后自动看到新图
|
||||
useEffect(() => { setSbViewVerId(null); }, [sbActiveShot?.id, sbActiveShot?.adopted_version]);
|
||||
const sbAnyImage = sbShots.some((s) => Boolean(s.adopted_asset_url || s.adopted_asset));
|
||||
// 全部场都出片(有采用版)= 故事板完成,可进视频;在制中(queued/running)用于转圈veil
|
||||
const sbAllDone = sbShots.length > 0 && sbShots.every((s) => Boolean(s.adopted_version));
|
||||
const sbAnyGenerating = sbShots.some((s) => ["queued", "running"].includes(s.status));
|
||||
// 故事板按「采用版脚本」逐镜出图 → 还没出图时,先按采用版镜头数铺等量空占位(场1…场N),
|
||||
// 让用户一眼看出「这里本该有几张」,出图后逐个填真图。无采用版脚本则为 0(显示「暂无」)。
|
||||
const sbAdoptedScript = scripts.find((s) => s.is_adopted) || null;
|
||||
const sbExpectedShots = sbAdoptedScript ? (sbAdoptedScript.segments?.length ?? 0) : 0;
|
||||
// 整张/全部生成中:不全屏遮挡,只在每张分镜占位上转个 spinner(样式同基础资产趴)
|
||||
const [sbGenerating, setSbGenerating] = useState(false);
|
||||
// 单场重跑的乐观态:点下立刻给该场转圈(撑到状态回传 queued/running 接管)
|
||||
const [sbBusyShots, setSbBusyShots] = useState<Set<string>>(() => new Set());
|
||||
const clearSbBusy = (id: string) => setSbBusyShots((s) => { if (!s.has(id)) return s; const n = new Set(s); n.delete(id); return n; });
|
||||
// 单场重跑:乐观置忙 + 调后端 → 由 poll 出图;状态进 queued/running 后由状态接管,失败/超时兜底解禁
|
||||
function rerunStoryboardShotOptimistic(shotId: string) {
|
||||
setSbBusyShots((s) => new Set(s).add(shotId));
|
||||
Promise.resolve(onRerunStoryboardShot?.(shotId, storyboardPrompt || SB_PROMPT_DEFAULT))
|
||||
.then((res) => { if (res == null) clearSbBusy(shotId); })
|
||||
.catch(() => clearSbBusy(shotId));
|
||||
window.setTimeout(() => clearSbBusy(shotId), 90000);
|
||||
}
|
||||
// 进入 succeeded/failed 终态的场,从乐观忙集合摘除(此后由真实状态驱动)
|
||||
const sbStatusKey = sbShots.map((s) => `${s.id}:${s.status}`).join("|");
|
||||
useEffect(() => {
|
||||
setSbBusyShots((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const next = new Set(prev);
|
||||
for (const s of sbShots) if (next.has(s.id) && ["succeeded", "failed"].includes(s.status)) next.delete(s.id);
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sbStatusKey]);
|
||||
// 没生成故事板时点「确认故事板」→ 弹气泡提示先去生成(3.5s 自动收起)
|
||||
const [sbConfirmHint, setSbConfirmHint] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!sbConfirmHint) return;
|
||||
const t = window.setTimeout(() => setSbConfirmHint(false), 3500);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [sbConfirmHint]);
|
||||
|
||||
// ── Stage 4:视频片段(adopted_asset 缩略图 + 状态 pill + 时长)──
|
||||
const segments = [...(project.video_segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const segDone = segments.filter((s) => ["succeeded", "completed", "done"].includes(s.status)).length;
|
||||
const segTotalSec = segments.reduce((sum, s) => sum + (s.target_duration_seconds || 0), 0);
|
||||
const videoAnyStarted = segments.some((s) => ["running", "succeeded", "queued", "completed", "done"].includes(s.status));
|
||||
const [chargeConfirm, setChargeConfirm] = useState<"storyboard" | "video" | null>(null);
|
||||
const sbChargeShots = shots.length || sbExpectedShots;
|
||||
const sbChargePoints = sbChargeShots * pts(20);
|
||||
const [chargeConfirm, setChargeConfirm] = useState<"video" | null>(null);
|
||||
const videoConfigs = videoModels ?? [];
|
||||
const defaultVideoModel = videoConfigs.find((m) => m.status === "active" && m.name === DEFAULT_VIDEO_MODEL_NAME)
|
||||
|| videoConfigs.find((m) => m.status === "active")
|
||||
@@ -1300,9 +1227,6 @@ export function PipelinePage(props: {
|
||||
(sum, duration) => sum + estimateCost(outputModel, { ratio: outputAspect, resolution: outputResolution, duration, refs: [] }, billingRates).points,
|
||||
0,
|
||||
);
|
||||
const sbNextLabel = sbAnyImage || sbAnyGenerating
|
||||
? "进入故事板"
|
||||
: `生成故事板 · ${sbChargeShots > 0 ? `${sbChargePoints} 积分` : `${pts(20)} 积分/镜`}`;
|
||||
const videoNextLabel = videoAnyStarted
|
||||
? "进入视频"
|
||||
: videoChargePoints > 0
|
||||
@@ -1381,7 +1305,7 @@ export function PipelinePage(props: {
|
||||
// 步进器:对齐镜像 activateStage 逻辑。默认(无 hash)pane=脚本(1) 但步进器 active=项目真实阶段;
|
||||
// 一旦导航(hash 或点击),active 跟随所看阶段,completed=max(项目阶段-1, 所看阶段-1)。
|
||||
// V1 雪藏拼接导出:已完成项目落到第 4 阶段(视频),不落到被藏的第 5(V2 恢复改回 5)
|
||||
const projectStage = project.status === "completed" ? 4 : Math.max(1, (stageOrder as readonly string[]).indexOf(project.current_stage) + 1);
|
||||
const projectStage = project.status === "completed" ? 3 : Math.max(1, (stageOrder as readonly string[]).indexOf(project.current_stage) + 1);
|
||||
const initHash = typeof location !== "undefined" ? location.hash.match(/#stage-(\d)/) : null;
|
||||
// 进入项目默认落到「项目当前进行到的阶段」(视频在生成/已生成 → 直接到第4阶段),而不是恒显第1阶段(ZWQ#16)。
|
||||
// 仍尊重地址栏 #stage-N(分享某阶段链接 / 浏览器前进后退)。
|
||||
@@ -1409,7 +1333,7 @@ export function PipelinePage(props: {
|
||||
// 在基础资产趴(stage 2)/ 故事板趴(stage 3)/ 视频趴(stage 4)定时轮询审核状态,刷新徽章
|
||||
// (processing → active绿 / failed红)。stage4 也轮询:过审闸里「一键送审」后,要在视频阶段就能看到盾变绿再生成。
|
||||
useEffect(() => {
|
||||
if (viewStage !== 2 && viewStage !== 3 && viewStage !== 4) return;
|
||||
if (viewStage !== 2 && viewStage !== 3) return;
|
||||
let alive = true;
|
||||
let timer = 0;
|
||||
const tick = async () => {
|
||||
@@ -1429,14 +1353,6 @@ export function PipelinePage(props: {
|
||||
timer = window.setInterval(tick, 8000);
|
||||
return () => { alive = false; window.clearInterval(timer); };
|
||||
}, [viewStage, project.id, genBusy, reviewWake, pendingGenKey]);
|
||||
// 故事板有在制场(queued/running)时,后台每 5s 静默轮询驱动出图 + 刷新(对标视频段轮询)。
|
||||
// 不占全局 loading → 单场重跑不会锁住其它场的按钮,可并行重跑多场。
|
||||
useEffect(() => {
|
||||
if (viewStage !== 3 || !sbAnyGenerating) return;
|
||||
const timer = window.setInterval(() => { void onPollStoryboardQuiet?.(); }, 5000);
|
||||
void onPollStoryboardQuiet?.();
|
||||
return () => window.clearInterval(timer);
|
||||
}, [viewStage, sbAnyGenerating, onPollStoryboardQuiet]);
|
||||
// 外部 hash 变化(浏览器前进/后退、地址栏改 #stage-N)也要切阶段——镜像有 hashchange 监听,这里补齐
|
||||
useEffect(() => {
|
||||
function onHashChange() {
|
||||
@@ -1453,7 +1369,7 @@ export function PipelinePage(props: {
|
||||
}, []);
|
||||
const activeDot = navigated ? viewStage : projectStage;
|
||||
const [chatText, setChatText] = useState("");
|
||||
// 媒体预览灯箱(视频片段播放 / 故事板分镜放大)
|
||||
// 媒体预览灯箱(视频片段播放)
|
||||
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
|
||||
const [chatMode, setChatMode] = useState<"ai" | "manual" | "video">("ai");
|
||||
const [chatAttachments, setChatAttachments] = useState<Array<{ name: string; chars: number }>>([]);
|
||||
@@ -2073,20 +1989,9 @@ export function PipelinePage(props: {
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
}, [gutterDragging]);
|
||||
const SB_PROMPT_DEFAULT = "统一商品、人物、场景风格,生成完整、可直接指导视频的导演故事板";
|
||||
// 整张风格提示词:项目级(原 StoryboardVersion.prompt 的去处),重跑时生效
|
||||
const sbSavedPrompt = (project.metadata as Record<string, unknown> | undefined)?.storyboard_prompt as string | undefined;
|
||||
const [storyboardPrompt, setStoryboardPrompt] = useState(sbSavedPrompt || SB_PROMPT_DEFAULT);
|
||||
const startStoryboardGeneration = () => {
|
||||
setSbGenerating(true);
|
||||
void persistOutputSpec()
|
||||
.catch(() => undefined)
|
||||
.then(() => onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT))
|
||||
.finally(() => setSbGenerating(false));
|
||||
};
|
||||
const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));
|
||||
|
||||
// ── Stage 5 · 真实视频播放器:时间轴 clips 当作播放列表,逐段播真实视频文件 ──
|
||||
// ── Stage 4 · 真实视频播放器:时间轴 clips 当作播放列表,逐段播真实视频文件 ──
|
||||
const isVideoAsset = (id: string | null | undefined): boolean => {
|
||||
const a = id ? byId.get(id) : null;
|
||||
if (!a) return false;
|
||||
@@ -2481,7 +2386,7 @@ export function PipelinePage(props: {
|
||||
if (cur === "succeeded" && (prev === "running" || prev === "queued")) setPreviewFinal(true);
|
||||
prevExportStatusRef.current = cur;
|
||||
}, [exportResult?.status]);
|
||||
const showingFinal = viewStage === 5 && previewFinal && exportResult?.status === "succeeded" && Boolean(exportResult?.output_url);
|
||||
const showingFinal = viewStage === 4 && previewFinal && exportResult?.status === "succeeded" && Boolean(exportResult?.output_url);
|
||||
|
||||
// ── 播放意图 effect:edPlaying 是唯一事实源,元素跟着意图走。
|
||||
// 边界换 src 浏览器自动 pause 也不会丢意图——effect 在新片段上接着 play,跨段连续播放。
|
||||
@@ -2697,7 +2602,7 @@ export function PipelinePage(props: {
|
||||
const [bgmPeaks, setBgmPeaks] = useState<number[] | null>(null);
|
||||
const videoAssetKey = edClips.filter((c) => c.isVideo && c.assetId).map((c) => c.assetId).join(",");
|
||||
useEffect(() => {
|
||||
if (viewStage !== 5 || !videoAssetKey) return;
|
||||
if (viewStage !== 4 || !videoAssetKey) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
for (const id of Array.from(new Set(videoAssetKey.split(",")))) {
|
||||
@@ -2710,7 +2615,7 @@ export function PipelinePage(props: {
|
||||
}, [viewStage, videoAssetKey]);
|
||||
const bgmAssetId = (project.timeline?.bgm_tracks ?? [])[0]?.asset || "";
|
||||
useEffect(() => {
|
||||
if (viewStage !== 5 || !bgmAssetId) { setBgmPeaks(null); return; }
|
||||
if (viewStage !== 4 || !bgmAssetId) { setBgmPeaks(null); return; }
|
||||
let cancelled = false;
|
||||
void extractWavePeaks(bgmAssetId).then((peaks) => { if (!cancelled && peaks.length) setBgmPeaks(peaks); });
|
||||
return () => { cancelled = true; };
|
||||
@@ -2733,7 +2638,7 @@ export function PipelinePage(props: {
|
||||
|
||||
// 键盘:仅 stage5 编辑预览态生效(空格播放/暂停,←/→ 逐帧);看成片时交给原生 <video controls>
|
||||
useEffect(() => {
|
||||
if (viewStage !== 5 || showingFinal) return;
|
||||
if (viewStage !== 4 || showingFinal) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
@@ -2756,7 +2661,7 @@ export function PipelinePage(props: {
|
||||
|
||||
// 进入视频 / 拼接阶段时回填已有成片(此前合成过就直接给出播放/下载入口)
|
||||
useEffect(() => {
|
||||
if (viewStage !== 4 && viewStage !== 5) return;
|
||||
if (viewStage !== 3 && viewStage !== 4) return;
|
||||
onRefreshExport();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewStage]);
|
||||
@@ -2765,7 +2670,7 @@ export function PipelinePage(props: {
|
||||
// 兜底 —— 否则发起方的轮询循环随刷新丢掉,界面会卡在旧百分比、按钮一直禁用。
|
||||
const exportRunning = exportResult?.status === "queued" || exportResult?.status === "running";
|
||||
useEffect(() => {
|
||||
if (!exportRunning || (viewStage !== 4 && viewStage !== 5)) return;
|
||||
if (!exportRunning || (viewStage !== 3 && viewStage !== 4)) return;
|
||||
const timer = setInterval(() => onRefreshExport(), 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [exportRunning, viewStage, onRefreshExport]);
|
||||
@@ -2781,7 +2686,7 @@ export function PipelinePage(props: {
|
||||
// ── Stage 5 编辑器:水合 + 片段操作 + 撤销/重做 + 保存负载 ──
|
||||
const [edZoom, setEdZoom] = useState(100);
|
||||
useEffect(() => {
|
||||
if (viewStage !== 5) { edHydratedRef.current = false; return; }
|
||||
if (viewStage !== 4) { edHydratedRef.current = false; return; }
|
||||
if (edHydratedRef.current) return;
|
||||
const hasData = Boolean(project.timeline?.clips?.length) || segments.some((s) => s.adopted_asset);
|
||||
if (!hasData) return;
|
||||
@@ -2901,7 +2806,7 @@ export function PipelinePage(props: {
|
||||
}
|
||||
// (旧的转场黑闪提示层已删:双缓冲槽位的 opacity 过渡就是真转场预览)
|
||||
|
||||
// Stage 4 / 5 文件上传
|
||||
// Stage 3 / 4 文件上传
|
||||
// 导出全部:把本项目所有已采用视频片段打成一个 zip 下载
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportErr, setExportErr] = useState("");
|
||||
@@ -3033,8 +2938,8 @@ export function PipelinePage(props: {
|
||||
const railMeta = productRecord
|
||||
? `${isLocalProduct ? "本地生活" : (productRecord.category || "未分类")} · ${(productRecord.images?.length || (railCover ? 1 : 0))} 张参考图`
|
||||
: "尚未选择商品";
|
||||
const workflowCurrent = Math.min(5, viewStage + 1);
|
||||
const unlockedWorkflow = Math.max(2, Math.min(5, projectStage + 1), workflowCurrent);
|
||||
const workflowCurrent = Math.min(4, viewStage + 1);
|
||||
const unlockedWorkflow = Math.max(2, Math.min(4, projectStage + 1), workflowCurrent);
|
||||
const plHead = PIPELINE_HEAD[viewStage] || PIPELINE_HEAD[1];
|
||||
function goWorkflow(step: number) {
|
||||
if (step > unlockedWorkflow || step <= 1) return;
|
||||
@@ -3066,8 +2971,8 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
</header>
|
||||
<div className="pl-rail-summary">
|
||||
<span>已解锁 {unlockedWorkflow} / 5 · 当前第 {workflowCurrent} 步</span>
|
||||
<div className="pl-rail-track"><span style={{ width: `${(unlockedWorkflow / 5) * 100}%` }} /></div>
|
||||
<span>已解锁 {unlockedWorkflow} / 4 · 当前第 {workflowCurrent} 步</span>
|
||||
<div className="pl-rail-track"><span style={{ width: `${(unlockedWorkflow / 4) * 100}%` }} /></div>
|
||||
</div>
|
||||
<div className="pl-rail-steps">
|
||||
{PIPELINE_RAIL.map((step, index) => {
|
||||
@@ -3830,9 +3735,9 @@ export function PipelinePage(props: {
|
||||
})}
|
||||
</div>
|
||||
<footer className="as-action-bar">
|
||||
<span><Info />确认后将使用以上资产创建故事板,后续仍可替换。提取 {pts(10)} / 人物 {pts(20)} / 场景 {pts(20)} · 失败不扣</span>
|
||||
<span><Info />确认后将用以上资产直接生成视频,后续仍可替换。提取 {pts(10)} / 人物 {pts(20)} / 场景 {pts(20)} · 失败不扣</span>
|
||||
<div>
|
||||
{sbAnyImage || sbAnyGenerating ? (
|
||||
{videoAnyStarted ? (
|
||||
<span className="pill pill-l2 neutral" title="后续生成视频将使用此设置">
|
||||
<span className="dot"></span>当前成片 · {outputSpecSummary}
|
||||
</span>
|
||||
@@ -3841,16 +3746,15 @@ export function PipelinePage(props: {
|
||||
<button
|
||||
className="pl-next"
|
||||
type="button"
|
||||
disabled={sbGenerating && !sbAnyImage && !sbAnyGenerating}
|
||||
onClick={() => {
|
||||
if (sbAnyImage || sbAnyGenerating) {
|
||||
if (videoAnyStarted) {
|
||||
goStage(3);
|
||||
return;
|
||||
}
|
||||
guardGen(shots, () => setChargeConfirm("storyboard"));
|
||||
guardVideoGen(shots, null, () => setChargeConfirm("video"));
|
||||
}}
|
||||
>
|
||||
<span>{sbNextLabel}</span>
|
||||
<span>{videoNextLabel}</span>
|
||||
<ArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
@@ -3860,191 +3764,8 @@ export function PipelinePage(props: {
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
{/* ============= STAGE 3 · 故事板(采用版的 frames,真图 + 镜头提示词)============= */}
|
||||
{viewStage === 3 && (() => {
|
||||
// 每场时间区间(累加脚本镜时长 → 「0~5s」)
|
||||
let cum = 0;
|
||||
const sceneTimes = shots.map((s) => { const st = cum; cum += shotSeconds(s); return `${st}~${cum}s`; });
|
||||
const shotImg = (s?: StoryboardShot | null) => frameUrl(s ? { asset: s.adopted_asset ?? "", asset_url: s.adopted_asset_url } : null);
|
||||
const shotBusy = (s: StoryboardShot) => sbGenerating || sbBusyShots.has(s.id) || ["queued", "running"].includes(s.status);
|
||||
const cardCount = sbShots.length || sbExpectedShots;
|
||||
const activeBusy = sbActiveShot ? shotBusy(sbActiveShot) : sbGenerating;
|
||||
const activeVers = [...(sbActiveShot?.versions ?? [])];
|
||||
// 当前「查看」的版本(纯预览,默认=采用版);切换它只动本地预览,不动后端、不丢重跑
|
||||
const sbViewedVer = activeVers.find((v) => v.id === sbViewVerId)
|
||||
|| activeVers.find((v) => v.is_adopted || v.id === sbActiveShot?.adopted_version) || null;
|
||||
const sbViewedIsAdopted = !sbViewedVer || sbViewedVer.is_adopted || sbViewedVer.id === sbActiveShot?.adopted_version;
|
||||
const mainUrl = sbViewedVer ? (sbViewedVer.asset_url || assetUrl(sbViewedVer.asset)) : shotImg(sbActiveShot);
|
||||
const mainAid = sbViewedVer?.asset || sbActiveShot?.adopted_asset || "";
|
||||
return (
|
||||
<section className="stage active" data-stage-pane="3">
|
||||
<div className="stage-storyboard">
|
||||
<div className="sb-canvas">
|
||||
<div className="sb-scenes-col" id="sb-scenes-row">
|
||||
{sbShots.length ? sbShots.map((shot, idx) => {
|
||||
const url = shotImg(shot);
|
||||
const aid = shot.adopted_asset || "";
|
||||
return (
|
||||
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={shot.id} data-sid={shot.id} onClick={() => setSbSelected(idx)}>
|
||||
<div className={`placeholder${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}>
|
||||
<span className="ph-frame">场 {idx + 1}</span>
|
||||
{shotBusy(shot) && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
|
||||
{/* 失败帧:角标红字「失败」,hover 看友好提示;不再灰着没反应(点该场到右侧看完整原因+重跑) */}
|
||||
{!shotBusy(shot) && shot.status === "failed" && (
|
||||
<span title={shot.error_message || "生成失败,点开本场查看原因并重跑"} style={{ position: "absolute", left: 6, top: 6, display: "inline-flex", alignItems: "center", gap: 3, padding: "2px 6px", borderRadius: 6, background: "var(--err, #d33)", color: "#fff", fontSize: 11, fontWeight: 600, zIndex: 2 }}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>失败
|
||||
</span>
|
||||
)}
|
||||
{aid && <span className="sb-frame-rv" data-stop onClick={(e) => e.stopPropagation()}><ReviewBadge compact status={(reviews[aid] || shot.review_status || "") as ReviewStatus} error={shot.review_error} onSubmit={() => void submitAssetReview(aid)} busy={reviewBusyId === aid} /></span>}
|
||||
</div>
|
||||
<div className="nm">场 {idx + 1}</div>
|
||||
<div className="sub">{sceneTimes[idx] || `#${shot.sort_order + 1}`}</div>
|
||||
</div>
|
||||
);
|
||||
}) : sbExpectedShots ? (
|
||||
/* 还没出图:按采用版脚本镜头数铺等量空占位,标「待生成」,让用户看出本该有几张 */
|
||||
Array.from({ length: sbExpectedShots }, (_, idx) => (
|
||||
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={`ph-${idx}`} onClick={() => setSbSelected(idx)}>
|
||||
<div className="placeholder"><span className="ph-frame">场 {idx + 1}</span>{sbGenerating && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}</div>
|
||||
<div className="nm">场 {idx + 1}</div>
|
||||
<div className="sub">待生成</div>
|
||||
</div>
|
||||
))
|
||||
) : <div className="placeholder" style={{ aspectRatio: "1" }}><span className="ph-frame">暂无</span></div>}
|
||||
</div>
|
||||
{(() => {
|
||||
const url = mainUrl;
|
||||
const aid = mainAid;
|
||||
return (
|
||||
<div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? { ...mediaStyle(url), cursor: "zoom-in" } : undefined} role={url ? "button" : undefined} tabIndex={url ? 0 : undefined} title={url ? "点击放大" : undefined} onClick={url ? () => setPreview({ src: url, kind: "image", name: `场 ${sbSelected + 1}` }) : undefined} onKeyDown={url ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: url, kind: "image", name: `场 ${sbSelected + 1}` }); } } : undefined}>
|
||||
<span className="ph-frame">{url ? `场 ${sbSelected + 1}${sbViewedIsAdopted ? "" : " · 预览历史版"}` : sbExpectedShots ? `场 ${sbSelected + 1} · 待生成` : "故事板未生成"}</span>
|
||||
{activeBusy && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
|
||||
{aid && <span className="sb-main-rv" data-stop onClick={(e) => e.stopPropagation()}><ReviewBadge compact status={(reviews[aid] || sbViewedVer?.review_status || sbActiveShot?.review_status || "") as ReviewStatus} error={sbViewedVer?.review_error || sbActiveShot?.review_error} onSubmit={() => void submitAssetReview(aid)} busy={reviewBusyId === aid} /></span>}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="sb-side">
|
||||
<div className="pane" style={{ padding: "18px" }}>
|
||||
<div className="hstack" style={{ marginBottom: "10px" }}>
|
||||
<strong style={{ fontSize: "14px" }}>故事板 · <span id="sb-side-scene">{sbShots.length ? `场 ${sbSelected + 1}` : "—"}</span></strong>
|
||||
<span className="spacer"></span>
|
||||
{sbActiveShot
|
||||
? (activeBusy
|
||||
? <span className="pill neutral"><span className="dot"></span>生成中</span>
|
||||
: sbActiveShot.status === "failed"
|
||||
? <span className="pill bad"><span className="dot"></span>生成失败</span>
|
||||
: sbActiveShot.adopted_version
|
||||
? <span className="pill ok"><span className="dot"></span>已出片</span>
|
||||
: <span className="pill neutral"><span className="dot"></span>待生成</span>)
|
||||
: <span className="pill neutral"><span className="dot"></span>未生成</span>}
|
||||
</div>
|
||||
{/* 失败原因·友好提示(后端已把 yunqi 真因翻成中文,如内容审核拦截 → 提示改措辞) */}
|
||||
{sbActiveShot?.status === "failed" && !activeBusy && sbActiveShot.error_message && (
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "flex-start", color: "var(--err, #d33)", fontSize: "12.5px", lineHeight: 1.55, margin: "2px 0 10px", padding: "8px 10px", background: "rgba(211,51,51,.07)", borderRadius: 8 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, marginTop: 1 }}><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
|
||||
<span>{sbActiveShot.error_message}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}>每个场由 image-2 直接生成一张完整导演故事板图片;可单独「重跑本场」只重出这一张,每场各自留历史版本,互不影响。</div>
|
||||
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>整张风格提示词(重跑时生效,可编辑)</div>
|
||||
<PromptBox
|
||||
className="prompt-edit"
|
||||
id="sb-prompt-edit"
|
||||
stop={false}
|
||||
value={storyboardPrompt}
|
||||
onChange={(v) => setStoryboardPrompt(v.trim())}
|
||||
/>
|
||||
<div className="sb-stage-actions">
|
||||
{sbActiveShot && (
|
||||
<button className="btn btn-sm" type="button" disabled={activeBusy} title="只重出当前这一场(新增一条该场历史版本)" onClick={() => guardGen(shots.filter((s) => s.sort_order === sbActiveShot.sort_order), () => rerunStoryboardShotOptimistic(sbActiveShot.id))}>
|
||||
{activeBusy ? <><span className="spinner btn-spin" aria-hidden="true" />生成中…</> : `↻ 重跑本场`}
|
||||
</button>
|
||||
)}
|
||||
<span className="spacer"></span>
|
||||
<span className="muted-2 mono" style={{ fontSize: "12px", alignSelf: "center" }}>{pts(20)} 积分/镜</span>
|
||||
</div>
|
||||
<div className="sb-history">
|
||||
<div className="sb-history-h">本场历史版本(<span id="sb-history-ct">{activeVers.length}</span>)· 点击预览</div>
|
||||
<div className="sb-history-row" id="sb-history-row">
|
||||
{activeVers.length ? activeVers.map((ver) => {
|
||||
const cover = frameUrl({ asset: ver.asset ?? "", asset_url: ver.asset_url });
|
||||
const isAdopted = ver.is_adopted || ver.id === sbActiveShot?.adopted_version;
|
||||
const isViewed = sbViewedVer?.id === ver.id;
|
||||
// 点击只切「预览」(本地态),不动后端 → 重跑在制时切版本不丢任务。采用要点下方按钮。
|
||||
return (
|
||||
<div className={`sb-history-thumb${isViewed ? " current" : ""}`} key={ver.id} data-vi={ver.id} role="button" tabIndex={0} title={isAdopted ? "当前采用版(点击预览)" : "点击预览此版本"} onClick={() => setSbViewVerId(ver.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSbViewVerId(ver.id); } }}>
|
||||
<div className={`placeholder${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}><span className="ph-frame">{isAdopted ? "采用" : "历史"}</span></div>
|
||||
<div className="ts">{formatShanghaiClock(ver.created_at)}</div>
|
||||
</div>
|
||||
);
|
||||
}) : <span className="muted-2 mono" style={{ fontSize: "12px" }}>本场暂无历史</span>}
|
||||
</div>
|
||||
{/* 预览的是非采用版 → 显式「采用此版本」(对标视频详情弹窗的采用按钮);采用不影响在制重跑 */}
|
||||
{sbViewedVer && !sbViewedIsAdopted && sbActiveShot && (
|
||||
<button className="btn btn-sm btn-primary" type="button" style={{ marginTop: "8px" }} onClick={() => void onAdoptStoryboardShotVersion?.(sbActiveShot.id, sbViewedVer.id)}>采用此版本</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="divider" style={{ marginTop: "16px" }}></div>
|
||||
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>绑定的资产</div>
|
||||
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }} id="sb-bound-assets">
|
||||
{(() => {
|
||||
const bound = [
|
||||
...groupsByKind("product").filter((g) => g.adopted_asset).map((g) => ({ id: g.id, name: (g.metadata?.label || "").trim() || KIND_LABEL.product, kind: "product" as const, url: groupMainUrl(g) })),
|
||||
...buildEntities("person").filter((e) => e.group.adopted_asset).map((e) => ({ id: e.key, name: e.name, kind: "person" as const, url: groupMainUrl(e.group) })),
|
||||
...buildEntities("scene").filter((e) => e.group.adopted_asset).map((e) => ({ id: e.key, name: e.name, kind: "scene" as const, url: groupMainUrl(e.group) })),
|
||||
];
|
||||
return bound.length ? bound.map((b) => (
|
||||
<span className="asset-tag" key={b.id}>{b.url ? <span className="asset-thumb" style={{ backgroundImage: `url(${b.url})` }} /> : <span className="dotc"></span>}{b.name}({KIND_LABEL[b.kind]})</span>
|
||||
)) : <span className="muted-2 mono" style={{ fontSize: "12px" }}>暂无绑定资产</span>;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-foot">
|
||||
<div className="info"><span className="mono">[ image-2 逐场输出 · {cardCount ? `${cardCount} 场` : "0 场"} · 单场可重跑,失败不扣 ]</span></div>
|
||||
<div className="hstack">
|
||||
<span className="pill pill-l2 neutral" title="后续生成视频将使用此设置">
|
||||
<span className="dot"></span>当前成片 · {outputSpecSummary}
|
||||
</span>
|
||||
<button className="btn" type="button" onClick={() => goStage(2)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回资产</button>
|
||||
<div className="sb-confirm-wrap">
|
||||
{sbConfirmHint && (
|
||||
<div className="sb-confirm-pop" role="tooltip">
|
||||
<span className="pop-h">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
|
||||
故事板还没出齐
|
||||
</span>
|
||||
<span className="pop-body">请先等每场故事板都出片,再点 <b>生成视频</b>。</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary btn-lg"
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
if (!sbAllDone) { setSbConfirmHint(true); return; }
|
||||
if (videoAnyStarted) {
|
||||
goStage(4);
|
||||
return;
|
||||
}
|
||||
guardVideoGen(shots, null, () => setChargeConfirm("video"));
|
||||
}}
|
||||
>
|
||||
{videoNextLabel}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
{/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */}
|
||||
{viewStage === 4 && (() => {
|
||||
{viewStage === 3 && (() => {
|
||||
const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
|
||||
const segSeconds = segments.map((s) => s.target_duration_seconds).filter((n) => n > 0);
|
||||
const segMin = segSeconds.length ? Math.min(...segSeconds) : 0;
|
||||
@@ -4069,7 +3790,7 @@ export function PipelinePage(props: {
|
||||
const merging = exportResult?.status === "queued" || exportResult?.status === "running";
|
||||
const mergeProgress = exportResult?.progress ?? 0;
|
||||
return (
|
||||
<section className="stage active" data-stage-pane="4">
|
||||
<section className="stage active" data-stage-pane="3">
|
||||
<div className="queue-bar">
|
||||
<div>
|
||||
<div style={{ fontSize: "14px", fontWeight: 600 }}>视频生成 · {segDone} / {segments.length} 完成</div>
|
||||
@@ -4155,7 +3876,7 @@ export function PipelinePage(props: {
|
||||
)}
|
||||
</div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" onClick={() => goStage(3)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回故事板</button>
|
||||
<button className="btn" type="button" onClick={() => goStage(2)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回资产</button>
|
||||
{/* 合成成片:各场视频按顺序拼成一条完整视频(合成完才有下面的播放/下载) */}
|
||||
{mergedUrl && (
|
||||
<>
|
||||
@@ -4183,7 +3904,7 @@ export function PipelinePage(props: {
|
||||
);
|
||||
})()}
|
||||
{/* ============= STAGE 5 · 拼接导出(timeline.clips / subtitle_tracks / bgm_tracks 真实定位)============= */}
|
||||
{viewStage === 5 && (() => {
|
||||
{viewStage === 4 && (() => {
|
||||
const previewUrl = edCur?.url || assetUrl(tlClips[0]?.asset) || segUrl(segments.find((s) => s.adopted_asset));
|
||||
const aspect = timeline?.aspect_ratio || "9:16";
|
||||
const resolution = timeline?.resolution || "1080×1920";
|
||||
@@ -4211,7 +3932,7 @@ export function PipelinePage(props: {
|
||||
const serverBgmName = serverBgm?.asset_name || (serverBgm ? "背景音乐" : "");
|
||||
const subVisible = edState.subtitleEnabled;
|
||||
return (
|
||||
<section className="stage active" data-stage-pane="5">
|
||||
<section className="stage active" data-stage-pane="4">
|
||||
<div className="editor">
|
||||
<div className="editor-preview">
|
||||
<div className={`canvas${!showVideo && !showFinal && previewUrl ? " has-mock-media" : ""}`} id="ed-canvas" style={!showVideo && !showFinal && previewUrl ? mediaStyle(previewUrl) : undefined}>
|
||||
@@ -4534,7 +4255,7 @@ export function PipelinePage(props: {
|
||||
{!canExport && !finalUrl && !exporting && <span className="mono" style={{ marginLeft: 10, color: "var(--black-alpha-48)" }}>待全部视频片段生成完成后可导出</span>}
|
||||
</div>
|
||||
<div className="hstack">
|
||||
<button className="btn" type="button" onClick={() => goStage(4)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回片段</button>
|
||||
<button className="btn" type="button" onClick={() => goStage(3)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> 返回片段</button>
|
||||
<button className="btn" type="button" disabled={loading} onClick={() => onSaveTimeline(buildSavePayload())}>保存草稿</button>
|
||||
{finalUrl && (
|
||||
<a className="btn" href={finalUrl} target="_blank" rel="noreferrer" download>
|
||||
@@ -4631,11 +4352,10 @@ export function PipelinePage(props: {
|
||||
document.body
|
||||
);
|
||||
})()}
|
||||
{/* ── 流程步骤4/5 · 兜底弹窗拦截:参考图不齐时拦住生成,让用户去补 / 仍要生成(随他便) ── */}
|
||||
{/* ── 流程步骤3 · 兜底弹窗拦截:参考图不齐时拦住生成,让用户去补 / 仍要生成(随他便) ── */}
|
||||
{refGate && (() => {
|
||||
// 拆两类:noref=连参考图都没有;notri=有立绘但缺三视图。文案分别告诉用户「去哪里点什么」。
|
||||
// noref=连参考图都没有。文案告诉用户「去哪里点什么」。
|
||||
const noRef = refGate.missing.filter((m) => m.reason === "noref");
|
||||
const noTri = refGate.missing.filter((m) => m.reason === "notri");
|
||||
return createPortal(
|
||||
<div className="ref-gate-mask" onClick={() => setRefGate(null)}>
|
||||
<div className="ref-gate-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -4643,7 +4363,7 @@ export function PipelinePage(props: {
|
||||
{noRef.length > 0 && (
|
||||
<>
|
||||
<div className="rg-body">
|
||||
下面这些角色 / 场景<strong>还没生成参考图</strong>。请回「基础资产」页,点对应卡片进详情后<strong>「生成立绘 / 场景图」</strong>并采用,否则故事板会变成纯文生图、跟你的设定对不上(白花钱):
|
||||
下面这些角色 / 场景<strong>还没生成参考图</strong>。请回「基础资产」页,点对应卡片进详情后<strong>「生成立绘 / 场景图」</strong>并采用,否则视频会变成纯文生视频、跟你的设定对不上(白花钱):
|
||||
</div>
|
||||
<div className="rg-list">
|
||||
{noRef.map((m) => (
|
||||
@@ -4655,21 +4375,6 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{noTri.length > 0 && (
|
||||
<>
|
||||
<div className="rg-body" style={{ marginTop: noRef.length > 0 ? 14 : 0 }}>
|
||||
下面这些角色已有立绘,但<strong>还没生成三视图</strong>。故事板 @图 合成要靠正 / 侧 / 背多角度锁人物,缺三视图角色容易跑形。请回「基础资产」页,点角色卡进详情后点<strong>「AI 生成三视图」</strong>:
|
||||
</div>
|
||||
<div className="rg-list">
|
||||
{noTri.map((m) => (
|
||||
<span className="rg-chip" key={`notri:${m.type}:${m.name}`}>
|
||||
<span className="rg-kind rg-kind-person">角色</span>
|
||||
{m.name} <span className="rg-warn">缺三视图</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="rg-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => { setRefGate(null); goStage(2); }}>去基础资产页补齐</button>
|
||||
<button type="button" className="btn btn-ghost rg-force" onClick={() => { const go = refGate.proceed; setRefGate(null); go(); }}>
|
||||
@@ -4682,13 +4387,13 @@ export function PipelinePage(props: {
|
||||
document.body
|
||||
);
|
||||
})()}
|
||||
{/* ── 流程步骤5 · 过审闸弹窗:含真人脸的人物/分镜未过审 → 列出哪一镜/哪个,先过审再生成(火山合规要求) ── */}
|
||||
{/* ── 流程步骤3 · 过审闸弹窗:含真人脸的人物未过审 → 列出哪一镜/哪个,先过审再生成(火山合规要求) ── */}
|
||||
{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>
|
||||
<div className="rg-body">
|
||||
含真人脸的<strong>人物形象</strong>和<strong>故事板分镜</strong>必须先通过火山合规审核(变<span style={{ color: "var(--ok, #16a34a)" }}>绿盾·已过审</span>),
|
||||
含真人脸的<strong>人物形象</strong>必须先通过火山合规审核(变<span style={{ color: "var(--ok, #16a34a)" }}>绿盾·已过审</span>),
|
||||
否则视频生成会被火山以「疑似真人」拒绝。下面这些还没过审:
|
||||
</div>
|
||||
<div className="rg-list">
|
||||
@@ -5008,38 +4713,22 @@ export function PipelinePage(props: {
|
||||
</TeamModal>
|
||||
<ConfirmModal
|
||||
open={chargeConfirm !== null}
|
||||
title={chargeConfirm === "video" ? "确认生成视频" : "确认生成故事板"}
|
||||
title="确认生成视频"
|
||||
subtitle="// 失败不扣 · 成功后结算"
|
||||
icon={<Sparkles size={16} />}
|
||||
detail={chargeConfirm === "video"
|
||||
? (
|
||||
<>
|
||||
将按故事板生成 <b>{videoChargeShots || "多"} 段</b>视频({outputSpecSummary}),预估扣除 <b>{videoChargePoints > 0 ? `${videoChargePoints} 积分` : "按实际用量结算"}</b>
|
||||
{videoChargeShots > 0 && videoChargePoints > 0 ? `(约 ${Math.round(videoChargePoints / videoChargeShots)} 积分/段)` : ""}。
|
||||
确认后进入视频页并开始生成。
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
将按脚本生成 <b>{sbChargeShots || "多"} 场</b>分镜图({outputAspect}),预估扣除 <b>{sbChargePoints > 0 ? `${sbChargePoints} 积分` : "按实际用量结算"}</b>
|
||||
{sbChargeShots > 0 ? `(${pts(20)} 积分/镜 × ${sbChargeShots} 场)` : ""}。
|
||||
确认后进入故事板并开始生成。
|
||||
</>
|
||||
)}
|
||||
confirmText={chargeConfirm === "video"
|
||||
? (videoChargePoints > 0 ? `确认生成 · ${videoChargePoints} 积分` : "确认生成")
|
||||
: (sbChargePoints > 0 ? `确认生成 · ${sbChargePoints} 积分` : "确认生成")}
|
||||
detail={(
|
||||
<>
|
||||
将按脚本的秒级分镜生成 <b>{videoChargeShots || "多"} 段</b>视频({outputSpecSummary}),预估扣除 <b>{videoChargePoints > 0 ? `${videoChargePoints} 积分` : "按实际用量结算"}</b>
|
||||
{videoChargeShots > 0 && videoChargePoints > 0 ? `(约 ${Math.round(videoChargePoints / videoChargeShots)} 积分/段)` : ""}。
|
||||
确认后进入视频页并开始生成。
|
||||
</>
|
||||
)}
|
||||
confirmText={videoChargePoints > 0 ? `确认生成 · ${videoChargePoints} 积分` : "确认生成"}
|
||||
onCancel={() => setChargeConfirm(null)}
|
||||
onConfirm={() => {
|
||||
const kind = chargeConfirm;
|
||||
setChargeConfirm(null);
|
||||
if (kind === "storyboard") {
|
||||
goStage(3);
|
||||
startStoryboardGeneration();
|
||||
} else if (kind === "video") {
|
||||
goStage(4);
|
||||
submitAllVideosOptimistic();
|
||||
}
|
||||
goStage(3);
|
||||
submitAllVideosOptimistic();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -21,8 +21,7 @@ const WIZ_RAIL = [
|
||||
{ n: "01", title: "选择商品", desc: "确定本次内容生产的商品主体" },
|
||||
{ n: "02", title: "脚本创建", desc: "生成并确认镜头脚本" },
|
||||
{ n: "03", title: "资产选择", desc: "准备商品、角色与场景资产" },
|
||||
{ n: "04", title: "故事板", desc: "生成并确认视频分镜画面" },
|
||||
{ n: "05", title: "视频生成", desc: "按故事板生成视频片段" },
|
||||
{ n: "04", title: "视频生成", desc: "按脚本的秒级分镜直接生成视频" },
|
||||
];
|
||||
|
||||
export function ProjectWizardPage({ products, projects = [], preselectProductId, onBack, onCreate, onCreateProduct, onUploadImage }: {
|
||||
@@ -335,7 +334,7 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
||||
{template && (
|
||||
<div className="nw-tpl">
|
||||
<strong>同套路重出</strong>
|
||||
<p>沿用「{template.name}」的镜数、每镜作用和节奏。文案、画面、模特与故事板都会按 {product?.title || "所选商品"} 重新生成。</p>
|
||||
<p>沿用「{template.name}」的镜数、每镜作用和节奏。文案、画面与模特都会按 {product?.title || "所选商品"} 重新生成。</p>
|
||||
{template.outline_text ? <pre>{template.outline_text}</pre> : null}
|
||||
</div>
|
||||
)}
|
||||
@@ -388,10 +387,11 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
|
||||
}
|
||||
|
||||
// 对齐 api-bridge / pipeline:阶段编号 / 状态分桶 / 友好标签 / pill 类
|
||||
// V1 雪藏「拼接导出」第 5 阶段(代码保留,V2 恢复):流程现为 4 步,export 归并到第 4(视频),
|
||||
// 已完成项目落到第 4 阶段而非被藏的第 5。(与 pipeline.tsx STAGE_STEPS / projectStage 对齐)
|
||||
const PROJ_STAGE_TOTAL = 4;
|
||||
const PROJ_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 4 };
|
||||
// 故事板已下线;V1 又雪藏「拼接导出」→ 流程现为 3 步,export 归并到第 3(视频),
|
||||
// 已完成项目落到第 3 阶段。(与 pipeline.tsx PIPELINE_RAIL / projectStage 对齐)
|
||||
// storyboard 保留映射,只为老项目的历史 current_stage 兜底。
|
||||
const PROJ_STAGE_TOTAL = 3;
|
||||
const PROJ_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 2, video: 3, export: 3 };
|
||||
function projStageNo(project: Project) { return project.status === "completed" ? PROJ_STAGE_TOTAL : (PROJ_STAGE_NO[project.current_stage] || 1); }
|
||||
function projBucket(project: Project) {
|
||||
if (project.status === "completed") return "done";
|
||||
@@ -436,8 +436,8 @@ 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: "quickCreate", image: "/assets/yz/video-quick.jpg", primary: true },
|
||||
{ title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产、故事板和最终视频。", page: "projectWizard", image: "/assets/yz/video-pro.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 }> = [
|
||||
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/yz/video-free.jpg" },
|
||||
@@ -470,7 +470,7 @@ function projStageLabel(project: Project): string {
|
||||
const map: Record<string, string> = {
|
||||
script: "脚本",
|
||||
base_assets: "资产创建",
|
||||
storyboard: "故事板",
|
||||
storyboard: "资产创建",
|
||||
video: "视频生成",
|
||||
export: "视频生成",
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Clapperboard,
|
||||
ChevronRight,
|
||||
ImagePlus,
|
||||
LayoutPanelTop,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
SlidersHorizontal,
|
||||
@@ -42,7 +41,6 @@ import {
|
||||
const PROGRESS_STEPS = [
|
||||
{ label: "脚本", icon: ScrollText },
|
||||
{ label: "资产", icon: Boxes },
|
||||
{ label: "故事板", icon: LayoutPanelTop },
|
||||
{ label: "视频", icon: Clapperboard },
|
||||
];
|
||||
const QUICK_RATIOS = [
|
||||
@@ -557,7 +555,7 @@ export function QuickCreatePage({
|
||||
const imageCount = savedImages.length + images.length;
|
||||
const canStart = Boolean(name.trim() && category && imageCount && selectedVideoModel) && !reviewBlocked;
|
||||
const canRetry = Boolean(name.trim() && imageCount && selectedVideoModel) && !reviewBlocked;
|
||||
const activePhase = submitting ? 0 : Math.max(0, Math.min(4, job?.phase_index ?? 0));
|
||||
const activePhase = submitting ? 0 : Math.max(0, Math.min(PROGRESS_STEPS.length, job?.phase_index ?? 0));
|
||||
const result = job?.result;
|
||||
const videoClips = result?.video_segments?.length
|
||||
? result.video_segments
|
||||
@@ -572,8 +570,9 @@ export function QuickCreatePage({
|
||||
{ ratio: aspectRatio, resolution, duration: totalDuration, refs: [] },
|
||||
billingRates,
|
||||
);
|
||||
// 商品理解、基础资产和每镜故事板在脚本生成前无法精确报价;按每场约60积分给出透明预估,最终按成功任务结算。
|
||||
const estimatedPoints = videoEstimate.points > 0 ? videoEstimate.points + sceneCount * 60 : sceneCount * 240;
|
||||
// 商品理解和基础资产在脚本生成前无法精确报价;按每场约40积分给出透明预估,最终按成功任务结算。
|
||||
// 故事板下线后每场少一次 image-2 出图,预估相应下调。
|
||||
const estimatedPoints = videoEstimate.points > 0 ? videoEstimate.points + sceneCount * 40 : sceneCount * 200;
|
||||
const shellClass = [
|
||||
"quick-create-shell",
|
||||
restoring ? "is-restoring" : "",
|
||||
@@ -741,7 +740,7 @@ export function QuickCreatePage({
|
||||
{PROGRESS_STEPS.map((step, index) => {
|
||||
const Icon = step.icon;
|
||||
const done = index < activePhase;
|
||||
const active = isGenerating && index === activePhase && activePhase < 4;
|
||||
const active = isGenerating && index === activePhase && activePhase < PROGRESS_STEPS.length;
|
||||
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>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
export const stageOrder = ["script", "base_assets", "storyboard", "video", "export"] as const;
|
||||
// 故事板已从流程中去掉:脚本 → 基础资产 → 视频 → 拼接导出。
|
||||
export const stageOrder = ["script", "base_assets", "video", "export"] as const;
|
||||
|
||||
export const stageMeta: Record<string, { no: string; label: string }> = {
|
||||
script: { no: "1", label: "脚本" },
|
||||
base_assets: { no: "2", label: "基础资产" },
|
||||
storyboard: { no: "3", label: "故事板" },
|
||||
video: { no: "4", label: "视频片段" },
|
||||
export: { no: "5", label: "拼接导出" }
|
||||
video: { no: "3", label: "视频片段" },
|
||||
export: { no: "4", label: "拼接导出" },
|
||||
// 已下线,仅为老项目的历史 current_stage 兜底显示
|
||||
storyboard: { no: "2", label: "基础资产" }
|
||||
};
|
||||
|
||||
// 积分裸数字格式化:DecimalField 序列化的 "75.0000" → "75"、"23.1000" → "23.1"(历史 ¥×10 迁移数据留有效小数)。
|
||||
|
||||
@@ -1445,7 +1445,6 @@ table.t tbody tr:hover { background: var(--bg-soft); }
|
||||
.dash-grid,
|
||||
.stage-script,
|
||||
.stage-assets,
|
||||
.stage-storyboard,
|
||||
.acc-grid,
|
||||
.team-grid,
|
||||
.settings-grid,
|
||||
|
||||
@@ -368,7 +368,7 @@ export type ScriptVersion = {
|
||||
entity_refs?: string[];
|
||||
dialogue?: Array<{ speaker: string | null; line: string }>;
|
||||
}>;
|
||||
// metadata 携带 hook/tone/entities(脚本 agent 产出),供结构化渲染与下游故事板 @图N
|
||||
// metadata 携带 hook/tone/entities(脚本 agent 产出),供结构化渲染与下游视频参考图 @图N
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
@@ -395,42 +395,6 @@ export type VideoSegment = {
|
||||
versions?: VideoSegmentVersion[];
|
||||
};
|
||||
|
||||
export type StoryboardVersion = {
|
||||
id: string;
|
||||
prompt: string;
|
||||
is_adopted: boolean;
|
||||
frames: Array<{ id: string; sort_order: number; prompt: string; asset: string; asset_url?: string; review_status?: string; review_error?: string }>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
// 故事板分镜制(对标 VideoSegment/Version):每镜一个 shot,各自历史版本 + 采用版
|
||||
export type StoryboardShotVersion = {
|
||||
id: string;
|
||||
asset: string | null;
|
||||
asset_url: string;
|
||||
prompt: string;
|
||||
is_adopted: boolean;
|
||||
review_status?: string;
|
||||
review_error?: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type StoryboardShot = {
|
||||
id: string;
|
||||
script_segment: string | null;
|
||||
sort_order: number;
|
||||
status: string;
|
||||
error_message: string;
|
||||
prompt: string;
|
||||
adopted_version: string | null;
|
||||
adopted_asset?: string | null;
|
||||
adopted_asset_url?: string;
|
||||
review_status?: string;
|
||||
review_error?: string;
|
||||
versions?: StoryboardShotVersion[];
|
||||
};
|
||||
|
||||
export type ExportPoll = {
|
||||
status: string;
|
||||
progress: number;
|
||||
@@ -536,8 +500,6 @@ export type Project = {
|
||||
metadata?: { label?: string } & Record<string, unknown>;
|
||||
created_at?: string;
|
||||
}>;
|
||||
storyboard_versions: StoryboardVersion[];
|
||||
storyboard_shots: StoryboardShot[];
|
||||
timeline: Timeline | null;
|
||||
metadata?: {
|
||||
wizard?: {
|
||||
@@ -565,7 +527,7 @@ export type Project = {
|
||||
// 流程步骤3/4 · 脚本提取出的每个人物/场景的建议生图提示词(基础资产据此 seed 卡片)
|
||||
cast_prompts?: Record<string, string>;
|
||||
scene_prompts?: Record<string, string>;
|
||||
// 提取步产出:角色/场景实体全量(含 id/ref_index);下游故事板/视频按每镜 entity_refs 取参考图
|
||||
// 提取步产出:角色/场景实体全量(含 id/ref_index);下游视频按每镜 entity_refs 取参考图
|
||||
script_entities?: Array<{ id: string; type: string; name: string; visual_prompt?: string; ref_index?: number }>;
|
||||
// 是否走过正式提取步(资产页提取闸门据此显隐;脚本生成期吐的不稳 entities 不算)
|
||||
entities_extracted?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user