144 lines
6.6 KiB
TypeScript
144 lines
6.6 KiB
TypeScript
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<number>()
|
|
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<ProductionCommand, string> = {
|
|
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
|
|
}
|
|
}
|