Skip to content

Commit 3401885

Browse files
committed
feat(frame-studio): full-resolution export for 48MP+ photos
Add tiled canvas rendering to bypass iOS Safari's ~16M pixel canvas limit. Large images are rendered in horizontal strips, pixel data is combined into a full-size buffer, and encoded using jpeg-js (JPEG) or upng-js (PNG). Small images use the direct canvas.toBlob() fast path. Dependencies: jpeg-js (~20KB), upng-js (~12KB)
1 parent 2501081 commit 3401885

5 files changed

Lines changed: 141 additions & 170 deletions

File tree

package-lock.json

Lines changed: 24 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@
2020
"@vercel/speed-insights": "^2.0.0",
2121
"exifreader": "^4.37.0",
2222
"framer-motion": "^12.38.0",
23+
"jpeg-js": "^0.4.4",
2324
"next": "^16.2.2",
2425
"next-intl": "^4.9.0",
2526
"react": "^19.0.0",
2627
"react-advanced-cropper": "^0.20.1",
2728
"react-dom": "^19.0.0",
2829
"resend": "^6.10.0",
29-
"sonner": "^2.0.7"
30+
"sonner": "^2.0.7",
31+
"upng-js": "^2.1.0"
3032
},
3133
"devDependencies": {
3234
"@eslint/js": "^9.21.0",

src/app/[locale]/frame-studio/_components/ExportDialog.tsx

Lines changed: 60 additions & 168 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
'use client'
22

3-
import { useState, useCallback, useMemo } from 'react'
3+
import { useState, useCallback } from 'react'
44
import { useTranslations } from 'next-intl'
55
import { computeExportDimensions, drawSolidBorder, drawGradientBorder, drawTextureBorder, drawInnerMat, drawShadow } from '@/lib/math/frame'
66
import { transferExif } from '@/lib/utils/exif'
7+
import { renderTiledExport, TILED_EXPORT_THRESHOLD } from '@/lib/utils/image-export'
78
import {
89
drawRuleOfThirds, drawGoldenRatio, drawGoldenSpiral, drawGoldenDiagonal,
910
drawDiagonalLines, drawCenterCross, drawSquareGrid, drawTriangles,
@@ -39,65 +40,36 @@ const GRID_DRAW_MAP: Record<GridType, DrawFn> = {
3940
'triangles': (ctx, w, h) => drawTriangles(ctx, w, h),
4041
}
4142

42-
function getLinePositions(
43-
gridType: GridType, w: number, h: number, opts: GridOptions,
44-
): { v: number[]; h: number[] } | null {
43+
function getLinePositions(gridType: GridType, w: number, h: number, opts: GridOptions) {
4544
switch (gridType) {
46-
case 'rule-of-thirds':
47-
return { v: [0, w / 3, 2 * w / 3], h: [0, h / 3, 2 * h / 3] }
48-
case 'golden-ratio': {
49-
const pw = w / GOLDEN_RATIO, ph = h / GOLDEN_RATIO
50-
return { v: [0, w - pw, pw], h: [0, h - ph, ph] }
45+
case 'rule-of-thirds': return { v: [0, w / 3, 2 * w / 3], h: [0, h / 3, 2 * h / 3] }
46+
case 'golden-ratio': { const pw = w / GOLDEN_RATIO, ph = h / GOLDEN_RATIO; return { v: [0, w - pw, pw], h: [0, h - ph, ph] } }
47+
case 'center-cross': return { v: [0, w / 2], h: [0, h / 2] }
48+
case 'square-grid': return {
49+
v: Array.from({ length: opts.gridDensity }, (_, i) => (i * w) / opts.gridDensity),
50+
h: Array.from({ length: opts.gridDensity }, (_, i) => (i * h) / opts.gridDensity),
5151
}
52-
case 'center-cross':
53-
return { v: [0, w / 2], h: [0, h / 2] }
54-
case 'square-grid':
55-
return {
56-
v: Array.from({ length: opts.gridDensity }, (_, i) => (i * w) / opts.gridDensity),
57-
h: Array.from({ length: opts.gridDensity }, (_, i) => (i * h) / opts.gridDensity),
58-
}
59-
default:
60-
return null
52+
default: return null
6153
}
6254
}
6355

64-
function drawExportGrid(
56+
function drawGridOnExport(
6557
ctx: CanvasRenderingContext2D, gridType: GridType, drawFn: DrawFn,
66-
originX: number, originY: number, gw: number, gh: number,
67-
ox: number, oy: number, opts: GridOptions,
58+
oX: number, oY: number, gw: number, gh: number, ox: number, oy: number, opts: GridOptions,
6859
) {
69-
if (ox === 0 && oy === 0) {
70-
ctx.save()
71-
ctx.translate(originX, originY)
72-
ctx.beginPath()
73-
drawFn(ctx, gw, gh, opts)
74-
ctx.restore()
75-
return
60+
const positions = ox === 0 && oy === 0 ? null : getLinePositions(gridType, gw, gh, opts)
61+
if (!positions && (ox === 0 && oy === 0)) {
62+
ctx.save(); ctx.translate(oX, oY); ctx.beginPath(); drawFn(ctx, gw, gh, opts); ctx.restore(); return
7663
}
77-
78-
const positions = getLinePositions(gridType, gw, gh, opts)
7964
if (positions) {
8065
ctx.beginPath()
81-
for (const base of positions.v) {
82-
const x = ((base + ox) % gw + gw) % gw
83-
if (x > 0.5) { ctx.moveTo(originX + x, originY); ctx.lineTo(originX + x, originY + gh) }
84-
}
85-
for (const base of positions.h) {
86-
const y = ((base + oy) % gh + gh) % gh
87-
if (y > 0.5) { ctx.moveTo(originX, originY + y); ctx.lineTo(originX + gw, originY + y) }
88-
}
66+
for (const b of positions.v) { const x = ((b + ox) % gw + gw) % gw; if (x > 0.5) { ctx.moveTo(oX + x, oY); ctx.lineTo(oX + x, oY + gh) } }
67+
for (const b of positions.h) { const y = ((b + oy) % gh + gh) % gh; if (y > 0.5) { ctx.moveTo(oX, oY + y); ctx.lineTo(oX + gw, oY + y) } }
8968
ctx.stroke()
9069
} else {
91-
const px = ((ox % gw) + gw) % gw
92-
const py = ((oy % gh) + gh) % gh
93-
for (let dx = -1; dx <= 0; dx++) {
94-
for (let dy = -1; dy <= 0; dy++) {
95-
ctx.save()
96-
ctx.translate(originX + px + dx * gw, originY + py + dy * gh)
97-
ctx.beginPath()
98-
drawFn(ctx, gw, gh, opts)
99-
ctx.restore()
100-
}
70+
const px = ((ox % gw) + gw) % gw, py = ((oy % gh) + gh) % gh
71+
for (let dx = -1; dx <= 0; dx++) for (let dy = -1; dy <= 0; dy++) {
72+
ctx.save(); ctx.translate(oX + px + dx * gw, oY + py + dy * gh); ctx.beginPath(); drawFn(ctx, gw, gh, opts); ctx.restore()
10173
}
10274
}
10375
}
@@ -111,106 +83,53 @@ export function ExportDialog({
11183
const [exporting, setExporting] = useState(false)
11284
const [closing, setClosing] = useState(false)
11385

114-
const MAX_CANVAS_PIXELS = 16_000_000
115-
const downscaleInfo = useMemo(() => {
116-
const sw = crop?.width ?? image.naturalWidth
117-
const sh = crop?.height ?? image.naturalHeight
118-
const bw = frameConfig.borderWidth
119-
const matW = frameConfig.innerMatEnabled ? frameConfig.innerMatWidth : 0
120-
const { width: ew, height: eh } = computeExportDimensions(sw, sh, bw, matW)
121-
if (ew * eh <= MAX_CANVAS_PIXELS) return null
122-
const scale = Math.sqrt(MAX_CANVAS_PIXELS / (ew * eh))
123-
return { from: `${sw}×${sh}`, to: `${Math.floor(sw * scale)}×${Math.floor(sh * scale)}` }
124-
}, [crop, image, frameConfig])
125-
126-
const handleClose = useCallback(() => {
127-
setClosing(true)
128-
setTimeout(onClose, 150)
129-
}, [onClose])
86+
const handleClose = useCallback(() => { setClosing(true); setTimeout(onClose, 150) }, [onClose])
13087

13188
const handleExport = useCallback(async () => {
13289
setExporting(true)
133-
13490
try {
135-
const sx = crop?.x ?? 0
136-
const sy = crop?.y ?? 0
137-
const sw = crop?.width ?? image.naturalWidth
138-
const sh = crop?.height ?? image.naturalHeight
139-
91+
const sx = crop?.x ?? 0, sy = crop?.y ?? 0
92+
const sw = crop?.width ?? image.naturalWidth, sh = crop?.height ?? image.naturalHeight
14093
const bw = frameConfig.borderWidth
14194
const matW = frameConfig.innerMatEnabled ? frameConfig.innerMatWidth : 0
14295
const { width: exportW, height: exportH } = computeExportDimensions(sw, sh, bw, matW)
14396

144-
// iOS Safari limits canvas to ~16M pixels; scale down for large images
145-
const MAX_CANVAS_PIXELS = 16_000_000
146-
let exportScale = 1
147-
if (exportW * exportH > MAX_CANVAS_PIXELS) {
148-
exportScale = Math.sqrt(MAX_CANVAS_PIXELS / (exportW * exportH))
149-
}
150-
151-
const canvas = document.createElement('canvas')
152-
canvas.width = Math.floor(exportW * exportScale)
153-
canvas.height = Math.floor(exportH * exportScale)
154-
const ctx = canvas.getContext('2d')!
155-
if (exportScale < 1) ctx.scale(exportScale, exportScale)
156-
157-
// All drawing below uses original (exportW × exportH) coordinates;
158-
// ctx.scale handles the physical pixel reduction automatically.
159-
160-
if (frameConfig.shadowEnabled && bw > 0) {
161-
drawShadow(ctx, exportW, exportH, bw, frameConfig.cornerRadius, {
162-
color: frameConfig.shadowColor,
163-
blur: frameConfig.shadowBlur,
164-
offsetX: frameConfig.shadowOffsetX,
165-
offsetY: frameConfig.shadowOffsetY,
166-
})
167-
}
168-
169-
if (bw > 0) {
170-
if (frameConfig.fillType === 'solid') {
171-
drawSolidBorder(ctx, exportW, exportH, frameConfig.solidColor, frameConfig.cornerRadius)
172-
} else if (frameConfig.fillType === 'gradient') {
173-
drawGradientBorder(ctx, exportW, exportH, frameConfig.gradientColor1, frameConfig.gradientColor2, frameConfig.gradientDirection, frameConfig.cornerRadius)
174-
} else {
175-
drawTextureBorder(ctx, exportW, exportH, frameConfig.texture, frameConfig.cornerRadius)
97+
const drawScene = (ctx: CanvasRenderingContext2D) => {
98+
if (frameConfig.shadowEnabled && bw > 0) {
99+
drawShadow(ctx, exportW, exportH, bw, frameConfig.cornerRadius, {
100+
color: frameConfig.shadowColor, blur: frameConfig.shadowBlur,
101+
offsetX: frameConfig.shadowOffsetX, offsetY: frameConfig.shadowOffsetY,
102+
})
176103
}
177-
}
178-
179-
if (frameConfig.innerMatEnabled && matW > 0) {
180-
drawInnerMat(ctx, exportW, exportH, bw, frameConfig.cornerRadius, matW, frameConfig.innerMatColor)
181-
}
182-
183-
const imgX = bw + matW
184-
const imgY = bw + matW
185-
ctx.drawImage(image, sx, sy, sw, sh, imgX, imgY, sw, sh)
186-
187-
if (includeGrid && activeGrids.length > 0) {
188-
ctx.save()
189-
ctx.beginPath()
190-
ctx.rect(imgX, imgY, sw, sh)
191-
ctx.clip()
192-
const scaleX = gridDisplaySize && gridDisplaySize.width > 0 ? sw / gridDisplaySize.width : 1
193-
const scaleY = gridDisplaySize && gridDisplaySize.height > 0 ? sh / gridDisplaySize.height : 1
194-
const ox = (gridOffset?.x ?? 0) * scaleX
195-
const oy = (gridOffset?.y ?? 0) * scaleY
196-
ctx.strokeStyle = gridOptions.color
197-
ctx.globalAlpha = gridOptions.opacity
198-
ctx.lineWidth = thicknessToPx(gridOptions.thickness)
199-
for (const gridType of activeGrids) {
200-
const drawFn = GRID_DRAW_MAP[gridType]
201-
if (drawFn) drawExportGrid(ctx, gridType, drawFn, imgX, imgY, sw, sh, ox, oy, gridOptions)
104+
if (bw > 0) {
105+
if (frameConfig.fillType === 'solid') drawSolidBorder(ctx, exportW, exportH, frameConfig.solidColor, frameConfig.cornerRadius)
106+
else if (frameConfig.fillType === 'gradient') drawGradientBorder(ctx, exportW, exportH, frameConfig.gradientColor1, frameConfig.gradientColor2, frameConfig.gradientDirection, frameConfig.cornerRadius)
107+
else drawTextureBorder(ctx, exportW, exportH, frameConfig.texture, frameConfig.cornerRadius)
108+
}
109+
if (frameConfig.innerMatEnabled && matW > 0) drawInnerMat(ctx, exportW, exportH, bw, frameConfig.cornerRadius, matW, frameConfig.innerMatColor)
110+
const imgX = bw + matW, imgY = bw + matW
111+
ctx.drawImage(image, sx, sy, sw, sh, imgX, imgY, sw, sh)
112+
if (includeGrid && activeGrids.length > 0) {
113+
ctx.save(); ctx.beginPath(); ctx.rect(imgX, imgY, sw, sh); ctx.clip()
114+
const scX = gridDisplaySize && gridDisplaySize.width > 0 ? sw / gridDisplaySize.width : 1
115+
const scY = gridDisplaySize && gridDisplaySize.height > 0 ? sh / gridDisplaySize.height : 1
116+
ctx.strokeStyle = gridOptions.color; ctx.globalAlpha = gridOptions.opacity; ctx.lineWidth = thicknessToPx(gridOptions.thickness)
117+
for (const gt of activeGrids) { const fn = GRID_DRAW_MAP[gt]; if (fn) drawGridOnExport(ctx, gt, fn, imgX, imgY, sw, sh, (gridOffset?.x ?? 0) * scX, (gridOffset?.y ?? 0) * scY, gridOptions) }
118+
ctx.restore()
202119
}
203-
ctx.restore()
204120
}
205121

206-
const quality = (originalMimeType === 'image/png') ? undefined : 1
207-
let blob = await new Promise<Blob | null>((resolve) => {
208-
canvas.toBlob(resolve, originalMimeType, quality)
209-
})
210-
if (!blob) {
211-
console.error('Export failed: canvas.toBlob returned null (image may be too large)')
212-
return
122+
let blob: Blob | null
123+
if (exportW * exportH > TILED_EXPORT_THRESHOLD) {
124+
blob = await renderTiledExport(exportW, exportH, drawScene, originalMimeType)
125+
} else {
126+
const canvas = document.createElement('canvas')
127+
canvas.width = exportW; canvas.height = exportH
128+
const ctx = canvas.getContext('2d')!
129+
drawScene(ctx)
130+
blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, originalMimeType, 1))
213131
}
132+
if (!blob) { console.error('Export failed: could not create image blob'); return }
214133

215134
const originalBuffer = await originalFile.arrayBuffer()
216135
blob = await transferExif(originalBuffer, blob, originalMimeType)
@@ -220,62 +139,35 @@ export function ExportDialog({
220139
const fileName = `${baseName}_edited${ext}`
221140
const file = new File([blob], fileName, { type: originalMimeType })
222141

223-
// Try Web Share API first (iOS Safari doesn't support anchor downloads)
224142
let shared = false
225143
if (typeof navigator.share === 'function') {
226-
try {
227-
await navigator.share({ files: [file] })
228-
shared = true
229-
} catch (e) {
230-
// AbortError = user cancelled share sheet (still counts as handled)
231-
if (e instanceof DOMException && e.name === 'AbortError') shared = true
232-
// TypeError = files not supported → fall through to anchor download
233-
}
144+
try { await navigator.share({ files: [file] }); shared = true }
145+
catch (e) { if (e instanceof DOMException && e.name === 'AbortError') shared = true }
234146
}
235-
236147
if (!shared) {
237148
const url = URL.createObjectURL(blob)
238-
const a = document.createElement('a')
239-
a.href = url
240-
a.download = fileName
241-
document.body.appendChild(a)
242-
a.click()
243-
document.body.removeChild(a)
149+
const a = document.createElement('a'); a.href = url; a.download = fileName
150+
document.body.appendChild(a); a.click(); document.body.removeChild(a)
244151
setTimeout(() => URL.revokeObjectURL(url), 30000)
245152
}
246153
onClose()
247-
} finally {
248-
setExporting(false)
249-
}
154+
} finally { setExporting(false) }
250155
}, [image, crop, frameConfig, includeGrid, activeGrids, gridOptions, gridOffset, gridDisplaySize, originalFile, originalMimeType, onClose])
251156

252157
return (
253158
<div className={`${styles.overlay} ${closing ? styles.closing : ''}`} onClick={handleClose}>
254159
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
255160
<h3 className={styles.title}>{t('exportPhoto')}</h3>
256-
257161
<div className={styles.info}>
258162
<span>{t('format')} {originalMimeType.split('/')[1]?.toUpperCase() ?? 'PNG'}</span>
259163
<span>{t('quality')} {t('qualityMaximum')}</span>
260164
</div>
261-
262-
{downscaleInfo && (
263-
<div className={styles.notice}>
264-
{t('downscaleNotice', { from: downscaleInfo.from, to: downscaleInfo.to })}
265-
</div>
266-
)}
267-
268165
{activeGrids.length > 0 && (
269166
<label className={styles.toggle}>
270-
<input
271-
type="checkbox"
272-
checked={includeGrid}
273-
onChange={(e) => setIncludeGrid(e.target.checked)}
274-
/>
167+
<input type="checkbox" checked={includeGrid} onChange={(e) => setIncludeGrid(e.target.checked)} />
275168
<span>{t('includeGridOverlay')}</span>
276169
</label>
277170
)}
278-
279171
<div className={styles.actions}>
280172
<button className={styles.cancelBtn} onClick={handleClose}>{t('cancel')}</button>
281173
<button className={styles.exportBtn} onClick={handleExport} disabled={exporting}>

0 commit comments

Comments
 (0)