From 0eca5c6530007b0a024ac66791b50bf2f2fa2b17 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Jan 2026 00:18:32 +0000 Subject: [PATCH 1/3] Add save/download feature for media with text overlay Add ability to save photos and videos with text overlay burned in before sending. Users can now tap the download button on the edit screen to save the content to their device with any text overlay permanently rendered. - Add handleSaveMedia function with photo and video support - For photos: Render to canvas with text overlay and export as JPEG - For videos: Re-encode with canvas + MediaRecorder to burn in text - Add download button with loading state next to the Aa text button - Import PiDownloadSimpleBold icon from react-icons --- src/pages/Home.tsx | 295 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 278 insertions(+), 17 deletions(-) diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 2a5283d..da392ae 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, useCallback } from 'react' -import { PiPaperPlaneRightFill } from 'react-icons/pi' +import { PiPaperPlaneRightFill, PiDownloadSimpleBold } from 'react-icons/pi' import { generateKey, exportKey, encrypt, compressImage } from '../crypto' import Stage from '../components/Stage' @@ -54,6 +54,7 @@ export default function Home() { const [recordingProgress, setRecordingProgress] = useState(0) const [previewMuted, setPreviewMuted] = useState(true) + const [isSaving, setIsSaving] = useState(false) // Refs const videoRef = useRef(null) @@ -920,6 +921,249 @@ export default function Home() { setStep('camera') } + // Save media with text overlay burned in + async function handleSaveMedia() { + if (isSaving) return + setIsSaving(true) + + try { + if (photo && !video) { + // Save photo with text overlay + await savePhotoWithOverlay() + } else if (video) { + // Save video with text overlay + await saveVideoWithOverlay() + } + showToast('Saved!') + } catch (err) { + console.error('Save failed:', err) + showToast('Save failed') + } finally { + setIsSaving(false) + } + } + + async function savePhotoWithOverlay() { + if (!photo) return + + // Create a canvas to render the photo with text + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d')! + const img = new Image() + + await new Promise((resolve, reject) => { + img.onload = () => resolve() + img.onerror = reject + img.src = photo + }) + + // Use image dimensions (should be 9:16 from capture) + canvas.width = img.width + canvas.height = img.height + + // Draw the image + ctx.drawImage(img, 0, 0) + + // Draw text overlay if present + if (overlayText) { + const fontSize = Math.round(canvas.width * 0.055) // ~5.5% of width + ctx.font = `500 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + const textY = (textPosition / 100) * canvas.height + const padding = fontSize * 0.6 + + // Draw semi-transparent background + ctx.fillStyle = 'rgba(0, 0, 0, 0.5)' + ctx.fillRect(0, textY - fontSize / 2 - padding, canvas.width, fontSize + padding * 2) + + // Draw text + ctx.fillStyle = 'white' + ctx.fillText(overlayText, canvas.width / 2, textY) + } + + // Convert to blob and download + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((b) => { + if (b) resolve(b) + else reject(new Error('Failed to create blob')) + }, 'image/jpeg', 0.92) + }) + + downloadBlob(blob, `booper-${Date.now()}.jpg`) + } + + async function saveVideoWithOverlay() { + if (!video) return + + // Create hidden video element to read frames from + const sourceVideo = document.createElement('video') + sourceVideo.src = video + sourceVideo.muted = true + sourceVideo.playsInline = true + + await new Promise((resolve, reject) => { + sourceVideo.onloadedmetadata = () => resolve() + sourceVideo.onerror = reject + }) + + // Target 9:16 at reasonable resolution + const targetWidth = Math.min(1080, sourceVideo.videoWidth) + const targetHeight = Math.round(targetWidth * (16 / 9)) + + // Create canvas for rendering + const canvas = document.createElement('canvas') + canvas.width = targetWidth + canvas.height = targetHeight + const ctx = canvas.getContext('2d')! + + // Calculate source crop for center-cover + const srcRatio = sourceVideo.videoWidth / sourceVideo.videoHeight + const targetRatio = 9 / 16 + let srcX = 0, srcY = 0, srcW = sourceVideo.videoWidth, srcH = sourceVideo.videoHeight + if (srcRatio > targetRatio) { + srcW = sourceVideo.videoHeight * targetRatio + srcX = (sourceVideo.videoWidth - srcW) / 2 + } else { + srcH = sourceVideo.videoWidth / targetRatio + srcY = (sourceVideo.videoHeight - srcH) / 2 + } + + // Mirror if it was recorded with selfie camera + const shouldMirror = facingMode === 'user' + + // Set up MediaRecorder to capture canvas + const stream = canvas.captureStream(30) + + // Add audio track from source video if available + // We need to create an AudioContext to route audio + let audioCtx: AudioContext | null = null + let audioSource: MediaElementAudioSourceNode | null = null + let audioDestination: MediaStreamAudioDestinationNode | null = null + + try { + audioCtx = new AudioContext() + audioSource = audioCtx.createMediaElementSource(sourceVideo) + audioDestination = audioCtx.createMediaStreamDestination() + audioSource.connect(audioDestination) + audioSource.connect(audioCtx.destination) // Also play to speakers (muted video, so this is for the stream) + + // Add audio track to our stream + const audioTrack = audioDestination.stream.getAudioTracks()[0] + if (audioTrack) { + stream.addTrack(audioTrack) + } + } catch { + // Audio routing failed, continue without audio + } + + // Determine output format + let mimeType: string | undefined + if (MediaRecorder.isTypeSupported('video/mp4')) { + mimeType = 'video/mp4' + } else if (MediaRecorder.isTypeSupported('video/webm;codecs=vp9')) { + mimeType = 'video/webm;codecs=vp9' + } else if (MediaRecorder.isTypeSupported('video/webm')) { + mimeType = 'video/webm' + } + + const recorder = new MediaRecorder(stream, { + ...(mimeType && { mimeType }), + videoBitsPerSecond: 2000000, // 2 Mbps for good quality save + }) + + const chunks: Blob[] = [] + recorder.ondataavailable = (e) => { + if (e.data.size > 0) chunks.push(e.data) + } + + const recordingDone = new Promise((resolve) => { + recorder.onstop = () => { + const finalMimeType = recorder.mimeType || mimeType || 'video/webm' + resolve(new Blob(chunks, { type: finalMimeType })) + } + }) + + // Start recording + recorder.start() + + // Play and render frames + sourceVideo.currentTime = 0 + await sourceVideo.play() + + const renderFrame = () => { + if (sourceVideo.ended || sourceVideo.paused) { + recorder.stop() + return + } + + // Clear and set up mirroring if needed + ctx.save() + if (shouldMirror) { + ctx.translate(canvas.width, 0) + ctx.scale(-1, 1) + } + + // Draw video frame with center-cover crop + ctx.drawImage( + sourceVideo, + srcX, srcY, srcW, srcH, + 0, 0, canvas.width, canvas.height + ) + + ctx.restore() + + // Draw text overlay if present + if (overlayText) { + const fontSize = Math.round(canvas.width * 0.055) + ctx.font = `500 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + const textY = (textPosition / 100) * canvas.height + const padding = fontSize * 0.6 + + // Draw semi-transparent background + ctx.fillStyle = 'rgba(0, 0, 0, 0.5)' + ctx.fillRect(0, textY - fontSize / 2 - padding, canvas.width, fontSize + padding * 2) + + // Draw text + ctx.fillStyle = 'white' + ctx.fillText(overlayText, canvas.width / 2, textY) + } + + requestAnimationFrame(renderFrame) + } + + renderFrame() + + // Wait for recording to complete + const blob = await recordingDone + + // Cleanup + sourceVideo.pause() + sourceVideo.src = '' + if (audioCtx) { + audioCtx.close() + } + + // Download + const ext = (recorder.mimeType || mimeType || '').includes('mp4') ? 'mp4' : 'webm' + downloadBlob(blob, `booper-${Date.now()}.${ext}`) + } + + function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } + // Single render with all layers - video always mounted return (
@@ -1209,13 +1453,28 @@ export default function Home() { {/* Bottom controls */}
-
- +
+ {/* Left side: Text + Download buttons */} +
+ + +
- )} -
+
+ +
+ )} +
{error && (

{error}

From 2fe9b306056a2f9b025be7926b82a1f402eadc82 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Jan 2026 00:19:10 +0000 Subject: [PATCH 2/3] Update package-lock.json --- package-lock.json | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index e9f34e1..e7c2bbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,7 +74,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -465,8 +464,7 @@ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251225.0.tgz", "integrity": "sha512-ZZl0cNLFcsBRFKtMftKWOsfAybUYSeiTMzpQV1NlTVlByHAs1rGQt45Jw/qz8LrfHoq9PGTieSj9W350Gi4Pvg==", "dev": true, - "license": "MIT OR Apache-2.0", - "peer": true + "license": "MIT OR Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -2068,7 +2066,6 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2079,7 +2076,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2238,7 +2234,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3098,7 +3093,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3126,7 +3120,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3148,7 +3141,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3158,7 +3150,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3527,7 +3518,6 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -3569,7 +3559,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3646,7 +3635,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, From 1ac982d23782be9a054fc0e6b7434de7d1039e41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Jan 2026 00:21:35 +0000 Subject: [PATCH 3/3] Refactor: Extract drawTextOverlay helper to reduce duplication Move the text overlay drawing logic into a shared helper function used by both savePhotoWithOverlay and saveVideoWithOverlay. --- src/pages/Home.tsx | 63 ++++++++++++++++------------------------------ 1 file changed, 22 insertions(+), 41 deletions(-) diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index da392ae..c2e3ea7 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -943,10 +943,28 @@ export default function Home() { } } + // Shared helper to draw text overlay on a canvas + function drawTextOverlay(ctx: CanvasRenderingContext2D, width: number, height: number, text: string, position: number) { + const fontSize = Math.round(width * 0.055) // ~5.5% of width + ctx.font = `500 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + const textY = (position / 100) * height + const padding = fontSize * 0.6 + + // Draw semi-transparent background + ctx.fillStyle = 'rgba(0, 0, 0, 0.5)' + ctx.fillRect(0, textY - fontSize / 2 - padding, width, fontSize + padding * 2) + + // Draw text + ctx.fillStyle = 'white' + ctx.fillText(text, width / 2, textY) + } + async function savePhotoWithOverlay() { if (!photo) return - // Create a canvas to render the photo with text const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d')! const img = new Image() @@ -957,38 +975,16 @@ export default function Home() { img.src = photo }) - // Use image dimensions (should be 9:16 from capture) canvas.width = img.width canvas.height = img.height - - // Draw the image ctx.drawImage(img, 0, 0) - // Draw text overlay if present if (overlayText) { - const fontSize = Math.round(canvas.width * 0.055) // ~5.5% of width - ctx.font = `500 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif` - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - - const textY = (textPosition / 100) * canvas.height - const padding = fontSize * 0.6 - - // Draw semi-transparent background - ctx.fillStyle = 'rgba(0, 0, 0, 0.5)' - ctx.fillRect(0, textY - fontSize / 2 - padding, canvas.width, fontSize + padding * 2) - - // Draw text - ctx.fillStyle = 'white' - ctx.fillText(overlayText, canvas.width / 2, textY) + drawTextOverlay(ctx, canvas.width, canvas.height, overlayText, textPosition) } - // Convert to blob and download const blob = await new Promise((resolve, reject) => { - canvas.toBlob((b) => { - if (b) resolve(b) - else reject(new Error('Failed to create blob')) - }, 'image/jpeg', 0.92) + canvas.toBlob((b) => b ? resolve(b) : reject(new Error('Failed to create blob')), 'image/jpeg', 0.92) }) downloadBlob(blob, `booper-${Date.now()}.jpg`) @@ -1114,23 +1110,8 @@ export default function Home() { ctx.restore() - // Draw text overlay if present if (overlayText) { - const fontSize = Math.round(canvas.width * 0.055) - ctx.font = `500 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif` - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - - const textY = (textPosition / 100) * canvas.height - const padding = fontSize * 0.6 - - // Draw semi-transparent background - ctx.fillStyle = 'rgba(0, 0, 0, 0.5)' - ctx.fillRect(0, textY - fontSize / 2 - padding, canvas.width, fontSize + padding * 2) - - // Draw text - ctx.fillStyle = 'white' - ctx.fillText(overlayText, canvas.width / 2, textY) + drawTextOverlay(ctx, canvas.width, canvas.height, overlayText, textPosition) } requestAnimationFrame(renderFrame)