diff --git a/src/features/generation-config/api.ts b/src/features/generation-config/api.ts new file mode 100644 index 0000000..fd39894 --- /dev/null +++ b/src/features/generation-config/api.ts @@ -0,0 +1,70 @@ +import { request } from '../../lib/http' +import type { + CreativeProfile, + CreativeProfileInput, + GenerationMediaType, + GenerationModelCatalog, + GenerationOverride, + GenerationOverrideInput, + GenerationOverrideTargetType, + ResolvedGenerationConfig +} from './types' + +function projectPath(projectId: string) { + return `/projects/${encodeURIComponent(projectId)}` +} + +function overridePath( + projectId: string, + targetType: GenerationOverrideTargetType, + targetId: string, + mediaType: GenerationMediaType +) { + return `${projectPath(projectId)}/generation-overrides/${targetType}/${encodeURIComponent(targetId)}/${mediaType}` +} + +export const generationConfigApi = { + catalog: (signal?: AbortSignal) => request('/generation-models', { signal }), + profile: (projectId: string, signal?: AbortSignal) => + request(`${projectPath(projectId)}/creative-profile`, { signal }), + saveProfile: (projectId: string, input: CreativeProfileInput) => + request(`${projectPath(projectId)}/creative-profile`, { method: 'PUT', body: input }), + resolve: ( + projectId: string, + mediaType: GenerationMediaType, + input: { + provider?: string + model?: string + options?: Record + target?: { targetType: GenerationOverrideTargetType; targetId: string } + } + ) => + request(`${projectPath(projectId)}/generation-config/${mediaType}/resolve`, { + method: 'POST', + body: input + }), + override: ( + projectId: string, + targetType: GenerationOverrideTargetType, + targetId: string, + mediaType: GenerationMediaType, + signal?: AbortSignal + ) => request(overridePath(projectId, targetType, targetId, mediaType), { signal }), + saveOverride: ( + projectId: string, + targetType: GenerationOverrideTargetType, + targetId: string, + mediaType: GenerationMediaType, + input: GenerationOverrideInput + ) => + request(overridePath(projectId, targetType, targetId, mediaType), { + method: 'PUT', + body: input + }), + deleteOverride: ( + projectId: string, + targetType: GenerationOverrideTargetType, + targetId: string, + mediaType: GenerationMediaType + ) => request(overridePath(projectId, targetType, targetId, mediaType), { method: 'DELETE' }) +} diff --git a/src/features/generation-config/components/CreativeProfileSettings.vue b/src/features/generation-config/components/CreativeProfileSettings.vue new file mode 100644 index 0000000..9b33deb --- /dev/null +++ b/src/features/generation-config/components/CreativeProfileSettings.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/src/features/generation-config/components/GenerationOptionFields.vue b/src/features/generation-config/components/GenerationOptionFields.vue new file mode 100644 index 0000000..589e24b --- /dev/null +++ b/src/features/generation-config/components/GenerationOptionFields.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/src/features/generation-config/types.ts b/src/features/generation-config/types.ts new file mode 100644 index 0000000..35e47cd --- /dev/null +++ b/src/features/generation-config/types.ts @@ -0,0 +1,80 @@ +export type CreativeProfileAspectRatio = '9:16' | '16:9' | '1:1' | '4:3' | '3:4' + +export type GenerationOptionValue = string | number | boolean +export type GenerationOptions = Record + +export interface GenerationOptionChoice { + label: string + value: GenerationOptionValue +} + +export interface GenerationOptionSchema { + key: string + label: string + type: 'boolean' | 'integer' | 'number' | 'select' | 'size' + required: boolean + options?: GenerationOptionChoice[] + min?: number + max?: number + specialValues?: GenerationOptionChoice[] + providerDefault?: GenerationOptionValue + description?: string +} + +export interface GenerationModelItem { + type: 'image' | 'video' + provider: string + providerLabel: string + model: string | null + modelLabel: string + supported: boolean + enabled: boolean + isDefault: boolean + capabilities: Record + generationOptions: GenerationOptionSchema[] +} + +export interface GenerationModelCatalog { + defaults: { imageProvider: string; videoProvider: string } + images: GenerationModelItem[] + videos: GenerationModelItem[] +} + +export interface CreativeProfileInput { + aspectRatio: CreativeProfileAspectRatio + imageProvider: string + imageModel: string + imageOptions: GenerationOptions + videoProvider: string + videoModel: string + videoOptions: GenerationOptions +} + +export interface CreativeProfile extends CreativeProfileInput { + id?: string + projectId: string + createdAt?: string + updatedAt?: string +} + +export interface ResolvedGenerationConfig { + provider: string + model: string + options: GenerationOptions +} + +export type GenerationOverrideTargetType = 'shot' | 'asset' +export type GenerationMediaType = 'image' | 'video' + +export interface GenerationOverrideInput { + provider?: string | null + model?: string | null + options: GenerationOptions +} + +export interface GenerationOverride extends GenerationOverrideInput { + projectId: string + targetType: GenerationOverrideTargetType + targetId: string + mediaType: GenerationMediaType +} diff --git a/src/features/production/ProductionPage.vue b/src/features/production/ProductionPage.vue index f4e797d..3827fdf 100644 --- a/src/features/production/ProductionPage.vue +++ b/src/features/production/ProductionPage.vue @@ -30,6 +30,8 @@ import { routeLocationKey } from 'vue-router' import { queryText } from './asset-links' import QualityDialog from './components/QualityDialog.vue' import DetailDisclosure from '../../components/ui/DetailDisclosure.vue' +import CreativeProfileSettings from '../generation-config/components/CreativeProfileSettings.vue' +import EpisodeAssemblyPanel from './components/EpisodeAssemblyPanel.vue' const qualityOpen = ref(false) /** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */ @@ -236,6 +238,7 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA 回到分镜设计 +
+ + request(`${projectPath(projectId)}/assembly/readiness`, { signal }), + /** 将单集有效主视频按分镜顺序拼为无声成片。 */ + assembleEpisode: (episodeId: string) => + request(`/episodes/${encodeURIComponent(episodeId)}/assembly/videos`, { + method: 'POST', + body: {}, + timeoutMs: 0 + }), /** 根据显式指定的模型查询最终输入,不能用通用规格推断授权素材是否生效。 */ providerInputSpec: (shotId: string, provider: string, signal?: AbortSignal) => request(`${shotPath(shotId)}/provider-input-spec?provider=${encodeURIComponent(provider)}`, { signal }), /** 查询当前计划,不生成图片、视频或提示词。 */ - plan: (projectId: string, signal?: AbortSignal) => - request(`${projectPath(projectId)}/production/plan`, { signal }), + plan: (projectId: string, signal?: AbortSignal, providers?: { imageProvider?: string; videoProvider?: string }) => { + const params = new URLSearchParams() + if (providers?.imageProvider) params.set('imageProvider', providers.imageProvider) + if (providers?.videoProvider) params.set('videoProvider', providers.videoProvider) + const query = params.size ? `?${params}` : '' + return request(`${projectPath(projectId)}/production/plan${query}`, { signal }) + }, /** 读取真实主资产完成状态,不能用任务提交回执代替。 */ status: (projectId: string, signal?: AbortSignal) => request(`${projectPath(projectId)}/production/status`, { signal }), - startPipeline: (projectId: string) => + startPipeline: (projectId: string, providers?: { imageProvider?: string; videoProvider?: string }) => request(`${projectPath(projectId)}/production/start`, { method: 'POST', - body: {}, + body: providers ?? {}, timeoutMs: 0 }), promptReadiness: (projectId: string, force: boolean, signal?: AbortSignal) => diff --git a/src/features/production/components/EpisodeAssemblyPanel.vue b/src/features/production/components/EpisodeAssemblyPanel.vue new file mode 100644 index 0000000..30152d7 --- /dev/null +++ b/src/features/production/components/EpisodeAssemblyPanel.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/src/features/production/types.ts b/src/features/production/types.ts index 9491fb9..ec5da29 100644 --- a/src/features/production/types.ts +++ b/src/features/production/types.ts @@ -455,3 +455,44 @@ export interface ProviderInputSpec { providerAsset: { provider: string; assetId: string } | null })[] } + +/** 单集成片拼接就绪问题。 */ +export interface EpisodeAssemblyIssue { + shotId?: string + beatNo?: number + shotNo?: number + reason: string +} + +/** 项目中单集的成片拼接就绪状态。 */ +export interface EpisodeAssemblyReadinessItem { + episodeId: string + episodeNo: number + title: string + ready: boolean + totalShots: number + readyShots: number + totalDurationSeconds: number + issues: EpisodeAssemblyIssue[] +} + +/** 项目成片拼接就绪汇总;查询不会运行 FFmpeg。 */ +export interface ProjectAssemblyReadiness { + projectId: string + total: number + ready: number + blocked: number + complete: boolean + items: EpisodeAssemblyReadinessItem[] +} + +/** 单集拼接成功回执;V1 产物为无声视频。 */ +export interface EpisodeAssemblyResult { + projectId: string + episodeId: string + episodeNo: number + title: string + shotCount: number + durationSeconds: number + videoUrl: string +} diff --git a/src/features/production/useAdvancedProduction.ts b/src/features/production/useAdvancedProduction.ts index 0e73791..23afc09 100644 --- a/src/features/production/useAdvancedProduction.ts +++ b/src/features/production/useAdvancedProduction.ts @@ -6,16 +6,21 @@ import { hasRunningImages } from '../subject-images/model' import { getOperation, runOperation } from '../workflows/operations' import { hasRunningWorkflow } from '../workflows/selectors' import { errorMessage } from '../../lib/http' +import { generationConfigApi } from '../generation-config/api' import { productionApi } from './api' import { getProductionSession } from './model' /** 使用后端计划区分可自动补齐和人工阻塞,保留项目归属及活动任务检查。 */ export async function checkPipeline(projectId: string) { + const profile = await generationConfigApi.profile(projectId) + const providers = profile + ? { imageProvider: profile.imageProvider, videoProvider: profile.videoProvider } + : undefined const [project, checkpoints, forms, plan, status] = await Promise.all([ projectsApi.detail(projectId), projectsApi.checkpoints(projectId), subjectImagesApi.listForms(projectId), - productionApi.plan(projectId), + productionApi.plan(projectId, undefined, providers), productionApi.status(projectId) ]) if ( @@ -26,6 +31,7 @@ export async function checkPipeline(projectId: string) { ) throw new Error('预检返回了其他项目的数据,请重新读取。') const issues: string[] = [] + if (!profile) issues.push('尚未配置项目画布与生成模型,请先在批量生产工具中保存配置。') const shotIds = new Set(plan.details.keyframes.map(item => item.shotId)) if ( shotIds.size !== plan.keyframes.total || @@ -54,7 +60,15 @@ export async function checkPipeline(projectId: string) { else if (![plan.subjectImages, plan.keyframes, plan.videoPrompts, plan.videos].some(stage => stage.planned > 0)) issues.push('当前没有可执行的制作任务,请先处理计划中的阻塞项。') // 人工阻塞在计划中独立展示;不拦截其余 ready 形态的部分成功生产。 - return { projectId, issues, plan, status, shotCount: plan.keyframes.total, checkedAt: new Date().toISOString() } + return { + projectId, + issues, + plan, + status, + profile, + shotCount: plan.keyframes.total, + checkedAt: new Date().toISOString() + } } /** 预检不付费;确认后再次预检并锁住本浏览器项目,长请求不自动重发。 */ @@ -128,7 +142,15 @@ export function useAdvancedProduction() { if (result.issues.length) throw new Error('提交前预检发现条件变化,未启动生产。请处理下方问题。') target.pipelineReceipt = null submitted = true - const receipt = await productionApi.startPipeline(projectId) + const receipt = await productionApi.startPipeline( + projectId, + result.profile + ? { + imageProvider: result.profile.imageProvider, + videoProvider: result.profile.videoProvider + } + : undefined + ) if (!receipt || receipt.projectId !== projectId) throw new Error('回执项目不匹配,请读取资产状态核对,不要直接重试。') target.pipelineReceipt = receipt