From 5e11f86e5740d8e090bc01ed5082fedd5de880cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=A0=E7=9A=84=E5=90=8D=E5=AD=97?= Date: Mon, 20 Jul 2026 16:47:23 +0800 Subject: [PATCH 1/2] feat: add common ratios to image outpaint --- static/canvas.html | 15 +++++++++++ static/js/canvas.js | 38 ++++++++++++++++++++++++++++ static/js/i18n/canvas.js | 5 +++- static/js/outpaint-ratios.js | 46 ++++++++++++++++++++++++++++++++++ static/js/smart-canvas.js | 37 +++++++++++++++++++++++++++ static/smart-canvas.html | 15 +++++++++++ tests/test_outpaint_ratios.py | 47 +++++++++++++++++++++++++++++++++++ 7 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 static/js/outpaint-ratios.js create mode 100644 tests/test_outpaint_ratios.py diff --git a/static/canvas.html b/static/canvas.html index 680cd5cd9..e74fc4908 100644 --- a/static/canvas.html +++ b/static/canvas.html @@ -251,6 +251,20 @@ +
+ 扩图比例 + + + + + + + + + + + +
@@ -351,6 +365,7 @@
+ diff --git a/static/js/canvas.js b/static/js/canvas.js index 12e7e1b3d..bf8a76013 100644 --- a/static/js/canvas.js +++ b/static/js/canvas.js @@ -446,6 +446,7 @@ let cropState = null; let cropDrag = null; let cropAspectPreset = 'free'; let cropAspectRatio = null; +let outpaintAspectPreset = 'free'; let imageEditMode = 'crop'; let imageEditModeTouched = false; let imageResizeScale = 0.5; @@ -2350,6 +2351,12 @@ document.querySelectorAll('[data-crop-ratio]').forEach(btn => { setCropAspectPreset(btn.dataset.cropRatio || 'free'); }); }); +document.querySelectorAll('[data-outpaint-ratio]').forEach(btn => { + btn.addEventListener('click', event => { + event.stopPropagation(); + setOutpaintAspectPreset(btn.dataset.outpaintRatio || 'free'); + }); +}); document.getElementById('outpaintFrame')?.addEventListener('mousedown', event => { if(event.target.closest('[data-outpaint-handle]')) return; document.getElementById('cropCanvas')?.classList.add('dragging-image'); @@ -4514,6 +4521,7 @@ function setImageEditMode(mode, userTouched=false){ _syncGridCustomCursor(); document.querySelectorAll('[data-image-edit-mode]').forEach(btn => btn.classList.toggle('active', btn.dataset.imageEditMode === imageEditMode)); document.getElementById('imageCropTools')?.classList.toggle('active', imageEditMode === 'crop'); + document.getElementById('imageOutpaintTools')?.classList.toggle('active', imageEditMode === 'outpaint'); document.getElementById('imageMaskTools').classList.toggle('active', imageEditMode === 'mask'); document.getElementById('imageBrushTools').classList.toggle('active', imageEditMode === 'brush'); document.getElementById('imageResizeTools')?.classList.toggle('active', imageEditMode === 'resize'); @@ -5247,6 +5255,28 @@ function resetOutpaintBox(){ cropState.h = h; renderCropBox(); } +function syncOutpaintRatioButtons(){ + document.querySelectorAll('[data-outpaint-ratio]').forEach(btn => { + btn.classList.toggle('active', btn.dataset.outpaintRatio === outpaintAspectPreset); + }); +} +function setOutpaintAspectPreset(preset='free'){ + outpaintAspectPreset = preset || 'free'; + syncOutpaintRatioButtons(); + if(!cropState || imageEditMode !== 'outpaint' || outpaintAspectPreset === 'free') return; + if(outpaintAspectPreset === 'source') return resetOutpaintBox(); + const img = document.getElementById('cropImage'); + const fit = window.OutpaintRatios?.fitContaining(img?.naturalWidth, img?.naturalHeight, outpaintAspectPreset); + if(!img || !fit) return; + const scaleX = Math.max(1, Number(img.naturalWidth || 1)) / Math.max(1, Number(img.clientWidth || 1)); + const scaleY = Math.max(1, Number(img.naturalHeight || 1)) / Math.max(1, Number(img.clientHeight || 1)); + cropState.w = fit.width / scaleX; + cropState.h = fit.height / scaleY; + cropState.x = fit.offsetX / scaleX; + cropState.y = fit.offsetY / scaleY; + clampOutpaint(); + renderCropBox(); +} function cropRatioFromPreset(preset){ if(!preset || preset === 'free') return null; if(preset === 'source'){ @@ -5324,7 +5354,9 @@ function openImageEditor(nodeId, initialMode='crop'){ imageEditModeTouched = false; cropAspectPreset = 'free'; cropAspectRatio = null; + outpaintAspectPreset = 'free'; syncCropRatioButtons(); + syncOutpaintRatioButtons(); editTextItems = []; editTextSelectedId = ''; editTextDrag = null; @@ -5404,7 +5436,9 @@ function closeImageEditor(){ imageEditModeTouched = false; cropAspectPreset = 'free'; cropAspectRatio = null; + outpaintAspectPreset = 'free'; syncCropRatioButtons(); + syncOutpaintRatioButtons(); document.getElementById('imageEditStage')?.classList.remove('overflowing', 'overflow-x', 'overflow-y'); const cropCanvasEl = document.getElementById('cropCanvas'); cropCanvasEl.classList.remove('grid-custom-h', 'grid-custom-v', 'outpaint-mode', 'outpaint-warning', 'dragging-image', 'text-mode', 'resize-mode'); @@ -5430,6 +5464,10 @@ function beginCropDrag(event, mode){ event.preventDefault(); event.stopPropagation(); if(imageEditMode === 'outpaint' && mode === 'move') return; + if(imageEditMode === 'outpaint' && String(mode || '').startsWith('outpaint-') && outpaintAspectPreset !== 'free'){ + outpaintAspectPreset = 'free'; + syncOutpaintRatioButtons(); + } cropDrag = {mode, sx:event.clientX, sy:event.clientY, start:{...cropState}}; } function resizeOutpaintFromDrag(dx, dy){ diff --git a/static/js/i18n/canvas.js b/static/js/i18n/canvas.js index 03f04617a..65d7fe6c8 100644 --- a/static/js/i18n/canvas.js +++ b/static/js/i18n/canvas.js @@ -133,6 +133,9 @@ "canvas.editImageSub": { zh: "选择裁剪、遮罩或画笔模式", en: "Select crop, mask, brush, or grid mode" }, "canvas.modeCrop": { zh: "裁剪", en: "Crop" }, "canvas.modeOutpaint": { zh: "扩图", en: "Expand" }, + "canvas.outpaintRatio": { zh: "扩图比例", en: "Expand ratio" }, + "canvas.ratioFree": { zh: "自由", en: "Free" }, + "canvas.ratioSource": { zh: "原图", en: "Source" }, "canvas.modeMask": { zh: "遮罩", en: "Mask" }, "canvas.modeBrush": { zh: "画笔", en: "Brush" }, "canvas.modeGrid": { zh: "宫格切分", en: "Grid Split" }, @@ -150,7 +153,7 @@ "canvas.applyBrush": { zh: "应用画笔", en: "Apply Brush" }, "canvas.applyOutpaint": { zh: "应用扩图", en: "Apply Expand" }, "canvas.outpaintImage": { zh: "扩图", en: "Expand Image" }, - "canvas.outpaintHint": { zh: "拖动红框调整画布范围,最大不超过 4K 尺寸,确认后空白区域填充白色", en: "Drag the red box to extend the canvas. It is capped at 4K size, and empty areas are filled white." }, + "canvas.outpaintHint": { zh: "选择常用比例或拖动边框扩展画布;适用于 Banana 2、Banana Pro 与 Image 2,空白区域会交给模型补全", en: "Choose a common ratio or drag the frame to expand; works with Banana 2, Banana Pro, and Image 2, which will fill the blank area." }, "canvas.applyGrid": { zh: "切分到 Output", en: "Split to Output" }, "canvas.gridHint": { zh: "按切分线分割图片,间隔会从线两侧扣除", en: "Split image along cut lines. Gap removes pixels on each side of every line." }, "canvas.gridCustom": { zh: "自定义", en: "Custom" }, diff --git a/static/js/outpaint-ratios.js b/static/js/outpaint-ratios.js new file mode 100644 index 000000000..f0f5b3cc3 --- /dev/null +++ b/static/js/outpaint-ratios.js @@ -0,0 +1,46 @@ +(function(root, factory){ + const api = factory(); + if(typeof module !== 'undefined' && module.exports) module.exports = api; + if(root) root.OutpaintRatios = api; +})(typeof globalThis !== 'undefined' ? globalThis : this, function(){ + const PRESETS = ['free', 'source', '1:1', '4:3', '3:4', '16:9', '9:16', '3:2', '2:3', '21:9', '9:21']; + + function gcd(a, b){ + a = Math.abs(Math.round(Number(a) || 0)); + b = Math.abs(Math.round(Number(b) || 0)); + while(b){ const next = a % b; a = b; b = next; } + return a || 1; + } + + function parsePreset(preset){ + const value = String(preset || '').trim(); + if(value === 'free' || value === 'source') return null; + const match = value.match(/^(\d+)\s*:\s*(\d+)$/); + if(!match) return null; + const width = Number(match[1]); + const height = Number(match[2]); + if(width <= 0 || height <= 0) return null; + const divisor = gcd(width, height); + return {width:width / divisor, height:height / divisor, ratio:width / height}; + } + + function fitContaining(sourceWidth, sourceHeight, preset){ + const width = Math.max(1, Math.round(Number(sourceWidth) || 1)); + const height = Math.max(1, Math.round(Number(sourceHeight) || 1)); + const parsed = parsePreset(preset); + if(!parsed) return {width, height, offsetX:0, offsetY:0, preset:preset || 'free'}; + const rawUnits = Math.max(1, Math.ceil(Math.max(width / parsed.width, height / parsed.height))); + const units = Math.ceil(rawUnits / 16) * 16; + const targetWidth = units * parsed.width; + const targetHeight = units * parsed.height; + return { + width:targetWidth, + height:targetHeight, + offsetX:Math.floor((targetWidth - width) / 2), + offsetY:Math.floor((targetHeight - height) / 2), + preset:String(preset), + }; + } + + return {PRESETS, parsePreset, fitContaining}; +}); diff --git a/static/js/smart-canvas.js b/static/js/smart-canvas.js index 8d7f362fb..eb1d36e4b 100644 --- a/static/js/smart-canvas.js +++ b/static/js/smart-canvas.js @@ -227,6 +227,7 @@ let cropState = null; let cropDrag = null; let cropAspectPreset = 'free'; let cropAspectRatio = null; +let outpaintAspectPreset = 'free'; let imageEditMode = 'crop'; let imageEditModeTouched = false; let imageResizeScale = 0.5; @@ -9051,6 +9052,7 @@ function setImageEditMode(mode, userTouched=false){ document.querySelectorAll('[data-image-edit-mode]').forEach(btn => btn.classList.toggle('active', btn.dataset.imageEditMode === imageEditMode)); document.getElementById('imagePreviewTools').classList.toggle('active', isPreview && !isVideoPreview); document.getElementById('imageCropTools')?.classList.toggle('active', imageEditMode === 'crop'); + document.getElementById('imageOutpaintTools')?.classList.toggle('active', imageEditMode === 'outpaint'); document.getElementById('imageMaskTools').classList.toggle('active', imageEditMode === 'mask'); document.getElementById('imageBrushTools').classList.toggle('active', imageEditMode === 'brush'); document.getElementById('imageResizeTools')?.classList.toggle('active', imageEditMode === 'resize'); @@ -10693,6 +10695,29 @@ function resetOutpaintBox(){ clampOutpaint(); renderCropBox(); } +function syncOutpaintRatioButtons(){ + document.querySelectorAll('[data-outpaint-ratio]').forEach(btn => { + btn.classList.toggle('active', btn.dataset.outpaintRatio === outpaintAspectPreset); + }); +} +function setOutpaintAspectPreset(preset='free'){ + outpaintAspectPreset = preset || 'free'; + syncOutpaintRatioButtons(); + if(!cropState || imageEditMode !== 'outpaint' || outpaintAspectPreset === 'free') return; + if(outpaintAspectPreset === 'source') return resetOutpaintBox(); + const img = document.getElementById('cropImage'); + const fit = window.OutpaintRatios?.fitContaining(img?.naturalWidth, img?.naturalHeight, outpaintAspectPreset); + if(!img || !fit) return; + const display = cropImageDisplaySize(); + const scaleX = Math.max(1, Number(img.naturalWidth || 1)) / Math.max(1, Number(display.w || img.clientWidth || 1)); + const scaleY = Math.max(1, Number(img.naturalHeight || 1)) / Math.max(1, Number(display.h || img.clientHeight || 1)); + cropState.w = fit.width / scaleX; + cropState.h = fit.height / scaleY; + cropState.x = fit.offsetX / scaleX; + cropState.y = fit.offsetY / scaleY; + clampOutpaint(); + renderCropBox(); +} function cropRatioFromPreset(preset){ if(!preset || preset === 'free') return null; if(preset === 'source'){ @@ -10832,6 +10857,7 @@ function openImageEditor(nodeId, imageIndex=0){ gridOperationMode = 'split'; gridJoinLayout = null; gridJoinDrag = null; gridJoinImageCache = new Map(); gridJoinUserMoved = false; gridJoinGroupId = ''; imageEditZoom = 1.0; imageEditBaseW = 0; imageEditBaseH = 0; imageResizeScale = 0.5; imageEditModeTouched = false; cropAspectPreset = 'free'; cropAspectRatio = null; syncCropRatioButtons(); + outpaintAspectPreset = 'free'; syncOutpaintRatioButtons(); editTextItems = []; editTextSelectedId = ''; editTextDrag = null; editTextDirty = false; const toggle = document.getElementById('gridCustomToggle'); if(toggle){ toggle.classList.add('secondary'); toggle.classList.remove('primary'); } @@ -10933,6 +10959,7 @@ function closeImageEditor(){ previewNavState = {nodeId:'', index:0, count:0}; imageEditZoom = 1.0; imageEditBaseW = 0; imageEditBaseH = 0; imageResizeScale = 0.5; imageEditModeTouched = false; cropAspectPreset = 'free'; cropAspectRatio = null; syncCropRatioButtons(); + outpaintAspectPreset = 'free'; syncOutpaintRatioButtons(); disposePanoramaPreview(); previewPanDrag = null; previewCompareDrag = false; imageEditPanDrag = null; resetPreviewTransform(); document.getElementById('imageEditStage')?.classList.remove('overflow-x', 'overflow-y', 'preview-mode'); @@ -10958,6 +10985,10 @@ function beginCropDrag(event, mode){ if(!cropState) return; event.preventDefault(); event.stopPropagation(); if(imageEditMode === 'outpaint' && mode === 'move') return; + if(imageEditMode === 'outpaint' && String(mode || '').startsWith('outpaint-') && outpaintAspectPreset !== 'free'){ + outpaintAspectPreset = 'free'; + syncOutpaintRatioButtons(); + } cropDrag = {mode, sx:event.clientX, sy:event.clientY, start:{...cropState}}; } function resizeOutpaintFromDrag(dx, dy){ @@ -16769,6 +16800,12 @@ document.querySelectorAll('[data-crop-ratio]').forEach(btn => { setCropAspectPreset(btn.dataset.cropRatio || 'free'); }); }); +document.querySelectorAll('[data-outpaint-ratio]').forEach(btn => { + btn.addEventListener('click', event => { + event.stopPropagation(); + setOutpaintAspectPreset(btn.dataset.outpaintRatio || 'free'); + }); +}); document.getElementById('outpaintFrame').addEventListener('mousedown', event => { if(event.target.closest('[data-outpaint-handle]')) return; beginCropDrag(event, 'image'); diff --git a/static/smart-canvas.html b/static/smart-canvas.html index 5b567a936..3fd6d6774 100644 --- a/static/smart-canvas.html +++ b/static/smart-canvas.html @@ -272,6 +272,20 @@ +
+ 扩图比例 + + + + + + + + + + + +
+ diff --git a/tests/test_outpaint_ratios.py b/tests/test_outpaint_ratios.py new file mode 100644 index 000000000..95bf7dd22 --- /dev/null +++ b/tests/test_outpaint_ratios.py @@ -0,0 +1,47 @@ +import json +import subprocess +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +RATIOS = ("1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "21:9", "9:21") + + +class OutpaintRatioTests(unittest.TestCase): + def run_ratio_module(self, width, height, preset): + module = (ROOT / "static" / "js" / "outpaint-ratios.js").as_posix() + script = ( + f"const r=require({json.dumps(module)});" + f"process.stdout.write(JSON.stringify(r.fitContaining({width},{height},{json.dumps(preset)})));" + ) + result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + return json.loads(result.stdout) + + def test_common_ratios_contain_source_and_are_exact(self): + for preset in RATIOS: + with self.subTest(preset=preset): + result = self.run_ratio_module(1024, 768, preset) + left, right = map(int, preset.split(":")) + self.assertGreaterEqual(result["width"], 1024) + self.assertGreaterEqual(result["height"], 768) + self.assertEqual(result["width"] * right, result["height"] * left) + self.assertEqual(result["width"] % 16, 0) + self.assertEqual(result["height"] % 16, 0) + + def test_source_ratio_keeps_original_size(self): + self.assertEqual( + self.run_ratio_module(1234, 987, "source"), + {"width": 1234, "height": 987, "offsetX": 0, "offsetY": 0, "preset": "source"}, + ) + + def test_both_canvas_editors_expose_same_presets(self): + for filename in ("canvas.html", "smart-canvas.html"): + html = (ROOT / "static" / filename).read_text(encoding="utf-8") + self.assertIn('/static/js/outpaint-ratios.js', html) + for preset in ("free", "source", *RATIOS): + self.assertIn(f'data-outpaint-ratio="{preset}"', html) + + +if __name__ == "__main__": + unittest.main() From b94e6ace33f001df57e4f72626b3eec621929375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=A0=E7=9A=84=E5=90=8D=E5=AD=97?= Date: Mon, 20 Jul 2026 17:02:01 +0800 Subject: [PATCH 2/2] fix: prevent stale canvas UI assets --- static/js/canvas-list.js | 5 +++-- static/js/i18n.js | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/static/js/canvas-list.js b/static/js/canvas-list.js index bfde2fc9f..9a0d49529 100644 --- a/static/js/canvas-list.js +++ b/static/js/canvas-list.js @@ -465,10 +465,11 @@ function attachCardDrag(card, c){ function openCanvas(c){ const enc = encodeURIComponent(c.id); const project = encodeURIComponent(c.project || currentProjectId || 'default'); + const version = Date.now(); rememberProjectId(c.project || currentProjectId || 'default'); window.location.href = (c.kind === 'smart') - ? `/static/smart-canvas.html?id=${enc}&project=${project}&v=2026.07.03.4` - : `/static/canvas.html?id=${enc}&project=${project}&v=2026.07.03.4`; + ? `/static/smart-canvas.html?id=${enc}&project=${project}&v=${version}` + : `/static/canvas.html?id=${enc}&project=${project}&v=${version}`; } /* ===== Card create flow ===== */ diff --git a/static/js/i18n.js b/static/js/i18n.js index e51152139..103f3ba33 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -1,5 +1,7 @@ (function(){ - const VERSION = '2026.07.04.rec-ui.1'; + // Translation bundles change independently from this loader. A fresh token + // prevents an older cached dictionary from leaving new UI keys untranslated. + const VERSION = Date.now(); const scripts = [ '/static/js/i18n-core.js', '/static/js/i18n/common.js',