From 868428b8507f6b52943c6b0b3b84741335ca0ba1 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:12:30 +0700 Subject: [PATCH] feat(media,pdf,calculators): add Video Compressor, Trimmer, PDF Fill & Timer hub Four high-demand, fully client-side tools: - Video Compressor (media/video-compress): compress a video to a target file size (WhatsApp/Discord presets) via ffmpeg.wasm single-pass CBR; pure bitrate-planning lib is unit-tested. - Audio & Video Trimmer (media/media-trim): cut a section from audio or video (MP3 ringtone / video trim), stream-copy for speed with a re-encode fallback; pure time parse/format/validate lib tested. - Fill PDF / Add Text (pdf/pdf-fill): type text, X marks and dates onto any PDF and export with pdf-lib; new textPlacementToPdf geometry + fillPdfText, tested. - Stopwatch, Timer & Alarm (calculators/timer-stopwatch): the everyday stopwatch/timer/alarm trio with a Web Audio beep; pure formatting + next-alarm math tested. Each has EN + ID SEO (title/description/intro/howTo/faqs) and Bahasa card labels. --- src/islands/calculators/TimerHub.tsx | 263 +++++++++++++++++++ src/islands/media/MediaTrim.tsx | 237 +++++++++++++++++ src/islands/media/VideoCompress.tsx | 270 ++++++++++++++++++++ src/islands/pdf/PdfFill.tsx | 261 +++++++++++++++++++ src/registry/tool-i18n.ts | 4 + src/registry/tool-seo.ts | 136 ++++++++++ src/registry/tools.ts | 46 +++- src/tools/calculators/stopwatch.lib.test.ts | 47 ++++ src/tools/calculators/stopwatch.lib.ts | 47 ++++ src/tools/media/trim.lib.test.ts | 54 ++++ src/tools/media/trim.lib.ts | 71 +++++ src/tools/media/video-compress.lib.test.ts | 55 ++++ src/tools/media/video-compress.lib.ts | 87 +++++++ src/tools/pdf/layout.lib.test.ts | 14 +- src/tools/pdf/layout.lib.ts | 29 +++ src/tools/pdf/pdf.lib.ts | 43 +++- 16 files changed, 1661 insertions(+), 3 deletions(-) create mode 100644 src/islands/calculators/TimerHub.tsx create mode 100644 src/islands/media/MediaTrim.tsx create mode 100644 src/islands/media/VideoCompress.tsx create mode 100644 src/islands/pdf/PdfFill.tsx create mode 100644 src/tools/calculators/stopwatch.lib.test.ts create mode 100644 src/tools/calculators/stopwatch.lib.ts create mode 100644 src/tools/media/trim.lib.test.ts create mode 100644 src/tools/media/trim.lib.ts create mode 100644 src/tools/media/video-compress.lib.test.ts create mode 100644 src/tools/media/video-compress.lib.ts diff --git a/src/islands/calculators/TimerHub.tsx b/src/islands/calculators/TimerHub.tsx new file mode 100644 index 0000000..33ce8c2 --- /dev/null +++ b/src/islands/calculators/TimerHub.tsx @@ -0,0 +1,263 @@ +import { useEffect, useRef, useState } from 'react'; +import { Play, Pause, RotateCcw, Flag, Plus, BellOff, X } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { formatStopwatch, formatCountdown, msUntilNext, msOfDay } from '@/tools/calculators/stopwatch.lib'; +import type { Lang } from '@/i18n/config'; + +type Tab = 'stopwatch' | 'timer' | 'alarm'; +interface Alarm { id: number; label: string; hh: number; mm: number; fireAt: number } + +const TR: Record = { + en: { + intro: 'A stopwatch, countdown timer and alarm clock in one — runs in your browser, no sign-in. Keep this tab open and it will beep when the time is up.', + stopwatch: 'Stopwatch', timer: 'Timer', alarm: 'Alarm', + start: 'Start', pause: 'Pause', reset: 'Reset', lap: 'Lap', laps: 'Laps', + min: 'min', sec: 'sec', done: 'Time\'s up!', setAlarm: 'Alarm time', label: 'Label', labelPh: 'Wake up', + add: 'Add alarm', rings: 'Ringing', noAlarms: 'No alarms set.', stop: 'Stop', + privacy: 'Everything runs on your device. The alarm needs this tab to stay open to ring.', + }, + id: { + intro: 'Stopwatch, timer hitung mundur, dan jam alarm dalam satu tool — berjalan di browser, tanpa masuk. Biarkan tab ini terbuka dan tool akan berbunyi saat waktunya habis.', + stopwatch: 'Stopwatch', timer: 'Timer', alarm: 'Alarm', + start: 'Mulai', pause: 'Jeda', reset: 'Reset', lap: 'Lap', laps: 'Lap', + min: 'mnt', sec: 'dtk', done: 'Waktu habis!', setAlarm: 'Waktu alarm', label: 'Label', labelPh: 'Bangun', + add: 'Tambah alarm', rings: 'Berbunyi', noAlarms: 'Belum ada alarm.', stop: 'Hentikan', + privacy: 'Semuanya berjalan di perangkat Anda. Alarm perlu tab ini tetap terbuka agar bisa berbunyi.', + }, +}; + +export default function TimerHub({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [tab, setTab] = useState('stopwatch'); + const [ringing, setRinging] = useState(false); + + // --- shared alarm sound (Web Audio; no asset) --- + const audioRef = useRef(null); + const ringInt = useRef(null); + const stopRinging = () => { + if (ringInt.current !== null) { clearInterval(ringInt.current); ringInt.current = null; } + setRinging(false); + }; + const startRinging = () => { + if (ringInt.current !== null) return; + setRinging(true); + const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + const ctx = audioRef.current ?? (audioRef.current = new Ctx()); + if (ctx.state === 'suspended') void ctx.resume(); + let n = 0; + const beep = () => { + const o = ctx.createOscillator(); + const g = ctx.createGain(); + o.frequency.value = 880; + o.connect(g); g.connect(ctx.destination); + const now = ctx.currentTime; + g.gain.setValueAtTime(0.0001, now); + g.gain.exponentialRampToValueAtTime(0.3, now + 0.02); + g.gain.exponentialRampToValueAtTime(0.0001, now + 0.4); + o.start(now); o.stop(now + 0.4); + if (++n > 40) stopRinging(); // auto-stop after ~24s + }; + beep(); + ringInt.current = window.setInterval(beep, 600); + }; + + // --- stopwatch --- + const [swElapsed, setSwElapsed] = useState(0); + const [swRunning, setSwRunning] = useState(false); + const [laps, setLaps] = useState([]); + const swStart = useRef(0); + const swAccum = useRef(0); + const swInt = useRef(null); + const swTick = () => setSwElapsed(swAccum.current + (Date.now() - swStart.current)); + const swToggle = () => { + if (swRunning) { + swAccum.current += Date.now() - swStart.current; + if (swInt.current !== null) clearInterval(swInt.current); + swInt.current = null; + setSwRunning(false); + } else { + swStart.current = Date.now(); + swInt.current = window.setInterval(swTick, 31); + setSwRunning(true); + } + }; + const swReset = () => { + if (swInt.current !== null) clearInterval(swInt.current); + swInt.current = null; + swAccum.current = 0; + setSwElapsed(0); setSwRunning(false); setLaps([]); + }; + const swLap = () => setLaps(l => [...l, swElapsed]); + + // --- timer --- + const [tMin, setTMin] = useState(5); + const [tSec, setTSec] = useState(0); + const [tRemaining, setTRemaining] = useState(0); + const [tRunning, setTRunning] = useState(false); + const tEnd = useRef(0); + const tInt = useRef(null); + const clearTimer = () => { if (tInt.current !== null) clearInterval(tInt.current); tInt.current = null; }; + const timerStart = () => { + const dur = tRunning ? tRemaining : (tMin * 60 + tSec) * 1000; + if (dur <= 0) return; + stopRinging(); + tEnd.current = Date.now() + dur; + setTRunning(true); + clearTimer(); + tInt.current = window.setInterval(() => { + const rem = tEnd.current - Date.now(); + if (rem <= 0) { setTRemaining(0); setTRunning(false); clearTimer(); startRinging(); } + else setTRemaining(rem); + }, 100); + }; + const timerPause = () => { clearTimer(); setTRunning(false); }; + const timerReset = () => { clearTimer(); setTRunning(false); setTRemaining(0); stopRinging(); }; + + // --- alarm --- + const [alarmTime, setAlarmTime] = useState('07:00'); + const [alarmLabel, setAlarmLabel] = useState(''); + const [alarms, setAlarms] = useState([]); + const alarmId = useRef(1); + const addAlarm = () => { + const m = alarmTime.match(/^(\d{1,2}):(\d{2})$/); + if (!m) return; + const hh = Math.min(23, Number(m[1])); + const mm = Math.min(59, Number(m[2])); + const fireAt = Date.now() + msUntilNext(msOfDay(new Date()), hh, mm); + setAlarms(a => [...a, { id: alarmId.current++, label: alarmLabel.trim(), hh, mm, fireAt }].sort((x, y) => x.fireAt - y.fireAt)); + setAlarmLabel(''); + }; + const removeAlarm = (id: number) => setAlarms(a => a.filter(x => x.id !== id)); + + // One interval watches all alarms while any exist. + useEffect(() => { + if (alarms.length === 0) return; + const id = window.setInterval(() => { + const now = Date.now(); + const due = alarms.filter(a => a.fireAt <= now); + if (due.length > 0) { + startRinging(); + setAlarms(a => a.filter(x => x.fireAt > now)); + } + }, 1000); + return () => clearInterval(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [alarms]); + + // Global cleanup. + useEffect(() => () => { + if (swInt.current !== null) clearInterval(swInt.current); + if (tInt.current !== null) clearInterval(tInt.current); + if (ringInt.current !== null) clearInterval(ringInt.current); + void audioRef.current?.close(); + }, []); + + const fmtHM = (a: Alarm) => `${String(a.hh).padStart(2, '0')}:${String(a.mm).padStart(2, '0')}`; + + return ( +
+

{t.intro}

+ +
+ {(['stopwatch', 'timer', 'alarm'] as const).map(x => ( + + ))} +
+ + {ringing && ( +
+ {t.done} + +
+ )} + + {tab === 'stopwatch' && ( +
+

{formatStopwatch(swElapsed)}

+
+ + + +
+ {laps.length > 0 && ( +
    +
  1. {t.laps}
  2. + {laps.map((l, i) => ( +
  3. + #{i + 1} + {formatStopwatch(l)} + +{formatStopwatch(l - (laps[i - 1] ?? 0))} +
  4. + ))} +
+ )} +
+ )} + + {tab === 'timer' && ( +
+

+ {formatCountdown(tRunning || tRemaining > 0 ? tRemaining : (tMin * 60 + tSec) * 1000)} +

+ {!tRunning && tRemaining === 0 && ( +
+ + +
+ )} +
+ {!tRunning + ? + : } + +
+
+ )} + + {tab === 'alarm' && ( +
+
+ + + +
+ {alarms.length === 0 + ?

{t.noAlarms}

+ : ( +
    + {alarms.map(a => ( +
  • + {fmtHM(a)} + {a.label && {a.label}} + +
  • + ))} +
+ )} +
+ )} + +

{t.privacy}

+
+ ); +} diff --git a/src/islands/media/MediaTrim.tsx b/src/islands/media/MediaTrim.tsx new file mode 100644 index 0000000..d57a6d8 --- /dev/null +++ b/src/islands/media/MediaTrim.tsx @@ -0,0 +1,237 @@ +import { useEffect, useRef, useState } from 'react'; +import { Download } from 'lucide-react'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import { ProgressBar } from '@/components/ui/ProgressBar'; +import { downloadService } from '@/services/download'; +import { formatBytes } from '@/tools/image/canvas.lib'; +import { loadFFmpeg, fileToU8 } from '@/services/ffmpeg.service'; +import { parseTime, formatTime, validateTrim, clampTrim } from '@/tools/media/trim.lib'; +import type { Lang } from '@/i18n/config'; + +const TR: Record = { + en: { + dropTitle: 'Drop an audio or video file, or click to browse', + dropSubtitle: 'Cut out a section — trim a video or make an MP3 ringtone, in your browser', + start: 'Start', end: 'End', grabTime: 'Use current', + selection: 'Selection', badRange: 'End must be after start.', badBounds: 'Times must be within the clip.', tooShort: 'Selection is too short.', + fast: 'Fast (no re-encode)', fastHelp: 'lossless, cuts at nearest keyframe', + privacy: 'Runs entirely in your browser via ffmpeg.wasm — the file never leaves your device.', + trim: 'Trim', trimming: 'Trimming…', clear: 'Clear', + working: 'Working…', loadEngine: 'Loading media engine (first run downloads ~31 MB)…', cutting: 'Cutting…', + result: 'Result', download: 'Download', error: 'Could not trim this file.', + }, + id: { + dropTitle: 'Letakkan file audio atau video, atau klik untuk memilih', + dropSubtitle: 'Potong satu bagian — pangkas video atau buat ringtone MP3, di browser Anda', + start: 'Mulai', end: 'Akhir', grabTime: 'Pakai posisi', + selection: 'Seleksi', badRange: 'Akhir harus setelah mulai.', badBounds: 'Waktu harus dalam durasi klip.', tooShort: 'Seleksi terlalu pendek.', + fast: 'Cepat (tanpa encode ulang)', fastHelp: 'lossless, memotong di keyframe terdekat', + privacy: 'Berjalan sepenuhnya di browser Anda via ffmpeg.wasm — file tidak pernah keluar dari perangkat Anda.', + trim: 'Potong', trimming: 'Memotong…', clear: 'Bersihkan', + working: 'Memproses…', loadEngine: 'Memuat mesin media (unduhan pertama ~31 MB)…', cutting: 'Memotong…', + result: 'Hasil', download: 'Unduh', error: 'Tidak dapat memotong file ini.', + }, +}; + +export default function MediaTrim({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [file, setFile] = useState(null); + const [srcUrl, setSrcUrl] = useState(''); + const [isVideo, setIsVideo] = useState(true); + const [duration, setDuration] = useState(0); + const [startStr, setStartStr] = useState('0:00'); + const [endStr, setEndStr] = useState('0:00'); + const [fast, setFast] = useState(true); + const [result, setResult] = useState(null); + const [resultUrl, setResultUrl] = useState(''); + const [busy, setBusy] = useState(false); + const [stage, setStage] = useState(''); + const [percent, setPercent] = useState(0); + const [error, setError] = useState(''); + const playerRef = useRef(null); + + useEffect(() => () => { if (srcUrl) URL.revokeObjectURL(srcUrl); }, [srcUrl]); + useEffect(() => () => { if (resultUrl) URL.revokeObjectURL(resultUrl); }, [resultUrl]); + + const start = parseTime(startStr); + const end = parseTime(endStr); + const valid = start !== null && end !== null && duration > 0 + ? validateTrim({ start, end, duration }) + : { ok: false as const }; + + const onDrop = (files: File[]) => { + const media = files.find(f => f.type.startsWith('video/') || f.type.startsWith('audio/')); + if (!media) return; + setFile(media); + setIsVideo(media.type.startsWith('video/')); + setResult(null); + setError(''); + setDuration(0); + setStartStr('0:00'); + setEndStr('0:00'); + setSrcUrl(prev => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(media); }); + }; + + const onMeta = () => { + const d = playerRef.current?.duration || 0; + setDuration(d); + setEndStr(formatTime(d)); + }; + + const grabTime = (which: 'start' | 'end') => { + const cur = playerRef.current?.currentTime ?? 0; + if (which === 'start') setStartStr(formatTime(cur)); + else setEndStr(formatTime(cur)); + }; + + const run = async () => { + if (!file || start === null || end === null) return; + const { start: s, end: e } = clampTrim(start, end, duration); + setBusy(true); + setError(''); + setResult(null); + setPercent(0); + const ext = (file.name.match(/\.([^.]+)$/)?.[1] || (isVideo ? 'mp4' : 'mp3')).toLowerCase(); + const out = `out.${ext}`; + try { + setStage(t.loadEngine); + const ffmpeg = await loadFFmpeg(); + const onProgress = ({ progress }: { progress: number }) => + setPercent(Math.min(100, Math.round(progress * 100))); + ffmpeg.on('progress', onProgress); + await ffmpeg.writeFile('in', await fileToU8(file)); + + const seek = ['-ss', String(s), '-to', String(e), '-i', 'in']; + const reencode = async () => { + // Re-encode the selection for a frame-precise cut (or when copy fails). + const enc = isVideo + ? ['-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-c:a', 'aac'] + : ['-c:a', 'libmp3lame', '-q:a', '2']; + const reOut = isVideo ? out : 'out.mp3'; + await ffmpeg.exec([...seek, ...enc, reOut]); + await finish(ffmpeg, reOut, reOut.split('.').pop()!, onProgress); + }; + setStage(t.cutting); + if (!fast) { + await reencode(); + } else { + try { + await ffmpeg.exec([...seek, '-c', 'copy', out]); + const probe = await ffmpeg.readFile(out); + if (!probe || (probe as Uint8Array).length === 0) throw new Error('empty'); + await finish(ffmpeg, out, ext, onProgress); + } catch { + await reencode(); // stream copy failed → fall back to a re-encode + } + } + } catch (err) { + setError(err instanceof Error && err.message ? err.message : t.error); + setBusy(false); + setStage(''); + } + }; + + const finish = async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ffmpeg: any, out: string, ext: string, onProgress: (p: { progress: number }) => void, + ) => { + const data = await ffmpeg.readFile(out); + ffmpeg.off('progress', onProgress); + const mime = isVideo ? `video/${ext === 'mov' ? 'quicktime' : ext}` : `audio/${ext === 'mp3' ? 'mpeg' : ext}`; + const blob = new Blob([data], { type: mime }); + setResult(blob); + setResultUrl(prev => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(blob); }); + setBusy(false); + setStage(''); + }; + + const download = () => { + if (!result || !file) return; + const ext = result.type.startsWith('audio/') ? (result.type === 'audio/mpeg' ? 'mp3' : result.type.split('/')[1]) : (file.name.match(/\.([^.]+)$/)?.[1] || 'mp4'); + downloadService.download(result, file.name.replace(/\.[^.]+$/, '') + '-trimmed.' + ext); + }; + + const errText = !valid.ok && start !== null && end !== null && duration > 0 + ? (valid.error === 'range' ? t.badRange : valid.error === 'bounds' ? t.badBounds : t.tooShort) + : ''; + + return ( +
+ +
+

{t.dropTitle}

+

{t.dropSubtitle}

+
+
+ + {file && srcUrl && ( +
+

+ {file.name} — {formatBytes(file.size)} + {duration > 0 && <> · {formatTime(duration)}} +

+ {isVideo + ?
+ )} + +

{t.privacy}

+ +
+ + +
+ + {errText && !busy && {errText}} + {busy && } + {error && {error}} + + {result && resultUrl && !busy && ( +
+
+ {t.result} + {formatBytes(result.size)} +
+ {isVideo + ?
+ )} +
+ ); +} diff --git a/src/islands/media/VideoCompress.tsx b/src/islands/media/VideoCompress.tsx new file mode 100644 index 0000000..ceadc79 --- /dev/null +++ b/src/islands/media/VideoCompress.tsx @@ -0,0 +1,270 @@ +import { useEffect, useState } from 'react'; +import { Download } from 'lucide-react'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import { ProgressBar } from '@/components/ui/ProgressBar'; +import { downloadService } from '@/services/download'; +import { formatBytes } from '@/tools/image/canvas.lib'; +import { loadFFmpeg, fileToU8 } from '@/services/ffmpeg.service'; +import { computeTargetBitrate, VIDEO_TARGET_PRESETS } from '@/tools/media/video-compress.lib'; +import { targetToBytes, pctSmaller, type SizeUnit } from '@/tools/files/compress-target.lib'; +import type { Lang } from '@/i18n/config'; + +/** Read a video's duration (seconds) from its metadata, in the browser. */ +function getVideoDuration(file: File): Promise { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const v = document.createElement('video'); + v.preload = 'metadata'; + v.onloadedmetadata = () => { URL.revokeObjectURL(url); resolve(v.duration || 0); }; + v.onerror = () => { URL.revokeObjectURL(url); reject(new Error('metadata')); }; + v.src = url; + }); +} + +const TR: Record string; download: string; error: string; +}> = { + en: { + dropTitle: 'Drop a video or click to browse', + dropSubtitle: 'Shrink a video to a target file size — right in your browser, nothing uploaded', + target: 'Target size', + custom: 'Custom', + keepAudio: 'Keep audio', + maxWidth: 'Max width (px)', + widthKeep: 'keep', + widthHelp: '0 = original', + privacy: 'Runs entirely in your browser via ffmpeg.wasm — the video never leaves your device. Encoding is CPU-bound; long or high-resolution clips take a while.', + compress: 'Compress', + compressing: 'Compressing…', + clear: 'Clear', + working: 'Working…', + loadEngine: 'Loading video engine (first run downloads ~31 MB)…', + encoding: 'Encoding…', + duration: 'Duration', + estimate: 'Estimated output', + overBudget: 'This target is very small for the clip length — the result may be larger than the target and low quality. Try a shorter clip, smaller width, or a bigger target.', + result: 'Result', + smaller: (p) => `${p}% smaller`, + download: 'Download MP4', + error: 'Could not compress this video.', + }, + id: { + dropTitle: 'Letakkan video atau klik untuk memilih', + dropSubtitle: 'Perkecil video ke ukuran file target — langsung di browser Anda, tanpa unggahan', + target: 'Ukuran target', + custom: 'Kustom', + keepAudio: 'Simpan audio', + maxWidth: 'Lebar maks (px)', + widthKeep: 'tetap', + widthHelp: '0 = asli', + privacy: 'Berjalan sepenuhnya di browser Anda via ffmpeg.wasm — video tidak pernah keluar dari perangkat Anda. Encoding bergantung pada CPU; klip yang panjang atau beresolusi tinggi butuh waktu.', + compress: 'Kompres', + compressing: 'Mengompres…', + clear: 'Bersihkan', + working: 'Memproses…', + loadEngine: 'Memuat mesin video (unduhan pertama ~31 MB)…', + encoding: 'Encoding…', + duration: 'Durasi', + estimate: 'Perkiraan output', + overBudget: 'Target ini sangat kecil untuk durasi klip — hasilnya mungkin lebih besar dari target dan berkualitas rendah. Coba klip lebih pendek, lebar lebih kecil, atau target lebih besar.', + result: 'Hasil', + smaller: (p) => `${p}% lebih kecil`, + download: 'Unduh MP4', + error: 'Tidak dapat mengompres video ini.', + }, +}; + +export default function VideoCompress({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [file, setFile] = useState(null); + const [duration, setDuration] = useState(0); + const [presetIdx, setPresetIdx] = useState(1); // default 16 MB (WhatsApp) + const [customValue, setCustomValue] = useState(10); + const [customUnit, setCustomUnit] = useState('MB'); + const [keepAudio, setKeepAudio] = useState(true); + const [maxWidth, setMaxWidth] = useState(0); + const [result, setResult] = useState(null); + const [resultUrl, setResultUrl] = useState(''); + const [busy, setBusy] = useState(false); + const [stage, setStage] = useState(''); + const [percent, setPercent] = useState(0); + const [error, setError] = useState(''); + + useEffect(() => () => { if (resultUrl) URL.revokeObjectURL(resultUrl); }, [resultUrl]); + + const isCustom = presetIdx < 0; + const targetBytes = isCustom ? targetToBytes(customValue, customUnit) : VIDEO_TARGET_PRESETS[presetIdx].bytes; + const plan = duration > 0 && targetBytes > 0 + ? computeTargetBitrate({ targetBytes, durationSec: duration, audioKbps: keepAudio ? 128 : 0 }) + : null; + + const onDrop = async (files: File[]) => { + const video = files.find(f => f.type.startsWith('video/')); + if (!video) return; + setFile(video); + setResult(null); + setError(''); + setDuration(0); + try { + setDuration(await getVideoDuration(video)); + } catch { + setError(t.error); + } + }; + + const run = async () => { + if (!file || !plan) return; + setBusy(true); + setError(''); + setResult(null); + setPercent(0); + try { + setStage(t.loadEngine); + const ffmpeg = await loadFFmpeg(); + const onProgress = ({ progress }: { progress: number }) => + setPercent(Math.min(100, Math.round(progress * 100))); + ffmpeg.on('progress', onProgress); + + await ffmpeg.writeFile('in', await fileToU8(file)); + + const args = ['-i', 'in']; + if (maxWidth > 0) args.push('-vf', `scale='min(${maxWidth},iw)':-2:flags=lanczos`); + // Single-pass constrained bitrate — reliably lands near (and under) target. + args.push( + '-c:v', 'libx264', + '-b:v', `${plan.videoKbps}k`, + '-maxrate', `${plan.videoKbps}k`, + '-bufsize', `${plan.videoKbps * 2}k`, + '-preset', 'veryfast', + '-pix_fmt', 'yuv420p', + '-movflags', '+faststart', + ); + if (plan.audioKbps > 0) args.push('-c:a', 'aac', '-b:a', `${plan.audioKbps}k`); + else args.push('-an'); + args.push('out.mp4'); + + setStage(t.encoding); + await ffmpeg.exec(args); + + const data = await ffmpeg.readFile('out.mp4'); + ffmpeg.off('progress', onProgress); + const blob = new Blob([data], { type: 'video/mp4' }); + setResult(blob); + setResultUrl(prev => { + if (prev) URL.revokeObjectURL(prev); + return URL.createObjectURL(blob); + }); + } catch (e) { + setError(e instanceof Error && e.message ? e.message : t.error); + } finally { + setBusy(false); + setStage(''); + } + }; + + const download = () => { + if (!result || !file) return; + downloadService.download(result, file.name.replace(/\.[^.]+$/, '') + '-compressed.mp4'); + }; + + return ( +
+ +
+

{t.dropTitle}

+

{t.dropSubtitle}

+
+
+ + {file && ( +

+ {file.name} — {formatBytes(file.size)} + {duration > 0 && <> · {t.duration} {Math.round(duration)}s} +

+ )} + +
+ + + {isCustom && ( + + )} + + + + +
+ + {plan && ( +

+ {t.estimate}: ~{formatBytes(plan.estimatedBytes)} +

+ )} + {plan?.overBudget && {t.overBudget}} + +

{t.privacy}

+ +
+ + +
+ + {busy && } + {error && {error}} + + {result && resultUrl && !busy && ( +
+
+ {t.result} + {formatBytes(result.size)} + {file && result.size < file.size && ( + {t.smaller(pctSmaller(file.size, result.size))} + )} +
+
+ )} +
+ ); +} diff --git a/src/islands/pdf/PdfFill.tsx b/src/islands/pdf/PdfFill.tsx new file mode 100644 index 0000000..fcdfad6 --- /dev/null +++ b/src/islands/pdf/PdfFill.tsx @@ -0,0 +1,261 @@ +import { useEffect, useRef, useState } from 'react'; +import { Type, Check, Calendar, Trash2, GripVertical } from 'lucide-react'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import { ResultActions } from '@/components/ui/ResultActions'; +import { openPdfRenderer, type PdfRenderer } from '@/tools/pdf/render.lib'; +import { fillPdfText } from '@/tools/pdf/pdf.lib'; +import type { TextPlacement } from '@/tools/pdf/layout.lib'; +import type { Lang } from '@/i18n/config'; + +interface Field extends TextPlacement { id: number } + +const DEFAULT_SIZE_RATIO = 0.018; + +const TR: Record = { + en: { + intro: 'Fill in a PDF form or add text anywhere on a PDF — type, add checkmarks and dates, drag them into place, and download. Everything runs in your browser; nothing is uploaded.', + drop: 'Drop a PDF or click to browse', dropSub: 'Filled on your device', + failed: 'Something went wrong.', + addText: 'Add text', addCheck: 'Add ✕ mark', addDate: 'Add date', + toolHint: 'Then click on the page where it should go.', + page: 'Page', prev: 'Prev', next: 'Next', size: 'Size', noFields: 'Add a field to get started.', + apply: 'Apply & download', working: 'Applying…', delete: 'Delete', + signNote: 'Need a handwritten signature? Use the Sign PDF tool.', + }, + id: { + intro: 'Isi formulir PDF atau tambahkan teks di mana saja pada PDF — ketik, tambahkan tanda centang dan tanggal, seret ke posisinya, lalu unduh. Semua berjalan di browser Anda; tidak ada yang diunggah.', + drop: 'Letakkan PDF atau klik untuk memilih', dropSub: 'Diisi di perangkat Anda', + failed: 'Terjadi kesalahan.', + addText: 'Tambah teks', addCheck: 'Tambah tanda ✕', addDate: 'Tambah tanggal', + toolHint: 'Lalu klik di halaman tempat elemen itu diletakkan.', + page: 'Halaman', prev: 'Sebelumnya', next: 'Berikutnya', size: 'Ukuran', noFields: 'Tambahkan field untuk memulai.', + apply: 'Terapkan & unduh', working: 'Menerapkan…', delete: 'Hapus', + signNote: 'Perlu tanda tangan tulisan tangan? Gunakan tool Sign PDF.', + }, +}; + +export default function PdfFill({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [file, setFile] = useState(null); + const [pageCount, setPageCount] = useState(0); + const [pageNum, setPageNum] = useState(1); + const [pageUrl, setPageUrl] = useState(''); + const [pageDisplayH, setPageDisplayH] = useState(0); + const [fields, setFields] = useState([]); + const [selected, setSelected] = useState(null); + const [tool, setTool] = useState(null); + const [result, setResult] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const rendererRef = useRef(null); + const pageBoxRef = useRef(null); + const imgRef = useRef(null); + const nextId = useRef(1); + const dragRef = useRef<{ id: number; dx: number; dy: number } | null>(null); + + useEffect(() => () => { rendererRef.current?.destroy(); }, []); + + const renderPage = async (renderer: PdfRenderer, n: number) => { + const page = await renderer.renderPage(n, 1.4); + setPageUrl(prev => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(page.blob); }); + }; + + const onDrop = async (files: File[]) => { + const f = files.find(x => x.type === 'application/pdf' || x.name.toLowerCase().endsWith('.pdf')); + if (!f) return; + setFile(f); setResult(null); setError(''); setFields([]); setSelected(null); + try { + const renderer = await openPdfRenderer(await f.arrayBuffer()); + rendererRef.current = renderer; + setPageCount(renderer.pageCount); + setPageNum(1); + await renderPage(renderer, 1); + } catch (e) { + setError(e instanceof Error ? e.message : t.failed); + } + }; + + const goPage = async (n: number) => { + if (!rendererRef.current || n < 1 || n > pageCount) return; + setPageNum(n); + await renderPage(rendererRef.current, n); + }; + + const onPageClick = (e: React.MouseEvent) => { + if (!tool) return; + const rect = pageBoxRef.current?.getBoundingClientRect(); + if (!rect) return; + const xRatio = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 0.98); + const yRatio = Math.min(Math.max((e.clientY - rect.top) / rect.height, 0), 0.98); + // 'X' is ASCII (drawable by the standard PDF font) and the conventional + // form checkmark — a real ✓ glyph isn't in Helvetica's encoding. + const text = tool === 'check' ? 'X' : tool === 'date' ? new Date().toLocaleDateString(lang === 'id' ? 'id-ID' : 'en-US') : ''; + const id = nextId.current++; + setFields(f => [...f, { id, pageIndex: pageNum - 1, xRatio, yRatio, text, sizeRatio: DEFAULT_SIZE_RATIO }]); + setSelected(id); + setTool(null); + }; + + const updateField = (id: number, patch: Partial) => + setFields(f => f.map(x => (x.id === id ? { ...x, ...patch } : x))); + + const removeField = (id: number) => + setFields(f => f.filter(x => x.id !== id)); + + const onGripDown = (e: React.PointerEvent, field: Field) => { + e.stopPropagation(); + const rect = pageBoxRef.current?.getBoundingClientRect(); + if (!rect) return; + dragRef.current = { + id: field.id, + dx: e.clientX - (rect.left + field.xRatio * rect.width), + dy: e.clientY - (rect.top + field.yRatio * rect.height), + }; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + setSelected(field.id); + }; + const onGripMove = (e: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + const rect = pageBoxRef.current!.getBoundingClientRect(); + const xRatio = Math.min(Math.max((e.clientX - drag.dx - rect.left) / rect.width, 0), 0.98); + const yRatio = Math.min(Math.max((e.clientY - drag.dy - rect.top) / rect.height, 0), 0.98); + updateField(drag.id, { xRatio, yRatio }); + }; + const onGripUp = () => { dragRef.current = null; }; + + const apply = async () => { + if (!file || fields.length === 0) return; + setBusy(true); setError(''); + try { + setResult(await fillPdfText(file, fields.map(({ id: _id, ...p }) => p))); + } catch (e) { + setError(e instanceof Error ? e.message : t.failed); + } finally { + setBusy(false); + } + }; + + const selectedField = fields.find(f => f.id === selected) ?? null; + const pageFields = fields.filter(f => f.pageIndex === pageNum - 1); + + return ( +
+

{t.intro}

+ + {!file && ( + +
+

{t.drop}

+

{t.dropSub}

+
+
+ )} + + {error && {error}} + + {file && ( +
+
+ {pageUrl && ( +
+ {`page setPageDisplayH(imgRef.current?.clientHeight ?? 0)} + className="block max-h-[72vh] w-auto select-none" + draggable={false} + /> + {pageFields.map(field => ( +
{ e.stopPropagation(); setSelected(field.id); }} + > + onGripDown(e, field)} + onPointerMove={onGripMove} + onPointerUp={onGripUp} + className="flex cursor-move items-center bg-accent/80 text-accent-foreground" + style={{ height: `${Math.max(field.sizeRatio * pageDisplayH, 12)}px` }} + > + + + updateField(field.id, { text: e.target.value })} + onFocus={() => setSelected(field.id)} + size={Math.max(field.text.length, 2)} + style={{ fontSize: `${Math.max(field.sizeRatio * pageDisplayH, 10)}px`, lineHeight: 1 }} + className="border border-dashed border-accent bg-white/70 px-0.5 font-sans text-black outline-none" + /> +
+ ))} +
+ )} + +
+ + {t.page} {pageNum} / {pageCount} + +
+
+ +
+
+ + + + {tool &&

{t.toolHint}

} +
+ + {selectedField ? ( +
+ + +
+ ) : ( +

{t.noFields}

+ )} + +
+ +
+

{t.signNote}

+
+
+ )} + + {result && } +
+ ); +} diff --git a/src/registry/tool-i18n.ts b/src/registry/tool-i18n.ts index 617fa22..43de4d1 100644 --- a/src/registry/tool-i18n.ts +++ b/src/registry/tool-i18n.ts @@ -68,6 +68,7 @@ const ID_LABELS: Record = { "roman-numerals": { name: "Konverter Angka Romawi", summary: "Konversi angka ke angka Romawi dan sebaliknya (1–3999)" }, "percentage-calculator": { name: "Kalkulator Persentase", summary: "Persentase, tip, dan diskon — bagi tagihan, potongan %, & kembalian" }, "countdown": { name: "Timer Hitung Mundur", summary: "Hitung mundur ke suatu tanggal — sisa hari, jam, menit, & detik" }, + "timer-stopwatch": { name: "Stopwatch, Timer & Alarm", summary: "Stopwatch, timer hitung mundur, dan jam alarm online" }, "timezone-converter": { name: "Konverter Zona Waktu", summary: "Konversi waktu antar zona & atur jadwal rapat lintas wilayah" }, "favicon-generator": { name: "Generator Favicon", summary: "Ubah gambar menjadi set favicon (ICO, PNG, manifest)" }, "compress-to-size": { name: "Kompres ke Ukuran", summary: "Kompres gambar atau PDF ke ukuran file target (mis. 100 KB)" }, @@ -88,6 +89,7 @@ const ID_LABELS: Record = { "onet": { name: "Onet Connect", summary: "Cocokkan pasangan ubin yang terhubung garis dengan maksimal dua belokan" }, "pdf-organize": { name: "Atur PDF", summary: "Seret untuk menata ulang atau hapus halaman PDF dan tambah nomor halaman" }, "pdf-sign": { name: "Tanda Tangani PDF", summary: "Gambar atau unggah tanda tangan dan tempatkan pada PDF" }, + "pdf-fill": { name: "Isi PDF (Tambah Teks)", summary: "Ketik teks, centang, dan tanggal ke formulir PDF" }, "pdf-redact": { name: "Redaksi PDF", summary: "Hapus permanen teks dan gambar sensitif dari PDF" }, "pdf-scrub-metadata": { name: "Pembersih Metadata PDF", summary: "Hapus metadata penulis, tanggal, dan XMP tersembunyi dari PDF" }, "pdf-to-excel": { name: "PDF ke Excel (CSV)", summary: "Ekstrak tabel dan teks PDF ke CSV untuk Excel (sebisa mungkin)" }, @@ -168,6 +170,8 @@ const ID_LABELS: Record = { "live-captions": { name: "Teks Langsung", summary: "Teks langsung berukuran besar dari ucapan (menggunakan pengenalan suara browser)" }, "teleprompter": { name: "Teleprompter", summary: "Baca naskah di layar dengan gulir otomatis, pelacakan suara, mode cermin dan pratinjau kamera" }, "video-convert": { name: "Konverter Video", summary: "Konversi, kompres, potong atau ubah ukuran video (sisi klien)" }, + "video-compress": { name: "Kompres Video", summary: "Kompres video ke ukuran file target (sisi klien)" }, + "media-trim": { name: "Pemotong Audio & Video", summary: "Potong bagian dari audio atau video (sisi klien)" }, "video-to-audio": { name: "Video → Audio", summary: "Ekstrak trek audio dari video (sisi klien)" }, "audio-convert": { name: "Konverter Audio", summary: "Konversi, enkode ulang atau potong file audio (sisi klien)" }, "screen-recorder": { name: "Perekam Layar", summary: "Rekam layar, jendela atau tab Anda (sisi klien)" }, diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index cf3f8ed..0e605e4 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -689,6 +689,23 @@ const en: Record = { { q: 'Does it work offline?', a: 'Yes. As a PWA it keeps working with no connection once loaded.' }, ], }, + 'timer-stopwatch': { + title: 'Online Stopwatch, Timer & Alarm Clock — Free', + description: 'A free online stopwatch with laps, a countdown timer, and an alarm clock — all in one. Runs in your browser and beeps when the time is up. Nothing to install.', + intro: 'This free tool combines a stopwatch (with lap times), a countdown timer, and an alarm clock in one page. It runs entirely in your browser and plays a beep when the timer reaches zero or an alarm time arrives — just keep the tab open.', + howTo: [ + 'Pick a tab: Stopwatch, Timer, or Alarm.', + 'Stopwatch: click Start, use Lap to record splits, and Reset to clear.', + 'Timer: set minutes and seconds, click Start, and it beeps at zero.', + 'Alarm: choose a clock time (and an optional label), click Add alarm, and it rings at that time.', + ], + faqs: [ + { q: 'Does it work offline?', a: 'Yes. Everything runs in your browser with no server, and once the page is loaded it keeps working offline. The tab needs to stay open for the timer or alarm to ring.' }, + { q: 'Why does the alarm only ring while the tab is open?', a: 'The sound is played by this page, so the browser tab must stay open (and not fully asleep) for the beep to fire. Keep it in a visible or background tab.' }, + { q: 'Is there a lap timer?', a: 'Yes. On the Stopwatch tab, click Lap while it runs to record each split; the list shows both the total time and the time since the previous lap.' }, + { q: 'How is this different from the Countdown Timer?', a: 'The Countdown Timer counts down to a future date (like a deadline or event). This tool is for short, everyday timing — a stopwatch, a minutes/seconds timer, and a wake-up alarm.' }, + ], + }, 'countdown': { title: 'Countdown Timer — Days Until Any Date', description: 'Count down to any date and time and see the days, hours, minutes and seconds left — deadlines, birthdays, launches and holidays. Free and in your browser.', @@ -1043,6 +1060,23 @@ const en: Record = { { q: 'Does it work offline?', a: 'Yes. As a PWA it keeps working with no connection once loaded.' }, ], }, + 'pdf-fill': { + title: 'Fill PDF Form Free — Add Text to a PDF Online', + description: 'Fill in a PDF form or add text anywhere on a PDF — type text, checkmarks and dates, drag them into place, and download. Runs in your browser; nothing is uploaded.', + intro: 'This free tool lets you fill in a PDF form or add text to any PDF without special software. Drop your PDF, click where you want to write, and type — add ✕ marks for checkboxes and stamp today\'s date too. Everything happens in your browser, so the document never leaves your device.', + howTo: [ + 'Drop a PDF onto the box or click to browse for one.', + 'Click "Add text", then click on the page where the text should go and type it.', + 'Use "Add ✕ mark" for checkboxes and "Add date" for today\'s date; drag any field by its grip to reposition it, and adjust its size.', + 'Click Apply & download to get your filled PDF.', + ], + faqs: [ + { q: 'Is my PDF uploaded to a server?', a: 'No. The PDF is rendered and edited entirely in your browser, so it never leaves your device — safe for contracts, forms and other private documents.' }, + { q: 'Can I fill in a form that has no interactive fields?', a: 'Yes. This tool overlays your own text anywhere on the page, so it works on flat/scanned forms that have no fillable fields — just click and type where you need to.' }, + { q: 'Can I add a checkbox tick or a date?', a: 'Yes. "Add ✕ mark" drops an X you can place in a checkbox, and "Add date" stamps today\'s date. You can edit or resize either afterwards.' }, + { q: 'How do I add a handwritten signature?', a: 'This tool adds typed text. For a drawn or uploaded signature, use the Sign PDF tool, which places a signature image onto the page.' }, + ], + }, 'pdf-sign': { title: 'Free Sign PDF — Add Your Signature to a PDF Online', description: 'Sign a PDF in your browser: draw or upload your signature, drag it onto the page, and download the signed file. Private — nothing is uploaded.', @@ -2727,6 +2761,40 @@ const en: Record = { { q: 'Does it work offline?', a: 'The built-in system voices generally work offline; some browsers stream certain higher-quality voices, which then need a connection.' }, ], }, + 'media-trim': { + title: 'Free Audio & Video Trimmer — Cut Clips & Make MP3 Ringtones', + description: 'Trim or cut a section from a video or audio file — a fast MP3 cutter and video trimmer that runs in your browser. Nothing is uploaded; the file stays on your device.', + intro: 'This free trimmer cuts a section out of any audio or video file. Set the start and end (or grab them from the player), and it exports just that part with ffmpeg running in your browser — great for making an MP3 ringtone or trimming a clip before sharing. Your file is never uploaded.', + howTo: [ + 'Drop an audio or video file onto the box, or click to browse.', + 'Play it, then set Start and End — type the times or click "Use current" at the playhead.', + 'Leave "Fast (no re-encode)" ticked for a quick lossless cut, or untick it for a frame-precise cut.', + 'Click Trim, then preview and download the trimmed file.', + ], + faqs: [ + { q: 'Is my file uploaded to a server?', a: 'No. Trimming runs entirely in your browser via ffmpeg.wasm, so the audio or video never leaves your device.' }, + { q: 'Can I make a phone ringtone?', a: 'Yes. Drop an audio file (or a video — the audio is kept), set the section you want, and export it. Audio is saved as MP3, which works as a ringtone on most phones.' }, + { q: 'What does "Fast (no re-encode)" do?', a: 'It copies the media streams instead of re-encoding, so the cut is instant and lossless — but it snaps to the nearest keyframe, so the start can be off by a fraction of a second. Untick it for an exact cut (slower, re-encodes).' }, + { q: 'Which formats can I trim?', a: 'Common video (MP4, WebM, MOV) and audio (MP3, M4A, WAV, Opus) files. The first run downloads the ~31 MB engine, then it is cached.' }, + ], + }, + 'video-compress': { + title: 'Free Video Compressor — Compress Video to a Target Size', + description: 'Compress a video to a target file size (e.g. under 25 MB for Discord or 16 MB for WhatsApp) right in your browser. Nothing is uploaded — the video stays on your device.', + intro: 'This free video compressor shrinks a clip to a file size you choose — pick a preset like 16 MB (WhatsApp) or 25 MB (Discord), or type your own target, and it calculates the right bitrate and re-encodes to MP4 with ffmpeg running in your browser. Your video is never uploaded.', + howTo: [ + 'Drop a video onto the box or click to browse for one.', + 'Pick a target size — 8/16/25/50/100 MB — or choose Custom and type your own.', + 'Optionally set a maximum width to shrink further, or untick Keep audio.', + 'Click Compress, watch the progress bar, then preview and download the smaller MP4.', + ], + faqs: [ + { q: 'Is my video uploaded to a server?', a: 'No. Compression runs entirely in your browser via ffmpeg.wasm, so the video never leaves your device — ideal for private clips or large files.' }, + { q: 'How does compressing to a target size work?', a: 'The tool reads your clip\'s length, works out the bitrate that fits your chosen size, and encodes to that bitrate. The result usually lands just under the target so it clears upload limits like WhatsApp or Discord.' }, + { q: 'Why is the result sometimes bigger than my target?', a: 'If the target is very small for a long clip, video quality would be unusable, so a minimum quality is kept and the tool warns you. Try a shorter clip, a smaller width, or a larger target.' }, + { q: 'Why is the first run slow?', a: 'The first compression downloads the video engine (about 31 MB) and encoding is CPU-bound, so long or high-resolution clips take longer. After the first load the engine is cached for next time.' }, + ], + }, 'video-convert': { title: 'Free Video Converter Tool — Convert & Compress', description: 'A free online video converter tool to convert, compress, trim, or resize video to MP4, WebM, or MOV. Runs in your browser — nothing is uploaded.', @@ -3669,6 +3737,23 @@ const id: Record = { { q: 'Apakah bekerja offline?', a: 'Ya. Sebagai PWA tetap berjalan tanpa koneksi setelah dimuat.' }, ], }, + 'timer-stopwatch': { + title: 'Stopwatch, Timer & Jam Alarm Online — Gratis', + description: 'Stopwatch online gratis dengan lap, timer hitung mundur, dan jam alarm — semua dalam satu. Berjalan di browser Anda dan berbunyi saat waktunya habis. Tanpa instalasi.', + intro: 'Tool gratis ini menggabungkan stopwatch (dengan waktu lap), timer hitung mundur, dan jam alarm dalam satu halaman. Berjalan sepenuhnya di browser Anda dan memainkan bunyi bip saat timer mencapai nol atau waktu alarm tiba — cukup biarkan tab tetap terbuka.', + howTo: [ + 'Pilih tab: Stopwatch, Timer, atau Alarm.', + 'Stopwatch: klik Mulai, pakai Lap untuk mencatat split, dan Reset untuk menghapus.', + 'Timer: atur menit dan detik, klik Mulai, dan tool berbunyi saat nol.', + 'Alarm: pilih waktu jam (dan label opsional), klik Tambah alarm, dan tool berbunyi pada waktu itu.', + ], + faqs: [ + { q: 'Apakah berfungsi offline?', a: 'Ya. Semuanya berjalan di browser Anda tanpa server, dan setelah halaman dimuat tool tetap berfungsi offline. Tab perlu tetap terbuka agar timer atau alarm bisa berbunyi.' }, + { q: 'Mengapa alarm hanya berbunyi saat tab terbuka?', a: 'Suara dimainkan oleh halaman ini, jadi tab browser harus tetap terbuka (dan tidak benar-benar tidur) agar bip berbunyi. Biarkan di tab yang terlihat atau di latar belakang.' }, + { q: 'Apakah ada lap timer?', a: 'Ya. Di tab Stopwatch, klik Lap saat berjalan untuk mencatat setiap split; daftar menampilkan total waktu dan waktu sejak lap sebelumnya.' }, + { q: 'Apa bedanya dengan Timer Hitung Mundur?', a: 'Timer Hitung Mundur menghitung mundur ke tanggal di masa depan (seperti tenggat atau acara). Tool ini untuk pengukuran waktu sehari-hari yang singkat — stopwatch, timer menit/detik, dan alarm bangun.' }, + ], + }, 'countdown': { title: 'Timer Hitung Mundur — Berapa Hari Lagi ke Tanggal Apa Pun', description: 'Hitung mundur ke tanggal dan waktu apa pun serta lihat hari, jam, menit, dan detik tersisa — tenggat, ulang tahun, peluncuran, dan liburan. Gratis dan di browser Anda.', @@ -4023,6 +4108,23 @@ const id: Record = { { q: 'Apakah bekerja offline?', a: 'Ya. Sebagai PWA tetap berjalan tanpa koneksi setelah dimuat.' }, ], }, + 'pdf-fill': { + title: 'Isi Formulir PDF Gratis — Tambah Teks ke PDF Online', + description: 'Isi formulir PDF atau tambahkan teks di mana saja pada PDF — ketik teks, tanda centang, dan tanggal, seret ke posisinya, lalu unduh. Berjalan di browser Anda; tidak ada yang diunggah.', + intro: 'Tool gratis ini memungkinkan Anda mengisi formulir PDF atau menambahkan teks ke PDF apa pun tanpa software khusus. Jatuhkan PDF Anda, klik tempat Anda ingin menulis, lalu ketik — tambahkan tanda ✕ untuk kotak centang dan cap tanggal hari ini juga. Semuanya terjadi di browser Anda, jadi dokumen tidak pernah keluar dari perangkat Anda.', + howTo: [ + 'Jatuhkan PDF ke dalam kotak atau klik untuk memilihnya.', + 'Klik "Tambah teks", lalu klik di halaman tempat teks diletakkan dan ketik.', + 'Gunakan "Tambah tanda ✕" untuk kotak centang dan "Tambah tanggal" untuk tanggal hari ini; seret field lewat gripnya untuk memindahkan, dan atur ukurannya.', + 'Klik Terapkan & unduh untuk mendapatkan PDF yang sudah diisi.', + ], + faqs: [ + { q: 'Apakah PDF saya diunggah ke server?', a: 'Tidak. PDF dirender dan diedit sepenuhnya di browser Anda, jadi tidak pernah keluar dari perangkat Anda — aman untuk kontrak, formulir, dan dokumen pribadi lainnya.' }, + { q: 'Bisakah mengisi formulir yang tidak punya field interaktif?', a: 'Bisa. Tool ini menempatkan teks Anda di mana saja pada halaman, jadi berfungsi pada formulir datar/hasil scan yang tidak punya field isian — cukup klik dan ketik di tempat yang Anda perlukan.' }, + { q: 'Bisakah menambahkan centang atau tanggal?', a: 'Bisa. "Tambah tanda ✕" meletakkan X yang bisa Anda tempatkan di kotak centang, dan "Tambah tanggal" mencap tanggal hari ini. Keduanya bisa diedit atau diubah ukurannya.' }, + { q: 'Bagaimana menambahkan tanda tangan tulisan tangan?', a: 'Tool ini menambahkan teks ketikan. Untuk tanda tangan gambar atau unggahan, gunakan tool Sign PDF yang menempatkan gambar tanda tangan pada halaman.' }, + ], + }, 'pdf-sign': { title: 'Sign PDF Gratis — Tambahkan Tanda Tangan ke PDF Online', description: 'Tandatangani PDF di browser Anda: gambar atau unggah tanda tangan, seret ke halaman, lalu unduh berkasnya. Privat — tidak ada yang diunggah.', @@ -5707,6 +5809,40 @@ const id: Record = { { q: 'Apakah bekerja offline?', a: 'Voice sistem bawaan umumnya bekerja offline; sebagian browser mengalirkan voice berkualitas lebih tinggi tertentu, yang lalu membutuhkan koneksi.' }, ], }, + 'media-trim': { + title: 'Pemotong Audio & Video Gratis — Potong Klip & Buat Ringtone MP3', + description: 'Potong satu bagian dari file video atau audio — pemotong MP3 dan pemangkas video cepat yang berjalan di browser Anda. Tidak ada yang diunggah; file tetap di perangkat Anda.', + intro: 'Tool pemotong gratis ini mengambil satu bagian dari file audio atau video mana pun. Atur waktu mulai dan akhir (atau ambil dari pemutar), lalu tool mengekspor bagian itu saja dengan ffmpeg yang berjalan di browser Anda — cocok untuk membuat ringtone MP3 atau memangkas klip sebelum dibagikan. File Anda tidak pernah diunggah.', + howTo: [ + 'Jatuhkan file audio atau video ke dalam kotak, atau klik untuk memilih.', + 'Putar, lalu atur Mulai dan Akhir — ketik waktunya atau klik "Pakai posisi" di posisi pemutar.', + 'Biarkan "Cepat (tanpa encode ulang)" tercentang untuk potongan lossless yang cepat, atau hilangkan centang untuk potongan presisi.', + 'Klik Potong, lalu pratinjau dan unduh file hasil pemotongan.', + ], + faqs: [ + { q: 'Apakah file saya diunggah ke server?', a: 'Tidak. Pemotongan berjalan sepenuhnya di browser Anda melalui ffmpeg.wasm, jadi audio atau video tidak pernah keluar dari perangkat Anda.' }, + { q: 'Bisakah saya membuat ringtone HP?', a: 'Bisa. Jatuhkan file audio (atau video — audionya diambil), atur bagian yang Anda inginkan, lalu ekspor. Audio disimpan sebagai MP3, yang bisa dipakai sebagai ringtone di sebagian besar HP.' }, + { q: 'Apa fungsi "Cepat (tanpa encode ulang)"?', a: 'Ini menyalin stream media alih-alih meng-encode ulang, jadi potongan instan dan lossless — tetapi menempel ke keyframe terdekat, sehingga awalnya bisa meleset sepersekian detik. Hilangkan centang untuk potongan presisi (lebih lambat, encode ulang).' }, + { q: 'Format apa saja yang bisa dipotong?', a: 'Video umum (MP4, WebM, MOV) dan audio (MP3, M4A, WAV, Opus). Proses pertama mengunduh engine ~31 MB, lalu tersimpan di cache.' }, + ], + }, + 'video-compress': { + title: 'Kompres Video Gratis — Perkecil Video ke Ukuran Target', + description: 'Kompres video ke ukuran file target (misalnya di bawah 25 MB untuk Discord atau 16 MB untuk WhatsApp) langsung di browser Anda. Tidak ada yang diunggah — video tetap di perangkat Anda.', + intro: 'Tool kompres video gratis ini memperkecil klip ke ukuran file yang Anda pilih — pilih preset seperti 16 MB (WhatsApp) atau 25 MB (Discord), atau ketik target Anda sendiri, lalu tool menghitung bitrate yang tepat dan meng-encode ulang ke MP4 dengan ffmpeg yang berjalan di browser Anda. Video Anda tidak pernah diunggah.', + howTo: [ + 'Jatuhkan video ke dalam kotak atau klik untuk memilihnya.', + 'Pilih ukuran target — 8/16/25/50/100 MB — atau pilih Custom dan ketik target Anda sendiri.', + 'Opsional, atur lebar maksimum untuk memperkecil lebih jauh, atau hilangkan centang Keep audio.', + 'Klik Compress, amati bilah progres, lalu pratinjau dan unduh MP4 yang lebih kecil.', + ], + faqs: [ + { q: 'Apakah video saya diunggah ke server?', a: 'Tidak. Kompresi berjalan sepenuhnya di browser Anda melalui ffmpeg.wasm, jadi video tidak pernah meninggalkan perangkat Anda — ideal untuk klip pribadi atau file besar.' }, + { q: 'Bagaimana cara kerja kompres ke ukuran target?', a: 'Tool membaca durasi klip Anda, menghitung bitrate yang pas untuk ukuran pilihan Anda, lalu meng-encode ke bitrate itu. Hasilnya biasanya sedikit di bawah target agar lolos batas unggah seperti WhatsApp atau Discord.' }, + { q: 'Mengapa hasilnya kadang lebih besar dari target?', a: 'Jika target sangat kecil untuk klip yang panjang, kualitas video akan tidak layak, jadi kualitas minimum dipertahankan dan tool memberi peringatan. Coba klip lebih pendek, lebar lebih kecil, atau target lebih besar.' }, + { q: 'Mengapa proses pertama lambat?', a: 'Kompresi pertama mengunduh engine video (sekitar 31 MB) dan encoding bergantung pada CPU, jadi klip yang panjang atau beresolusi tinggi memerlukan waktu lebih lama. Setelah pemuatan pertama, engine tersimpan di cache.' }, + ], + }, 'video-convert': { title: 'Tool Konverter Video Gratis — Konversi & Kompres', description: 'Tool konverter video online gratis untuk mengonversi, mengompres, memotong, atau mengubah ukuran video ke MP4, WebM, atau MOV. Berjalan di browser Anda — tidak ada yang diunggah.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 227b3a1..c7c3a59 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -1,4 +1,4 @@ -import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock, ClipboardPaste, PlugZap, Regex, Contact, Wallet, Network, Subtitles, Presentation, SquareUser, WholeWord, Percent, Baseline, CaseSensitive, Brush, AppWindow, ListOrdered, FileSignature, Shrink, Cake, Ruler, Timer, Highlighter, Gauge, Speech, Accessibility, Tags, Link2Off, Home, HeartHandshake, Gift, Barcode, Disc3, Sticker, Glasses, HeartPulse, BookCopy, Users, Grip, MailOpen, Scan, Activity, Grid3x3, Bird, ServerCog, Pilcrow, MonitorSmartphone, Volume2, Monitor, MousePointerClick, ListChecks, Landmark, Hourglass, Globe, Smile, StickyNote, Waves, Music4, ScanBarcode, Brain, ToyBrick, Footprints, Rabbit, ListMusic, Link2 } from 'lucide-react'; +import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock, ClipboardPaste, PlugZap, Regex, Contact, Wallet, Network, Subtitles, Presentation, SquareUser, WholeWord, Percent, Baseline, CaseSensitive, Brush, AppWindow, ListOrdered, FileSignature, Shrink, Cake, Ruler, Timer, Highlighter, Gauge, Speech, Accessibility, Tags, Link2Off, Home, HeartHandshake, Gift, Barcode, Disc3, Sticker, Glasses, HeartPulse, BookCopy, Users, Grip, MailOpen, Scan, Activity, Grid3x3, Bird, ServerCog, Pilcrow, MonitorSmartphone, Volume2, Monitor, MousePointerClick, ListChecks, Landmark, Hourglass, Globe, Smile, StickyNote, Waves, Music4, ScanBarcode, Brain, ToyBrick, Footprints, Rabbit, ListMusic, Link2, AlarmClock } from 'lucide-react'; import type { ToolDef } from '@/types/tool'; export const tools: ToolDef[] = [ @@ -674,6 +674,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/calculators/Countdown'), status: 'beta' }, + { + id: 'timer-stopwatch', + name: 'Stopwatch, Timer & Alarm', + category: 'Calculators', + route: '/tools/timer-stopwatch', + keywords: ['stopwatch', 'online stopwatch', 'timer', 'online timer', 'countdown timer', 'alarm clock', 'online alarm', 'set a timer', 'kitchen timer', 'lap timer'], + icon: AlarmClock, + summary: 'Online stopwatch, countdown timer and alarm clock', + load: () => import('@/islands/calculators/TimerHub'), + status: 'beta' + }, { id: 'timezone-converter', name: 'Time Zone Converter', @@ -894,6 +905,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/pdf/PdfSign'), status: 'beta' }, + { + id: 'pdf-fill', + name: 'Fill PDF (Add Text)', + category: 'PDF', + route: '/tools/pdf-fill', + keywords: ['fill pdf', 'fill pdf form', 'add text to pdf', 'edit pdf', 'type on pdf', 'pdf form filler', 'write on pdf', 'checkbox', 'insert date'], + icon: PenLine, + summary: 'Type text, checkmarks and dates onto a PDF form', + load: () => import('@/islands/pdf/PdfFill'), + status: 'beta' + }, { id: 'pdf-redact', name: 'Redact PDF', @@ -1774,6 +1796,28 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/media/VideoConvert'), status: 'stable' }, + { + id: 'video-compress', + name: 'Video Compressor', + category: 'Media', + route: '/tools/video-compress', + keywords: ['compress video', 'video compressor', 'reduce video size', 'shrink video', 'compress video for whatsapp', 'compress video for discord', 'video to target size', 'mp4', 'ffmpeg', 'under 25mb', 'under 16mb'], + icon: Shrink, + summary: 'Compress a video to a target file size (client-side)', + load: () => import('@/islands/media/VideoCompress'), + status: 'beta' + }, + { + id: 'media-trim', + name: 'Audio & Video Trimmer', + category: 'Media', + route: '/tools/media-trim', + keywords: ['trim video', 'cut video', 'video cutter', 'mp3 cutter', 'audio trimmer', 'ringtone maker', 'cut audio', 'clip', 'ffmpeg', 'crop video length'], + icon: Scissors, + summary: 'Trim or cut a section from audio or video (client-side)', + load: () => import('@/islands/media/MediaTrim'), + status: 'beta' + }, { id: 'video-to-audio', name: 'Video → Audio', diff --git a/src/tools/calculators/stopwatch.lib.test.ts b/src/tools/calculators/stopwatch.lib.test.ts new file mode 100644 index 0000000..d85863f --- /dev/null +++ b/src/tools/calculators/stopwatch.lib.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { formatStopwatch, formatCountdown, msUntilNext, msOfDay } from './stopwatch.lib'; + +describe('formatStopwatch', () => { + it.each([ + [0, '0:00.00'], + [1234, '0:01.23'], + [61000, '1:01.00'], + [3661000, '1:01:01.00'], + [-50, '0:00.00'], + ])('formats %d ms → %s', (ms, expected) => { + expect(formatStopwatch(ms)).toBe(expected); + }); +}); + +describe('formatCountdown', () => { + it.each([ + [0, '0:00'], + [5000, '0:05'], + [4200, '0:05'], // rounds up to the second + [65000, '1:05'], + [3661000, '1:01:01'], + ])('formats %d ms → %s', (ms, expected) => { + expect(formatCountdown(ms)).toBe(expected); + }); +}); + +describe('msUntilNext', () => { + const at = (h: number, m = 0) => (h * 60 + m) * 60_000; + + it('counts to a later time today', () => { + expect(msUntilNext(at(10), 10, 30)).toBe(30 * 60_000); + }); + it('rolls over to tomorrow when the time has passed', () => { + expect(msUntilNext(at(10), 9, 0)).toBe(23 * 3600_000); + }); + it('schedules exactly-now for the next day', () => { + expect(msUntilNext(at(10), 10, 0)).toBe(86_400_000); + }); +}); + +describe('msOfDay', () => { + it('reduces a Date to ms since local midnight', () => { + const d = new Date(2026, 0, 1, 1, 2, 3, 500); + expect(msOfDay(d)).toBe(((1 * 60 + 2) * 60 + 3) * 1000 + 500); + }); +}); diff --git a/src/tools/calculators/stopwatch.lib.ts b/src/tools/calculators/stopwatch.lib.ts new file mode 100644 index 0000000..92bc072 --- /dev/null +++ b/src/tools/calculators/stopwatch.lib.ts @@ -0,0 +1,47 @@ +/** + * Pure time-formatting + alarm-scheduling helpers for the Stopwatch / Timer / + * Alarm hub. Kept free of timers and DOM so they can be unit-tested; the ticking + * intervals, audio and notifications live in the island. + */ + +const pad = (n: number) => String(n).padStart(2, '0'); + +/** Format elapsed milliseconds as `M:SS.cs` (or `H:MM:SS.cs`) for a stopwatch. */ +export function formatStopwatch(ms: number): string { + if (!(ms > 0)) ms = 0; + const cs = Math.floor((ms % 1000) / 10); + const totalSec = Math.floor(ms / 1000); + const s = totalSec % 60; + const m = Math.floor(totalSec / 60) % 60; + const h = Math.floor(totalSec / 3600); + const base = h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; + return `${base}.${pad(cs)}`; +} + +/** Format remaining milliseconds as `M:SS` (or `H:MM:SS`), rounded up to the second. */ +export function formatCountdown(ms: number): string { + if (!(ms > 0)) ms = 0; + const totalSec = Math.ceil(ms / 1000); + const s = totalSec % 60; + const m = Math.floor(totalSec / 60) % 60; + const h = Math.floor(totalSec / 3600); + return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; +} + +/** + * Milliseconds from `nowMsOfDay` (ms since local midnight) until the next + * occurrence of the wall-clock time `hh:mm`. If that time has already passed + * today (or is exactly now), it schedules for tomorrow. + */ +export function msUntilNext(nowMsOfDay: number, hh: number, mm: number): number { + const DAY = 86_400_000; + const targetMs = (hh * 60 + mm) * 60_000; + let diff = targetMs - nowMsOfDay; + if (diff <= 0) diff += DAY; + return diff; +} + +/** Convert a Date into milliseconds since local midnight. */ +export function msOfDay(d: Date): number { + return ((d.getHours() * 60 + d.getMinutes()) * 60 + d.getSeconds()) * 1000 + d.getMilliseconds(); +} diff --git a/src/tools/media/trim.lib.test.ts b/src/tools/media/trim.lib.test.ts new file mode 100644 index 0000000..e6aa4c0 --- /dev/null +++ b/src/tools/media/trim.lib.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { parseTime, formatTime, validateTrim, clampTrim } from './trim.lib'; + +describe('parseTime', () => { + it.each([ + ['83', 83], + ['1:23', 83], + ['1:23.5', 83.5], + ['01:02:03', 3723], + ['0:05', 5], + ['12', 12], + ])('parses %s → %d', (input, expected) => { + expect(parseTime(input)).toBe(expected); + }); + + it.each(['', 'abc', '1:60', '1:2:60', '1:99', ':30', '1:2:3:4'])('rejects %s', (input) => { + expect(parseTime(input)).toBeNull(); + }); +}); + +describe('formatTime', () => { + it.each([ + [83, '1:23'], + [83.5, '1:23.5'], + [3723, '1:02:03'], + [5, '0:05'], + [0, '0:00'], + [-4, '0:00'], + ])('formats %d → %s', (input, expected) => { + expect(formatTime(input)).toBe(expected); + }); +}); + +describe('validateTrim', () => { + it('accepts a normal in-bounds range', () => { + expect(validateTrim({ start: 0, end: 10, duration: 60 })).toEqual({ ok: true }); + }); + it('rejects end <= start', () => { + expect(validateTrim({ start: 10, end: 5, duration: 60 }).error).toBe('range'); + }); + it('rejects out-of-bounds end', () => { + expect(validateTrim({ start: 0, end: 100, duration: 60 }).error).toBe('bounds'); + }); + it('rejects a too-short selection', () => { + expect(validateTrim({ start: 1, end: 1.05, duration: 60 }).error).toBe('tooShort'); + }); +}); + +describe('clampTrim', () => { + it('orders and clamps into [0, duration]', () => { + expect(clampTrim(-5, 80, 60)).toEqual({ start: 0, end: 60 }); + expect(clampTrim(40, 10, 60)).toEqual({ start: 10, end: 40 }); + }); +}); diff --git a/src/tools/media/trim.lib.ts b/src/tools/media/trim.lib.ts new file mode 100644 index 0000000..d51b036 --- /dev/null +++ b/src/tools/media/trim.lib.ts @@ -0,0 +1,71 @@ +/** + * Pure time-range helpers for the Audio/Video Trimmer. Parsing, formatting and + * validating the [start, end] selection is all testable without a real decoder; + * the ffmpeg cut itself lives in the island. + */ + +/** Shortest selection we allow (seconds) — avoids empty/degenerate cuts. */ +export const MIN_TRIM_SEC = 0.1; + +/** + * Parse a time string to seconds. Accepts `SS`, `MM:SS`, `HH:MM:SS`, each with + * an optional `.fraction`. Returns null on anything malformed or out of range + * (minutes/seconds must be 0–59). + */ +export function parseTime(input: string): number | null { + const s = input.trim(); + if (!s) return null; + if (!/^\d+(:\d{1,2}){0,2}(\.\d+)?$/.test(s)) return null; + const parts = s.split(':'); + const nums = parts.map(Number); + if (nums.some(n => Number.isNaN(n))) return null; + + let h = 0, m = 0, sec = 0; + if (nums.length === 1) [sec] = nums; + else if (nums.length === 2) [m, sec] = nums; + else [h, m, sec] = nums; + + // For MM:SS / HH:MM:SS forms the sub-fields can't overflow 59. + if (nums.length >= 2 && sec >= 60) return null; + if (nums.length === 3 && m >= 60) return null; + return h * 3600 + m * 60 + sec; +} + +/** Format seconds as `M:SS`, `H:MM:SS`, keeping up to one decimal of fraction. */ +export function formatTime(total: number): string { + if (!Number.isFinite(total) || total < 0) total = 0; + const whole = Math.floor(total); + const frac = total - whole; + const h = Math.floor(whole / 3600); + const m = Math.floor((whole % 3600) / 60); + const s = whole % 60; + const fracStr = frac > 0 ? `.${Math.round(frac * 10)}` : ''; + const ss = String(s).padStart(2, '0'); + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${ss}${fracStr}`; + return `${m}:${ss}${fracStr}`; +} + +export interface TrimRange { + start: number; + end: number; + duration: number; +} +export interface TrimValidation { + ok: boolean; + error?: 'range' | 'bounds' | 'tooShort'; +} + +/** Validate a [start, end] selection against the clip duration. */ +export function validateTrim({ start, end, duration }: TrimRange): TrimValidation { + if (start < 0 || end > duration + 0.05) return { ok: false, error: 'bounds' }; + if (end <= start) return { ok: false, error: 'range' }; + if (end - start < MIN_TRIM_SEC) return { ok: false, error: 'tooShort' }; + return { ok: true }; +} + +/** Clamp a raw [start, end] into a valid, ordered range within [0, duration]. */ +export function clampTrim(start: number, end: number, duration: number): { start: number; end: number } { + const s = Math.min(Math.max(0, start), duration); + const e = Math.min(Math.max(0, end), duration); + return s <= e ? { start: s, end: e } : { start: e, end: s }; +} diff --git a/src/tools/media/video-compress.lib.test.ts b/src/tools/media/video-compress.lib.test.ts new file mode 100644 index 0000000..c9e1dbf --- /dev/null +++ b/src/tools/media/video-compress.lib.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { computeTargetBitrate, estimateBytes, MIN_VIDEO_KBPS } from './video-compress.lib'; + +describe('computeTargetBitrate', () => { + it('keeps the requested audio when the budget is comfortable', () => { + // 10 MB over 100 s at overhead 1 → 800 kbps budget; 128 for audio, 672 video. + const plan = computeTargetBitrate({ targetBytes: 10_000_000, durationSec: 100, audioKbps: 128, overhead: 1 }); + expect(plan.audioKbps).toBe(128); + expect(plan.videoKbps).toBe(672); + expect(plan.overBudget).toBe(false); + // Estimate should land back on the target. + expect(plan.estimatedBytes).toBe(10_000_000); + }); + + it('steps the audio down a ladder when video would fall below the floor', () => { + // 2,000,000 bytes over 80 s at overhead 1 → 200 kbps budget. + // audio 128 → video 72 (< 100), so drop to 96 → video 104 (ok). + const plan = computeTargetBitrate({ targetBytes: 2_000_000, durationSec: 80, audioKbps: 128, overhead: 1 }); + expect(plan.audioKbps).toBe(96); + expect(plan.videoKbps).toBe(104); + expect(plan.overBudget).toBe(false); + }); + + it('flags overBudget and clamps to the floor when nothing fits', () => { + // 100,000 bytes over 100 s at overhead 1 → 8 kbps budget: impossible. + const plan = computeTargetBitrate({ targetBytes: 100_000, durationSec: 100, audioKbps: 128, overhead: 1 }); + expect(plan.overBudget).toBe(true); + expect(plan.videoKbps).toBe(MIN_VIDEO_KBPS); + expect(plan.audioKbps).toBe(0); + }); + + it('drops audio when requested audioKbps is 0', () => { + const plan = computeTargetBitrate({ targetBytes: 10_000_000, durationSec: 100, audioKbps: 0, overhead: 1 }); + expect(plan.audioKbps).toBe(0); + expect(plan.videoKbps).toBe(800); + }); + + it('applies the default overhead headroom (< raw budget)', () => { + const plan = computeTargetBitrate({ targetBytes: 10_000_000, durationSec: 100, audioKbps: 0 }); + // default overhead 0.95 → 760 video, under the raw 800. + expect(plan.videoKbps).toBe(760); + }); + + it('throws on non-positive duration or target', () => { + expect(() => computeTargetBitrate({ targetBytes: 1000, durationSec: 0, audioKbps: 0 })).toThrow(); + expect(() => computeTargetBitrate({ targetBytes: 0, durationSec: 10, audioKbps: 0 })).toThrow(); + }); +}); + +describe('estimateBytes', () => { + it('is the inverse of the bitrate budget', () => { + expect(estimateBytes(672, 128, 100)).toBe(10_000_000); + expect(estimateBytes(100, 0, 1)).toBe(12_500); + }); +}); diff --git a/src/tools/media/video-compress.lib.ts b/src/tools/media/video-compress.lib.ts new file mode 100644 index 0000000..f133569 --- /dev/null +++ b/src/tools/media/video-compress.lib.ts @@ -0,0 +1,87 @@ +/** + * Pure bitrate planning for "compress a video to a target file size". + * + * The size of an encoded video is (bitrate × duration), so to hit a target file + * size we solve for the bitrate budget and split it between video and audio. + * All browser/ffmpeg work lives in the island; this module is pure math so it + * can be unit-tested without a real encoder. + */ + +/** Below this, x264 output looks unusable — we clamp here and warn instead. */ +export const MIN_VIDEO_KBPS = 100; + +export interface SizePreset { + label: string; + bytes: number; +} + +/** Common upload limits people compress for (WhatsApp/Discord/email). */ +export const VIDEO_TARGET_PRESETS: SizePreset[] = [ + { label: '8 MB', bytes: 8 * 1024 * 1024 }, + { label: '16 MB (WhatsApp)', bytes: 16 * 1024 * 1024 }, + { label: '25 MB (Discord / email)', bytes: 25 * 1024 * 1024 }, + { label: '50 MB', bytes: 50 * 1024 * 1024 }, + { label: '100 MB', bytes: 100 * 1024 * 1024 }, +]; + +export interface BitrateInput { + /** Desired output size in bytes. */ + targetBytes: number; + /** Clip duration in seconds (after any trim). */ + durationSec: number; + /** Requested audio bitrate in kbps; 0 drops audio entirely. */ + audioKbps: number; + /** Fraction of the raw budget to actually target (container/muxing headroom). Default 0.95. */ + overhead?: number; +} + +export interface BitratePlan { + videoKbps: number; + audioKbps: number; + /** Predicted output size in bytes for the chosen bitrates. */ + estimatedBytes: number; + /** True when even the minimum video bitrate (audio dropped) exceeds the target. */ + overBudget: boolean; +} + +/** Predicted encoded size for a given video+audio bitrate over a duration. */ +export function estimateBytes(videoKbps: number, audioKbps: number, durationSec: number): number { + return Math.round(((videoKbps + audioKbps) * 1000 * durationSec) / 8); +} + +/** + * Plan the video/audio bitrates that land closest to (and under) `targetBytes`. + * If the requested audio bitrate leaves too little for video, the audio is + * stepped down a ladder (96 → 64 → 48 → 32 → drop) before the video is clamped. + */ +export function computeTargetBitrate(input: BitrateInput): BitratePlan { + const overhead = input.overhead ?? 0.95; + if (!(input.durationSec > 0)) throw new Error('durationSec must be > 0'); + if (!(input.targetBytes > 0)) throw new Error('targetBytes must be > 0'); + + const budgetKbps = ((input.targetBytes * 8) / 1000 / input.durationSec) * overhead; + + const ladder = [96, 64, 48, 32].filter(v => v < input.audioKbps); + const audioOptions = input.audioKbps > 0 ? [input.audioKbps, ...ladder, 0] : [0]; + + for (const a of audioOptions) { + const v = budgetKbps - a; + if (v >= MIN_VIDEO_KBPS) { + const videoKbps = Math.floor(v); + return { + videoKbps, + audioKbps: a, + estimatedBytes: estimateBytes(videoKbps, a, input.durationSec), + overBudget: false, + }; + } + } + + // Even with audio dropped we can't fit — clamp to the floor and flag it. + return { + videoKbps: MIN_VIDEO_KBPS, + audioKbps: 0, + estimatedBytes: estimateBytes(MIN_VIDEO_KBPS, 0, input.durationSec), + overBudget: true, + }; +} diff --git a/src/tools/pdf/layout.lib.test.ts b/src/tools/pdf/layout.lib.test.ts index d4c81d2..37daa36 100644 --- a/src/tools/pdf/layout.lib.test.ts +++ b/src/tools/pdf/layout.lib.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { pageNumberXY, placementToPdfRect } from './layout.lib'; +import { pageNumberXY, placementToPdfRect, textPlacementToPdf } from './layout.lib'; describe('pageNumberXY', () => { it('places bottom-center', () => { @@ -30,3 +30,15 @@ describe('placementToPdfRect', () => { expect(r.height).toBe(200); }); }); + +describe('textPlacementToPdf', () => { + it('maps a top-left text placement to a bottom-left baseline', () => { + const r = textPlacementToPdf( + { pageIndex: 0, xRatio: 0.5, yRatio: 0.25, text: 'hi', sizeRatio: 0.02 }, + 600, 800, + ); + expect(r.size).toBe(16); // 0.02 × 800 + expect(r.x).toBe(300); // 0.5 × 600 + expect(r.y).toBe(584); // 800 − (0.25 × 800) − 16 + }); +}); diff --git a/src/tools/pdf/layout.lib.ts b/src/tools/pdf/layout.lib.ts index fee44b1..bbba3d5 100644 --- a/src/tools/pdf/layout.lib.ts +++ b/src/tools/pdf/layout.lib.ts @@ -44,6 +44,35 @@ export interface SignPlacement { wRatio: number; } +/** A line of typed text placed on a page (top-left origin, page-relative ratios). */ +export interface TextPlacement { + pageIndex: number; + /** left edge, fraction of page width */ + xRatio: number; + /** top edge of the text, fraction of page height (from the top) */ + yRatio: number; + /** the text to draw */ + text: string; + /** font size as a fraction of page height */ + sizeRatio: number; +} + +/** + * Convert a top-left-origin text placement into pdf-lib draw coordinates. + * pdf-lib's `drawText` y is the text baseline and its origin is bottom-left, so + * the baseline sits one font-size below the top edge of the text box. + */ +export function textPlacementToPdf( + p: TextPlacement, + pageW: number, + pageH: number, +): { x: number; y: number; size: number } { + const size = p.sizeRatio * pageH; + const x = p.xRatio * pageW; + const yFromTop = p.yRatio * pageH; + return { x, y: pageH - yFromTop - size, size }; +} + /** * Convert a top-left-origin ratio placement into a pdf-lib bottom-left rect. * Height is derived from the image aspect ratio (w/h). diff --git a/src/tools/pdf/pdf.lib.ts b/src/tools/pdf/pdf.lib.ts index 192aef9..5a2c41d 100644 --- a/src/tools/pdf/pdf.lib.ts +++ b/src/tools/pdf/pdf.lib.ts @@ -1,5 +1,5 @@ import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'; -import { pageNumberXY, placementToPdfRect, type PageNumberOptions, type SignPlacement } from './layout.lib'; +import { pageNumberXY, placementToPdfRect, textPlacementToPdf, type PageNumberOptions, type SignPlacement, type TextPlacement } from './layout.lib'; // Loading/parsing existing PDFs is handled by the mupdf engine (in a worker) — // it parses the wide range of real-world PDFs that pdf-lib's parser rejects. @@ -203,6 +203,47 @@ export async function signPdf( return toBlob(await out.save()); } +// Helvetica (a StandardFont) can only encode WinAnsi characters, so typed text +// containing anything outside it (curly quotes, em dashes, non-Latin scripts) +// would make drawText throw. Fold the common typographic characters back to +// ASCII and drop anything still unsupported so filling a form never crashes. +function toWinAnsi(text: string): string { + return text + .replace(/[‘’‚′]/g, "'") + .replace(/[“”„″]/g, '"') + .replace(/[–—]/g, '-') + .replace(/…/g, '...') + // eslint-disable-next-line no-control-regex + .replace(/[^\x00-\xFF]/g, ''); +} + +/** Stamp typed text (form fields, checkmarks, dates) onto the given placements. */ +export async function fillPdfText( + file: File, + placements: TextPlacement[], +): Promise { + const src = await loadViaMupdf(file); + const out = await PDFDocument.create(); + const pages = await out.copyPages(src, src.getPageIndices()); + pages.forEach(page => out.addPage(page)); + + const font = await out.embedFont(StandardFonts.Helvetica); + const docPages = out.getPages(); + for (const placement of placements) { + const page = docPages[placement.pageIndex]; + if (!page) continue; + const text = toWinAnsi(placement.text); + if (!text) continue; + const { width, height } = page.getSize(); + const { x, y, size } = textPlacementToPdf(placement, width, height); + // Support multi-line fields; each line drops one line-height below the last. + text.split('\n').forEach((line, i) => { + page.drawText(line, { x, y: y - i * size * 1.2, size, font, color: rgb(0, 0, 0) }); + }); + } + return toBlob(await out.save()); +} + /** Build a PDF from images (one image per page, page sized to the image). */ export async function imagesToPdf(images: File[]): Promise { const out = await PDFDocument.create();