feat: 对齐后端能力并补齐形态提示词与生产诊断
This commit is contained in:
@@ -21,6 +21,7 @@ import { issueLabel, productionStatusLabel } from './model'
|
||||
import { useProduction } from './useProduction'
|
||||
import ProductionReceipt from './components/ProductionReceipt.vue'
|
||||
import ShotProductionAssets from './components/ShotProductionAssets.vue'
|
||||
import AdvancedProduction from './components/AdvancedProduction.vue'
|
||||
|
||||
/** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */
|
||||
const {
|
||||
@@ -40,6 +41,10 @@ const {
|
||||
session,
|
||||
concurrency,
|
||||
force,
|
||||
keyframeLimit,
|
||||
keyframeWidth,
|
||||
keyframeHeight,
|
||||
keyframeValid,
|
||||
batchValid,
|
||||
blocked,
|
||||
run
|
||||
@@ -117,6 +122,12 @@ watch(
|
||||
}
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => session.value.pipelineReceipt,
|
||||
receipt => {
|
||||
if (receipt) toolsOpen.value = true
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -155,7 +166,7 @@ watch(
|
||||
<WorkspaceTools
|
||||
v-model:open="toolsOpen"
|
||||
title="项目生产总览与批量操作"
|
||||
:has-receipt="!!session.receipt"
|
||||
:has-receipt="!!session.receipt || !!session.pipelineReceipt"
|
||||
>
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
@@ -185,11 +196,11 @@ watch(
|
||||
v-model:checked="force"
|
||||
:disabled="operation.pending"
|
||||
class="control-row-checkbox text-xs"
|
||||
>覆盖模式:为已有结果新增候选
|
||||
>覆盖模式:重写提示词/新增图视频候选
|
||||
</NCheckbox>
|
||||
</div>
|
||||
<p class="mt-3 text-[11px] leading-6 text-muted">
|
||||
提示词与视频批量操作面向全项目;首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式新增全项目候选。Seedream
|
||||
提示词与视频批量操作面向全项目;首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式处理全项目;过期首帧成功后会自动接替旧主图,其余新增候选。Seedream
|
||||
与 Seedance 均可能产生费用。
|
||||
</p>
|
||||
</section>
|
||||
@@ -198,7 +209,7 @@ watch(
|
||||
><NCollapseItem name="overview" title="项目生产总览与批量操作"
|
||||
><div class="overview-content">
|
||||
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
|
||||
每一步都按后端就绪检查执行。覆盖只会新增首帧/视频候选,不会替换当前主资产;上游参考资产变化后,下游过期结果会被标记并阻止继续使用,需要重新生成后才能恢复就绪。
|
||||
每一步都按后端就绪检查执行。覆盖会重写提示词;图片和视频通常新增候选,但过期首帧即使在覆盖模式也会更新为主图。上游资产变化后,请先更新过期结果再继续生产。
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-if="videoRunning"
|
||||
@@ -325,7 +336,12 @@ watch(
|
||||
<ConfirmAction
|
||||
class="mt-4"
|
||||
:label="stage.action"
|
||||
:disabled="blocked || !batchValid || !(stage.data?.ready ?? 0)"
|
||||
:disabled="
|
||||
blocked ||
|
||||
!batchValid ||
|
||||
(stage.key === 'keyframes' && !keyframeValid) ||
|
||||
!(stage.data?.ready ?? 0)
|
||||
"
|
||||
acknowledgement
|
||||
:description="
|
||||
stage.key === 'videos'
|
||||
@@ -333,12 +349,46 @@ watch(
|
||||
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的 Seedance 任务;新视频完成后会自动接替过期主视频。'
|
||||
: '只为当前就绪镜头创建 Seedance 异步任务。创建成功不代表视频已经完成,任务会在后台继续运行。'
|
||||
: stage.key === 'keyframes'
|
||||
? '处理缺失主首帧和参考资产已变化的过期主首帧。过期镜头会按当前主体参考资产重新生成并恢复为可用于视频的主首帧;覆盖模式只新增候选。'
|
||||
? `处理缺失或过期主首帧。过期首帧成功后自动接替旧主图(覆盖模式也如此),其余已有主图只新增候选。本批上限:${keyframeLimit || '不限制'};尺寸:${keyframeWidth && keyframeHeight ? `${keyframeWidth} × ${keyframeHeight}` : '后端默认'}。`
|
||||
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
|
||||
"
|
||||
:primary="stage.key === 'videos'"
|
||||
@confirm="run(stage.key)"
|
||||
/>
|
||||
<div v-if="stage.key === 'keyframes'" class="mt-4 grid gap-3">
|
||||
<label
|
||||
><span class="field-label">本批上限(可选)</span
|
||||
><NInputNumber
|
||||
:value="keyframeLimit === '' ? null : keyframeLimit"
|
||||
@update:value="keyframeLimit = $event ?? ''"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:disabled="operation.pending"
|
||||
placeholder="留空不限制"
|
||||
/></label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label
|
||||
><span class="field-label">宽度(像素)</span
|
||||
><NInputNumber
|
||||
:value="keyframeWidth === '' ? null : keyframeWidth"
|
||||
@update:value="keyframeWidth = $event ?? ''"
|
||||
:min="1"
|
||||
:disabled="operation.pending"
|
||||
placeholder="默认" /></label
|
||||
><label
|
||||
><span class="field-label">高度(像素)</span
|
||||
><NInputNumber
|
||||
:value="keyframeHeight === '' ? null : keyframeHeight"
|
||||
@update:value="keyframeHeight = $event ?? ''"
|
||||
:min="1"
|
||||
:disabled="operation.pending"
|
||||
placeholder="默认"
|
||||
/></label>
|
||||
</div>
|
||||
<p v-if="!keyframeValid" class="text-xs text-danger">
|
||||
上限须为正整数;宽高须同时留空或填写正整数。
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="query.data.value" class="panel mt-4 p-5">
|
||||
@@ -386,10 +436,10 @@ watch(
|
||||
></NCollapse
|
||||
>
|
||||
</div>
|
||||
<AdvancedProduction />
|
||||
</WorkspaceTools>
|
||||
</div>
|
||||
</div></template
|
||||
>
|
||||
</div></div
|
||||
></template>
|
||||
<NScrollbar v-if="query.error.value" class="workspace-feedback"
|
||||
><NAlert role="alert" type="error" :show-icon="false"
|
||||
>{{ query.error.value
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
KeyframeSpec,
|
||||
ProjectVideoStatus,
|
||||
PromptReadiness,
|
||||
ProductionPipelineResult,
|
||||
RetryVideosResult,
|
||||
ShotKeyframe,
|
||||
ShotVideo,
|
||||
@@ -29,6 +30,12 @@ function shotPath(id: string) {
|
||||
|
||||
/** 生产链 API;生成请求不设置客户端短超时,也不自动重试。 */
|
||||
export const productionApi = {
|
||||
startPipeline: (projectId: string) =>
|
||||
request<ProductionPipelineResult>(`${projectPath(projectId)}/production/start`, {
|
||||
method: 'POST',
|
||||
body: { imageProvider: 'seedream', videoProvider: 'seedance' },
|
||||
timeoutMs: 0
|
||||
}),
|
||||
promptReadiness: (projectId: string, force: boolean, signal?: AbortSignal) =>
|
||||
request<PromptReadiness>(`${projectPath(projectId)}/video-prompts/readiness?force=${force}`, { signal }),
|
||||
generatePrompts: (projectId: string, input: StoryboardBatchInput) =>
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { projectContextKey } from '../projects/context'
|
||||
import { testProjectContext } from '../../testing/project-context'
|
||||
import { getOperation } from '../workflows/operations'
|
||||
import { formFixture } from '../subject-images/testing/fixtures'
|
||||
import { directionsResult } from '../storyboard/testing/fixtures'
|
||||
import { checkPipeline, useAdvancedProduction } from './useAdvancedProduction'
|
||||
import { useProduction } from './useProduction'
|
||||
import { getProductionSession } from './model'
|
||||
import AdvancedProduction from './components/AdvancedProduction.vue'
|
||||
import { expandSections } from '../../testing/naive'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
/** 从实际挂载的确认弹窗查找操作按钮。 */
|
||||
function button(label: string) {
|
||||
return [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)!
|
||||
}
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
for (const id of ['capability-test', 'new-project']) {
|
||||
Object.assign(getOperation(id), { pending: false, error: '', notice: '', label: '' })
|
||||
Object.assign(getProductionSession(id), { receipt: null, pipelineReceipt: null })
|
||||
}
|
||||
})
|
||||
|
||||
/** 所有预检均是模拟 GET;生成接口只记录契约,不调用真实 Provider。 */
|
||||
function server() {
|
||||
const context = testProjectContext()
|
||||
const form = formFixture('capability-test')
|
||||
const readiness = {
|
||||
total: 1,
|
||||
ready: 1,
|
||||
skipped: 0,
|
||||
blocked: 0,
|
||||
inProgress: 0,
|
||||
stalePrimaryKeyframe: 0,
|
||||
items: [
|
||||
{
|
||||
shotId: 'shot-1',
|
||||
shotNo: 1,
|
||||
episodeNo: 1,
|
||||
beatNo: 1,
|
||||
status: 'ready',
|
||||
issues: [] as { code: string; reason: string }[],
|
||||
primaryKeyframeId: 'keyframe-1',
|
||||
primaryKeyframeStale: false
|
||||
}
|
||||
]
|
||||
}
|
||||
const receipt = {
|
||||
projectId: 'capability-test',
|
||||
completed: true,
|
||||
needsManualReview: false,
|
||||
stopReason: '',
|
||||
errors: ['一个提示词未生成'],
|
||||
videoGenerationResult: { total: 1, created: 1, skipped: 0, failed: 0 }
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
const path = String(url)
|
||||
let data: unknown = context.project.value
|
||||
if (path.endsWith('/checkpoints')) data = context.checkpoints.value
|
||||
else if (path.endsWith('/subject-forms')) data = [form]
|
||||
else if (path.includes('/readiness')) data = readiness
|
||||
else if (path.includes('/storyboard-directions')) data = directionsResult('capability-test')
|
||||
else if (path.endsWith('/videos/status'))
|
||||
data = {
|
||||
total: 1,
|
||||
completed: 0,
|
||||
queued: 0,
|
||||
running: 0,
|
||||
pending: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
notStarted: 1,
|
||||
items: []
|
||||
}
|
||||
else if (init?.method === 'POST')
|
||||
data = path.endsWith('/production/start')
|
||||
? receipt
|
||||
: { total: 1, targetCount: 1, generated: 1, skipped: 0, blocked: 0, failed: 0, failures: [] }
|
||||
return new Response(JSON.stringify({ data }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
return {
|
||||
context,
|
||||
form,
|
||||
readiness,
|
||||
receipt,
|
||||
fetcher,
|
||||
posts: () => fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
|
||||
}
|
||||
}
|
||||
|
||||
async function advanced() {
|
||||
const data = server()
|
||||
let service!: ReturnType<typeof useAdvancedProduction>
|
||||
wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
service = useAdvancedProduction()
|
||||
return () => null
|
||||
}
|
||||
}),
|
||||
{ global: { provide: { [projectContextKey as symbol]: data.context } } }
|
||||
)
|
||||
await flushPromises()
|
||||
return { ...data, service }
|
||||
}
|
||||
|
||||
describe('高级串联生产的安全边界', () => {
|
||||
it('挂载不查询或生成,通过预检仍需显式启动,再次预检后提交固定 Provider', async () => {
|
||||
const { service, fetcher, posts } = await advanced()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
await service.start()
|
||||
expect(posts()).toHaveLength(0)
|
||||
await service.preflight()
|
||||
expect(service.check.value?.issues).toEqual([])
|
||||
expect(posts()).toHaveLength(0)
|
||||
await service.start()
|
||||
expect(posts()).toHaveLength(1)
|
||||
expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/production/start')
|
||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
|
||||
imageProvider: 'seedream',
|
||||
videoProvider: 'seedance'
|
||||
})
|
||||
expect(
|
||||
fetcher.mock.calls.filter(([url]) => String(url).includes('/keyframes/readiness?force=true'))
|
||||
).toHaveLength(2)
|
||||
expect(service.session.value.pipelineReceipt?.errors).toEqual(['一个提示词未生成'])
|
||||
expect(service.check.value).toBeNull()
|
||||
})
|
||||
it('预检通过后主首帧变旧,确认时再次预检会拦截,不提交', async () => {
|
||||
const { service, readiness, posts } = await advanced()
|
||||
await service.preflight()
|
||||
readiness.items[0]!.primaryKeyframeStale = true
|
||||
await service.start()
|
||||
expect(posts()).toHaveLength(0)
|
||||
expect(service.check.value?.issues.join()).toContain('有效主首帧')
|
||||
expect(getOperation('capability-test').error).toContain('条件变化')
|
||||
})
|
||||
it('后台活动视频、缺失主图与剧本未完成均阻止预检通过', async () => {
|
||||
const { context, form, readiness } = server()
|
||||
context.data.value!.project.status = 'need_review'
|
||||
form.images = []
|
||||
readiness.inProgress = 1
|
||||
const result = await checkPipeline('capability-test')
|
||||
expect(result.issues.join()).toContain('剧本尚未完成')
|
||||
expect(result.issues.join()).toContain('所有形态主图')
|
||||
expect(result.issues.join()).toContain('活动视频任务')
|
||||
})
|
||||
it('已有视频的 skipped 也不能掩盖缺失首帧,只有缺少提示词允许本流程补齐', async () => {
|
||||
const { readiness } = server()
|
||||
readiness.items[0]!.status = 'skipped'
|
||||
readiness.items[0]!.issues = [{ code: 'missing_keyframe', reason: '无首帧' }]
|
||||
const result = await checkPipeline('capability-test')
|
||||
expect(result.issues.join()).toContain('视频前置检查未通过')
|
||||
})
|
||||
it('项目锁和未完成剧本下不启动,切项目清空旧预检', async () => {
|
||||
const { service, context, posts } = await advanced()
|
||||
await service.preflight()
|
||||
getOperation('capability-test').pending = true
|
||||
await service.start()
|
||||
expect(posts()).toHaveLength(0)
|
||||
getOperation('capability-test').pending = false
|
||||
context.data.value!.project.status = 'failed'
|
||||
await service.start()
|
||||
expect(posts()).toHaveLength(0)
|
||||
context.data.value!.project.id = 'new-project'
|
||||
await flushPromises()
|
||||
expect(service.check.value).toBeNull()
|
||||
})
|
||||
it('已提交的长请求只写回原项目的回执,不污染新项目', async () => {
|
||||
const { service, context, fetcher, receipt } = await advanced()
|
||||
await service.preflight()
|
||||
const original = fetcher.getMockImplementation()!
|
||||
let finish!: (value: Response) => void
|
||||
fetcher.mockImplementation((url, init) =>
|
||||
String(url).endsWith('/production/start')
|
||||
? new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
: original(url, init)
|
||||
)
|
||||
const pending = service.start()
|
||||
await flushPromises()
|
||||
context.data.value!.project.id = 'new-project'
|
||||
await flushPromises()
|
||||
finish(new Response(JSON.stringify({ data: receipt })))
|
||||
await pending
|
||||
expect(service.session.value.pipelineReceipt).toBeNull()
|
||||
expect(getProductionSession('capability-test').pipelineReceipt?.projectId).toBe('capability-test')
|
||||
})
|
||||
it('回执项目不匹配时不发布成功回执', async () => {
|
||||
const { service, receipt } = await advanced()
|
||||
await service.preflight()
|
||||
receipt.projectId = 'wrong'
|
||||
await service.start()
|
||||
expect(service.session.value.pipelineReceipt).toBeNull()
|
||||
expect(getOperation('capability-test').error).toContain('回执项目不匹配')
|
||||
})
|
||||
it('界面区分流程返回与视频完成,显示部分错误,启动需要费用确认', async () => {
|
||||
const { context, receipt, posts } = server()
|
||||
wrapper = mount(AdvancedProduction, {
|
||||
attachTo: document.body,
|
||||
global: { provide: { [projectContextKey as symbol]: context } }
|
||||
})
|
||||
await flushPromises()
|
||||
await expandSections()
|
||||
expect(button('启动串联生产').disabled).toBe(true)
|
||||
button('检查串联生产条件').click()
|
||||
await flushPromises()
|
||||
button('启动串联生产').click()
|
||||
await flushPromises()
|
||||
expect(button('确认启动串联生产').disabled).toBe(true)
|
||||
expect(posts()).toHaveLength(0)
|
||||
document.querySelector<HTMLElement>('.app-dialog [role="checkbox"]')!.click()
|
||||
await flushPromises()
|
||||
button('确认启动串联生产').click()
|
||||
await flushPromises()
|
||||
expect(posts()).toHaveLength(1)
|
||||
expect(document.body.textContent).toContain('流程返回不等于成片完成')
|
||||
expect(document.body.textContent).toContain(receipt.errors[0])
|
||||
expect(document.body.textContent).toContain('已提交 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('首帧批量参数', () => {
|
||||
it('只向首帧传上限和成对尺寸,默认范围仍是当前剧集', async () => {
|
||||
const { context, posts } = server()
|
||||
let service!: ReturnType<typeof useProduction>
|
||||
wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
service = useProduction()
|
||||
return () => null
|
||||
}
|
||||
}),
|
||||
{ global: { provide: { [projectContextKey as symbol]: context } } }
|
||||
)
|
||||
await flushPromises()
|
||||
service.keyframeLimit.value = 2
|
||||
service.keyframeWidth.value = 2048
|
||||
await service.run('keyframes')
|
||||
expect(posts()).toHaveLength(0)
|
||||
service.keyframeHeight.value = 2048
|
||||
await service.run('keyframes')
|
||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
|
||||
provider: 'seedream',
|
||||
concurrency: 2,
|
||||
force: false,
|
||||
episodeNo: 1,
|
||||
limit: 2,
|
||||
width: 2048,
|
||||
height: 2048
|
||||
})
|
||||
service.keyframeLimit.value = -1
|
||||
await service.run('keyframes')
|
||||
expect(posts()).toHaveLength(1)
|
||||
await service.run('prompts')
|
||||
expect(JSON.parse(String(posts()[1]?.[1]?.body))).toEqual({ concurrency: 2, force: false })
|
||||
service.keyframeLimit.value = ''
|
||||
service.keyframeWidth.value = ''
|
||||
service.keyframeHeight.value = ''
|
||||
service.force.value = true
|
||||
await flushPromises()
|
||||
await service.run('keyframes')
|
||||
expect(JSON.parse(String(posts()[2]?.[1]?.body))).toEqual({ provider: 'seedream', concurrency: 2, force: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NCollapse, NCollapseItem } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import ConfirmAction from '../../workflows/ConfirmAction.vue'
|
||||
import { downloadText, formatDate } from '../../../lib/format'
|
||||
import { useAdvancedProduction } from '../useAdvancedProduction'
|
||||
|
||||
/** 高级入口明确真实流程边界,默认折叠且不在挂载时触发预检或生成。 */
|
||||
const { id, operation, session, checking, error, check, blocked, preflight, start } = useAdvancedProduction()
|
||||
const receipt = computed(() => session.value.pipelineReceipt)
|
||||
const expanded = ref<string[]>([])
|
||||
watch(
|
||||
receipt,
|
||||
value => {
|
||||
if (value) expanded.value = ['advanced']
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
const rows = computed(() => [
|
||||
{ name: '形态正式提示词', result: receipt.value?.visualPromptResult },
|
||||
{ name: '形态参考图', result: receipt.value?.subjectImageResult },
|
||||
{ name: '视频提示词', result: receipt.value?.videoPromptResult }
|
||||
])
|
||||
/** 导出实际后端回执,不把流程完成转换为视频完成。 */
|
||||
function exportReceipt() {
|
||||
if (receipt.value)
|
||||
downloadText(`project-${id.value}-pipeline.json`, JSON.stringify(receipt.value, null, 2), 'application/json')
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<NCollapse v-model:expanded-names="expanded" class="mt-5"
|
||||
><NCollapseItem name="advanced" title="高级:串联生产(全项目)">
|
||||
<NAlert type="warning" :show-icon="false"
|
||||
>依次补齐形态提示词、形态参考图、视频提示词并提交视频任务。不会生成视觉风格、身份母版、导演设计、即时状态或首帧。请先在各阶段完成验收。</NAlert
|
||||
>
|
||||
<p class="mt-3 text-xs leading-6 text-muted">
|
||||
范围固定为全项目,跳过已有结果;不使用上方并发、覆盖、数量上限和尺寸配置。后端固定文字并发
|
||||
3、图片和视频并发 2,可能产生模型费用。此流程没有持久运行锁或恢复
|
||||
checkpoint;请确认其他页面/终端没有任务。断网后先查资产,勿直接重试。
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<NButton :disabled="blocked || checking" :loading="checking" @click="preflight"
|
||||
>检查串联生产条件</NButton
|
||||
>
|
||||
<ConfirmAction
|
||||
label="启动串联生产"
|
||||
:disabled="blocked || checking || !check || !!check.issues.length"
|
||||
acknowledgement
|
||||
:description="`项目 ${id}:全项目串联生产,不生成首帧,也不等待视频完成。确认后会重新检查前置条件;可能产生文字、图片和视频模型费用。`"
|
||||
@confirm="start"
|
||||
/>
|
||||
</div>
|
||||
<NAlert v-if="error" type="error" class="mt-3" :show-icon="false">{{ error }}</NAlert>
|
||||
<NAlert
|
||||
v-if="operation.label === '高级串联生产' && operation.error"
|
||||
type="error"
|
||||
class="mt-3"
|
||||
:show-icon="false"
|
||||
>{{ operation.error }}</NAlert
|
||||
>
|
||||
<div v-if="check" class="surface-inset mt-3 p-4">
|
||||
<p class="text-xs text-muted">{{ formatDate(check.checkedAt) }} · 检查 {{ check.shotCount }} 镜</p>
|
||||
<ul v-if="check.issues.length" class="mt-2 space-y-2 text-xs text-danger">
|
||||
<li v-for="issue in check.issues" :key="issue">{{ issue }}</li>
|
||||
</ul>
|
||||
<p v-else class="mt-2 text-sm">前置检查通过,提交前仍会再次校验。</p>
|
||||
</div>
|
||||
<p v-if="operation.pending && operation.label === '高级串联生产'" role="status" class="mt-3 text-sm">
|
||||
请求进行中;关闭面板不会取消后端任务,请勿重复提交。
|
||||
</p>
|
||||
<section v-if="receipt" aria-label="串联生产回执" class="surface-inset mt-4 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold">
|
||||
串联生产回执 ·
|
||||
{{
|
||||
receipt.needsManualReview ? '需要人工处理' : receipt.completed ? '流程已返回' : '流程未完成'
|
||||
}}
|
||||
</h3>
|
||||
<NButton text size="small" @click="exportReceipt">导出回执</NButton>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-muted">
|
||||
流程返回不等于成片完成。已保存结果不会回滚,最终结果以各资产页为准。
|
||||
</p>
|
||||
<p v-for="row in rows" :key="row.name" class="mt-2 text-xs">
|
||||
{{ row.name }}:<template v-if="row.result"
|
||||
>共 {{ row.result.total }} · 生成 {{ row.result.generated }} · 跳过 {{ row.result.skipped }} ·
|
||||
失败 {{ row.result.failed }}</template
|
||||
><template v-else>未执行或未返回</template>
|
||||
</p>
|
||||
<p class="mt-2 text-xs">
|
||||
视频任务:<template v-if="receipt.videoGenerationResult"
|
||||
>共 {{ receipt.videoGenerationResult.total }} · 已提交
|
||||
{{ receipt.videoGenerationResult.created }} · 跳过 {{ receipt.videoGenerationResult.skipped }} ·
|
||||
提交失败 {{ receipt.videoGenerationResult.failed }}</template
|
||||
><template v-else>未执行或未返回</template>
|
||||
</p>
|
||||
<NAlert
|
||||
v-if="receipt.stopReason || receipt.errors.length"
|
||||
type="warning"
|
||||
:show-icon="false"
|
||||
class="mt-3"
|
||||
><p>{{ receipt.stopReason }}</p>
|
||||
<p v-for="(item, index) in receipt.errors" :key="index" class="mt-2">{{ item }}</p></NAlert
|
||||
>
|
||||
</section>
|
||||
</NCollapseItem></NCollapse
|
||||
>
|
||||
</template>
|
||||
@@ -28,6 +28,9 @@ function exportReceipt() {
|
||||
{{ receipt.result.generated }} · 跳过 {{ receipt.result.skipped }} · 阻塞 {{ receipt.result.blocked }} ·
|
||||
失败
|
||||
{{ receipt.result.failed }}
|
||||
<template v-if="receipt.result.selected !== undefined">
|
||||
· 因本批上限未执行 {{ Math.max(0, receipt.result.selected - receipt.result.targetCount) }}</template
|
||||
>
|
||||
</p>
|
||||
<p v-else-if="receipt.kind === 'videos'" class="mt-3 text-xs text-muted">
|
||||
全项目 {{ receipt.result.total }} 镜 · 本次目标 {{ receipt.result.targetCount }} · 已创建任务
|
||||
|
||||
@@ -196,6 +196,8 @@ export interface GenerateKeyframeInput {
|
||||
/** 首帧批量生成回执;详细阻塞原因应配合 readiness 查看。 */
|
||||
export interface KeyframeBatchResult {
|
||||
total: number
|
||||
/** 应用剧集范围后、数量上限前的目标数;老版本可能不返回。 */
|
||||
selected?: number
|
||||
targetCount: number
|
||||
generated: number
|
||||
skipped: number
|
||||
@@ -265,4 +267,28 @@ export type ProductionReceipt =
|
||||
/** 项目生产页的浏览器会话数据。 */
|
||||
export interface ProductionSession {
|
||||
receipt: ProductionReceipt | null
|
||||
pipelineReceipt?: ProductionPipelineResult | null
|
||||
}
|
||||
|
||||
/** 串联生产只汇总节点结果;completed 不代表视频已完成。 */
|
||||
export interface ProductionPipelineResult {
|
||||
projectId: string
|
||||
imageProvider: string
|
||||
videoProvider: string
|
||||
visualPromptResult?: PipelineBatchResult
|
||||
subjectImageResult?: PipelineBatchResult
|
||||
videoPromptResult?: PipelineBatchResult
|
||||
videoGenerationResult?: Omit<PipelineBatchResult, 'generated'> & { created: number }
|
||||
completed: boolean
|
||||
needsManualReview: boolean
|
||||
stopReason: string
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
/** 后端串联节点只有汇总数量,逐项失败信息保留在 errors。 */
|
||||
export interface PipelineBatchResult {
|
||||
total: number
|
||||
generated: number
|
||||
skipped: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||
import { projectsApi } from '../projects/api'
|
||||
import { useProjectContext } from '../projects/context'
|
||||
import { subjectImagesApi } from '../subject-images/api'
|
||||
import { hasRunningImages, isPrimaryIdentityStale, primaryImage } from '../subject-images/model'
|
||||
import { getOperation, runOperation } from '../workflows/operations'
|
||||
import { workflowCheckpoints } from '../workflows/selectors'
|
||||
import { errorMessage } from '../../lib/http'
|
||||
import { productionApi } from './api'
|
||||
import { getProductionSession } from './model'
|
||||
|
||||
/** 高级串联入口只能消费已验图的主资产,不替用户跳过选角、设计和首帧环节。 */
|
||||
export async function checkPipeline(projectId: string) {
|
||||
const [project, checkpoints, forms, keyframes, prompts, videos] = await Promise.all([
|
||||
projectsApi.detail(projectId),
|
||||
projectsApi.checkpoints(projectId),
|
||||
subjectImagesApi.listForms(projectId),
|
||||
productionApi.keyframeReadiness(projectId, true),
|
||||
productionApi.promptReadiness(projectId, true),
|
||||
productionApi.videoReadiness(projectId, false)
|
||||
])
|
||||
if (project.id !== projectId || forms.some(form => form.subject.projectId !== projectId))
|
||||
throw new Error('预检返回了其他项目的数据,请重新读取。')
|
||||
const issues: string[] = []
|
||||
const shotIds = new Set(keyframes.items.map(item => item.shotId))
|
||||
if (
|
||||
shotIds.size !== keyframes.total ||
|
||||
[prompts, videos].some(
|
||||
data =>
|
||||
new Set(data.items.map(item => item.shotId)).size !== shotIds.size ||
|
||||
data.items.some(item => !shotIds.has(item.shotId))
|
||||
)
|
||||
)
|
||||
issues.push('各阶段镜头列表不一致,可能正在重新拆解,请刷新后重试。')
|
||||
if (project.status !== 'completed') issues.push('剧本尚未完成,请先完成剧本创作。')
|
||||
if (
|
||||
project.tasks.some(task => ['pending', 'queued', 'running', 'generating'].includes(task.status)) ||
|
||||
[...new Set(checkpoints.map(item => item.workflowName))].some(
|
||||
name => workflowCheckpoints(checkpoints, name).at(-1)?.state.workflowExecution?.status === 'running'
|
||||
)
|
||||
)
|
||||
issues.push('项目仍有后台任务或工作流进行中。')
|
||||
if (!forms.length) issues.push('尚无正式形态,请先完成拆解。')
|
||||
if (forms.some(form => hasRunningImages(form.images))) issues.push('形态图片仍在生成中。')
|
||||
if (forms.some(form => !primaryImage(form.images)?.imageUrl || isPrimaryIdentityStale(form)))
|
||||
issues.push('请先补齐并确认所有形态主图,更新过期母版引用,再生成首帧。')
|
||||
if (
|
||||
!keyframes.total ||
|
||||
keyframes.items.length !== keyframes.total ||
|
||||
keyframes.items.some(
|
||||
item =>
|
||||
!item.primaryKeyframeId || item.primaryKeyframeStale || item.issues.length || item.status === 'blocked'
|
||||
)
|
||||
)
|
||||
issues.push('请先为全部镜头完成设计、身份检查和有效主首帧;串联流程不会生成首帧。')
|
||||
if (
|
||||
prompts.total !== keyframes.total ||
|
||||
prompts.items.length !== prompts.total ||
|
||||
prompts.items.some(item => item.issues.length || item.status === 'blocked')
|
||||
)
|
||||
issues.push('视频提示词的生成规格或参考图不完整,请到分镜页修复。')
|
||||
if (
|
||||
videos.total !== keyframes.total ||
|
||||
videos.items.length !== videos.total ||
|
||||
videos.items.some(item => item.issues.some(issue => issue.code !== 'missing_prompt'))
|
||||
)
|
||||
issues.push('视频前置检查未通过,请检查参考图和主首帧。')
|
||||
if (videos.inProgress || videos.items.some(item => item.status === 'in_progress' || item.activeVideoId))
|
||||
issues.push('存在活动视频任务,请等待结束后再操作。')
|
||||
return { projectId, issues, shotCount: keyframes.total, checkedAt: new Date().toISOString() }
|
||||
}
|
||||
|
||||
/** 预检不付费;确认后再次预检并锁住本浏览器项目,长请求不自动重发。 */
|
||||
export function useAdvancedProduction() {
|
||||
const context = useProjectContext()
|
||||
const id = computed(() => context.project.value?.id ?? '')
|
||||
const operation = computed(() => getOperation(id.value))
|
||||
const session = computed(() => getProductionSession(id.value))
|
||||
const checking = ref(false)
|
||||
const error = ref('')
|
||||
const check = ref<Awaited<ReturnType<typeof checkPipeline>> | null>(null)
|
||||
const blocked = computed(
|
||||
() =>
|
||||
!id.value ||
|
||||
operation.value.pending ||
|
||||
!!context.error.value ||
|
||||
context.project.value?.status !== 'completed'
|
||||
)
|
||||
let version = 0
|
||||
let disposed = false
|
||||
onScopeDispose(() => {
|
||||
disposed = true
|
||||
version++
|
||||
})
|
||||
watch(id, () => {
|
||||
version++
|
||||
check.value = null
|
||||
error.value = ''
|
||||
checking.value = false
|
||||
})
|
||||
|
||||
async function preflight() {
|
||||
if (blocked.value || checking.value) return
|
||||
const current = ++version
|
||||
const projectId = id.value
|
||||
checking.value = true
|
||||
check.value = null
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await checkPipeline(projectId)
|
||||
if (current === version && id.value === projectId) check.value = result
|
||||
} catch (cause) {
|
||||
if (current === version) error.value = errorMessage(cause)
|
||||
} finally {
|
||||
if (current === version) checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (
|
||||
blocked.value ||
|
||||
checking.value ||
|
||||
!check.value ||
|
||||
check.value.projectId !== id.value ||
|
||||
check.value.issues.length
|
||||
)
|
||||
return
|
||||
const projectId = id.value
|
||||
const target = getProductionSession(projectId)
|
||||
let submitted = false
|
||||
await runOperation(projectId, '高级串联生产', async () => {
|
||||
const result = await checkPipeline(projectId)
|
||||
if (
|
||||
disposed ||
|
||||
id.value !== projectId ||
|
||||
context.project.value?.status !== 'completed' ||
|
||||
context.error.value
|
||||
)
|
||||
return
|
||||
check.value = result
|
||||
if (result.issues.length) throw new Error('提交前预检发现条件变化,未启动生产。请处理下方问题。')
|
||||
target.pipelineReceipt = null
|
||||
submitted = true
|
||||
const receipt = await productionApi.startPipeline(projectId)
|
||||
if (!receipt || receipt.projectId !== projectId)
|
||||
throw new Error('回执项目不匹配,请读取资产状态核对,不要直接重试。')
|
||||
target.pipelineReceipt = receipt
|
||||
})
|
||||
if (!disposed && id.value === projectId) {
|
||||
if (submitted) check.value = null
|
||||
await context.refresh()
|
||||
}
|
||||
}
|
||||
return { id, operation, session, checking, error, check, blocked, preflight, start }
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { storyboardApi } from '../storyboard/api'
|
||||
import { breakdownSnapshot } from '../workflows/selectors'
|
||||
import { getOperation, runOperation } from '../workflows/operations'
|
||||
import { productionApi } from './api'
|
||||
import { getProductionSession } from './model'
|
||||
import { getProductionSession, validOptionalSize } from './model'
|
||||
|
||||
/** 项目级生产动作;每次请求都由统一项目锁防止重复提交。 */
|
||||
export type ProductionCommand = 'prompts' | 'keyframes' | 'videos' | 'retry-videos'
|
||||
@@ -19,6 +19,9 @@ export function useProduction() {
|
||||
const selectedShot = ref('')
|
||||
const concurrency = ref(2)
|
||||
const force = ref(false)
|
||||
const keyframeLimit = ref<number | ''>('')
|
||||
const keyframeWidth = ref<number | ''>('')
|
||||
const keyframeHeight = ref<number | ''>('')
|
||||
const snapshot = computed(() => breakdownSnapshot(context.checkpoints.value))
|
||||
const sourceEpisodes = computed(
|
||||
() => snapshot.value?.storyboardEpisodeShots ?? snapshot.value?.breakdownResult?.storyboardEpisodeShots ?? []
|
||||
@@ -67,6 +70,11 @@ export function useProduction() {
|
||||
const operation = computed(() => getOperation(id.value))
|
||||
const session = computed(() => getProductionSession(id.value))
|
||||
const batchValid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0)
|
||||
const keyframeValid = computed(
|
||||
() =>
|
||||
(keyframeLimit.value === '' || (Number.isSafeInteger(keyframeLimit.value) && keyframeLimit.value > 0)) &&
|
||||
validOptionalSize(keyframeWidth.value, keyframeHeight.value)
|
||||
)
|
||||
const blocked = computed(
|
||||
() =>
|
||||
operation.value.pending ||
|
||||
@@ -78,7 +86,7 @@ export function useProduction() {
|
||||
|
||||
/** 批量操作只向就绪镜头提交;最终资产状态由轮询查询确认。 */
|
||||
async function run(command: ProductionCommand) {
|
||||
if (blocked.value || !batchValid.value) return
|
||||
if (blocked.value || !batchValid.value || (command === 'keyframes' && !keyframeValid.value)) return
|
||||
const projectId = id.value
|
||||
const input = { concurrency: concurrency.value, force: force.value }
|
||||
const target = getProductionSession(projectId)
|
||||
@@ -114,7 +122,11 @@ export function useProduction() {
|
||||
provider: 'seedream',
|
||||
...input,
|
||||
// 有 stale 时由后端优先处理全部 stale;否则只补当前剧集,避免误跑整个项目。
|
||||
...(force.value || staleKeyframes > 0 ? {} : { episodeNo: episodeNo.value })
|
||||
...(force.value || staleKeyframes > 0 ? {} : { episodeNo: episodeNo.value }),
|
||||
...(keyframeLimit.value === '' ? {} : { limit: keyframeLimit.value }),
|
||||
...(keyframeWidth.value === '' || keyframeHeight.value === ''
|
||||
? {}
|
||||
: { width: keyframeWidth.value, height: keyframeHeight.value })
|
||||
})
|
||||
}
|
||||
} else if (command === 'videos') {
|
||||
@@ -151,6 +163,10 @@ export function useProduction() {
|
||||
session,
|
||||
concurrency,
|
||||
force,
|
||||
keyframeLimit,
|
||||
keyframeWidth,
|
||||
keyframeHeight,
|
||||
keyframeValid,
|
||||
batchValid,
|
||||
blocked,
|
||||
run
|
||||
|
||||
Reference in New Issue
Block a user