Files
short-drama-agent-front/src/features/storyboard/components/ShotTools.vue
T

224 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>