diff --git a/packages/frontend/src/integrations/imageedit/process.ts b/packages/frontend/src/integrations/imageedit/process.ts index 45a7a0e..5e6a25a 100644 --- a/packages/frontend/src/integrations/imageedit/process.ts +++ b/packages/frontend/src/integrations/imageedit/process.ts @@ -84,12 +84,18 @@ export function extensionForFormat(format: ExportFormat) { return 'png'; } +export function clampPixelSize(value: number) { + return Math.min(8192, Math.max(1, Math.round(Number(value)) || 1)); +} + function outputSize( srcWidth: number, srcHeight: number, scale: number, maxEdge: number | null, padAspect: number | null, + targetWidth?: number | null, + targetHeight?: number | null, ) { let width = Math.max(1, srcWidth); let height = Math.max(1, srcHeight); @@ -103,22 +109,29 @@ function outputSize( } } - const factor = Math.min(1, Math.max(0.01, scale)); - width *= factor; - height *= factor; + if ( + targetWidth != null && + targetHeight != null && + targetWidth > 0 && + targetHeight > 0 + ) { + width = targetWidth; + height = targetHeight; + } else { + const factor = Math.min(8, Math.max(0.01, scale)); + width *= factor; + height *= factor; + } if (maxEdge && maxEdge > 0) { - const long = Math.max(width, height); - if (long > maxEdge) { - const shrink = maxEdge / long; - width *= shrink; - height *= shrink; - } + const cap = Math.min(1, maxEdge / width, maxEdge / height); + width *= cap; + height *= cap; } return { - width: Math.max(1, Math.round(width)), - height: Math.max(1, Math.round(height)), + width: clampPixelSize(width), + height: clampPixelSize(height), }; } @@ -128,6 +141,8 @@ export function previewOutputSize(options: { maxEdge: number | null; padAspect: number | null; fit: RatioFit; + targetWidth?: number | null; + targetHeight?: number | null; }) { const padAspect = options.fit === 'pad' ? options.padAspect : null; return outputSize( @@ -136,6 +151,8 @@ export function previewOutputSize(options: { options.scale, options.maxEdge, padAspect, + options.targetWidth, + options.targetHeight, ); } @@ -195,6 +212,9 @@ export async function exportEditedImage(options: { fit: RatioFit; padAspect: number | null; padColor: string; + targetWidth?: number | null; + targetHeight?: number | null; + lockAspect?: boolean; }): Promise<{ blob: Blob; width: number; height: number }> { const crop = clampCrop( options.crop, @@ -211,6 +231,8 @@ export async function exportEditedImage(options: { options.scale, options.maxEdge, padAspect, + options.targetWidth, + options.targetHeight, ); const canvas = document.createElement('canvas'); @@ -230,7 +252,8 @@ export async function exportEditedImage(options: { ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; - if (padAspect) { + const letterbox = Boolean(padAspect) && options.lockAspect !== false; + if (letterbox) { const fitted = containSize(crop.width, crop.height, size.width, size.height); const dx = Math.round((size.width - fitted.width) / 2); const dy = Math.round((size.height - fitted.height) / 2); diff --git a/packages/frontend/src/views/ToolsImageEditView.vue b/packages/frontend/src/views/ToolsImageEditView.vue index 5a2fab8..01ff86d 100644 --- a/packages/frontend/src/views/ToolsImageEditView.vue +++ b/packages/frontend/src/views/ToolsImageEditView.vue @@ -16,6 +16,7 @@ import { loadImageFromUrl, maxCropForAspect, previewOutputSize, + clampPixelSize, type ExportFormat, type PixelRect, type RatioFit, @@ -55,6 +56,9 @@ const ratioId = ref<(typeof RATIO_PRESETS)[number]['id']>('free'); const fit = ref('crop'); const scalePercent = ref(100); const maxEdgeId = ref(''); +const lockAspect = ref(true); +const outWidth = ref(1); +const outHeight = ref(1); const format = ref('image/jpeg'); const quality = ref(80); const padColor = ref('#ffffff'); @@ -85,6 +89,21 @@ const maxEdge = computed(() => { return n > 0 ? n : null; }); +const naturalSize = computed(() => + previewOutputSize({ + crop: crop.value, + scale: 1, + maxEdge: null, + padAspect: cropAspect.value, + fit: fit.value, + }), +); + +const sourceAspect = computed(() => { + const { width, height } = naturalSize.value; + return height ? width / height : 1; +}); + const outputHint = computed(() => previewOutputSize({ crop: crop.value, @@ -92,9 +111,68 @@ const outputHint = computed(() => maxEdge: maxEdge.value, padAspect: cropAspect.value, fit: fit.value, + targetWidth: outWidth.value, + targetHeight: outHeight.value, }), ); +function exportPayload() { + return { + crop: crop.value, + scale: scalePercent.value / 100, + maxEdge: maxEdge.value, + format: format.value, + quality: quality.value / 100, + fit: fit.value, + padAspect: cropAspect.value, + padColor: padColor.value, + targetWidth: outWidth.value, + targetHeight: outHeight.value, + lockAspect: lockAspect.value, + }; +} + +function syncScaleFromOutput() { + const base = naturalSize.value.width || 1; + const percent = (outWidth.value / base) * 100; + scalePercent.value = Math.min(200, Math.max(1, Math.round(percent))); +} + +function applyScaleToOutput() { + const size = previewOutputSize({ + crop: crop.value, + scale: scalePercent.value / 100, + maxEdge: maxEdge.value, + padAspect: cropAspect.value, + fit: fit.value, + }); + outWidth.value = size.width; + outHeight.value = size.height; +} + +function setOutWidth(raw: number) { + const width = clampPixelSize(raw); + outWidth.value = width; + if (lockAspect.value) { + outHeight.value = clampPixelSize(width / sourceAspect.value); + } + syncScaleFromOutput(); +} + +function setOutHeight(raw: number) { + const height = clampPixelSize(raw); + outHeight.value = height; + if (lockAspect.value) { + outWidth.value = clampPixelSize(height * sourceAspect.value); + } + syncScaleFromOutput(); +} + +function onLockAspect(next: boolean) { + lockAspect.value = next; + if (next) setOutWidth(outWidth.value); +} + const qualityDisabled = computed( () => format.value === 'image/png' || format.value === 'image/x-icon', ); @@ -156,7 +234,9 @@ async function loadFile(file: File) { fit.value = 'crop'; scalePercent.value = 100; maxEdgeId.value = ''; + lockAspect.value = true; crop.value = fullImageCrop(imageWidth.value, imageHeight.value); + applyScaleToOutput(); notice.value = `${imageWidth.value}×${imageHeight.value} · ${formatBytes(file.size)}${extra}`; } catch (reason) { if (url) revoke(url); @@ -256,6 +336,7 @@ function resetCrop() { } else { crop.value = fullImageCrop(imageWidth.value, imageHeight.value); } + applyScaleToOutput(); } watch(ratioId, () => { @@ -288,6 +369,16 @@ watch(fit, (next) => { } }); +watch(maxEdgeId, () => { + if (!hasImage.value) return; + applyScaleToOutput(); +}); + +watch([ratioId, fit], () => { + if (!hasImage.value) return; + if (lockAspect.value) applyScaleToOutput(); +}); + let estimateTimer = 0; let estimateGen = 0; @@ -302,6 +393,9 @@ watch( ratioId, padColor, loadedImage, + outWidth, + outHeight, + lockAspect, ], () => { window.clearTimeout(estimateTimer); @@ -324,14 +418,7 @@ async function refreshEstimate() { try { const result = await exportEditedImage({ image, - crop: crop.value, - scale: scalePercent.value / 100, - maxEdge: maxEdge.value, - format: format.value, - quality: quality.value / 100, - fit: fit.value, - padAspect: cropAspect.value, - padColor: padColor.value, + ...exportPayload(), }); if (gen !== estimateGen) return; estimateBytes.value = result.blob.size; @@ -351,14 +438,7 @@ async function saveImage() { try { const result = await exportEditedImage({ image, - crop: crop.value, - scale: scalePercent.value / 100, - maxEdge: maxEdge.value, - format: format.value, - quality: quality.value / 100, - fit: fit.value, - padAspect: cropAspect.value, - padColor: padColor.value, + ...exportPayload(), }); const ext = extensionForFormat(format.value); downloadBlob(result.blob, `${baseName()}-edited.${ext}`); @@ -498,18 +578,78 @@ onBeforeUnmount(() => {
-

축소

+

크기

+ +
+ + +