fix: 补齐过期首帧素材定位与图库影响提示

This commit is contained in:
GouJ
2026-09-02 18:49:20 +08:00
parent f71d2bf0a4
commit 771b66cafc
11 changed files with 826 additions and 23 deletions
+44 -13
View File
@@ -12,7 +12,7 @@ import {
NProgress,
NSelect
} from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, RefreshCw } from '@lucide/vue'
import { EmptyState, StatusBadge } from '../../components/ui'
import BeatShotDirectory from '../storyboard/components/BeatShotDirectory.vue'
@@ -22,6 +22,9 @@ import { useProduction } from './useProduction'
import ProductionReceipt from './components/ProductionReceipt.vue'
import ShotProductionAssets from './components/ShotProductionAssets.vue'
import AdvancedProduction from './components/AdvancedProduction.vue'
import StaleKeyframeNotice from './components/StaleKeyframeNotice.vue'
import { routeLocationKey } from 'vue-router'
import { queryText } from './asset-links'
/** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */
const {
@@ -49,6 +52,23 @@ const {
blocked,
run
} = useProduction()
const route = inject(routeLocationKey, undefined)
/** 从素材页返回时恢复到指定剧集与正式镜头,不按重复的 Shot 编号定位。 */
watch(
() => [route?.query.episodeNo, route?.query.shotId],
() => {
const episode = Number(queryText(route?.query.episodeNo))
selectedEpisode.value = Number.isSafeInteger(episode) && episode > 0 ? episode : undefined
selectedShot.value = queryText(route?.query.shotId)
},
{ immediate: true }
)
const linkedShotMissing = computed(
() =>
queryText(route?.query.shotId) &&
query.data.value &&
!shots.value.some(item => item.shotId === selectedShot.value)
)
const staleVideos = computed(() => query.data.value?.videos.stalePrimaryVideo ?? 0)
const stages = computed(() => {
const data = query.data.value
@@ -92,6 +112,12 @@ const stages = computed(() => {
const selectedIssues = computed(() =>
[promptItem.value, keyframeItem.value, videoItem.value].flatMap(item => item?.issues ?? [])
)
const remainingIssues = computed(() => selectedIssues.value.filter(issue => issue.code !== 'stale_keyframe'))
const selectedKeyframeStale = computed(
() =>
!!keyframeItem.value?.primaryKeyframeStale ||
selectedIssues.value.some(issue => issue.code === 'stale_keyframe')
)
const videoRunning = computed(
() =>
(query.data.value?.videoStatus.pending ?? 0) +
@@ -514,6 +540,9 @@ watch(
<article class="production-detail min-w-0">
<NScrollbar class="panel-scroll" content-class="p-5 lg:p-7">
<header class="mb-5 pb-5">
<NAlert v-if="linkedShotMissing" type="warning" :show-icon="false" class="mb-4"
>链接中的镜头已不存在或不属于该剧集,请在左侧重新选择。</NAlert
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="eyebrow">BEAT {{ shot.beatNo }} / SHOT {{ shot.shotNo }}</p>
@@ -527,15 +556,17 @@ watch(
</div>
<p v-if="shot.description" class="mt-3 text-sm leading-7">{{ shot.description }}</p>
<p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p>
<NAlert
v-if="keyframeItem?.primaryKeyframeStale"
role="status"
type="info"
:show-icon="false"
class="mt-4 text-xs"
>
主首帧已过期:当前主体参考资产与生成该首帧时不一致。旧首帧会保留,但不能继续用于视频生成。
</NAlert>
<StaleKeyframeNotice
v-if="selectedKeyframeStale"
:key="shot.shotId"
:project-id="id"
:shot-id="shot.shotId"
:issues="selectedIssues"
:inconsistent="
keyframeItem?.primaryKeyframeStale === false &&
selectedIssues.some(issue => issue.code === 'stale_keyframe')
"
/>
<NAlert
v-if="videoItem?.primaryVideoStale"
role="status"
@@ -545,8 +576,8 @@ watch(
>
主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。
</NAlert>
<ul v-if="selectedIssues.length" class="alert alert-error mt-4 space-y-1" role="alert">
<li v-for="(issue, index) in selectedIssues" :key="`${issue.code}-${index}`">
<ul v-if="remainingIssues.length" class="alert alert-error mt-4 space-y-1" role="alert">
<li v-for="(issue, index) in remainingIssues" :key="`${issue.code}-${index}`">
{{ issueLabel(issue.code) }}{{ issue.reason }}
</li>
</ul>
@@ -558,7 +589,7 @@ watch(
:prompt-readiness="promptItem"
:keyframe-readiness="keyframeItem"
:video-readiness="videoItem"
:disabled="blocked"
:disabled="blocked || !!linkedShotMissing"
@changed="refreshProject"
/>
</NScrollbar>
+63
View File
@@ -0,0 +1,63 @@
import type { ProductionIssue } from './types'
import type { ShotReferences } from '../storyboard/types'
/** 素材定位使用正式 Form ID;无当前关联时只保留主体引用,不猜测形态。 */
export interface ReferenceTarget {
subjectRef: string
subjectId?: string
formId?: string
label: string
}
/** 优先使用后端结构化变更引用;仅有过期标记时展示全部当前关联素材,不能称其全部已变更。 */
export function referenceTargets(references: ShotReferences | null, issues: ProductionIssue[]): ReferenceTarget[] {
const changed = [
...new Set(
issues.filter(issue => issue.code === 'stale_keyframe').flatMap(issue => issue.missingSubjects ?? [])
)
]
const available = references?.references ?? []
const refs = changed.length
? changed
: [
...new Set([
...available.map(item => item.subjectRef),
...(references?.missing ?? []).map(item => item.subjectRef)
])
]
return refs.flatMap(ref => {
const matches = available.filter(item => item.subjectRef === ref || item.subjectId === ref)
if (matches.length)
return matches.map(item => ({
subjectRef: item.subjectRef,
subjectId: item.subjectId,
formId: item.subjectFormId,
label: `${item.subjectName} · ${item.subjectFormName}${item.subjectRef}`
}))
const missing = references?.missing.find(item => item.subjectRef === ref || item.subjectId === ref)
return [
{
subjectRef: missing?.subjectRef ?? ref,
subjectId: missing?.subjectId,
label: `${missing?.subjectName ? `${missing.subjectName} · ` : ''}${ref}(当前形态待核对)`
}
]
})
}
/** 深链接保留来源镜头,图库据此读取实时状态而不是相信 URL 的过期标记。 */
export function referenceLocation(projectId: string, shotId: string, target: ReferenceTarget) {
return {
path: `/projects/${encodeURIComponent(projectId)}/subject-images`,
query: {
sourceShotId: shotId,
subjectRef: target.subjectRef,
...(target.formId ? { subjectFormId: target.formId } : {})
}
}
}
/** 从 URL 取单值查询参数,不接受多值数组或空白值。 */
export function queryText(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NAlert, NButton } from 'naive-ui'
import { referenceLocation, referenceTargets } from '../asset-links'
import { useShotReferences } from '../useShotReferences'
import type { ProductionIssue } from '../types'
/** 主首帧过期提示直接定位当前引用的形态,缺失关联时明确降级到主体检索。 */
const props = defineProps<{ projectId: string; shotId: string; issues: ProductionIssue[]; inconsistent?: boolean }>()
const references = useShotReferences(
computed(() => props.projectId),
computed(() => props.shotId)
)
const targets = computed(() => referenceTargets(references.data.value, props.issues))
const reasons = computed(() => [
...new Set(props.issues.filter(issue => issue.code === 'stale_keyframe').map(issue => issue.reason))
])
const precise = computed(() =>
props.issues.some(issue => issue.code === 'stale_keyframe' && issue.missingSubjects?.length)
)
</script>
<template>
<NAlert
role="alert"
type="warning"
:show-icon="false"
class="mt-4 text-xs"
:title="inconsistent ? '主首帧状态待核对' : '主首帧已过期'"
>
<p v-if="inconsistent">
首帧检查未标记过期但视频检查报告参考资产已变化两项后端检查结果不一致请先核对下列素材与身份母版不建议直接重复生图视频仍会按后端检查阻止提交
</p>
<p v-else>
当前参考资产与生成该首帧时不一致旧首帧需重建素材本身不一定过期请先核对关联素材确认有效后返回本镜头重新生成主首帧
</p>
<p v-for="reason in reasons" :key="reason" class="mt-2">{{ reason }}</p>
<p class="mt-3 font-medium">
{{ precise ? '定位后端标记的变更主体' : '核对本镜头关联素材(后端未返回具体变更项)' }}
</p>
<div class="mt-2 flex flex-wrap items-center gap-3">
<RouterLink
v-for="target in targets"
:key="target.formId || target.subjectRef"
:to="referenceLocation(projectId, shotId, target)"
class="text-button"
>{{ target.label }} </RouterLink
>
<RouterLink
v-if="!targets.length"
:to="{
path: `/projects/${encodeURIComponent(projectId)}/subject-images`,
query: { sourceShotId: shotId }
}"
class="text-button"
>前往图库核对 </RouterLink
>
</div>
<p v-if="references.loading.value" class="mt-2 text-muted">正在定位当前引用的形态</p>
<div v-if="references.error.value" class="mt-2 text-danger">
{{ references.error.value }} <NButton text size="small" @click="references.refresh">重试定位</NButton>
</div>
</NAlert>
</template>
@@ -0,0 +1,16 @@
import { computed, type Ref } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { storyboardApi } from '../storyboard/api'
/** 仅查询已由当前项目列表确认的镜头,切换目标自动取消旧响应。 */
export function useShotReferences(projectId: Ref<string>, shotId: Ref<string>) {
const key = computed(() => JSON.stringify([projectId.value, shotId.value]))
return usePolling(key, async (value, signal) => {
const [project, shot] = JSON.parse(value) as [string, string]
if (!project || !shot) return null
const result = await storyboardApi.references(shot, signal)
if (!result || result.shotId !== shot || !Array.isArray(result.references) || !Array.isArray(result.missing))
throw new Error('镜头参考素材返回不匹配,请刷新后重试。')
return result
})
}