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
@@ -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
<ArrowLeft :size="14" />回到分镜设计
</RouterLink>
</div>
<CreativeProfileSettings :project-id="id" />
<section class="mt-3" aria-label="项目生产配置">
<AppForm
:model="batchModel"
@@ -527,6 +530,7 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
></NCollapse
>
</div>
<EpisodeAssemblyPanel :project-id="id" />
<AdvancedProduction />
</WorkspaceTools>
</div></div
+22 -5
View File
@@ -20,7 +20,9 @@ import type {
VideoQualityBatchResult,
VideoQualityConfig,
VideoGenerationSpec,
VideoReadiness
VideoReadiness,
ProjectAssemblyReadiness,
EpisodeAssemblyResult
} from './types'
/** 项目路径统一编码,避免业务组件手工拼接 ID。 */
@@ -35,21 +37,36 @@ function shotPath(id: string) {
/** 生产链 API;生成请求不设置客户端短超时,也不自动重试。 */
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) =>
request<ProviderInputSpec>(`${shotPath(shotId)}/provider-input-spec?provider=${encodeURIComponent(provider)}`, {
signal
}),
/** 查询当前计划,不生成图片、视频或提示词。 */
plan: (projectId: string, signal?: AbortSignal) =>
request<ProductionPlan>(`${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<ProductionPlan>(`${projectPath(projectId)}/production/plan${query}`, { signal })
},
/** 读取真实主资产完成状态,不能用任务提交回执代替。 */
status: (projectId: string, signal?: AbortSignal) =>
request<ProjectProductionStatus>(`${projectPath(projectId)}/production/status`, { signal }),
startPipeline: (projectId: string) =>
startPipeline: (projectId: string, providers?: { imageProvider?: string; videoProvider?: string }) =>
request<ProductionPipelineResult>(`${projectPath(projectId)}/production/start`, {
method: 'POST',
body: {},
body: providers ?? {},
timeoutMs: 0
}),
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
})[]
}
/** 单集成片拼接就绪问题。 */
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 { 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