diff --git a/src/features/production/ProductionPage.vue b/src/features/production/ProductionPage.vue new file mode 100644 index 0000000..45a4e77 --- /dev/null +++ b/src/features/production/ProductionPage.vue @@ -0,0 +1,336 @@ + + + diff --git a/src/features/production/api.ts b/src/features/production/api.ts new file mode 100644 index 0000000..e597301 --- /dev/null +++ b/src/features/production/api.ts @@ -0,0 +1,94 @@ +import { request } from '../../lib/http' +import type { PromptBatchResult, StoryboardBatchInput } from '../storyboard/types' +import type { + GenerateKeyframeInput, + GenerateKeyframesInput, + GenerateVideosInput, + KeyframeBatchResult, + KeyframeReadiness, + KeyframeSpec, + ProjectVideoStatus, + PromptReadiness, + RetryVideosResult, + ShotKeyframe, + ShotVideo, + VideoBatchResult, + VideoGenerationSpec, + VideoReadiness +} from './types' + +/** 项目路径统一编码,避免业务组件手工拼接 ID。 */ +function projectPath(id: string) { + return `/projects/${encodeURIComponent(id)}` +} + +/** 正式镜头路径统一编码,只接受数据库 Shot ID。 */ +function shotPath(id: string) { + return `/storyboard-shots/${encodeURIComponent(id)}` +} + +/** 生产链 API;生成请求不设置客户端短超时,也不自动重试。 */ +export const productionApi = { + promptReadiness: (projectId: string, force: boolean, signal?: AbortSignal) => + request(`${projectPath(projectId)}/video-prompts/readiness?force=${force}`, { signal }), + generatePrompts: (projectId: string, input: StoryboardBatchInput) => + request(`${projectPath(projectId)}/video-prompts/generate`, { + method: 'POST', + body: input, + timeoutMs: 0 + }), + keyframeReadiness: (projectId: string, force: boolean, signal?: AbortSignal) => + request(`${projectPath(projectId)}/keyframes/readiness?force=${force}`, { signal }), + generateKeyframes: (projectId: string, input: GenerateKeyframesInput) => + request(`${projectPath(projectId)}/keyframes/generate`, { + method: 'POST', + body: input, + timeoutMs: 0 + }), + videoReadiness: (projectId: string, force: boolean, signal?: AbortSignal) => + request(`${projectPath(projectId)}/videos/readiness?force=${force}`, { signal }), + generateVideos: (projectId: string, input: GenerateVideosInput) => + request(`${projectPath(projectId)}/videos/generate`, { + method: 'POST', + body: input, + timeoutMs: 0 + }), + projectVideoStatus: (projectId: string, signal?: AbortSignal) => + request(`${projectPath(projectId)}/videos/status`, { signal }), + retryVideos: (projectId: string, concurrency: number) => + request(`${projectPath(projectId)}/videos/retry-failed`, { + method: 'POST', + body: { provider: 'seedance', concurrency }, + timeoutMs: 0 + }), + listKeyframes: (shotId: string, signal?: AbortSignal) => + request(`${shotPath(shotId)}/keyframes`, { signal }), + keyframeSpec: (shotId: string, signal?: AbortSignal) => + request(`${shotPath(shotId)}/keyframe-spec`, { signal }), + generateKeyframe: (shotId: string, input: GenerateKeyframeInput) => + request(`${shotPath(shotId)}/keyframe`, { + method: 'POST', + body: input, + timeoutMs: 0 + }), + setPrimaryKeyframe: (shotId: string, keyframeId: string) => + request(`${shotPath(shotId)}/keyframes/${encodeURIComponent(keyframeId)}/primary`, { + method: 'PUT' + }), + videoGenerationSpec: (shotId: string, signal?: AbortSignal) => + request(`${shotPath(shotId)}/video-generation-spec`, { signal }), + listVideos: (shotId: string, signal?: AbortSignal) => + request(`${shotPath(shotId)}/videos`, { signal }), + generateVideo: (shotId: string) => + request(`${shotPath(shotId)}/videos`, { + method: 'POST', + body: { provider: 'seedance' }, + timeoutMs: 0 + }), + refreshVideo: (videoId: string, signal?: AbortSignal) => + request(`/storyboard-shot-videos/${encodeURIComponent(videoId)}/status`, { signal }), + setPrimaryVideo: (shotId: string, videoId: string) => + request(`${shotPath(shotId)}/videos/${encodeURIComponent(videoId)}/primary`, { + method: 'PUT' + }) +} diff --git a/src/features/production/components/KeyframeDialog.vue b/src/features/production/components/KeyframeDialog.vue new file mode 100644 index 0000000..04512af --- /dev/null +++ b/src/features/production/components/KeyframeDialog.vue @@ -0,0 +1,77 @@ + + + diff --git a/src/features/production/components/ProductionReceipt.vue b/src/features/production/components/ProductionReceipt.vue new file mode 100644 index 0000000..314bf42 --- /dev/null +++ b/src/features/production/components/ProductionReceipt.vue @@ -0,0 +1,52 @@ + + + diff --git a/src/features/production/components/ShotProductionAssets.vue b/src/features/production/components/ShotProductionAssets.vue new file mode 100644 index 0000000..fc2a542 --- /dev/null +++ b/src/features/production/components/ShotProductionAssets.vue @@ -0,0 +1,412 @@ + + + diff --git a/src/features/production/index.ts b/src/features/production/index.ts new file mode 100644 index 0000000..dcfada8 --- /dev/null +++ b/src/features/production/index.ts @@ -0,0 +1,4 @@ +/** 镜头生产功能的公共导出入口。 */ +export { productionApi } from './api' +export * from './model' +export type * from './types' diff --git a/src/features/production/model.ts b/src/features/production/model.ts new file mode 100644 index 0000000..584ade4 --- /dev/null +++ b/src/features/production/model.ts @@ -0,0 +1,68 @@ +import { reactive } from 'vue' +import type { ProductionIssueCode, ProductionSession, ShotKeyframe, ShotVideo } from './types' + +/** 按项目隔离生产回执,切换页面后仍能查看上一次批量操作结果。 */ +const sessions = reactive>({}) + +/** 取得项目的生产页会话容器。 */ +export function getProductionSession(projectId: string): ProductionSession { + return (sessions[projectId] ??= { receipt: null }) +} + +/** 生成规格尺寸必须成对留空或成对填写正整数。 */ +export function validOptionalSize(width: number | '', height: number | ''): boolean { + if (width === '' && height === '') return true + return ( + Number.isSafeInteger(width) && + Number.isSafeInteger(height) && + typeof width === 'number' && + typeof height === 'number' && + width > 0 && + height > 0 + ) +} + +/** 返回当前成功主首帧;异常的重复主图只取最新列表中的第一条。 */ +export function primaryKeyframe(items: ShotKeyframe[]): ShotKeyframe | undefined { + return items.find(item => item.isPrimary && item.status === 'completed' && item.imageUrl) +} + +/** 返回当前成功主视频。 */ +export function primaryVideo(items: ShotVideo[]): ShotVideo | undefined { + return items.find(item => item.isPrimary && item.status === 'completed' && item.videoUrl) +} + +/** 活动视频状态与后端重复任务保护保持一致。 */ +export function isActiveVideo(item: ShotVideo): boolean { + return ['pending', 'queued', 'running'].includes(item.status) +} + +/** 就绪问题转为短标签,后端详细 reason 仍在页面原样显示。 */ +export function issueLabel(code: ProductionIssueCode): string { + const labels: Record = { + invalid_generation_spec: '生成规格不完整', + missing_visual_style: '缺少视觉风格', + missing_reference: '缺少主体参考图', + missing_prompt: '缺少视频提示词', + missing_keyframe: '缺少主首帧' + } + return labels[code] +} + +/** 生产状态中文标签,未知值由状态组件回退显示原文。 */ +export function productionStatusLabel(status: string): string { + const labels: Record = { + ready: '已就绪', + skipped: '已有结果', + in_progress: '任务进行中', + blocked: '前置条件不足', + pending: '等待提交', + queued: '排队中', + running: '生成中', + completed: '已完成', + failed: '失败', + cancelled: '已取消', + not_started: '未开始' + } + return labels[status] ?? status +} diff --git a/src/features/production/production.test.ts b/src/features/production/production.test.ts new file mode 100644 index 0000000..01b848f --- /dev/null +++ b/src/features/production/production.test.ts @@ -0,0 +1,132 @@ +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mediaAssetUrl } from '../../lib/assets' +import KeyframeDialog from './components/KeyframeDialog.vue' +import { productionApi } from './api' +import { + isActiveVideo, + issueLabel, + primaryKeyframe, + primaryVideo, + productionStatusLabel, + validOptionalSize +} from './model' +import { keyframeFixture, videoFixture } from './testing/fixtures' + +let wrapper: VueWrapper | undefined +afterEach(() => { + wrapper?.unmount() + wrapper = undefined + document.body.innerHTML = '' + vi.unstubAllGlobals() +}) + +/** 从 Reka Portal 中查找精确按钮。 */ +function button(label: string): HTMLButtonElement { + const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label) + if (!item) throw new Error(`缺少按钮 ${label}`) + return item +} + +describe('镜头生产数据契约', () => { + it('可选尺寸必须成对留空或填写正整数', () => { + expect(validOptionalSize('', '')).toBe(true) + expect(validOptionalSize(1920, 1080)).toBe(true) + expect(validOptionalSize(1920, '')).toBe(false) + expect(validOptionalSize('', 1080)).toBe(false) + expect(validOptionalSize(0, 1080)).toBe(false) + expect(validOptionalSize(10.5, 1080)).toBe(false) + }) + + it('主资产只接受已完成且具有地址的记录,活动视频覆盖三种状态', () => { + expect(primaryKeyframe([keyframeFixture()])?.id).toBe('keyframe-1') + expect(primaryKeyframe([keyframeFixture({ status: 'failed' })])).toBeUndefined() + expect(primaryVideo([videoFixture()])?.id).toBe('video-1') + expect(primaryVideo([videoFixture({ videoUrl: null })])).toBeUndefined() + for (const status of ['pending', 'queued', 'running'] as const) + expect(isActiveVideo(videoFixture({ status }))).toBe(true) + expect(isActiveVideo(videoFixture())).toBe(false) + }) + + it('就绪问题和异步任务状态提供中文标签', () => { + expect(issueLabel('missing_keyframe')).toBe('缺少主首帧') + expect(issueLabel('invalid_generation_spec')).toBe('生成规格不完整') + expect(productionStatusLabel('in_progress')).toBe('任务进行中') + expect(productionStatusLabel('unknown')).toBe('unknown') + }) + + it('视频地址与图片使用相同的安全协议限制', () => { + expect(mediaAssetUrl('/storage/videos/a.mp4')).toContain('/storage/videos/a.mp4') + expect(mediaAssetUrl('https://cdn.example.com/a.mp4')).toBe('https://cdn.example.com/a.mp4') + expect(mediaAssetUrl('javascript:alert(1)')).toBeNull() + expect(mediaAssetUrl('/storage/../admin')).toBeNull() + }) + + it('项目接口传递 force、Provider 与并发,视频创建不冒充同步完成', async () => { + const fetcher = vi.fn().mockImplementation(async url => { + const path = String(url) + if (path.includes('/readiness')) + return new Response( + JSON.stringify({ + data: { + total: 1, + ready: 1, + skipped: 0, + inProgress: 0, + blocked: 0, + missingPrompt: 0, + missingKeyframe: 0, + missingReference: 0, + items: [] + } + }) + ) + return new Response( + JSON.stringify({ + data: { + total: 1, + targetCount: 1, + created: 1, + skipped: 0, + readiness: {}, + failed: 0, + failures: [] + } + }) + ) + }) + vi.stubGlobal('fetch', fetcher) + await productionApi.videoReadiness('project/1', true) + await productionApi.generateVideos('project/1', { provider: 'seedance', concurrency: 3, force: true }) + expect(fetcher.mock.calls[0]?.[0]).toBe('/api/projects/project%2F1/videos/readiness?force=true') + expect(fetcher.mock.calls[1]?.[0]).toBe('/api/projects/project%2F1/videos/generate') + expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({ + provider: 'seedance', + concurrency: 3, + force: true + }) + }) + + it('首个首帧默认设主图,已有主图时默认只新增候选,并要求费用确认', async () => { + wrapper = mount(KeyframeDialog, { + attachTo: document.body, + props: { open: true, shotId: 'shot-1', shotTitle: '开场', keyframes: [], disabled: false } + }) + await flushPromises() + expect(button('确认生成首帧').disabled).toBe(true) + document.querySelector('#confirm-keyframe-cost')!.click() + await flushPromises() + button('确认生成首帧').click() + await flushPromises() + expect(wrapper.emitted('generate')).toEqual([[{ provider: 'seedream', setPrimary: true }]]) + + await wrapper.setProps({ open: false }) + await flushPromises() + await wrapper.setProps({ open: true, shotId: 'shot-2', keyframes: [keyframeFixture({ shotId: 'shot-2' })] }) + await flushPromises() + document.querySelector('#confirm-keyframe-cost')!.click() + await flushPromises() + button('确认生成首帧').click() + expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ provider: 'seedream', setPrimary: false }]) + }) +}) diff --git a/src/features/production/testing/fixtures.ts b/src/features/production/testing/fixtures.ts new file mode 100644 index 0000000..ff03238 --- /dev/null +++ b/src/features/production/testing/fixtures.ts @@ -0,0 +1,44 @@ +import type { ShotKeyframe, ShotVideo } from '../types' + +/** 首帧测试记录,覆盖主图、候选与失败状态时只需覆写关心字段。 */ +export function keyframeFixture(patch: Partial = {}): ShotKeyframe { + return { + id: 'keyframe-1', + shotId: 'shot-1', + source: 'generated', + provider: 'seedream', + model: 'seedream-test', + prompt: '首帧提示词', + imageUrl: '/storage/keyframes/keyframe-1.png', + width: 1920, + height: 1080, + status: 'completed', + isPrimary: true, + providerTaskId: null, + error: null, + createdAt: '2026-08-31T00:00:00.000Z', + updatedAt: '2026-08-31T00:00:00.000Z', + ...patch + } +} + +/** 视频测试记录,默认是已完成的主视频。 */ +export function videoFixture(patch: Partial = {}): ShotVideo { + return { + id: 'video-1', + shotId: 'shot-1', + provider: 'seedance', + model: 'seedance-test', + providerTaskId: 'provider-task-1', + prompt: '视频提示词', + negativePrompt: null, + status: 'completed', + videoUrl: '/storage/videos/video-1.mp4', + durationSeconds: 5, + isPrimary: true, + error: null, + createdAt: '2026-08-31T00:00:00.000Z', + updatedAt: '2026-08-31T00:00:00.000Z', + ...patch + } +} diff --git a/src/features/production/types.ts b/src/features/production/types.ts new file mode 100644 index 0000000..e06c5a9 --- /dev/null +++ b/src/features/production/types.ts @@ -0,0 +1,239 @@ +/** 项目生产链的就绪状态;进行中只会出现在视频阶段。 */ +export type ProductionReadinessStatus = 'ready' | 'skipped' | 'in_progress' | 'blocked' + +/** 三个阶段可能返回的正式问题代码。 */ +export type ProductionIssueCode = + | 'invalid_generation_spec' + | 'missing_visual_style' + | 'missing_reference' + | 'missing_prompt' + | 'missing_keyframe' + +/** 就绪检查中的具体阻塞原因;部分参考图问题会附带主体引用。 */ +export interface ProductionIssue { + code: ProductionIssueCode + reason: string + missingSubjects?: string[] +} + +/** 视频提示词就绪检查的逐镜结果。 */ +export interface PromptReadinessItem { + shotId: string + shotNo: number + status: Exclude + issues: ProductionIssue[] +} + +/** 视频提示词项目级就绪汇总。 */ +export interface PromptReadiness { + total: number + ready: number + skipped: number + blocked: number + invalidGenerationSpec: number + missingReference: number + items: PromptReadinessItem[] +} + +/** 镜头首帧就绪检查的逐镜结果。 */ +export interface KeyframeReadinessItem extends PromptReadinessItem { + primaryKeyframeId?: string | null +} + +/** 镜头首帧项目级就绪汇总。 */ +export interface KeyframeReadiness extends PromptReadiness { + missingVisualStyle: number + items: KeyframeReadinessItem[] +} + +/** 视频任务就绪检查的逐镜结果。 */ +export interface VideoReadinessItem { + shotId: string + shotNo: number + status: ProductionReadinessStatus + issues: ProductionIssue[] + primaryVideoId?: string | null + activeVideoId?: string | null +} + +/** 视频任务项目级就绪汇总。 */ +export interface VideoReadiness { + total: number + ready: number + skipped: number + inProgress: number + blocked: number + missingPrompt: number + missingKeyframe: number + missingReference: number + items: VideoReadinessItem[] +} + +/** 首帧生成记录;成功图片与失败历史都保留。 */ +export interface ShotKeyframe { + id: string + shotId: string + source: string + provider: string | null + model: string | null + prompt: string | null + imageUrl: string | null + width: number | null + height: number | null + status: 'pending' | 'generating' | 'completed' | 'failed' + isPrimary: boolean + providerTaskId: string | null + error: string | null + createdAt: string + updatedAt: string +} + +/** 视频生成记录;完成后地址指向后端持久化的视频资产。 */ +export interface ShotVideo { + id: string + shotId: string + provider: string + model: string + providerTaskId: string | null + prompt: string + negativePrompt: string | null + status: 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled' + videoUrl: string | null + durationSeconds: number | null + isPrimary: boolean + error: string | null + createdAt: string + updatedAt: string +} + +/** 首帧生成规格中的主体参考图。 */ +export interface KeyframeReference { + subjectId: string + subjectRef: string + module: string + subjectFormId: string + imageId: string + imageUrl: string + providerImageUrl?: string +} + +/** 后端确定性编译的首帧生成规格,不会在查询时调用图片模型。 */ +export interface KeyframeSpec { + projectId: string + shotId: string + episodeNo: number + beatNo: number + shotNo: number + prompt: string + references: KeyframeReference[] +} + +/** 视频生成规格中的主体参考图。 */ +export interface VideoReference extends KeyframeReference { + subjectName: string + subjectFormName: string +} + +/** 后端实际提交 Seedance 前编译的完整视频生成规格。 */ +export interface VideoGenerationSpec { + projectId: string + shotId: string + episodeNo: number + beatNo: number + shotNo: number + durationSeconds: number + videoPrompt: string + negativePrompt?: string + keyframe: { id: string; imageUrl: string; width?: number; height?: number } + references: VideoReference[] +} + +/** 项目首帧批量生成参数。 */ +export interface GenerateKeyframesInput { + provider: 'seedream' + concurrency: number + force: boolean + width?: number + height?: number +} + +/** 单镜头首帧生成参数。 */ +export interface GenerateKeyframeInput { + provider: 'seedream' + width?: number + height?: number + setPrimary: boolean +} + +/** 首帧批量生成回执;详细阻塞原因应配合 readiness 查看。 */ +export interface KeyframeBatchResult { + total: number + targetCount: number + generated: number + skipped: number + blocked: number + missingReference: number + failed: number + failures: { shotId: string; error: string }[] +} + +/** 项目视频批量生成参数。 */ +export interface GenerateVideosInput { + provider: 'seedance' + concurrency: number + force: boolean +} + +/** 视频批量任务创建回执;created 只表示已提交任务,不表示视频已完成。 */ +export interface VideoBatchResult { + total: number + targetCount: number + created: number + skipped: number + readiness: Omit + failed: number + failures: { shotId: string; error: string }[] +} + +/** 项目最近一次视频任务的逐镜状态。 */ +export interface ProjectVideoStatusItem { + shotId: string + shotNo: number + status: ShotVideo['status'] | 'not_started' + videoId: string | null + videoUrl: string | null + error: string | null +} + +/** 项目视频任务状态汇总;与 readiness 的前置条件统计不同。 */ +export interface ProjectVideoStatus { + total: number + completed: number + queued: number + running: number + failed: number + cancelled: number + pending: number + notStarted: number + items: ProjectVideoStatusItem[] +} + +/** 失败视频批量重试回执。 */ +export interface RetryVideosResult { + totalFailed: number + retried: number + failed: number + failures: { shotId: string; error: string }[] +} + +/** 页面回执按生成阶段区分,避免把已创建任务误认为已完成资产。 */ +export type ProductionReceipt = + | { kind: 'prompts'; title: string; result: import('../storyboard/types').PromptBatchResult } + | { kind: 'keyframes'; title: string; result: KeyframeBatchResult } + | { kind: 'videos'; title: string; result: VideoBatchResult } + | { kind: 'retry'; title: string; result: RetryVideosResult } + +/** 项目生产页的浏览器会话数据。 */ +export interface ProductionSession { + receipt: ProductionReceipt | null +} diff --git a/src/features/production/useProduction.ts b/src/features/production/useProduction.ts new file mode 100644 index 0000000..cc6569c --- /dev/null +++ b/src/features/production/useProduction.ts @@ -0,0 +1,143 @@ +import { computed, ref } from 'vue' +import { usePolling } from '../../composables/usePolling' +import { useProjectContext } from '../projects/context' +import { mergeDesignedShots } from '../storyboard/model' +import { storyboardApi } from '../storyboard/api' +import { breakdownSnapshot } from '../workflows/selectors' +import { getOperation, runOperation } from '../workflows/operations' +import { productionApi } from './api' +import { getProductionSession } from './model' + +/** 项目级生产动作;每次请求都由统一项目锁防止重复提交。 */ +export type ProductionCommand = 'prompts' | 'keyframes' | 'videos' | 'retry-videos' + +/** 镜头生产页的查询、筛选、就绪状态与批量操作。 */ +export function useProduction() { + const context = useProjectContext() + const id = computed(() => context.project.value?.id ?? '') + const selectedEpisode = ref() + const selectedShot = ref('') + const concurrency = ref(2) + const force = ref(false) + const snapshot = computed(() => breakdownSnapshot(context.checkpoints.value)) + const sourceEpisodes = computed( + () => snapshot.value?.storyboardEpisodeShots ?? snapshot.value?.breakdownResult?.storyboardEpisodeShots ?? [] + ) + const episodeOptions = computed(() => { + const entries = new Map(context.project.value?.episodes.map(episode => [episode.episode, episode.title]) ?? []) + for (const episode of sourceEpisodes.value) { + if (!entries.has(episode.episodeNo)) entries.set(episode.episodeNo, episode.episodePlan.episodeTitle) + } + return [...entries] + .map(([episodeNo, title]) => ({ episodeNo, title })) + .toSorted((a, b) => a.episodeNo - b.episodeNo) + }) + const episodeNo = computed( + () => + episodeOptions.value.find(item => item.episodeNo === selectedEpisode.value)?.episodeNo ?? + episodeOptions.value[0]?.episodeNo ?? + 0 + ) + const queryKey = computed(() => JSON.stringify([id.value, episodeNo.value, force.value])) + const query = usePolling(queryKey, async (key, signal) => { + const [projectId, number, overwrite] = JSON.parse(key) as [string, number, boolean] + if (!projectId || !number) return null + const [directions, prompts, keyframes, videos, videoStatus] = await Promise.all([ + storyboardApi.directions(projectId, number, signal), + productionApi.promptReadiness(projectId, overwrite, signal), + productionApi.keyframeReadiness(projectId, overwrite, signal), + productionApi.videoReadiness(projectId, overwrite, signal), + productionApi.projectVideoStatus(projectId, signal) + ]) + if (directions.projectId !== projectId || directions.episodeNo !== number) + throw new Error('镜头生产查询返回了不匹配的项目或剧集,请刷新后重试。') + return { directions, prompts, keyframes, videos, videoStatus } + }) + const source = computed(() => sourceEpisodes.value.find(item => item.episodeNo === episodeNo.value)) + const shots = computed(() => mergeDesignedShots(query.data.value?.directions ?? null, null, source.value)) + const shot = computed(() => shots.value.find(item => item.shotId === selectedShot.value) ?? shots.value[0]) + const promptItem = computed(() => query.data.value?.prompts.items.find(item => item.shotId === shot.value?.shotId)) + const keyframeItem = computed(() => + query.data.value?.keyframes.items.find(item => item.shotId === shot.value?.shotId) + ) + const videoItem = computed(() => query.data.value?.videos.items.find(item => item.shotId === shot.value?.shotId)) + const videoStatusItem = computed(() => + query.data.value?.videoStatus.items.find(item => item.shotId === shot.value?.shotId) + ) + const operation = computed(() => getOperation(id.value)) + const session = computed(() => getProductionSession(id.value)) + const batchValid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0) + const blocked = computed( + () => + operation.value.pending || + !!context.error.value || + !!query.error.value || + !query.data.value || + context.project.value?.status === 'generating' + ) + + /** 批量操作只向就绪镜头提交;最终资产状态由轮询查询确认。 */ + async function run(command: ProductionCommand) { + if (blocked.value || !batchValid.value) return + const projectId = id.value + const input = { concurrency: concurrency.value, force: force.value } + const target = getProductionSession(projectId) + target.receipt = null + const labels: Record = { + prompts: force.value ? '重生成项目视频提示词' : '补齐项目视频提示词', + keyframes: force.value ? '新增项目首帧候选' : '补齐项目主首帧', + videos: force.value ? '新增项目视频候选' : '提交就绪视频任务', + 'retry-videos': '重试失败视频任务' + } + await runOperation(projectId, labels[command], async () => { + if (command === 'prompts') { + target.receipt = { + kind: 'prompts', + title: labels[command], + result: await productionApi.generatePrompts(projectId, input) + } + } else if (command === 'keyframes') { + target.receipt = { + kind: 'keyframes', + title: labels[command], + result: await productionApi.generateKeyframes(projectId, { provider: 'seedream', ...input }) + } + } else if (command === 'videos') { + target.receipt = { + kind: 'videos', + title: labels[command], + result: await productionApi.generateVideos(projectId, { provider: 'seedance', ...input }) + } + } else { + target.receipt = { + kind: 'retry', + title: labels[command], + result: await productionApi.retryVideos(projectId, concurrency.value) + } + } + }) + await query.refresh() + } + + return { + id, + selectedEpisode, + selectedShot, + episodeNo, + episodeOptions, + shots, + shot, + promptItem, + keyframeItem, + videoItem, + videoStatusItem, + query, + operation, + session, + concurrency, + force, + batchValid, + blocked, + run + } +} diff --git a/src/features/projects/ProjectLayout.vue b/src/features/projects/ProjectLayout.vue index 3ca9e91..3df99e1 100644 --- a/src/features/projects/ProjectLayout.vue +++ b/src/features/projects/ProjectLayout.vue @@ -1,7 +1,18 @@