d1581892d7
- 素材重命名(跨范围契约):`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 拒绝文案、版本卡下载门禁、上传中关闭路径、浮层判据、菜单跟随滚动、分区映射
302 lines
8.4 KiB
TypeScript
302 lines
8.4 KiB
TypeScript
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);
|
||
}
|