@@ -193,7 +193,10 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
|||||||
| GET | `/storyboard-shots/:shotId/videos` | 单镜头视频历史 |
|
| GET | `/storyboard-shots/:shotId/videos` | 单镜头视频历史 |
|
||||||
| POST | `/storyboard-shots/:shotId/videos/generate-quality` | 单镜头严格质量候选;后台自动校验与有限修复 |
|
| POST | `/storyboard-shots/:shotId/videos/generate-quality` | 单镜头严格质量候选;后台自动校验与有限修复 |
|
||||||
| PUT | `/storyboard-shots/:shotId/videos/:videoId/primary` | 切换主视频 |
|
| PUT | `/storyboard-shots/:shotId/videos/:videoId/primary` | 切换主视频 |
|
||||||
| POST | `/projects/:id/production/start` | 高级串联入口;重复预检、费用确认,仅传两种 Provider,不生成首帧、不等待成片 |
|
| GET | `/projects/:id/production/plan` | 无消耗执行计划、分阶段数量与阻塞原因 |
|
||||||
|
| GET | `/projects/:id/production/status` | 真实主资产有效性与制作完成状态 |
|
||||||
|
| GET | `/storyboard-shots/:id/provider-input-spec?provider=...` | 指定模型的最终首帧与授权素材,只读检查 |
|
||||||
|
| POST | `/projects/:id/production/start` | 高级串联入口;按执行计划再次预检、费用确认,body 为 {},包含首帧质量流程,不等待异步视频完成 |
|
||||||
|
|
||||||
API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 checkpoint 查询取得阶段状态,避免重复拉取同样内容。
|
API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 checkpoint 查询取得阶段状态,避免重复拉取同样内容。
|
||||||
|
|
||||||
@@ -327,3 +330,4 @@ server {
|
|||||||
本轮新增测试覆盖风格 JSON/锁定/图片 DELETE、身份归并/前置条件/尺寸/显式参考图/母版切换、部分失败、旧响应、草稿保护与费用确认。模型调用均使用模拟 API。当前执行环境未能通过浏览器访问本地开发服务,**未完成浏览器视觉验收及真实模型联调**,请按上述步骤在本地验收。
|
本轮新增测试覆盖风格 JSON/锁定/图片 DELETE、身份归并/前置条件/尺寸/显式参考图/母版切换、部分失败、旧响应、草稿保护与费用确认。模型调用均使用模拟 API。当前执行环境未能通过浏览器访问本地开发服务,**未完成浏览器视觉验收及真实模型联调**,请按上述步骤在本地验收。
|
||||||
|
|
||||||
自动测试使用模拟 API,覆盖既有流程、分镜接口与竞态,以及形态图片自动读取、正式 ID 生图、主图 PUT、费用确认、尺寸校验、候选图选择、批量部分失败和图片加载错误。不等同于真实模型、MySQL 或浏览器视觉联调。请勿用正式项目随意制造失败以测试恢复。
|
自动测试使用模拟 API,覆盖既有流程、分镜接口与竞态,以及形态图片自动读取、正式 ID 生图、主图 PUT、费用确认、尺寸校验、候选图选择、批量部分失败和图片加载错误。不等同于真实模型、MySQL 或浏览器视觉联调。请勿用正式项目随意制造失败以测试恢复。
|
||||||
|
|
||||||
|
|||||||
@@ -30,3 +30,17 @@
|
|||||||
## 回归验证范围
|
## 回归验证范围
|
||||||
|
|
||||||
测试使用不包含旧内部字段的公开 DTO,覆盖页面加载、身份保存/选角、图片主图、Prompt 正式 ID、质量会话隔离、输入变体禁用、失败重试和缺失历史不误报过期。全部 API 使用模拟响应,不触发真实模型。此次不以自动化 DOM 测试代替浏览器视觉验收或真实后端联调。
|
测试使用不包含旧内部字段的公开 DTO,覆盖页面加载、身份保存/选角、图片主图、Prompt 正式 ID、质量会话隔离、输入变体禁用、失败重试和缺失历史不误报过期。全部 API 使用模拟响应,不触发真实模型。此次不以自动化 DOM 测试代替浏览器视觉验收或真实后端联调。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-14 增量同步
|
||||||
|
|
||||||
|
核对后端 dev `c2142681b16798ae5ff865a2eb648a9f45221af2` 的路由、Production Plan/Status/State、主体生图及视频输入编译实现。历史文档中的 `f0321c2` 已无法通过 GitHub compare 解析,本轮以当前源码为准。
|
||||||
|
|
||||||
|
- 接入制作执行计划和真实制作状态,删除“串联不会生成首帧”和必须先有全部主图的旧限制。
|
||||||
|
- 补齐首帧质量回执、视频阻塞/进行中、dispatchCompleted;Provider 启动参数保持空对象。
|
||||||
|
- 人工阻塞与请求错误分开,允许其余 ready 主体部分成功;提交前重新预检,项目归属/活动任务校验保留。
|
||||||
|
- 批量生图默认包含过期主图,但新候选不会自动替换已有主图,页面明确提醒确认主图。
|
||||||
|
- 指定模型最终输入支持只读检查授权素材与实际首帧,通用规格改为明确未指定模型的展示。授权资产绑定管理和实验探针不在本轮新增范围。
|
||||||
|
- 浅色主画布统一白色,输入框使用浅灰,Tab 导航、辅助区和图库使用统一主题变量,首屏 theme-color 与应用一致。深色视频播放背景保留。
|
||||||
|
|
||||||
|
验证使用模拟 API,不调用真实生成模型。浏览器无法访问当前本地预览地址,尚未完成浏览器截图验收或真实后端联调。
|
||||||
|
|||||||
@@ -31,14 +31,19 @@
|
|||||||
|
|
||||||
## 高级串联生产边界
|
## 高级串联生产边界
|
||||||
|
|
||||||
实际后端顺序:形态正式提示词 → 形态图片 → 视频提示词 → 视频任务提交。**没有视觉风格、身份、导演设计、即时状态或首帧生成节点,也不会等待成片。**
|
实际后端顺序:形态正式提示词 → 形态图片 → 首帧质量生成/校验/有限修复 → 视频提示词 → 视频任务提交。视觉风格、身份母版、导演设计和即时状态需提前准备。
|
||||||
|
|
||||||
- 仅剧本 `completed` 可启动;先检查项目/工作流任务、形态活动图片、当前母版引用、镜头规格、主首帧有效性和活动视频。
|
- 通过 GET `/projects/:id/production/plan` 展示可执行、跳过、前置完成后可执行和人工阻塞数量,并保留逐项后端原因。缺少可自动生成的主图或首帧不再被前端直接拦截。
|
||||||
- 为避免中途生成主图使既有首帧失效,前端要求先确认全部形态主图并补齐有效主首帧;因此该入口通常跳过形态生图,只串联仍缺少的文本与视频任务。
|
- GET `/projects/:id/production/status` 展示真实资产有效数量与完成状态。计划是当前快照,前置阶段执行后后端还会重新检查。
|
||||||
- 点击预检只有 GET;付费确认后再次 GET 校验。查询失败或条件变更不提交 POST。单次项目锁仅覆盖本浏览器,不能代替服务器事务和跨客户端互斥。
|
- 剧本未完成、活动任务、资产归属或列表不一致、没有可执行工作时不提交。人工阻塞独立提示,允许其余 ready 形态部分成功;不能保证后续阶段继续。
|
||||||
- 图片与视频模型由后端统一配置,前端不传请求级 Provider。旧串联路由仍解析默认字段,但实际执行节点已改用统一配置;不据此显示虚假模型选择。后端内部文字并发 3、图片与视频并发 2,不接受普通批量面板的覆盖、上限和尺寸配置。
|
- 点击检查只有 GET;费用确认后再次读取计划与状态,条件变化时不提交 POST。POST body 为 `{}`,图片/视频模型与并发读取后端配置,不消费普通批量面板参数。
|
||||||
- 图工作流 `completed` 仅说明经过 finalize,可能仍有错误;`needsManualReview`、`stopReason` 和所有 `errors` 原样显示。视频统计用“已提交”,不标“成片已完成”。
|
- `dispatchCompleted` 表示同步分发结束;`completed` 由后端核对所有有效主体主图、首帧、主视频后给出。回执展示首帧 passed/repairFailed/failed/blocked 及视频 inProgress/blocked,不把提交成功显示为制作完成。
|
||||||
- 此工作流没有持久 checkpoint/恢复/取消接口。网络断开不能推断后端停止,禁止自动重发。已提交的长请求回执按原项目隔离;页面刷新后需从资产状态核对。
|
- 批量主体生图会处理缺失及过期主图,已有过期主图仍须用户确认新候选为主图。前端不读取 rawJson 猜测时效。
|
||||||
|
- 单次项目锁仅覆盖本浏览器;长请求不自动重发,回执按原项目隔离。刷新后通过只读制作状态重新核对。
|
||||||
|
|
||||||
|
## 视频实际输入
|
||||||
|
|
||||||
|
生成规格弹窗提供 GET `/storyboard-shots/:id/provider-input-spec?provider=...`,显式填写目标模型,查看实际首帧、变体原因和授权素材。查询失败保留后端错误;切换镜头或模型清除旧结果。通用生成规格不宣称是指定模型最终输入。本轮未增加授权资产绑定管理或探针生成入口。
|
||||||
|
|
||||||
## 观测指标边界
|
## 观测指标边界
|
||||||
|
|
||||||
@@ -71,3 +76,4 @@
|
|||||||
- 自动测试全部模拟 API,覆盖正式 ID/参数、提示词与图片职责分离、批量部分失败、数量和尺寸校验、候选不覆盖、未完成剧本禁用、费用确认、预检后条件变化、项目切换与旧响应隔离。
|
- 自动测试全部模拟 API,覆盖正式 ID/参数、提示词与图片职责分离、批量部分失败、数量和尺寸校验、候选不覆盖、未完成剧本禁用、费用确认、预检后条件变化、项目切换与旧响应隔离。
|
||||||
- 已通过 lint、格式、TypeScript、Vitest(含质量链路及菜单/折叠交互回归)和生产构建;定位回归见 `src/features/subject-images/asset-impact.test.ts`。测试不触发真实模型或生产任务。
|
- 已通过 lint、格式、TypeScript、Vitest(含质量链路及菜单/折叠交互回归)和生产构建;定位回归见 `src/features/subject-images/asset-impact.test.ts`。测试不触发真实模型或生产任务。
|
||||||
- 当前环境无法打开应用预览,未进行浏览器视觉验收;需在可访问的开发环境检查浅/暗主题和窄屏布局,并由用户明确批准后进行真实小批量模型联调。
|
- 当前环境无法打开应用预览,未进行浏览器视觉验收;需在可访问的开发环境检查浅/暗主题和窄屏布局,并由用户明确批准后进行真实小批量模型联调。
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
### 模型配置
|
### 模型配置
|
||||||
|
|
||||||
图片与视频实际使用后端 `IMAGE_*`、`VIDEO_*` 配置。身份/选角/形态/首帧/视频请求不再发送固定 `provider`;串联生产也不再传固定 `imageProvider`、`videoProvider`。旧串联路由仍保留默认字段,但新执行节点不再消费这些请求级模型值,因此界面不能把它们当实际模型展示。
|
图片与视频实际使用后端 `IMAGE_*`、`VIDEO_*` 配置。身份/选角/形态/首帧/视频请求不再发送固定 `provider`;串联生产也不再传固定 `imageProvider`、`videoProvider`。串联启动路由已移除请求级模型字段;指定模型的只读输入检查独立传入 provider,不影响生产配置。
|
||||||
|
|
||||||
质量面板或资产规格弹窗可按需读取 `/image-providers/capabilities` 和当前镜头的 `keyframe-provider-capability`。此入口只读,不提供会被后端忽略的“前端切换模型”。未声明参考图上限显示“上限未声明”,不声称无限制。
|
质量面板或资产规格弹窗可按需读取 `/image-providers/capabilities` 和当前镜头的 `keyframe-provider-capability`。此入口只读,不提供会被后端忽略的“前端切换模型”。未声明参考图上限显示“上限未声明”,不声称无限制。
|
||||||
|
|
||||||
@@ -64,3 +64,4 @@
|
|||||||
## 验证边界
|
## 验证边界
|
||||||
|
|
||||||
单元与组件测试全部使用模拟 API,验证参数、正式 ID、禁用、费用确认、范围、归属、回执隔离和菜单访问,不触发真实模型任务。浏览器视觉与实际模型联调需要在可访问的开发环境进行;不将 DOM 测试视为视觉验收。
|
单元与组件测试全部使用模拟 API,验证参数、正式 ID、禁用、费用确认、范围、归属、回执隔离和菜单访问,不触发真实模型任务。浏览器视觉与实际模型联调需要在可访问的开发环境进行;不将 DOM 测试视为视觉验收。
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#ededed" />
|
<meta name="theme-color" content="#ffffff" />
|
||||||
<script>
|
<script>
|
||||||
// 在应用挂载前恢复主题,避免深色模式出现白色闪屏。
|
// 在应用挂载前恢复主题,避免深色模式出现白色闪屏。
|
||||||
try {
|
try {
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
((!preference || preference === 'system') && matchMedia('(prefers-color-scheme: dark)').matches)
|
((!preference || preference === 'system') && matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||||
document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
|
document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
|
||||||
document.querySelector('meta[name="theme-color"]').setAttribute('content', dark ? '#111111' : '#ededed')
|
document.querySelector('meta[name="theme-color"]').setAttribute('content', dark ? '#111111' : '#ffffff')
|
||||||
} catch {
|
} catch {
|
||||||
/* 存储不可用时由应用主题配置接管。 */
|
/* 存储不可用时由应用主题配置接管。 */
|
||||||
}
|
}
|
||||||
@@ -26,3 +26,4 @@
|
|||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function useTheme() {
|
|||||||
const disabledColor = dark ? '#303030' : '#e4e4e4'
|
const disabledColor = dark ? '#303030' : '#e4e4e4'
|
||||||
const disabledText = dark ? '#808080' : '#8c8c8c'
|
const disabledText = dark ? '#808080' : '#8c8c8c'
|
||||||
const accentText = dark ? '#5cd693' : '#087c42'
|
const accentText = dark ? '#5cd693' : '#087c42'
|
||||||
const body = dark ? '#111111' : '#ededed'
|
const body = dark ? '#111111' : '#ffffff'
|
||||||
const surface = dark ? '#191919' : '#ffffff'
|
const surface = dark ? '#191919' : '#ffffff'
|
||||||
const subtle = dark ? '#232323' : '#f7f7f7'
|
const subtle = dark ? '#232323' : '#f7f7f7'
|
||||||
const text = dark ? '#d9d9d9' : '#191919'
|
const text = dark ? '#d9d9d9' : '#191919'
|
||||||
@@ -254,7 +254,7 @@ export function useTheme() {
|
|||||||
document.documentElement.style.colorScheme = isDark.value ? 'dark' : 'light'
|
document.documentElement.style.colorScheme = isDark.value ? 'dark' : 'light'
|
||||||
document
|
document
|
||||||
.querySelector('meta[name="theme-color"]')
|
.querySelector('meta[name="theme-color"]')
|
||||||
?.setAttribute('content', isDark.value ? '#111111' : '#ededed')
|
?.setAttribute('content', isDark.value ? '#111111' : '#ffffff')
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(storageKey, preference.value)
|
localStorage.setItem(storageKey, preference.value)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -263,3 +263,4 @@ export function useTheme() {
|
|||||||
})
|
})
|
||||||
return { preference, isDark, theme, overrides }
|
return { preference, isDark, theme, overrides }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import type {
|
|||||||
ProjectVideoStatus,
|
ProjectVideoStatus,
|
||||||
PromptReadiness,
|
PromptReadiness,
|
||||||
ProductionPipelineResult,
|
ProductionPipelineResult,
|
||||||
|
ProductionPlan,
|
||||||
|
ProviderInputSpec,
|
||||||
|
ProjectProductionStatus,
|
||||||
RetryVideosResult,
|
RetryVideosResult,
|
||||||
ShotKeyframe,
|
ShotKeyframe,
|
||||||
ShotVideo,
|
ShotVideo,
|
||||||
@@ -32,6 +35,17 @@ function shotPath(id: string) {
|
|||||||
|
|
||||||
/** 生产链 API;生成请求不设置客户端短超时,也不自动重试。 */
|
/** 生产链 API;生成请求不设置客户端短超时,也不自动重试。 */
|
||||||
export const productionApi = {
|
export const productionApi = {
|
||||||
|
/** 根据显式指定的模型查询最终输入,不能用通用规格推断授权素材是否生效。 */
|
||||||
|
providerInputSpec: (shotId: string, provider: string, signal?: AbortSignal) =>
|
||||||
|
request<ProviderInputSpec>(`${shotPath(shotId)}/provider-input-spec?provider=${encodeURIComponent(provider)}`, {
|
||||||
|
signal
|
||||||
|
}),
|
||||||
|
/** 查询当前计划,不生成图片、视频或提示词。 */
|
||||||
|
plan: (projectId: string, signal?: AbortSignal) =>
|
||||||
|
request<ProductionPlan>(`${projectPath(projectId)}/production/plan`, { signal }),
|
||||||
|
/** 读取真实主资产完成状态,不能用任务提交回执代替。 */
|
||||||
|
status: (projectId: string, signal?: AbortSignal) =>
|
||||||
|
request<ProjectProductionStatus>(`${projectPath(projectId)}/production/status`, { signal }),
|
||||||
startPipeline: (projectId: string) =>
|
startPipeline: (projectId: string) =>
|
||||||
request<ProductionPipelineResult>(`${projectPath(projectId)}/production/start`, {
|
request<ProductionPipelineResult>(`${projectPath(projectId)}/production/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue'
|
|||||||
import ConfirmAction from '../../workflows/ConfirmAction.vue'
|
import ConfirmAction from '../../workflows/ConfirmAction.vue'
|
||||||
import { downloadText, formatDate } from '../../../lib/format'
|
import { downloadText, formatDate } from '../../../lib/format'
|
||||||
import { useAdvancedProduction } from '../useAdvancedProduction'
|
import { useAdvancedProduction } from '../useAdvancedProduction'
|
||||||
|
import ProductionPlanPanel from './ProductionPlanPanel.vue'
|
||||||
|
|
||||||
/** 高级入口明确真实流程边界,默认折叠且不在挂载时触发预检或生成。 */
|
/** 高级入口明确真实流程边界,默认折叠且不在挂载时触发预检或生成。 */
|
||||||
const { id, operation, session, checking, error, check, blocked, preflight, start } = useAdvancedProduction()
|
const { id, operation, session, checking, error, check, blocked, preflight, start } = useAdvancedProduction()
|
||||||
@@ -31,12 +32,10 @@ function exportReceipt() {
|
|||||||
<NCollapse v-model:expanded-names="expanded" class="mt-5"
|
<NCollapse v-model:expanded-names="expanded" class="mt-5"
|
||||||
><NCollapseItem name="advanced" title="高级:串联生产(全项目)">
|
><NCollapseItem name="advanced" title="高级:串联生产(全项目)">
|
||||||
<NAlert type="warning" :show-icon="false"
|
<NAlert type="warning" :show-icon="false"
|
||||||
>依次补齐形态提示词、形态参考图、视频提示词并提交视频任务。不会生成视觉风格、身份母版、导演设计、即时状态或首帧。请先在各阶段完成验收。</NAlert
|
>依次补齐形态提示词、参考图、首帧质量校验与修复、视频提示词,再提交视频任务。视觉风格、身份母版、导演设计和即时状态仍需提前准备。</NAlert
|
||||||
>
|
>
|
||||||
<p class="mt-3 text-xs leading-6 text-muted">
|
<p class="mt-3 text-xs leading-6 text-muted">
|
||||||
范围固定为全项目,跳过已有结果;不使用上方并发、覆盖、数量上限和尺寸配置。后端固定文字并发
|
范围固定为全项目,跳过已有结果;不使用上方并发、覆盖、数量上限和尺寸配置。可能产生文字、图片、首帧校验与修复、视频模型费用。模型与并发由服务端配置决定。请确认其他页面没有任务;断网后先刷新制作状态,勿直接重试。
|
||||||
3、图片和视频并发 2,可能产生模型费用。此流程没有持久运行锁或恢复
|
|
||||||
checkpoint;请确认其他页面/终端没有任务。断网后先查资产,勿直接重试。
|
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||||
<NButton :disabled="blocked || checking" :loading="checking" @click="preflight"
|
<NButton :disabled="blocked || checking" :loading="checking" @click="preflight"
|
||||||
@@ -46,7 +45,7 @@ function exportReceipt() {
|
|||||||
label="启动串联生产"
|
label="启动串联生产"
|
||||||
:disabled="blocked || checking || !check || !!check.issues.length"
|
:disabled="blocked || checking || !check || !!check.issues.length"
|
||||||
acknowledgement
|
acknowledgement
|
||||||
:description="`项目 ${id}:全项目串联生产,不生成首帧,也不等待视频完成。确认后会重新检查前置条件;可能产生文字、图片和视频模型费用。`"
|
:description="`项目 ${id}:全项目串联生产,包含首帧生成、校验与有限修复,不等待异步视频完成。确认后会重新读取计划;有阻塞时先处理可执行部分,成功资产会保留。可能产生文字、图片、校验和视频模型费用。`"
|
||||||
@confirm="start"
|
@confirm="start"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,8 +62,9 @@ function exportReceipt() {
|
|||||||
<ul v-if="check.issues.length" class="mt-2 space-y-2 text-xs text-danger">
|
<ul v-if="check.issues.length" class="mt-2 space-y-2 text-xs text-danger">
|
||||||
<li v-for="issue in check.issues" :key="issue">{{ issue }}</li>
|
<li v-for="issue in check.issues" :key="issue">{{ issue }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="mt-2 text-sm">前置检查通过,提交前仍会再次校验。</p>
|
<p v-else class="mt-2 text-sm">可以执行计划中的任务,提交前仍会再次校验。</p>
|
||||||
</div>
|
</div>
|
||||||
|
<ProductionPlanPanel v-if="check" :plan="check.plan" :status="check.status" />
|
||||||
<p v-if="operation.pending && operation.label === '高级串联生产'" role="status" class="mt-3 text-sm">
|
<p v-if="operation.pending && operation.label === '高级串联生产'" role="status" class="mt-3 text-sm">
|
||||||
请求进行中,请勿重复提交。
|
请求进行中,请勿重复提交。
|
||||||
</p>
|
</p>
|
||||||
@@ -73,7 +73,13 @@ function exportReceipt() {
|
|||||||
<h3 class="text-sm font-semibold">
|
<h3 class="text-sm font-semibold">
|
||||||
串联生产回执 ·
|
串联生产回执 ·
|
||||||
{{
|
{{
|
||||||
receipt.needsManualReview ? '需要人工处理' : receipt.completed ? '流程已返回' : '流程未完成'
|
receipt.needsManualReview
|
||||||
|
? '需要人工处理'
|
||||||
|
: receipt.completed
|
||||||
|
? '制作已完成'
|
||||||
|
: receipt.dispatchCompleted
|
||||||
|
? '任务已分发,等待视频完成'
|
||||||
|
: '本轮未完成'
|
||||||
}}
|
}}
|
||||||
</h3>
|
</h3>
|
||||||
<NButton text size="small" @click="exportReceipt">导出回执</NButton>
|
<NButton text size="small" @click="exportReceipt">导出回执</NButton>
|
||||||
@@ -87,11 +93,23 @@ function exportReceipt() {
|
|||||||
失败 {{ row.result.failed }}</template
|
失败 {{ row.result.failed }}</template
|
||||||
><template v-else>未执行或未返回</template>
|
><template v-else>未执行或未返回</template>
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mt-2 text-xs">
|
||||||
|
首帧质量:<template v-if="receipt.keyframeQualityResult">
|
||||||
|
共 {{ receipt.keyframeQualityResult.total }} · 校验通过
|
||||||
|
{{ receipt.keyframeQualityResult.passed }} · 跳过 {{ receipt.keyframeQualityResult.skipped }} ·
|
||||||
|
阻塞 {{ receipt.keyframeQualityResult.blocked }} · 修复未通过
|
||||||
|
{{ receipt.keyframeQualityResult.repairFailed }} · 执行失败
|
||||||
|
{{ receipt.keyframeQualityResult.failed }} </template
|
||||||
|
><template v-else>未执行或未返回</template>
|
||||||
|
</p>
|
||||||
<p class="mt-2 text-xs">
|
<p class="mt-2 text-xs">
|
||||||
视频任务:<template v-if="receipt.videoGenerationResult"
|
视频任务:<template v-if="receipt.videoGenerationResult"
|
||||||
>共 {{ receipt.videoGenerationResult.total }} · 已提交
|
>共 {{ receipt.videoGenerationResult.total }} · 已提交
|
||||||
{{ receipt.videoGenerationResult.created }} · 跳过 {{ receipt.videoGenerationResult.skipped }} ·
|
{{ receipt.videoGenerationResult.created }} · 跳过
|
||||||
提交失败 {{ receipt.videoGenerationResult.failed }}</template
|
{{ receipt.videoGenerationResult.skipped }} (含进行中
|
||||||
|
{{ receipt.videoGenerationResult.inProgress }}、阻塞
|
||||||
|
{{ receipt.videoGenerationResult.blocked }}) · 提交失败
|
||||||
|
{{ receipt.videoGenerationResult.failed }}</template
|
||||||
><template v-else>未执行或未返回</template>
|
><template v-else>未执行或未返回</template>
|
||||||
</p>
|
</p>
|
||||||
<NAlert
|
<NAlert
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { NAlert, NCollapse, NCollapseItem, NScrollbar } from 'naive-ui'
|
||||||
|
import type { ProductionPlan, ProjectProductionStatus } from '../types'
|
||||||
|
|
||||||
|
/** 展示后端只读执行计划与真实资产状态,不自行推断阻塞是否可自动解除。 */
|
||||||
|
const props = defineProps<{ plan: ProductionPlan; status: ProjectProductionStatus }>()
|
||||||
|
const rows = computed(() => [
|
||||||
|
{ label: '主体参考图', stage: props.plan.subjectImages },
|
||||||
|
{ label: '首帧质量', stage: props.plan.keyframes },
|
||||||
|
{ label: '视频提示词', stage: props.plan.videoPrompts },
|
||||||
|
{ label: '视频任务', stage: props.plan.videos }
|
||||||
|
])
|
||||||
|
const stageNames = { subject_images: '主体参考图', keyframes: '首帧', videos: '视频', completed: '全部完成' }
|
||||||
|
/** 保留全部后端问题,包括预计可由前置阶段解决的问题,便于核对计划。 */
|
||||||
|
const problems = computed(() => [
|
||||||
|
...props.plan.details.subjectImages
|
||||||
|
.filter(item => item.issues.length)
|
||||||
|
.map(item => ({
|
||||||
|
key: `form:${item.subjectFormId}`,
|
||||||
|
label: `主体参考图 · ${item.subjectName} / ${item.formName}(${item.subjectRef})`,
|
||||||
|
issues: item.issues
|
||||||
|
})),
|
||||||
|
...[
|
||||||
|
{ label: '首帧', key: 'keyframe', items: props.plan.details.keyframes },
|
||||||
|
{ label: '视频提示词', key: 'prompt', items: props.plan.details.videoPrompts },
|
||||||
|
{ label: '视频', key: 'video', items: props.plan.details.videos }
|
||||||
|
].flatMap(group =>
|
||||||
|
group.items
|
||||||
|
.filter(item => item.issues.length)
|
||||||
|
.map(item => ({
|
||||||
|
key: `${group.key}:${item.shotId}`,
|
||||||
|
label: `${group.label} · 镜头 ${item.shotNo}(${item.shotId})`,
|
||||||
|
issues: item.issues
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="surface-inset mt-3 p-4" aria-label="制作执行计划">
|
||||||
|
<h3 class="text-sm font-semibold">执行计划 · 仅检查,不产生生成费用</h3>
|
||||||
|
<p class="mt-2 text-xs" role="status">
|
||||||
|
当前制作状态:{{ status.completed ? '制作已完成' : `待完成${stageNames[status.stage]}` }} · 有效主体图
|
||||||
|
{{ status.subjectImages.current }}/{{ status.subjectImages.total }} · 有效首帧
|
||||||
|
{{ status.keyframes.skipped }}/{{ status.keyframes.total }} · 有效主视频 {{ status.videos.skipped }}/{{
|
||||||
|
status.videos.total
|
||||||
|
}}
|
||||||
|
· 视频进行中 {{ status.videos.inProgress }}
|
||||||
|
</p>
|
||||||
|
<NScrollbar x-scrollable class="mt-3">
|
||||||
|
<table class="production-plan-table">
|
||||||
|
<caption class="sr-only">
|
||||||
|
各阶段当前执行数量
|
||||||
|
</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">阶段</th>
|
||||||
|
<th scope="col">总数</th>
|
||||||
|
<th scope="col">可执行</th>
|
||||||
|
<th scope="col">跳过</th>
|
||||||
|
<th scope="col">前置完成后可执行</th>
|
||||||
|
<th scope="col">需人工处理</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in rows" :key="row.label">
|
||||||
|
<th scope="row">{{ row.label }}</th>
|
||||||
|
<td>{{ row.stage.total }}</td>
|
||||||
|
<td>{{ row.stage.planned }}</td>
|
||||||
|
<td>{{ row.stage.skipped }}</td>
|
||||||
|
<td>{{ row.stage.upstreamResolvable }}</td>
|
||||||
|
<td>{{ row.stage.manualBlocked }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</NScrollbar>
|
||||||
|
<p class="mt-2 text-xs text-muted">
|
||||||
|
计划按当前资产估算,前置生成后会重新检查。视频“跳过”包含已有有效视频和进行中的任务。已有过期主体主图生成新候选后,仍需在形态图库确认新主图。
|
||||||
|
</p>
|
||||||
|
<NAlert v-if="plan.hasManualBlockers" type="warning" :show-icon="false" class="mt-3">
|
||||||
|
各阶段合计
|
||||||
|
{{ plan.manualBlocked }}
|
||||||
|
项需人工处理(同一镜头可能重复计数)。可先生成就绪的主体参考图;成功结果会保留,遇到阻塞时后续阶段可能暂停。
|
||||||
|
</NAlert>
|
||||||
|
<NCollapse v-if="problems.length" class="mt-3">
|
||||||
|
<NCollapseItem name="issues" title="查看前置条件与阻塞原因">
|
||||||
|
<ul class="space-y-3 text-xs">
|
||||||
|
<li v-for="item in problems" :key="item.key">
|
||||||
|
<strong class="break-all">{{ item.label }}</strong>
|
||||||
|
<p v-for="(issue, index) in item.issues" :key="index" class="mt-1 break-words">
|
||||||
|
{{ issue.reason
|
||||||
|
}}<span v-if="issue.missingSubjects?.length">
|
||||||
|
· 关联主体:{{ issue.missingSubjects.join('、') }}</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</NCollapseItem>
|
||||||
|
</NCollapse>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.production-plan-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 540px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.production-plan-table th,
|
||||||
|
.production-plan-table td {
|
||||||
|
padding: 9px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.production-plan-table thead {
|
||||||
|
background: var(--app-control);
|
||||||
|
}
|
||||||
|
.production-plan-table tbody {
|
||||||
|
background: var(--app-surface);
|
||||||
|
}
|
||||||
|
.production-plan-table tbody tr:nth-child(even) {
|
||||||
|
background: var(--app-subtle);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||||
|
import { NAlert, NButton, NInput } from 'naive-ui'
|
||||||
|
import { productionApi } from '../api'
|
||||||
|
import { errorMessage } from '../../../lib/http'
|
||||||
|
import type { ProviderInputSpec } from '../types'
|
||||||
|
|
||||||
|
/** 最终输入检查仅发 GET,切换模型或镜头后清除旧回执,不修改授权绑定。 */
|
||||||
|
const props = defineProps<{ shotId: string }>()
|
||||||
|
const provider = ref('')
|
||||||
|
const targetProvider = computed(() => provider.value.trim().toLowerCase())
|
||||||
|
const result = ref<ProviderInputSpec | null>(null)
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
let version = 0
|
||||||
|
let controller: AbortController | undefined
|
||||||
|
/** 使晚到响应失效,避免把另一镜头或模型的授权信息展示到当前页面。 */
|
||||||
|
function clear() {
|
||||||
|
version++
|
||||||
|
controller?.abort()
|
||||||
|
result.value = null
|
||||||
|
error.value = ''
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
watch([() => props.shotId, targetProvider], clear, { flush: 'sync' })
|
||||||
|
onScopeDispose(clear)
|
||||||
|
|
||||||
|
/** 查询实际输入并核对镜头及模型归属。 */
|
||||||
|
async function inspect() {
|
||||||
|
if (!props.shotId || !targetProvider.value || loading.value) return
|
||||||
|
clear()
|
||||||
|
const current = version
|
||||||
|
const shotId = props.shotId
|
||||||
|
const providerName = targetProvider.value
|
||||||
|
controller = new AbortController()
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await productionApi.providerInputSpec(shotId, providerName, controller.signal)
|
||||||
|
if (current !== version) return
|
||||||
|
if (data.shotId !== shotId || data.targetProvider !== providerName)
|
||||||
|
throw new Error('最终输入与当前镜头或模型不匹配,请重新查询。')
|
||||||
|
result.value = data
|
||||||
|
} catch (cause) {
|
||||||
|
if (current === version) error.value = errorMessage(cause)
|
||||||
|
} finally {
|
||||||
|
if (current === version) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="surface-inset mt-4 p-4" aria-label="视频模型最终输入检查">
|
||||||
|
<h4 class="text-sm font-medium">按模型检查实际输入</h4>
|
||||||
|
<p class="mt-2 text-xs text-muted">
|
||||||
|
填写后端使用的视频模型标识,检查实际首帧与授权素材。只读查询,不产生生成费用。
|
||||||
|
</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<NInput
|
||||||
|
v-model:value="provider"
|
||||||
|
placeholder="例如 seedance"
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
:input-props="{ 'aria-label': '视频模型标识' }"
|
||||||
|
/>
|
||||||
|
<NButton :disabled="!targetProvider || loading" :loading="loading" @click="inspect">检查实际输入</NButton>
|
||||||
|
</div>
|
||||||
|
<NAlert v-if="error" type="error" :show-icon="false" class="mt-3">{{ error }}</NAlert>
|
||||||
|
<div v-if="result" class="mt-3 space-y-2 break-all text-xs">
|
||||||
|
<p>目标模型:{{ result.targetProvider }}</p>
|
||||||
|
<p>业务主首帧:{{ result.businessKeyframe.id }}</p>
|
||||||
|
<p>
|
||||||
|
实际输入首帧:{{ result.providerInputKeyframe.id }} ·
|
||||||
|
{{ result.providerInputKeyframe.source === 'provider_variant' ? '模型专用变体' : '业务主首帧' }}
|
||||||
|
</p>
|
||||||
|
<p v-if="result.providerInputKeyframe.variantReason">
|
||||||
|
变体原因:{{ result.providerInputKeyframe.variantReason }}
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li v-for="reference in result.references" :key="`${reference.subjectId}:${reference.subjectFormId}`">
|
||||||
|
{{ reference.subjectName }} / {{ reference.subjectFormName }}({{ reference.subjectRef }}):
|
||||||
|
<template v-if="reference.providerAsset"
|
||||||
|
>授权素材 {{ reference.providerAsset.assetId }} ·
|
||||||
|
{{ reference.providerAsset.provider }}</template
|
||||||
|
>
|
||||||
|
<template v-else>未返回该模型的有效授权素材</template>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import ProviderInputInspector from './ProviderInputInspector.vue'
|
||||||
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
|
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ExternalLink, ImagePlus, RefreshCw } from '@lucide/vue'
|
import { ExternalLink, ImagePlus, RefreshCw } from '@lucide/vue'
|
||||||
@@ -502,6 +503,7 @@ async function inspectSpecs() {
|
|||||||
<NAlert v-if="specError" role="alert" type="error" :show-icon="false" class="mt-4">{{ specError }}</NAlert>
|
<NAlert v-if="specError" role="alert" type="error" :show-icon="false" class="mt-4">{{ specError }}</NAlert>
|
||||||
<p v-if="inspecting" role="status" class="mt-4 text-xs text-muted">正在读取生成规格…</p>
|
<p v-if="inspecting" role="status" class="mt-4 text-xs text-muted">正在读取生成规格…</p>
|
||||||
<ModelCapabilities :shot-id="shot.shotId" />
|
<ModelCapabilities :shot-id="shot.shotId" />
|
||||||
|
<ProviderInputInspector :shot-id="shot.shotId" />
|
||||||
<NCollapse v-if="keyframeSpec || videoSpec" class="mt-5 pt-4 text-xs" :default-expanded-names="['details']"
|
<NCollapse v-if="keyframeSpec || videoSpec" class="mt-5 pt-4 text-xs" :default-expanded-names="['details']"
|
||||||
><NCollapseItem name="details"
|
><NCollapseItem name="details"
|
||||||
><template #header>本镜确定性生成规格</template>
|
><template #header>本镜确定性生成规格</template>
|
||||||
@@ -530,7 +532,7 @@ async function inspectSpecs() {
|
|||||||
业务主首帧 ID · {{ videoSpec.keyframe.id }}
|
业务主首帧 ID · {{ videoSpec.keyframe.id }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="videoSpec.providerInputKeyframe" class="mt-2 break-all text-[10px] text-muted">
|
<p v-if="videoSpec.providerInputKeyframe" class="mt-2 break-all text-[10px] text-muted">
|
||||||
模型输入首帧 ID · {{ videoSpec.providerInputKeyframe.id }}
|
通用规格首帧 ID(未指定模型) · {{ videoSpec.providerInputKeyframe.id }}
|
||||||
{{
|
{{
|
||||||
videoSpec.providerInputKeyframe.source === 'provider_variant'
|
videoSpec.providerInputKeyframe.source === 'provider_variant'
|
||||||
? '(专用变体)'
|
? '(专用变体)'
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ export interface KeyframeSpec {
|
|||||||
|
|
||||||
/** 视频生成规格中的主体参考图。 */
|
/** 视频生成规格中的主体参考图。 */
|
||||||
export interface VideoReference {
|
export interface VideoReference {
|
||||||
|
/** 当前目标视频模型的授权素材,不拼接模型私有地址。 */
|
||||||
|
providerAsset?: { provider: string; assetId: string }
|
||||||
subjectId: string
|
subjectId: string
|
||||||
subjectRef: string
|
subjectRef: string
|
||||||
subjectName: string
|
subjectName: string
|
||||||
@@ -302,15 +304,20 @@ export interface ProductionSession {
|
|||||||
pipelineReceipt?: ProductionPipelineResult | null
|
pipelineReceipt?: ProductionPipelineResult | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 串联生产只汇总节点结果;completed 不代表视频已完成。 */
|
/** 串联生产回执;completed 由后端核对全部当前有效主资产后给出。 */
|
||||||
export interface ProductionPipelineResult {
|
export interface ProductionPipelineResult {
|
||||||
projectId: string
|
projectId: string
|
||||||
imageProvider: string
|
|
||||||
videoProvider: string
|
|
||||||
visualPromptResult?: PipelineBatchResult
|
visualPromptResult?: PipelineBatchResult
|
||||||
subjectImageResult?: PipelineBatchResult
|
subjectImageResult?: PipelineBatchResult
|
||||||
videoPromptResult?: PipelineBatchResult
|
videoPromptResult?: PipelineBatchResult
|
||||||
videoGenerationResult?: Omit<PipelineBatchResult, 'generated'> & { created: number }
|
keyframeQualityResult?: PipelineKeyframeQualityResult
|
||||||
|
videoGenerationResult?: Omit<PipelineBatchResult, 'generated'> & {
|
||||||
|
created: number
|
||||||
|
inProgress: number
|
||||||
|
blocked: number
|
||||||
|
}
|
||||||
|
/** 同步任务分发结束;异步视频仍可能正在生成和校验。 */
|
||||||
|
dispatchCompleted: boolean
|
||||||
completed: boolean
|
completed: boolean
|
||||||
needsManualReview: boolean
|
needsManualReview: boolean
|
||||||
stopReason: string
|
stopReason: string
|
||||||
@@ -335,3 +342,116 @@ export interface VideoInputKeyframe {
|
|||||||
|
|
||||||
/** 后端公开的模型输入恢复状态,不能当作普通重试状态。 */
|
/** 后端公开的模型输入恢复状态,不能当作普通重试状态。 */
|
||||||
export type ProviderInputRecoveryStatus = 'not_required' | 'required' | 'variant_ready' | 'exhausted'
|
export type ProviderInputRecoveryStatus = 'not_required' | 'required' | 'variant_ready' | 'exhausted'
|
||||||
|
|
||||||
|
/** 项目当前制作阶段,与剧本创作状态分别展示。 */
|
||||||
|
export type ProductionStage = 'subject_images' | 'keyframes' | 'videos' | 'completed'
|
||||||
|
|
||||||
|
/** 主体参考图的公开就绪明细,阻塞原因由后端提供。 */
|
||||||
|
export interface SubjectImageReadinessItem {
|
||||||
|
subjectFormId: string
|
||||||
|
subjectRef: string
|
||||||
|
subjectName: string
|
||||||
|
module: string
|
||||||
|
formName: string
|
||||||
|
status: 'ready' | 'skipped' | 'blocked'
|
||||||
|
primaryImageId: string | null
|
||||||
|
primaryImageStale: boolean
|
||||||
|
issues: ProductionIssue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无消耗计划中每个阶段的数量;不同阶段的阻塞数不可当作去重镜头数。 */
|
||||||
|
export interface ProductionPlanStage {
|
||||||
|
total: number
|
||||||
|
planned: number
|
||||||
|
skipped: number
|
||||||
|
upstreamResolvable: number
|
||||||
|
manualBlocked: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端执行计划只读取资产,不触发模型生成。 */
|
||||||
|
export interface ProductionPlan {
|
||||||
|
projectId: string
|
||||||
|
completed: boolean
|
||||||
|
stage: ProductionStage
|
||||||
|
hasManualBlockers: boolean
|
||||||
|
manualBlocked: number
|
||||||
|
subjectImages: ProductionPlanStage
|
||||||
|
keyframes: ProductionPlanStage
|
||||||
|
videoPrompts: ProductionPlanStage
|
||||||
|
videos: ProductionPlanStage
|
||||||
|
details: {
|
||||||
|
subjectImages: SubjectImageReadinessItem[]
|
||||||
|
keyframes: KeyframeReadinessItem[]
|
||||||
|
videoPrompts: PromptReadinessItem[]
|
||||||
|
videos: VideoReadinessItem[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 真实制作状态;只有主体主图、主首帧和主视频均有效才完成。 */
|
||||||
|
export interface ProjectProductionStatus {
|
||||||
|
projectId: string
|
||||||
|
completed: boolean
|
||||||
|
stage: ProductionStage
|
||||||
|
subjectImages: {
|
||||||
|
total: number
|
||||||
|
current: number
|
||||||
|
missing: number
|
||||||
|
stale: number
|
||||||
|
complete: boolean
|
||||||
|
items: {
|
||||||
|
subjectFormId: string
|
||||||
|
subjectRef: string
|
||||||
|
subjectName: string
|
||||||
|
module: string
|
||||||
|
formName: string
|
||||||
|
primaryImageId: string | null
|
||||||
|
status: 'missing' | 'current' | 'stale'
|
||||||
|
reason: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
keyframes: Pick<KeyframeReadiness, 'total' | 'ready' | 'skipped' | 'blocked' | 'stalePrimaryKeyframe' | 'items'> & {
|
||||||
|
complete: boolean
|
||||||
|
}
|
||||||
|
videos: Pick<
|
||||||
|
VideoReadiness,
|
||||||
|
| 'total'
|
||||||
|
| 'ready'
|
||||||
|
| 'skipped'
|
||||||
|
| 'inProgress'
|
||||||
|
| 'blocked'
|
||||||
|
| 'stalePrimaryVideo'
|
||||||
|
| 'providerInputRecovery'
|
||||||
|
| 'items'
|
||||||
|
> & {
|
||||||
|
complete: boolean
|
||||||
|
tasks: ProjectVideoStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首帧质量流程保留未通过和异常失败计数,不将已有主图视作本轮校验通过。 */
|
||||||
|
export interface PipelineKeyframeQualityResult {
|
||||||
|
total: number
|
||||||
|
passed: number
|
||||||
|
repairFailed: number
|
||||||
|
failed: number
|
||||||
|
skipped: number
|
||||||
|
blocked: number
|
||||||
|
stalePrimaryKeyframe: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指定模型的最终输入查询回执;此接口不会生成图片或视频。 */
|
||||||
|
export interface ProviderInputSpec {
|
||||||
|
shotId: string
|
||||||
|
targetProvider: string
|
||||||
|
businessKeyframe: Pick<VideoInputKeyframe, 'id' | 'imageUrl'>
|
||||||
|
providerInputKeyframe: Pick<
|
||||||
|
VideoGenerationSpec['providerInputKeyframe'],
|
||||||
|
'id' | 'imageUrl' | 'source' | 'targetProvider' | 'variantReason'
|
||||||
|
>
|
||||||
|
references: (Pick<
|
||||||
|
VideoReference,
|
||||||
|
'subjectId' | 'subjectRef' | 'subjectName' | 'module' | 'subjectFormId' | 'subjectFormName'
|
||||||
|
> & {
|
||||||
|
providerAsset: { provider: string; assetId: string } | null
|
||||||
|
})[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,66 +2,59 @@ import { computed, onScopeDispose, ref, watch } from 'vue'
|
|||||||
import { projectsApi } from '../projects/api'
|
import { projectsApi } from '../projects/api'
|
||||||
import { useProjectContext } from '../projects/context'
|
import { useProjectContext } from '../projects/context'
|
||||||
import { subjectImagesApi } from '../subject-images/api'
|
import { subjectImagesApi } from '../subject-images/api'
|
||||||
import { hasRunningImages, primaryImage } from '../subject-images/model'
|
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 { 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 [project, checkpoints, forms, keyframes, prompts, videos] = 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.keyframeReadiness(projectId, true),
|
productionApi.plan(projectId),
|
||||||
productionApi.promptReadiness(projectId, true),
|
productionApi.status(projectId)
|
||||||
productionApi.videoReadiness(projectId, false)
|
|
||||||
])
|
])
|
||||||
if (project.id !== projectId || forms.some(form => form.subject.projectId !== projectId))
|
if (
|
||||||
|
project.id !== projectId ||
|
||||||
|
plan.projectId !== projectId ||
|
||||||
|
status.projectId !== projectId ||
|
||||||
|
forms.some(form => form.subject.projectId !== projectId)
|
||||||
|
)
|
||||||
throw new Error('预检返回了其他项目的数据,请重新读取。')
|
throw new Error('预检返回了其他项目的数据,请重新读取。')
|
||||||
const issues: string[] = []
|
const issues: string[] = []
|
||||||
const shotIds = new Set(keyframes.items.map(item => item.shotId))
|
const shotIds = new Set(plan.details.keyframes.map(item => item.shotId))
|
||||||
if (
|
if (
|
||||||
shotIds.size !== keyframes.total ||
|
shotIds.size !== plan.keyframes.total ||
|
||||||
[prompts, videos].some(
|
plan.details.keyframes.length !== plan.keyframes.total ||
|
||||||
data =>
|
plan.subjectImages.total !== forms.length ||
|
||||||
new Set(data.items.map(item => item.shotId)).size !== shotIds.size ||
|
plan.details.subjectImages.length !== forms.length ||
|
||||||
data.items.some(item => !shotIds.has(item.shotId))
|
plan.details.subjectImages.some(item => !forms.some(form => form.id === item.subjectFormId)) ||
|
||||||
|
[plan.details.videoPrompts, plan.details.videos, status.keyframes.items, status.videos.items].some(
|
||||||
|
items =>
|
||||||
|
items.length !== shotIds.size ||
|
||||||
|
new Set(items.map(item => item.shotId)).size !== shotIds.size ||
|
||||||
|
items.some(item => !shotIds.has(item.shotId))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
issues.push('各阶段镜头列表不一致,可能正在重新拆解,请刷新后重试。')
|
issues.push('各阶段资产列表不一致,可能正在重新拆解,请刷新后重试。')
|
||||||
if (project.status !== 'completed') issues.push('剧本尚未完成,请先完成剧本创作。')
|
if (project.status !== 'completed') issues.push('剧本尚未完成,请先完成剧本创作。')
|
||||||
if (hasRunningWorkflow(checkpoints)) issues.push('项目仍有后台任务或工作流进行中。')
|
if (hasRunningWorkflow(checkpoints)) issues.push('项目仍有后台任务或工作流进行中。')
|
||||||
if (!forms.length) issues.push('尚无正式形态,请先完成拆解。')
|
if (!forms.length || !plan.keyframes.total) issues.push('尚无正式形态或镜头,请先完成拆解和分镜。')
|
||||||
if (forms.some(form => hasRunningImages(form.images))) issues.push('形态图片仍在生成中。')
|
if (forms.some(form => hasRunningImages(form.images))) issues.push('形态图片仍在生成中。')
|
||||||
if (forms.some(form => !primaryImage(form.images)?.imageUrl))
|
|
||||||
issues.push('请先补齐并确认所有形态主图,更新过期母版引用,再生成首帧。')
|
|
||||||
if (
|
if (
|
||||||
!keyframes.total ||
|
status.videos.inProgress ||
|
||||||
keyframes.items.length !== keyframes.total ||
|
status.videos.items.some(item => item.status === 'in_progress' || item.activeVideoId)
|
||||||
keyframes.items.some(
|
|
||||||
item =>
|
|
||||||
!item.primaryKeyframeId || item.primaryKeyframeStale || item.issues.length || item.status === 'blocked'
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
issues.push('请先为全部镜头完成设计、身份检查和有效主首帧;串联流程不会生成首帧。')
|
|
||||||
if (
|
|
||||||
prompts.total !== keyframes.total ||
|
|
||||||
prompts.items.length !== prompts.total ||
|
|
||||||
prompts.items.some(item => item.issues.length || item.status === 'blocked')
|
|
||||||
)
|
|
||||||
issues.push('视频提示词的生成规格或参考图不完整,请到分镜页修复。')
|
|
||||||
if (
|
|
||||||
videos.total !== keyframes.total ||
|
|
||||||
videos.items.length !== videos.total ||
|
|
||||||
videos.items.some(item => item.issues.some(issue => issue.code !== 'missing_prompt'))
|
|
||||||
)
|
|
||||||
issues.push('视频前置检查未通过,请检查参考图和主首帧。')
|
|
||||||
if (videos.inProgress || videos.items.some(item => item.status === 'in_progress' || item.activeVideoId))
|
|
||||||
issues.push('存在活动视频任务,请等待结束后再操作。')
|
issues.push('存在活动视频任务,请等待结束后再操作。')
|
||||||
return { projectId, issues, shotCount: keyframes.total, checkedAt: new Date().toISOString() }
|
if (status.completed || plan.completed) issues.push('当前项目制作已完成,无需再次启动。')
|
||||||
|
else if (![plan.subjectImages, plan.keyframes, plan.videoPrompts, plan.videos].some(stage => stage.planned > 0))
|
||||||
|
issues.push('当前没有可执行的制作任务,请先处理计划中的阻塞项。')
|
||||||
|
// 人工阻塞在计划中独立展示;不拦截其余 ready 形态的部分成功生产。
|
||||||
|
return { projectId, issues, plan, status, shotCount: plan.keyframes.total, checkedAt: new Date().toISOString() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 预检不付费;确认后再次预检并锁住本浏览器项目,长请求不自动重发。 */
|
/** 预检不付费;确认后再次预检并锁住本浏览器项目,长请求不自动重发。 */
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
|
|||||||
:description="
|
:description="
|
||||||
force
|
force
|
||||||
? '对项目全部形态调用图片模型新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
|
? '对项目全部形态调用图片模型新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
|
||||||
: '仅对没有主图的形态调用图片模型生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
|
: '对缺少主图或主图已过期的形态调用图片模型,仍有效的主图跳过。已有过期主图不会被自动替换,生成后请在图片记录中确认新主图。操作面向整个项目,不受列表筛选影响。'
|
||||||
"
|
"
|
||||||
@confirm="generateProject"
|
@confirm="generateProject"
|
||||||
/>
|
/>
|
||||||
@@ -846,7 +846,7 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
|
|||||||
/* 外层使用浅灰分组,输入与下拉恢复为表面色,避免同色后失去控件边界。 */
|
/* 外层使用浅灰分组,输入与下拉恢复为表面色,避免同色后失去控件边界。 */
|
||||||
--app-field: var(--app-surface);
|
--app-field: var(--app-surface);
|
||||||
--app-field-hover: var(--app-surface);
|
--app-field-hover: var(--app-surface);
|
||||||
background: color-mix(in srgb, var(--app-subtle) 96%, white);
|
background: var(--app-subtle);
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
.form-image-toolbar {
|
.form-image-toolbar {
|
||||||
|
|||||||
+12
-3
@@ -2,13 +2,13 @@
|
|||||||
:root {
|
:root {
|
||||||
--app-ink: #191919;
|
--app-ink: #191919;
|
||||||
--app-muted: #707070;
|
--app-muted: #707070;
|
||||||
--app-body: #ededed;
|
--app-body: #ffffff;
|
||||||
--app-surface: #ffffff;
|
--app-surface: #ffffff;
|
||||||
--app-subtle: #f7f7f7;
|
--app-subtle: #f7f7f7;
|
||||||
--app-control: #f0f0f0;
|
--app-control: #f0f0f0;
|
||||||
--app-control-hover: #e5e5e5;
|
--app-control-hover: #e5e5e5;
|
||||||
--app-field: #ffffff;
|
--app-field: #f0f0f0;
|
||||||
--app-field-hover: #ffffff;
|
--app-field-hover: #e8e8e8;
|
||||||
--app-border: #e5e5e5;
|
--app-border: #e5e5e5;
|
||||||
--app-inverse: #ffffff;
|
--app-inverse: #ffffff;
|
||||||
--app-accent: #07c160;
|
--app-accent: #07c160;
|
||||||
@@ -208,6 +208,7 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.workspace-tabs > .n-tabs-nav {
|
.workspace-tabs > .n-tabs-nav {
|
||||||
|
background: var(--app-surface);
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.workspace-tabs .n-tabs-tab {
|
.workspace-tabs .n-tabs-tab {
|
||||||
@@ -437,3 +438,11 @@ body {
|
|||||||
grid-template-rows: minmax(0, 1fr) 130px;
|
grid-template-rows: minmax(0, 1fr) 130px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 导航保留明确选中态与轻量悬停反馈,明暗模式共用主题变量。 */
|
||||||
|
.workspace-tabs .n-tabs-tab--active {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.workspace-tabs .n-tabs-tab:hover {
|
||||||
|
background: var(--app-subtle);
|
||||||
|
}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ describe('全站平面主题', () => {
|
|||||||
expect(source).not.toContain('title="网格布局"')
|
expect(source).not.toContain('title="网格布局"')
|
||||||
expect(source).not.toContain('title="瀑布流布局"')
|
expect(source).not.toContain('title="瀑布流布局"')
|
||||||
expect(css).toMatch(
|
expect(css).toMatch(
|
||||||
/\.form-image-filter-region\s*\{[^}]*padding:\s*12px;[^}]*--app-field:\s*var\(--app-surface\);[^}]*--app-field-hover:\s*var\(--app-surface\);[^}]*background:\s*color-mix\(in srgb, var\(--app-subtle\) 96%, white\);/
|
/\.form-image-filter-region\s*\{[^}]*padding:\s*12px;[^}]*--app-field:\s*var\(--app-surface\);[^}]*--app-field-hover:\s*var\(--app-surface\);[^}]*background:\s*var\(--app-subtle\);/
|
||||||
)
|
)
|
||||||
expect(css).toMatch(/\.form-image-toolbar\s*\{[^}]*align-items:\s*center;[^}]*gap:\s*12px 16px;/)
|
expect(css).toMatch(/\.form-image-toolbar\s*\{[^}]*align-items:\s*center;[^}]*gap:\s*12px 16px;/)
|
||||||
expect(css).toMatch(
|
expect(css).toMatch(
|
||||||
@@ -298,3 +298,4 @@ describe('全站平面主题', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe('微信风格明暗主题', () => {
|
|||||||
const color = overrides.value.Tabs!.tabBorderColor!
|
const color = overrides.value.Tabs!.tabBorderColor!
|
||||||
if (typeof color !== 'string') throw new Error('Tabs 分隔线颜色必须为字符串')
|
if (typeof color !== 'string') throw new Error('Tabs 分隔线颜色必须为字符串')
|
||||||
expect(color).toBe('#dadada')
|
expect(color).toBe('#dadada')
|
||||||
expect(contrast(color, '#ededed')).toBeGreaterThan(1.15)
|
expect(contrast(color, '#ffffff')).toBeGreaterThan(1.15)
|
||||||
expect(contrast(color, '#ffffff')).toBeGreaterThan(1.15)
|
expect(contrast(color, '#ffffff')).toBeGreaterThan(1.15)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ describe('微信风格明暗主题', () => {
|
|||||||
it('深浅模式同时更新 Naive 主题和业务颜色标记', async () => {
|
it('深浅模式同时更新 Naive 主题和业务颜色标记', async () => {
|
||||||
const state = setup()
|
const state = setup()
|
||||||
expect(state.overrides.value.common?.primaryColor).toBe('#07c160')
|
expect(state.overrides.value.common?.primaryColor).toBe('#07c160')
|
||||||
expect(state.overrides.value.common?.bodyColor).toBe('#ededed')
|
expect(state.overrides.value.common?.bodyColor).toBe('#ffffff')
|
||||||
state.preference.value = 'dark'
|
state.preference.value = 'dark'
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(state.theme.value?.name).toBe('dark')
|
expect(state.theme.value?.name).toBe('dark')
|
||||||
@@ -158,13 +158,13 @@ describe('微信风格明暗主题', () => {
|
|||||||
document.head.appendChild(meta)
|
document.head.appendChild(meta)
|
||||||
try {
|
try {
|
||||||
const state = setup()
|
const state = setup()
|
||||||
expect(meta.content).toBe('#ededed')
|
expect(meta.content).toBe('#ffffff')
|
||||||
state.change(true)
|
state.change(true)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(meta.content).toBe('#111111')
|
expect(meta.content).toBe('#111111')
|
||||||
state.preference.value = 'light'
|
state.preference.value = 'light'
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(meta.content).toBe('#ededed')
|
expect(meta.content).toBe('#ffffff')
|
||||||
} finally {
|
} finally {
|
||||||
meta.remove()
|
meta.remove()
|
||||||
}
|
}
|
||||||
@@ -190,3 +190,4 @@ describe('微信风格明暗主题', () => {
|
|||||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -54,17 +54,67 @@ function server() {
|
|||||||
}
|
}
|
||||||
const receipt = {
|
const receipt = {
|
||||||
projectId: 'capability-test',
|
projectId: 'capability-test',
|
||||||
completed: true,
|
completed: false,
|
||||||
|
dispatchCompleted: true,
|
||||||
needsManualReview: false,
|
needsManualReview: false,
|
||||||
stopReason: '',
|
stopReason: '',
|
||||||
errors: ['一个提示词未生成'],
|
errors: ['一个提示词未生成'],
|
||||||
videoGenerationResult: { total: 1, created: 1, skipped: 0, failed: 0 }
|
keyframeQualityResult: {
|
||||||
|
total: 1,
|
||||||
|
passed: 1,
|
||||||
|
skipped: 0,
|
||||||
|
blocked: 0,
|
||||||
|
repairFailed: 0,
|
||||||
|
failed: 0,
|
||||||
|
stalePrimaryKeyframe: 0
|
||||||
|
},
|
||||||
|
videoGenerationResult: { total: 1, created: 1, skipped: 0, inProgress: 0, blocked: 0, failed: 0 }
|
||||||
|
}
|
||||||
|
const stage = () => ({ total: 1, planned: 1, skipped: 0, upstreamResolvable: 0, manualBlocked: 0 })
|
||||||
|
const plan = {
|
||||||
|
projectId: 'capability-test',
|
||||||
|
completed: false,
|
||||||
|
stage: 'subject_images',
|
||||||
|
hasManualBlockers: false,
|
||||||
|
manualBlocked: 0,
|
||||||
|
subjectImages: stage(),
|
||||||
|
keyframes: stage(),
|
||||||
|
videoPrompts: stage(),
|
||||||
|
videos: stage(),
|
||||||
|
details: {
|
||||||
|
subjectImages: [
|
||||||
|
{
|
||||||
|
subjectFormId: form.id,
|
||||||
|
subjectRef: 'C1',
|
||||||
|
subjectName: '角色',
|
||||||
|
formName: '基础形态',
|
||||||
|
module: 'character',
|
||||||
|
status: 'ready',
|
||||||
|
primaryImageId: null,
|
||||||
|
primaryImageStale: false,
|
||||||
|
issues: [] as { code: string; reason: string }[]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
keyframes: readiness.items,
|
||||||
|
videoPrompts: readiness.items,
|
||||||
|
videos: readiness.items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const status = {
|
||||||
|
projectId: 'capability-test',
|
||||||
|
completed: false,
|
||||||
|
stage: 'subject_images',
|
||||||
|
subjectImages: { total: 1, current: 0, missing: 1, stale: 0, complete: false, items: [] },
|
||||||
|
keyframes: readiness,
|
||||||
|
videos: readiness
|
||||||
}
|
}
|
||||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||||
const path = String(url)
|
const path = String(url)
|
||||||
let data: unknown = context.project.value
|
let data: unknown = context.project.value
|
||||||
if (path.endsWith('/checkpoints')) data = context.checkpoints.value
|
if (path.endsWith('/checkpoints')) data = context.checkpoints.value
|
||||||
else if (path.endsWith('/subject-forms')) data = [form]
|
else if (path.endsWith('/subject-forms')) data = [form]
|
||||||
|
else if (path.endsWith('/production/plan')) data = plan
|
||||||
|
else if (path.endsWith('/production/status')) data = status
|
||||||
else if (path.includes('/readiness')) data = readiness
|
else if (path.includes('/readiness')) data = readiness
|
||||||
else if (path.includes('/storyboard-directions')) data = directionsResult('capability-test')
|
else if (path.includes('/storyboard-directions')) data = directionsResult('capability-test')
|
||||||
else if (path.endsWith('/videos/status'))
|
else if (path.endsWith('/videos/status'))
|
||||||
@@ -91,6 +141,8 @@ function server() {
|
|||||||
form,
|
form,
|
||||||
readiness,
|
readiness,
|
||||||
receipt,
|
receipt,
|
||||||
|
plan,
|
||||||
|
status,
|
||||||
fetcher,
|
fetcher,
|
||||||
posts: () => fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
|
posts: () => fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
|
||||||
}
|
}
|
||||||
@@ -113,7 +165,7 @@ async function advanced() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('高级串联生产的安全边界', () => {
|
describe('高级串联生产的安全边界', () => {
|
||||||
it('挂载不查询或生成,通过预检仍需显式启动,再次预检后提交固定 Provider', async () => {
|
it('挂载不查询或生成,通过预检仍需显式启动,再次预检后提交空参数', async () => {
|
||||||
const { service, fetcher, posts } = await advanced()
|
const { service, fetcher, posts } = await advanced()
|
||||||
expect(fetcher).not.toHaveBeenCalled()
|
expect(fetcher).not.toHaveBeenCalled()
|
||||||
await service.start()
|
await service.start()
|
||||||
@@ -125,37 +177,56 @@ describe('高级串联生产的安全边界', () => {
|
|||||||
expect(posts()).toHaveLength(1)
|
expect(posts()).toHaveLength(1)
|
||||||
expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/production/start')
|
expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/production/start')
|
||||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({})
|
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({})
|
||||||
expect(
|
expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith('/production/plan'))).toHaveLength(2)
|
||||||
fetcher.mock.calls.filter(([url]) => String(url).includes('/keyframes/readiness?force=true'))
|
|
||||||
).toHaveLength(2)
|
|
||||||
expect(service.session.value.pipelineReceipt?.errors).toEqual(['一个提示词未生成'])
|
expect(service.session.value.pipelineReceipt?.errors).toEqual(['一个提示词未生成'])
|
||||||
expect(service.check.value).toBeNull()
|
expect(service.check.value).toBeNull()
|
||||||
})
|
})
|
||||||
it('预检通过后主首帧变旧,确认时再次预检会拦截,不提交', async () => {
|
it('预检后新增活动任务,确认时重新检查并阻止重复提交', async () => {
|
||||||
const { service, readiness, posts } = await advanced()
|
const { service, readiness, posts } = await advanced()
|
||||||
await service.preflight()
|
await service.preflight()
|
||||||
readiness.items[0]!.primaryKeyframeStale = true
|
readiness.inProgress = 1
|
||||||
await service.start()
|
await service.start()
|
||||||
expect(posts()).toHaveLength(0)
|
expect(posts()).toHaveLength(0)
|
||||||
expect(service.check.value?.issues.join()).toContain('有效主首帧')
|
expect(service.check.value?.issues.join()).toContain('活动视频任务')
|
||||||
expect(getOperation('capability-test').error).toContain('条件变化')
|
expect(getOperation('capability-test').error).toContain('条件变化')
|
||||||
})
|
})
|
||||||
it('后台活动视频、缺失主图与剧本未完成均阻止预检通过', async () => {
|
it('缺失主图和过期首帧允许后端计划补齐,但未完成剧本和活动任务仍阻止启动', async () => {
|
||||||
const { context, form, readiness } = server()
|
const { context, form, readiness } = server()
|
||||||
context.data.value!.project.status = 'need_review'
|
|
||||||
form.images = []
|
form.images = []
|
||||||
|
readiness.items[0]!.primaryKeyframeStale = true
|
||||||
|
readiness.items[0]!.issues = [{ code: 'missing_reference', reason: '先补参考图' }]
|
||||||
|
expect((await checkPipeline('capability-test')).issues).toEqual([])
|
||||||
|
context.data.value!.project.status = 'need_review'
|
||||||
readiness.inProgress = 1
|
readiness.inProgress = 1
|
||||||
const result = await checkPipeline('capability-test')
|
const result = await checkPipeline('capability-test')
|
||||||
expect(result.issues.join()).toContain('剧本尚未完成')
|
expect(result.issues.join()).toContain('剧本尚未完成')
|
||||||
expect(result.issues.join()).toContain('所有形态主图')
|
|
||||||
expect(result.issues.join()).toContain('活动视频任务')
|
expect(result.issues.join()).toContain('活动视频任务')
|
||||||
})
|
})
|
||||||
it('已有视频的 skipped 也不能掩盖缺失首帧,只有缺少提示词允许本流程补齐', async () => {
|
it('人工阻塞保留后端原因,同时允许其他就绪形态先生成', async () => {
|
||||||
const { readiness } = server()
|
const { service, plan, posts } = await advanced()
|
||||||
readiness.items[0]!.status = 'skipped'
|
plan.hasManualBlockers = true
|
||||||
readiness.items[0]!.issues = [{ code: 'missing_keyframe', reason: '无首帧' }]
|
plan.manualBlocked = 1
|
||||||
const result = await checkPipeline('capability-test')
|
plan.keyframes.manualBlocked = 1
|
||||||
expect(result.issues.join()).toContain('视频前置检查未通过')
|
plan.details.keyframes[0]!.issues = [{ code: 'missing_identity_anchor', reason: '缺少身份母版' }]
|
||||||
|
await service.preflight()
|
||||||
|
expect(service.check.value?.issues).toEqual([])
|
||||||
|
expect(service.check.value?.plan.details.keyframes[0]?.issues[0]?.reason).toBe('缺少身份母版')
|
||||||
|
await service.start()
|
||||||
|
expect(posts()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
it('没有可执行任务或真实资产已全部完成时不再启动', async () => {
|
||||||
|
const { plan, status } = server()
|
||||||
|
for (const stage of [plan.subjectImages, plan.keyframes, plan.videoPrompts, plan.videos]) stage.planned = 0
|
||||||
|
expect((await checkPipeline('capability-test')).issues.join()).toContain('没有可执行')
|
||||||
|
status.completed = true
|
||||||
|
expect((await checkPipeline('capability-test')).issues.join()).toContain('制作已完成')
|
||||||
|
})
|
||||||
|
it('计划项目不匹配或镜头集合改变时拒绝采用旧计划', async () => {
|
||||||
|
const { plan } = server()
|
||||||
|
plan.details.videoPrompts = []
|
||||||
|
expect((await checkPipeline('capability-test')).issues.join()).toContain('资产列表不一致')
|
||||||
|
plan.projectId = 'other'
|
||||||
|
await expect(checkPipeline('capability-test')).rejects.toThrow('其他项目')
|
||||||
})
|
})
|
||||||
it('项目锁和未完成剧本下不启动,切项目清空旧预检', async () => {
|
it('项目锁和未完成剧本下不启动,切项目清空旧预检', async () => {
|
||||||
const { service, context, posts } = await advanced()
|
const { service, context, posts } = await advanced()
|
||||||
@@ -223,6 +294,9 @@ describe('高级串联生产的安全边界', () => {
|
|||||||
expect(document.body.textContent).toContain('流程返回不等于成片完成')
|
expect(document.body.textContent).toContain('流程返回不等于成片完成')
|
||||||
expect(document.body.textContent).toContain(receipt.errors[0])
|
expect(document.body.textContent).toContain(receipt.errors[0])
|
||||||
expect(document.body.textContent).toContain('已提交 1')
|
expect(document.body.textContent).toContain('已提交 1')
|
||||||
|
expect(document.body.textContent).toContain('任务已分发,等待视频完成')
|
||||||
|
expect(document.body.textContent).toContain('校验通过 1')
|
||||||
|
expect(document.body.textContent).not.toContain('不会生成首帧')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { afterEach, expect, it, vi } from 'vitest'
|
||||||
|
import ProviderInputInspector from '@/features/production/components/ProviderInputInspector.vue'
|
||||||
|
|
||||||
|
let wrapper: VueWrapper | undefined
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 仅提供公开输入规格,不连接生成服务。 */
|
||||||
|
function inputSpec(shotId = 'shot-1') {
|
||||||
|
return {
|
||||||
|
shotId,
|
||||||
|
targetProvider: 'seedance',
|
||||||
|
businessKeyframe: { id: 'keyframe-1', imageUrl: '/key.png' },
|
||||||
|
providerInputKeyframe: {
|
||||||
|
id: 'variant-1',
|
||||||
|
imageUrl: '/variant.png',
|
||||||
|
source: 'provider_variant',
|
||||||
|
variantReason: '模型输入适配'
|
||||||
|
},
|
||||||
|
references: [
|
||||||
|
{
|
||||||
|
subjectId: 'subject-1',
|
||||||
|
subjectFormId: 'form-1',
|
||||||
|
subjectRef: 'C1',
|
||||||
|
subjectName: '角色',
|
||||||
|
subjectFormName: '基础形态',
|
||||||
|
module: 'character',
|
||||||
|
providerAsset: { provider: 'seedance', assetId: 'asset-1' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('按显式模型发出只读查询并展示授权素材,切换镜头清除旧结果', async () => {
|
||||||
|
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({ data: inputSpec() })))
|
||||||
|
vi.stubGlobal('fetch', fetcher)
|
||||||
|
wrapper = mount(ProviderInputInspector, { props: { shotId: 'shot-1' } })
|
||||||
|
expect(fetcher).not.toHaveBeenCalled()
|
||||||
|
await wrapper.get('input').setValue(' Seedance ')
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/storyboard-shots/shot-1/provider-input-spec?provider=seedance')
|
||||||
|
expect(fetcher.mock.calls[0]?.[1]?.method).toBe('GET')
|
||||||
|
expect(wrapper.text()).toContain('asset-1')
|
||||||
|
expect(wrapper.text()).toContain('variant-1')
|
||||||
|
await wrapper.setProps({ shotId: 'shot-2' })
|
||||||
|
expect(wrapper.text()).not.toContain('asset-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('模型切换使晚到的旧响应失效,错误归属不发布结果', async () => {
|
||||||
|
let finish!: (response: Response) => void
|
||||||
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise(resolve => {
|
||||||
|
finish = resolve
|
||||||
|
})
|
||||||
|
)
|
||||||
|
vi.stubGlobal('fetch', fetcher)
|
||||||
|
wrapper = mount(ProviderInputInspector, { props: { shotId: 'shot-1' } })
|
||||||
|
await wrapper.get('input').setValue('seedance')
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
await wrapper.get('input').setValue('other')
|
||||||
|
finish(new Response(JSON.stringify({ data: inputSpec() })))
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).not.toContain('asset-1')
|
||||||
|
await wrapper.get('input').setValue('seedance')
|
||||||
|
fetcher.mockResolvedValue(new Response(JSON.stringify({ data: inputSpec('wrong-shot') })))
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('不匹配')
|
||||||
|
expect(wrapper.text()).not.toContain('asset-1')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user