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 与泥点价档位,并断言参数入口不存在。
285 lines
7.8 KiB
TypeScript
285 lines
7.8 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() ?? '';
|
||
}
|
||
|
||
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);
|
||
}
|