feat: 补齐前端主流程并收紧工作台布局
统一工作台紧凑头部与形态图库间距,修复主体身份布局,并接入正式严格视频质量流水线。
This commit is contained in:
@@ -69,7 +69,7 @@ function exportScript() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkspacePage split class="script-workspace-page"
|
||||
<WorkspacePage split compact class="script-workspace-page"
|
||||
><template #header
|
||||
><div class="stage-strip">
|
||||
<!-- 以下是创作阶段进度 -->
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
/** 项目生产页的浏览器会话数据。 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -65,7 +65,7 @@ function clearFilters() {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<WorkspacePage split>
|
||||
<WorkspacePage split compact>
|
||||
<template #header>
|
||||
<div class="project-index-chrome">
|
||||
<!-- 以下是标题与新建 -->
|
||||
|
||||
@@ -224,17 +224,11 @@ watch(
|
||||
>导演摄影设计
|
||||
</h3>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
景别、机位、运镜与构图。单集操作会重新生成并覆盖;批量默认跳过完整剧集。
|
||||
景别、机位、运镜与构图。正式项目生成默认跳过已完整剧集,覆盖模式会重新生成全部剧集。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ConfirmAction
|
||||
label="生成本集导演设计"
|
||||
:disabled="!allowed['direction-one']"
|
||||
acknowledgement
|
||||
description="调用模型生成当前整集的导演设计,校验通过后保存。如果此集已有设计,将覆盖原结果。"
|
||||
@confirm="run('direction-one')"
|
||||
/><ConfirmAction
|
||||
:label="force ? '重生成全部导演设计' : '补齐项目导演设计'"
|
||||
:disabled="!allowed['direction-all']"
|
||||
acknowledgement
|
||||
@@ -259,12 +253,6 @@ watch(
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ConfirmAction
|
||||
label="生成本集即时状态"
|
||||
:disabled="!allowed['visual-one']"
|
||||
acknowledgement
|
||||
description="依据本集导演设计和主体形态生成即时视觉状态,按配置尝试自动修复。校验通过才保存,已有状态将被覆盖。"
|
||||
@confirm="run('visual-one')"
|
||||
/><ConfirmAction
|
||||
:label="force ? '重生成全部即时状态' : '补齐项目即时状态'"
|
||||
:disabled="!allowed['visual-all']"
|
||||
acknowledgement
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { request } from '../../lib/http'
|
||||
import type {
|
||||
DirectionGeneration,
|
||||
EpisodeDirections,
|
||||
EpisodeVisualStates,
|
||||
PromptBatchResult,
|
||||
@@ -8,8 +7,7 @@ import type {
|
||||
ShotReferences,
|
||||
StoryboardBatchInput,
|
||||
StoryboardBatchResult,
|
||||
VisualStateBatchInput,
|
||||
VisualStateGeneration
|
||||
VisualStateBatchInput
|
||||
} from './types'
|
||||
import type { ShotGenerationSpec } from './generation-spec.types'
|
||||
|
||||
@@ -23,30 +21,18 @@ function shotPath(id: string) {
|
||||
return `/storyboard-shots/${encodeURIComponent(id)}`
|
||||
}
|
||||
|
||||
/** Storyboard 路由契约;单集入口虽名为 generate-test,但 persist=true 才适用于工作台。 */
|
||||
/** Storyboard 正式路由契约;测试路由不暴露为前端生产入口。 */
|
||||
export const storyboardApi = {
|
||||
directions: (id: string, episodeNo: number, signal?: AbortSignal) =>
|
||||
request<EpisodeDirections>(`${projectPath(id)}/storyboard-directions?episodeNo=${episodeNo}`, { signal }),
|
||||
visualStates: (id: string, episodeNo: number, signal?: AbortSignal) =>
|
||||
request<EpisodeVisualStates>(`${projectPath(id)}/storyboard-visual-states?episodeNo=${episodeNo}`, { signal }),
|
||||
generateDirection: (id: string, episodeNo: number) =>
|
||||
request<DirectionGeneration>(`${projectPath(id)}/storyboard-directions/generate-test`, {
|
||||
method: 'POST',
|
||||
body: { episodeNo, persist: true },
|
||||
timeoutMs: 0
|
||||
}),
|
||||
generateDirections: (id: string, input: StoryboardBatchInput) =>
|
||||
request<StoryboardBatchResult>(`${projectPath(id)}/storyboard-directions/generate`, {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
timeoutMs: 0
|
||||
}),
|
||||
generateVisualState: (id: string, episodeNo: number, maxRepairAttempts: number) =>
|
||||
request<VisualStateGeneration>(`${projectPath(id)}/storyboard-visual-states/generate-test`, {
|
||||
method: 'POST',
|
||||
body: { episodeNo, persist: true, maxRepairAttempts },
|
||||
timeoutMs: 0
|
||||
}),
|
||||
generateVisualStates: (id: string, input: VisualStateBatchInput) =>
|
||||
request<StoryboardBatchResult>(`${projectPath(id)}/storyboard-visual-states/generate`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -3,24 +3,13 @@ import { NScrollbar, NAlert, NButton, NTable } from 'naive-ui'
|
||||
import { computed } from 'vue'
|
||||
import { Download } from '@lucide/vue'
|
||||
import { downloadText } from '../../../lib/format'
|
||||
import type { StoryboardIssue, StoryboardReceipt } from '../types'
|
||||
import type { StoryboardReceipt } from '../types'
|
||||
|
||||
/** 独立展示提交结果,不能将 HTTP 成功或局部成功显示为整批完成。 */
|
||||
const props = defineProps<{ receipt: StoryboardReceipt }>()
|
||||
const issues = computed<StoryboardIssue[]>(() => {
|
||||
if (props.receipt.kind === 'direction') return props.receipt.result.validation.issues
|
||||
if (props.receipt.kind === 'visual-state')
|
||||
return props.receipt.result.remainingIssues.length
|
||||
? props.receipt.result.remainingIssues
|
||||
: props.receipt.result.validation.issues
|
||||
return []
|
||||
})
|
||||
const failed = computed(() => {
|
||||
if (props.receipt.kind === 'batch') return props.receipt.result.failedEpisodes > 0
|
||||
if (props.receipt.kind === 'prompts') return props.receipt.result.failed > 0
|
||||
if (props.receipt.kind === 'visual-state' && !props.receipt.result.persisted) return true
|
||||
return !props.receipt.result.validation.valid
|
||||
})
|
||||
const failed = computed(() =>
|
||||
props.receipt.kind === 'batch' ? props.receipt.result.failedEpisodes > 0 : props.receipt.result.failed > 0
|
||||
)
|
||||
|
||||
/** 导出原始回执,保留后端提供的完整失败诊断。 */
|
||||
function exportReceipt() {
|
||||
@@ -91,24 +80,6 @@ function exportReceipt() {
|
||||
当前批量接口不返回成功镜头的 Prompt 正文;选择镜头后点击“读取/生成提示词”,已有内容会直接复用。
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="mt-4 text-sm" :class="failed ? 'text-danger' : 'text-success'">
|
||||
{{ receipt.result.validation.valid ? '生成校验通过' : '校验失败,未保存本次生成结果'
|
||||
}}<span v-if="receipt.kind === 'visual-state'">
|
||||
· 自动修复 {{ receipt.result.repairAttempts }} 次 ·
|
||||
{{ receipt.result.persisted ? '已持久化' : '未持久化' }}</span
|
||||
>
|
||||
</p>
|
||||
<p v-if="receipt.kind === 'direction' && receipt.result.validation.valid" class="mt-2 text-xs text-muted">
|
||||
已请求持久化,数据库实际覆盖率以下方刷新后的数据为准。
|
||||
</p>
|
||||
<ul class="mt-3 space-y-2 text-xs text-danger">
|
||||
<li v-for="(issue, index) in issues" :key="index">
|
||||
第 {{ issue.episodeNo }} 集 / Beat {{ issue.beatNo ?? '—' }} / Shot {{ issue.shotNo ?? '—' }}
|
||||
{{ issue.subjectRef }}:{{ issue.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<p class="mt-4 text-[11px] text-muted">回执仅保留在当前浏览器会话中。数据库结果是覆盖率和镜头详情的依据。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface SubjectVisualState {
|
||||
transientState: string
|
||||
}
|
||||
|
||||
/** GET 返回扁平化的环境字段,与 generate-test 的嵌套 environment 不同。 */
|
||||
/** GET 返回已经持久化的扁平化环境字段。 */
|
||||
export interface ShotVisualState {
|
||||
timeOfDay: string
|
||||
weather: string
|
||||
@@ -62,33 +62,6 @@ export interface StoryboardIssue {
|
||||
message: string
|
||||
}
|
||||
|
||||
/** 单集生成结果中的校验信息,HTTP 200 不代表 valid。 */
|
||||
export interface StoryboardValidation {
|
||||
valid: boolean
|
||||
issues: StoryboardIssue[]
|
||||
}
|
||||
|
||||
/** 单集导演设计返回领域结构;是否落库应再查 GET 覆盖率确认。 */
|
||||
export interface DirectionGeneration {
|
||||
episodeNo: number
|
||||
direction: {
|
||||
episodeNo: number
|
||||
beatDirections: { beatNo: number; shots: (ShotDirection & { shotNo: number })[] }[]
|
||||
}
|
||||
validation: StoryboardValidation
|
||||
}
|
||||
|
||||
/** 单集 VisualState 的自动修复与落库回执。 */
|
||||
export interface VisualStateGeneration {
|
||||
episodeNo: number
|
||||
visualState: unknown
|
||||
validation: StoryboardValidation
|
||||
repaired: boolean
|
||||
repairAttempts: number
|
||||
remainingIssues: StoryboardIssue[]
|
||||
persisted: boolean
|
||||
}
|
||||
|
||||
/** 项目批量生成中的单集结果;保留失败原因和修复诊断。 */
|
||||
export interface EpisodeGenerationResult {
|
||||
episodeNo: number
|
||||
@@ -178,8 +151,6 @@ export interface DesignedShot {
|
||||
|
||||
/** 生成回执按操作区分,切换剧集时仍保留原始目标信息。 */
|
||||
export type StoryboardReceipt =
|
||||
| { kind: 'direction'; title: string; episodeNo: number; result: DirectionGeneration }
|
||||
| { kind: 'visual-state'; title: string; episodeNo: number; result: VisualStateGeneration }
|
||||
| { kind: 'batch'; title: string; result: StoryboardBatchResult }
|
||||
| { kind: 'prompts'; title: string; result: PromptBatchResult }
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import { getOperation, runOperation } from '../workflows/operations'
|
||||
import { storyboardApi } from './api'
|
||||
import { getStoryboardSession, mergeDesignedShots, storyboardPrerequisites } from './model'
|
||||
|
||||
/** 分镜页允许的生成命令,保持单集与批量参数边界。 */
|
||||
export type StoryboardCommand = 'direction-one' | 'direction-all' | 'visual-one' | 'visual-all' | 'prompts'
|
||||
/** 分镜页只提供正式项目级生成命令,不暴露后端测试路由。 */
|
||||
export type StoryboardCommand = 'direction-all' | 'visual-all' | 'prompts'
|
||||
|
||||
/** 分镜查询、依赖判断与操作入口;不修改后端 graph。 */
|
||||
export function useStoryboard() {
|
||||
@@ -87,10 +87,7 @@ export function useStoryboard() {
|
||||
data.value.directions.coverage === 1
|
||||
)
|
||||
const allowed = computed<Record<StoryboardCommand, boolean>>(() => ({
|
||||
'direction-one': !blocked.value && prerequisites.value.directionEpisode && !!data.value?.directions.shotCount,
|
||||
'direction-all': !blocked.value && prerequisites.value.directionProject && batchValid.value,
|
||||
'visual-one':
|
||||
!blocked.value && prerequisites.value.visualEpisode && directionComplete.value && repairsValid.value,
|
||||
'visual-all': !blocked.value && prerequisites.value.visualProject && batchValid.value && repairsValid.value,
|
||||
prompts: !blocked.value && shots.value.length > 0 && batchValid.value
|
||||
}))
|
||||
@@ -99,28 +96,17 @@ export function useStoryboard() {
|
||||
async function run(command: StoryboardCommand) {
|
||||
if (!allowed.value[command]) return
|
||||
const projectId = id.value
|
||||
const number = episodeNo.value
|
||||
const batch = { concurrency: concurrency.value, force: force.value }
|
||||
const repairs = maxRepairAttempts.value
|
||||
const target = getStoryboardSession(projectId)
|
||||
const titles: Record<StoryboardCommand, string> = {
|
||||
'direction-one': `第 ${number} 集导演设计`,
|
||||
'direction-all': '项目导演设计',
|
||||
'visual-one': `第 ${number} 集即时状态`,
|
||||
'visual-all': '项目即时状态',
|
||||
prompts: '项目视频提示词'
|
||||
}
|
||||
target.receipt = null
|
||||
await runOperation(projectId, `生成${titles[command]}`, async () => {
|
||||
switch (command) {
|
||||
case 'direction-one':
|
||||
target.receipt = {
|
||||
kind: 'direction',
|
||||
title: titles[command],
|
||||
episodeNo: number,
|
||||
result: await storyboardApi.generateDirection(projectId, number)
|
||||
}
|
||||
break
|
||||
case 'direction-all':
|
||||
target.receipt = {
|
||||
kind: 'batch',
|
||||
@@ -128,14 +114,6 @@ export function useStoryboard() {
|
||||
result: await storyboardApi.generateDirections(projectId, batch)
|
||||
}
|
||||
break
|
||||
case 'visual-one':
|
||||
target.receipt = {
|
||||
kind: 'visual-state',
|
||||
title: titles[command],
|
||||
episodeNo: number,
|
||||
result: await storyboardApi.generateVisualState(projectId, number, repairs)
|
||||
}
|
||||
break
|
||||
case 'visual-all':
|
||||
target.receipt = {
|
||||
kind: 'batch',
|
||||
|
||||
@@ -545,7 +545,7 @@ watch(
|
||||
@save="save"
|
||||
@dirty="dirty = $event"
|
||||
/>
|
||||
<div v-if="anchor" class="mt-5 flex items-center gap-4 pt-5">
|
||||
<div v-if="anchor" class="identity-anchor mt-5 flex items-center gap-4 pt-5">
|
||||
<AssetImage
|
||||
:src="anchor.imageUrl"
|
||||
:alt="`${subject.name}当前身份母版`"
|
||||
@@ -677,6 +677,25 @@ watch(
|
||||
.identity-workspace > div {
|
||||
@apply min-h-0 overflow-hidden overscroll-contain;
|
||||
}
|
||||
.identity-detail,
|
||||
.identity-detail > .n-scrollbar-container,
|
||||
.identity-detail .n-scrollbar-content,
|
||||
.identity-detail .panel {
|
||||
@apply w-full min-w-0 max-w-full;
|
||||
}
|
||||
.identity-detail .n-scrollbar-content {
|
||||
@apply wrap-anywhere;
|
||||
}
|
||||
.identity-detail .panel {
|
||||
@apply box-border;
|
||||
}
|
||||
/* AssetImage 的通用 width:100% 不得撑满母版摘要行,否则会把说明文字挤到最右侧。 */
|
||||
.identity-anchor > .asset-image {
|
||||
@apply h-20 w-20 shrink-0 aspect-square;
|
||||
}
|
||||
.identity-anchor > div {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.identity-subject-list {
|
||||
@apply max-h-none min-h-0 flex-1;
|
||||
}
|
||||
|
||||
@@ -305,8 +305,8 @@ async function showStaleForms() {
|
||||
|
||||
<template>
|
||||
<WorkspacePage compact class="gallery-workspace-page"
|
||||
><template #default>
|
||||
<!-- 筛选、关联说明和图片共用正文滚动区,避免镜头切换时两块滚动区域高度不同步。 -->
|
||||
><template #header>
|
||||
<!-- 图库统计和固定操作使用与其他工作台一致的紧凑头部。 -->
|
||||
<div class="workspace-toolbar">
|
||||
<span class="toolbar-summary">主图 {{ completeCount }} / {{ forms.length }}</span>
|
||||
<NButton v-if="staleForms.length" text type="error" size="small" @click="toolsOpen = true"
|
||||
@@ -476,7 +476,9 @@ async function showStaleForms() {
|
||||
</section>
|
||||
</WorkspaceTools>
|
||||
</div>
|
||||
</div>
|
||||
</div></template
|
||||
><template #default>
|
||||
<!-- 关联说明、筛选和图片共用正文滚动区,避免镜头切换时两块滚动区域高度不同步。 -->
|
||||
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-3" title="形态主图需要更新">
|
||||
{{ staleForms.length }}
|
||||
个形态主图与当前已锁定身份母版不一致。请生成候选、验图并切换主图,再重建相关首帧。
|
||||
@@ -851,7 +853,7 @@ async function showStaleForms() {
|
||||
@apply [overflow-anchor:none];
|
||||
}
|
||||
.form-image-filter-region {
|
||||
@apply my-4 py-3.5 px-4 bg-(--app-subtle);
|
||||
@apply my-4 py-[5px] px-4 bg-(--app-subtle);
|
||||
container-type: inline-size;
|
||||
}
|
||||
.form-image-toolbar {
|
||||
|
||||
Reference in New Issue
Block a user