Image editor: hide raw Prompt, use Resolution

Remove backend-assembled raw Prompt and copy action from image info; render a lightweight generationInputs snapshot (user panel inputs + reference thumbnails) stored on canvas layers and shown in the image info dialog. Unify canvas display and info to use originalWidth/originalHeight (Resolution) instead of saved Size and hydrate legacy layout width/height only as fallback. Add model/aspectRatio/imageSize options for character/icon generation (frontend state, tests, and client payloads). Increase Axum JSON body limit for character animation endpoint to 12MB for compatibility and prefer submitting persisted objectKey over large Data URLs. Update tests, docs, and related server/frontend code to reflect these behaviors and validations.
This commit is contained in:
2026-06-16 17:06:21 +08:00
parent 7eeff10c67
commit 3a3cc89280
14 changed files with 1041 additions and 135 deletions

View File

@@ -111,6 +111,22 @@ type EditorAsset = {
assetObjectId?: string;
};
type CanvasGenerationInputField = {
title: string;
value: string;
};
type CanvasGenerationInputReference = {
title: string;
label: string;
src: string;
};
type CanvasGenerationInputs = {
fields: CanvasGenerationInputField[];
references: CanvasGenerationInputReference[];
};
type CanvasLayer = {
id: string;
resourceId: string;
@@ -134,6 +150,7 @@ type CanvasLayer = {
sourceResourceId?: string | null;
groupId?: string | null;
assetKind?: 'spec' | 'character' | 'icon' | 'icon-spec' | null;
generationInputs?: CanvasGenerationInputs | null;
};
type CanvasViewport = {
@@ -383,8 +400,8 @@ const INITIAL_LAYERS: CanvasLayer[] = [
src: '/creation-type-references/puzzle.webp',
x: 470,
y: 300,
width: 420,
height: 420,
width: 640,
height: 640,
originalWidth: 640,
originalHeight: 640,
zIndex: 1,
@@ -397,8 +414,8 @@ const INITIAL_LAYERS: CanvasLayer[] = [
src: '/creation-type-references/big-fish.webp',
x: 930,
y: 360,
width: 420,
height: 236,
width: 720,
height: 405,
originalWidth: 720,
originalHeight: 405,
zIndex: 2,
@@ -544,6 +561,18 @@ function formatImageSizeValue(width: number, height: number) {
return `${safeWidth}x${safeHeight}`;
}
function resolveLayerResolutionSize(
originalWidth: number,
originalHeight: number,
fallback: { width: number; height: number },
) {
// 中文注释:画布不再维护独立展示 Size图片显示尺寸统一跟随图片原始 Resolution。
return {
width: Math.max(1, Math.round(originalWidth || fallback.width || 1)),
height: Math.max(1, Math.round(originalHeight || fallback.height || 1)),
};
}
function buildQuickEditSizeOptions(currentSize: string) {
return Array.from(new Set([currentSize, ...QUICK_EDIT_SIZE_PRESETS]));
}
@@ -565,10 +594,11 @@ function createLayerFromAsset(
viewport: CanvasViewport,
screenCenter: { x: number; y: number },
): CanvasLayer {
const longestSide = Math.max(asset.width, asset.height);
const sizeRatio = longestSide > 0 ? 360 / longestSide : 1;
const width = Math.round(asset.width * sizeRatio);
const height = Math.round(asset.height * sizeRatio);
const { width, height } = resolveLayerResolutionSize(
asset.width,
asset.height,
{ width: 360, height: 360 },
);
const worldCenterX = (screenCenter.x - viewport.x) / viewport.scale;
const worldCenterY = (screenCenter.y - viewport.y) / viewport.scale;
const offset = index * 34;
@@ -620,6 +650,7 @@ function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot {
sourceResourceId: layer.sourceResourceId,
groupId: layer.groupId,
assetKind: layer.assetKind,
generationInputs: layer.generationInputs,
};
}
@@ -643,10 +674,18 @@ function hydrateLayer(
src,
x: numberFromSnapshot(snapshot.x, 0),
y: numberFromSnapshot(snapshot.y, 0),
width: numberFromSnapshot(snapshot.width, 320),
height: numberFromSnapshot(snapshot.height, 320),
originalWidth: numberFromSnapshot(snapshot.originalWidth, 320),
originalHeight: numberFromSnapshot(snapshot.originalHeight, 320),
...(() => {
const originalWidth = numberFromSnapshot(snapshot.originalWidth, 320);
const originalHeight = numberFromSnapshot(snapshot.originalHeight, 320);
return {
...resolveLayerResolutionSize(originalWidth, originalHeight, {
width: numberFromSnapshot(snapshot.width, 320),
height: numberFromSnapshot(snapshot.height, 320),
}),
originalWidth,
originalHeight,
};
})(),
zIndex: numberFromSnapshot(snapshot.zIndex, 1),
sourceType: isCanvasSourceType(snapshot.sourceType)
? snapshot.sourceType
@@ -661,6 +700,7 @@ function hydrateLayer(
sourceResourceId: stringOrNull(snapshot.sourceResourceId),
groupId: stringOrNull(snapshot.groupId),
assetKind: canvasAssetKindOrNull(snapshot.assetKind),
generationInputs: generationInputsOrNull(snapshot.generationInputs),
};
}
@@ -717,6 +757,45 @@ function stringOrNull(value: unknown) {
return typeof value === 'string' && value.trim() ? value : null;
}
function generationInputsOrNull(value: unknown): CanvasGenerationInputs | null {
if (!value || typeof value !== 'object') {
return null;
}
const snapshot = value as {
fields?: unknown;
references?: unknown;
};
const fields = Array.isArray(snapshot.fields)
? snapshot.fields.flatMap((field) => {
if (!field || typeof field !== 'object') {
return [];
}
const item = field as { title?: unknown; value?: unknown };
const title = stringOrNull(item.title);
const fieldValue = stringOrNull(item.value);
return title && fieldValue ? [{ title, value: fieldValue }] : [];
})
: [];
const references = Array.isArray(snapshot.references)
? snapshot.references.flatMap((reference) => {
if (!reference || typeof reference !== 'object') {
return [];
}
const item = reference as {
title?: unknown;
label?: unknown;
src?: unknown;
};
const title = stringOrNull(item.title);
const label = stringOrNull(item.label);
const src = stringOrNull(item.src);
return title && label && src ? [{ title, label, src }] : [];
})
: [];
return fields.length || references.length ? { fields, references } : null;
}
function canvasAssetKindOrNull(value: unknown): CanvasLayer['assetKind'] {
return value === 'spec' ||
value === 'character' ||
@@ -821,6 +900,11 @@ function calculateCharacterAnimationPrice(
return (resolution === '720p' ? 20 : 10) * durationSeconds;
}
function resolveCharacterAnimationSourceImageSrc(layer: CanvasLayer) {
// 中文注释:角色图已持久化到 OSS 时优先传 objectKey避免把大 Data URL 塞进 JSON 请求体触发 body limit。
return layer.objectKey?.trim() || layer.src;
}
function createCanvasLayerReference(
layer: CanvasLayer,
): CharacterReferenceImage {
@@ -831,6 +915,110 @@ function createCanvasLayerReference(
};
}
function createGenerationInputField(
title: string,
value: string | null | undefined,
): CanvasGenerationInputField[] {
const normalizedValue = value?.trim();
return normalizedValue ? [{ title, value: normalizedValue }] : [];
}
function buildImageGenerationInputs(prompt: string): CanvasGenerationInputs {
return {
fields: createGenerationInputField('生成提示词', prompt),
references: [],
};
}
function buildSpecGenerationInputs(
specType: SpecGenerationType,
values: SpecFormValues,
): CanvasGenerationInputs {
if (specType === 'custom') {
return {
fields: createGenerationInputField('自定义规范提示词', values.customPrompt),
references: [],
};
}
const baseFields = [
...createGenerationInputField('玩法设定', values.playSetting),
...createGenerationInputField('美术风格', values.artStyle),
];
if (specType === 'character') {
baseFields.push(
...createGenerationInputField('头身比', values.bodyRatio),
...createGenerationInputField('角色视角', values.characterView),
);
}
return {
fields: baseFields,
references: [],
};
}
function buildCharacterGenerationInputs(
prompt: string,
specReference: CharacterReferenceImage | null | undefined,
references: CharacterReferenceImage[] | undefined,
): CanvasGenerationInputs {
return {
fields: createGenerationInputField('角色设定', prompt),
references: [
...(specReference
? [
{
title: '角色形象规范',
label: specReference.label,
src: specReference.src,
},
]
: []),
...(references ?? []).map((reference, index) => ({
title: `常规参考图 ${index + 1}`,
label: reference.label,
src: reference.src,
})),
],
};
}
function buildIconGenerationInputs(
iconDescriptions: string[],
specReference: CharacterReferenceImage,
): CanvasGenerationInputs {
return {
fields: iconDescriptions.map((description, index) => ({
title: `素材描述 ${index + 1}`,
value: description,
})),
references: [
{
title: '图标素材规范',
label: specReference.label,
src: specReference.src,
},
],
};
}
function buildEditGenerationInputs(
title: '修改要求' | '快速编辑提示词',
prompt: string,
sourceLayer: CanvasLayer,
): CanvasGenerationInputs {
return {
fields: createGenerationInputField(title, prompt),
references: [
{
title: '参考图',
label: sourceLayer.title,
src: sourceLayer.src,
},
],
};
}
function buildPortalMenuStyle(
anchor: HTMLElement | null,
placement: 'above' | 'below',
@@ -2408,10 +2596,11 @@ export function ImageCanvasEditorView() {
uploadedImage.onload = () => {
const originalWidth = uploadedImage.naturalWidth || fallbackWidth;
const originalHeight = uploadedImage.naturalHeight || fallbackHeight;
const longestSide = Math.max(originalWidth, originalHeight);
const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1;
const width = Math.round(originalWidth * sizeRatio);
const height = Math.round(originalHeight * sizeRatio);
const { width, height } = resolveLayerResolutionSize(
originalWidth,
originalHeight,
{ width: fallbackWidth, height: fallbackHeight },
);
if (options.addToCanvas) {
setLayers((currentLayers) =>
currentLayers.map((layer) =>
@@ -2672,19 +2861,28 @@ export function ImageCanvasEditorView() {
assetKind?: CanvasLayer['assetKind'];
title?: string;
dialogId?: string;
generationInputs?: CanvasGenerationInputs;
} = {},
) => {
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const originalWidth = generated.width || 1024;
const originalHeight = generated.height || 1024;
const longestSide = Math.max(originalWidth, originalHeight);
const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1;
const width = options.frame?.width ?? Math.round(originalWidth * sizeRatio);
const height =
options.frame?.height ?? Math.round(originalHeight * sizeRatio);
const { width, height } = resolveLayerResolutionSize(
originalWidth,
originalHeight,
{ width: 1024, height: 1024 },
);
const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale;
const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale;
const frameX =
options.frame && options.frame.width > 0
? options.frame.x + options.frame.width / 2 - width / 2
: undefined;
const frameY =
options.frame && options.frame.height > 0
? options.frame.y + options.frame.height / 2 - height / 2
: undefined;
const nextLayer: CanvasLayer = {
id: options.sourceLayer
? `layer-edit-${generatedIndex}`
@@ -2698,10 +2896,10 @@ export function ImageCanvasEditorView() {
src: generated.imageSrc,
x: options.sourceLayer
? options.sourceLayer.x + options.sourceLayer.width + 32
: (options.frame?.x ?? worldCenterX - width / 2),
: (frameX ?? worldCenterX - width / 2),
y: options.sourceLayer
? options.sourceLayer.y
: (options.frame?.y ?? worldCenterY - height / 2),
: (frameY ?? worldCenterY - height / 2),
width,
height,
originalWidth,
@@ -2717,6 +2915,7 @@ export function ImageCanvasEditorView() {
objectKey: generated.objectKey,
assetObjectId: generated.assetObjectId,
sourceResourceId: options.sourceLayer?.resourceId,
generationInputs: options.generationInputs,
};
setLayers((currentLayers) => [...currentLayers, nextLayer]);
@@ -2748,9 +2947,20 @@ export function ImageCanvasEditorView() {
const addQuickEditResultLayer = (
generated: EditorImageGenerationResult,
sourceLayer: CanvasLayer,
generationInputs: CanvasGenerationInputs,
) => {
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const originalWidth = generated.width || sourceLayer.originalWidth || 1024;
const originalHeight = generated.height || sourceLayer.originalHeight || 1024;
const { width, height } = resolveLayerResolutionSize(
originalWidth,
originalHeight,
{
width: sourceLayer.width,
height: sourceLayer.height,
},
);
const nextLayer: CanvasLayer = {
id: `layer-quick-edit-${generatedIndex}`,
resourceId: `local-resource-quick-edit-${generatedIndex}`,
@@ -2758,10 +2968,10 @@ export function ImageCanvasEditorView() {
src: generated.imageSrc,
x: sourceLayer.x + sourceLayer.width + 32,
y: sourceLayer.y,
width: sourceLayer.width,
height: sourceLayer.height,
originalWidth: sourceLayer.originalWidth,
originalHeight: sourceLayer.originalHeight,
width,
height,
originalWidth,
originalHeight,
zIndex: generatedIndex + 10,
sourceType: generated.sourceType,
prompt: generated.prompt,
@@ -2774,6 +2984,7 @@ export function ImageCanvasEditorView() {
sourceResourceId: sourceLayer.resourceId,
groupId: sourceLayer.groupId,
assetKind: sourceLayer.assetKind,
generationInputs,
};
setLayers((currentLayers) => [...currentLayers, nextLayer]);
@@ -2788,6 +2999,7 @@ export function ImageCanvasEditorView() {
const addIconSpritesheetResultLayers = (
generated: EditorIconSpritesheetGenerationResult,
iconResults: EditorIconSpritesheetIconResult[],
generationInputs: CanvasGenerationInputs,
frame?: GenerateDialogState['placeholder'],
dialogId?: string,
) => {
@@ -2809,10 +3021,11 @@ export function ImageCanvasEditorView() {
iconResults.forEach((icon) => {
const originalWidth = icon.width || 128;
const originalHeight = icon.height || 128;
const longestSide = Math.max(originalWidth, originalHeight);
const sizeRatio = longestSide > 0 ? Math.min(1, 128 / longestSide) : 1;
const width = Math.round(originalWidth * sizeRatio);
const height = Math.round(originalHeight * sizeRatio);
const { width, height } = resolveLayerResolutionSize(
originalWidth,
originalHeight,
{ width: 128, height: 128 },
);
if (cursorX > startX && cursorX + width - startX > maxRowWidth) {
cursorX = startX;
cursorY += rowHeight + spacing;
@@ -2840,6 +3053,7 @@ export function ImageCanvasEditorView() {
provider: generated.provider,
taskId: generated.taskId,
assetKind: 'icon',
generationInputs,
});
cursorX += width + spacing;
@@ -2951,6 +3165,7 @@ export function ImageCanvasEditorView() {
addIconSpritesheetResultLayers(
generated,
generated.iconImageSrcs,
buildIconGenerationInputs(iconDescriptions, dialog.iconSpecReference),
getGeneratingDialogPlaceholder(dialog),
canvasDialog.id,
);
@@ -2988,7 +3203,15 @@ export function ImageCanvasEditorView() {
model: quickEditPanel.model,
referenceImageSrcs: [referenceImageSrc],
});
addQuickEditResultLayer(generated, quickEditSourceLayer);
addQuickEditResultLayer(
generated,
quickEditSourceLayer,
buildEditGenerationInputs(
'快速编辑提示词',
normalizedPrompt,
quickEditSourceLayer,
),
);
} catch (error) {
setQuickEditPanel({
...quickEditPanel,
@@ -3035,7 +3258,14 @@ export function ImageCanvasEditorView() {
prompt: normalizedPrompt,
sourceImageSrc: referenceImageSrc,
});
addGeneratedResultLayer(generated, { sourceLayer });
addGeneratedResultLayer(generated, {
sourceLayer,
generationInputs: buildEditGenerationInputs(
'修改要求',
normalizedPrompt,
sourceLayer,
),
});
} else if (dialog.mode === 'spec') {
const specType = dialog.specType ?? 'custom';
const specValues =
@@ -3052,6 +3282,7 @@ export function ImageCanvasEditorView() {
assetKind: specType === 'icon' ? 'icon-spec' : 'spec',
title: `${SPEC_TYPE_LABEL[specType]} ${layerCounterRef.current + 1}`,
dialogId: canvasDialog?.id,
generationInputs: buildSpecGenerationInputs(specType, specValues),
});
} else if (dialog.mode === 'character') {
const referenceImageSrcs = [
@@ -3070,6 +3301,11 @@ export function ImageCanvasEditorView() {
assetKind: 'character',
title: `角色形象 ${layerCounterRef.current + 1}`,
dialogId: canvasDialog?.id,
generationInputs: buildCharacterGenerationInputs(
normalizedPrompt,
dialog.characterSpecReference,
dialog.characterReferences,
),
});
} else {
const generated = await generateEditorImage({
@@ -3078,6 +3314,7 @@ export function ImageCanvasEditorView() {
addGeneratedResultLayer(generated, {
frame: getGeneratingDialogPlaceholder(dialog),
dialogId: canvasDialog?.id,
generationInputs: buildImageGenerationInputs(normalizedPrompt),
});
}
} catch (error) {
@@ -3688,7 +3925,9 @@ export function ImageCanvasEditorView() {
try {
const result = await generateEditorCharacterAnimation({
sourceLayerId: characterAnimationSourceLayer.id,
sourceImageSrc: characterAnimationSourceLayer.src,
sourceImageSrc: resolveCharacterAnimationSourceImageSrc(
characterAnimationSourceLayer,
),
sourceWidth: characterAnimationSourceLayer.originalWidth,
sourceHeight: characterAnimationSourceLayer.originalHeight,
promptText,
@@ -4298,8 +4537,8 @@ export function ImageCanvasEditorView() {
size="xs"
className="image-canvas-editor__size-badge"
>
{Math.round(layer.width)} x {Math.round(layer.height)}{' '}
px
{Math.round(layer.originalWidth)} x{' '}
{Math.round(layer.originalHeight)} px
</PlatformPillBadge>
) : null}
{layerGeneratingLabel ? (
@@ -5846,24 +6085,42 @@ export function ImageCanvasEditorView() {
<dl className="image-canvas-editor__metadata-grid">
<dt></dt>
<dd>{formatLayerImageType(metadataLayer)}</dd>
<dt>Prompt</dt>
<dd className="image-canvas-editor__metadata-prompt">
{metadataLayer.prompt ? (
<dt></dt>
<dd className="image-canvas-editor__metadata-inputs">
{metadataLayer.generationInputs?.fields.length ||
metadataLayer.generationInputs?.references.length ? (
<>
<span>{metadataLayer.prompt}</span>
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
className="image-canvas-editor__metadata-copy"
onClick={() => {
void navigator.clipboard?.writeText(
metadataLayer.prompt ?? '',
);
}}
>
Prompt
</PlatformActionButton>
{metadataLayer.generationInputs.fields.map((field) => (
<div
key={`${field.title}-${field.value}`}
className="image-canvas-editor__metadata-input-field"
>
<span className="image-canvas-editor__metadata-input-title">
{field.title}
</span>
<span>{field.value}</span>
</div>
))}
{metadataLayer.generationInputs.references.length ? (
<div className="image-canvas-editor__metadata-reference-list">
{metadataLayer.generationInputs.references.map(
(reference) => (
<div
key={`${reference.title}-${reference.label}-${reference.src}`}
className="image-canvas-editor__metadata-reference-card"
>
<img src={reference.src} alt="" aria-hidden="true" />
<span className="image-canvas-editor__metadata-reference-copy">
<span className="image-canvas-editor__metadata-input-title">
{reference.title}
</span>
<span>{reference.label}</span>
</span>
</div>
),
)}
</div>
) : null}
</>
) : (
'-'
@@ -5871,11 +6128,6 @@ export function ImageCanvasEditorView() {
</dd>
<dt>Model</dt>
<dd>{metadataLayer.model ?? '-'}</dd>
<dt>Size</dt>
<dd>
{Math.round(metadataLayer.width)} x{' '}
{Math.round(metadataLayer.height)} px
</dd>
<dt>Resolution</dt>
<dd>
{metadataLayer.originalWidth} x {metadataLayer.originalHeight} px