feat: 优化生产面板并增加图片网格加载
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NButton, NImage } from 'naive-ui'
|
import { NButton } from 'naive-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ImageOff } from '@lucide/vue'
|
import { ImageOff } from '@lucide/vue'
|
||||||
import { referenceImageUrl } from '../../lib/assets'
|
import { referenceImageUrl } from '../../lib/assets'
|
||||||
|
import RevealImage from './RevealImage.vue'
|
||||||
|
|
||||||
/** 图片统一铺满容器;原图预览按场景开启,历史选择按钮不触发预览。 */
|
/** 图片统一铺满容器;原图预览按场景开启,历史选择按钮不触发预览。 */
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -28,16 +29,12 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="asset-image">
|
<div class="asset-image">
|
||||||
<NImage
|
<RevealImage
|
||||||
v-if="url && !failed"
|
v-if="url && !failed"
|
||||||
:src="url"
|
:src="url"
|
||||||
:preview-src="url"
|
|
||||||
:alt="alt"
|
:alt="alt"
|
||||||
lazy
|
:preview="preview"
|
||||||
:preview-disabled="!preview"
|
|
||||||
:object-fit="objectFit"
|
:object-fit="objectFit"
|
||||||
:img-props="{ referrerpolicy: 'no-referrer' }"
|
|
||||||
:previewed-img-props="{ alt, referrerpolicy: 'no-referrer' }"
|
|
||||||
@error="failed = true"
|
@error="failed = true"
|
||||||
/>
|
/>
|
||||||
<span v-else class="asset-image-empty"
|
<span v-else class="asset-image-empty"
|
||||||
@@ -63,6 +60,7 @@ watch(
|
|||||||
.asset-image-empty {
|
.asset-image-empty {
|
||||||
@apply flex items-center justify-center flex-col gap-2.5 p-5 text-muted text-[11px] text-center;
|
@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 {
|
.asset-image .n-image {
|
||||||
@apply w-full h-full min-h-0 min-w-0 flex justify-center;
|
@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 EmptyState } from './EmptyState.vue'
|
||||||
export { default as StatusBadge } from './StatusBadge.vue'
|
export { default as StatusBadge } from './StatusBadge.vue'
|
||||||
export { default as AssetImage } from './AssetImage.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 DirectoryItem } from './DirectoryItem.vue'
|
||||||
export { default as WorkspaceTools } from './WorkspaceTools.vue'
|
export { default as WorkspaceTools } from './WorkspaceTools.vue'
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ onScopeDispose(() => controller?.abort())
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NCollapse class="mt-4">
|
<NCollapse class="production-tool-section">
|
||||||
<NCollapseItem name="creative-profile" title="项目画布与生成模型">
|
<NCollapseItem name="creative-profile" title="项目画布与生成模型">
|
||||||
<NSpin :show="loading">
|
<NSpin :show="loading">
|
||||||
<NAlert v-if="error" type="error" :show-icon="false" class="mb-3">{{ error }}</NAlert>
|
<NAlert v-if="error" type="error" :show-icon="false" class="mb-3">{{ error }}</NAlert>
|
||||||
|
|||||||
@@ -227,8 +227,9 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
|||||||
label="批量生产"
|
label="批量生产"
|
||||||
:has-receipt="!!session.receipt || !!session.pipelineReceipt"
|
:has-receipt="!!session.receipt || !!session.pipelineReceipt"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
<div class="production-tools-intro">
|
||||||
<div>
|
<div>
|
||||||
|
<p class="eyebrow">PRODUCTION</p>
|
||||||
<h2 class="text-lg font-semibold">镜头生产</h2>
|
<h2 class="text-lg font-semibold">镜头生产</h2>
|
||||||
<p class="mt-2 text-sm text-muted">
|
<p class="mt-2 text-sm text-muted">
|
||||||
从可生成的提示词和首帧,到异步视频任务与最终成片。
|
从可生成的提示词和首帧,到异步视频任务与最终成片。
|
||||||
@@ -238,8 +239,13 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
|||||||
<ArrowLeft :size="14" />回到分镜设计
|
<ArrowLeft :size="14" />回到分镜设计
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<CreativeProfileSettings :project-id="id" />
|
<section class="production-batch-config" aria-label="项目生产配置">
|
||||||
<section class="mt-3" 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
|
<AppForm
|
||||||
:model="batchModel"
|
:model="batchModel"
|
||||||
:rules="batchRules"
|
:rules="batchRules"
|
||||||
@@ -265,14 +271,16 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
|||||||
</NCheckbox>
|
</NCheckbox>
|
||||||
</div>
|
</div>
|
||||||
</AppForm>
|
</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 class="text-xs leading-6 text-muted">
|
||||||
提示词与视频就绪检查面向全项目;视频实际提交受本批镜头上限控制,并使用严格质量流水线。首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式处理全项目;过期首帧成功后会自动接替旧主图,其余新增候选。图片模型、视频模型与视觉校验均可能产生费用。
|
提示词与视频就绪检查面向全项目;视频实际提交受本批镜头上限控制,并使用严格质量流水线。首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式处理全项目;过期首帧成功后会自动接替旧主图,其余新增候选。图片模型、视频模型与视觉校验均可能产生费用。
|
||||||
</p></DetailDisclosure
|
</p></DetailDisclosure
|
||||||
>
|
>
|
||||||
</section>
|
|
||||||
<div class="workspace-overview-panel">
|
<div class="workspace-overview-panel">
|
||||||
<NCollapse v-model:expanded-names="overview"
|
<NCollapse v-model:expanded-names="overview" class="production-tool-section"
|
||||||
><NCollapseItem name="overview" title="项目生产总览与批量操作"
|
><NCollapseItem name="overview" title="项目生产总览与批量操作"
|
||||||
><div class="overview-content">
|
><div class="overview-content">
|
||||||
<NAlert
|
<NAlert
|
||||||
@@ -354,7 +362,10 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
|||||||
Math.max(
|
Math.max(
|
||||||
0,
|
0,
|
||||||
(stage.done /
|
(stage.done /
|
||||||
Math.max(1, Math.max(stage.data?.total ?? 0, 1))) *
|
Math.max(
|
||||||
|
1,
|
||||||
|
Math.max(stage.data?.total ?? 0, 1)
|
||||||
|
)) *
|
||||||
100
|
100
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -385,13 +396,15 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
|||||||
v-if="stage.key === 'videos' && staleVideos"
|
v-if="stage.key === 'videos' && staleVideos"
|
||||||
class="mt-3 text-[11px] leading-5"
|
class="mt-3 text-[11px] leading-5"
|
||||||
>
|
>
|
||||||
主视频已过期 {{ staleVideos }} · 会按当前主首帧与参考资产重新生成
|
主视频已过期 {{ staleVideos }} ·
|
||||||
|
会按当前主首帧与参考资产重新生成
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-if="stage.key === 'keyframes' && identityBlocked"
|
v-if="stage.key === 'keyframes' && identityBlocked"
|
||||||
class="mt-3 text-[11px] leading-5 text-danger"
|
class="mt-3 text-[11px] leading-5 text-danger"
|
||||||
>
|
>
|
||||||
身份缺失 {{ query.data.value?.keyframes.missingIdentity ?? 0 }} ·
|
身份缺失
|
||||||
|
{{ query.data.value?.keyframes.missingIdentity ?? 0 }} ·
|
||||||
母版缺失
|
母版缺失
|
||||||
{{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} ·
|
{{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} ·
|
||||||
未锁定
|
未锁定
|
||||||
@@ -450,7 +463,9 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
|||||||
><NFormItem path="keyframeHeight" label="高度(像素)"
|
><NFormItem path="keyframeHeight" label="高度(像素)"
|
||||||
><NInputNumber
|
><NInputNumber
|
||||||
:input-props="{ 'aria-label': '高度(像素)' }"
|
:input-props="{ 'aria-label': '高度(像素)' }"
|
||||||
:value="keyframeHeight === '' ? null : keyframeHeight"
|
:value="
|
||||||
|
keyframeHeight === '' ? null : keyframeHeight
|
||||||
|
"
|
||||||
@update:value="keyframeHeight = $event ?? ''"
|
@update:value="keyframeHeight = $event ?? ''"
|
||||||
:min="1"
|
:min="1"
|
||||||
:disabled="operation.pending"
|
:disabled="operation.pending"
|
||||||
@@ -496,7 +511,9 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
|||||||
<ConfirmAction
|
<ConfirmAction
|
||||||
label="重试失败视频"
|
label="重试失败视频"
|
||||||
:disabled="
|
:disabled="
|
||||||
blocked || !batchValid || !query.data.value.videoStatus.failed
|
blocked ||
|
||||||
|
!batchValid ||
|
||||||
|
!query.data.value.videoStatus.failed
|
||||||
"
|
"
|
||||||
acknowledgement
|
acknowledgement
|
||||||
description="仅重新提交后端判定可重试的失败视频;不可重试项会跳过,并在回执中显示数量。"
|
description="仅重新提交后端判定可重试的失败视频;不可重试项会跳过,并在回执中显示数量。"
|
||||||
@@ -532,6 +549,7 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
|||||||
</div>
|
</div>
|
||||||
<EpisodeAssemblyPanel :project-id="id" />
|
<EpisodeAssemblyPanel :project-id="id" />
|
||||||
<AdvancedProduction />
|
<AdvancedProduction />
|
||||||
|
</div>
|
||||||
</WorkspaceTools>
|
</WorkspaceTools>
|
||||||
</div></div
|
</div></div
|
||||||
></template>
|
></template>
|
||||||
@@ -695,6 +713,46 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
|
|||||||
<style>
|
<style>
|
||||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||||
@reference "../../styles/styles.css";
|
@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 {
|
.production-controls {
|
||||||
@apply grid grid-cols-[minmax(220px,_1.6fr)_minmax(110px,_0.5fr)_minmax(230px,_1fr)_auto] items-end gap-[18px];
|
@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) {
|
@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-controls,
|
||||||
.production-workspace {
|
.production-workspace {
|
||||||
@apply grid-cols-[minmax(0,_1fr)];
|
@apply grid-cols-[minmax(0,_1fr)];
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function exportReceipt() {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<NCollapse v-model:expanded-names="expanded" class="mt-5"
|
<NCollapse v-model:expanded-names="expanded" class="production-tool-section"
|
||||||
><NCollapseItem name="advanced" title="高级:串联生产(全项目)">
|
><NCollapseItem name="advanced" title="高级:串联生产(全项目)">
|
||||||
<NAlert type="warning" :show-icon="false"
|
<NAlert type="warning" :show-icon="false"
|
||||||
>依次补齐形态提示词、参考图、首帧质量校验与修复、视频提示词,再提交视频任务。视觉风格、身份母版、导演设计和即时状态仍需提前准备。</NAlert
|
>依次补齐形态提示词、参考图、首帧质量校验与修复、视频提示词,再提交视频任务。视觉风格、身份母版、导演设计和即时状态仍需提前准备。</NAlert
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ onScopeDispose(() => controller?.abort())
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NCollapse class="mt-4">
|
<NCollapse class="production-tool-section">
|
||||||
<NCollapseItem name="episode-assembly" title="单集成片拼接">
|
<NCollapseItem name="episode-assembly" title="单集成片拼接">
|
||||||
<NSpin :show="loading">
|
<NSpin :show="loading">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NImage } from 'naive-ui'
|
|
||||||
import { Box, MapPin, UserRound } from '@lucide/vue'
|
import { Box, MapPin, UserRound } from '@lucide/vue'
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { referenceImageUrl } from '../../../lib/assets'
|
import { referenceImageUrl } from '../../../lib/assets'
|
||||||
import { subjectIdentityApi } from '../api'
|
import { subjectIdentityApi } from '../api'
|
||||||
import { currentAnchor } from '../model'
|
import { currentAnchor } from '../model'
|
||||||
|
import RevealImage from '../../../components/ui/RevealImage.vue'
|
||||||
|
|
||||||
/** source 为 undefined 时按需查询;null 表示上层已确认没有母版,不能用候选或形态图替代。 */
|
/** source 为 undefined 时按需查询;null 表示上层已确认没有母版,不能用候选或形态图替代。 */
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -89,16 +89,7 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span ref="target" class="identity-thumbnail" :title="label">
|
<span ref="target" class="identity-thumbnail" :title="label">
|
||||||
<NImage
|
<RevealImage v-if="url && !imageFailed" :src="url" :alt="label" @error="imageFailed = true" />
|
||||||
v-if="url && !imageFailed"
|
|
||||||
:src="url"
|
|
||||||
:alt="label"
|
|
||||||
object-fit="cover"
|
|
||||||
preview-disabled
|
|
||||||
lazy
|
|
||||||
:img-props="{ referrerpolicy: 'no-referrer' }"
|
|
||||||
@error="imageFailed = true"
|
|
||||||
/>
|
|
||||||
<span v-else class="identity-thumbnail-placeholder" role="img" :aria-label="label">
|
<span v-else class="identity-thumbnail-placeholder" role="img" :aria-label="label">
|
||||||
<component :is="icon" :size="22" :stroke-width="1.4" aria-hidden="true" />
|
<component :is="icon" :size="22" :stroke-width="1.4" aria-hidden="true" />
|
||||||
<span aria-hidden="true">{{ kind }}</span>
|
<span aria-hidden="true">{{ kind }}</span>
|
||||||
@@ -113,7 +104,8 @@ watch(
|
|||||||
@apply flex w-12 h-[64px] overflow-hidden bg-(--app-subtle);
|
@apply flex w-12 h-[64px] overflow-hidden bg-(--app-subtle);
|
||||||
}
|
}
|
||||||
.identity-thumbnail .n-image,
|
.identity-thumbnail .n-image,
|
||||||
.identity-thumbnail .n-image img {
|
.identity-thumbnail .n-image img,
|
||||||
|
.identity-thumbnail .reveal-image {
|
||||||
@apply w-full h-full;
|
@apply w-full h-full;
|
||||||
}
|
}
|
||||||
.identity-thumbnail-placeholder {
|
.identity-thumbnail-placeholder {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { NImage } from 'naive-ui'
|
import { NImage } from 'naive-ui'
|
||||||
import AssetImage from '@/components/ui/AssetImage.vue'
|
import AssetImage from '@/components/ui/AssetImage.vue'
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ afterEach(() => {
|
|||||||
wrapper?.unmount()
|
wrapper?.unmount()
|
||||||
wrapper = undefined
|
wrapper = undefined
|
||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('资源图片展示与原图预览', () => {
|
describe('资源图片展示与原图预览', () => {
|
||||||
@@ -31,6 +33,21 @@ describe('资源图片展示与原图预览', () => {
|
|||||||
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('contain')
|
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('contain')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('图片加载完成后按网格逐块显现,并在动画结束后移除覆盖层', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||||
|
callback(0)
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
wrapper = mount(AssetImage, { props: { src: '/storage/reveal.png', alt: '加载动效图片' } })
|
||||||
|
expect(wrapper.findAll('.reveal-image-cell')).toHaveLength(16)
|
||||||
|
await wrapper.get('img').trigger('load')
|
||||||
|
expect(wrapper.get('.reveal-image').classes()).toContain('is-loaded')
|
||||||
|
vi.advanceTimersByTime(760)
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.find('.reveal-image-grid').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('cover 仅裁切卡片,点击使用 Naive 原生预览展示同一完整资源,遮罩可关闭', async () => {
|
it('cover 仅裁切卡片,点击使用 Naive 原生预览展示同一完整资源,遮罩可关闭', async () => {
|
||||||
wrapper = mount(AssetImage, {
|
wrapper = mount(AssetImage, {
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
|
|||||||
Reference in New Issue
Block a user