收紧角色外观参考图容量门禁

限制视频模式角色外观图最多九张,并在前端追加、恢复和提交链路统一收口。

在入队归一化和 durable worker 执行前限制单图、累计字节、解码边长与总像素。

补充九/十张、三十兆字节、累计大小和压缩炸弹像素边界回归。

同步更新 Spine 技术方案与团队决策记录。
This commit is contained in:
2026-08-17 15:01:17 +08:00
parent c51d88fd19
commit ae5c2d8821
11 changed files with 274 additions and 8 deletions
@@ -1872,6 +1872,26 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('drops character animation appearance references beyond the ninth during hydration', () => {
const snapshot = {
id: 'dialog-character-animation-over-limit',
mode: 'character-animation',
prompt: '奔跑',
status: 'idle',
characterAnimationAppearanceReferences: Array.from(
{ length: 10 },
(_, index) => ({
id: `appearance-${index}`,
label: `角色外观${index + 1}`,
src: `users/user-1/appearance-${index}.png`,
}),
),
};
const hydrated = hydrateCanvasGenerationDialog(snapshot);
expect(hydrated?.characterAnimationAppearanceReferences).toHaveLength(9);
});
it('keeps the operation ledger out of the layout and restores it from the local ledger', () => {
const dialogId = 'dialog-perfect-pixel-round-trip';
const operation = buildPerfectPixelOperation(dialogId);
@@ -32,6 +32,7 @@ import type {
EditorAssetFolder,
PerfectPixelOperationSnapshot,
} from './ImageCanvasEditorTypes';
import { CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT } from './ImageCanvasFileModel';
import {
hydrateCanvasGenerationInputs,
hydrateCanvasGenerationInputsResult,
@@ -1282,7 +1283,7 @@ export function hydrateCanvasGenerationDialog(
snapshot.characterAnimationAppearanceReferences,
resourcesById,
currentUserId,
),
)?.slice(0, CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT),
characterAnimationConversionVideoReference: hydrateCharacterReference(
snapshot.characterAnimationConversionVideoReference,
resourcesById,
@@ -25,6 +25,9 @@ export function getEditorUploadAccept(target: string) {
return EDITOR_ASSET_UPLOAD_ACCEPT;
}
export const CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT = 9;
export const CHARACTER_ANIMATION_APPEARANCE_TOTAL_MAX_BYTES = 64 * 1024 * 1024;
export const SEEDANCE_REFERENCE_FILE_LIMITS = {
imageBytes: 30 * 1024 * 1024,
videoBytes: 50 * 1024 * 1024,
@@ -19,6 +19,7 @@ import type {
SpecFormValues,
SpecGenerationType,
} from './ImageCanvasEditorTypes';
import { CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT } from './ImageCanvasFileModel';
import {
appendLimitedImageReferences,
appendLimitedQuickEditReferences,
@@ -2404,7 +2405,7 @@ export function appendGenerationReference(
characterAnimationAppearanceReferences: [
...(dialog.characterAnimationAppearanceReferences ?? []),
createCanvasLayerReference(layer),
],
].slice(0, CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT),
}
: {
sourceLayerId: layer.id,
@@ -537,6 +537,39 @@ describe('ImageCanvasUploadModel', () => {
});
});
it('caps character animation appearance uploads at nine references', () => {
const motionReference = {
id: 'motion',
label: '动作视频',
src: 'motion.mp4',
mediaType: 'video' as const,
};
const existingAppearances = Array.from({ length: 9 }, (_, index) => ({
id: `appearance-${index}`,
label: `角色外观${index + 1}`,
src: `appearance-${index}.png`,
mediaType: 'image' as const,
}));
const nextAppearance = {
id: 'appearance-10',
label: '第十张角色外观',
src: 'appearance-10.png',
mediaType: 'image' as const,
};
expect(
applyGenerationReferenceUpload({
dialog: createDialog({
mode: 'character-animation',
generationReferences: [motionReference],
characterAnimationAppearanceReferences: existingAppearances,
}),
target: 'video-reference-image',
references: [nextAppearance],
})?.characterAnimationAppearanceReferences,
).toEqual(existingAppearances);
});
it('resizes a same-ratio character animation placeholder for an uploaded reference', () => {
const dialog = createDialog({
mode: 'character-animation',
@@ -11,6 +11,7 @@ import type {
GenerateDialogState,
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
import { CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT } from './ImageCanvasFileModel';
import {
appendLimitedImageReferences,
QUICK_EDIT_REFERENCE_LIMIT,
@@ -279,7 +280,7 @@ export function applyGenerationReferenceUpload({
characterAnimationAppearanceReferences: [
...(dialog.characterAnimationAppearanceReferences ?? []),
firstReference,
],
].slice(0, CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT),
composerOpen: true,
}
: {
@@ -51,6 +51,10 @@ import type {
QuickEditPanelState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import {
CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT,
CHARACTER_ANIMATION_APPEARANCE_TOTAL_MAX_BYTES,
} from './ImageCanvasFileModel';
import {
applyImageEditResultToSourceLayer,
createAudioResultLayer,
@@ -2703,6 +2707,23 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const selectedReference = characterAnimationPanel.references?.[0];
const appearanceReferences =
characterAnimationPanel.appearanceReferences ?? [];
if (
appearanceReferences.length >
CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT
) {
throw new Error(
`角色外观图最多支持${CHARACTER_ANIMATION_APPEARANCE_REFERENCE_LIMIT}张`,
);
}
const knownAppearanceBytes = appearanceReferences.reduce(
(total, reference) => total + (reference.sizeBytes ?? 0),
0,
);
if (
knownAppearanceBytes > CHARACTER_ANIMATION_APPEARANCE_TOTAL_MAX_BYTES
) {
throw new Error('角色外观图总大小不能超过64MB');
}
const referenceMediaType =
selectedReference?.mediaType === 'video' ? 'video' : 'image';
const stableReferenceSrc = selectedReference