Files
moteva/frontend/src/ui/pages/CanvasWorkspace/canvas/imageGeneratorSizing.ts
T
root cbf4e21e2b feat(canvas): per-generator target size, placement and draft preservation
Persist each image-generator node's target resolution in metadata and
surface it in the selection frame and node preview. Place new generators
without overlapping existing ones, keep local draft generators when a
compressed snapshot omits them, and add a dedicated selection-frame
variant plus scale-aware preview styling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:46:16 +08:00

101 lines
3.1 KiB
TypeScript

import type { ComposerImageSettings } from "@/ui/components/CanvasAgentComposer";
import type { CanvasNode } from "@/domain/design";
type StageSize = {
width: number;
height: number;
};
export type ImageGeneratorTargetSize = {
width: number;
height: number;
};
const targetMetadataKey = "imageGeneratorTarget";
export function imageGeneratorPlaceholderSize(settings: ComposerImageSettings, viewportScale: number, stage: StageSize) {
const width = Math.max(1, settings.width);
const height = Math.max(1, settings.height);
const ratio = width / height;
const stageShortSide = Math.max(1, Math.min(stage.width, stage.height));
const screenMaxSide = clamp(stageShortSide * 0.42, 240, 360);
const worldMaxSide = screenMaxSide / Math.max(viewportScale, 0.12);
if (ratio >= 1) {
return {
width: Math.round(worldMaxSide),
height: Math.round(worldMaxSide / ratio)
};
}
return {
width: Math.round(worldMaxSide * ratio),
height: Math.round(worldMaxSide)
};
}
export function imageGeneratorTargetSizeFromSettings(settings: ComposerImageSettings): ImageGeneratorTargetSize {
return {
width: positiveInteger(settings.width, 1024),
height: positiveInteger(settings.height, 1024)
};
}
export function imageGeneratorTargetSizeFromNode(node: CanvasNode): ImageGeneratorTargetSize {
const metadata = parseImageGeneratorTargetMetadata(node.imageAdjustments);
if (metadata) {
return {
width: positiveInteger(metadata.width, 1024),
height: positiveInteger(metadata.height, 1024)
};
}
const ratio = Math.max(node.width, 1) / Math.max(node.height, 1);
if (ratio >= 1) {
return {
width: positiveInteger(1024 * ratio, 1024),
height: 1024
};
}
return {
width: 1024,
height: positiveInteger(1024 / ratio, 1024)
};
}
export function serializeImageGeneratorTargetSize(settings: ComposerImageSettings, previous?: string): string {
const metadata = parseJsonObject(previous);
metadata[targetMetadataKey] = imageGeneratorTargetSizeFromSettings(settings);
return JSON.stringify(metadata);
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function parseImageGeneratorTargetMetadata(value?: string | null): Partial<ImageGeneratorTargetSize> | null {
const metadata = parseJsonObject(value)[targetMetadataKey];
if (!metadata || typeof metadata !== "object") return null;
const source = metadata as Partial<Record<keyof ImageGeneratorTargetSize, unknown>>;
return {
width: Number(source.width),
height: Number(source.height)
};
}
function parseJsonObject(value?: string | null): Record<string, unknown> {
if (!value) return {};
try {
const parsed = JSON.parse(value) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return { ...(parsed as Record<string, unknown>) };
} catch {
return {};
}
return {};
}
function positiveInteger(value: unknown, fallback: number) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) return Math.max(1, Math.round(fallback));
return Math.max(1, Math.round(numeric));
}