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,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>