feat: 对接项目生成配置与单集成片
This commit is contained in:
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user