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 } }