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" }, diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 2a5283d..c2e3ea7 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,230 @@ 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) + } + } + + // 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 + + 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 + }) + + canvas.width = img.width + canvas.height = img.height + ctx.drawImage(img, 0, 0) + + if (overlayText) { + drawTextOverlay(ctx, canvas.width, canvas.height, overlayText, textPosition) + } + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((b) => b ? resolve(b) : 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() + + if (overlayText) { + drawTextOverlay(ctx, canvas.width, canvas.height, overlayText, textPosition) + } + + 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 +1434,28 @@ export default function Home() { {/* Bottom controls */}
-
- +
+ {/* Left side: Text + Download buttons */} +
+ + +
- )} -
+
+ +
+ )} +
{error && (

{error}