feat: 对接项目生成配置与单集成片

This commit is contained in:
GouJ
2026-09-21 10:03:50 +08:00
parent 1bbfd30627
commit 01f274cd8e
9 changed files with 722 additions and 8 deletions
+70
View File
@@ -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<GenerationModelCatalog>('/generation-models', { signal }),
profile: (projectId: string, signal?: AbortSignal) =>
request<CreativeProfile | null>(`${projectPath(projectId)}/creative-profile`, { signal }),
saveProfile: (projectId: string, input: CreativeProfileInput) =>
request<CreativeProfile>(`${projectPath(projectId)}/creative-profile`, { method: 'PUT', body: input }),
resolve: (
projectId: string,
mediaType: GenerationMediaType,
input: {
provider?: string
model?: string
options?: Record<string, unknown>
target?: { targetType: GenerationOverrideTargetType; targetId: string }
}
) =>
request<ResolvedGenerationConfig>(`${projectPath(projectId)}/generation-config/${mediaType}/resolve`, {
method: 'POST',
body: input
}),
override: (
projectId: string,
targetType: GenerationOverrideTargetType,
targetId: string,
mediaType: GenerationMediaType,
signal?: AbortSignal
) => request<GenerationOverride | null>(overridePath(projectId, targetType, targetId, mediaType), { signal }),
saveOverride: (
projectId: string,
targetType: GenerationOverrideTargetType,
targetId: string,
mediaType: GenerationMediaType,
input: GenerationOverrideInput
) =>
request<GenerationOverride>(overridePath(projectId, targetType, targetId, mediaType), {
method: 'PUT',
body: input
}),
deleteOverride: (
projectId: string,
targetType: GenerationOverrideTargetType,
targetId: string,
mediaType: GenerationMediaType
) => request<unknown>(overridePath(projectId, targetType, targetId, mediaType), { method: 'DELETE' })
}
@@ -0,0 +1,254 @@
<script setup lang="ts">
import { computed, onScopeDispose, ref, watch } from 'vue'
import { NAlert, NButton, NCollapse, NCollapseItem, NFormItem, NSelect, NSpin } from 'naive-ui'
import { errorMessage } from '../../../lib/http'
import { generationConfigApi } from '../api'
import type {
CreativeProfileAspectRatio,
CreativeProfileInput,
GenerationModelCatalog,
GenerationModelItem,
GenerationOptions
} from '../types'
import GenerationOptionFields from './GenerationOptionFields.vue'
const props = defineProps<{ projectId: string }>()
const emit = defineEmits<{ saved: [profile: CreativeProfileInput] }>()
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const notice = ref('')
const catalog = ref<GenerationModelCatalog | null>(null)
const profile = ref<CreativeProfileInput | null>(null)
let controller: AbortController | null = null
const aspectRatios = ['9:16', '16:9', '1:1', '4:3', '3:4'].map(value => ({ label: value, value }))
const enabledImages = computed(() => catalog.value?.images.filter(item => item.enabled && item.model) ?? [])
const enabledVideos = computed(() => catalog.value?.videos.filter(item => item.enabled && item.model) ?? [])
const imageSelection = computed(() =>
profile.value ? selectionValue(profile.value.imageProvider, profile.value.imageModel) : null
)
const videoSelection = computed(() =>
profile.value ? selectionValue(profile.value.videoProvider, profile.value.videoModel) : null
)
const imageModel = computed(() =>
enabledImages.value.find(item =>
profile.value ? item.provider === profile.value.imageProvider && item.model === profile.value.imageModel : false
)
)
const videoModel = computed(() =>
enabledVideos.value.find(item =>
profile.value ? item.provider === profile.value.videoProvider && item.model === profile.value.videoModel : false
)
)
function selectionValue(provider: string, model: string) {
return `${provider}\u0000${model}`
}
function modelOptions(items: GenerationModelItem[]) {
return items.map(item => ({
label: `${item.providerLabel} · ${item.modelLabel}`,
value: selectionValue(item.provider, item.model || '')
}))
}
function defaultOptions(item: GenerationModelItem): GenerationOptions {
return Object.fromEntries(
item.generationOptions.flatMap(schema => {
if (schema.providerDefault !== undefined) return [[schema.key, schema.providerDefault]]
if (!schema.required) return []
if (schema.type === 'boolean') return [[schema.key, false]]
if (schema.type === 'select' && schema.options?.length) {
const first = schema.options[0]
return first ? [[schema.key, first.value]] : []
}
if ((schema.type === 'integer' || schema.type === 'number') && schema.min !== undefined)
return [[schema.key, schema.min]]
if (schema.type === 'size') return [[schema.key, 'auto']]
return []
})
)
}
function selectModel(type: 'image' | 'video', value: string) {
if (!profile.value) return
const items = type === 'image' ? enabledImages.value : enabledVideos.value
const item = items.find(row => selectionValue(row.provider, row.model || '') === value)
if (!item?.model) return
if (type === 'image') {
profile.value.imageProvider = item.provider
profile.value.imageModel = item.model
profile.value.imageOptions = defaultOptions(item)
} else {
profile.value.videoProvider = item.provider
profile.value.videoModel = item.model
profile.value.videoOptions = defaultOptions(item)
}
}
function createInitial(nextCatalog: GenerationModelCatalog): CreativeProfileInput | null {
const image =
nextCatalog.images.find(item => item.enabled && item.isDefault && item.model) ??
nextCatalog.images.find(item => item.enabled && item.model)
const video =
nextCatalog.videos.find(item => item.enabled && item.isDefault && item.model) ??
nextCatalog.videos.find(item => item.enabled && item.model)
if (!image?.model || !video?.model) return null
return {
aspectRatio: '9:16',
imageProvider: image.provider,
imageModel: image.model,
imageOptions: defaultOptions(image),
videoProvider: video.provider,
videoModel: video.model,
videoOptions: defaultOptions(video)
}
}
async function load() {
controller?.abort()
controller = new AbortController()
loading.value = true
error.value = ''
notice.value = ''
try {
const [nextCatalog, saved] = await Promise.all([
generationConfigApi.catalog(controller.signal),
generationConfigApi.profile(props.projectId, controller.signal)
])
catalog.value = nextCatalog
profile.value = saved
? {
aspectRatio: saved.aspectRatio,
imageProvider: saved.imageProvider,
imageModel: saved.imageModel,
imageOptions: { ...saved.imageOptions },
videoProvider: saved.videoProvider,
videoModel: saved.videoModel,
videoOptions: { ...saved.videoOptions }
}
: createInitial(nextCatalog)
} catch (cause) {
if (!controller.signal.aborted) error.value = errorMessage(cause)
} finally {
if (!controller.signal.aborted) loading.value = false
}
}
async function save() {
if (!profile.value || saving.value) return
saving.value = true
error.value = ''
notice.value = ''
try {
const saved = await generationConfigApi.saveProfile(props.projectId, profile.value)
profile.value = {
aspectRatio: saved.aspectRatio,
imageProvider: saved.imageProvider,
imageModel: saved.imageModel,
imageOptions: { ...saved.imageOptions },
videoProvider: saved.videoProvider,
videoModel: saved.videoModel,
videoOptions: { ...saved.videoOptions }
}
notice.value = '项目生成配置已保存,后续首帧和视频生成会继承此配置。'
emit('saved', profile.value)
} catch (cause) {
error.value = errorMessage(cause)
} finally {
saving.value = false
}
}
watch(() => props.projectId, load, { immediate: true })
onScopeDispose(() => controller?.abort())
</script>
<template>
<NCollapse class="mt-4">
<NCollapseItem name="creative-profile" title="项目画布与生成模型">
<NSpin :show="loading">
<NAlert v-if="error" type="error" :show-icon="false" class="mb-3">{{ error }}</NAlert>
<NAlert v-if="notice" type="success" :show-icon="false" class="mb-3">{{ notice }}</NAlert>
<NAlert
v-if="!loading && (!enabledImages.length || !enabledVideos.length)"
type="warning"
:show-icon="false"
>
后端尚未启用完整的图片和视频模型暂时无法保存项目生成配置
</NAlert>
<div v-if="profile" class="creative-profile-grid">
<NFormItem label="作品宽高比">
<NSelect
:value="profile.aspectRatio"
:options="aspectRatios"
:disabled="saving"
@update:value="profile.aspectRatio = $event as CreativeProfileAspectRatio"
/>
</NFormItem>
<div></div>
<section class="model-section">
<NFormItem label="图片模型">
<NSelect
:value="imageSelection"
:options="modelOptions(enabledImages)"
:disabled="saving"
@update:value="selectModel('image', $event)"
/>
</NFormItem>
<GenerationOptionFields
v-model="profile.imageOptions"
:schemas="imageModel?.generationOptions || []"
:disabled="saving"
/>
</section>
<section class="model-section">
<NFormItem label="视频模型">
<NSelect
:value="videoSelection"
:options="modelOptions(enabledVideos)"
:disabled="saving"
@update:value="selectModel('video', $event)"
/>
</NFormItem>
<GenerationOptionFields
v-model="profile.videoOptions"
:schemas="videoModel?.generationOptions || []"
:disabled="saving"
/>
</section>
</div>
<div class="mt-3 flex items-center gap-3">
<NButton
type="primary"
:loading="saving"
:disabled="loading || !profile || !enabledImages.length || !enabledVideos.length"
@click="save"
>保存项目生成配置</NButton
>
<span class="text-xs text-muted">切换模型时会按新模型重置生成参数</span>
</div>
</NSpin>
</NCollapseItem>
</NCollapse>
</template>
<style scoped>
@reference "../../../styles/styles.css";
.creative-profile-grid {
@apply grid grid-cols-[repeat(2,_minmax(0,_1fr))] gap-x-5;
}
.model-section {
@apply min-w-0 p-4 bg-(--app-subtle);
}
@media (max-width: 900px) {
.creative-profile-grid {
@apply grid-cols-[minmax(0,_1fr)];
}
.creative-profile-grid > div:empty {
@apply hidden;
}
}
</style>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { NCheckbox, NFormItem, NInput, NInputNumber, NSelect } from 'naive-ui'
import type { GenerationOptionSchema, GenerationOptions } from '../types'
defineProps<{ schemas: GenerationOptionSchema[]; modelValue: GenerationOptions; disabled?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: GenerationOptions] }>()
function update(model: GenerationOptions, key: string, value: string | number | boolean | null) {
const next = { ...model }
if (value === null || value === '') delete next[key]
else next[key] = value
emit('update:modelValue', next)
}
function selectValue(value: string | number | boolean | undefined) {
return typeof value === 'string' || typeof value === 'number' ? value : null
}
function selectOptions(schema: GenerationOptionSchema) {
return [...(schema.options || []), ...(schema.specialValues || [])].flatMap(option =>
typeof option.value === 'string' || typeof option.value === 'number'
? [{ label: option.label, value: option.value }]
: []
)
}
</script>
<template>
<div v-if="schemas.length" class="generation-option-grid">
<NFormItem
v-for="schema in schemas"
:key="schema.key"
:label="`${schema.label}${schema.required ? ' *' : ''}`"
:feedback="schema.description"
:show-feedback="!!schema.description"
>
<NCheckbox
v-if="schema.type === 'boolean'"
:checked="modelValue[schema.key] === true"
:disabled="disabled"
@update:checked="update(modelValue, schema.key, $event)"
>启用</NCheckbox
>
<NSelect
v-else-if="schema.type === 'select'"
:value="selectValue(modelValue[schema.key])"
:options="selectOptions(schema)"
:disabled="disabled"
clearable
@update:value="update(modelValue, schema.key, $event)"
/>
<NInputNumber
v-else-if="schema.type === 'integer' || schema.type === 'number'"
:value="typeof modelValue[schema.key] === 'number' ? (modelValue[schema.key] as number) : null"
:min="schema.min"
:max="schema.max"
:precision="schema.type === 'integer' ? 0 : undefined"
:disabled="disabled"
clearable
@update:value="update(modelValue, schema.key, $event)"
/>
<NInput
v-else
:value="typeof modelValue[schema.key] === 'string' ? (modelValue[schema.key] as string) : ''"
:disabled="disabled"
placeholder="例如 2048x2048 或 auto"
clearable
@update:value="update(modelValue, schema.key, $event)"
/>
</NFormItem>
</div>
<p v-else class="text-xs text-muted">当前模型没有开放额外生成参数</p>
</template>
<style scoped>
@reference "../../../styles/styles.css";
.generation-option-grid {
@apply grid grid-cols-[repeat(2,_minmax(0,_1fr))] gap-x-4;
}
@media (max-width: 760px) {
.generation-option-grid {
@apply grid-cols-[minmax(0,_1fr)];
}
}
</style>
+80
View File
@@ -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<string, GenerationOptionValue>
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<string, unknown>
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
}
@@ -30,6 +30,8 @@ import { routeLocationKey } from 'vue-router'
import { queryText } from './asset-links' import { queryText } from './asset-links'
import QualityDialog from './components/QualityDialog.vue' import QualityDialog from './components/QualityDialog.vue'
import DetailDisclosure from '../../components/ui/DetailDisclosure.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) const qualityOpen = ref(false)
/** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */ /** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */
@@ -236,6 +238,7 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
<ArrowLeft :size="14" />回到分镜设计 <ArrowLeft :size="14" />回到分镜设计
</RouterLink> </RouterLink>
</div> </div>
<CreativeProfileSettings :project-id="id" />
<section class="mt-3" aria-label="项目生产配置"> <section class="mt-3" aria-label="项目生产配置">
<AppForm <AppForm
:model="batchModel" :model="batchModel"
@@ -527,6 +530,7 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
></NCollapse ></NCollapse
> >
</div> </div>
<EpisodeAssemblyPanel :project-id="id" />
<AdvancedProduction /> <AdvancedProduction />
</WorkspaceTools> </WorkspaceTools>
</div></div </div></div
+22 -5
View File
@@ -20,7 +20,9 @@ import type {
VideoQualityBatchResult, VideoQualityBatchResult,
VideoQualityConfig, VideoQualityConfig,
VideoGenerationSpec, VideoGenerationSpec,
VideoReadiness VideoReadiness,
ProjectAssemblyReadiness,
EpisodeAssemblyResult
} from './types' } from './types'
/** 项目路径统一编码,避免业务组件手工拼接 ID。 */ /** 项目路径统一编码,避免业务组件手工拼接 ID。 */
@@ -35,21 +37,36 @@ function shotPath(id: string) {
/** 生产链 API;生成请求不设置客户端短超时,也不自动重试。 */ /** 生产链 API;生成请求不设置客户端短超时,也不自动重试。 */
export const productionApi = { export const productionApi = {
/** 读取各集主视频是否满足顺序拼接条件,不运行 FFmpeg。 */
assemblyReadiness: (projectId: string, signal?: AbortSignal) =>
request<ProjectAssemblyReadiness>(`${projectPath(projectId)}/assembly/readiness`, { signal }),
/** 将单集有效主视频按分镜顺序拼为无声成片。 */
assembleEpisode: (episodeId: string) =>
request<EpisodeAssemblyResult>(`/episodes/${encodeURIComponent(episodeId)}/assembly/videos`, {
method: 'POST',
body: {},
timeoutMs: 0
}),
/** 根据显式指定的模型查询最终输入,不能用通用规格推断授权素材是否生效。 */ /** 根据显式指定的模型查询最终输入,不能用通用规格推断授权素材是否生效。 */
providerInputSpec: (shotId: string, provider: string, signal?: AbortSignal) => providerInputSpec: (shotId: string, provider: string, signal?: AbortSignal) =>
request<ProviderInputSpec>(`${shotPath(shotId)}/provider-input-spec?provider=${encodeURIComponent(provider)}`, { request<ProviderInputSpec>(`${shotPath(shotId)}/provider-input-spec?provider=${encodeURIComponent(provider)}`, {
signal signal
}), }),
/** 查询当前计划,不生成图片、视频或提示词。 */ /** 查询当前计划,不生成图片、视频或提示词。 */
plan: (projectId: string, signal?: AbortSignal) => plan: (projectId: string, signal?: AbortSignal, providers?: { imageProvider?: string; videoProvider?: string }) => {
request<ProductionPlan>(`${projectPath(projectId)}/production/plan`, { signal }), 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<ProductionPlan>(`${projectPath(projectId)}/production/plan${query}`, { signal })
},
/** 读取真实主资产完成状态,不能用任务提交回执代替。 */ /** 读取真实主资产完成状态,不能用任务提交回执代替。 */
status: (projectId: string, signal?: AbortSignal) => status: (projectId: string, signal?: AbortSignal) =>
request<ProjectProductionStatus>(`${projectPath(projectId)}/production/status`, { signal }), request<ProjectProductionStatus>(`${projectPath(projectId)}/production/status`, { signal }),
startPipeline: (projectId: string) => startPipeline: (projectId: string, providers?: { imageProvider?: string; videoProvider?: string }) =>
request<ProductionPipelineResult>(`${projectPath(projectId)}/production/start`, { request<ProductionPipelineResult>(`${projectPath(projectId)}/production/start`, {
method: 'POST', method: 'POST',
body: {}, body: providers ?? {},
timeoutMs: 0 timeoutMs: 0
}), }),
promptReadiness: (projectId: string, force: boolean, signal?: AbortSignal) => promptReadiness: (projectId: string, force: boolean, signal?: AbortSignal) =>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { onScopeDispose, ref, watch } from 'vue'
import { NAlert, NButton, NCollapse, NCollapseItem, NProgress, NSpin } from 'naive-ui'
import { mediaAssetUrl } from '../../../lib/assets'
import { errorMessage } from '../../../lib/http'
import { productionApi } from '../api'
import type { EpisodeAssemblyResult, ProjectAssemblyReadiness } from '../types'
const props = defineProps<{ projectId: string }>()
const readiness = ref<ProjectAssemblyReadiness | null>(null)
const loading = ref(false)
const assembling = ref('')
const error = ref('')
const results = ref<Record<string, EpisodeAssemblyResult>>({})
let controller: AbortController | null = null
async function refresh() {
controller?.abort()
controller = new AbortController()
loading.value = true
error.value = ''
try {
readiness.value = await productionApi.assemblyReadiness(props.projectId, controller.signal)
} catch (cause) {
if (!controller.signal.aborted) error.value = errorMessage(cause)
} finally {
if (!controller.signal.aborted) loading.value = false
}
}
async function assemble(episodeId: string) {
if (assembling.value) return
assembling.value = episodeId
error.value = ''
try {
const result = await productionApi.assembleEpisode(episodeId)
results.value = { ...results.value, [episodeId]: result }
await refresh()
} catch (cause) {
error.value = errorMessage(cause)
} finally {
assembling.value = ''
}
}
function resultUrl(episodeId: string) {
const result = results.value[episodeId]
return result ? mediaAssetUrl(result.videoUrl) : null
}
watch(
() => props.projectId,
() => {
results.value = {}
void refresh()
},
{ immediate: true }
)
onScopeDispose(() => controller?.abort())
</script>
<template>
<NCollapse class="mt-4">
<NCollapseItem name="episode-assembly" title="单集成片拼接">
<NSpin :show="loading">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-xs text-muted">
Beat Shot 顺序拼接当前有效主视频V1 只输出无声视频不包含对白配乐字幕和转场
</p>
<NButton size="small" :disabled="loading" @click="refresh">刷新就绪状态</NButton>
</div>
<NAlert v-if="error" type="error" :show-icon="false" class="mt-3">{{ error }}</NAlert>
<p v-if="readiness" class="mt-3 text-xs">
已就绪 {{ readiness.ready }}/{{ readiness.total }} · 阻塞 {{ readiness.blocked }}
</p>
<div v-if="readiness?.items.length" class="assembly-list mt-3">
<article v-for="item in readiness.items" :key="item.episodeId" class="assembly-row">
<div class="min-w-0">
<h4 class="truncate text-sm font-semibold">
{{ item.episodeNo }} · {{ item.title }}
</h4>
<p class="mt-1 text-xs text-muted">
主视频 {{ item.readyShots }}/{{ item.totalShots }} · {{ item.totalDurationSeconds }}
</p>
<NProgress
class="mt-2"
type="line"
:show-indicator="false"
:percentage="
item.totalShots ? Math.round((item.readyShots / item.totalShots) * 100) : 0
"
:status="item.ready ? 'success' : 'default'"
/>
<p
v-for="(issue, index) in item.issues.slice(0, 3)"
:key="index"
class="mt-1 text-xs text-muted"
>
<span v-if="issue.shotNo">镜头 {{ issue.shotNo }}</span>{{ issue.reason }}
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<NButton
size="small"
:disabled="!item.ready || !!assembling"
:loading="assembling === item.episodeId"
@click="assemble(item.episodeId)"
>生成无声成片</NButton
>
<NButton
v-if="resultUrl(item.episodeId)"
tag="a"
size="small"
:href="resultUrl(item.episodeId) || undefined"
target="_blank"
rel="noopener"
>查看成片</NButton
>
</div>
</article>
</div>
<p v-else-if="readiness && !loading" class="mt-3 text-xs text-muted">当前项目还没有可拼接的剧集</p>
</NSpin>
</NCollapseItem>
</NCollapse>
</template>
<style scoped>
@reference "../../../styles/styles.css";
.assembly-list {
@apply grid gap-2;
}
.assembly-row {
@apply grid grid-cols-[minmax(0,_1fr)_auto] items-center gap-4 p-4 bg-(--app-subtle);
}
@media (max-width: 760px) {
.assembly-row {
@apply grid-cols-[minmax(0,_1fr)];
}
}
</style>
+41
View File
@@ -455,3 +455,44 @@ export interface ProviderInputSpec {
providerAsset: { provider: string; assetId: string } | null 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
}
@@ -6,16 +6,21 @@ import { hasRunningImages } from '../subject-images/model'
import { getOperation, runOperation } from '../workflows/operations' import { getOperation, runOperation } from '../workflows/operations'
import { hasRunningWorkflow } from '../workflows/selectors' import { hasRunningWorkflow } from '../workflows/selectors'
import { errorMessage } from '../../lib/http' import { errorMessage } from '../../lib/http'
import { generationConfigApi } from '../generation-config/api'
import { productionApi } from './api' import { productionApi } from './api'
import { getProductionSession } from './model' import { getProductionSession } from './model'
/** 使用后端计划区分可自动补齐和人工阻塞,保留项目归属及活动任务检查。 */ /** 使用后端计划区分可自动补齐和人工阻塞,保留项目归属及活动任务检查。 */
export async function checkPipeline(projectId: string) { 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([ const [project, checkpoints, forms, plan, status] = await Promise.all([
projectsApi.detail(projectId), projectsApi.detail(projectId),
projectsApi.checkpoints(projectId), projectsApi.checkpoints(projectId),
subjectImagesApi.listForms(projectId), subjectImagesApi.listForms(projectId),
productionApi.plan(projectId), productionApi.plan(projectId, undefined, providers),
productionApi.status(projectId) productionApi.status(projectId)
]) ])
if ( if (
@@ -26,6 +31,7 @@ export async function checkPipeline(projectId: string) {
) )
throw new Error('预检返回了其他项目的数据,请重新读取。') throw new Error('预检返回了其他项目的数据,请重新读取。')
const issues: string[] = [] const issues: string[] = []
if (!profile) issues.push('尚未配置项目画布与生成模型,请先在批量生产工具中保存配置。')
const shotIds = new Set(plan.details.keyframes.map(item => item.shotId)) const shotIds = new Set(plan.details.keyframes.map(item => item.shotId))
if ( if (
shotIds.size !== plan.keyframes.total || 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)) else if (![plan.subjectImages, plan.keyframes, plan.videoPrompts, plan.videos].some(stage => stage.planned > 0))
issues.push('当前没有可执行的制作任务,请先处理计划中的阻塞项。') issues.push('当前没有可执行的制作任务,请先处理计划中的阻塞项。')
// 人工阻塞在计划中独立展示;不拦截其余 ready 形态的部分成功生产。 // 人工阻塞在计划中独立展示;不拦截其余 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('提交前预检发现条件变化,未启动生产。请处理下方问题。') if (result.issues.length) throw new Error('提交前预检发现条件变化,未启动生产。请处理下方问题。')
target.pipelineReceipt = null target.pipelineReceipt = null
submitted = true 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) if (!receipt || receipt.projectId !== projectId)
throw new Error('回执项目不匹配,请读取资产状态核对,不要直接重试。') throw new Error('回执项目不匹配,请读取资产状态核对,不要直接重试。')
target.pipelineReceipt = receipt target.pipelineReceipt = receipt