Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts
T
suzmii d1581892d7 修复 PR #316 review(前端 TS/TSX):资源工作台与资源画布
- 素材重命名(跨范围契约):`rename_local_project_asset` 现在要求 `expectedProjectId` + `expectedProjectRevision`,前端按删除 / 分类保存同一套 CAS 口径补齐——先 `get_local_game_project_revision` 读当前 revision 再连同项目身份提交,不用可能过期的本地缓存值
- 失败文案统一:新增 `projectAssetCommandErrorMessage`,把 `project-identity-conflict` / `project-revision-conflict` 翻成用户可读中文,重命名 / 删除 / 分类保存三条链路共用一份映射,其余错误原样透出
- index.tsx:资源面板下载按 `resolveDownloadablePanelEntries` 过滤,版本卡这类合成 path 的条目不再进入落盘链路
- index.tsx:`characterAnimationPanel` 纳入画布浮层开关判据(抽 `resolveResourceCanvasFloatingPanelOpen`),「生成动画」面板与快速编辑 / 信息浮层共用同一条「点外部 / Esc 关闭」规则
- index.tsx:去掉 `ResourceBookScene` 上重复的 `onWheel`,滚轮只由 manager 上的原生 `passive:false` 监听处理,平移 / 缩放不再被翻倍
- index.tsx:快速编辑的源资源在层 id 命中不到时回落到已正规化的 `asset:<id>`,重试不再静默什么都不做;取不到时给出可见失败
- ResourceCanvasPanelView.tsx:下载按钮按可下载条数判可用、不再在上传中显示下载转圈;上传中禁掉 × / Esc / 遮罩三条关闭路径;预览占位文案抽 helper
- GameRunVersionPicker.tsx:portal 菜单在滚动 / 缩放后重算位置;外部点击判定沿用共享 `useImageCanvasFloatingOptionDismiss` + `menuRef` 边界(review 该条已修,保留现状并补位置用例)
- ResourceCanvasGenerationPanelView.tsx:`submitting` 改 `finally` 收回,成功路径不再永久锁住面板
- ResourceAssetDeleteDialog.tsx / ResourceRenameDialog.tsx / ResourceClassificationPanel.tsx:在飞时 `closeOnEscape` / `closeOnBackdrop` 与头部 × 一起挡住
- resourceEditModel.ts / resourceCanvasToolbarModel.ts / useProjectResourceCardPreviews.ts / projectResourceLiveUpdateModel.ts:抽 `resourceBaseName`、`canonicalProjectedResourceMediaType` 参数收窄去掉强转、删未使用参数、补「IO 失败会 reject」的签名说明
- resourceCanvasHistoryModel.ts:`resourceCanvasSnapshotsEqual` 不再比较不可恢复的 `manuallyPlaced`,消除「压进历史但撤销是空操作」
- resourceCanvasSectionMapping.ts:删掉从未接线、且注释与实现相矛盾的 `LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK`
- resourceCanvasChrome.css:`.game-resource-panel-grid` 补 `flex: 1 1 auto; min-height: 0`,卡片网格成为真正的滚动区
- 用例:重命名 CAS 载荷与读序、CAS 拒绝文案、版本卡下载门禁、上传中关闭路径、浮层判据、菜单跟随滚动、分区映射
2026-09-12 20:49:03 +08:00

302 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type ProjectResource,
projectResourceDisplayKind,
} from './resourceProjectionModel';
export type LocalProjectResourceEditKind =
| 'image-reference'
| 'svg'
| 'character-animation'
| 'video'
| 'sound-effect'
| 'background-music'
| 'text'
| 'agent-result'
| 'version';
export type ProjectResourceEditCapability = {
route: 'derive';
editKind: LocalProjectResourceEditKind;
sourceMediaType: string;
semanticNotice: string | null;
generationMode?: 'create' | 'derive';
};
const extensionMediaTypes: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
webp: 'image/webp',
gif: 'image/gif',
svg: 'image/svg+xml',
avif: 'image/avif',
bmp: 'image/bmp',
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
m4a: 'audio/mp4',
aac: 'audio/aac',
flac: 'audio/flac',
opus: 'audio/opus',
md: 'text/markdown',
markdown: 'text/markdown',
mdx: 'application/mdx',
txt: 'text/plain',
json: 'application/json',
yaml: 'application/yaml',
yml: 'application/yaml',
toml: 'application/toml',
html: 'text/html',
htm: 'text/html',
css: 'text/css',
scss: 'text/css',
less: 'text/css',
js: 'text/javascript',
jsx: 'text/javascript',
mjs: 'text/javascript',
cjs: 'text/javascript',
ts: 'text/typescript',
tsx: 'text/typescript',
rs: 'text/x-rust',
py: 'text/x-python',
go: 'text/x-go',
java: 'text/plain',
kt: 'text/plain',
kts: 'text/plain',
c: 'text/plain',
cc: 'text/plain',
cpp: 'text/plain',
h: 'text/plain',
hpp: 'text/plain',
cs: 'text/plain',
swift: 'text/plain',
php: 'text/plain',
rb: 'text/plain',
lua: 'text/plain',
sh: 'text/plain',
bash: 'text/plain',
zsh: 'text/plain',
sql: 'text/plain',
graphql: 'text/plain',
gql: 'text/plain',
xml: 'text/plain',
csv: 'text/csv',
ini: 'text/plain',
conf: 'text/plain',
vue: 'text/plain',
svelte: 'text/plain',
};
function pathExtension(path: string) {
return path.split(/[?#]/u, 1)[0]?.split('.').pop()?.toLowerCase() ?? '';
}
/**
* 规范化的媒体类型:只读 `mediaType` 与 `path`。
*
* 参数按实际读到的字段收窄(而不是收整个 `ProjectResource`),调用方拿完整资源也兼容,
* 但少一层 `as ProjectResource` 强转:以后再读别的字段会当场编译报错,而不是拿到 `undefined`。
*/
export function canonicalProjectedResourceMediaType(
resource: Pick<ProjectResource, 'mediaType' | 'path'>,
) {
const declared = resource.mediaType.trim().toLowerCase();
if (/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/u.test(declared)) {
return declared;
}
return extensionMediaTypes[pathExtension(resource.path)] ?? declared;
}
function isBackgroundMusic(resource: ProjectResource) {
const semanticSource = `${resource.subtype} ${resource.path}`.toLowerCase();
return ['background', 'bgm', 'music', 'theme'].some((marker) =>
semanticSource.includes(marker),
);
}
export function resolveProjectResourceEditCapability(
resource: ProjectResource,
): ProjectResourceEditCapability {
if (resource.version) {
return {
route: 'derive',
editKind: 'version',
sourceMediaType: 'application/vnd.genarrative.project-version+json',
semanticNotice: null,
};
}
if (resource.subtype === 'agent-result') {
return {
route: 'derive',
editKind: 'agent-result',
sourceMediaType: 'text/markdown',
semanticNotice: null,
};
}
const mediaType = canonicalProjectedResourceMediaType(resource);
if (mediaType === 'image/svg+xml') {
return {
route: 'derive',
editKind: 'svg',
sourceMediaType: mediaType,
semanticNotice: null,
};
}
if (mediaType.startsWith('image/')) {
return {
route: 'derive',
editKind: 'image-reference',
sourceMediaType: mediaType,
semanticNotice: null,
};
}
if (mediaType.startsWith('video/')) {
return {
route: 'derive',
editKind: 'video',
sourceMediaType: mediaType,
semanticNotice: null,
};
}
if (
mediaType.startsWith('audio/') ||
projectResourceDisplayKind(resource) === 'audio'
) {
return {
route: 'derive',
editKind: isBackgroundMusic(resource)
? 'background-music'
: 'sound-effect',
sourceMediaType: mediaType,
semanticNotice: '音频将基于原资源语义派生重制,不会修改原音频波形。',
};
}
return {
route: 'derive',
editKind: 'text',
sourceMediaType: mediaType,
semanticNotice: null,
};
}
export function resolveProjectCharacterAnimationCapability(
resource: ProjectResource,
): Extract<ProjectResourceEditCapability, { route: 'derive' }> | null {
const mediaType = canonicalProjectedResourceMediaType(resource);
if (!mediaType.startsWith('image/')) return null;
return {
route: 'derive',
editKind: 'character-animation',
sourceMediaType: mediaType,
semanticNotice: '将基于当前图片生成角色动作序列帧,源图片保持不变。',
generationMode: 'derive',
};
}
/**
* 资源名去掉扩展名后的基名(找不到可用名字时返回空串,由各自的后缀规则补兜底名)。
*
* 派生资源的命名规则共用这一条,避免「-编辑版」与「-角色动画」在去扩展名的口径上分叉。
*/
export function resourceBaseName(
resource: Pick<ProjectResource, 'label'>,
): string {
return resource.label.replace(/\.[^.]+$/u, '').trim();
}
export function defaultDerivedResourceName(resource: ProjectResource) {
return `${resourceBaseName(resource) || '资源'}-编辑版`;
}
/**
* 角色动画派生资源的默认名称。
*
* 与 `defaultDerivedResourceName` 同一条口径(去扩展名 + 兜底名),只换后缀:动作序列帧
* 不是"编辑版",用「-角色动画」让新素材在资源卡上一眼认出来源与产出类型。
*/
export function defaultCharacterAnimationResourceName(
resource: ProjectResource,
) {
return `${resourceBaseName(resource) || '资源'}-角色动画`;
}
export function resourceEditPromptMaxLength(
editKind: LocalProjectResourceEditKind,
) {
switch (editKind) {
case 'background-music':
return 140;
case 'sound-effect':
return 1_900;
case 'video':
return 4_000;
case 'character-animation':
return 4_000;
default:
return 32_000;
}
}
/** 提示词被长度上限截断时给用户看的替代文案;动过内容就必须说,不许静默丢内容。 */
export const RESOURCE_EDIT_PROMPT_TRUNCATED_NOTICE = '已按长度上限截断';
/**
* 按资源类型的提示词上限截断(与 Rust `resource_editor.rs` 的上限同口径,只是本地先截)。
*
* 上限本身不动后端;这里只处理润色回填这类程序化写入——输入框的 `maxLength` 只管用户键入。
*/
export function truncateResourceEditPrompt(
prompt: string,
editKind: LocalProjectResourceEditKind,
): { text: string; notice: string | null } {
const limit = resourceEditPromptMaxLength(editKind);
if (prompt.length <= limit) {
return { text: prompt, notice: null };
}
return {
text: prompt.slice(0, limit),
notice: RESOURCE_EDIT_PROMPT_TRUNCATED_NOTICE,
};
}
/** 一次资源编辑请求的身份:绑定到它铸造时的那句提示词。 */
export type ResourceEditRequestIdentity = {
operationId: string;
idempotencyKey: string;
prompt: string;
};
export function createResourceEditRequestIdentity(
prompt: string,
): ResourceEditRequestIdentity {
return {
operationId: crypto.randomUUID(),
idempotencyKey: crypto.randomUUID(),
prompt,
};
}
/**
* 复用还是重铸请求身份。
*
* Rust 的 `resource_edit_request_fingerprint` 把 `prompt` 也算进摘要
* (`src-tauri/src/project/resource_editor.rs:807-827`),因此同一个 operationId /
* 幂等键带着改过的提示词重试,会被判成「operationId 或幂等键已绑定到不同资源编辑请求」
* (同文件 :5216-5225)。所以身份只对它铸造时的那句提示词有效:提示词变了就重铸,
* 而不是靠禁用润色入口来回避。
*
* 提示词没变时照旧复用:失败重试仍命中同一 operation 账本,不会重复扣费。
*/
export function resolveResourceEditRequestIdentity(
current: ResourceEditRequestIdentity | null,
prompt: string,
): ResourceEditRequestIdentity {
return current && current.prompt === prompt
? current
: createResourceEditRequestIdentity(prompt);
}