feat: 接入分镜单集重生成接口
接入后端正式单集 Storyboard Shot 重生成能力,补齐校验回执、旧 Shot 缓存失效与页面刷新逻辑。
This commit is contained in:
@@ -50,6 +50,7 @@ const {
|
||||
repairsValid,
|
||||
blocked,
|
||||
directionComplete,
|
||||
hasCurrentEpisodeShots,
|
||||
run
|
||||
} = useStoryboard()
|
||||
const coverage = computed(() => [
|
||||
@@ -217,6 +218,31 @@ watch(
|
||||
<NCollapse v-model:expanded-names="overview"
|
||||
><NCollapseItem name="generation" title="生成配置、批量操作与回执"
|
||||
><div class="overview-content">
|
||||
<div class="generation-row">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
<span class="mr-2 font-mono text-xs text-muted">00</span
|
||||
>本集镜头拆解
|
||||
</h3>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
只重新拆解当前选择的第
|
||||
{{ episodeNo }}
|
||||
集,其它剧集不受影响。新镜头会替换旧镜头,并清空本集已有的导演设计、即时状态、首帧与视频结果。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ConfirmAction
|
||||
:label="`重生成第 ${episodeNo} 集镜头`"
|
||||
:disabled="!allowed['shots-current']"
|
||||
acknowledgement
|
||||
:description="`将删除并重建第 ${episodeNo} 集的正式镜头。该集已有导演设计、即时状态、首帧、提示词和视频结果会随旧镜头一并失效,且无法撤销;其它剧集保持不变。`"
|
||||
@confirm="run('shots-current')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!hasCurrentEpisodeShots" class="mt-3 text-[11px] text-muted">
|
||||
当前集尚无正式 Storyboard Shot,不能执行单集重生成;请先完成拆解流程。
|
||||
</p>
|
||||
<div class="generation-row">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ShotReferences,
|
||||
StoryboardBatchInput,
|
||||
StoryboardBatchResult,
|
||||
StoryboardShotRegenerationResult,
|
||||
VisualStateBatchInput
|
||||
} from './types'
|
||||
import type { ShotGenerationSpec } from './generation-spec.types'
|
||||
@@ -23,6 +24,13 @@ function shotPath(id: string) {
|
||||
|
||||
/** Storyboard 正式路由契约;测试路由不暴露为前端生产入口。 */
|
||||
export const storyboardApi = {
|
||||
/** 强制重生成指定单集的镜头拆解;后端校验通过后才覆盖正式 Storyboard。 */
|
||||
regenerateEpisodeShots: (id: string, episodeNo: number) =>
|
||||
request<StoryboardShotRegenerationResult>(`${projectPath(id)}/storyboard-shots/regenerate`, {
|
||||
method: 'POST',
|
||||
body: { episodeNo },
|
||||
timeoutMs: 0
|
||||
}),
|
||||
directions: (id: string, episodeNo: number, signal?: AbortSignal) =>
|
||||
request<EpisodeDirections>(`${projectPath(id)}/storyboard-directions?episodeNo=${episodeNo}`, { signal }),
|
||||
visualStates: (id: string, episodeNo: number, signal?: AbortSignal) =>
|
||||
|
||||
@@ -7,9 +7,11 @@ import type { StoryboardReceipt } from '../types'
|
||||
|
||||
/** 独立展示提交结果,不能将 HTTP 成功或局部成功显示为整批完成。 */
|
||||
const props = defineProps<{ receipt: StoryboardReceipt }>()
|
||||
const failed = computed(() =>
|
||||
props.receipt.kind === 'batch' ? props.receipt.result.failedEpisodes > 0 : props.receipt.result.failed > 0
|
||||
)
|
||||
const failed = computed(() => {
|
||||
if (props.receipt.kind === 'shots') return !props.receipt.result.storyboardShotValidation.valid
|
||||
if (props.receipt.kind === 'batch') return props.receipt.result.failedEpisodes > 0
|
||||
return props.receipt.result.failed > 0
|
||||
})
|
||||
|
||||
/** 导出原始回执,保留后端提供的完整失败诊断。 */
|
||||
function exportReceipt() {
|
||||
@@ -26,7 +28,23 @@ function exportReceipt() {
|
||||
<NAlert v-if="failed" role="alert" type="error" :show-icon="false" class="mt-3">
|
||||
本次存在失败、校验未通过或未保存项。关闭“批量覆盖已有结果”后再补齐,可跳过已完整保存项。
|
||||
</NAlert>
|
||||
<template v-if="receipt.kind === 'batch'">
|
||||
<template v-if="receipt.kind === 'shots'">
|
||||
<p class="my-4 text-xs text-muted">
|
||||
第 {{ receipt.result.episodeNo }} 集 · {{ receipt.result.storyboardShotSummary.beatCount }} 个 Beat ·
|
||||
{{ receipt.result.storyboardShotSummary.shotCount }} 个镜头 ·
|
||||
{{ receipt.result.storyboardShotSummary.totalDurationSeconds }} 秒 · 形态绑定覆盖率
|
||||
{{ Math.round(receipt.result.storyboardShotSummary.bindingCoverage * 100) }}%
|
||||
</p>
|
||||
<NAlert v-if="receipt.result.storyboardShotValidation.valid" type="success" :show-icon="false" class="mt-3">
|
||||
本集新镜头已保存。该集下游结果已随旧镜头清除,请依次重新生成导演设计、即时状态、首帧、提示词和视频。
|
||||
</NAlert>
|
||||
<ul v-else class="mt-3 space-y-2 text-xs text-danger">
|
||||
<li v-for="(issue, index) in receipt.result.storyboardShotValidation.issues" :key="index">
|
||||
Beat {{ issue.beatNo ?? '—' }} / Shot {{ issue.shotNo ?? '—' }}:{{ issue.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template v-else-if="receipt.kind === 'batch'">
|
||||
<p class="my-4 text-xs text-muted">
|
||||
共 {{ receipt.result.episodeCount }} 集 · 完成 {{ receipt.result.completedEpisodes }} · 跳过
|
||||
{{ receipt.result.skippedEpisodes }} · 失败 {{ receipt.result.failedEpisodes }}
|
||||
|
||||
@@ -9,7 +9,7 @@ const sessions = reactive<Record<string, StoryboardSession>>({})
|
||||
|
||||
/** 获取指定项目的独立回执容器。 */
|
||||
export function getStoryboardSession(id: string): StoryboardSession {
|
||||
return (sessions[id] ??= { receipt: null, prompts: {} })
|
||||
return (sessions[id] ??= { receipt: null, prompts: {}, regeneratedEpisodes: {} })
|
||||
}
|
||||
|
||||
/** 后端生成服务只读取最新 Breakdown;不能用历史可恢复快照冒充当前生成前置条件。 */
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { EpisodeShots } from '../breakdown/types'
|
||||
|
||||
/** 导演摄影设计的正式数据库字段。 */
|
||||
export interface ShotDirection {
|
||||
shotSize: string
|
||||
@@ -100,6 +102,29 @@ export interface VisualStateBatchInput extends StoryboardBatchInput {
|
||||
maxRepairAttempts: number
|
||||
}
|
||||
|
||||
/** 单集 Storyboard Shot 重生成返回的统计信息。 */
|
||||
export interface StoryboardShotSummary {
|
||||
episodeCount: number
|
||||
beatCount: number
|
||||
shotCount: number
|
||||
totalDurationSeconds: number
|
||||
subjectRefCount: number
|
||||
subjectBindingCount: number
|
||||
bindingCoverage: number
|
||||
}
|
||||
|
||||
/** 单集 Storyboard Shot 重生成结果;校验通过时后端才会覆盖正式数据。 */
|
||||
export interface StoryboardShotRegenerationResult {
|
||||
projectId: string
|
||||
episodeNo: number
|
||||
episodeShot: EpisodeShots
|
||||
storyboardShotSummary: StoryboardShotSummary
|
||||
storyboardShotValidation: {
|
||||
valid: boolean
|
||||
issues: StoryboardIssue[]
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前镜头已解析的主体参考图和缺失原因。 */
|
||||
export interface ShotReferences {
|
||||
shotId: string
|
||||
@@ -151,6 +176,7 @@ export interface DesignedShot {
|
||||
|
||||
/** 生成回执按操作区分,切换剧集时仍保留原始目标信息。 */
|
||||
export type StoryboardReceipt =
|
||||
| { kind: 'shots'; title: string; result: StoryboardShotRegenerationResult }
|
||||
| { kind: 'batch'; title: string; result: StoryboardBatchResult }
|
||||
| { kind: 'prompts'; title: string; result: PromptBatchResult }
|
||||
|
||||
@@ -158,4 +184,5 @@ export type StoryboardReceipt =
|
||||
export interface StoryboardSession {
|
||||
receipt: StoryboardReceipt | null
|
||||
prompts: Record<string, ShotPromptResult>
|
||||
regeneratedEpisodes: Record<number, EpisodeShots>
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { getOperation, runOperation } from '../workflows/operations'
|
||||
import { storyboardApi } from './api'
|
||||
import { getStoryboardSession, mergeDesignedShots, storyboardPrerequisites } from './model'
|
||||
|
||||
/** 分镜页只提供正式项目级生成命令,不暴露后端测试路由。 */
|
||||
export type StoryboardCommand = 'direction-all' | 'visual-all' | 'prompts'
|
||||
/** 分镜页只提供正式主流程命令,不暴露后端测试路由。 */
|
||||
export type StoryboardCommand = 'shots-current' | 'direction-all' | 'visual-all' | 'prompts'
|
||||
|
||||
/** 分镜查询、依赖判断与操作入口;不修改后端 graph。 */
|
||||
export function useStoryboard() {
|
||||
@@ -61,14 +61,19 @@ export function useStoryboard() {
|
||||
false
|
||||
)
|
||||
const data = query.data
|
||||
const source = computed(() => sourceEpisodes.value.find(item => item.episodeNo === episodeNo.value))
|
||||
const session = computed(() => getStoryboardSession(id.value))
|
||||
/** 单集重生成返回的是最新镜头正文,优先于不会随局部覆盖更新的历史 Breakdown 快照。 */
|
||||
const source = computed(
|
||||
() =>
|
||||
session.value.regeneratedEpisodes[episodeNo.value] ??
|
||||
sourceEpisodes.value.find(item => item.episodeNo === episodeNo.value)
|
||||
)
|
||||
const shots = computed(() =>
|
||||
mergeDesignedShots(data.value?.directions ?? null, data.value?.visualStates ?? null, source.value)
|
||||
)
|
||||
const shot = computed(() => shots.value.find(item => item.shotId === selectedShot.value) ?? shots.value[0])
|
||||
const prerequisites = computed(() => storyboardPrerequisites(context.checkpoints.value, episodeNo.value))
|
||||
const operation = computed(() => getOperation(id.value))
|
||||
const session = computed(() => getStoryboardSession(id.value))
|
||||
const batchValid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0)
|
||||
const repairsValid = computed(() => Number.isSafeInteger(maxRepairAttempts.value) && maxRepairAttempts.value >= 0)
|
||||
const blocked = computed(
|
||||
@@ -86,7 +91,11 @@ export function useStoryboard() {
|
||||
data.value.directions.directionCount === data.value.directions.shotCount &&
|
||||
data.value.directions.coverage === 1
|
||||
)
|
||||
const hasCurrentEpisodeShots = computed(
|
||||
() => Math.max(data.value?.directions.shotCount ?? 0, data.value?.visualStates.shotCount ?? 0) > 0
|
||||
)
|
||||
const allowed = computed<Record<StoryboardCommand, boolean>>(() => ({
|
||||
'shots-current': !blocked.value && episodeNo.value > 0 && hasCurrentEpisodeShots.value,
|
||||
'direction-all': !blocked.value && prerequisites.value.directionProject && batchValid.value,
|
||||
'visual-all': !blocked.value && prerequisites.value.visualProject && batchValid.value && repairsValid.value,
|
||||
prompts: !blocked.value && shots.value.length > 0 && batchValid.value
|
||||
@@ -98,8 +107,10 @@ export function useStoryboard() {
|
||||
const projectId = id.value
|
||||
const batch = { concurrency: concurrency.value, force: force.value }
|
||||
const repairs = maxRepairAttempts.value
|
||||
const targetEpisode = episodeNo.value
|
||||
const target = getStoryboardSession(projectId)
|
||||
const titles: Record<StoryboardCommand, string> = {
|
||||
'shots-current': `第 ${targetEpisode} 集镜头`,
|
||||
'direction-all': '项目导演设计',
|
||||
'visual-all': '项目即时状态',
|
||||
prompts: '项目视频提示词'
|
||||
@@ -107,6 +118,21 @@ export function useStoryboard() {
|
||||
target.receipt = null
|
||||
await runOperation(projectId, `生成${titles[command]}`, async () => {
|
||||
switch (command) {
|
||||
case 'shots-current': {
|
||||
const result = await storyboardApi.regenerateEpisodeShots(projectId, targetEpisode)
|
||||
if (result.projectId !== projectId || result.episodeNo !== targetEpisode) {
|
||||
throw new Error('镜头重生成返回了不匹配的项目或剧集,请刷新后重试。')
|
||||
}
|
||||
target.receipt = { kind: 'shots', title: titles[command], result }
|
||||
if (!result.storyboardShotValidation.valid) {
|
||||
throw new Error('本集镜头校验未通过,后端未覆盖正式 Storyboard。')
|
||||
}
|
||||
target.regeneratedEpisodes[targetEpisode] = result.episodeShot
|
||||
// 新镜头会获得新的数据库 ID,废弃所有按旧 Shot ID 缓存的提示词。
|
||||
target.prompts = {}
|
||||
if (episodeNo.value === targetEpisode) selectedShot.value = ''
|
||||
break
|
||||
}
|
||||
case 'direction-all':
|
||||
target.receipt = {
|
||||
kind: 'batch',
|
||||
@@ -160,6 +186,7 @@ export function useStoryboard() {
|
||||
repairsValid,
|
||||
blocked,
|
||||
directionComplete,
|
||||
hasCurrentEpisodeShots,
|
||||
run
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import ShotTools from '@/features/storyboard/components/ShotTools.vue'
|
||||
import {
|
||||
designedShot,
|
||||
directionsResult,
|
||||
episodeShots,
|
||||
storyboardCheckpoint,
|
||||
visualStatesResult
|
||||
} from '@/features/storyboard/testing/fixtures'
|
||||
@@ -141,7 +142,7 @@ afterEach(() => {
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
Object.assign(getStoryboardSession(fixture.id), { receipt: null, prompts: {} })
|
||||
Object.assign(getStoryboardSession(fixture.id), { receipt: null, prompts: {}, regeneratedEpisodes: {} })
|
||||
getImageSession(fixture.id).receipt = null
|
||||
getImageSession(fixture.id).promptReceipt = null
|
||||
Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' })
|
||||
@@ -1190,6 +1191,46 @@ describe('工作台页面交互', () => {
|
||||
expect(drawerPanel().text()).toContain('缺少主体状态')
|
||||
})
|
||||
|
||||
it('重生成当前集镜头后刷新正式设计,并用接口回执替换过期 Breakdown 文本', async () => {
|
||||
const regenerated = episodeShots()
|
||||
regenerated.beatShots[0]!.shots[0]!.title = '重生成后的开场镜头'
|
||||
regenerated.beatShots[0]!.shots[0]!.description = '新的镜头正文'
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
if (init?.method === 'POST')
|
||||
return jsonResponse({
|
||||
projectId: fixture.id,
|
||||
episodeNo: 1,
|
||||
episodeShot: regenerated,
|
||||
storyboardShotSummary: {
|
||||
episodeCount: 1,
|
||||
beatCount: 2,
|
||||
shotCount: 2,
|
||||
totalDurationSeconds: 10,
|
||||
subjectRefCount: 2,
|
||||
subjectBindingCount: 2,
|
||||
bindingCoverage: 1
|
||||
},
|
||||
storyboardShotValidation: { valid: true, issues: [] }
|
||||
})
|
||||
return jsonResponse(
|
||||
String(url).includes('storyboard-directions')
|
||||
? directionsResult(fixture.id, 1, false)
|
||||
: visualStatesResult(fixture.id)
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await mountStoryboard()
|
||||
await confirmGeneration('重生成第 1 集镜头')
|
||||
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
||||
expect(post[0]).toBe('/api/projects/page-test-project/storyboard-shots/regenerate')
|
||||
expect(JSON.parse(post[1]!.body as string)).toEqual({ episodeNo: 1 })
|
||||
expect(wrapper!.text()).toContain('重生成后的开场镜头')
|
||||
expect(wrapper!.text()).toContain('新的镜头正文')
|
||||
expect(drawerPanel().text()).toContain('本集新镜头已保存')
|
||||
expect(drawerPanel().text()).toContain('2 个镜头')
|
||||
expect(fetcher.mock.calls.filter(([, init]) => !init?.method || init.method === 'GET')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('批量 200 部分失败不显示全成功,默认补齐与显式覆盖参数独立', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
if (init?.method === 'POST')
|
||||
|
||||
@@ -46,9 +46,11 @@ describe('后端 API 契约', () => {
|
||||
it('分镜只调用正式项目接口,并保持 force 和零次修复参数', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}'))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await storyboardApi.regenerateEpisodeShots('a/b', 3)
|
||||
await storyboardApi.generateDirections('a/b', { concurrency: 2, force: false })
|
||||
await storyboardApi.generateVisualStates('p', { concurrency: 3, force: true, maxRepairAttempts: 0 })
|
||||
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method, JSON.parse(init!.body as string)])).toEqual([
|
||||
['/api/projects/a%2Fb/storyboard-shots/regenerate', 'POST', { episodeNo: 3 }],
|
||||
['/api/projects/a%2Fb/storyboard-directions/generate', 'POST', { concurrency: 2, force: false }],
|
||||
[
|
||||
'/api/projects/p/storyboard-visual-states/generate',
|
||||
|
||||
Reference in New Issue
Block a user