diff --git a/e2e/tools/video-recorder.spec.ts b/e2e/tools/video-recorder.spec.ts new file mode 100644 index 0000000..55b49f9 --- /dev/null +++ b/e2e/tools/video-recorder.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from '@playwright/test'; + +// Drive the real getUserMedia + MediaRecorder flow with Chromium's synthetic +// camera/mic so no hardware is needed. +test.use({ + permissions: ['camera', 'microphone'], + launchOptions: { + args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'], + }, +}); + +test('records 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 () => { + await start.click(); + await expect(record).toBeVisible({ timeout: 3000 }); + }).toPass({ timeout: 30_000 }); + + // Record a short clip. + 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/src/islands/media/VideoRecorder.tsx b/src/islands/media/VideoRecorder.tsx new file mode 100644 index 0000000..51fed46 --- /dev/null +++ b/src/islands/media/VideoRecorder.tsx @@ -0,0 +1,316 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Video, Circle, Pause, Play, Square, Camera, Download, RotateCcw } 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 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', + 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.', + }, + 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', + 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.', + }, +}; + +export default function VideoRecorder({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(''); + const [cams, setCams] = useState([]); + const [mics, setMics] = useState([]); + const [camId, setCamId] = useState(''); + const [micId, setMicId] = useState(''); + const [withAudio, setWithAudio] = useState(true); + const [resolution, setResolution] = useState('720p'); + const [mirror, setMirror] = useState(true); + const [useCountdown, setUseCountdown] = useState(false); + const [count, setCount] = useState(0); + const [elapsed, setElapsed] = useState(0); + const [resultUrl, setResultUrl] = useState(''); + + const videoRef = useRef(null); + const reviewRef = useRef(null); + const streamRef = useRef(null); + const recorderRef = useRef(null); + const chunksRef = useRef([]); + const resultBlobRef = useRef(null); + const extRef = useRef('webm'); + const timerRef = useRef(null); + const startRef = useRef(0); + const accumRef = useRef(0); + const cdRef = useRef(null); + + const stopStream = useCallback(() => { + streamRef.current?.getTracks().forEach(tk => tk.stop()); + streamRef.current = null; + }, []); + + 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. + const startCamera = useCallback(async () => { + setError(''); + if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') { + setError(t.unsupported); + return; + } + stopStream(); + const { width, height } = resolutionConstraint(resolution); + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { deviceId: camId ? { exact: camId } : undefined, width: { ideal: width }, height: { ideal: height } }, + 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. + const devices = await navigator.mediaDevices.enumerateDevices(); + setCams(devices.filter(d => d.kind === 'videoinput')); + setMics(devices.filter(d => d.kind === 'audioinput')); + const vTrack = stream.getVideoTracks()[0]; + const aTrack = stream.getAudioTracks()[0]; + if (vTrack && !camId) setCamId(vTrack.getSettings().deviceId ?? ''); + if (aTrack && !micId) setMicId(aTrack.getSettings().deviceId ?? ''); + setStatus('live'); + } 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]); + + // 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 tick = () => setElapsed(accumRef.current + (Date.now() - startRef.current)); + + const beginRecording = useCallback(() => { + const stream = streamRef.current; + if (!stream) return; + const { mime, ext } = pickRecordingType(); + extRef.current = ext; + chunksRef.current = []; + const rec = new MediaRecorder(stream, 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' }); + resultBlobRef.current = blob; + const url = URL.createObjectURL(blob); + setResultUrl(url); + setStatus('review'); + clearTimer(); + }; + recorderRef.current = rec; + accumRef.current = 0; + startRef.current = Date.now(); + setElapsed(0); + rec.start(); + clearTimer(); + timerRef.current = window.setInterval(tick, 250); + setStatus('recording'); + }, []); + + const onRecord = useCallback(() => { + if (!useCountdown) { beginRecording(); return; } + setStatus('countdown'); + setCount(3); + clearCd(); + cdRef.current = window.setInterval(() => { + setCount(c => { + if (c <= 1) { clearCd(); beginRecording(); return 0; } + return c - 1; + }); + }, 1000); + }, [useCountdown, beginRecording]); + + const onPause = () => { + const rec = recorderRef.current; + if (!rec) return; + if (rec.state === 'recording') { + 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'); + } + }; + + 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 onDownload = () => { + if (resultBlobRef.current) downloadService.download(resultBlobRef.current, `video-recording.${extRef.current}`); + }; + + const recordAgain = () => { + if (resultUrl) URL.revokeObjectURL(resultUrl); + 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(); + if (recorderRef.current?.state && recorderRef.current.state !== 'inactive') recorderRef.current.stop(); + if (resultUrl) URL.revokeObjectURL(resultUrl); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const busy = status === 'recording' || status === 'paused' || status === 'countdown'; + + return ( +
+

{t.intro}

+ + {error && {error}} + +
+ {/* Live preview (hidden while reviewing a result). */} +
+ + {/* Controls */} + {status === 'review' ? ( +
+ + +
+ ) : ( +
+ {status === 'live' && ( + + )} + {(status === 'recording' || status === 'paused') && ( + <> + + + + )} + {(status === 'live' || status === 'recording' || status === 'paused') && ( + + )} +
+ )} + + {/* Settings — locked while busy. */} +
+ + + +
+ + + +
+
+

{t.mirrorNote}

+
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index 6d97ed1..01f6790 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -3208,6 +3208,24 @@ const en: Record = { { q: 'Why does the first conversion take longer?', a: 'The first run downloads the roughly 31 MB audio engine once. After that it\'s cached, so subsequent conversions start much faster.' }, ], }, + '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.', + howTo: [ + 'Click “Start camera” and allow camera and microphone access.', + 'Pick the camera, microphone and resolution you want (mirror the view if you like).', + '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: '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.' }, + ], + }, 'screen-recorder': { title: 'Free Screen Recorder Tool — Record Screen & Tab', description: 'A free online screen recorder tool to capture your screen, window or tab with optional mic audio — 100% private. Everything is recorded in your browser, nothing uploaded.', @@ -6618,6 +6636,24 @@ const id: Record = { { q: 'Mengapa konversi pertama memerlukan waktu lebih lama?', a: 'Proses pertama mengunduh mesin audio berukuran sekitar 31 MB sekali. Setelah itu ia di-cache, sehingga konversi berikutnya dimulai jauh lebih cepat.' }, ], }, + '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.', + howTo: [ + 'Klik “Mulai kamera” dan izinkan akses kamera serta mikrofon.', + 'Pilih kamera, mikrofon, dan resolusi yang Anda inginkan (cerminkan tampilan bila perlu).', + '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: '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.' }, + ], + }, 'screen-recorder': { title: 'Tool Perekam Layar Gratis — Rekam Layar & Tab', description: 'Tool perekam layar daring gratis untuk merekam layar, jendela, atau tab Anda dengan audio mikrofon opsional — 100% privat. Semuanya direkam di browser Anda, tidak ada yang diunggah.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 9115b87..dd39dd8 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -2082,6 +2082,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/media/ScreenRecorder'), status: 'stable' }, + { + id: 'video-recorder', + name: 'Video Recorder', + category: 'Media', + route: '/tools/video-recorder', + keywords: ['video recorder', 'webcam recorder', 'record video', 'record webcam', 'camera recorder', 'record from camera', 'webcam video', 'record mic', 'online video recorder', 'record yourself'], + icon: Video, + summary: 'Record video from your webcam & mic — preview and download', + load: () => import('@/islands/media/VideoRecorder'), + status: 'beta' + }, { id: 'screenshot', name: 'Screenshot', diff --git a/src/tools/media/video-recorder.lib.test.ts b/src/tools/media/video-recorder.lib.test.ts new file mode 100644 index 0000000..19006f9 --- /dev/null +++ b/src/tools/media/video-recorder.lib.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { pickRecordingType, formatDuration, resolutionConstraint } from './video-recorder.lib'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('pickRecordingType', () => { + it('falls back when MediaRecorder is unavailable (jsdom)', () => { + expect(pickRecordingType()).toEqual({ mime: '', ext: 'webm' }); + }); + + it('picks the first supported candidate', () => { + vi.stubGlobal('MediaRecorder', { + isTypeSupported: (m: string) => m === 'video/webm;codecs=vp8,opus' || m === 'video/webm', + }); + expect(pickRecordingType()).toEqual({ mime: 'video/webm;codecs=vp8,opus', ext: 'webm' }); + }); + + it('prefers vp9 when supported', () => { + vi.stubGlobal('MediaRecorder', { isTypeSupported: () => true }); + expect(pickRecordingType()).toEqual({ mime: 'video/webm;codecs=vp9,opus', ext: 'webm' }); + }); + + it('uses mp4 when only mp4 is supported', () => { + vi.stubGlobal('MediaRecorder', { isTypeSupported: (m: string) => m === 'video/mp4' }); + expect(pickRecordingType()).toEqual({ mime: 'video/mp4', ext: 'mp4' }); + }); +}); + +describe('formatDuration', () => { + it.each([ + [0, '0:00'], + [1000, '0:01'], + [61_000, '1:01'], + [600_000, '10:00'], + [3_661_000, '1:01:01'], + [-500, '0:00'], + ])('formatDuration(%i) → %s', (ms, expected) => { + expect(formatDuration(ms)).toBe(expected); + }); +}); + +describe('resolutionConstraint', () => { + it.each([ + ['480p', 854, 480], + ['720p', 1280, 720], + ['1080p', 1920, 1080], + ] as const)('%s → %ix%i', (res, w, h) => { + expect(resolutionConstraint(res)).toEqual({ width: w, height: h }); + }); +}); diff --git a/src/tools/media/video-recorder.lib.ts b/src/tools/media/video-recorder.lib.ts new file mode 100644 index 0000000..9a77f64 --- /dev/null +++ b/src/tools/media/video-recorder.lib.ts @@ -0,0 +1,47 @@ +/** + * Pure helpers for the webcam Video Recorder. Framework- and DOM-light so they + * unit-test cleanly; the island owns the MediaStream / MediaRecorder lifecycle. + */ + +export interface RecordingType { + mime: string; + ext: string; +} + +// Ordered best-first. WebM (VP9/VP8) is what MediaRecorder supports widely; +// mp4 is a fallback for the (few) engines that record it. +const CANDIDATES: RecordingType[] = [ + { mime: 'video/webm;codecs=vp9,opus', ext: 'webm' }, + { mime: 'video/webm;codecs=vp8,opus', ext: 'webm' }, + { mime: 'video/webm', ext: 'webm' }, + { mime: 'video/mp4', ext: 'mp4' }, +]; + +/** The best recording container/codec the current browser supports. */ +export function pickRecordingType(): RecordingType { + const MR = typeof MediaRecorder !== 'undefined' ? MediaRecorder : undefined; + for (const c of CANDIDATES) { + if (MR && MR.isTypeSupported(c.mime)) return c; + } + return { mime: '', ext: 'webm' }; // let the browser choose its default container +} + +/** Elapsed milliseconds → `M:SS` (or `H:MM:SS`). */ +export function formatDuration(ms: number): string { + const total = Math.max(0, Math.floor(ms / 1000)); + const p = (n: number) => String(n).padStart(2, '0'); + const s = total % 60, m = Math.floor(total / 60) % 60, h = Math.floor(total / 3600); + return h > 0 ? `${h}:${p(m)}:${p(s)}` : `${m}:${p(s)}`; +} + +export type Resolution = '480p' | '720p' | '1080p'; + +/** Ideal capture width/height for a resolution preset. */ +export function resolutionConstraint(res: Resolution): { width: number; height: number } { + switch (res) { + case '480p': return { width: 854, height: 480 }; + case '1080p': return { width: 1920, height: 1080 }; + case '720p': + default: return { width: 1280, height: 720 }; + } +}