feat: 补齐前端主流程并收紧工作台布局

统一工作台紧凑头部与形态图库间距,修复主体身份布局,并接入正式严格视频质量流水线。
This commit is contained in:
GouJ
2026-09-08 09:25:14 +08:00
committed by GitHub
parent 0f8b2074e4
commit 4edb0ff6cc
25 changed files with 355 additions and 233 deletions
+34 -8
View File
@@ -50,7 +50,10 @@ const {
keyframeLimit,
keyframeWidth,
keyframeHeight,
videoLimit,
videoRepairAttempts,
keyframeValid,
videoQualityValid,
batchValid,
blocked,
run
@@ -100,15 +103,15 @@ const stages = computed(() => {
key: 'videos' as const,
code: '03',
title: '视频成片',
description: '以主首帧作为第一帧,连同提示词和主体参考图提交视频模型。',
description: '生成质量候选,完成后自动校验;失败时有限修复,通过后才晋升主视频。',
icon: Film,
data: data?.videos,
done: data?.videoStatus.completed ?? 0,
done: data?.videos.skipped ?? 0,
action: force.value
? '为全部就绪镜头新增视频'
? '新增项目质量视频候选'
: staleVideos.value > 0
? `更新 ${staleVideos.value} 个过期主视频`
: '提交就绪视频任务'
: '提交视频质量任务'
}
]
})
@@ -237,8 +240,7 @@ watch(
</div>
<DetailDisclosure title="处理范围与覆盖规则" class="mt-3"
><p class="text-xs leading-6 text-muted">
提示词与视频批量操作面向全项目首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式处理全项目过期首帧成功后会自动接替旧主图其余新增候选图片模型
与视频模型均可能产生费用
提示词与视频就绪检查面向全项目视频实际提交受本批镜头上限控制并使用严格质量流水线首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式处理全项目过期首帧成功后会自动接替旧主图其余新增候选图片模型视频模型与视觉校验均可能产生费用
</p></DetailDisclosure
>
</section>
@@ -375,14 +377,15 @@ watch(
blocked ||
!batchValid ||
(stage.key === 'keyframes' && !keyframeValid) ||
(stage.key === 'videos' && !videoQualityValid) ||
!(stage.data?.ready ?? 0)
"
acknowledgement
:description="
stage.key === 'videos'
? staleVideos && !force
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的视频模型任务;新视频完成后会自动接替过期主视频。'
: '只为当前就绪镜头创建视频模型异步任务创建成功不代表视频已经完成,任务会在后台继续运行。'
? `当前存在过期主视频,非覆盖模式只会为其中至多 ${videoLimit || '—'} 个镜头启动严格质量流水线;候选自动校验并有限修复,通过后才接替旧主视频。`
: `只为至多 ${videoLimit || '—'} 个就绪镜头启动严格质量流水线。任务创建后由后端自动校验,最多修复 ${videoRepairAttempts || '—'} 次,通过后才晋升主视频。`
: stage.key === 'keyframes'
? `处理缺失或过期主首帧。过期首帧成功后自动接替旧主图(覆盖模式也如此),其余已有主图只新增候选。本批上限:${keyframeLimit || '不限制'};尺寸:${keyframeWidth && keyframeHeight ? `${keyframeWidth} × ${keyframeHeight}` : '后端默认'}。`
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
@@ -424,6 +427,29 @@ watch(
上限须为正整数宽高须同时留空或填写正整数
</p>
</div>
<div v-if="stage.key === 'videos'" class="mt-4 grid gap-3">
<label
><span class="field-label">本批镜头上限</span
><NInputNumber
v-model:value="videoLimit"
:min="1"
:precision="0"
:disabled="operation.pending"
:input-props="{ 'aria-label': '视频质量任务镜头上限' }"
/></label>
<label
><span class="field-label">每镜最多自动修复</span
><NInputNumber
v-model:value="videoRepairAttempts"
:min="1"
:precision="0"
:disabled="operation.pending"
:input-props="{ 'aria-label': '视频自动修复次数' }"
/></label>
<p v-if="!videoQualityValid" class="text-xs text-danger">
本批上限和自动修复次数都必须是正整数
</p>
</div>
</article>
</div>
<div v-if="query.data.value" class="panel mt-4 p-5">
+11 -7
View File
@@ -3,7 +3,7 @@ import type { PromptBatchResult, StoryboardBatchInput } from '../storyboard/type
import type {
GenerateKeyframeInput,
GenerateKeyframesInput,
GenerateVideosInput,
GenerateQualityVideosInput,
KeyframeBatchResult,
KeyframeReadiness,
KeyframeSpec,
@@ -13,7 +13,9 @@ import type {
RetryVideosResult,
ShotKeyframe,
ShotVideo,
VideoBatchResult,
ShotVideoQualityResult,
VideoQualityBatchResult,
VideoQualityConfig,
VideoGenerationSpec,
VideoReadiness
} from './types'
@@ -54,8 +56,9 @@ export const productionApi = {
}),
videoReadiness: (projectId: string, force: boolean, signal?: AbortSignal) =>
request<VideoReadiness>(`${projectPath(projectId)}/videos/readiness?force=${force}`, { signal }),
generateVideos: (projectId: string, input: GenerateVideosInput) =>
request<VideoBatchResult>(`${projectPath(projectId)}/videos/generate`, {
/** 启动项目级严格视频质量流水线;创建后由后端自动完成校验、有限修复与晋升。 */
generateQualityVideos: (projectId: string, input: GenerateQualityVideosInput) =>
request<VideoQualityBatchResult>(`${projectPath(projectId)}/videos/generate-quality`, {
method: 'POST',
body: input,
timeoutMs: 0
@@ -86,10 +89,11 @@ export const productionApi = {
request<VideoGenerationSpec>(`${shotPath(shotId)}/video-generation-spec`, { signal }),
listVideos: (shotId: string, signal?: AbortSignal) =>
request<ShotVideo[]>(`${shotPath(shotId)}/videos`, { signal }),
generateVideo: (shotId: string) =>
request<ShotVideo | null>(`${shotPath(shotId)}/videos`, {
/** 启动单镜头严格视频质量流水线;返回初始候选,最终主视频以后端状态为准。 */
generateQualityVideo: (shotId: string, input: VideoQualityConfig) =>
request<ShotVideoQualityResult>(`${shotPath(shotId)}/videos/generate-quality`, {
method: 'POST',
body: {},
body: input,
timeoutMs: 0
}),
refreshVideo: (videoId: string, signal?: AbortSignal) =>
@@ -33,8 +33,11 @@ function exportReceipt() {
>
</p>
<p v-else-if="receipt.kind === 'videos'" class="mt-3 text-xs text-muted">
全项目 {{ receipt.result.total }} · 本次目标 {{ receipt.result.targetCount }} · 已创建任务
{{ receipt.result.created }} · 跳过 {{ receipt.result.skipped }} · 创建失败 {{ receipt.result.failed }}
全项目 {{ receipt.result.total }} · 就绪 {{ receipt.result.readyTargetCount }} · 本次目标
{{ receipt.result.targetCount }} · 已创建质量候选 {{ receipt.result.created }} · 待后续提交
{{ receipt.result.remainingReady }} · 跳过 {{ receipt.result.skipped }} · 进行中
{{ receipt.result.inProgress }} · 阻塞 {{ receipt.result.blocked }} · 创建失败
{{ receipt.result.failed }}
</p>
<p v-else class="mt-3 text-xs text-muted">
检测到 {{ receipt.result.totalFailed }} 个失败镜头 · 已重新提交 {{ receipt.result.retried }} · 提交失败
@@ -47,7 +50,7 @@ function exportReceipt() {
:show-icon="false"
class="mt-3"
>
视频任务已经创建但尚未代表成片完成后台会继续查询 视频模型以下方任务状态和视频资产为准
严格质量候选已经创建但尚未代表成片完成后台会继续查询视频模型执行视觉校验并在失败时有限修复只有校验通过的候选才会晋升主视频
</NAlert>
<NAlert v-if="receipt.result.failed" role="alert" type="error" :show-icon="false" class="mt-3">
本次存在部分失败成功提交或生成的结果不会回滚请查看逐镜错误后再重试
@@ -27,7 +27,7 @@ import ActionMenu from '../../../components/ui/ActionMenu.vue'
import { AppDialog } from '../../../components/ui'
import ModelCapabilities from './ModelCapabilities.vue'
import type { QualityTarget } from '../quality.types'
import { savedVideoValidation, videoRepairInfo } from '../quality'
import { savedVideoValidation, videoQualityInfo, videoRepairInfo } from '../quality'
/** 单镜头生产面板只负责正式资产,不修改上游导演设计与即时状态。 */
const props = defineProps<{
@@ -162,14 +162,17 @@ async function setPrimaryKeyframe() {
await refreshAll()
}
/** 创建单镜头视频模型视频任务;任务完成由后台轮询确认。 */
/** 创建单镜头严格质量候选;后端完成生成后自动校验、有限修复并按结果晋升。 */
async function generateVideo() {
if (!canGenerateVideo.value) return
const shotId = props.shot.shotId
await runOperation(props.projectId, `提交镜头 ${props.shot.shotNo} 视频任务`, async () => {
const result = await productionApi.generateVideo(shotId)
if (!result || result.shotId !== shotId || !isActiveVideo(result))
throw new Error('接口未返回有效的视频生成任务。')
await runOperation(props.projectId, `提交镜头 ${props.shot.shotNo} 视频质量任务`, async () => {
const result = await productionApi.generateQualityVideo(shotId, {
maxRepairAttempts: 2,
allowedTexts: []
})
if (!result || result.shotId !== shotId || !result.candidate || !isActiveVideo(result.candidate))
throw new Error('接口未返回有效的视频质量候选任务。')
})
await refreshAll()
}
@@ -190,7 +193,12 @@ async function setPrimaryVideo() {
const videoId = confirmingVideo.value
if (assetBlocked.value || !videoId) return
const video = videos.value.find(item => item.id === videoId)
if (!video || (videoRepairInfo(video.rawJson) && savedVideoValidation(video.rawJson)?.passed !== true)) return
if (
!video ||
((videoRepairInfo(video.rawJson) || videoQualityInfo(video.rawJson)) &&
savedVideoValidation(video.rawJson)?.passed !== true)
)
return
const shotId = props.shot.shotId
confirmingVideo.value = ''
await runOperation(props.projectId, `设置镜头 ${props.shot.shotNo} 主视频`, async () => {
@@ -396,6 +404,19 @@ async function inspectSpecs() {
<NTag v-if="videoRepairInfo(item.rawJson) && !item.isPrimary" size="small" :bordered="false"
>修复候选 · 待复检</NTag
>
<NTag
v-else-if="videoQualityInfo(item.rawJson) && !item.isPrimary"
size="small"
:bordered="false"
>质量候选 ·
{{
savedVideoValidation(item.rawJson)
? savedVideoValidation(item.rawJson)?.passed
? '已通过'
: '未通过'
: '自动校验中'
}}</NTag
>
<NTag v-if="item.isPrimary && videoStale" size="small" :bordered="false">已过期</NTag>
<span class="text-[10px] text-muted"
>{{ item.durationSeconds || shot.durationSeconds || '—' }}s</span
@@ -430,7 +451,7 @@ async function inspectSpecs() {
item.status === 'completed' &&
item.videoUrl &&
!item.isPrimary &&
(!videoRepairInfo(item.rawJson) ||
(!(videoRepairInfo(item.rawJson) || videoQualityInfo(item.rawJson)) ||
savedVideoValidation(item.rawJson)?.passed === true)
"
:disabled="assetBlocked"
@@ -454,15 +475,17 @@ async function inspectSpecs() {
<p v-else class="mt-4 text-xs text-muted">尚无视频任务记录。</p>
<div class="mt-4 flex flex-wrap gap-3">
<ConfirmAction
:label="videoStale ? '重新生成主视频' : currentVideo ? '再生成一个视频候选' : '提交本镜视频任务'"
:label="
videoStale ? '重新生成质量视频' : currentVideo ? '再生成一个质量候选' : '提交本镜视频质量任务'
"
:disabled="!canGenerateVideo"
acknowledgement
:description="
videoStale
? '使用当前视频提示词、主首帧和主体参考图重新创建视频模型任务;完成后会自动接替当前过期主视频。'
? '使用当前视频提示词、主首帧和主体参考图启动严格质量流水线;最多自动修复 2 次,通过后才接替当前过期主视频。'
: currentVideo
? '使用当前视频提示词、主首帧和主体参考图新增一个视频模型视频候选,不自动替换主视频。'
: '使用当前视频提示词、主首帧和主体参考图创建视频模型任务;任务在后台继续运行。'
? '新增一个严格质量候选;后端会自动校验并最多修复 2 次,通过后才替换主视频。'
: '启动严格视频质量流水线;任务在后台生成、校验并有限修复,通过后才晋升主视频。'
"
primary
@confirm="generateVideo"
+26
View File
@@ -104,3 +104,29 @@ export function videoRepairInfo(rawJson?: string | null): { attempt: number } |
return null
}
}
/** 识别严格质量候选及其自动修复配置,防止未通过候选被手动绕过校验设为主视频。 */
export function videoQualityInfo(
rawJson?: string | null
): { allowedTexts: string[]; maxRepairAttempts: number } | null {
try {
const snapshot: unknown = JSON.parse(rawJson || '{}')
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) return null
const record = snapshot as Record<string, unknown>
if (record.qualityCandidate !== true) return null
const pipeline = record.qualityPipeline
if (!pipeline || typeof pipeline !== 'object' || Array.isArray(pipeline)) return null
const config = pipeline as Record<string, unknown>
const maxRepairAttempts = config.maxRepairAttempts
if (typeof maxRepairAttempts !== 'number' || !Number.isSafeInteger(maxRepairAttempts) || maxRepairAttempts <= 0)
return null
return {
allowedTexts: Array.isArray(config.allowedTexts)
? config.allowedTexts.filter((item): item is string => typeof item === 'string')
: [],
maxRepairAttempts
}
} catch {
return null
}
}
+32 -8
View File
@@ -208,21 +208,45 @@ export interface KeyframeBatchResult {
failures: { shotId: string; error: string }[]
}
/** 项目视频批量生成参数。 */
export interface GenerateVideosInput {
concurrency: number
force: boolean
/** 严格视频质量流水线的共用参数;候选必须校验通过后才会晋升主视频。 */
export interface VideoQualityConfig {
allowedTexts?: string[]
maxRepairAttempts: number
}
/** 视频批量任务创建回执;created 只表示已提交任务,不表示视频已完成。 */
export interface VideoBatchResult {
/** 项目级严格视频质量生成参数;limit 限制本次实际提交的镜头数。 */
export interface GenerateQualityVideosInput extends VideoQualityConfig {
concurrency: number
force: boolean
limit?: number
}
/** 单镜头严格视频质量任务回执;candidate 只是异步候选,不代表已经通过校验。 */
export interface ShotVideoQualityResult {
shotId: string
qualityPipeline: Required<VideoQualityConfig>
candidate: ShotVideo | null
}
/** 项目级严格视频质量任务回执;后续校验与修复由后端 Poller 自动推进。 */
export interface VideoQualityBatchResult {
projectId: string
concurrency: number
limit: number | null
force: boolean
qualityPipeline: Required<VideoQualityConfig>
total: number
readyTargetCount: number
targetCount: number
remainingReady: number
created: number
skipped: number
readiness: Omit<VideoReadiness, 'total' | 'items'>
inProgress: number
blocked: number
failed: number
readiness: Omit<VideoReadiness, 'total' | 'items'>
failures: { shotId: string; error: string }[]
candidateIds: string[]
}
/** 项目最近一次视频任务的逐镜状态。 */
@@ -260,7 +284,7 @@ export interface RetryVideosResult {
export type ProductionReceipt =
| { kind: 'prompts'; title: string; result: import('../storyboard/types').PromptBatchResult }
| { kind: 'keyframes'; title: string; result: KeyframeBatchResult }
| { kind: 'videos'; title: string; result: VideoBatchResult }
| { kind: 'videos'; title: string; result: VideoQualityBatchResult }
| { kind: 'retry'; title: string; result: RetryVideosResult }
/** 项目生产页的浏览器会话数据。 */
+27 -4
View File
@@ -22,6 +22,8 @@ export function useProduction() {
const keyframeLimit = ref<number | ''>('')
const keyframeWidth = ref<number | ''>('')
const keyframeHeight = ref<number | ''>('')
const videoLimit = ref<number | null>(1)
const videoRepairAttempts = ref<number | null>(2)
const snapshot = computed(() => breakdownSnapshot(context.checkpoints.value))
const sourceEpisodes = computed(
() => snapshot.value?.storyboardEpisodeShots ?? snapshot.value?.breakdownResult?.storyboardEpisodeShots ?? []
@@ -80,6 +82,14 @@ export function useProduction() {
(keyframeLimit.value === '' || (Number.isSafeInteger(keyframeLimit.value) && keyframeLimit.value > 0)) &&
validOptionalSize(keyframeWidth.value, keyframeHeight.value)
)
/** 视频质量流水线要求有限的提交范围和至少一次自动修复额度。 */
const videoQualityValid = computed(
() =>
Number.isSafeInteger(videoLimit.value) &&
(videoLimit.value ?? 0) > 0 &&
Number.isSafeInteger(videoRepairAttempts.value) &&
(videoRepairAttempts.value ?? 0) > 0
)
const blocked = computed(
() =>
operation.value.pending ||
@@ -91,7 +101,13 @@ export function useProduction() {
/** 批量操作只向就绪镜头提交;最终资产状态由轮询查询确认。 */
async function run(command: ProductionCommand) {
if (blocked.value || !batchValid.value || (command === 'keyframes' && !keyframeValid.value)) return
if (
blocked.value ||
!batchValid.value ||
(command === 'keyframes' && !keyframeValid.value) ||
(command === 'videos' && !videoQualityValid.value)
)
return
const projectId = id.value
const input = { concurrency: concurrency.value, force: force.value }
const target = getProductionSession(projectId)
@@ -106,10 +122,10 @@ export function useProduction() {
? `更新 ${staleKeyframes} 个过期主首帧`
: `补齐第 ${episodeNo.value} 集主首帧`,
videos: force.value
? '新增项目视频候选'
? '新增项目质量视频候选'
: staleVideos > 0
? `更新 ${staleVideos} 个过期主视频`
: '提交就绪视频任务',
: '提交视频质量任务',
'retry-videos': '重试失败视频任务'
}
await runOperation(projectId, labels[command], async () => {
@@ -137,7 +153,11 @@ export function useProduction() {
target.receipt = {
kind: 'videos',
title: labels[command],
result: await productionApi.generateVideos(projectId, input)
result: await productionApi.generateQualityVideos(projectId, {
...input,
limit: videoLimit.value ?? undefined,
maxRepairAttempts: videoRepairAttempts.value ?? 0
})
}
} else {
target.receipt = {
@@ -170,7 +190,10 @@ export function useProduction() {
keyframeLimit,
keyframeWidth,
keyframeHeight,
videoLimit,
videoRepairAttempts,
keyframeValid,
videoQualityValid,
batchValid,
blocked,
run