diff --git a/e2e/tools/video-recorder.spec.ts b/e2e/tools/video-recorder.spec.ts index 55b49f9..01f63e4 100644 --- a/e2e/tools/video-recorder.spec.ts +++ b/e2e/tools/video-recorder.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; -// Drive the real getUserMedia + MediaRecorder flow with Chromium's synthetic -// camera/mic so no hardware is needed. +// Drive the real getUserMedia + canvas + MediaRecorder pipeline with Chromium's +// synthetic camera/mic so no hardware is needed. test.use({ permissions: ['camera', 'microphone'], launchOptions: { @@ -9,11 +9,10 @@ test.use({ }, }); -test('records from the (fake) webcam and offers a download', async ({ page }) => { +test('records the effects canvas from the (fake) webcam and offers a download', async ({ page }) => { await page.goto('/tools/video-recorder'); await page.waitForLoadState('networkidle').catch(() => {}); - // Start the camera; the Record button appears once the stream is live. const start = page.getByRole('button', { name: 'Start camera' }); const record = page.getByRole('button', { name: 'Record', exact: true }); await expect(async () => { @@ -21,13 +20,15 @@ test('records from the (fake) webcam and offers a download', async ({ page }) => await expect(record).toBeVisible({ timeout: 3000 }); }).toPass({ timeout: 30_000 }); - // Record a short clip. + // Apply a colour filter — exercises the canvas-composited pipeline (no model). + const effectSelect = page.locator('select:has(option[value="greenscreen"])'); + await effectSelect.selectOption('filter'); + await record.click(); await expect(page.getByRole('button', { name: 'Stop' })).toBeVisible(); await page.waitForTimeout(1200); await page.getByRole('button', { name: 'Stop' }).click(); - // Stopping yields a downloadable result. await expect(page.getByRole('button', { name: 'Download' })).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole('button', { name: 'Record again' })).toBeVisible(); }); diff --git a/scripts/stage-models.mjs b/scripts/stage-models.mjs index a70f597..bb0ba32 100644 --- a/scripts/stage-models.mjs +++ b/scripts/stage-models.mjs @@ -35,7 +35,18 @@ async function stageMediapipe() { writeFileSync(model, Buffer.from(await res.arrayBuffer())); console.log('Downloaded face-detection model.'); } - console.log(`Staged Face-Blur assets → ${DEST}`); + + // Selfie segmenter for the Video Recorder's real-time background blur/replace. + const seg = `${DEST}/selfie_segmenter.tflite`; + if (!existsSync(seg)) { + const url = + 'https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite'; + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to download selfie segmenter: ${res.status}`); + writeFileSync(seg, Buffer.from(await res.arrayBuffer())); + console.log('Downloaded selfie-segmenter model.'); + } + console.log(`Staged Face-Blur + segmenter assets → ${DEST}`); } function stageUpscaler() { diff --git a/src/islands/media/VideoRecorder.tsx b/src/islands/media/VideoRecorder.tsx index 51fed46..a185cb8 100644 --- a/src/islands/media/VideoRecorder.tsx +++ b/src/islands/media/VideoRecorder.tsx @@ -1,43 +1,57 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { Video, Circle, Pause, Play, Square, Camera, Download, RotateCcw } from 'lucide-react'; +import { Video, Circle, Pause, Play, Square, Camera, Download, RotateCcw, Upload } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { Alert } from '@/components/ui/Alert'; import { downloadService } from '@/services/download'; import { pickRecordingType, formatDuration, resolutionConstraint, type Resolution } from '@/tools/media/video-recorder.lib'; +import { filterCss, hexToRgb, applyChromaKey, type EffectKind, type FilterPreset } from '@/tools/media/video-effects.lib'; import type { Lang } from '@/i18n/config'; type Status = 'idle' | 'live' | 'countdown' | 'recording' | 'paused' | 'review'; const TR: Record = { en: { - intro: 'Record video from your webcam with sound, preview it, and download — 100% in your browser, nothing uploaded. Pick your camera and microphone, mirror the view, and grab photo snapshots.', - start: 'Start camera', camera: 'Camera', mic: 'Microphone', noMic: 'No microphone (video only)', resolution: 'Resolution', + intro: 'Record video from your webcam with sound, add filters or a blurred / replaced / green-screen background, preview, and download — 100% in your browser, nothing uploaded.', + start: 'Start camera', camera: 'Camera', mic: 'Microphone', resolution: 'Resolution', mirror: 'Mirror', countdown: '3-2-1 countdown', record: 'Record', pause: 'Pause', resume: 'Resume', stop: 'Stop', snapshot: 'Photo', download: 'Download', again: 'Record again', recording: 'Recording', paused: 'Paused', unsupported: 'Your browser does not support webcam recording (getUserMedia / MediaRecorder).', denied: 'Camera/microphone access was blocked. Allow it in your browser and try again.', notfound: 'No camera was found on this device.', permission: 'Click “Start camera” and allow access — the video never leaves your device.', - mirrorNote: 'Mirror affects the preview only, not the recorded file.', + effect: 'Effect', fxNone: 'None', fxFilter: 'Color filter', fxBlur: 'Blur background', fxReplace: 'Replace background', fxGreen: 'Green screen', + filter: 'Filter', background: 'Background', bgColor: 'Color', bgImage: 'Image', keyColor: 'Key color', tolerance: 'Tolerance', + loadingModel: 'Loading background model…', modelFailed: 'Could not load the background model; effect turned off.', }, id: { - intro: 'Rekam video dari webcam Anda dengan suara, pratinjau, dan unduh — 100% di browser Anda, tidak ada yang diunggah. Pilih kamera dan mikrofon, cerminkan tampilan, dan ambil foto snapshot.', - start: 'Mulai kamera', camera: 'Kamera', mic: 'Mikrofon', noMic: 'Tanpa mikrofon (video saja)', resolution: 'Resolusi', + intro: 'Rekam video dari webcam Anda dengan suara, tambahkan filter atau latar buram / ganti / green-screen, pratinjau, dan unduh — 100% di browser Anda, tidak ada yang diunggah.', + start: 'Mulai kamera', camera: 'Kamera', mic: 'Mikrofon', resolution: 'Resolusi', mirror: 'Cermin', countdown: 'Hitung mundur 3-2-1', record: 'Rekam', pause: 'Jeda', resume: 'Lanjut', stop: 'Berhenti', snapshot: 'Foto', download: 'Unduh', again: 'Rekam lagi', recording: 'Merekam', paused: 'Dijeda', unsupported: 'Browser Anda tidak mendukung perekaman webcam (getUserMedia / MediaRecorder).', denied: 'Akses kamera/mikrofon diblokir. Izinkan di browser Anda lalu coba lagi.', notfound: 'Tidak ada kamera yang ditemukan di perangkat ini.', permission: 'Klik “Mulai kamera” dan izinkan akses — video tidak pernah meninggalkan perangkat Anda.', - mirrorNote: 'Cermin hanya memengaruhi pratinjau, bukan file rekaman.', + effect: 'Efek', fxNone: 'Tidak ada', fxFilter: 'Filter warna', fxBlur: 'Buramkan latar', fxReplace: 'Ganti latar', fxGreen: 'Green screen', + filter: 'Filter', background: 'Latar', bgColor: 'Warna', bgImage: 'Gambar', keyColor: 'Warna kunci', tolerance: 'Toleransi', + loadingModel: 'Memuat model latar…', modelFailed: 'Tidak bisa memuat model latar; efek dimatikan.', }, }; +// A MediaPipe ImageSegmenter kept minimal for typing without importing the dep at module scope. +interface Segmenter { + segmentForVideo: (video: HTMLVideoElement, ts: number, cb: (r: { confidenceMasks?: Array<{ getAsFloat32Array: () => Float32Array; width: number; height: number }> }) => void) => void; + close: () => void; +} + export default function VideoRecorder({ lang = 'en' }: { lang?: Lang }) { const t = TR[lang] ?? TR.en; @@ -55,7 +69,15 @@ export default function VideoRecorder({ lang = 'en' }: { lang?: Lang }) { const [elapsed, setElapsed] = useState(0); const [resultUrl, setResultUrl] = useState(''); + const [effect, setEffect] = useState('none'); + const [filterPreset, setFilterPreset] = useState('grayscale'); + const [bgColor, setBgColor] = useState('#1e293b'); + const [keyColor, setKeyColor] = useState('#00b140'); + const [tolerance, setTolerance] = useState(120); + const [modelBusy, setModelBusy] = useState(false); + const videoRef = useRef(null); + const canvasRef = useRef(null); const reviewRef = useRef(null); const streamRef = useRef(null); const recorderRef = useRef(null); @@ -66,6 +88,15 @@ export default function VideoRecorder({ lang = 'en' }: { lang?: Lang }) { const startRef = useRef(0); const accumRef = useRef(0); const cdRef = useRef(null); + const rafRef = useRef(null); + const segRef = useRef(null); + const bgImgRef = useRef(null); + const workRef = useRef(null); + const personRef = useRef(null); + const maskRef = useRef(null); + // Latest effect settings for the rAF loop (avoids stale closures). + const fx = useRef({ effect, filterPreset, bgColor, keyColor, tolerance, mirror }); + fx.current = { effect, filterPreset, bgColor, keyColor, tolerance, mirror }; const stopStream = useCallback(() => { streamRef.current?.getTracks().forEach(tk => tk.stop()); @@ -75,7 +106,123 @@ export default function VideoRecorder({ lang = 'en' }: { lang?: Lang }) { const clearTimer = () => { if (timerRef.current !== null) { clearInterval(timerRef.current); timerRef.current = null; } }; const clearCd = () => { if (cdRef.current !== null) { clearInterval(cdRef.current); cdRef.current = null; } }; - // Acquire (or re-acquire) the preview stream with the current device/resolution. + function scratch(ref: React.MutableRefObject): HTMLCanvasElement { + if (!ref.current) ref.current = document.createElement('canvas'); + return ref.current; + } + + // The per-frame draw. Reads the latest settings from fx.current. + const drawFrame = useCallback(() => { + const v = videoRef.current, c = canvasRef.current; + rafRef.current = requestAnimationFrame(drawFrame); + if (!v || !c || v.readyState < 2 || !v.videoWidth) return; + const w = v.videoWidth, h = v.videoHeight; + if (c.width !== w) c.width = w; + if (c.height !== h) c.height = h; + const ctx = c.getContext('2d'); + if (!ctx) return; + const { effect: e, filterPreset: fp, bgColor: bg, keyColor: kc, tolerance: tol, mirror: mir } = fx.current; + + const drawMirrored = (src: CanvasImageSource) => { + ctx.save(); + if (mir) { ctx.translate(w, 0); ctx.scale(-1, 1); } + ctx.drawImage(src, 0, 0, w, h); + ctx.restore(); + }; + + ctx.filter = 'none'; + ctx.clearRect(0, 0, w, h); + + if (e === 'none' || e === 'filter') { + ctx.save(); + if (mir) { ctx.translate(w, 0); ctx.scale(-1, 1); } + ctx.filter = e === 'filter' ? filterCss(fp) : 'none'; + ctx.drawImage(v, 0, 0, w, h); + ctx.restore(); + return; + } + + if (e === 'greenscreen') { + const work = scratch(workRef); work.width = w; work.height = h; + const wctx = work.getContext('2d', { willReadFrequently: true }); + if (!wctx) return; + wctx.drawImage(v, 0, 0, w, h); + const img = wctx.getImageData(0, 0, w, h); + applyChromaKey(img.data, hexToRgb(kc), tol); + wctx.putImageData(img, 0, 0); + // background then keyed person + if (bgImgRef.current) ctx.drawImage(bgImgRef.current, 0, 0, w, h); + else { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); } + drawMirrored(work); + return; + } + + // blur-bg / replace-bg — needs the segmenter mask. + const seg = segRef.current; + if (!seg) { drawMirrored(v); return; } + seg.segmentForVideo(v, performance.now(), res => { + const mask = res.confidenceMasks?.[0]; + if (!mask) { drawMirrored(v); return; } + const conf = mask.getAsFloat32Array(); + const mw = mask.width, mh = mask.height; + // mask → alpha canvas + const mc = scratch(maskRef); mc.width = mw; mc.height = mh; + const mctx = mc.getContext('2d'); if (!mctx) return; + const mimg = mctx.createImageData(mw, mh); + for (let i = 0; i < conf.length; i++) { mimg.data[i * 4 + 3] = Math.round(conf[i] * 255); } + mctx.putImageData(mimg, 0, 0); + // person = sharp video masked by the person alpha + const person = scratch(personRef); person.width = w; person.height = h; + const pctx = person.getContext('2d'); if (!pctx) return; + pctx.clearRect(0, 0, w, h); + pctx.drawImage(v, 0, 0, w, h); + pctx.globalCompositeOperation = 'destination-in'; + pctx.drawImage(mc, 0, 0, w, h); + pctx.globalCompositeOperation = 'source-over'; + // background + const work = scratch(workRef); work.width = w; work.height = h; + const wctx = work.getContext('2d'); if (!wctx) return; + wctx.clearRect(0, 0, w, h); + if (e === 'blur-bg') { wctx.filter = 'blur(14px)'; wctx.drawImage(v, 0, 0, w, h); wctx.filter = 'none'; } + else if (bgImgRef.current) wctx.drawImage(bgImgRef.current, 0, 0, w, h); + else { wctx.fillStyle = bg; wctx.fillRect(0, 0, w, h); } + wctx.drawImage(person, 0, 0, w, h); + drawMirrored(work); + }); + }, []); + + const startLoop = useCallback(() => { + if (rafRef.current === null) rafRef.current = requestAnimationFrame(drawFrame); + }, [drawFrame]); + const stopLoop = () => { if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } }; + + // Lazily load the MediaPipe selfie segmenter when a background effect needs it. + useEffect(() => { + if (effect !== 'blur-bg' && effect !== 'replace-bg') return; + if (segRef.current) return; + let cancelled = false; + setModelBusy(true); + (async () => { + try { + const { FilesetResolver, ImageSegmenter } = await import('@mediapipe/tasks-vision'); + const vision = await FilesetResolver.forVisionTasks(new URL('/models/mediapipe/wasm', location.origin).href); + const seg = await ImageSegmenter.createFromOptions(vision, { + baseOptions: { modelAssetPath: new URL('/models/mediapipe/selfie_segmenter.tflite', location.origin).href }, + runningMode: 'VIDEO', + outputConfidenceMasks: true, + outputCategoryMask: false, + }); + if (cancelled) { seg.close(); return; } + segRef.current = seg as unknown as Segmenter; + } catch { + if (!cancelled) { setError(t.modelFailed); setEffect('none'); } + } finally { + if (!cancelled) setModelBusy(false); + } + })(); + return () => { cancelled = true; }; + }, [effect, t.modelFailed]); + const startCamera = useCallback(async () => { setError(''); if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') { @@ -90,8 +237,7 @@ export default function VideoRecorder({ lang = 'en' }: { lang?: Lang }) { audio: withAudio ? (micId ? { deviceId: { exact: micId } } : true) : false, }); streamRef.current = stream; - if (videoRef.current) { videoRef.current.srcObject = stream; void videoRef.current.play().catch(() => {}); } - // Labels are only populated after permission is granted. + if (videoRef.current) { videoRef.current.srcObject = stream; await videoRef.current.play().catch(() => {}); } const devices = await navigator.mediaDevices.enumerateDevices(); setCams(devices.filter(d => d.kind === 'videoinput')); setMics(devices.filter(d => d.kind === 'audioinput')); @@ -100,28 +246,37 @@ export default function VideoRecorder({ lang = 'en' }: { lang?: Lang }) { if (vTrack && !camId) setCamId(vTrack.getSettings().deviceId ?? ''); if (aTrack && !micId) setMicId(aTrack.getSettings().deviceId ?? ''); setStatus('live'); + startLoop(); } catch (e) { const name = (e as DOMException)?.name; setError(name === 'NotFoundError' || name === 'OverconstrainedError' ? t.notfound : t.denied); setStatus('idle'); } - }, [camId, micId, withAudio, resolution, stopStream, t]); + }, [camId, micId, withAudio, resolution, stopStream, startLoop, t]); - // Re-acquire when device/resolution/audio changes while previewing. useEffect(() => { if (status === 'live') void startCamera(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [camId, micId, withAudio, resolution]); + const onPickBgImage = (file: File) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { bgImgRef.current = img; URL.revokeObjectURL(url); }; + img.src = url; + }; + const tick = () => setElapsed(accumRef.current + (Date.now() - startRef.current)); const beginRecording = useCallback(() => { - const stream = streamRef.current; - if (!stream) return; + const canvas = canvasRef.current, srcStream = streamRef.current; + if (!canvas || !srcStream) return; const { mime, ext } = pickRecordingType(); extRef.current = ext; chunksRef.current = []; - const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined); + const canvasStream = canvas.captureStream(30); + srcStream.getAudioTracks().forEach(tk => canvasStream.addTrack(tk)); + const rec = new MediaRecorder(canvasStream, mime ? { mimeType: mime } : undefined); rec.ondataavailable = e => { if (e.data.size > 0) chunksRef.current.push(e.data); }; rec.onstop = () => { const blob = new Blob(chunksRef.current, { type: chunksRef.current[0]?.type || 'video/webm' }); @@ -147,10 +302,7 @@ export default function VideoRecorder({ lang = 'en' }: { lang?: Lang }) { setCount(3); clearCd(); cdRef.current = window.setInterval(() => { - setCount(c => { - if (c <= 1) { clearCd(); beginRecording(); return 0; } - return c - 1; - }); + setCount(c => { if (c <= 1) { clearCd(); beginRecording(); return 0; } return c - 1; }); }, 1000); }, [useCountdown, beginRecording]); @@ -158,30 +310,18 @@ export default function VideoRecorder({ lang = 'en' }: { lang?: Lang }) { const rec = recorderRef.current; if (!rec) return; if (rec.state === 'recording') { - rec.pause(); - accumRef.current += Date.now() - startRef.current; - clearTimer(); - setStatus('paused'); + rec.pause(); accumRef.current += Date.now() - startRef.current; clearTimer(); setStatus('paused'); } else if (rec.state === 'paused') { - rec.resume(); - startRef.current = Date.now(); - timerRef.current = window.setInterval(tick, 250); - setStatus('recording'); + rec.resume(); startRef.current = Date.now(); timerRef.current = window.setInterval(tick, 250); setStatus('recording'); } }; const onStop = () => { const rec = recorderRef.current; if (rec && rec.state !== 'inactive') rec.stop(); }; const snapshot = () => { - const v = videoRef.current; - if (!v || !v.videoWidth) return; - const canvas = document.createElement('canvas'); - canvas.width = v.videoWidth; - canvas.height = v.videoHeight; - const ctx = canvas.getContext('2d'); - if (!ctx) return; - ctx.drawImage(v, 0, 0, canvas.width, canvas.height); - canvas.toBlob(b => { if (b) downloadService.download(b, 'snapshot.png'); }, 'image/png'); + const c = canvasRef.current; + if (!c || !c.width) return; + c.toBlob(b => { if (b) downloadService.download(b, 'snapshot.png'); }, 'image/png'); }; const onDownload = () => { @@ -190,49 +330,33 @@ export default function VideoRecorder({ lang = 'en' }: { lang?: Lang }) { const recordAgain = () => { if (resultUrl) URL.revokeObjectURL(resultUrl); - setResultUrl(''); - resultBlobRef.current = null; - setElapsed(0); - setStatus('live'); + setResultUrl(''); resultBlobRef.current = null; setElapsed(0); setStatus('live'); }; - // Attach the recorded result to its playback element. useEffect(() => { if (status === 'review' && reviewRef.current && resultUrl) reviewRef.current.src = resultUrl; }, [status, resultUrl]); - // Global cleanup. useEffect(() => () => { - clearTimer(); clearCd(); stopStream(); + clearTimer(); clearCd(); stopLoop(); stopStream(); if (recorderRef.current?.state && recorderRef.current.state !== 'inactive') recorderRef.current.stop(); + segRef.current?.close(); if (resultUrl) URL.revokeObjectURL(resultUrl); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const busy = status === 'recording' || status === 'paused' || status === 'countdown'; + const showBgControls = effect === 'replace-bg' || effect === 'greenscreen'; return (

{t.intro}

- {error && {error}}
- {/* Live preview (hidden while reviewing a result). */} -
- {/* Controls */} {status === 'review' ? (
@@ -262,9 +388,7 @@ export default function VideoRecorder({ lang = 'en' }: { lang?: Lang }) {
) : (
- {status === 'live' && ( - - )} + {status === 'live' && } {(status === 'recording' || status === 'paused') && ( <>
)} - {/* Settings — locked while busy. */} + {/* Effects — usable live and while recording. */} +
+ + {effect === 'filter' && ( + + )} + {showBgControls && ( +
+ + +
+ )} + {effect === 'greenscreen' && ( +
+ + +
+ )} +
+ + {/* Camera settings */}
-

{t.mirrorNote}

); } diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index 01f6790..703b7d6 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -3211,16 +3211,18 @@ const en: Record = { 'video-recorder': { title: 'Free Webcam Video Recorder — Record Video Online', description: 'A free online webcam video recorder — record video from your camera and microphone, preview it, and download. 100% in your browser, nothing uploaded.', - intro: 'This free video recorder captures video from your webcam with sound, right in your browser. Pick your camera and microphone, choose a resolution, mirror the preview, record with an optional countdown and pause/resume, grab photo snapshots, then preview and download the clip. Everything is recorded and saved on your device — nothing is uploaded.', + intro: 'This free video recorder captures video from your webcam with sound, right in your browser. Pick your camera and microphone, choose a resolution, mirror the preview, and add effects — a colour filter, a blurred or replaced background, or a green-screen chroma key. Record with an optional countdown and pause/resume, grab photo snapshots, then preview and download the clip. Everything is recorded and saved on your device — nothing is uploaded.', howTo: [ 'Click “Start camera” and allow camera and microphone access.', 'Pick the camera, microphone and resolution you want (mirror the view if you like).', + 'Optionally add an effect — a colour filter, blur/replace background, or green screen.', 'Press Record (optionally after a 3-2-1 countdown); pause/resume or grab a photo any time.', 'Press Stop to preview the clip, then Download it — or Record again.', ], faqs: [ { q: 'Is my video uploaded anywhere?', a: 'No. Recording uses your browser’s camera and MediaRecorder APIs; the video is encoded and saved entirely on your device and never leaves it.' }, { q: 'Can I choose which microphone is used?', a: 'Yes. Once you allow access, both a camera and a microphone picker appear, so you can select exactly which devices record. You can also record video without audio.' }, + { q: 'Can I blur or replace my background?', a: 'Yes. Choose “Blur background” or “Replace background” and an on-device selfie-segmentation model separates you from the scene — no green screen needed. There is also a real green-screen (chroma-key) mode. It all runs in your browser.' }, { q: 'What format is the download?', a: 'Recordings download as WebM (VP9/VP8 with Opus audio), the format browsers record natively. To convert to MP4 you can use the Video Converter tool.' }, { q: 'Does it work on my phone?', a: 'Yes, in a supported mobile browser — allow camera/mic access and it records from the phone camera. Add it to your home screen to use it like an app.' }, { q: 'Does it work offline?', a: 'Yes. As a PWA it keeps working with no connection once loaded; recording never needs the network.' }, @@ -6639,16 +6641,18 @@ const id: Record = { 'video-recorder': { title: 'Perekam Video Webcam Gratis — Rekam Video Online', description: 'Perekam video webcam online gratis — rekam video dari kamera dan mikrofon Anda, pratinjau, dan unduh. 100% di browser Anda, tidak ada yang diunggah.', - intro: 'Perekam video gratis ini menangkap video dari webcam Anda dengan suara, langsung di browser. Pilih kamera dan mikrofon, pilih resolusi, cerminkan pratinjau, rekam dengan hitung mundur opsional dan jeda/lanjut, ambil foto snapshot, lalu pratinjau dan unduh klipnya. Semuanya direkam dan disimpan di perangkat Anda — tidak ada yang diunggah.', + intro: 'Perekam video gratis ini menangkap video dari webcam Anda dengan suara, langsung di browser. Pilih kamera dan mikrofon, pilih resolusi, cerminkan pratinjau, dan tambahkan efek — filter warna, latar buram atau ganti latar, atau green-screen chroma key. Rekam dengan hitung mundur opsional dan jeda/lanjut, ambil foto snapshot, lalu pratinjau dan unduh klipnya. Semuanya direkam dan disimpan di perangkat Anda — tidak ada yang diunggah.', howTo: [ 'Klik “Mulai kamera” dan izinkan akses kamera serta mikrofon.', 'Pilih kamera, mikrofon, dan resolusi yang Anda inginkan (cerminkan tampilan bila perlu).', + 'Opsional tambahkan efek — filter warna, buramkan/ganti latar, atau green screen.', 'Tekan Rekam (opsional setelah hitung mundur 3-2-1); jeda/lanjut atau ambil foto kapan saja.', 'Tekan Berhenti untuk pratinjau klip, lalu Unduh — atau Rekam lagi.', ], faqs: [ { q: 'Apakah video saya diunggah ke suatu tempat?', a: 'Tidak. Perekaman memakai API kamera dan MediaRecorder browser Anda; video dikodekan dan disimpan sepenuhnya di perangkat Anda dan tidak pernah keluar.' }, { q: 'Bisakah memilih mikrofon yang digunakan?', a: 'Bisa. Setelah Anda mengizinkan akses, picker kamera dan mikrofon muncul, jadi Anda bisa memilih perangkat mana yang merekam. Anda juga bisa merekam video tanpa audio.' }, + { q: 'Bisakah memburamkan atau mengganti latar?', a: 'Bisa. Pilih “Buramkan latar” atau “Ganti latar” dan model segmentasi selfie di perangkat memisahkan Anda dari latar — tanpa green screen. Ada juga mode green-screen (chroma key) sungguhan. Semuanya berjalan di browser Anda.' }, { q: 'Format unduhannya apa?', a: 'Rekaman diunduh sebagai WebM (VP9/VP8 dengan audio Opus), format yang direkam browser secara native. Untuk konversi ke MP4 gunakan tool Video Converter.' }, { q: 'Apakah bekerja di ponsel saya?', a: 'Ya, di browser seluler yang didukung — izinkan akses kamera/mikrofon dan ia merekam dari kamera ponsel. Tambahkan ke layar utama untuk memakainya seperti aplikasi.' }, { q: 'Apakah bekerja offline?', a: 'Ya. Sebagai PWA tetap berjalan tanpa koneksi setelah dimuat; perekaman tidak pernah butuh jaringan.' }, diff --git a/src/tools/media/video-effects.lib.test.ts b/src/tools/media/video-effects.lib.test.ts new file mode 100644 index 0000000..4833ae8 --- /dev/null +++ b/src/tools/media/video-effects.lib.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { filterCss, hexToRgb, applyChromaKey, FILTER_CSS } from './video-effects.lib'; + +describe('filterCss', () => { + it('maps presets to CSS filter strings', () => { + expect(filterCss('none')).toBe('none'); + expect(filterCss('grayscale')).toBe('grayscale(1)'); + expect(filterCss('invert')).toBe('invert(1)'); + }); + it('covers every preset', () => { + for (const k of Object.keys(FILTER_CSS)) expect(typeof filterCss(k as never)).toBe('string'); + }); +}); + +describe('hexToRgb', () => { + it.each([ + ['#00b140', { r: 0, g: 177, b: 64 }], + ['#fff', { r: 255, g: 255, b: 255 }], + ['000000', { r: 0, g: 0, b: 0 }], + ['nothex', { r: 0, g: 0, b: 0 }], + ])('%s', (hex, rgb) => expect(hexToRgb(hex)).toEqual(rgb)); +}); + +describe('applyChromaKey', () => { + it('zeroes alpha for pixels near the key color and keeps others', () => { + // pixel 0: pure green (key) → removed; pixel 1: red → kept. + const data = new Uint8ClampedArray([0, 177, 64, 255, 255, 0, 0, 255]); + applyChromaKey(data, { r: 0, g: 177, b: 64 }, 40); + expect(data[3]).toBe(0); // green pixel now transparent + expect(data[7]).toBe(255); // red pixel untouched + }); + + it('threshold widens the match', () => { + const near = new Uint8ClampedArray([20, 190, 80, 255]); // close-ish to key + applyChromaKey(near, { r: 0, g: 177, b: 64 }, 10); + expect(near[3]).toBe(255); // too far for a tight threshold + const near2 = new Uint8ClampedArray([20, 190, 80, 255]); + applyChromaKey(near2, { r: 0, g: 177, b: 64 }, 60); + expect(near2[3]).toBe(0); // within a loose threshold + }); +}); diff --git a/src/tools/media/video-effects.lib.ts b/src/tools/media/video-effects.lib.ts new file mode 100644 index 0000000..2e44141 --- /dev/null +++ b/src/tools/media/video-effects.lib.ts @@ -0,0 +1,50 @@ +/** + * Pure helpers for the Video Recorder's effects pipeline (filters, green-screen + * chroma key). DOM-free so they unit-test cleanly; the island owns the canvas, + * camera stream and MediaPipe segmenter. + */ + +export type EffectKind = 'none' | 'filter' | 'blur-bg' | 'replace-bg' | 'greenscreen'; + +export type FilterPreset = 'none' | 'grayscale' | 'sepia' | 'vivid' | 'cool' | 'warm' | 'invert'; + +export const FILTER_CSS: Record = { + none: 'none', + grayscale: 'grayscale(1)', + sepia: 'sepia(0.85)', + vivid: 'saturate(1.6) contrast(1.08)', + cool: 'hue-rotate(-18deg) saturate(1.2) brightness(1.05)', + warm: 'sepia(0.35) saturate(1.35) brightness(1.05)', + invert: 'invert(1)', +}; + +export function filterCss(p: FilterPreset): string { + return FILTER_CSS[p] ?? 'none'; +} + +export interface RGB { + r: number; + g: number; + b: number; +} + +/** Parse `#rrggbb` (or `#rgb`) to RGB. Falls back to black on bad input. */ +export function hexToRgb(hex: string): RGB { + let h = hex.replace('#', '').trim(); + if (h.length === 3) h = h.split('').map(c => c + c).join(''); + if (!/^[0-9a-fA-F]{6}$/.test(h)) return { r: 0, g: 0, b: 0 }; + return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) }; +} + +/** + * Green-screen chroma key: makes pixels within `threshold` (0–441, Euclidean + * RGB distance) of `key` transparent, in place. Higher threshold removes more. + */ +export function applyChromaKey(data: Uint8ClampedArray, key: RGB, threshold: number): void { + for (let i = 0; i < data.length; i += 4) { + const dr = data[i] - key.r; + const dg = data[i + 1] - key.g; + const db = data[i + 2] - key.b; + if (Math.sqrt(dr * dr + dg * dg + db * db) <= threshold) data[i + 3] = 0; + } +}