feat: 接入分镜导演设计与即时视觉状态工作台

增加单集和批量生成、修复诊断、正式镜头检查、参考图、生成规格与视频提示词。
补齐接口契约、竞态保护和确认交互测试,保持现有两条工作流。
This commit is contained in:
GouJ
2026-08-28 14:14:25 +08:00
parent 2ee6f049f6
commit bae69c3c62
22 changed files with 2211 additions and 11 deletions
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Download } from '@lucide/vue'
import { downloadText } from '../../../lib/format'
import type { StoryboardIssue, 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
})
/** 导出原始回执,保留后端提供的完整失败诊断。 */
function exportReceipt() {
downloadText('storyboard-generation-receipt.json', JSON.stringify(props.receipt, null, 2), 'application/json')
}
</script>
<template>
<section class="panel mt-5 p-5" aria-label="生成回执">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold">{{ receipt.title }} · 本次回执</h3>
<button class="text-button" @click="exportReceipt"><Download :size="13" />导出诊断</button>
</div>
<p v-if="failed" class="alert alert-error mt-3" role="alert">
本次存在失败校验未通过或未保存项关闭批量覆盖已有结果后再补齐可跳过已完整保存项
</p>
<template v-if="receipt.kind === 'batch'">
<p class="my-4 text-xs text-muted">
{{ receipt.result.episodeCount }} · 完成 {{ receipt.result.completedEpisodes }} · 跳过
{{ receipt.result.skippedEpisodes }} · 失败 {{ receipt.result.failedEpisodes }}
</p>
<div class="table-scroll">
<table class="project-table">
<thead>
<tr>
<th>剧集</th>
<th>结果</th>
<th>回执数量 / 镜头</th>
<th>自动修复</th>
<th>诊断</th>
</tr>
</thead>
<tbody>
<tr v-for="item in receipt.result.episodes" :key="item.episodeNo">
<td> {{ item.episodeNo }} </td>
<td :class="item.status === 'failed' ? 'text-danger' : 'text-success'">
{{ { completed: '完成', skipped: '跳过已有', failed: '失败' }[item.status] }}
</td>
<td>{{ item.directionCount ?? item.visualStateCount ?? 0 }} / {{ item.shotCount }}</td>
<td>
{{ item.repairAttempts === undefined ? '—' : item.repairAttempts + ' 次'
}}<span v-if="item.repaired"> · 已修复</span>
</td>
<td class="min-w-48 whitespace-pre-wrap text-xs">
<p>{{ item.error || '—' }}</p>
<p v-for="(issue, index) in item.remainingIssues" :key="index" class="mt-2 text-danger">
Beat {{ issue.beatNo ?? '' }} / Shot {{ issue.shotNo ?? '' }}
{{ issue.subjectRef }}{{ issue.message }}
</p>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<template v-else-if="receipt.kind === 'prompts'">
<p class="mt-4 text-sm">
{{ receipt.result.total }} 个镜头 · 本次目标 {{ receipt.result.targetCount }} · 处理成功
{{ receipt.result.generated }} · 跳过 {{ receipt.result.skipped }} · 失败 {{ receipt.result.failed }}
</p>
<ul class="mt-3 space-y-2 text-xs text-danger">
<li v-for="item in receipt.result.failures" :key="item.shotId">
<code>{{ item.shotId }}</code
>{{ item.error }}
</li>
</ul>
<p class="mt-3 text-xs text-muted">
当前批量接口不返回成功镜头的 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>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { directionLabel } from '../model'
import type { DesignedShot } from '../types'
/** 正式镜头设计的只读检查;内容编辑尚无后端写接口。 */
defineProps<{ shot: DesignedShot }>()
</script>
<template>
<div class="space-y-7">
<section>
<h3 class="mb-4 text-sm font-semibold">
导演摄影设计 <span class="ml-2 font-mono text-[10px] font-normal text-muted">Direction</span>
</h3>
<template v-if="shot.direction"
><div class="mb-5 flex flex-wrap gap-2">
<span
v-for="field in [
{ key: 'shotSize', label: '景别' },
{ key: 'cameraAngle', label: '角度' },
{ key: 'cameraMovement', label: '运镜' },
{ key: 'lensIntent', label: '焦段' },
{ key: 'depthOfField', label: '景深' }
] as const"
:key="field.key"
class="tag"
>{{ field.label }} · {{ directionLabel(shot.direction[field.key], field.key) }}</span
>
</div>
<dl class="detail-grid">
<dt>构图</dt>
<dd>{{ shot.direction.composition }}</dd>
<dt>机位</dt>
<dd>{{ shot.direction.cameraPosition }}</dd>
<dt>视觉意图</dt>
<dd>{{ shot.direction.visualIntent }}</dd>
</dl></template
>
<p v-else class="text-sm text-muted">此镜头尚无已保存的导演设计。</p>
</section>
<section class="border-t border-line pt-6">
<h3 class="mb-4 text-sm font-semibold">
即时视觉状态 <span class="ml-2 font-mono text-[10px] font-normal text-muted">VisualState</span>
</h3>
<template v-if="shot.visualState"
><dl class="detail-grid">
<dt>时间</dt>
<dd>{{ shot.visualState.timeOfDay || '未指定' }}</dd>
<dt>天气</dt>
<dd>{{ shot.visualState.weather || '不适用 / 未指定' }}</dd>
<dt>氛围</dt>
<dd>{{ shot.visualState.atmosphere || '未指定' }}</dd>
<dt>环境变化</dt>
<dd>{{ shot.visualState.environmentTransientState || '无临时变化' }}</dd>
<dt>连续性</dt>
<dd>{{ shot.visualState.continuityNote || '未附说明' }}</dd>
</dl>
<article
v-for="subject in shot.visualState.subjects"
:key="subject.subjectId"
class="mt-5 border-l-2 border-line pl-4"
>
<div class="mb-3 flex flex-wrap items-center gap-2">
<code class="subject-ref">{{ subject.subjectRef }}</code
><span class="text-xs font-medium">{{ subject.formName }}</span>
</div>
<dl class="detail-grid">
<template
v-for="field in [
{ key: 'expression', label: '表情' },
{ key: 'pose', label: '姿态' },
{ key: 'gaze', label: '视线' },
{ key: 'position', label: '位置' },
{ key: 'transientState', label: '临时变化' }
] as const"
:key="field.key"
><dt>{{ field.label }}</dt>
<dd>{{ subject[field.key] || '不适用 / 未指定' }}</dd></template
>
</dl>
</article></template
>
<p v-else class="text-sm leading-6 text-muted">
此镜头尚无已保存的即时状态先补齐本集导演设计再生成 VisualState
</p>
</section>
</div>
</template>
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { computed, onScopeDispose, ref, watch } from 'vue'
import { Download, ExternalLink, RefreshCw } from '@lucide/vue'
import { storyboardApi } from '../api'
import { getStoryboardSession, referenceImageUrl } from '../model'
import type { DesignedShot, ShotReferences } from '../types'
import type { ShotGenerationSpec } from '../generation-spec.types'
import { errorMessage } from '../../../lib/http'
import { downloadText } from '../../../lib/format'
import { getOperation, runOperation } from '../../workflows/operations'
import ConfirmAction from '../../workflows/ConfirmAction.vue'
/** 镜头级只读资源与显式 Prompt 生成;查询与耗费模型的操作分开。 */
const props = defineProps<{ projectId: string; shot: DesignedShot; disabled: boolean }>()
const references = ref<ShotReferences | null>(null)
const spec = ref<ShotGenerationSpec | null>(null)
const errors = ref({ references: '', spec: '' })
const busy = ref({ references: false, spec: false })
const brokenImages = ref<string[]>([])
const force = ref(false)
const prompt = computed(() => getStoryboardSession(props.projectId).prompts[props.shot.shotId])
const operation = computed(() => getOperation(props.projectId))
const locked = computed(() => props.disabled || operation.value.pending)
const controllers: Partial<Record<'references' | 'spec', AbortController>> = {}
let revision = 0
/** 切换镜头或已保存设计改变时废弃查询,防止旧编译结果覆盖新设计。 */
function reset() {
revision++
for (const controller of Object.values(controllers)) controller.abort()
references.value = null
spec.value = null
errors.value = { references: '', spec: '' }
busy.value = { references: false, spec: false }
brokenImages.value = []
}
watch(() => JSON.stringify([props.projectId, props.shot.shotId, props.shot.direction, props.shot.visualState]), reset)
onScopeDispose(reset)
/** 只在用户点击后读取参考图或编译规格;两种 GET 均不调用生成模型。 */
async function load(kind: 'references' | 'spec') {
if (busy.value[kind] || locked.value || (kind === 'spec' && (!props.shot.direction || !props.shot.visualState)))
return
const version = revision
const shotId = props.shot.shotId
const controller = new AbortController()
controllers[kind] = controller
busy.value[kind] = true
errors.value[kind] = ''
try {
if (kind === 'references') {
const result = await storyboardApi.references(shotId, controller.signal)
if (version !== revision) return
if (result.shotId !== shotId) throw new Error('参考图返回的 Shot ID 不匹配。')
references.value = result
brokenImages.value = []
} else {
const result = await storyboardApi.generationSpec(shotId, controller.signal)
if (version !== revision) return
if (result.shotId !== shotId) throw new Error('生成规格返回的 Shot ID 不匹配。')
spec.value = result
}
} catch (error) {
if (version === revision) errors.value[kind] = errorMessage(error)
} finally {
if (version === revision) busy.value[kind] = false
}
}
/** 没有独立 GET Prompt 接口:force=false 会复用已有正文,否则仍会调用模型。 */
async function generatePrompt() {
if (locked.value) return
const projectId = props.projectId
const shotId = props.shot.shotId
const overwrite = force.value
await runOperation(projectId, `读取/生成镜头 ${props.shot.shotNo} 提示词`, async () => {
const session = getStoryboardSession(projectId)
// 请求结果不确定时不要保留可能已被覆盖的旧正文。
delete session.prompts[shotId]
const result = await storyboardApi.generatePrompt(shotId, overwrite)
if (!result || result.id !== shotId || !result.videoPrompt)
throw new Error('接口未返回此镜头的有效提示词,请检查后端记录后重试。')
session.prompts[shotId] = result
})
}
/** 仅导出已返回的真实规格或提示词,不在前端补造内容。 */
function exportResult(kind: 'spec' | 'prompt') {
if (kind === 'spec' && spec.value)
downloadText(`shot-${props.shot.shotId}-spec.json`, JSON.stringify(spec.value, null, 2), 'application/json')
if (kind === 'prompt' && prompt.value)
downloadText(
`shot-${props.shot.shotId}-prompt.txt`,
`${prompt.value.videoPrompt}\n\nNegative prompt:\n${prompt.value.negativePrompt || ''}`
)
}
</script>
<template>
<div class="space-y-7">
<section>
<div class="flex flex-wrap items-center justify-between gap-3">
<h3 class="text-sm font-semibold">主体参考图</h3>
<button
class="button button-secondary"
:disabled="busy.references || locked"
@click="load('references')"
>
<RefreshCw :size="13" :class="{ 'animate-spin': busy.references }" />{{
references ? '刷新参考图' : '读取参考图'
}}
</button>
</div>
<p class="mt-2 text-xs leading-6 text-muted">
读取镜头已绑定形态的图片不生成新图参考图缺失会影响视频提示词生成
</p>
<p v-if="errors.references" class="alert alert-error mt-3" role="alert">{{ errors.references }}</p>
<template v-if="references">
<div class="reference-grid mt-4">
<article v-for="item in references.references" :key="item.shotSubjectId" class="reference-card">
<a
v-if="referenceImageUrl(item.imageUrl) && !brokenImages.includes(item.imageId)"
:href="referenceImageUrl(item.imageUrl)!"
target="_blank"
rel="noopener noreferrer"
class="block"
><img
:src="referenceImageUrl(item.imageUrl)!"
:alt="`${item.subjectName} · ${item.subjectFormName}`"
loading="lazy"
referrerpolicy="no-referrer"
@error="brokenImages.push(item.imageId)"
/></a>
<div v-else class="reference-placeholder">图片不可用</div>
<div class="p-3">
<p class="text-xs font-medium">{{ item.subjectName }} · {{ item.subjectFormName }}</p>
<code class="mt-1 block text-[10px] text-muted">{{ item.subjectRef }}</code
><a
v-if="referenceImageUrl(item.imageUrl)"
:href="referenceImageUrl(item.imageUrl)!"
target="_blank"
rel="noopener noreferrer"
class="text-button mt-2"
>原图 <ExternalLink :size="11"
/></a>
</div>
</article>
</div>
<ul v-if="references.missing.length" class="alert mt-3 space-y-2 text-xs">
<li v-for="item in references.missing" :key="item.subjectId">
{{ item.subjectName }}{{ item.subjectRef }}{{ item.reason }}
</li>
</ul>
<p v-if="!references.references.length && !references.missing.length" class="mt-3 text-xs text-muted">
后端未返回参考图或缺失记录
</p>
<p v-if="references.missing.length" class="mt-3 text-xs text-muted">
请先在后端补齐对应形态的图片再刷新本页暂不包含主体图生成
</p>
</template>
</section>
<section class="border-t border-line pt-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<h3 class="text-sm font-semibold">镜头生成规格</h3>
<button
class="button button-secondary"
:disabled="locked || busy.spec || !shot.direction || !shot.visualState"
@click="load('spec')"
>
{{ busy.spec ? '编译中…' : '读取生成规格' }}
</button>
</div>
<p class="mt-2 text-xs leading-6 text-muted">
后端合并主体形态摄影设计与即时状态编译为 GenerationSpec不调用模型需要本镜头已有 Direction
VisualState
</p>
<p v-if="errors.spec" class="alert alert-error mt-3" role="alert">{{ errors.spec }}</p>
<template v-if="spec"
><div class="mt-4 flex items-center justify-between">
<span class="text-xs text-muted"
>{{ spec.subjects.length }} 个主体 · {{ spec.durationSeconds }} </span
><button class="text-button" @click="exportResult('spec')"><Download :size="13" />导出规格</button>
</div>
<pre class="storyboard-json mt-3">{{ JSON.stringify(spec, null, 2) }}</pre>
</template>
</section>
<section class="border-t border-line pt-6">
<h3 class="text-sm font-semibold">视频提示词</h3>
<p class="mt-2 text-xs leading-6 text-muted">
已有提示词直接读取没有则调用模型生成当前后端仍使用镜头描述和参考图生成 Prompt尚未接入上方
GenerationSpec此操作不会生成视频
</p>
<div class="mt-4 flex flex-wrap items-center gap-4">
<ConfirmAction
label="读取/生成提示词"
:disabled="locked"
acknowledgement
:description="
force
? '将覆盖此镜头已保存的视频提示词,并调用模型重新生成。'
: '读取此镜头已有提示词;若不存在,将调用模型并保存结果。后端会检查参考图是否齐全。'
"
@confirm="generatePrompt"
/><label class="flex items-center gap-2 text-xs"
><input
v-model="force"
type="checkbox"
class="accent-accent"
:disabled="locked"
/>覆盖此镜头提示词</label
>
</div>
<div v-if="prompt" class="mt-5">
<div class="mb-3 flex items-center justify-between">
<span class="text-xs font-medium">本会话已读取的正文</span
><button class="text-button" @click="exportResult('prompt')"><Download :size="13" />导出</button>
</div>
<p class="whitespace-pre-wrap text-sm leading-7">{{ prompt.videoPrompt }}</p>
<template v-if="prompt.negativePrompt"
><h4 class="mb-2 mt-5 text-xs font-medium text-muted">Negative prompt</h4>
<p class="whitespace-pre-wrap text-sm leading-6">{{ prompt.negativePrompt }}</p></template
>
</div>
</section>
</div>
</template>