feat: 优化生产面板并增加图片网格加载
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NImage } from 'naive-ui'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ImageOff } from '@lucide/vue'
|
||||
import { referenceImageUrl } from '../../lib/assets'
|
||||
import RevealImage from './RevealImage.vue'
|
||||
|
||||
/** 图片统一铺满容器;原图预览按场景开启,历史选择按钮不触发预览。 */
|
||||
const props = withDefaults(
|
||||
@@ -28,16 +29,12 @@ watch(
|
||||
|
||||
<template>
|
||||
<div class="asset-image">
|
||||
<NImage
|
||||
<RevealImage
|
||||
v-if="url && !failed"
|
||||
:src="url"
|
||||
:preview-src="url"
|
||||
:alt="alt"
|
||||
lazy
|
||||
:preview-disabled="!preview"
|
||||
:preview="preview"
|
||||
:object-fit="objectFit"
|
||||
:img-props="{ referrerpolicy: 'no-referrer' }"
|
||||
:previewed-img-props="{ alt, referrerpolicy: 'no-referrer' }"
|
||||
@error="failed = true"
|
||||
/>
|
||||
<span v-else class="asset-image-empty"
|
||||
@@ -63,6 +60,7 @@ watch(
|
||||
.asset-image-empty {
|
||||
@apply flex items-center justify-center flex-col gap-2.5 p-5 text-muted text-[11px] text-center;
|
||||
}
|
||||
.asset-image .reveal-image,
|
||||
.asset-image .n-image {
|
||||
@apply w-full h-full min-h-0 min-w-0 flex justify-center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { NImage } from 'naive-ui'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
/**
|
||||
* 全局图片加载动效:借鉴 Grid Reveal 的网格落图方式,但只使用 16 个 CSS 单元,
|
||||
* 避免列表中的每张缩略图都创建 Canvas、采样像素并持续运行 requestAnimationFrame。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
src: string
|
||||
alt: string
|
||||
objectFit?: 'contain' | 'cover'
|
||||
preview?: boolean
|
||||
lazy?: boolean
|
||||
}>(),
|
||||
{ objectFit: 'cover', preview: false, lazy: true }
|
||||
)
|
||||
const emit = defineEmits<{ error: [event: Event] }>()
|
||||
const loaded = ref(false)
|
||||
const settled = ref(false)
|
||||
let settleTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
/** 让相邻方格错开退出,视觉上从中心和高关注区域向外展开。 */
|
||||
const cells = computed(() => {
|
||||
const order = [12, 8, 4, 13, 3, 0, 1, 9, 11, 2, 5, 14, 10, 6, 7, 15]
|
||||
return Array.from({ length: 16 }, (_, index) => ({
|
||||
index,
|
||||
order: order[index] ?? index,
|
||||
column: index % 4,
|
||||
row: Math.floor(index / 4)
|
||||
}))
|
||||
})
|
||||
|
||||
function reset() {
|
||||
clearTimeout(settleTimer)
|
||||
loaded.value = false
|
||||
settled.value = false
|
||||
}
|
||||
|
||||
function reveal() {
|
||||
requestAnimationFrame(() => {
|
||||
loaded.value = true
|
||||
settleTimer = setTimeout(() => {
|
||||
settled.value = true
|
||||
}, 760)
|
||||
})
|
||||
}
|
||||
|
||||
function fail(event: Event) {
|
||||
clearTimeout(settleTimer)
|
||||
emit('error', event)
|
||||
}
|
||||
|
||||
function cellStyle(cell: (typeof cells.value)[number]) {
|
||||
return {
|
||||
'--reveal-order': cell.order,
|
||||
backgroundImage: loaded.value ? `url(${JSON.stringify(props.src)})` : undefined,
|
||||
backgroundPosition: `${(cell.column / 3) * 100}% ${(cell.row / 3) * 100}%`
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.src, reset)
|
||||
onBeforeUnmount(() => clearTimeout(settleTimer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="reveal-image" :class="{ 'is-loaded': loaded, 'is-settled': settled }">
|
||||
<NImage
|
||||
:src="src"
|
||||
:preview-src="src"
|
||||
:alt="alt"
|
||||
:lazy="lazy"
|
||||
:preview-disabled="!preview"
|
||||
:object-fit="objectFit"
|
||||
:img-props="{ referrerpolicy: 'no-referrer' }"
|
||||
:previewed-img-props="{ alt, referrerpolicy: 'no-referrer' }"
|
||||
@load="reveal"
|
||||
@error="fail"
|
||||
/>
|
||||
<span v-if="!settled" class="reveal-image-grid" aria-hidden="true">
|
||||
<span v-for="cell in cells" :key="cell.index" class="reveal-image-cell" :style="cellStyle(cell)" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@reference "../../styles/styles.css";
|
||||
.reveal-image {
|
||||
@apply relative block w-full h-full min-w-0 min-h-0 overflow-hidden bg-(--app-subtle);
|
||||
}
|
||||
.reveal-image > .n-image,
|
||||
.reveal-image > .n-image img {
|
||||
@apply w-full h-full min-w-0 min-h-0;
|
||||
}
|
||||
.reveal-image > .n-image img {
|
||||
opacity: 0;
|
||||
filter: blur(9px);
|
||||
transform: scale(1.025);
|
||||
transition:
|
||||
opacity 280ms ease,
|
||||
filter 620ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 620ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.reveal-image.is-loaded > .n-image img {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: scale(1);
|
||||
}
|
||||
.reveal-image-grid {
|
||||
@apply absolute inset-0 grid grid-cols-4 grid-rows-4 pointer-events-none;
|
||||
gap: 1px;
|
||||
background: var(--app-body);
|
||||
}
|
||||
.reveal-image-cell {
|
||||
background-color: var(--app-control);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 400% 400%;
|
||||
opacity: 1;
|
||||
transform: scale(1.01);
|
||||
transition:
|
||||
opacity 420ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 520ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transition-delay: calc(var(--reveal-order) * 18ms);
|
||||
}
|
||||
.reveal-image:not(.is-loaded) .reveal-image-cell {
|
||||
animation: reveal-image-wait 1.8s ease-in-out infinite alternate;
|
||||
animation-delay: calc(var(--reveal-order) * -55ms);
|
||||
}
|
||||
.reveal-image.is-loaded .reveal-image-cell {
|
||||
opacity: 0;
|
||||
transform: scale(0.88);
|
||||
}
|
||||
@keyframes reveal-image-wait {
|
||||
from {
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
to {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal-image > .n-image img,
|
||||
.reveal-image-cell {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,5 +3,6 @@ export { default as AppDialog } from './AppDialog.vue'
|
||||
export { default as EmptyState } from './EmptyState.vue'
|
||||
export { default as StatusBadge } from './StatusBadge.vue'
|
||||
export { default as AssetImage } from './AssetImage.vue'
|
||||
export { default as RevealImage } from './RevealImage.vue'
|
||||
export { default as DirectoryItem } from './DirectoryItem.vue'
|
||||
export { default as WorkspaceTools } from './WorkspaceTools.vue'
|
||||
|
||||
@@ -167,7 +167,7 @@ onScopeDispose(() => controller?.abort())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCollapse class="mt-4">
|
||||
<NCollapse class="production-tool-section">
|
||||
<NCollapseItem name="creative-profile" title="项目画布与生成模型">
|
||||
<NSpin :show="loading">
|
||||
<NAlert v-if="error" type="error" :show-icon="false" class="mb-3">{{ error }}</NAlert>
|
||||
|
||||
@@ -227,8 +227,9 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
label="批量生产"
|
||||
:has-receipt="!!session.receipt || !!session.pipelineReceipt"
|
||||
>
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div class="production-tools-intro">
|
||||
<div>
|
||||
<p class="eyebrow">PRODUCTION</p>
|
||||
<h2 class="text-lg font-semibold">镜头生产</h2>
|
||||
<p class="mt-2 text-sm text-muted">
|
||||
从可生成的提示词和首帧,到异步视频任务与最终成片。
|
||||
@@ -238,8 +239,13 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
<ArrowLeft :size="14" />回到分镜设计
|
||||
</RouterLink>
|
||||
</div>
|
||||
<CreativeProfileSettings :project-id="id" />
|
||||
<section class="mt-3" aria-label="项目生产配置">
|
||||
<section class="production-batch-config" aria-label="项目生产配置">
|
||||
<div class="production-section-heading">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">批量执行设置</h3>
|
||||
<p class="mt-1 text-xs text-muted">控制本次手动批处理的并发与覆盖方式。</p>
|
||||
</div>
|
||||
</div>
|
||||
<AppForm
|
||||
:model="batchModel"
|
||||
:rules="batchRules"
|
||||
@@ -265,273 +271,285 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
</NCheckbox>
|
||||
</div>
|
||||
</AppForm>
|
||||
<DetailDisclosure title="处理范围与覆盖规则" class="mt-3"
|
||||
</section>
|
||||
<div class="production-tool-stack">
|
||||
<CreativeProfileSettings :project-id="id" />
|
||||
<DetailDisclosure title="处理范围与覆盖规则" class="production-tool-section"
|
||||
><p class="text-xs leading-6 text-muted">
|
||||
提示词与视频就绪检查面向全项目;视频实际提交受本批镜头上限控制,并使用严格质量流水线。首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式处理全项目;过期首帧成功后会自动接替旧主图,其余新增候选。图片模型、视频模型与视觉校验均可能产生费用。
|
||||
</p></DetailDisclosure
|
||||
>
|
||||
</section>
|
||||
<div class="workspace-overview-panel">
|
||||
<NCollapse v-model:expanded-names="overview"
|
||||
><NCollapseItem name="overview" title="项目生产总览与批量操作"
|
||||
><div class="overview-content">
|
||||
<NAlert
|
||||
v-if="videoRunning"
|
||||
role="status"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-4"
|
||||
>
|
||||
当前有 {{ videoRunning }} 个视频任务等待或生成中。后端正在轮询
|
||||
Provider;同一镜头不会重复提交活动任务。
|
||||
</NAlert>
|
||||
<NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4">
|
||||
当前有 {{ identityBlocked }} 项角色身份前置问题。Character 必须生成
|
||||
Identity、确认演员母版并锁定后,首帧才能就绪。
|
||||
<RouterLink
|
||||
:to="`/projects/${id}/subject-identity`"
|
||||
class="text-button ml-2"
|
||||
>前往角色选角 →</RouterLink
|
||||
></NAlert
|
||||
>
|
||||
<NAlert
|
||||
v-if="staleKeyframes"
|
||||
role="status"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-4"
|
||||
>
|
||||
当前有
|
||||
{{ staleKeyframes }}
|
||||
个主首帧已过期。它们使用的主体参考资产已经变化,视频阶段会被阻止;可在下方逐镜重新生成,或使用“补齐
|
||||
/ 更新主首帧”批量处理。
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-if="staleVideos"
|
||||
role="status"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-4"
|
||||
>
|
||||
当前有
|
||||
{{ staleVideos }}
|
||||
个主视频已过期。它们使用的主首帧或主体参考图已经变化;非覆盖模式批量生成时会只重建这些过期主视频。
|
||||
</NAlert>
|
||||
<ProductionReceipt v-if="session.receipt" :receipt="session.receipt" />
|
||||
<div v-if="query.data.value" class="production-pipeline mt-5">
|
||||
<article
|
||||
v-for="stage in stages"
|
||||
:key="stage.key"
|
||||
class="panel production-pipeline-card"
|
||||
<div class="workspace-overview-panel">
|
||||
<NCollapse v-model:expanded-names="overview" class="production-tool-section"
|
||||
><NCollapseItem name="overview" title="项目生产总览与批量操作"
|
||||
><div class="overview-content">
|
||||
<NAlert
|
||||
v-if="videoRunning"
|
||||
role="status"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="production-step"
|
||||
><component :is="stage.icon" :size="17"
|
||||
/></span>
|
||||
<div>
|
||||
<p class="eyebrow">STEP {{ stage.code }}</p>
|
||||
<h3 class="mt-2 text-sm font-semibold">
|
||||
{{ stage.title }}
|
||||
</h3>
|
||||
当前有 {{ videoRunning }} 个视频任务等待或生成中。后端正在轮询
|
||||
Provider;同一镜头不会重复提交活动任务。
|
||||
</NAlert>
|
||||
<NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4">
|
||||
当前有 {{ identityBlocked }} 项角色身份前置问题。Character 必须生成
|
||||
Identity、确认演员母版并锁定后,首帧才能就绪。
|
||||
<RouterLink
|
||||
:to="`/projects/${id}/subject-identity`"
|
||||
class="text-button ml-2"
|
||||
>前往角色选角 →</RouterLink
|
||||
></NAlert
|
||||
>
|
||||
<NAlert
|
||||
v-if="staleKeyframes"
|
||||
role="status"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-4"
|
||||
>
|
||||
当前有
|
||||
{{ staleKeyframes }}
|
||||
个主首帧已过期。它们使用的主体参考资产已经变化,视频阶段会被阻止;可在下方逐镜重新生成,或使用“补齐
|
||||
/ 更新主首帧”批量处理。
|
||||
</NAlert>
|
||||
<NAlert
|
||||
v-if="staleVideos"
|
||||
role="status"
|
||||
type="info"
|
||||
:show-icon="false"
|
||||
class="mt-4"
|
||||
>
|
||||
当前有
|
||||
{{ staleVideos }}
|
||||
个主视频已过期。它们使用的主首帧或主体参考图已经变化;非覆盖模式批量生成时会只重建这些过期主视频。
|
||||
</NAlert>
|
||||
<ProductionReceipt v-if="session.receipt" :receipt="session.receipt" />
|
||||
<div v-if="query.data.value" class="production-pipeline mt-5">
|
||||
<article
|
||||
v-for="stage in stages"
|
||||
:key="stage.key"
|
||||
class="panel production-pipeline-card"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="production-step"
|
||||
><component :is="stage.icon" :size="17"
|
||||
/></span>
|
||||
<div>
|
||||
<p class="eyebrow">STEP {{ stage.code }}</p>
|
||||
<h3 class="mt-2 text-sm font-semibold">
|
||||
{{ stage.title }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-muted"
|
||||
>{{ stage.done }} / {{ stage.data?.total ?? 0 }}</span
|
||||
>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-muted"
|
||||
>{{ stage.done }} / {{ stage.data?.total ?? 0 }}</span
|
||||
>
|
||||
</div>
|
||||
<p class="mt-4 min-h-10 text-xs leading-5 text-muted">
|
||||
{{ stage.description }}
|
||||
</p>
|
||||
<NProgress
|
||||
:aria-label="`${stage.title}完成数量`"
|
||||
type="line"
|
||||
:show-indicator="false"
|
||||
:height="4"
|
||||
:percentage="
|
||||
Math.min(
|
||||
100,
|
||||
Math.max(
|
||||
0,
|
||||
(stage.done /
|
||||
Math.max(1, Math.max(stage.data?.total ?? 0, 1))) *
|
||||
100
|
||||
<p class="mt-4 min-h-10 text-xs leading-5 text-muted">
|
||||
{{ stage.description }}
|
||||
</p>
|
||||
<NProgress
|
||||
:aria-label="`${stage.title}完成数量`"
|
||||
type="line"
|
||||
:show-indicator="false"
|
||||
:height="4"
|
||||
:percentage="
|
||||
Math.min(
|
||||
100,
|
||||
Math.max(
|
||||
0,
|
||||
(stage.done /
|
||||
Math.max(
|
||||
1,
|
||||
Math.max(stage.data?.total ?? 0, 1)
|
||||
)) *
|
||||
100
|
||||
)
|
||||
)
|
||||
)
|
||||
"
|
||||
class="mt-4"
|
||||
></NProgress>
|
||||
<dl class="production-stats mt-4">
|
||||
"
|
||||
class="mt-4"
|
||||
></NProgress>
|
||||
<dl class="production-stats mt-4">
|
||||
<div>
|
||||
<dt>可执行</dt>
|
||||
<dd>{{ stage.data?.ready ?? 0 }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>已有结果</dt>
|
||||
<dd>{{ stage.data?.skipped ?? 0 }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>阻塞</dt>
|
||||
<dd>{{ stage.data?.blocked ?? 0 }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p
|
||||
v-if="stage.key === 'keyframes' && staleKeyframes"
|
||||
class="mt-3 text-[11px] leading-5"
|
||||
>
|
||||
已过期 {{ staleKeyframes }} · 会按当前主体参考资产重新生成
|
||||
</p>
|
||||
<p
|
||||
v-if="stage.key === 'videos' && staleVideos"
|
||||
class="mt-3 text-[11px] leading-5"
|
||||
>
|
||||
主视频已过期 {{ staleVideos }} ·
|
||||
会按当前主首帧与参考资产重新生成
|
||||
</p>
|
||||
<p
|
||||
v-if="stage.key === 'keyframes' && identityBlocked"
|
||||
class="mt-3 text-[11px] leading-5 text-danger"
|
||||
>
|
||||
身份缺失
|
||||
{{ query.data.value?.keyframes.missingIdentity ?? 0 }} ·
|
||||
母版缺失
|
||||
{{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} ·
|
||||
未锁定
|
||||
{{ query.data.value?.keyframes.identityUnlocked ?? 0 }}
|
||||
</p>
|
||||
<ConfirmAction
|
||||
class="mt-4"
|
||||
:label="stage.action"
|
||||
:disabled="
|
||||
blocked ||
|
||||
!batchValid ||
|
||||
(stage.key === 'keyframes' && !keyframeValid) ||
|
||||
(stage.key === 'videos' && !videoQualityValid) ||
|
||||
!(stage.data?.ready ?? 0)
|
||||
"
|
||||
acknowledgement
|
||||
:description="
|
||||
stage.key === 'videos'
|
||||
? staleVideos && !force
|
||||
? `当前存在过期主视频,非覆盖模式只会为其中至多 ${videoLimit || '—'} 个镜头启动严格质量流水线;候选自动校验并有限修复,通过后才接替旧主视频。`
|
||||
: `只为至多 ${videoLimit || '—'} 个就绪镜头启动严格质量流水线。任务创建后由后端自动校验,最多修复 ${videoRepairAttempts || '—'} 次,通过后才晋升主视频。`
|
||||
: stage.key === 'keyframes'
|
||||
? `处理缺失或过期主首帧。过期首帧成功后自动接替旧主图(覆盖模式也如此),其余已有主图只新增候选。本批上限:${keyframeLimit || '不限制'};尺寸:${keyframeWidth && keyframeHeight ? `${keyframeWidth} × ${keyframeHeight}` : '后端默认'}。`
|
||||
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
|
||||
"
|
||||
:primary="stage.key === 'videos'"
|
||||
@confirm="run(stage.key)"
|
||||
/>
|
||||
<AppForm
|
||||
v-if="stage.key === 'keyframes'"
|
||||
:model="keyframeModel"
|
||||
:rules="keyframeRules"
|
||||
:disabled="operation.pending"
|
||||
validate-on-change
|
||||
class="mt-4 grid gap-3"
|
||||
>
|
||||
<NFormItem path="keyframeLimit" label="本批上限(可选)"
|
||||
><NInputNumber
|
||||
:input-props="{ 'aria-label': '本批上限(可选)' }"
|
||||
:value="keyframeLimit === '' ? null : keyframeLimit"
|
||||
@update:value="keyframeLimit = $event ?? ''"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:disabled="operation.pending"
|
||||
placeholder="留空不限制"
|
||||
/></NFormItem>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<NFormItem path="keyframeWidth" label="宽度(像素)"
|
||||
><NInputNumber
|
||||
:input-props="{ 'aria-label': '宽度(像素)' }"
|
||||
:value="keyframeWidth === '' ? null : keyframeWidth"
|
||||
@update:value="keyframeWidth = $event ?? ''"
|
||||
:min="1"
|
||||
:disabled="operation.pending"
|
||||
placeholder="默认" /></NFormItem
|
||||
><NFormItem path="keyframeHeight" label="高度(像素)"
|
||||
><NInputNumber
|
||||
:input-props="{ 'aria-label': '高度(像素)' }"
|
||||
:value="
|
||||
keyframeHeight === '' ? null : keyframeHeight
|
||||
"
|
||||
@update:value="keyframeHeight = $event ?? ''"
|
||||
:min="1"
|
||||
:disabled="operation.pending"
|
||||
placeholder="默认"
|
||||
/></NFormItem>
|
||||
</div>
|
||||
</AppForm>
|
||||
<AppForm
|
||||
v-if="stage.key === 'videos'"
|
||||
:model="videoModel"
|
||||
:rules="videoRules"
|
||||
:disabled="operation.pending"
|
||||
validate-on-change
|
||||
class="mt-4 grid gap-3"
|
||||
>
|
||||
<NFormItem path="videoLimit" label="本批镜头上限"
|
||||
><NInputNumber
|
||||
v-model:value="videoLimit"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ 'aria-label': '视频质量任务镜头上限' }"
|
||||
/></NFormItem>
|
||||
<NFormItem path="videoRepairAttempts" label="每镜最多自动修复"
|
||||
><NInputNumber
|
||||
v-model:value="videoRepairAttempts"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ 'aria-label': '视频自动修复次数' }"
|
||||
/></NFormItem>
|
||||
</AppForm>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="query.data.value" class="panel mt-4 p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<dt>可执行</dt>
|
||||
<dd>{{ stage.data?.ready ?? 0 }}</dd>
|
||||
<h3 class="text-sm font-semibold">视频任务状态</h3>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
状态统计取每个镜头最近一次任务,不等同于主视频数量。
|
||||
</p>
|
||||
</div>
|
||||
<ConfirmAction
|
||||
label="重试失败视频"
|
||||
:disabled="
|
||||
blocked ||
|
||||
!batchValid ||
|
||||
!query.data.value.videoStatus.failed
|
||||
"
|
||||
acknowledgement
|
||||
description="仅重新提交后端判定可重试的失败视频;不可重试项会跳过,并在回执中显示数量。"
|
||||
@confirm="run('retry-videos')"
|
||||
/>
|
||||
</div>
|
||||
<dl class="production-status-grid mt-4">
|
||||
<div>
|
||||
<dt>完成</dt>
|
||||
<dd>{{ query.data.value.videoStatus.completed }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>已有结果</dt>
|
||||
<dd>{{ stage.data?.skipped ?? 0 }}</dd>
|
||||
<dt>排队/生成</dt>
|
||||
<dd>{{ videoRunning }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>阻塞</dt>
|
||||
<dd>{{ stage.data?.blocked ?? 0 }}</dd>
|
||||
<dt>失败</dt>
|
||||
<dd>{{ query.data.value.videoStatus.failed }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>取消</dt>
|
||||
<dd>{{ query.data.value.videoStatus.cancelled }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>未开始</dt>
|
||||
<dd>{{ query.data.value.videoStatus.notStarted }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p
|
||||
v-if="stage.key === 'keyframes' && staleKeyframes"
|
||||
class="mt-3 text-[11px] leading-5"
|
||||
>
|
||||
已过期 {{ staleKeyframes }} · 会按当前主体参考资产重新生成
|
||||
</p>
|
||||
<p
|
||||
v-if="stage.key === 'videos' && staleVideos"
|
||||
class="mt-3 text-[11px] leading-5"
|
||||
>
|
||||
主视频已过期 {{ staleVideos }} · 会按当前主首帧与参考资产重新生成
|
||||
</p>
|
||||
<p
|
||||
v-if="stage.key === 'keyframes' && identityBlocked"
|
||||
class="mt-3 text-[11px] leading-5 text-danger"
|
||||
>
|
||||
身份缺失 {{ query.data.value?.keyframes.missingIdentity ?? 0 }} ·
|
||||
母版缺失
|
||||
{{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} ·
|
||||
未锁定
|
||||
{{ query.data.value?.keyframes.identityUnlocked ?? 0 }}
|
||||
</p>
|
||||
<ConfirmAction
|
||||
class="mt-4"
|
||||
:label="stage.action"
|
||||
:disabled="
|
||||
blocked ||
|
||||
!batchValid ||
|
||||
(stage.key === 'keyframes' && !keyframeValid) ||
|
||||
(stage.key === 'videos' && !videoQualityValid) ||
|
||||
!(stage.data?.ready ?? 0)
|
||||
"
|
||||
acknowledgement
|
||||
:description="
|
||||
stage.key === 'videos'
|
||||
? staleVideos && !force
|
||||
? `当前存在过期主视频,非覆盖模式只会为其中至多 ${videoLimit || '—'} 个镜头启动严格质量流水线;候选自动校验并有限修复,通过后才接替旧主视频。`
|
||||
: `只为至多 ${videoLimit || '—'} 个就绪镜头启动严格质量流水线。任务创建后由后端自动校验,最多修复 ${videoRepairAttempts || '—'} 次,通过后才晋升主视频。`
|
||||
: stage.key === 'keyframes'
|
||||
? `处理缺失或过期主首帧。过期首帧成功后自动接替旧主图(覆盖模式也如此),其余已有主图只新增候选。本批上限:${keyframeLimit || '不限制'};尺寸:${keyframeWidth && keyframeHeight ? `${keyframeWidth} × ${keyframeHeight}` : '后端默认'}。`
|
||||
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
|
||||
"
|
||||
:primary="stage.key === 'videos'"
|
||||
@confirm="run(stage.key)"
|
||||
/>
|
||||
<AppForm
|
||||
v-if="stage.key === 'keyframes'"
|
||||
:model="keyframeModel"
|
||||
:rules="keyframeRules"
|
||||
:disabled="operation.pending"
|
||||
validate-on-change
|
||||
class="mt-4 grid gap-3"
|
||||
>
|
||||
<NFormItem path="keyframeLimit" label="本批上限(可选)"
|
||||
><NInputNumber
|
||||
:input-props="{ 'aria-label': '本批上限(可选)' }"
|
||||
:value="keyframeLimit === '' ? null : keyframeLimit"
|
||||
@update:value="keyframeLimit = $event ?? ''"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:disabled="operation.pending"
|
||||
placeholder="留空不限制"
|
||||
/></NFormItem>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<NFormItem path="keyframeWidth" label="宽度(像素)"
|
||||
><NInputNumber
|
||||
:input-props="{ 'aria-label': '宽度(像素)' }"
|
||||
:value="keyframeWidth === '' ? null : keyframeWidth"
|
||||
@update:value="keyframeWidth = $event ?? ''"
|
||||
:min="1"
|
||||
:disabled="operation.pending"
|
||||
placeholder="默认" /></NFormItem
|
||||
><NFormItem path="keyframeHeight" label="高度(像素)"
|
||||
><NInputNumber
|
||||
:input-props="{ 'aria-label': '高度(像素)' }"
|
||||
:value="keyframeHeight === '' ? null : keyframeHeight"
|
||||
@update:value="keyframeHeight = $event ?? ''"
|
||||
:min="1"
|
||||
:disabled="operation.pending"
|
||||
placeholder="默认"
|
||||
/></NFormItem>
|
||||
</div>
|
||||
</AppForm>
|
||||
<AppForm
|
||||
v-if="stage.key === 'videos'"
|
||||
:model="videoModel"
|
||||
:rules="videoRules"
|
||||
:disabled="operation.pending"
|
||||
validate-on-change
|
||||
class="mt-4 grid gap-3"
|
||||
>
|
||||
<NFormItem path="videoLimit" label="本批镜头上限"
|
||||
><NInputNumber
|
||||
v-model:value="videoLimit"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ 'aria-label': '视频质量任务镜头上限' }"
|
||||
/></NFormItem>
|
||||
<NFormItem path="videoRepairAttempts" label="每镜最多自动修复"
|
||||
><NInputNumber
|
||||
v-model:value="videoRepairAttempts"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:disabled="operation.pending"
|
||||
:input-props="{ 'aria-label': '视频自动修复次数' }"
|
||||
/></NFormItem>
|
||||
</AppForm>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="query.data.value" class="panel mt-4 p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">视频任务状态</h3>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
状态统计取每个镜头最近一次任务,不等同于主视频数量。
|
||||
</p>
|
||||
</div>
|
||||
<ConfirmAction
|
||||
label="重试失败视频"
|
||||
:disabled="
|
||||
blocked || !batchValid || !query.data.value.videoStatus.failed
|
||||
"
|
||||
acknowledgement
|
||||
description="仅重新提交后端判定可重试的失败视频;不可重试项会跳过,并在回执中显示数量。"
|
||||
@confirm="run('retry-videos')"
|
||||
/>
|
||||
</div>
|
||||
<dl class="production-status-grid mt-4">
|
||||
<div>
|
||||
<dt>完成</dt>
|
||||
<dd>{{ query.data.value.videoStatus.completed }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>排队/生成</dt>
|
||||
<dd>{{ videoRunning }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>失败</dt>
|
||||
<dd>{{ query.data.value.videoStatus.failed }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>取消</dt>
|
||||
<dd>{{ query.data.value.videoStatus.cancelled }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>未开始</dt>
|
||||
<dd>{{ query.data.value.videoStatus.notStarted }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div></NCollapseItem
|
||||
></NCollapse
|
||||
>
|
||||
</div></NCollapseItem
|
||||
></NCollapse
|
||||
>
|
||||
</div>
|
||||
<EpisodeAssemblyPanel :project-id="id" />
|
||||
<AdvancedProduction />
|
||||
</div>
|
||||
<EpisodeAssemblyPanel :project-id="id" />
|
||||
<AdvancedProduction />
|
||||
</WorkspaceTools>
|
||||
</div></div
|
||||
></template>
|
||||
@@ -695,6 +713,46 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.production-tools-intro {
|
||||
@apply flex flex-wrap items-end justify-between gap-4 p-[18px] bg-(--app-surface);
|
||||
}
|
||||
.production-tools-intro .eyebrow {
|
||||
@apply mb-2;
|
||||
}
|
||||
.production-batch-config {
|
||||
@apply mt-3 p-4 bg-(--app-surface);
|
||||
}
|
||||
.production-section-heading {
|
||||
@apply flex items-start justify-between gap-4;
|
||||
}
|
||||
.workspace-tools-body .production-batch-config .production-controls {
|
||||
@apply mt-4 mb-0;
|
||||
}
|
||||
.production-tool-stack {
|
||||
@apply mt-3 px-4 bg-(--app-surface);
|
||||
}
|
||||
.production-tool-stack .production-tool-section.n-collapse {
|
||||
@apply m-0;
|
||||
box-shadow: inset 0 1px var(--app-border);
|
||||
}
|
||||
.production-tool-stack > :first-child.production-tool-section.n-collapse {
|
||||
box-shadow: none;
|
||||
}
|
||||
.production-tool-stack .production-tool-section .n-collapse-item__header {
|
||||
@apply min-h-12 py-0;
|
||||
}
|
||||
.production-tool-stack .production-tool-section .n-collapse-item__header-main {
|
||||
@apply text-[13px] font-medium;
|
||||
}
|
||||
.production-tool-stack .production-tool-section .n-collapse-item__content-inner {
|
||||
@apply pt-1 pb-4 pl-6;
|
||||
}
|
||||
.production-tool-stack .detail-disclosure-content {
|
||||
@apply py-3 px-4 bg-(--app-subtle);
|
||||
}
|
||||
.production-tool-stack .workspace-overview-panel {
|
||||
@apply m-0;
|
||||
}
|
||||
.production-controls {
|
||||
@apply grid grid-cols-[minmax(220px,_1.6fr)_minmax(110px,_0.5fr)_minmax(230px,_1fr)_auto] items-end gap-[18px];
|
||||
}
|
||||
@@ -737,6 +795,16 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.production-tools-intro,
|
||||
.production-section-heading {
|
||||
@apply items-start;
|
||||
}
|
||||
.production-tool-stack {
|
||||
@apply px-3;
|
||||
}
|
||||
.production-tool-stack .production-tool-section .n-collapse-item__content-inner {
|
||||
@apply pl-4;
|
||||
}
|
||||
.production-controls,
|
||||
.production-workspace {
|
||||
@apply grid-cols-[minmax(0,_1fr)];
|
||||
|
||||
@@ -29,7 +29,7 @@ function exportReceipt() {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<NCollapse v-model:expanded-names="expanded" class="mt-5"
|
||||
<NCollapse v-model:expanded-names="expanded" class="production-tool-section"
|
||||
><NCollapseItem name="advanced" title="高级:串联生产(全项目)">
|
||||
<NAlert type="warning" :show-icon="false"
|
||||
>依次补齐形态提示词、参考图、首帧质量校验与修复、视频提示词,再提交视频任务。视觉风格、身份母版、导演设计和即时状态仍需提前准备。</NAlert
|
||||
|
||||
@@ -60,7 +60,7 @@ onScopeDispose(() => controller?.abort())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCollapse class="mt-4">
|
||||
<NCollapse class="production-tool-section">
|
||||
<NCollapseItem name="episode-assembly" title="单集成片拼接">
|
||||
<NSpin :show="loading">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NImage } from 'naive-ui'
|
||||
import { Box, MapPin, UserRound } from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { referenceImageUrl } from '../../../lib/assets'
|
||||
import { subjectIdentityApi } from '../api'
|
||||
import { currentAnchor } from '../model'
|
||||
import RevealImage from '../../../components/ui/RevealImage.vue'
|
||||
|
||||
/** source 为 undefined 时按需查询;null 表示上层已确认没有母版,不能用候选或形态图替代。 */
|
||||
const props = defineProps<{
|
||||
@@ -89,16 +89,7 @@ watch(
|
||||
|
||||
<template>
|
||||
<span ref="target" class="identity-thumbnail" :title="label">
|
||||
<NImage
|
||||
v-if="url && !imageFailed"
|
||||
:src="url"
|
||||
:alt="label"
|
||||
object-fit="cover"
|
||||
preview-disabled
|
||||
lazy
|
||||
:img-props="{ referrerpolicy: 'no-referrer' }"
|
||||
@error="imageFailed = true"
|
||||
/>
|
||||
<RevealImage v-if="url && !imageFailed" :src="url" :alt="label" @error="imageFailed = true" />
|
||||
<span v-else class="identity-thumbnail-placeholder" role="img" :aria-label="label">
|
||||
<component :is="icon" :size="22" :stroke-width="1.4" aria-hidden="true" />
|
||||
<span aria-hidden="true">{{ kind }}</span>
|
||||
@@ -113,7 +104,8 @@ watch(
|
||||
@apply flex w-12 h-[64px] overflow-hidden bg-(--app-subtle);
|
||||
}
|
||||
.identity-thumbnail .n-image,
|
||||
.identity-thumbnail .n-image img {
|
||||
.identity-thumbnail .n-image img,
|
||||
.identity-thumbnail .reveal-image {
|
||||
@apply w-full h-full;
|
||||
}
|
||||
.identity-thumbnail-placeholder {
|
||||
|
||||
Reference in New Issue
Block a user