From 8db7cf0300eb5d2200af01e3d46bf9707f85bcfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=A5=A5=E7=94=9F?= Date: Tue, 15 Sep 2026 17:23:24 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(assets):=20=E7=BB=9F=E4=B8=80=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E5=BA=93=20entity=20=E5=8F=A3=E5=BE=84=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=9C=E7=AB=AF=E7=A9=BA=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E9=81=AE=E8=94=BD=E6=9C=AC=E5=9C=B0=E7=B4=A0=E6=9D=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 个人素材库列表与 facets 统一排除 entity 类型,计数与列表同口径,修复“总数 30 但列表为空” - 个人素材页 /assets、参考内容弹窗、项目资产页三处“远端空数组 truthy 遮蔽本地素材”统一修复:远端无可展示素材时回退本地列表,分页、分类、关键词过滤同步切换口径 - 项目资产页待确认候选不再硬编码 character 分类:分类切换联动查询,角色/道具/场景动态图标与文案,仅角色类展示“归并到角色” - 项目资产页分类计数改为独立查询并与 tab 解耦,修复切 tab 时总数漂移;确认资产后同步失效候选与资产列表缓存;切分类重置候选页码 --- backend/internal/repository/asset_library.go | 6 +- .../assets/asset-library-picker-modal.tsx | 17 +++--- web/src/pages/assets/index.tsx | 25 +++++---- web/src/pages/projects/detail/assets.tsx | 56 +++++++++++++------ 4 files changed, 68 insertions(+), 36 deletions(-) diff --git a/backend/internal/repository/asset_library.go b/backend/internal/repository/asset_library.go index 409ebbfe8..9058fa879 100644 --- a/backend/internal/repository/asset_library.go +++ b/backend/internal/repository/asset_library.go @@ -28,7 +28,9 @@ type UserAssetFacetRow struct { func (r *Repository) UserAssetsPage(userID string, page int, pageSize int, filter UserAssetPageFilter) ([]model.Asset, int64, error) { var assets []model.Asset var total int64 - query := userAssetFilteredQuery(r.db.Model(&model.Asset{}).Where("user_id = ?", userID), filter, true) + // 素材库页面只展示媒体与文本素材;entity 角色卡由项目资产页管理。列表与 facets 必须同口径排除, + // 否则 facets 会计入 entity,前端出现“全部计数 30 但列表为空”的矛盾。 + query := userAssetFilteredQuery(r.db.Model(&model.Asset{}).Where("user_id = ? AND kind <> ?", userID, "entity"), filter, true) if err := query.Count(&total).Error; err != nil { return nil, 0, err } @@ -38,7 +40,7 @@ func (r *Repository) UserAssetsPage(userID string, page int, pageSize int, filte func (r *Repository) UserAssetFacets(userID string, status string) ([]UserAssetFacetRow, []UserAssetFacetRow, []UserAssetFacetRow, error) { base := func() *gorm.DB { - return userAssetFilteredQuery(r.db.Model(&model.Asset{}).Where("user_id = ?", userID), UserAssetPageFilter{Status: status}, false) + return userAssetFilteredQuery(r.db.Model(&model.Asset{}).Where("user_id = ? AND kind <> ?", userID, "entity"), UserAssetPageFilter{Status: status}, false) } var kindRows []UserAssetFacetRow if err := base().Select("kind AS key, COUNT(*) AS count").Group("kind").Scan(&kindRows).Error; err != nil { diff --git a/web/src/components/assets/asset-library-picker-modal.tsx b/web/src/components/assets/asset-library-picker-modal.tsx index dc4a894b4..fe4d9a1cd 100644 --- a/web/src/components/assets/asset-library-picker-modal.tsx +++ b/web/src/components/assets/asset-library-picker-modal.tsx @@ -146,7 +146,10 @@ export function AssetLibraryPickerModal({ kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本", searchText: (asset.tags ?? []).join(" "), ...(items.find((item) => item.id === asset.id) || { disabledReason: "此素材不适用于当前操作" }), })), [remoteQuery.data, items]); - const effectivePagination = remoteEnabled ? { current: remotePage, pageSize: remotePageSize, total: remoteQuery.data?.total || 0, onChange: (page: number, pageSize: number) => { setRemotePage(page); setRemotePageSize(pageSize); } } : pagination; + // 远端分页可能没有任何可展示素材(本地素材尚未同步时远端为空);此时回退调用方传入的本地素材, + // 否则弹窗会被远端空结果遮蔽成空白。分页、分类与关键词过滤必须与数据源同步切换口径。 + const useRemoteItems = remoteEnabled && remoteItems.length > 0; + const effectivePagination = useRemoteItems ? { current: remotePage, pageSize: remotePageSize, total: remoteQuery.data?.total || 0, onChange: (page: number, pageSize: number) => { setRemotePage(page); setRemotePageSize(pageSize); } } : pagination; const uploadInputRef = useRef(null); const initialSelectedIdsRef = useRef(initialSelectedIds); const itemsRef = useRef(items); @@ -161,16 +164,16 @@ export function AssetLibraryPickerModal({ const hasPluginSource = useMemo(() => Object.keys(categoryLabels).some((value) => value.startsWith("external:")) || pluginItems.some((item) => item.category.startsWith("external:")), [categoryLabels, pluginItems]); // 媒体类型在分类之前收窄数据源,让左侧分类计数、网格和分页始终描述同一批素材。 const sourceItems = useMemo(() => { - const base = source === "plugin" ? pluginItems : remoteEnabled ? remoteItems : localItems; + const base = source === "plugin" ? pluginItems : useRemoteItems ? remoteItems : localItems; if (mediaKind === "all") return base; return base.filter((item) => pickerItemMediaKind(item) === mediaKind); - }, [localItems, mediaKind, pluginItems, remoteEnabled, remoteItems, source]); + }, [localItems, mediaKind, pluginItems, useRemoteItems, remoteItems, source]); const activeSourceItems = useMemo(() => sourceItems.filter((item) => !item.archived), [sourceItems]); const archivedItems = useMemo(() => sourceItems.filter((item) => item.archived), [sourceItems]); const mediaKindOptions = useMemo(() => (remoteKind ? [] : Array.from(new Set(mediaKinds))), [mediaKinds, remoteKind]); const sourceFolders = source === "plugin" ? folders : []; const showCategories = source === "local" || !sourceFolders.length; - const normalCategories = useMemo(() => remoteEnabled ? Object.keys(categoryLabels).filter((value) => value !== "archived" && !value.startsWith("external:")) : ["all", ...Array.from(new Set(activeSourceItems.map((item) => item.category || "other"))).filter((value) => value !== "all")], [activeSourceItems, categoryLabels, remoteEnabled]); + const normalCategories = useMemo(() => useRemoteItems ? Object.keys(categoryLabels).filter((value) => value !== "archived" && !value.startsWith("external:")) : ["all", ...Array.from(new Set(activeSourceItems.map((item) => item.category || "other"))).filter((value) => value !== "all")], [activeSourceItems, categoryLabels, useRemoteItems]); const archivedCount = archivedItems.length; const isRecycleBin = category === "archived"; @@ -183,9 +186,9 @@ export function AssetLibraryPickerModal({ return false; } if (folderId !== "all" && (item.folderId || "") !== folderId) return false; - return remoteEnabled || !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query); + return useRemoteItems || !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query); }); - }, [category, folderId, keyword, sourceItems, remoteEnabled]); + }, [category, folderId, keyword, sourceItems, useRemoteItems]); const selectedIds = useMemo( () => Array.from(selected).filter((id) => { @@ -481,7 +484,7 @@ export function AssetLibraryPickerModal({
- {remoteEnabled && remoteQuery.isError ?
素材读取失败
: loading || (remoteEnabled && remoteQuery.isFetching) ? ( + {remoteEnabled && remoteQuery.isError ?
素材读取失败
: loading || (useRemoteItems && remoteQuery.isFetching) ? (
正在读取素材 diff --git a/web/src/pages/assets/index.tsx b/web/src/pages/assets/index.tsx index ef8e563d4..9ee8a69cc 100644 --- a/web/src/pages/assets/index.tsx +++ b/web/src/pages/assets/index.tsx @@ -164,16 +164,17 @@ export default function AssetsPage() { const start = (page - 1) * pageSize; return filteredAssets.slice(start, start + pageSize); }, [filteredAssets, page, pageSize]); - const visibleAssets = useMemo( - () => (assetPageQuery.data?.assets || localVisibleAssets).filter((asset): asset is LibraryAsset => asset.kind !== "entity"), - [assetPageQuery.data?.assets, localVisibleAssets], - ); + // 远端分页结果可能只含被本页排除的 entity 角色卡;此时若直接采用远端结果, + // 本地已保存、尚未同步到服务端的素材会被遮蔽成空页,因此回退到本地筛选结果。 + const remotePageAssets = useMemo(() => (assetPageQuery.data?.assets || []).filter((asset): asset is LibraryAsset => asset.kind !== "entity"), [assetPageQuery.data?.assets]); + const useRemotePage = remotePageAssets.length > 0; + const visibleAssets = useMemo(() => useRemotePage ? remotePageAssets : localVisibleAssets, [useRemotePage, remotePageAssets, localVisibleAssets]); const visibleAssetIds = useMemo(() => visibleAssets.map((asset) => asset.id), [visibleAssets]); const allFilteredSelected = visibleAssetIds.length > 0 && visibleAssetIds.every((id) => selectedIds.includes(id)); const canCreateAsset = page === 1 && viewMode === "library" && !keyword.trim() && kindFilter === "all" && categoryFilter === "all" && folderFilter === "all"; - const totalAssets = assetPageQuery.data?.total ?? filteredAssets.length; - const kindCounts = useMemo(() => assetCountMap(kindOptions, assetPageQuery.data?.kindCounts, viewMode === "trash" ? trashAssets : activeAssets, (asset) => asset.kind), [activeAssets, assetPageQuery.data?.kindCounts, trashAssets, viewMode]); - const categoryCounts = useMemo(() => assetCountMap(categoryOptions, assetPageQuery.data?.categoryCounts, viewMode === "trash" ? trashAssets : activeAssets, (asset) => asset.category || "other"), [activeAssets, assetPageQuery.data?.categoryCounts, trashAssets, viewMode]); + const totalAssets = useRemotePage ? assetPageQuery.data?.total ?? filteredAssets.length : filteredAssets.length; + const kindCounts = useMemo(() => assetCountMap(kindOptions, useRemotePage ? assetPageQuery.data?.kindCounts : undefined, viewMode === "trash" ? trashAssets : activeAssets, (asset) => asset.kind), [activeAssets, assetPageQuery.data?.kindCounts, trashAssets, useRemotePage, viewMode]); + const categoryCounts = useMemo(() => assetCountMap(categoryOptions, useRemotePage ? assetPageQuery.data?.categoryCounts : undefined, viewMode === "trash" ? trashAssets : activeAssets, (asset) => asset.category || "other"), [activeAssets, assetPageQuery.data?.categoryCounts, trashAssets, useRemotePage, viewMode]); const folderCounts = assetPageQuery.data?.folderCounts || {}; useEffect(() => { @@ -525,7 +526,7 @@ export default function AssetsPage() { {validAssets.length} 个素材} + meta={{totalAssets} 个素材} actions={
@@ -725,7 +726,7 @@ export default function AssetsPage() { onDelete={() => setBatchDeleteOpen(true)} /> ) : null} - {validAssets.length === 0 ? ( + {validAssets.length === 0 && totalAssets === 0 ? ( viewMode === "trash" ? ( ) : ( @@ -733,7 +734,7 @@ export default function AssetsPage() { ) ) : ( <> - {filteredAssets.length === 0 ? ( + {visibleAssets.length === 0 ? ( ) : ( @@ -1519,7 +1520,9 @@ function readAssetGridDensity(): AssetGridDensity { function assetCountMap(options: T[], remote: Record | undefined, fallback: LibraryAsset[], valueOf: (asset: LibraryAsset) => string) { const result = new Map(); options.forEach((option) => { - if (remote) result.set(option.value, option.value === "all" ? Object.values(remote).reduce((sum, count) => sum + count, 0) : remote[option.value] || 0); + // 列表只展示 LibraryAsset(entity 角色卡被排除);"全部"计数只能累加选项里声明的类型, + // 否则远端 facets 里的 entity 会计入"全部",出现计数 30 但列表为空的矛盾。 + if (remote) result.set(option.value, option.value === "all" ? options.reduce((sum, item) => item.value === "all" ? sum : sum + (remote[item.value] || 0), 0) : remote[option.value] || 0); else result.set(option.value, option.value === "all" ? fallback.length : fallback.filter((asset) => valueOf(asset) === option.value).length); }); return result; diff --git a/web/src/pages/projects/detail/assets.tsx b/web/src/pages/projects/detail/assets.tsx index 4e5fd335a..78b36cbb8 100644 --- a/web/src/pages/projects/detail/assets.tsx +++ b/web/src/pages/projects/detail/assets.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; -import { useMutation, useMutationState, useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useMutation, useMutationState, useQuery, useQueryClient } from "@tanstack/react-query"; import { App, Button, Dropdown, Form, Input, Modal, Popconfirm, Tabs, type FormInstance } from "antd"; import { Box, Check, ChevronDown, Download, FileText, FolderOpen, FolderPlus, Image as ImageIcon, Link2, MoreHorizontal, MoveRight, Music2, Pencil, Plus, RefreshCw, Search, Sparkles, Trash2, Upload, UserRound, Video, VolumeX } from "lucide-react"; @@ -90,6 +90,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet const [voicePickerOpen, setVoicePickerOpen] = useState(false); const [voiceInstructions, setVoiceInstructions] = useState(""); const [form] = Form.useForm(); + const queryClient = useQueryClient(); const assetsQuery = useQuery({ queryKey: ["project", detail.project.id, "assets", page, pageSize, category, folderId, debouncedKeyword], @@ -100,12 +101,26 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet folderId: folderId === ALL_FOLDERS ? undefined : folderId, query: debouncedKeyword || undefined, }), + placeholderData: keepPreviousData, }); const foldersQuery = useQuery({ queryKey: ["project", detail.project.id, "asset-folders"], queryFn: () => listProjectAssetFolders(detail.project.id) }); - const showPendingCandidates = folderId === ALL_FOLDERS && (category === "all" || category === "character"); + // 左侧分类计数不能依赖跟随当前 tab 的候选查询,否则"角色"的数字会随所在 tab 的待确认数漂移; + // 这里独立取"全部待确认"和"角色待确认"两个总数,计数与当前所处分类解耦。 + const pendingCountQuery = useQuery({ + queryKey: ["project", detail.project.id, "asset-candidates", "pending-count"], + queryFn: () => listProjectAssetCandidates(detail.project.id, { page: 1, pageSize: 1, status: "pending_confirmation" }), + }); + const characterPendingCountQuery = useQuery({ + queryKey: ["project", detail.project.id, "asset-candidates", "character-pending-count"], + queryFn: () => listProjectAssetCandidates(detail.project.id, { page: 1, pageSize: 1, category: "character", status: "pending_confirmation" }), + }); + // 章节提取会产出角色、场景和道具三类候选;待确认列表必须跟随当前分类,否则非角色候选永远无法确认。 + const candidateCategory = category === "all" ? undefined : category; + const candidateLabel = category === "all" ? "资产" : categoryLabel(category); + const showPendingCandidates = folderId === ALL_FOLDERS; const candidatesQuery = useQuery({ - queryKey: ["project", detail.project.id, "asset-candidates", candidatePage, candidatePageSize, "character", "pending_confirmation", debouncedKeyword], - queryFn: () => listProjectAssetCandidates(detail.project.id, { page: candidatePage, pageSize: candidatePageSize, category: "character", status: "pending_confirmation", query: debouncedKeyword || undefined }), + queryKey: ["project", detail.project.id, "asset-candidates", candidatePage, candidatePageSize, candidateCategory || "all", "pending_confirmation", debouncedKeyword], + queryFn: () => listProjectAssetCandidates(detail.project.id, { page: candidatePage, pageSize: candidatePageSize, category: candidateCategory, status: "pending_confirmation", query: debouncedKeyword || undefined }), }); const assets = assetsQuery.data?.assets || []; const assetFolders = foldersQuery.data?.folders || []; @@ -140,6 +155,8 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet setCategory(nextCategory); setFolderId(ALL_FOLDERS); setPage(1); + // 待确认列表跟随分类后,不同分类的候选数量不同,切分类必须回到第 1 页。 + setCandidatePage(1); }; const projectAssetIds = new Set(assets.map((asset) => asset.id)); @@ -180,8 +197,8 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet const categoryCounts = categories.map((value) => ({ value, count: value === "all" - ? totalAssetCount + (candidatesQuery.data?.total || 0) - : (categoryCountMap[value] || 0) + (value === "character" ? candidatesQuery.data?.total || 0 : 0), + ? totalAssetCount + (pendingCountQuery.data?.total || 0) + : (categoryCountMap[value] || 0) + (value === "character" ? characterPendingCountQuery.data?.total || 0 : 0), })); const audioPickerItems = useMemo(() => { const localItems = personalAssets.flatMap((asset) => { @@ -282,10 +299,16 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet const confirmMutation = useMutation({ mutationFn: ({ candidateId, targetAssetId }: { candidateId: string; targetAssetId?: string }) => confirmProjectAssetCandidate(detail.project.id, candidateId, targetAssetId), onSuccess: ({ asset }, variables) => { - syncPersonalCharacterProjection(asset); - done(variables.targetAssetId ? "候选信息已归并到角色新版本" : "角色卡已创建"); + if (asset.category === "character") syncPersonalCharacterProjection(asset); + const label = asset.category === "character" ? "角色卡" : categoryLabel(asset.category); + // 确认会改变候选、资产列表和左侧计数三处数据,必须一并失效缓存,否则计数与列表滞后。 + void Promise.all([ + queryClient.invalidateQueries({ queryKey: ["project", detail.project.id, "asset-candidates"] }), + queryClient.invalidateQueries({ queryKey: ["project", detail.project.id, "assets"] }), + ]); + done(variables.targetAssetId ? `候选信息已归并到${label}新版本` : `${label}已创建`); }, - onError: failed("角色确认失败"), + onError: failed("资产确认失败"), }); const confirmingCandidateId = confirmMutation.isPending ? confirmMutation.variables?.candidateId || "" : ""; const saveCharacter = useMutation({ @@ -407,24 +430,25 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
{childFolders.length ?
{childFolders.map((folder) => selectFolder(folder.id)} onRename={() => openFolderEditor(folder)} onMove={(parentId) => moveFolderMutation.mutate({ id: folder.id, parentId })} onStyle={(style) => styleFolderMutation.mutate({ id: folder.id, style })} onTheme={(theme) => themeFolderMutation.mutate({ id: folder.id, theme })} onDelete={() => modal.confirm({ title: `删除文件夹“${folder.name}”?`, content: "仅空文件夹可以删除,素材和子文件夹不会被级联删除。", okText: "删除", okButtonProps: { danger: true }, cancelText: "取消", onOk: () => deleteFolderMutation.mutateAsync(folder.id) })} deleting={(deleteFolderMutation.isPending && deleteFolderMutation.variables === folder.id) || (moveFolderMutation.isPending && moveFolderMutation.variables?.id === folder.id) || (styleFolderMutation.isPending && styleFolderMutation.variables?.id === folder.id) || (themeFolderMutation.isPending && themeFolderMutation.variables?.id === folder.id)} />)}
: null} - {folderId === ALL_FOLDERS && (category === "all" || category === "character") && pendingCandidates.length ? ( -
+ {showPendingCandidates && pendingCandidates.length ? ( +
-
剧情识别出的角色
+
剧情识别出的{candidateLabel}
剩余 {candidatesQuery.data?.total || 0} 个待确认
{pendingCandidates.map((candidate) => { const confirming = confirmingCandidateId === candidate.id; + const isCharacter = candidate.category === "character"; return (
- + {isCharacter ? : }
{candidate.name}
-
待确认角色卡 · 来自章节分析
+
待确认{categoryLabel(candidate.category)} · 来自章节分析
- - {characterAssets.length ? ({ key: asset.id, label: asset.title })), onClick: ({ key }) => confirmMutation.mutate({ candidateId: candidate.id, targetAssetId: key }) }}> : null} + + {isCharacter && characterAssets.length ? ({ key: asset.id, label: asset.title })), onClick: ({ key }) => confirmMutation.mutate({ candidateId: candidate.id, targetAssetId: key }) }}> : null}
From 446c2634263099c648c002573e5ef21023d89b5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=A5=A5=E7=94=9F?= Date: Tue, 15 Sep 2026 17:23:38 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(storyboard):=20=E7=AB=A0=E8=8A=82?= =?UTF-8?q?=E5=88=86=E9=95=9C=E4=BB=BB=E5=8A=A1=E8=A1=A5=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E5=B9=B6=E7=A9=BF=E9=80=8F=20text=20?= =?UTF-8?q?=E5=8C=85=E8=A3=85=E8=A7=A3=E6=9E=90=E9=95=9C=E5=A4=B4=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 章节分镜任务 prompt 显式追加 {title, rows} JSON 输出契约:该链路不走服务端分镜模板(无 metadata.promptTemplateOperation),缺契约时模型按自然语言习惯返回 Markdown 表格,导致“分镜任务没有返回镜头行” - 文本任务 result_json 外层统一为 {mode, reasoning, text} 包装,storyboardRowsFromTask 现穿透 text 内层解析分镜表,并兜底剥离代码块与首尾大括号截取;顶层直接落 {title, rows} 的结构保持兼容 - 分镜任务 input 显式携带 mode: text(后端仅对 video_ 前缀任务兜底) - 新增 canvas-storyboard-rows-parse.test.ts 覆盖六种解析场景 --- web/src/lib/canvas/canvas-project-domain.ts | 43 +++++++++++++- .../projects/detail/project-chapter-ai.ts | 11 +++- web/test/canvas-storyboard-rows-parse.test.ts | 57 +++++++++++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 web/test/canvas-storyboard-rows-parse.test.ts diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts index 5738fc752..69b9afaef 100644 --- a/web/src/lib/canvas/canvas-project-domain.ts +++ b/web/src/lib/canvas/canvas-project-domain.ts @@ -98,8 +98,49 @@ export function cinematicStoryboardColumns(columns?: StoryboardColumn[]): Storyb ])) as StoryboardColumn[]; } +// 分镜文本任务的模型侧输出契约,字段与 storyboardRowsFromTask 的解析结构一一对应。 +// 后端只有带 metadata.promptTemplateOperation 的任务才会编译服务端分镜模板(含 JSON 契约), +// 章节分镜走的是自定义 prompt,缺这段约束时模型会按自然语言习惯返回 Markdown 表格,解析阶段拿不到 rows。 +export function storyboardRowsOutputContract(requirement: string) { + return [ + "【受保护输出契约】", + "- 只返回一个 JSON 对象,不要 Markdown、表格、代码块、注释或任何解释文字。", + `- 顶层固定为 {"title": string, "rows": object[]},rows 至少 1 项。${requirement}`, + "- rows 每项对应一个镜头,按剧情顺序排列;字段缺失时填空字符串或空数组,不要省略字段:", + " shotNumber(整数,从 1 递增)、durationSeconds(整数,1-60)、plotDescription(本镜可见的剧情与关键动作)、", + " dialogue(本镜实际念出的台词或简短旁白,超长必须拆镜)、characters(本镜出现的角色名数组)、", + " narrativeIntent、viewerPOV、performanceBlocking、shotSize(景别)、emotion、lightingAndAtmosphere、", + " audioEffects、camera、motion、timeBeats、imageGenerationPrompt(首帧画面提示词)、", + " videoMotionPrompt(运镜与结尾状态)、continuityOut、negativePrompt、", + " mustHave(数组,最多 3 项)、optionalDetails(数组)。", + "- 镜头数量以完整覆盖剧情因果、人物关系和关键台词为准,不要为压缩数量删减关键情节。", + ].join("\n"); +} + +// 文本任务的 result_json 外层是 {mode, reasoning, text} 包装,分镜表在 text 字段的内层 JSON 字符串里; +// 历史模板路径可能直接落 {title, rows}。两种结构都要能解析,内层再做代码块剥离与首尾大括号截取兜底, +// 模型偶发违反契约(包 ```json 或混入解释文字)时仍能取出分镜表。 +function parseStoryboardRowsPayload(raw: string): { title?: string; rows?: Array> } { + const parsed = JSON.parse(raw) as { title?: string; rows?: Array>; text?: unknown }; + if (Array.isArray(parsed.rows)) return parsed; + if (typeof parsed.text === "string" && parsed.text.trim()) { + const inner = parsed.text.trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, ""); + const start = inner.indexOf("{"); + const end = inner.lastIndexOf("}"); + if (start >= 0 && end > start) { + try { + const innerParsed = JSON.parse(inner.slice(start, end + 1)) as { title?: string; rows?: Array> }; + if (Array.isArray(innerParsed.rows)) return innerParsed; + } catch { + // 内层不是合法 JSON 时落到外层结构,由调用方的缺行校验统一报错。 + } + } + } + return parsed; +} + export function storyboardRowsFromTask(task: GenerationTask) { - const result = JSON.parse(task.resultJson || "{}") as { title?: string; rows?: Array> }; + const result = parseStoryboardRowsPayload(task.resultJson || "{}"); if (!Array.isArray(result.rows) || !result.rows.length) throw new Error("分镜任务没有返回镜头行"); return { title: result.title?.trim(), diff --git a/web/src/pages/projects/detail/project-chapter-ai.ts b/web/src/pages/projects/detail/project-chapter-ai.ts index 333169cec..0fedddca2 100644 --- a/web/src/pages/projects/detail/project-chapter-ai.ts +++ b/web/src/pages/projects/detail/project-chapter-ai.ts @@ -1,6 +1,6 @@ import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation"; import { PromptTemplateOperation, promptTemplateTaskPlaceholder } from "@/lib/prompts"; -import { storyboardRowsFromTask } from "@/lib/canvas/canvas-project-domain"; +import { storyboardRowsFromTask, storyboardRowsOutputContract } from "@/lib/canvas/canvas-project-domain"; import { parseChapterAssetBreakdown, type ChapterAssetBreakdown } from "@/lib/canvas/chapter-asset-breakdown"; import { parseCharacterBreakdown } from "@/lib/canvas/canvas-character-reference"; import { backendProviderConfig, parseBackendGenerationResult } from "@/services/api/generation-task"; @@ -90,14 +90,21 @@ export async function generateChapterStoryboard(input: ChapterStoryboardGenerati skills: input.skills, selectedSkillIds: input.selectedSkillIds, }); + // 技能上下文只描述工作流,不会约束输出结构;必须显式带上输出契约,否则模型返回 Markdown 表格。 + const prompt = [ + skillExecution.prompt, + storyboardRowsOutputContract("每个镜头必须能独立用于生成首帧图片和镜头视频。"), + ].join("\n\n"); const task = await createGenerationTask({ projectId: input.projectId, type: "canvas_text", operation: "storyboard", - prompt: skillExecution.prompt, + prompt, model, ...(logicalModelIDForConfig(config) ? { logicalModelId: logicalModelIDForConfig(config) } : {}), input: { + // 后端只在 video_ 前缀的任务上兜底 mode,文本任务必须显式带上。 + mode: "text", canvasAssets: input.assets, requirements: "输出可直接写入分镜制作并继续生成分镜图、动作预演和镜头视频的分镜表。", projectStyle: input.projectStyle, diff --git a/web/test/canvas-storyboard-rows-parse.test.ts b/web/test/canvas-storyboard-rows-parse.test.ts new file mode 100644 index 000000000..a954cc5ab --- /dev/null +++ b/web/test/canvas-storyboard-rows-parse.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; + +import { storyboardRowsFromTask } from "../src/lib/canvas/canvas-project-domain"; +import type { GenerationTask } from "../src/services/api/task-center"; + +function storyboardTask(resultJson: string): GenerationTask { + return { id: "task-1", resultJson } as GenerationTask; +} + +// 后端 canvas_text 任务统一落 {mode, reasoning, text} 包装,模型输出正文嵌在 text 里; +// 分镜解析必须穿透包装取内层 {title, rows},同时兼容模板路径直接落顶层的结构。 +describe("storyboardRowsFromTask", () => { + test("从 {mode, reasoning, text} 包装的 text 内层解析分镜行", () => { + const payload = { title: "桃源三结义", rows: [ + { shotNumber: 1, durationSeconds: 8, plotDescription: "朝堂开篇", dialogue: "", characters: ["刘备"], mustHave: ["列队"], optionalDetails: [] }, + { shotNumber: 2, durationSeconds: 10, plotDescription: "告示墙前叹气", dialogue: "唉……", characters: ["刘备", "关羽"] }, + ] }; + const task = storyboardTask(JSON.stringify({ mode: "text", reasoning: "思考过程", text: JSON.stringify(payload) })); + const result = storyboardRowsFromTask(task); + expect(result.title).toBe("桃源三结义"); + expect(result.rows).toHaveLength(2); + expect(result.rows[0].shotNumber).toBe(1); + expect(result.rows[0].status).toBe("idle"); + expect(result.rows[0].characters).toEqual(["刘备"]); + expect(result.rows[1].characters).toEqual(["刘备", "关羽"]); + }); + + test("text 内层被 ```json 代码块包裹时仍可解析", () => { + const payload = { title: "章节", rows: [{ shotNumber: 1, plotDescription: "单镜头" }] }; + const wrappedText = "```json\n" + JSON.stringify(payload) + "\n```"; + const task = storyboardTask(JSON.stringify({ mode: "text", text: wrappedText })); + expect(storyboardRowsFromTask(task).rows).toHaveLength(1); + }); + + test("text 内层混入解释文字时按首尾大括号截取解析", () => { + const payload = { title: "章节", rows: [{ shotNumber: 1, plotDescription: "单镜头" }] }; + const noisyText = "以下是分镜表:\n" + JSON.stringify(payload) + "\n如需调整请告知。"; + const task = storyboardTask(JSON.stringify({ mode: "text", text: noisyText })); + expect(storyboardRowsFromTask(task).rows).toHaveLength(1); + }); + + test("兼容顶层直接落 {title, rows} 的结构", () => { + const task = storyboardTask(JSON.stringify({ title: "章节", rows: [{ shotNumber: 1, plotDescription: "单镜头" }] })); + expect(storyboardRowsFromTask(task).rows).toHaveLength(1); + }); + + test("text 内层不是 JSON 时抛出缺行错误", () => { + // 无契约时期模型返回 Markdown 表格的真实场景。 + const task = storyboardTask(JSON.stringify({ mode: "text", text: "| 7-41 | 全景/拉远 | 桃园结义 |" })); + expect(() => storyboardRowsFromTask(task)).toThrow("分镜任务没有返回镜头行"); + }); + + test("rows 为空数组时抛出缺行错误", () => { + const task = storyboardTask(JSON.stringify({ mode: "text", text: JSON.stringify({ title: "空", rows: [] }) })); + expect(() => storyboardRowsFromTask(task)).toThrow("分镜任务没有返回镜头行"); + }); +});