fix(assets,storyboard): 统一素材库 entity 口径、修复远端空结果遮蔽,补分镜输出契约与解析 (#510)

远端空页只在本地仍有未同步结果或本页全是 entity 时回退,避免把合法空搜索铺成过期缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-17 00:01:57 +08:00
7 changed files with 183 additions and 38 deletions

View File

@@ -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 {

View File

@@ -146,7 +146,6 @@ 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 uploadInputRef = useRef<HTMLInputElement>(null);
const initialSelectedIdsRef = useRef(initialSelectedIds);
const itemsRef = useRef(items);
@@ -157,20 +156,28 @@ export function AssetLibraryPickerModal({
}, [items, uploadedItems]);
itemsRef.current = allItems;
const localItems = useMemo(() => allItems.filter((item) => !item.external), [allItems]);
// 远端成功且有可展示素材时用远端。真正的空结果保持空列表。
// 仅在远端空而本地仍有素材、或远端总数>0 但本页全被排除时回退本地,避免合法空搜索被缓存铺满。
const remoteTotal = remoteQuery.data?.total ?? 0;
const remoteReady = remoteEnabled && remoteQuery.isSuccess;
const preferLocalUnsynced = remoteReady && remoteTotal === 0 && localItems.length > 0;
const remoteEntityOnlyPage = remoteReady && remoteItems.length === 0 && remoteTotal > 0;
const useRemoteItems = remoteReady && !preferLocalUnsynced && !remoteEntityOnlyPage && (remoteItems.length > 0 || remoteTotal === 0);
const effectivePagination = useRemoteItems ? { current: remotePage, pageSize: remotePageSize, total: remoteTotal, onChange: (page: number, pageSize: number) => { setRemotePage(page); setRemotePageSize(pageSize); } } : pagination;
const pluginItems = useMemo(() => allItems.filter((item) => Boolean(item.external)), [allItems]);
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 +190,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 +488,7 @@ export function AssetLibraryPickerModal({
</nav>
<div className="asset-picker-grid-wrap">
<div className="asset-picker-grid">
{remoteEnabled && remoteQuery.isError ? <div role="alert">素材读取失败<Button onClick={() => void remoteQuery.refetch()}>重试</Button></div> : loading || (remoteEnabled && remoteQuery.isFetching) ? (
{remoteEnabled && remoteQuery.isError ? <div role="alert">素材读取失败<Button onClick={() => void remoteQuery.refetch()}>重试</Button></div> : loading || (useRemoteItems && remoteQuery.isFetching) ? (
<div className="asset-picker-empty">
<LoaderCircle className="animate-spin" />
<strong>正在读取素材</strong>

View File

@@ -99,8 +99,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<Partial<StoryboardRow>> } {
const parsed = JSON.parse(raw) as { title?: string; rows?: Array<Partial<StoryboardRow>>; 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<Partial<StoryboardRow>> };
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<Partial<StoryboardRow>> };
const result = parseStoryboardRowsPayload(task.resultJson || "{}");
if (!Array.isArray(result.rows) || !result.rows.length) throw new Error("分镜任务没有返回镜头行");
return {
title: result.title?.trim(),

View File

@@ -166,15 +166,20 @@ 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],
);
// 远端成功且本页有可展示素材时用远端。真正的空结果保持空页。
// 仅在「远端空、本地仍有筛选结果」或「远端总数>0 但本页全是被排除的 entity」时回退本地。
const remotePageAssets = useMemo(() => (assetPageQuery.data?.assets || []).filter((asset): asset is LibraryAsset => asset.kind !== "entity"), [assetPageQuery.data?.assets]);
const remoteTotal = assetPageQuery.data?.total ?? 0;
const remoteReady = assetPageQuery.isSuccess && assetPageQuery.data !== undefined;
const preferLocalUnsynced = remoteReady && remoteTotal === 0 && localVisibleAssets.length > 0;
const remoteEntityOnlyPage = remoteReady && remotePageAssets.length === 0 && remoteTotal > 0;
const useRemotePage = remoteReady && !preferLocalUnsynced && !remoteEntityOnlyPage && (remotePageAssets.length > 0 || remoteTotal === 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 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 ? remoteTotal : 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(() => {
@@ -708,7 +713,7 @@ export default function AssetsPage() {
onDelete={() => setBatchDeleteOpen(true)}
/>
) : null}
{validAssets.length === 0 ? (
{validAssets.length === 0 && totalAssets === 0 ? (
viewMode === "trash" ? (
<WorkspaceState icon="assets" compact title="回收站是空的" description="删除画布或手动移入回收站的素材会暂存到这里,可在需要时随时还原。" />
) : (
@@ -716,7 +721,7 @@ export default function AssetsPage() {
)
) : (
<>
{filteredAssets.length === 0 ? (
{visibleAssets.length === 0 ? (
<WorkspaceState icon="assets" compact title="没有匹配的素材" description="调整关键词或左侧分类后再试。" />
) : (
<CollectionGrid className="library-grid assets-library-grid" style={{ "--assets-grid-columns": gridDensity } as React.CSSProperties}>
@@ -1493,7 +1498,9 @@ function readAssetGridDensity(): AssetGridDensity {
function assetCountMap<T extends { label: string; value: string }>(options: T[], remote: Record<string, number> | undefined, fallback: LibraryAsset[], valueOf: (asset: LibraryAsset) => string) {
const result = new Map<string, number>();
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;

View File

@@ -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<CharacterForm>();
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<AssetLibraryPickerItem[]>(() => {
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
</div>
</div>
{childFolders.length ? <div className="project-asset-folder-grid mb-5">{childFolders.map((folder) => <ProjectAssetFolderCard key={folder.id} folder={folder} folders={assetFolders} assets={assets} folderCounts={folderCountMap} personalAssets={personalAssets} onOpen={() => 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)} />)}</div> : null}
{folderId === ALL_FOLDERS && (category === "all" || category === "character") && pendingCandidates.length ? (
<section className="mb-4" aria-label="待确认角色">
{showPendingCandidates && pendingCandidates.length ? (
<section className="mb-4" aria-label={`待确认${candidateLabel}`}>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-xs font-medium"><Sparkles className="size-3.5 text-foreground/50" />剧情识别出的角色</div>
<div className="flex items-center gap-1.5 text-xs font-medium"><Sparkles className="size-3.5 text-foreground/50" />剧情识别出的{candidateLabel}</div>
<span className="text-[var(--fs-tiny)] tabular-nums text-foreground/42">剩余 {candidatesQuery.data?.total || 0} 个待确认</span>
</div>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{pendingCandidates.map((candidate) => {
const confirming = confirmingCandidateId === candidate.id;
const isCharacter = candidate.category === "character";
return (
<article key={candidate.id} className="flex min-h-28 items-center gap-3 rounded-lg bg-surface-active p-3">
<span className="grid size-12 shrink-0 place-items-center rounded-md bg-foreground/[.045] text-foreground/25"><UserRound className="size-5" /></span>
<span className="grid size-12 shrink-0 place-items-center rounded-md bg-foreground/[.045] text-foreground/25">{isCharacter ? <UserRound className="size-5" /> : <Box className="size-5" />}</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold">{candidate.name}</div>
<div className="mt-1 text-[var(--fs-tiny)] text-foreground/42">待确认角色卡 · 来自章节分析</div>
<div className="mt-1 text-[var(--fs-tiny)] text-foreground/42">待确认{categoryLabel(candidate.category)} · 来自章节分析</div>
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1">
<Button type="text" size="small" icon={<Check className="size-3.5" />} loading={confirming} disabled={Boolean(confirmingCandidateId) && !confirming} onClick={() => confirmMutation.mutate({ candidateId: candidate.id })}>确认新角色</Button>
{characterAssets.length ? <Dropdown trigger={["click"]} menu={{ items: characterAssets.map((asset) => ({ key: asset.id, label: asset.title })), onClick: ({ key }) => confirmMutation.mutate({ candidateId: candidate.id, targetAssetId: key }) }}><Button type="text" size="small" disabled={Boolean(confirmingCandidateId)}>归并到角色<ChevronDown className="size-3" /></Button></Dropdown> : null}
<Button type="text" size="small" icon={<Check className="size-3.5" />} loading={confirming} disabled={Boolean(confirmingCandidateId) && !confirming} onClick={() => confirmMutation.mutate({ candidateId: candidate.id })}>{isCharacter ? "确认新角色" : "确认新增"}</Button>
{isCharacter && characterAssets.length ? <Dropdown trigger={["click"]} menu={{ items: characterAssets.map((asset) => ({ key: asset.id, label: asset.title })), onClick: ({ key }) => confirmMutation.mutate({ candidateId: candidate.id, targetAssetId: key }) }}><Button type="text" size="small" disabled={Boolean(confirmingCandidateId)}>归并到角色<ChevronDown className="size-3" /></Button></Dropdown> : null}
</div>
</div>
</article>

View File

@@ -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,

View File

@@ -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("分镜任务没有返回镜头行");
});
});