Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts
T
suzmii e765c9f8a6 资源卡工具条接通「生成动画」:只放行真能跑通的角色图派生
- resourceCanvasToolbarModel:只有在「已登记的栅格角色图」上放行 character-animation
  (非角色 / 未登记为正式素材 / 非栅格三类都不放行;Rust 侧角色动画要解码源图取宽高,SVG 必失败);其余动作仍按 opt-in 不渲染,并写明不做它们各自的阻塞证据。
- index.tsx:把源解析与 revision 缓存抽成 resolveResourceDeriveSource,快速编辑与生成动画共用,避免同一个源在两条链路上得到两份 manifest 视图;新增生成动画的源/浮层状态、请求身份(与快速编辑同一套「提示词变了才重铸」口径)与打开/提交/关闭时机。
- 面板复用共享 ImageCanvasCharacterAnimationPanelView,参数入口显式关闭(比例/时长/清晰度与模型由客户端 Rust 固定为 same/720p/32 帧/4 秒/seedance2.0-fast,改它不改变请求);档位常量与 Rust 常量同值,泥点价按同一算法算,避免按钮上出现与实际计费不符的数字。
- 「改造」(redraw) 不再另起入口:其语义由既有「快速编辑」承担,接独立按钮只是同链路重复入口。
- 断言改写(四不写):角色资源那条由「只放行快速编辑与下载」改写为「放行生成动画」,并新增三条反向守卫(非角色图片 / 角色图未登记 / 非栅格角色图均不放行)。
- 端到端新增「从角色资源卡生成动画」:断言 editKind/generationMode/prompt/assetName/sourceAssetId 与泥点价档位,并断言参数入口不存在。
2026-09-11 20:08:22 +08:00

285 lines
7.8 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() ?? '';
}
export function canonicalProjectedResourceMediaType(resource: ProjectResource) {
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 defaultDerivedResourceName(resource: ProjectResource) {
const baseName = resource.label.replace(/\.[^.]+$/u, '').trim();
return `${baseName || '资源'}-编辑版`;
}
/**
* 角色动画派生资源的默认名称。
*
* 与 `defaultDerivedResourceName` 同一条口径(去扩展名 + 兜底名),只换后缀:动作序列帧
* 不是"编辑版",用「-角色动画」让新素材在资源卡上一眼认出来源与产出类型。
*/
export function defaultCharacterAnimationResourceName(
resource: ProjectResource,
) {
const baseName = resource.label.replace(/\.[^.]+$/u, '').trim();
return `${baseName || '资源'}-角色动画`;
}
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);
}