diff --git a/README.md b/README.md index 58280a5..9b51ffd 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec 9. 设为主图后,回到分镜点击“刷新参考图”读取最新绑定。已有视频提示词不会自动重生成,需要按需覆盖。本页面不会调用 `/production/start` 或实际视频生成。 10. 人物、场景、道具均仅继承已锁定身份的当前母版;无母版或未锁定不继承。刷新过期形态只生成候选,验图后手动切换主图。批量图片可限制本次数量,回执独立展示因上限未执行数,不把它算作已有结果的跳过数。 11. 提示词批量入口有独立并发、覆盖和回执;默认补缺,覆盖会重写已有正式文本,但不改变历史图片、实际 Prompt 或主图。未完成剧本、运行中的任务及查询错误均阻止生成。 +12. 生产页过期提示可点击直达镜头实际使用的形态;图库支持 `sourceShotId`/`subjectFormId` 定位、高亮和返回原镜头。顶部独立显示身份主图过期与下游首帧影响,检查失败不当作“没有过期”。两种后端就绪检查结果冲突时提示“状态待核对”,不建议据此重复生图;已知后端原因见 [覆盖说明](docs/backend-coverage.md#过期首帧与素材定位)。 ### 视觉风格、身份与形态的衔接 diff --git a/docs/backend-coverage.md b/docs/backend-coverage.md index 079dbae..7f492bc 100644 --- a/docs/backend-coverage.md +++ b/docs/backend-coverage.md @@ -42,6 +42,14 @@ 时间线分组是节点阶段,不是独立 executionId。未识别的节点被后端归为“其它”;前端用共享 checkpoint 的 workflowName 进一步筛选。两个观测接口独立报错,关闭弹窗不继续发起 API 查询,旧项目迟到响应不会覆盖新项目。 +## 过期首帧与素材定位 + +- 生产页将结构化 `stale_keyframe.missingSubjects` 映射到当前镜头 `/references` 返回的正式 `subjectFormId`,点击可定位图库中实际使用的形态;不按主体名称猜默认形态。缺少关联时退回主体引用检索并明确提示待核对。 +- 图库始终读取 keyframes 与 videos 的 readiness,显示下游过期镜头数量;选择来源镜头后才查询其参考图,避免遍历全部镜头的 N+1 请求。只允许定位当前项目列表中的镜头。 +- 图库深链接支持 `sourceShotId`、`subjectFormId` 和 `subjectRef`;精确形态优先,切换定位清除旧筛选,丢失的形态不自动回退其他形态。返回生产页携带 `episodeNo` 与正式 `shotId`。 +- “身份主图过期”与“下游首帧过期”独立展示。素材有效而首帧过期时,只需核对后重建首帧;定位、刷新、筛选不触发生成或切换主资产。 +- 当前后端基线存在检查口径不一致风险:`video-readiness.service.ts` 与 `video-generation-compiler.service.ts` 调用 `shotKeyframeCurrentnessService.check` 时仅传形态引用,遗漏第三个当前身份母版参数。默认空列表会将快照里的身份母版视为已移除;而 `shot-keyframe-readiness.service.ts` 会正确传入当前母版。前端因此合并两种检查,并将“首帧未标过期、视频报告过期”单列为“状态待核对”,不据此判定素材失效或建议直接重复生图。本次仅修改前端,不绕过视频阻塞;后端调用链需另行修复与联调。 + ## 不添加无效或重复入口 | 后端路由/能力 | 处理方式与理由 | @@ -57,5 +65,5 @@ ## 回归验证 - 自动测试全部模拟 API,覆盖正式 ID/参数、提示词与图片职责分离、批量部分失败、数量和尺寸校验、候选不覆盖、未完成剧本禁用、费用确认、预检后条件变化、项目切换与旧响应隔离。 -- 已通过 lint、格式、TypeScript、Vitest(22 个文件、220 项测试)和生产构建。不触发真实文本/图片/视频模型,不提交真实生产任务。 +- 已通过 lint、格式、TypeScript、Vitest(23 个文件、233 项测试)和生产构建;定位回归见 `src/features/subject-images/asset-impact.test.ts`。测试不触发真实模型或生产任务。 - 当前环境无法打开应用预览,未进行浏览器视觉验收;需在可访问的开发环境检查浅/暗主题和窄屏布局,并由用户明确批准后进行真实小批量模型联调。 diff --git a/src/admin.css b/src/admin.css index 3766ab1..d191e88 100644 --- a/src/admin.css +++ b/src/admin.css @@ -1460,6 +1460,18 @@ body { gap: 12px; min-width: 0; } +/* 镜头影响和素材定位使用固定底色;高亮只表示当前定位,不代表素材失效。 */ +.asset-impact-context { + padding: 14px 16px; + background: var(--app-subtle); +} +.asset-impact-select { + flex: 0 1 340px; + min-width: 0; +} +.form-image-card-focused { + box-shadow: inset 3px 0 0 var(--app-accent-text); +} .select-control { min-width: 130px; } diff --git a/src/features/production/ProductionPage.vue b/src/features/production/ProductionPage.vue index 57423c2..358dcc5 100644 --- a/src/features/production/ProductionPage.vue +++ b/src/features/production/ProductionPage.vue @@ -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(
+ 链接中的镜头已不存在或不属于该剧集,请在左侧重新选择。

BEAT {{ shot.beatNo }} / SHOT {{ shot.shotNo }}

@@ -527,15 +556,17 @@ watch(

{{ shot.description }}

Shot ID · {{ shot.shotId }}

- - 主首帧已过期:当前主体参考资产与生成该首帧时不一致。旧首帧会保留,但不能继续用于视频生成。 - + 主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。 -
+ + {{ staleForms.length }} + 个形态主图与当前已锁定身份母版不一致。请生成候选、验图并切换主图,再重建相关首帧。 + 筛选身份过期形态 + + + {{ impact.stale.value.length }} + 个镜头被后端报告主首帧过期。此警告不表示形态图片本身失效;请选择镜头核对当前素材,确认过期后再重建首帧。 +

+ 其中 + {{ impact.stale.value.filter(item => item.inconsistent).length }} + 个镜头的首帧与视频检查结果不一致,不建议直接重复生图。 +

+
+ 镜头影响检查失败,暂时无法确认有无过期首帧:{{ impact.query.error.value }} + 重试检查 +
+
+ + 返回第 {{ sourceShot?.episodeNo }} 集 · 镜头 {{ sourceShot?.shotNo }} → + 查看全部素材 +
+

+ 来源镜头不存在或不属于当前项目,未读取其素材。 +

+

+ 当前检查:{{ + sourceShot.primaryKeyframeId ? '该镜头主首帧未被标记为过期' : '该镜头暂无主首帧' + }}。请以后端最新就绪状态为准。 +

+

+ 当前镜头:首帧检查未标记过期,但视频检查报告过期。请核对后端检查逻辑与身份母版,暂不建议重复生图。 +

+

+ 指定形态不在该镜头当前的有效引用列表中,关联可能已调整或主图缺失,请从下方关联素材重新核对。 +

+
+ 本镜头当前关联素材:{{ target.label }} → +
+

正在读取镜头关联素材…

+

+ {{ impact.references.error.value }} + 重试读取素材 +

+
+ + + + 清除定位 +
+ + 第 {{ sourceShot.episodeNo }} 集 · BEAT {{ sourceShot.beatNo }} / 镜头 + {{ sourceShot.shotNo }} 的待处理首帧关联此素材。 + {{ + sourceShot.inconsistent + ? '后端首帧与视频检查结果不一致,请先核对,不要据此重复生图。' + : isPrimaryIdentityStale(form) + ? '请先更新该形态主图,再重建首帧。' + : '关联不等于本素材失效;核对形态主图与身份母版,确认无误后只需重建首帧。' + }} + 返回该镜头处理 → +

{{ form.images[0].error }}

@@ -419,6 +624,6 @@ function resetFilters() { :project-id="id" :form="galleryForm" :disabled="blocked || !galleryForm" - @changed="query.refresh" + @changed="refreshAssets" /> diff --git a/src/features/subject-images/asset-impact.test.ts b/src/features/subject-images/asset-impact.test.ts new file mode 100644 index 0000000..2d53115 --- /dev/null +++ b/src/features/subject-images/asset-impact.test.ts @@ -0,0 +1,360 @@ +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { createMemoryHistory, createRouter } from 'vue-router' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NSelect } from 'naive-ui' +import { testProjectContext } from '../../testing/project-context' +import { projectContextKey } from '../projects/context' +import { formFixture, imageFixture } from './testing/fixtures' +import { referenceLocation, referenceTargets } from '../production/asset-links' +import StaleKeyframeNotice from '../production/components/StaleKeyframeNotice.vue' +import SubjectImagesPage from './SubjectImagesPage.vue' +import ProductionPage from '../production/ProductionPage.vue' +import { directionsResult, storyboardCheckpoint } from '../storyboard/testing/fixtures' +import type { ProductionIssue } from '../production/types' +import type { ShotReferences } from '../storyboard/types' + +let wrapper: VueWrapper | undefined +afterEach(() => { + wrapper?.unmount() + wrapper = undefined + document.body.innerHTML = '' + vi.unstubAllGlobals() +}) + +/** 镜头引用非默认形态,用于防止退化成仅按主体名搜索。 */ +function fixture() { + const first = formFixture('capability-test') + const second = { + ...first, + id: 'form-special', + name: '雨夜形态', + isDefault: false, + images: [imageFixture({ subjectFormId: 'form-special' })] + } + const other = { + ...formFixture('capability-test'), + id: 'form-other', + subjectId: 'subject-other', + subject: { ...first.subject, id: 'subject-other', name: '路人', ref: '@CH0005' }, + images: [imageFixture({ subjectFormId: 'form-other' })] + } + const references: ShotReferences = { + shotId: 'shot-target', + references: [ + { + shotSubjectId: 'binding-1', + subjectId: first.subjectId, + subjectRef: first.subject.ref, + subjectName: first.subject.name, + module: 'character', + subjectFormId: second.id, + subjectFormName: second.name, + imageId: 'image-db-1', + imageUrl: '/storage/current.png' + }, + { + shotSubjectId: 'binding-2', + subjectId: other.subjectId, + subjectRef: other.subject.ref, + subjectName: other.subject.name, + module: 'character', + subjectFormId: other.id, + subjectFormName: other.name, + imageId: 'other-image', + imageUrl: '/storage/other.png' + } + ], + missing: [] + } + const issues: ProductionIssue[] = [ + { code: 'stale_keyframe', reason: '参考资产已变化: @CH0001, @CH0005', missingSubjects: ['@CH0001', '@CH0005'] } + ] + const keyframes = { + total: 1, + ready: 1, + skipped: 0, + blocked: 0, + stalePrimaryKeyframe: 1, + items: [ + { + shotId: 'shot-target', + shotNo: 1, + episodeNo: 2, + beatNo: 2, + status: 'ready', + primaryKeyframeStale: true, + primaryKeyframeId: 'keyframe-old', + issues: [] + } + ] + } + const videos = { + total: 1, + ready: 0, + skipped: 0, + blocked: 1, + items: [{ shotId: 'shot-target', shotNo: 1, status: 'blocked', issues }] + } + const fetcher = vi.fn().mockImplementation(async url => { + const path = String(url) + const data = path.endsWith('/subject-forms') + ? [first, second, other] + : path.includes('/keyframes/readiness') + ? keyframes + : path.includes('/videos/readiness') + ? videos + : path.endsWith('/references') + ? references + : [] + return new Response(JSON.stringify({ data })) + }) + vi.stubGlobal('fetch', fetcher) + return { first, second, other, references, issues, keyframes, videos, fetcher } +} + +/** 在真实内存路由中验证跨页 URL,不替换 RouterLink 行为。 */ +async function gallery(query = '') { + const data = fixture() + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/projects/:projectId/subject-images', component: SubjectImagesPage }, + { path: '/projects/:projectId/production', component: { template: '
' } } + ] + }) + await router.push('/projects/capability-test/subject-images' + query) + wrapper = mount(SubjectImagesPage, { + attachTo: document.body, + global: { plugins: [router], provide: { [projectContextKey as symbol]: testProjectContext() } } + }) + await flushPromises() + return { ...data, router } +} + +describe('过期首帧到具体素材定位', () => { + it('切换镜头取消旧参考图查询,迟到响应不会产生旧素材链接', async () => { + const { references } = fixture() + let finish!: (response: Response) => void + const nextReferences = { + ...references, + shotId: 'shot-new', + references: [{ ...references.references[0]!, subjectFormId: 'form-new', subjectFormName: '新镜头形态' }] + } + const fetcher = vi.fn().mockImplementation(url => + String(url).includes('shot-target') + ? new Promise(resolve => { + finish = resolve + }) + : Promise.resolve(new Response(JSON.stringify({ data: nextReferences }))) + ) + vi.stubGlobal('fetch', fetcher) + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/:pathMatch(.*)*', component: { template: '
' } }] + }) + await router.push('/') + wrapper = mount(StaleKeyframeNotice, { + global: { plugins: [router] }, + props: { projectId: 'capability-test', shotId: 'shot-target', issues: [] } + }) + await flushPromises() + const signal = fetcher.mock.calls[0]?.[1]?.signal + await wrapper.setProps({ shotId: 'shot-new' }) + await flushPromises() + expect(signal?.aborted).toBe(true) + finish(new Response(JSON.stringify({ data: references }))) + await flushPromises() + expect(wrapper.text()).toContain('新镜头形态') + expect(wrapper.findAll('a').some(link => link.attributes('href')?.includes('form-special'))).toBe(false) + }) + it('参考素材查询失败时保留引用级定位和重试,不静默选择默认形态', async () => { + const { issues } = fixture() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 500 }))) + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/:pathMatch(.*)*', component: { template: '
' } }] + }) + await router.push('/') + wrapper = mount(StaleKeyframeNotice, { + global: { plugins: [router] }, + props: { projectId: 'capability-test', shotId: 'shot-target', issues, inconsistent: true } + }) + await flushPromises() + expect(wrapper.text()).toContain('状态待核对') + expect(wrapper.text()).toContain('重试定位') + expect(wrapper.findAll('a').every(link => !link.attributes('href')?.includes('subjectFormId'))).toBe(true) + }) + it('结构化变更引用映射到实际使用的非默认形态,并保留来源镜头', () => { + const { references, issues } = fixture() + const targets = referenceTargets(references, issues) + expect(targets.map(item => item.formId)).toEqual(['form-special', 'form-other']) + expect(referenceLocation('project/1', 'shot-target', targets[0]!)).toEqual({ + path: '/projects/project%2F1/subject-images', + query: { sourceShotId: 'shot-target', subjectRef: '@CH0001', subjectFormId: 'form-special' } + }) + }) + it('没有当前关联时只回退到引用,不猜默认形态;没有变更列表时展示关联素材', () => { + const { references } = fixture() + const targets = referenceTargets(references, [ + { code: 'stale_keyframe', reason: '历史主体被移除', missingSubjects: ['@CH0999'] } + ]) + expect(targets[0]?.formId).toBeUndefined() + expect(targets[0]?.label).toContain('当前形态待核对') + expect(referenceTargets(references, [])).toHaveLength(2) + }) + it('生产警告的引用可点击定位,点击不提交生图', async () => { + const { issues, fetcher } = fixture() + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/:pathMatch(.*)*', component: { template: '
' } }] + }) + await router.push('/projects/capability-test/production') + wrapper = mount(StaleKeyframeNotice, { + global: { plugins: [router] }, + props: { projectId: 'capability-test', shotId: 'shot-target', issues } + }) + await flushPromises() + const links = wrapper.findAll('a') + expect(links).toHaveLength(2) + expect(links[0]?.text()).toContain('雨夜形态') + await links[0]!.trigger('click') + await flushPromises() + expect(router.currentRoute.value.query.subjectFormId).toBe('form-special') + expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true) + }) + it('图库直接访问即显示下游警告,但不把有效素材误标成身份过期', async () => { + const { fetcher } = await gallery() + expect(wrapper!.get('[data-keyframe-impact]').text()).toContain('1 个镜头') + expect(wrapper!.text()).toContain('不表示形态图片本身失效') + expect(wrapper!.findAll('.form-image-card')).toHaveLength(3) + expect(wrapper!.text()).not.toContain('个形态主图与当前已锁定身份母版不一致') + expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/references'))).toBe(false) + const select = wrapper! + .findAllComponents(NSelect) + .find(item => item.attributes('aria-label') === '选择关联镜头')! + select.vm.$emit('update:value', 'shot-target') + await flushPromises() + expect(wrapper!.text()).toContain('待处理首帧关联此素材') + expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith('/references'))).toHaveLength(1) + }) + it('精准定位卡片、清空旧类型筛选、提供可恢复原镜头的返回链接', async () => { + const { router } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special&subjectRef=%40CH0001') + expect(wrapper!.findAll('.form-image-card')).toHaveLength(1) + expect(wrapper!.get('.form-image-card').attributes('data-form-id')).toBe('form-special') + expect(wrapper!.get('.form-image-card').classes()).toContain('form-image-card-focused') + expect(wrapper!.get('[data-focused-material]').text()).toContain('雨夜形态') + const back = wrapper!.findAll('a').find(link => link.text().includes('返回第 2 集'))! + const url = new URL(back.attributes('href')!, 'https://local.test') + expect(url.searchParams.get('episodeNo')).toBe('2') + expect(url.searchParams.get('shotId')).toBe('shot-target') + const type = wrapper!.findAllComponents(NSelect).find(item => item.attributes('aria-label') === '筛选主体类型')! + type.vm.$emit('update:value', 'scene') + await flushPromises() + expect(wrapper!.findAll('.form-image-card')).toHaveLength(0) + await router.push({ query: { sourceShotId: 'shot-target', subjectFormId: 'form-other' } }) + await flushPromises() + expect(wrapper!.get('.form-image-card').attributes('data-form-id')).toBe('form-other') + const clear = wrapper!.findAll('button').find(button => button.text() === '清除定位')! + await clear.trigger('click') + await flushPromises() + expect(wrapper!.findAll('.form-image-card')).toHaveLength(3) + }) + it('身份主图真的过期时显示独立顶部警告,并可从定位模式筛选所有过期素材', async () => { + const { first, router } = await gallery('?subjectFormId=form-other') + first.subject.identity = { id: 'identity-1', isLocked: true, images: [{ id: 'anchor-new' }] } + const refresh = wrapper!.findAll('button').find(button => button.text() === '刷新图库')! + await refresh.trigger('click') + await flushPromises() + expect(wrapper!.text()).toContain('形态主图需要更新') + await wrapper! + .findAll('button') + .find(button => button.text() === '筛选身份过期形态')! + .trigger('click') + await flushPromises() + expect(router.currentRoute.value.query.subjectFormId).toBeUndefined() + expect(wrapper!.findAll('.form-image-card').map(item => item.attributes('data-form-id'))).toEqual([ + 'form-db-1', + 'form-special' + ]) + }) + it('仅视频接口报告过期时也显示警告,并明确两种检查不一致', async () => { + const { keyframes } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special') + keyframes.items[0]!.primaryKeyframeStale = false + await wrapper! + .findAll('button') + .find(button => button.text() === '刷新图库')! + .trigger('click') + await flushPromises() + expect(wrapper!.get('[data-keyframe-impact]').text()).toContain('检查结果不一致') + expect(wrapper!.get('.form-image-card').text()).toContain('不要据此重复生图') + }) + it('镜头修复后清除过期警告,保留明确的当前状态和返回入口', async () => { + const { keyframes, videos } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special') + keyframes.items[0]!.primaryKeyframeStale = false + videos.items[0]!.issues = [] + await wrapper! + .findAll('button') + .find(button => button.text() === '刷新图库')! + .trigger('click') + await flushPromises() + expect(wrapper!.find('[data-keyframe-impact]').exists()).toBe(false) + expect(wrapper!.text()).toContain('未被标记为过期') + }) + it('无效镜头和已移除形态不发跨项目请求,也不误定位其他素材', async () => { + const { fetcher } = await gallery('?sourceShotId=foreign-shot&subjectFormId=deleted-form') + expect(wrapper!.text()).toContain('来源镜头不存在或不属于当前项目') + expect(wrapper!.get('[data-focused-material]').text()).toContain('指定形态不存在') + expect(wrapper!.findAll('.form-image-card')).toHaveLength(0) + expect(fetcher.mock.calls.some(([url]) => String(url).includes('foreign-shot/references'))).toBe(false) + }) + it('影响查询失败不能当成没有过期首帧,仍然显示已有图库', async () => { + const { fetcher } = await gallery() + const original = fetcher.getMockImplementation()! + fetcher.mockImplementation((url, init) => + String(url).includes('/readiness') + ? Promise.resolve(new Response('{}', { status: 500 })) + : original(url, init) + ) + await wrapper! + .findAll('button') + .find(button => button.text() === '刷新图库')! + .trigger('click') + await flushPromises() + expect(wrapper!.text()).toContain('暂时无法确认有无过期首帧') + expect(wrapper!.findAll('.form-image-card')).toHaveLength(3) + }) + it('素材返回生产页时按正式 Shot ID 选择,不误选同编号的另一个 Beat', async () => { + const context = testProjectContext() + context.data.value!.checkpoints = [storyboardCheckpoint()] + const data = directionsResult('capability-test', 2) + const target = data.beats[1]!.shots[0]! + const fetcher = vi.fn().mockImplementation(async url => { + const path = String(url) + return new Response( + JSON.stringify({ + data: path.includes('/storyboard-directions') + ? data + : path.includes('/readiness') || path.endsWith('/videos/status') + ? { total: 0, items: [] } + : [] + }) + ) + }) + vi.stubGlobal('fetch', fetcher) + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/projects/:projectId/production', component: ProductionPage }] + }) + await router.push({ + path: '/projects/capability-test/production', + query: { episodeNo: '2', shotId: target.shotId } + }) + wrapper = mount(ProductionPage, { + global: { plugins: [router], provide: { [projectContextKey as symbol]: context } } + }) + await flushPromises() + expect(wrapper!.get('.production-detail').text()).toContain(target.shotId) + expect(fetcher.mock.calls.some(([url]) => String(url).includes('episodeNo=2'))).toBe(true) + expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true) + }) +}) diff --git a/src/features/subject-images/useKeyframeImpact.ts b/src/features/subject-images/useKeyframeImpact.ts new file mode 100644 index 0000000..1891108 --- /dev/null +++ b/src/features/subject-images/useKeyframeImpact.ts @@ -0,0 +1,42 @@ +import { computed, type Ref } from 'vue' +import { usePolling } from '../../composables/usePolling' +import { productionApi } from '../production/api' +import { useShotReferences } from '../production/useShotReferences' + +/** 镜头影响检查与素材查询独立;检查失败不把素材误判为正常或阻断独立的素材操作。 */ +export function useKeyframeImpact(projectId: Ref, sourceShotId: Ref) { + const query = usePolling(projectId, async (id, signal) => { + if (!id) return null + const [keyframes, videos] = await Promise.all([ + productionApi.keyframeReadiness(id, false, signal), + productionApi.videoReadiness(id, false, signal) + ]) + if (!keyframes || !Array.isArray(keyframes.items) || !videos || !Array.isArray(videos.items)) + throw new Error('主首帧影响检查未返回镜头列表。') + const byShot = new Map(videos.items.map(item => [item.shotId, item])) + if (byShot.size !== keyframes.items.length || keyframes.items.some(item => !byShot.has(item.shotId))) + throw new Error('首帧与视频检查的镜头列表不一致,可能正在重新拆解,请刷新后重试。') + return { + ...keyframes, + items: keyframes.items.map(item => { + const issues = byShot.get(item.shotId)?.issues.filter(issue => issue.code === 'stale_keyframe') ?? [] + return { + ...item, + primaryKeyframeStale: !!item.primaryKeyframeStale || issues.length > 0, + inconsistent: item.primaryKeyframeStale === false && issues.length > 0 + } + }) + } + }) + const stale = computed(() => query.data.value?.items.filter(item => item.primaryKeyframeStale) ?? []) + // 不根据 URL 中的任意镜头 ID 发请求,必须属于当前项目的就绪列表。 + const source = computed(() => query.data.value?.items.find(item => item.shotId === sourceShotId.value)) + const references = useShotReferences( + projectId, + computed(() => source.value?.shotId ?? '') + ) + const relatedForms = computed( + () => new Set(references.data.value?.references.map(item => item.subjectFormId) ?? []) + ) + return { query, stale, source, references, relatedForms } +} diff --git a/src/features/workflows/pages.test.ts b/src/features/workflows/pages.test.ts index 1f945e1..48c3910 100644 --- a/src/features/workflows/pages.test.ts +++ b/src/features/workflows/pages.test.ts @@ -856,7 +856,9 @@ describe('工作台页面交互', () => { expect(wrapper!.text()).toContain('林知夏') expect(wrapper!.text()).toContain('主参考图') expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([ - ['/api/projects/page-test-project/subject-forms', 'GET'] + ['/api/projects/page-test-project/subject-forms', 'GET'], + ['/api/projects/page-test-project/keyframes/readiness?force=false', 'GET'], + ['/api/projects/page-test-project/videos/readiness?force=false', 'GET'] ]) await wrapper!.get('[aria-label="搜索形态图片"]').setValue('不存在的形态') expect(wrapper!.text()).toContain('没有匹配的形态') @@ -883,7 +885,7 @@ describe('工作台页面交互', () => { '/storage/subjects/project/form/image.png' ) expect(document.querySelector('.image-history')).toBeNull() - expect(fetcher).toHaveBeenCalledOnce() + expect(fetcher).toHaveBeenCalledTimes(3) document.querySelector('.n-image-preview-overlay')!.click() await flushPromises() button('查看图片与记录').click()