494 lines
24 KiB
Vue
494 lines
24 KiB
Vue
<script setup lang="ts">
|
||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||
import ActionMenu from '../../components/ui/ActionMenu.vue'
|
||
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
|
||
import {
|
||
NScrollbar,
|
||
NAlert,
|
||
NButton,
|
||
NCheckbox,
|
||
NCollapse,
|
||
NCollapseItem,
|
||
NInputNumber,
|
||
NProgress,
|
||
NSelect,
|
||
NTab,
|
||
NTabs
|
||
} from 'naive-ui'
|
||
import { computed, ref, watch } from 'vue'
|
||
import { ArrowLeft, RefreshCw } from '@lucide/vue'
|
||
import { EmptyState } from '../../components/ui'
|
||
import { downloadText } from '../../lib/format'
|
||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||
import GenerationReceipt from './components/GenerationReceipt.vue'
|
||
import ShotDesign from './components/ShotDesign.vue'
|
||
import ShotTools from './components/ShotTools.vue'
|
||
import BeatShotDirectory from './components/BeatShotDirectory.vue'
|
||
import { useStoryboard } from './useStoryboard'
|
||
|
||
const detailTab = ref('design')
|
||
|
||
/** 分镜工作区按单集查看正式数据,生成配置与镜头检查分开。 */
|
||
const {
|
||
id,
|
||
selectedEpisode,
|
||
episodeNo,
|
||
episodeOptions,
|
||
selectedShot,
|
||
shots,
|
||
shot,
|
||
query,
|
||
data,
|
||
prerequisites,
|
||
session,
|
||
operation,
|
||
concurrency,
|
||
maxRepairAttempts,
|
||
force,
|
||
allowed,
|
||
batchValid,
|
||
repairsValid,
|
||
blocked,
|
||
directionComplete,
|
||
run
|
||
} = useStoryboard()
|
||
const coverage = computed(() => [
|
||
{
|
||
title: '导演设计',
|
||
count: data.value?.directions.directionCount ?? 0,
|
||
total: data.value?.directions.shotCount ?? 0
|
||
},
|
||
{
|
||
title: '即时状态',
|
||
count: data.value?.visualStates.visualStateCount ?? 0,
|
||
total: data.value?.visualStates.shotCount ?? 0
|
||
}
|
||
])
|
||
|
||
/** 导出当前剧集的正式镜头数据;标题与剧情描述来源于 Breakdown 快照。 */
|
||
function exportEpisode() {
|
||
downloadText(
|
||
`episode-${episodeNo.value}-storyboard.json`,
|
||
JSON.stringify(
|
||
{
|
||
projectId: id.value,
|
||
episodeNo: episodeNo.value,
|
||
directions: data.value?.directions,
|
||
visualStates: data.value?.visualStates,
|
||
shots: shots.value
|
||
},
|
||
null,
|
||
2
|
||
),
|
||
'application/json'
|
||
)
|
||
}
|
||
/** 回执到达后打开诊断区;平时留出镜头检查空间。 */
|
||
const toolsOpen = ref(false)
|
||
const overview = ref<string[]>(['generation'])
|
||
watch(
|
||
() => session.value.receipt,
|
||
receipt => {
|
||
if (receipt) {
|
||
overview.value = ['generation']
|
||
toolsOpen.value = true
|
||
}
|
||
}
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<WorkspacePage split compact class="storyboard-workspace-page"
|
||
><template #header
|
||
><div class="workspace-toolbar">
|
||
<div class="toolbar-episode">
|
||
<NSelect
|
||
:value="episodeNo"
|
||
aria-label="选择分镜剧集"
|
||
@update:value="selectedEpisode = Number($event)"
|
||
:options="[
|
||
...(!episodeOptions.length ? [{ label: String('暂无剧集'), value: 0 }] : []),
|
||
...episodeOptions.map(episode => ({
|
||
label: String(' 第 ' + episode.episodeNo + ' 集 · ' + episode.title),
|
||
value: episode.episodeNo
|
||
}))
|
||
]"
|
||
class="select-control"
|
||
></NSelect>
|
||
</div>
|
||
<div class="toolbar-metrics">
|
||
<span v-for="item in coverage" :key="item.title"
|
||
>{{ item.title }} {{ item.count }} / {{ item.total }}</span
|
||
>
|
||
</div>
|
||
<div class="toolbar-actions">
|
||
<div class="flex gap-3">
|
||
<NButton
|
||
:disabled="query.loading.value"
|
||
@click="query.refresh"
|
||
quaternary
|
||
class="icon-button"
|
||
aria-label="刷新分镜"
|
||
title="刷新分镜"
|
||
><RefreshCw :size="16" :class="{ 'animate-spin': query.loading.value }"
|
||
/></NButton>
|
||
<ActionMenu
|
||
label="分镜更多操作"
|
||
:items="[{ key: 'export', label: '导出本集', disabled: !shots.length }]"
|
||
@select="exportEpisode"
|
||
/>
|
||
</div>
|
||
|
||
<WorkspaceTools
|
||
v-model:open="toolsOpen"
|
||
title="分镜生成配置与回执"
|
||
label="生成与恢复"
|
||
:has-receipt="!!session.receipt"
|
||
>
|
||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||
<div>
|
||
<h2 class="text-lg font-semibold">分镜设计</h2>
|
||
<p class="mt-2 text-sm text-muted">从镜头拆解到摄影设计,再补齐每一镜的即时状态。</p>
|
||
</div>
|
||
<RouterLink :to="`/projects/${id}/breakdown`" class="text-button"
|
||
><ArrowLeft :size="14" />回到拆解</RouterLink
|
||
>
|
||
</div>
|
||
<section class="mt-3" aria-label="分镜生成配置">
|
||
<div class="storyboard-controls">
|
||
<label
|
||
><span class="field-label">批量并发</span
|
||
><NInputNumber
|
||
:disabled="operation.pending"
|
||
:input-props="{ id: 'storyboard-concurrency' }"
|
||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||
@update:value="concurrency = $event ?? 0"
|
||
:min="1"
|
||
:step="1"
|
||
></NInputNumber
|
||
><span v-if="!batchValid" class="mt-1 block text-xs text-danger"
|
||
>请输入正整数</span
|
||
></label
|
||
>
|
||
<label
|
||
><span class="field-label">状态自动修复次数</span
|
||
><NInputNumber
|
||
:disabled="operation.pending"
|
||
:input-props="{ id: 'storyboard-repairs' }"
|
||
:value="typeof maxRepairAttempts === 'number' ? maxRepairAttempts : null"
|
||
@update:value="maxRepairAttempts = $event ?? 0"
|
||
:min="0"
|
||
:step="1"
|
||
></NInputNumber
|
||
><span v-if="!repairsValid" class="mt-1 block text-xs text-danger"
|
||
>请输入非负整数,0 表示不修复</span
|
||
></label
|
||
>
|
||
<NCheckbox
|
||
v-model:checked="force"
|
||
:disabled="operation.pending"
|
||
class="control-row-checkbox text-xs"
|
||
>批量覆盖已有结果</NCheckbox
|
||
>
|
||
</div>
|
||
</section>
|
||
<div class="storyboard-coverage grid grid-cols-2 gap-3">
|
||
<div v-for="item in coverage" :key="item.title" class="panel px-3 py-2">
|
||
<div class="mb-3 flex items-center justify-between text-xs">
|
||
<span>{{ item.title }}</span
|
||
><span class="font-mono text-muted">{{ item.count }} / {{ item.total }} 镜头</span>
|
||
</div>
|
||
<NProgress
|
||
:aria-label="item.title + '已保存覆盖率'"
|
||
type="line"
|
||
:show-indicator="false"
|
||
:height="4"
|
||
:percentage="
|
||
Math.min(
|
||
100,
|
||
Math.max(0, (item.count / Math.max(1, Math.max(item.total, 1))) * 100)
|
||
)
|
||
"
|
||
class=""
|
||
></NProgress>
|
||
</div>
|
||
</div>
|
||
<div class="workspace-overview-panel">
|
||
<NCollapse v-model:expanded-names="overview"
|
||
><NCollapseItem name="generation" title="生成配置、批量操作与回执"
|
||
><div class="overview-content">
|
||
<div class="generation-row">
|
||
<div>
|
||
<h3 class="text-sm font-semibold">
|
||
<span class="mr-2 font-mono text-xs text-muted">01</span
|
||
>导演摄影设计
|
||
</h3>
|
||
<p class="mt-2 text-xs leading-6 text-muted">
|
||
景别、机位、运镜与构图。正式项目生成默认跳过已完整剧集,覆盖模式会重新生成全部剧集。
|
||
</p>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<ConfirmAction
|
||
:label="force ? '重生成全部导演设计' : '补齐项目导演设计'"
|
||
:disabled="!allowed['direction-all']"
|
||
acknowledgement
|
||
:description="
|
||
force
|
||
? '重新生成项目全部剧集的导演设计,覆盖已有结果。'
|
||
: '对整个项目生成导演设计,跳过已完整保存的剧集,可用于重试失败或未完成的剧集。'
|
||
"
|
||
@confirm="run('direction-all')"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="generation-row">
|
||
<div>
|
||
<h3 class="text-sm font-semibold">
|
||
<span class="mr-2 font-mono text-xs text-muted">02</span
|
||
>即时视觉状态
|
||
</h3>
|
||
<p class="mt-2 text-xs leading-6 text-muted">
|
||
表情、姿态、位置、环境与连续性。每集需要完整导演设计;批量中缺少前置设计的剧集会单独报错。
|
||
</p>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<ConfirmAction
|
||
:label="force ? '重生成全部即时状态' : '补齐项目即时状态'"
|
||
:disabled="!allowed['visual-all']"
|
||
acknowledgement
|
||
:description="
|
||
force
|
||
? '重新生成整个项目的即时状态,覆盖已有结果。每集必须先有完整导演设计。'
|
||
: '补齐项目即时状态,跳过完整剧集;未完成导演设计的剧集会返回失败原因。'
|
||
"
|
||
@confirm="run('visual-all')"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p class="mt-4 text-[11px] leading-6 text-muted">
|
||
修改或覆盖上游设计后,后端不会自动使旧状态、旧提示词失效;请按顺序重生成下游内容。自动修复次数仅用于
|
||
VisualState。
|
||
</p>
|
||
<NCollapse class="mt-3 pt-3"
|
||
><NCollapseItem name="details"
|
||
><template #header>视频提示词批量操作</template>
|
||
<p class="my-3 text-xs leading-6 text-muted">
|
||
后端会先校验 GenerationSpec
|
||
与主体参考图。这里只生成提示词;首帧、视频任务、就绪诊断与失败重试已整理到
|
||
<RouterLink
|
||
:to="`/projects/${id}/production`"
|
||
class="text-button mx-1"
|
||
>镜头生产</RouterLink
|
||
>。
|
||
</p>
|
||
<ConfirmAction
|
||
:label="force ? '重生成全部视频提示词' : '补齐项目视频提示词'"
|
||
:disabled="!allowed.prompts"
|
||
acknowledgement
|
||
:description="
|
||
force
|
||
? '调用模型重新生成全项目镜头的视频提示词,覆盖已有正文。'
|
||
: '调用模型生成缺少视频提示词的镜头,已有提示词直接跳过。参考图缺失会返回失败。'
|
||
"
|
||
@confirm="run('prompts')" /></NCollapseItem
|
||
></NCollapse>
|
||
<GenerationReceipt
|
||
v-if="session.receipt"
|
||
:receipt="session.receipt"
|
||
/></div></NCollapseItem
|
||
></NCollapse>
|
||
</div>
|
||
</WorkspaceTools>
|
||
</div></div
|
||
></template>
|
||
<NScrollbar
|
||
v-if="
|
||
query.error.value ||
|
||
!prerequisites.directionEpisode ||
|
||
!prerequisites.visualEpisode ||
|
||
(data && !directionComplete && data.directions.shotCount)
|
||
"
|
||
class="workspace-feedback"
|
||
>
|
||
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
|
||
>{{ query.error.value }} 当前保留上次成功读取的数据,暂停生成操作。<NButton
|
||
@click="query.refresh"
|
||
text
|
||
size="small"
|
||
class="ml-3"
|
||
>
|
||
重试查询
|
||
</NButton></NAlert
|
||
><NAlert
|
||
v-if="!prerequisites.directionEpisode || !prerequisites.visualEpisode"
|
||
type="info"
|
||
:show-icon="false"
|
||
class="mt-4"
|
||
>
|
||
最新 Breakdown
|
||
缺少本集生成所需的镜头、主体或形态数据。已保存设计仍可查看;请先在拆解页完成或恢复相应阶段。 </NAlert
|
||
><NAlert
|
||
v-else-if="data && !directionComplete && data.directions.shotCount"
|
||
type="info"
|
||
:show-icon="false"
|
||
class="mt-4"
|
||
>
|
||
本集导演设计尚未补齐,请先完成第 01 步,再生成本集即时状态。
|
||
</NAlert>
|
||
</NScrollbar>
|
||
<div v-if="!data && query.loading.value" class="panel p-8 text-sm text-muted" role="status">
|
||
正在读取正式分镜数据……
|
||
</div>
|
||
<div v-else-if="shot" class="panel storyboard-workspace">
|
||
<nav class="shot-list directory-panel" aria-label="本集镜头">
|
||
<div class="directory-heading">
|
||
<h3>
|
||
镜头目录 <span>{{ shots.length }} 镜</span>
|
||
</h3>
|
||
<span>按 Beat 排列</span>
|
||
</div>
|
||
<NScrollbar
|
||
class="panel-scroll directory-scroll"
|
||
content-class="directory-list-content shot-list-content"
|
||
x-scrollable
|
||
>
|
||
<BeatShotDirectory
|
||
:shots="shots"
|
||
:active-id="shot.shotId"
|
||
item-class="shot-link"
|
||
@select="selectedShot = $event"
|
||
>
|
||
<template #meta="{ shot: item }">
|
||
<span class="directory-status">{{ item.direction ? '设计已保存' : '待设计' }}</span>
|
||
<span class="directory-status">{{ item.visualState ? '状态已保存' : '待状态' }}</span>
|
||
</template>
|
||
</BeatShotDirectory>
|
||
</NScrollbar>
|
||
</nav>
|
||
<article class="storyboard-detail min-w-0">
|
||
<NScrollbar class="panel-scroll" content-class="p-5 lg:p-7">
|
||
<header class="mb-6">
|
||
<p class="eyebrow">
|
||
BEAT {{ shot.beatNo }} / SHOT {{ shot.shotNo }}
|
||
<span v-if="shot.durationSeconds" class="ml-3">{{ shot.durationSeconds }}s</span>
|
||
</p>
|
||
<h3 class="mt-2 text-lg font-semibold">{{ shot.title }}</h3>
|
||
<p v-if="shot.description" class="mt-3 whitespace-pre-wrap text-sm leading-7">
|
||
{{ shot.description }}
|
||
</p>
|
||
<p v-if="shot.visualFocus" class="mt-2 text-xs leading-6 text-muted">
|
||
视觉重点:{{ shot.visualFocus }}
|
||
</p>
|
||
<p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p>
|
||
<p class="mt-1 text-[10px] text-muted">
|
||
标题与剧情描述取自最近可用 Breakdown 快照;以下设计与状态取自数据库。
|
||
</p>
|
||
</header>
|
||
<div>
|
||
<NTabs v-model:value="detailTab" type="line" size="small" aria-label="镜头详情"
|
||
><NTab name="design">设计与状态</NTab><NTab name="tools">参考图与生成规格</NTab></NTabs
|
||
>
|
||
<div v-if="detailTab === 'design'" class="pt-6"><ShotDesign :shot="shot" /></div>
|
||
<div v-if="detailTab === 'tools'" class="pt-6">
|
||
<ShotTools :key="shot.shotId" :project-id="id" :shot="shot" :disabled="blocked" />
|
||
</div>
|
||
</div>
|
||
</NScrollbar>
|
||
</article>
|
||
</div>
|
||
<EmptyState
|
||
v-else-if="data"
|
||
title="本集还没有正式分镜"
|
||
description="先完成剧本拆解与分镜持久化,再回来生成导演设计和即时状态。"
|
||
><RouterLink :to="`/projects/${id}/breakdown`" class="button button-secondary mt-4"
|
||
>前往拆解</RouterLink
|
||
></EmptyState
|
||
></WorkspacePage
|
||
>
|
||
</template>
|
||
|
||
<style>
|
||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||
@reference "../../styles/styles.css";
|
||
.storyboard-controls {
|
||
@apply grid grid-cols-[minmax(180px,_1fr)_100px_145px_auto] gap-[18px] items-end;
|
||
}
|
||
.generation-row {
|
||
@apply flex items-center justify-between gap-5 pt-5 mt-5;
|
||
}
|
||
.generation-row > div:last-child {
|
||
@apply shrink-0;
|
||
}
|
||
.storyboard-workspace {
|
||
@apply grid grid-cols-[220px_minmax(0,_1fr)] overflow-hidden;
|
||
}
|
||
.shot-list {
|
||
@apply max-h-[1000px] overflow-hidden bg-(--app-surface) pt-0 px-2 pb-5;
|
||
}
|
||
@media (max-width: 1200px) {
|
||
.generation-row {
|
||
@apply items-start flex-col gap-3;
|
||
}
|
||
.storyboard-controls {
|
||
@apply grid-cols-[minmax(0,_1fr)_minmax(0,_1fr)];
|
||
}
|
||
}
|
||
@media (max-width: 760px) {
|
||
.storyboard-controls {
|
||
@apply grid-cols-[minmax(0,_1fr)] gap-3;
|
||
}
|
||
.storyboard-workspace {
|
||
@apply grid-cols-[minmax(0,_1fr)];
|
||
}
|
||
.shot-list {
|
||
@apply max-h-[240px];
|
||
border-right: 0;
|
||
}
|
||
}
|
||
.storyboard-workspace-page .storyboard-workspace {
|
||
@apply flex-1 h-auto min-h-0 overflow-hidden;
|
||
}
|
||
.storyboard-coverage {
|
||
@apply shrink-0;
|
||
}
|
||
.storyboard-workspace {
|
||
@apply grid-cols-[clamp(240px,_22%,_280px)_minmax(0,_1fr)];
|
||
}
|
||
.storyboard-workspace {
|
||
@apply h-[clamp(360px,65dvh,850px)] min-h-0;
|
||
}
|
||
.storyboard-workspace > article,
|
||
.storyboard-workspace .shot-list {
|
||
@apply overflow-hidden min-h-0 h-full max-h-none;
|
||
}
|
||
.shot-list-content {
|
||
@apply gap-3 pt-0 px-0 pb-3;
|
||
}
|
||
.shot-list {
|
||
@apply p-0;
|
||
}
|
||
@media (max-width: 760px) {
|
||
/* 移动端按整行排列剧集、统计和操作,避免 flex 自动换行留下不规则空白。 */
|
||
.storyboard-workspace-page .workspace-toolbar {
|
||
@apply grid grid-cols-[minmax(0,_1fr)] gap-2;
|
||
}
|
||
.storyboard-workspace-page .toolbar-episode,
|
||
.storyboard-workspace-page .toolbar-metrics,
|
||
.storyboard-workspace-page .toolbar-actions {
|
||
@apply w-full min-w-0 m-0;
|
||
}
|
||
.storyboard-workspace-page .toolbar-metrics {
|
||
@apply justify-between;
|
||
}
|
||
.storyboard-workspace-page .toolbar-actions {
|
||
@apply justify-start;
|
||
}
|
||
.storyboard-workspace {
|
||
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[184px_minmax(0,_1fr)];
|
||
}
|
||
.storyboard-controls {
|
||
@apply grid-cols-[minmax(0,_1fr)_minmax(0,_1fr)];
|
||
}
|
||
}
|
||
</style>
|