/** * Pre-upload compression for Blossom / NIP-96: images (WebP/JPEG), audio (MP3), video (WebM). * * All compression runs entirely on-device (Canvas, Web Audio, bundled lamejs, MediaRecorder). * No files or pixels are sent to third-party transcoding services — only your chosen upload * step (Blossom / NIP-96) sends the already-compressed blob out. * * Falls back to the original file when decode, encode, or APIs fail. */ import { compressImage } from '@/lib/compress-image' import logger from '@/lib/logger' /** * Dev always; otherwise set `localStorage.setItem('jumble-upload-log', 'true')` (e.g. for `vite preview`). * Uses console.log (not console.info): many browsers hide the "Info" level in DevTools by default, so info looked like "no logs" even in vite dev. */ function uploadCompressionDiag(message: string, data?: Record): void { try { const enabled = import.meta.env.DEV || (typeof localStorage !== 'undefined' && localStorage.getItem('jumble-upload-log') === 'true') if (!enabled) return if (data !== undefined) console.log(`[compress-upload] ${message}`, data) else console.log(`[compress-upload] ${message}`) } catch { // private mode / no storage } } const AUDIO_TARGET_SAMPLE_RATE = 44100 const AUDIO_MP3_KBPS = 96 const MP3_FRAME_SAMPLES = 1152 const MAX_VIDEO_DURATION_SEC = 15 * 60 const MAX_VIDEO_WIDTH_PX = 1280 const VIDEO_TARGET_BITRATE_MAX = 2_500_000 /** Floor so short clips don’t balloon vs efficient H.264 in MP4. */ const VIDEO_TARGET_BITRATE_MIN = 450_000 const VIDEO_AUDIO_BITRATE = 96_000 /** Browsers often leave `File.type` empty for some paths; still treat as video. */ const VIDEO_FILENAME_RE = /\.(mp4|m4v|mov|mkv|webm|ogv|avi|mpeg|mpg|3gp|3g2)$/i /** Image/audio extensions for drag/drop and paste when `File.type` is empty (common on Linux). */ const IMAGE_FILENAME_RE = /\.(jpe?g|png|gif|webp|bmp|svg|ico|heic|heif|avif)$/i const AUDIO_FILENAME_RE = /\.(mp3|m4a|mka|wav|ogg|opus|aac|flac|mpeg)$/i /** * True if the file is likely a user media upload (image, video, or audio) from MIME or filename. * Use for clipboard/drop filters where `DataTransferItem.type` / `File.type` may be empty. */ export function fileLooksLikeUploadableMedia(file: File): boolean { const t = file.type if (t.startsWith('image/') || t.startsWith('video/') || t.startsWith('audio/')) return true if (VIDEO_FILENAME_RE.test(file.name)) return true if (IMAGE_FILENAME_RE.test(file.name)) return true if (AUDIO_FILENAME_RE.test(file.name)) return true return false } function float32ToInt16(f32: Float32Array): Int16Array { const out = new Int16Array(f32.length) for (let i = 0; i < f32.length; i++) { const s = Math.max(-1, Math.min(1, f32[i])) out[i] = s < 0 ? (s * 0x8000) | 0 : (s * 0x7fff) | 0 } return out } function fileLooksLikeMatroskaAudio(file: File): boolean { return /\.mka$/i.test(file.name) || file.type === 'audio/x-matroska' } async function compressAudioToMp3( file: File, signal?: AbortSignal, onProgress?: (percent: number) => void ): Promise { if (!file.type.startsWith('audio/') && !fileLooksLikeMatroskaAudio(file)) return file let lastP = -1 const bump = (p: number) => { const n = Math.max(0, Math.min(100, Math.round(p))) if (n > lastP) { lastP = n onProgress?.(n) } } const ctx = new AudioContext() try { bump(4) const ab = await file.arrayBuffer() if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') let audioBuffer: AudioBuffer try { audioBuffer = await ctx.decodeAudioData(ab.slice(0)) } catch { bump(100) return file } if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') if (audioBuffer.duration <= 0 || !Number.isFinite(audioBuffer.duration)) { bump(100) return file } bump(18) const length = Math.ceil(audioBuffer.duration * AUDIO_TARGET_SAMPLE_RATE) const offline = new OfflineAudioContext(1, length, AUDIO_TARGET_SAMPLE_RATE) const monoSrc = offline.createBuffer(1, audioBuffer.length, audioBuffer.sampleRate) if (audioBuffer.numberOfChannels === 1) { monoSrc.copyToChannel(audioBuffer.getChannelData(0), 0) } else { const m = new Float32Array(audioBuffer.length) for (let i = 0; i < audioBuffer.length; i++) { let s = 0 for (let c = 0; c < audioBuffer.numberOfChannels; c++) { s += audioBuffer.getChannelData(c)[i] } m[i] = s / audioBuffer.numberOfChannels } monoSrc.copyToChannel(m, 0) } const src = offline.createBufferSource() src.buffer = monoSrc src.connect(offline.destination) src.start(0) const rendered = await offline.startRendering() if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') bump(32) const pcm = float32ToInt16(rendered.getChannelData(0)) const { Mp3Encoder } = await import('lamejs') const enc = new Mp3Encoder(1, AUDIO_TARGET_SAMPLE_RATE, AUDIO_MP3_KBPS) const chunks: BlobPart[] = [] for (let i = 0; i < pcm.length; i += MP3_FRAME_SAMPLES) { if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') if (i % (MP3_FRAME_SAMPLES * 80) === 0) { bump(34 + Math.floor(62 * (i / Math.max(1, pcm.length)))) } if (i % (MP3_FRAME_SAMPLES * 200) === 0) { await new Promise((r) => setTimeout(r, 0)) } const block = new Int16Array(MP3_FRAME_SAMPLES) const take = Math.min(MP3_FRAME_SAMPLES, pcm.length - i) block.set(pcm.subarray(i, i + take)) const mp3 = enc.encodeBuffer(block) if (mp3.length > 0) { chunks.push(new Uint8Array(mp3.buffer.slice(mp3.byteOffset, mp3.byteOffset + mp3.byteLength)) as BlobPart) } } const tail = enc.flush() if (tail.length > 0) { chunks.push(new Uint8Array(tail.buffer.slice(tail.byteOffset, tail.byteOffset + tail.byteLength)) as BlobPart) } bump(96) const blob = new Blob(chunks, { type: 'audio/mpeg' }) if (blob.size === 0 || blob.size >= file.size * 0.97) { bump(100) return file } const base = file.name.replace(/\.[^.]+$/, '') || 'audio' bump(100) return new File([blob], `${base}.mp3`, { type: 'audio/mpeg' }) } catch (e) { if (e instanceof DOMException && e.name === 'AbortError') throw e bump(100) return file } finally { await ctx.close().catch(() => {}) } } function pickVideoMime(): string | null { if (typeof MediaRecorder === 'undefined') return null // Prefer explicit audio codec where supported (better mux with captured audio). for (const m of [ 'video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm', 'video/mp4' ] as const) { if (MediaRecorder.isTypeSupported(m)) return m } return null } function isMediaRecorderAudioMuxUnsupportedError(e: unknown): boolean { if (!(e instanceof DOMException) || e.name !== 'NotSupportedError') return false const m = (e.message || '').toLowerCase() return m.includes('audio') || m.includes('cannot be recorded') } function fileLooksLikeVideo(file: File): boolean { return file.type.startsWith('video/') || VIDEO_FILENAME_RE.test(file.name) } function extensionForRecordedMime(mime: string): string { if (mime.includes('mp4')) return '.mp4' return '.webm' } type VideoElementWithCapture = HTMLVideoElement & { captureStream?: () => MediaStream mozCaptureStream?: () => MediaStream } /** Firefox historically used `mozCaptureStream`; some builds expose capture only on instances, not via `in` on prototype. */ function captureStreamFromVideoElement(video: HTMLVideoElement): MediaStream | null { const v = video as VideoElementWithCapture if (typeof v.captureStream === 'function') { try { return v.captureStream() } catch { /* fall through */ } } if (typeof v.mozCaptureStream === 'function') { try { return v.mozCaptureStream() } catch { return null } } return null } function waitVideoEvent(el: HTMLVideoElement, name: keyof HTMLMediaElementEventMap): Promise { return new Promise((resolve, reject) => { const onOk = () => { cleanup() resolve() } const onErr = () => { cleanup() reject(new Error('video error')) } const cleanup = () => { el.removeEventListener(name, onOk) el.removeEventListener('error', onErr) } el.addEventListener(name, onOk, { once: true }) el.addEventListener('error', onErr, { once: true }) }) } async function compressVideoToWebm( file: File, signal?: AbortSignal, onProgress?: (percent: number) => void ): Promise { if (!fileLooksLikeVideo(file)) return file const mime = pickVideoMime() if (!mime) { uploadCompressionDiag('video skip: no MediaRecorder MIME supported in this browser') logger.debug('[compress-upload] MediaRecorder has no supported video MIME in this browser') return file } if (typeof HTMLCanvasElement === 'undefined') { return file } const probeCanvas = document.createElement('canvas') if (typeof probeCanvas.captureStream !== 'function') { uploadCompressionDiag('video skip: canvas.captureStream not available') return file } let lastVp = -1 const bump = (p: number) => { const n = Math.max(0, Math.min(100, Math.round(p))) if (n > lastVp) { lastVp = n onProgress?.(n) } } const objUrl = URL.createObjectURL(file) const video = document.createElement('video') video.src = objUrl video.muted = true video.playsInline = true video.setAttribute('playsinline', '') try { await waitVideoEvent(video, 'loadedmetadata') if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') bump(6) const probeStream = captureStreamFromVideoElement(video) if (!probeStream) { uploadCompressionDiag( 'video skip: video.captureStream / mozCaptureStream not available (try another browser or disable strict privacy flags)' ) bump(100) return file } probeStream.getTracks().forEach((t) => t.stop()) const { duration, videoWidth, videoHeight } = video if (!Number.isFinite(duration) || duration <= 0 || duration > MAX_VIDEO_DURATION_SEC) { uploadCompressionDiag('video skip: bad or too long duration', { duration }) logger.debug('[compress-upload] video duration skip', { duration }) bump(100) return file } if (videoWidth < 2 || videoHeight < 2) { uploadCompressionDiag('video skip: dimensions too small', { videoWidth, videoHeight }) bump(100) return file } const durationSec = Math.max(0.1, duration) const sourceBitrate = (file.size * 8) / durationSec const primaryVideoBps = Math.min( VIDEO_TARGET_BITRATE_MAX, Math.max(VIDEO_TARGET_BITRATE_MIN, Math.floor(sourceBitrate * 0.42)) ) const seekVideoToStart = async () => { video.pause() if (video.currentTime < 0.05) return await new Promise((resolve, reject) => { const onSeeked = () => { video.removeEventListener('seeked', onSeeked) video.removeEventListener('error', onErr) resolve() } const onErr = () => { video.removeEventListener('seeked', onSeeked) video.removeEventListener('error', onErr) reject(new Error('video seek error')) } video.addEventListener('seeked', onSeeked, { once: true }) video.addEventListener('error', onErr, { once: true }) video.currentTime = 0 }) } const encodePass = async (maxWidthPx: number, videoBitsPerSecond: number): Promise => { const scale = Math.min(1, maxWidthPx / videoWidth) const w = Math.max(2, Math.floor((videoWidth * scale) / 2) * 2) const h = Math.max(2, Math.floor((videoHeight * scale) / 2) * 2) await seekVideoToStart() const canvas = document.createElement('canvas') canvas.width = w canvas.height = h const ctx = canvas.getContext('2d', { alpha: false }) if (!ctx) return null const canvasStream = canvas.captureStream(30) const vs = captureStreamFromVideoElement(video) const audioTracksFromVideo = vs ? [...vs.getAudioTracks()] : [] const buildRecorder = (stream: MediaStream) => { const chunks: Blob[] = [] const recorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond, ...(stream.getAudioTracks().length > 0 ? { audioBitsPerSecond: VIDEO_AUDIO_BITRATE } : {}) }) recorder.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data) } const stopped = new Promise((resolve, reject) => { recorder.onstop = () => resolve() recorder.onerror = () => reject(new Error('MediaRecorder error')) }) return { recorder, chunks, stopped } } let activeStream: MediaStream = audioTracksFromVideo.length > 0 ? new MediaStream([...canvasStream.getVideoTracks(), ...audioTracksFromVideo]) : new MediaStream([...canvasStream.getVideoTracks()]) let { recorder: rec, chunks, stopped } = buildRecorder(activeStream) try { rec.start(250) } catch (startErr) { if (audioTracksFromVideo.length > 0 && isMediaRecorderAudioMuxUnsupportedError(startErr)) { uploadCompressionDiag( 'video pass: dropping audio (browser cannot mux source audio with this recorder codec)', { maxWidthPx, mime, detail: String((startErr as Error).message) } ) audioTracksFromVideo.forEach((t) => t.stop()) activeStream = new MediaStream([...canvasStream.getVideoTracks()]) ;({ recorder: rec, chunks, stopped } = buildRecorder(activeStream)) try { rec.start(250) } catch (e2) { uploadCompressionDiag('video pass: MediaRecorder.start failed after video-only fallback', { error: String(e2), maxWidthPx }) return null } } else { uploadCompressionDiag('video pass: MediaRecorder.start failed', { error: String(startErr), maxWidthPx }) return null } } try { await video.play() } catch (e) { uploadCompressionDiag('video pass: play() failed', { error: String(e), maxWidthPx }) logger.debug('[compress-upload] video.play() failed', { e }) rec.stop() await stopped.catch(() => {}) return null } try { await new Promise((resolve, reject) => { let settled = false const finish = () => { if (settled) return settled = true try { ctx.drawImage(video, 0, 0, w, h) } catch { /* ignore */ } resolve() } video.addEventListener('ended', finish, { once: true }) video.addEventListener('error', () => reject(new Error('Video playback error')), { once: true }) let frames = 0 const maxFrames = Math.min(Math.ceil(durationSec * 100) + 2000, 500_000) /** Yield to the event loop so React can paint (compression is CPU-heavy). */ const YIELD_EVERY_FRAMES = 30 const PROGRESS_EVERY_FRAMES = 6 const step = () => { if (settled) return if (signal?.aborted) { video.pause() reject(new DOMException('Aborted', 'AbortError')) return } if (video.ended) return try { ctx.drawImage(video, 0, 0, w, h) } catch { reject(new Error('drawImage failed')) return } frames++ if (frames === 1 || frames % PROGRESS_EVERY_FRAMES === 0) { bump(8 + Math.floor(84 * (frames / maxFrames))) } if (frames > maxFrames) { video.pause() finish() return } if (frames % YIELD_EVERY_FRAMES === 0) { setTimeout(() => requestAnimationFrame(step), 0) } else { requestAnimationFrame(step) } } requestAnimationFrame(step) }) } catch (e) { rec.stop() await stopped.catch(() => {}) if (e instanceof DOMException && e.name === 'AbortError') throw e uploadCompressionDiag('video pass: playback/draw failed', { error: String(e), maxWidthPx }) return null } rec.stop() await stopped const blob = new Blob(chunks, { type: mime }) if (blob.size === 0) { uploadCompressionDiag('video pass: empty blob', { maxWidthPx }) return null } if (blob.size >= file.size) { uploadCompressionDiag('video pass: output not smaller than source', { maxWidthPx, inBytes: file.size, outBytes: blob.size }) return null } const base = file.name.replace(/\.[^.]+$/, '') || 'video' const ext = extensionForRecordedMime(mime) return new File([blob], `${base}${ext}`, { type: mime }) } const attempts: { maxW: number; bps: number }[] = [ { maxW: MAX_VIDEO_WIDTH_PX, bps: primaryVideoBps }, { maxW: 854, bps: VIDEO_TARGET_BITRATE_MIN }, { maxW: 640, bps: VIDEO_TARGET_BITRATE_MIN } ] for (const { maxW, bps } of attempts) { const out = await encodePass(maxW, bps) if (out) { uploadCompressionDiag('video: re-encoded for upload', { inBytes: file.size, outBytes: out.size, mime, maxWidthPx: maxW, outName: out.name }) bump(100) return out } } uploadCompressionDiag('video skip: all passes failed or did not beat source size', { inBytes: file.size, mime }) logger.debug('[compress-upload] video re-encode: all passes kept original') bump(100) return file } catch (e) { if (e instanceof DOMException && e.name === 'AbortError') throw e uploadCompressionDiag('video skip: encode pipeline error', { error: String(e) }) logger.debug('[compress-upload] video compress failed', { e }) bump(100) return file } finally { URL.revokeObjectURL(objUrl) video.removeAttribute('src') video.load() } } export type CompressMediaOptions = { signal?: AbortSignal /** Raster images are scaled/encoded until under this size when possible (default 2 MiB — fits typical profile `picture` limits). */ imageTargetMaxBytes?: number /** 0–100 during local compression only (not HTTP upload). */ onCompressProgress?: (percent: number) => void } /** Default cap for raster image uploads (profile pics and inline media). */ const DEFAULT_IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024 /** * Compress media before upload. Non-media types are returned unchanged. */ export async function compressMediaForUpload(file: File, options?: CompressMediaOptions): Promise { const signal = options?.signal const imageTarget = options?.imageTargetMaxBytes ?? DEFAULT_IMAGE_UPLOAD_MAX_BYTES if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') let branch: 'image' | 'audio' | 'video' | 'none' = 'none' let out: File = file const onProg = options?.onCompressProgress if (file.type.startsWith('image/')) { branch = 'image' out = await compressImage(file, imageTarget, onProg) } else if (file.type.startsWith('audio/') || fileLooksLikeMatroskaAudio(file)) { branch = 'audio' out = await compressAudioToMp3(file, signal, onProg) } else if (fileLooksLikeVideo(file)) { branch = 'video' out = await compressVideoToWebm(file, signal, onProg) } onProg?.(100) uploadCompressionDiag('compressMediaForUpload result', { branch, inName: file.name, inBytes: file.size, inType: file.type || '(empty)', outBytes: out.size, outType: out.type || '(empty)', outName: out.name, changed: out !== file || out.size !== file.size || out.name !== file.name }) return out }