224 lines
12 KiB
Vue
224 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { NAlert, NButton, NCheckbox } from 'naive-ui'
|
||
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>
|
||
<NButton :disabled="busy.references || locked" @click="load('references')"
|
||
><RefreshCw :size="13" :class="{ 'animate-spin': busy.references }" />{{
|
||
references ? '刷新参考图' : '读取参考图'
|
||
}}</NButton
|
||
>
|
||
</div>
|
||
<p class="mt-2 text-xs leading-6 text-muted">
|
||
读取镜头已绑定形态的图片,不生成新图。参考图缺失会影响视频提示词生成。
|
||
</p>
|
||
<NAlert v-if="errors.references" role="alert" type="error" :show-icon="false" class="mt-3">{{
|
||
errors.references
|
||
}}</NAlert>
|
||
<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">
|
||
请在形态图库补齐图片或设置主图,再刷新本镜头参考图。
|
||
<RouterLink :to="`/projects/${projectId}/subject-images`" class="text-button ml-2"
|
||
>前往形态图片</RouterLink
|
||
>
|
||
</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>
|
||
<NButton
|
||
:disabled="locked || busy.spec || !shot.direction || !shot.visualState"
|
||
@click="load('spec')"
|
||
>{{ busy.spec ? '编译中…' : '读取生成规格' }}</NButton
|
||
>
|
||
</div>
|
||
<p class="mt-2 text-xs leading-6 text-muted">
|
||
后端合并主体稳定身份、具体形态、摄影设计与即时状态,编译为 GenerationSpec,不调用模型。需要本镜头已有
|
||
Direction 和 VisualState。
|
||
</p>
|
||
<NAlert v-if="errors.spec" role="alert" type="error" :show-icon="false" class="mt-3">{{
|
||
errors.spec
|
||
}}</NAlert>
|
||
<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
|
||
><NButton @click="exportResult('spec')" text size="small"><Download :size="13" />导出规格</NButton>
|
||
</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">
|
||
已有提示词可直接复用,没有则依据 GenerationSpec 与参考图调用模型生成。此处只处理
|
||
Prompt;首帧和实际视频任务请到“镜头生产”。
|
||
</p>
|
||
<div class="mt-4 flex flex-wrap items-center gap-4">
|
||
<ConfirmAction
|
||
label="读取/生成提示词"
|
||
:disabled="locked"
|
||
acknowledgement
|
||
:description="
|
||
force
|
||
? '将覆盖此镜头已保存的视频提示词,并调用模型重新生成。'
|
||
: '读取此镜头已有提示词;若不存在,将调用模型并保存结果。后端会检查参考图是否齐全。'
|
||
"
|
||
@confirm="generatePrompt"
|
||
/><NCheckbox v-model:checked="force" :disabled="locked" class="flex items-center gap-2 text-xs"
|
||
>覆盖此镜头提示词</NCheckbox
|
||
>
|
||
</div>
|
||
<div v-if="prompt" class="mt-5">
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<span class="text-xs font-medium">本会话已读取的正文</span
|
||
><NButton @click="exportResult('prompt')" text size="small"><Download :size="13" />导出</NButton>
|
||
</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>
|