🤖 AI-Readable JSON for this phase
All 10 sub-phases of Media Apps as JSON — perfect for AI agents.
{
"phase": {
"id": 6,
"name": "Media Apps",
"icon": "🎬",
"tagline": "Audio + video + image",
"color": "#ec4899",
"slug": "media-apps"
},
"sub_phases": [
{
"id": "6.1",
"title": "Audio Editor (Waveform)",
"icon": "🎵",
"description": "Visual waveform + region trim + fade in/out. WaveSurfer.js v7 ships regions + MP3 decode + 50KB footprint.",
"when_to_use": "Podcast editor, music trimmer, karaoke timing, voice memo cleanup, SFX browser ใน CodeHub (karaoke-studio ใช้แพทเทิร์นเดียวกัน)",
"when_not_to_use": "Multi-track DAW (8+ tracks, VST plugins, MIDI); pro mastering (need sample-accurate offline render); real-time DSP effects chain (need AudioWorklet)",
"pros": [
"AudioContext.decodeAudioData() ให้ Float32 PCM peaks ทันที ไม่ต้อง parse WAV header เอง",
"Region select = trim/fade โดยไม่ต้อง re-encode เพราะใช้ <audio> element seek",
"WaveSurfer v7 มี Regions/Timeline/Minimap plugin ใน bundle เดียว ~50KB gzipped",
"Web Audio API playback = sample-accurate scrub, สามารถใส่ gain node ต่อ region ได้",
"ทำงานบน mobile ได้ (iOS Safari รองรับ decodeAudioData ตั้งแต่ iOS 13)",
"Export MP3 ผ่าน lamejs (~30KB) ไม่ต้องส่งไป server"
],
"cons": [
"decodeAudioData() ใช้ memory = file_size × 4 (Float32) → ไฟล์ 50MB ใช้ RAM 200MB",
"WaveSurfer render ใช้ canvas ขนาด 1:1 กับ pixels → 4K width = slow on mobile",
"ไม่มี built-in effect chain (compressor/EQ) — ต้องเขียน AudioWorklet เอง",
"Cannot open .aiff/.flac บน Safari (MP3/WAV/AAC/OGG เท่านั้น)",
"Visual seek ไม่ sample-accurate ถ้าไฟล์ VBR MP3 (seek table ผิด)",
"iOS background tab = audio pause ทันที (autoplay policy)"
],
"gotchas": [
"iOS Safari: user gesture (tap) ต้องเกิดก่อน audio.play() ไม่งั้น block + console error",
"decodeAudioData() returns Promise — ห่อ try/catch เพราะ CORS file จะ throw โดยไม่บอกรายละเอียด",
"CORS: cross-origin audio ต้องมี Access-Control-Allow-Origin หรือ response เป็น opaque (แล้ว decodeAudioData จะล้มเหลว)",
"Web Audio context = single instance per page สร้างซ้อนกันไม่ได้ (resume() หลัง user gesture)",
"MP3 VBR seek ไม่แม่น → ใช้ peaks pre-computed แทนการ random-access",
"Canvas resize during playback = waveform ต้อง redraw (debounce 100ms)",
"Region drag บน touch device = pointer events ต้องใช้ PointerEvent ไม่ใช่ touchstart",
"getByteFrequencyData ต้องเรียกหลัง analyser.getFloatTimeDomainData ทุก frame"
],
"anti_patterns": [
"Parse WAV header เอง (RIFF chunk) เพื่ออ่าน samples — ใช้ decodeAudioData() แท้ๆ ทำได้ใน 1 บรรทัด",
"Render waveform ทุก animation frame — peaks ไม่เปลี่ยน decode ครั้งเดียว วาดครั้งเดียว",
"ใช้ setInterval(ontimeupdate) — ใช้ AudioContext.currentTime เป็น master clock แทน",
"เก็บ original audio ใน <audio> + AudioBuffer ซ้ำกัน — ใช้ AudioBufferSourceNode.playbackRate แทน",
"Export โดย upload file → server → process — ใช้ lamejs (MP3) หรือ WebM Opus encode client-side"
],
"prereqs": [
"1.5",
"1.6"
],
"next_steps": [
"6.6",
"6.7"
],
"examples": [
"karaoke-studio",
"sj88cute-voice-isolator"
],
"snippet": "const ws=WaveSurfer.create({container:'#wf',waveColor:'#ec4899',progressColor:'#a855f7',url:'audio.mp3',peaks:[/*pre-computed*/]});",
"snippet_full": "const audioCtx = new (window.AudioContext||window.webkitAudioContext)();\\nconst buf = await audioCtx.decodeAudioData(await fetch('song.mp3').then(r=>r.arrayBuffer()));\\nconst peaks = [];\\nconst ch = buf.getChannelData(0);\\nfor (let i = 0; i < buf.duration * 100; i++) {\\n const s = Math.floor(i * ch.length / (buf.duration * 100));\\n let min = 1, max = -1;\\n for (let j = 0; j < ch.length / (buf.duration*100); j++) {\\n const v = ch[s+j] || 0; if (v<min)min=v; if (v>max)max=v;\\n }\\n peaks.push([min, max]);\\n}\\nconst ws = WaveSurfer.create({container:'#wf',waveColor:'#ec4899',progressColor:'#a855f7',url:'song.mp3',peaks});",
"tags": [
"audio",
"waveform",
"เสียง",
"wavesurfer",
"webaudio",
"trim",
"fade"
],
"difficulty": "Intermediate",
"time_to_learn": "4 hours",
"docs_url": "https://wavesurfer.xyz/docs/",
"compare_with": [
"Tone.js (effect chain, larger 200KB)",
"Howler.js (playback only, no waveform)",
"Peak.js (BBC, frame-accurate but ~150KB)"
]
},
{
"id": "6.2",
"title": "Custom Video Player",
"icon": "▶️",
"description": "HTML5 <video> + custom controls + playlist + speed + WebVTT subs + Picture-in-Picture. ~30KB zero-dep.",
"when_to_use": "Learning platform, social feed video, portfolio reel, internal training, embedded player สำหรับ karaoke-studio / sj88-video-editor-pro previews",
"when_not_to_use": "DRM content (Widevine/FairPlay ต้อง HTTPS + license server + MSE/EME); live streaming (HLS.js หรือ Shaka Player); ad monetization (IMA SDK)",
"pros": [
"Native <video> = zero decode cost, GPU compositor, ใช้ codec ของ OS (H.264/H.265/AV1/VP9)",
"WebVTT via <track> = captions ทำงานทันที, screen readers อ่านได้ (a11y boost)",
"Picture-in-Picture API (Chrome/Safari) = ลอย video บน desktop ขณะ multitask",
"PlaybackRate 0.0625–16 = useful สำหรับ tutorial (slow-mo) และ skip-through",
"MediaSession API ติดปุ่ม lock-screen controls (iOS/Android) = native feel",
"No build, no framework, ship <video>+<script> ได้เลย"
],
"cons": [
"DRM/encrypted streams ต้อง MSE + EME + HTTPS + license request (CORS nightmare)",
"iOS Safari: playsinline attribute ต้องใช้ มิงั้น fullscreen อัตโนมัติ",
"Autoplay policy: Chrome block autoplay จนกว่า user gesture + muted หรือ media engagement",
"Quality switching ต้อง MSE + manifest (HLS/DASH) ไม่ใช่ <video src> ธรรมดา",
"Subtitle styling จำกัด (ไม่รองรับ ::cue pseudo บน legacy Edge)",
"Seek precision ขึ้นกับ keyframe interval — VBR content seek ±2-5s"
],
"gotchas": [
"iOS playsinline: <video playsinline webkit-playsinline> ไม่งั้น iPhone fullscreen ทันทีเมื่อ play",
"preload='metadata' = โหลดแค่ header (fps/duration) ลด bandwidth 90% เทียบ preload='auto'",
"currentTime = 0.001 trick เพื่อ force thumbnail แสดง (Chrome bug บาง version)",
"onloadedmetadata ก่อน oncanplay — ใช้ loadedmetadata เพื่อ get duration",
"WebVTT cues ใช้ timestamp HH:MM:SS.mmm — ไม่มี frame number (VTT ไม่ใช่ frame-based)",
"iOS volume control ผ่าน JS ไม่ได้ (security) — ใช้ MediaSession metadata แทน",
"Track element default ไม่แสดง — ต้อง track.default = true (default attribute)",
"Black frame ตอน seek = post-processing keyframe ใหม่, ใช้ requestVideoFrameCallback (Chrome) detect"
],
"anti_patterns": [
"สร้าง custom decoder/encoder — browser มี HW-accelerated decoder แล้ว ใช้ <video> ดีกว่า",
"ใช้ <iframe YouTube> เพื่อ 'custom' UI — track events ไม่ได้, no offline, branding",
"Force flash fallback — Flash เลิกใช้ 2017 แล้ว",
"Re-render controls ทุก video state change — ใช้ CSS class toggle แทน (reflow 5x น้อยกว่า)",
"Bind onwheel บน progress bar เพื่อ scrub — conflict กับ page zoom, ใช้ pointer events"
],
"prereqs": [
"1.6"
],
"next_steps": [
"6.3",
"6.8"
],
"examples": [
"karaoke-studio",
"sj88-video-editor-pro",
"sj88cute-frame-extractor"
],
"snippet": "const v=document.querySelector('video'); v.playbackRate=1.5; v.requestPictureInPicture(); v.textTracks[0].mode='showing';",
"snippet_full": "const v = document.querySelector('video');\\nconst track = v.addTextTrack('captions','en','English');\\ntrack.addCue(new VTTCue(2,5,'Hello world'));\\ntrack.mode = 'showing';\\nv.playbackRate = 1.5;\\nv.addEventListener('enterpictureinpicture', () => console.log('PiP on'));\\ndocument.querySelector('#pip-btn').onclick = () => v.requestPictureInPicture();\\nnavigator.mediaSession.metadata = new MediaMetadata({title:'Lesson 1',artist:'CodeHub'});",
"tags": [
"video",
"player",
"วิดีโอ",
"html5",
"webvtt",
"pip",
"playback"
],
"difficulty": "Intermediate",
"time_to_learn": "3 hours",
"docs_url": "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video",
"compare_with": [
"Video.js (full player ~200KB, plugins)",
"Plyr (lightweight ~30KB, opinionated)",
"HLS.js (live streaming, MSE-based)"
]
},
{
"id": "6.3",
"title": "Video Editor (Timeline)",
"icon": "🎥",
"description": "Multi-track timeline + clip trim/ripple + transitions + WebCodecs decode + ffmpeg.wasm export. Reference: sj88-video-editor-pro.",
"when_to_use": "Short-form social video, course/tutorial editor, podcast clipper, meme-maker, sj88-video-editor-pro (CapCut-style) ใช้พื้นฐานนี้",
"when_not_to_use": "Long-form pro (Premiere/Resolve); collaborative real-time multi-user (need OT/CRDT); 4K RAW color grading",
"pros": [
"WebCodecs API (Chrome 94+, Edge) = hardware decode/encode VideoFrame โดยตรง, 5-10x เร็วกว่า <video>+canvas",
"ffmpeg.wasm 30MB single-file = MP4/H.264 export client-side ไม่ต้อง server",
"Canvas 2D + WebGL shader = transition/effect chain render ที่ 60fps ได้",
"IndexedDB blob storage = project file เก็บในเบราว์เซอร์, export MP4 ตอน save",
"Multi-track = audio หลายชั้น + video overlays (text/sticker) ใน timeline เดียว",
"Keyframe interpolation (linear/ease/bezier) ใน JS = animation ลื่น ไม่ต้อง After Effects"
],
"cons": [
"WebCodecs = Chrome/Edge only (Safari 16.4 partial, Firefox behind flag) — fallback <video>+canvas ช้ากว่า 5x",
"ffmpeg.wasm 30MB initial load = first export รอ 5-10s ขณะ WASM compile",
"4K60 timeline = 8GB RAM (VideoFrame pool ไม่คืน memory ทันที)",
"Audio sync drift: media time ≠ wall clock เมื่อ render นาน > 30s (frame counter mismatch)",
"No GPU shader caching: effect chain 8 filters = recompile shader ทุก frame ถ้าไม่ memoize",
"Project state = JSON ไม่พอ — binary blobs (raw frames, audio) เก็บแยก IndexedDB"
],
"gotchas": [
"WebCodecs VideoFrame.close() = ต้องเรียนด้วยตัวเอง มิงั้น GPU memory leak 1GB/min",
"ffmpeg.wasm: SharedArrayBuffer ต้อง COOP/COEP headers (cross-origin isolated)",
"Frame timing: requestVideoFrameCallback() ให้ mediaTime จริง (ไม่ใช่ performance.now)",
"Audio drift: AudioContext.currentTime - video.currentTime ตรวจทุก 5s แล้ว resync",
"Canvas captureStream(30) = 30fps cap — render 60fps เสียเปล่า, ใช้ VideoEncoder ตรงๆ ดีกว่า",
"IndexedDB quota ≈ 60% disk → video project เกิน 2GB จะ quota error",
"Seeking = dispose VideoFrame ทั้งหมดก่อน, decode ใหม่ (ทำลาย seek perf)",
"WebGL texture upload = bottleneck ถ้าไม่ใช้ texImage2D จาก VideoFrame (vs HTMLVideoElement)"
],
"anti_patterns": [
"ใช้ MediaRecorder + canvas.captureStream() เพื่อ export — WebCodecs VideoEncoder เร็วกว่า 10x, ได้ keyframe control",
"Render ทุก frame ทั้ง timeline — pre-compute static segments, render dynamic เฉพาะส่วนเปลี่ยน",
"เก็บ project ใน localStorage 5MB limit — ใช้ IndexedDB สำหรับ binary, JSON สำหรับ metadata",
"Build timeline DOM = div ต่อ frame — ใช้ canvas เดียว (1 clip = 1 region) ลด DOM ลง 1000x",
"Sync audio ผ่าน <audio>.currentTime = video.currentTime — drift ทันที, ใช้ AudioContext destination + GainNode"
],
"prereqs": [
"1.9",
"6.2"
],
"next_steps": [
"6.6",
"6.7",
"6.10"
],
"examples": [
"sj88-video-editor-pro",
"karaoke-studio",
"sj88cute-voice-isolator",
"sj88cute-frame-extractor"
],
"snippet": "const enc=new VideoEncoder({output:chunk=>muxer.addVideoChunk(chunk),error:console.error}); enc.configure({codec:'avc1.42E01E',width:1920,height:1080,bitrate:5_000_000,framerate:30});",
"snippet_full": "// sj88-video-editor-pro style: WebCodecs frame-by-frame export\\nconst muxer = new Mp4Muxer({target:new ArrayBufferTarget(),video:{codec:'avc',width:1920,height:1080},fastStart:'in-memory'});\\nconst enc = new VideoEncoder({output:c=>muxer.addVideoChunk(c),error:e=>console.error(e)});\\nenc.configure({codec:'avc1.42E01E',width:1920,height:1080,bitrate:5_000_000,framerate:30});\\nfor (let t = 0; t < duration; t += 1/30) {\\n const frame = new VideoFrame(canvas, {timestamp:t*1_000_000});\\n enc.encode(frame, {keyFrame:t%(30*2)===0});\\n frame.close();\\n}\\nawait enc.flush();\\ndownloadBlob(new Blob([muxer.target.buffer],{type:'video/mp4'}),'export.mp4');",
"tags": [
"video",
"editor",
"วิดีโอ",
"timeline",
"webcodecs",
"ffmpeg",
"keyframe",
"export"
],
"difficulty": "Advanced",
"time_to_learn": "2 weeks",
"docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API",
"compare_with": [
"Remotion (React, server-render only)",
"MLT/Melted (C++ pipeline, native only)",
"CapCut Web (closed source)"
]
},
{
"id": "6.4",
"title": "Image Editor (Crop/Filter)",
"icon": "🖼️",
"description": "Canvas 2D + WebGL filters (brightness/contrast/blur) + sticker overlay + text. Reference: fitcheck, cutout-studio, palette.",
"when_to_use": "Profile pic cropper, sticker maker, meme layer, product photo (fitcheck, cutout-studio, palette ใช้พื้นฐานนี้), social post composer",
"when_not_to_use": "RAW/DNG editing; HDR merge; 16-bit color; Photoshop-grade layers (50+ smart objects, blend modes)",
"pros": [
"ctx.filter='brightness(1.2)' = CSS-level effect 0-line, GPU-accelerated",
"OffscreenCanvas + Worker = filter 4K image โดยไม่ block main thread (responsive UI)",
"WebGL fragment shader = 50+ custom filters (sepia/glitch/vignette) ที่ 60fps",
"createImageBitmap() = decode 12MP JPEG ใน ~80ms (vs <img> 200ms)",
"toBlob('image/webp', 0.9) = 30-50% smaller output กว่า JPEG เทียบ visual quality",
"Sticker/text layer = transform matrix (translate/rotate/scale) ผ่าน canvas.setTransform"
],
"cons": [
"Canvas 2D filter quality ต่ำกว่า Photoshop (no 16-bit, no CMYK, limited blend modes)",
"WebGL shader ต้อง recompile ทุกครั้ง filter เปลี่ยน — memoize program ด้วย string key",
"EXIF orientation: <img> ไม่หมุนอัตโนมัติ, ต้อง parse EXIF หรือ createImageBitmap({imageOrientation:'from-image'})",
"4K image (3840×2160) × Float32 RGBA = 130MB GPU memory — tile-based render แทน",
"Browser max canvas = 16384×16384 บน Chrome / 4096×4096 iOS — resize ก่อน drawImage",
"iOS Safari: OffscreenCanvas + Worker ตั้งแต่ 16.4, bug บน 14-15"
],
"gotchas": [
"iOS rotation bug: iPhone photo ถ่าย portrait แต่ EXIF landscape → drawImage ออกมาตะแคงข้าง",
"Workaround: createImageBitmap(blob,{imageOrientation:'from-image'}) แล้ว draw แบบ bitmap",
"ctx.filter ต้อง set ก่อน drawImage ไม่งั้นไม่ apply",
"WebGL texImage2D from HTMLImageElement = upload CPU-side pixel (slow) → ใช้ texImage2D(target,level,format,bitmap) แทน",
"iOS Safari: canvas.toBlob() = 'image/png' เท่านั้น (ไม่รองรับ webp ในบาง version)",
"Filter pipeline: ต้อง save/restore context เพื่อไม่ให้ filter leak ไป layer ถัดไป",
"Crop aspect ratio: devicePixelRatio 2x retina → canvas ใหญ่กว่าที่ตาเห็น 2 เท่า (handle ก่อน export)",
"Undo/redo = เก็บ ImageData snapshot → 50MB per layer × 20 steps = OOM, ใช้ Command pattern (delta) แทน"
],
"anti_patterns": [
"Stack CSS filter + ctx.filter = double apply (CSS คือ compositing pass, ไม่ใช่ effect layer)",
"ใช้ base64 string แทน Blob URL = RAM x4, GC pressure สูง, ใช้ URL.createObjectURL(file)",
"เก็บ canvas state ใน JSON serialization (toDataURL ใน array) — base64 33% bigger, ใช้ OffscreenCanvas snapshot",
"Read EXIF manual เพื่อ orientation — createImageBitmap options ทำให้แล้ว 1 บรรทัด",
"Re-render full canvas ทุก slider change — debounce 16ms (1 frame) หรือ in-place filter chain"
],
"prereqs": [
"1.6"
],
"next_steps": [
"6.5",
"6.9"
],
"examples": [
"fitcheck",
"cutout-studio",
"palette"
],
"snippet": "const ctx=canvas.getContext('2d'); ctx.filter='brightness(1.2) saturate(1.5) contrast(1.1)'; ctx.drawImage(img,0,0); ctx.filter='none';",
"snippet_full": "// fitcheck / cutout-studio style: layered editor with re-doable filters\\nconst cvs = document.querySelector('canvas');\\nconst ctx = cvs.getContext('2d', {willReadFrequently:false});\\nconst bitmap = await createImageBitmap(file, {imageOrientation:'from-image'});\\ncvs.width = bitmap.width; cvs.height = bitmap.height;\\nconst filters = {brightness:1.1, contrast:1.05, saturate:1.4, blur:0};\\nfunction render(){\\n ctx.filter = `brightness(${filters.brightness}) contrast(${filters.contrast}) saturate(${filters.saturate}) blur(${filters.blur}px)`;\\n ctx.clearRect(0,0,cvs.width,cvs.height);\\n ctx.drawImage(bitmap, 0, 0);\\n}\\nrender();\\ncvs.toBlob(b=>downloadBlob(b,'edited.webp'),'image/webp',0.9);",
"tags": [
"image",
"editor",
"รูปภาพ",
"canvas",
"filter",
"crop",
"sticker",
"webgl"
],
"difficulty": "Intermediate",
"time_to_learn": "4 hours",
"docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/filter",
"compare_with": [
"Fabric.js (object-oriented, ~300KB)",
"Konva.js (DOM-based, ~150KB)",
"P5.js (creative coding, ~700KB)"
]
},
{
"id": "6.5",
"title": "PDF Generation",
"icon": "📄",
"description": "jsPDF client-side (zero install) vs WeasyPrint/Puppeteer server-side (HTML-to-PDF). Reference: pos-grocery receipt, nesspaper-style.",
"when_to_use": "Invoice/receipt (pos-grocery), report, certificate, contract, ticket, e-book, nesspaper-style newspaper layout",
"when_not_to_use": "Real-time collaborative PDF edit (Google Docs model); 500+ page documents (memory + render time); interactive PDF forms (need PDF.js or AcroForm)",
"pros": [
"jsPDF ฝั่ง client = no server cost, GDPR-friendly (data ไม่ออกจาก browser), offline-capable",
"Puppeteer/WeasyPrint server = full HTML/CSS (flexbox, grid) → PDF ตรง design 100%",
"jsPDF font embedding base64 = CJK (TH/VI/ZH/JA/KR) render ถูกต้อง (ฟอนต์ Sarabun, Noto Sans TH)",
"pdf-lib client-side = edit existing PDF (add page, fill form, merge) โดยไม่ round-trip server",
"Puppeteer headless Chrome = same engine as user's browser = pixel-perfect print",
"jsPDF + html2canvas = rasterize HTML div → image → embed (escape hatch เมื่อ layout ซับซ้อน)"
],
"cons": [
"jsPDF: ไม่มี CSS engine — ต้องคำนวณ position เอง (x,y,fontSize), ไม่มี flexbox/grid",
"Puppeteer headless Chrome = 200-300MB RAM per process, เปิดใหม่ 1-2s per PDF (cold)",
"CJK font base64 = 1-3MB inline → PDF ใหญ่ขึ้น, แต่ละ font subset ต้อง license (Noto OFL ok)",
"Puppeteer render = blocking event loop ใน Node (--no-sandbox แล้ว security risk)",
"html2canvas + jsPDF = rasterize = text ไม่ selectable, ไฟล์ใหญ่, ไม่ accessible",
"jsPDF autoTable plugin ไม่รองรับ colspan/rowspan ซับซ้อน, header repeat across pages เอง"
],
"gotchas": [
"CORS: external image ใน jsPDF ต้อง fetch → base64 (jsPDF.addImage base64 only)",
"Thai text broken = ใช้ฟอนต์ที่ไม่มีสระลอย (Sarabun, Noto Sans Thai) แล้ว embed เป็น .ttf base64",
"Puppeteer sandbox: Docker ต้อง --no-sandbox flag หรือ seccomp profile เพื่อหลีกเลี่ยง crash",
"jsPDF: addFont() ต้องเรียกก่อน doc.text() มิงั้น default Helvetica ใช้แล้ว text ผิด encoding",
"Font subsetting: jsPDF โหลดฟอนต์เต็ม 3MB เพื่อคำ 5 คำ — ใช้ pyftsubset ฝั่ง build pipeline",
"A4 = 595×842 pt (not px!) — convert px@96dpi ด้วย 595/96 = 6.2x scale factor",
"Puppeteer page.pdf() ไม่ render @media print stylesheet เสมอ — ใช้ emulateMedia('print')",
"WeasyPrint: ไม่รองรับ JS execution (Python+CSS only) — สำหรับ static layout เท่านั้น"
],
"anti_patterns": [
"ใช้ html2canvas + jsPDF แทน server-side — raster PDF ใหญ่ 10-20x, ไม่ selectable, accessibility แย่",
"Load ฟอนต์จาก Google Fonts ใน jsPDF — CORS block ตอน runtime, embed base64 ตอน build แทน",
"Spawn Puppeteer ต่อ request = 300MB process / 2s spinup — ใช้ pool (puppeteer-cluster) หรือ serverless browser",
"Generate PDF เป็น base64 string เก็บ DB — base64 33% larger, ใช้ binary blob (BYTEA, S3)",
"Assume jsPDF รู้จัก UTF-8 = false — ใช้ addFont() with 'utf-8' encoding parameter เสมอ"
],
"prereqs": [
"1.7"
],
"next_steps": [],
"examples": [
"pos-grocery"
],
"snippet": "const doc=new jsPDF(); doc.setFont('Sarabun'); doc.text('ใบเสร็จรับเงิน',20,20); doc.setFontSize(12); doc.text('Total: 350 บาท',20,30); doc.save('receipt.pdf');",
"snippet_full": "// pos-grocery style: Thai receipt via jsPDF + base64 font\\nimport jsPDF from 'jspdf';\\nimport sarabunBase64 from './Sarabun-Regular.base64.txt';\\nconst doc = new jsPDF({unit:'mm',format:'a4'});\\ndoc.addFileToVFS('Sarabun.ttf', sarabunBase64);\\ndoc.addFont('Sarabun.ttf', 'Sarabun', 'normal');\\ndoc.setFont('Sarabun');\\ndoc.setFontSize(18);\\ndoc.text('ใบเสร็จรับเงิน / Receipt', 20, 20);\\ndoc.setFontSize(11);\\nlet y = 35;\\nitems.forEach(it => { doc.text(`${it.name} x${it.qty}`, 20, y); doc.text(`${it.price*it.qty} ฿`, 150, y); y += 7; });\\ndoc.setFontSize(14); doc.text(`รวม / Total: ${total} ฿`, 20, y+5);\\ndoc.save(`receipt-${orderId}.pdf`);",
"tags": [
"pdf",
"jspdf",
"invoice",
"ใบเสร็จ",
"receipt",
"html2pdf",
"puppeteer",
"weasyprint"
],
"difficulty": "Intermediate",
"time_to_learn": "1 day",
"docs_url": "https://artskydj.github.io/jsPDF/docs/",
"compare_with": [
"pdfmake (declarative, ~500KB)",
"pdf-lib (edit existing, ~400KB)",
"react-pdf (React, server-side only)"
]
},
{
"id": "6.6",
"title": "Lyric Video Maker",
"icon": "📝",
"description": "Karaoke-style timing editor: LRC import + click-to-set timing + animated highlight + canvas overlay + WebM export. Reference: karaoke-studio (6+ theme variants).",
"when_to_use": "Music content creator, social lyric clip (TikTok/Reels), karaoke timing rehearsal, lyric translation video, karaoke-studio base",
"when_not_to_use": "Live karaoke (need real-time pitch detection เช่น YIN/CREPE); VFX-heavy music video; auto-align to vocals (need audio-to-text alignment)",
"pros": [
"LRC format = universal (10+ tools export), แค่ [mm:ss.xx]text per line",
"Click-to-set timing = กด spacebar ตอนฟัง + click line = sub-second timing precision",
"Canvas overlay = render highlight color/bounce/glow ต่อ character (ไม่ใช่ทั้งบรรทัด)",
"requestAnimationFrame + audio.currentTime = 60fps highlight scroll แม่นยำ",
"WebM export via MediaRecorder + canvas.captureStream = no server, ~5MB/min 720p",
"karaoke-studio 11 themes ใช้ pattern เดียวกัน (theme = JSON of CSS+canvas configs)"
],
"cons": [
"LRC ไม่มี syllable timing (ทั้งบรรทัดเปลี่ยนพร้อมกัน) — karaoke effect แท้ๆ ต้อง advanced LRC (Enhanced LRC / LRCv2)",
"WebM export = Safari/iOS เปิดไม่ได้ (VP9) — ต้อง MP4 export แยก (ffmpeg.wasm)",
"requestAnimationFrame หยุดเมื่อ tab background → timing drift (ใช้ AudioContext clock แทน)",
"Subtitle 'bounce' effect ต้อง pre-compute keyframes (ไม่ใช่ random per frame)",
"Vertical video (9:16 TikTok) export = re-author layout, WebM ใช้ 720x1280 ไม่ใช่ 1280x720",
"Font loading race: โหลดฟอนต์ไม่เสร็จตอน render = fall back to default (text width เปลี่ยน)"
],
"gotchas": [
"LRC tag order: [ti:title][ar:artist][al:album][by:creator] ต้องมาก่อน [mm:ss.xx] แรก",
"Enhanced LRC: [mm:ss.xx]<mm:ss.xx>word<mm:ss.xx>word สำหรับ per-word highlight",
"iOS Safari: ไม่ render ฟอนต์ใน <canvas> ถ้า @font-face ยังโหลดไม่เสร็จ (document.fonts.ready await ก่อน)",
"AudioContext clock (currentTime) ไม่ drift แม้ background tab — ใช้เป็น timing master",
"WebM MediaRecorder bitrate 2500kbps = 5min = 95MB, ใช้ VP9 + Opus 1500kbps ได้คุณภาพใกล้เคียง",
"LRC timestamp regex: \\[(\\d+):(\\d+\\.?\\d*)\\] capture min and sec, validate mm<60 ss<60",
"Sync offset: LRC อาจ offset +200ms เทียบ audio — ให้ user ปรับ global offset slider",
"Export: render ผ่าน captureStream 30fps = encoding ใช้ CPU 100% บน laptop, เตือน user"
],
"anti_patterns": [
"setInterval 16ms เพื่อ update highlight — drift 5s ใน 10 นาที, ใช้ rAF + audio clock",
"ใช้ <video> + <track> WebVTT ทำ lyric video — WebVTT ไม่มี per-character style (CSS ::cue จำกัด)",
"Pre-render ทุก frame เป็น <img> sequence — memory 1GB/30s, ใช้ canvas real-time render",
"Convert LRC → JSON แล้วเก็บใน localStorage เป็น array — LRC string 50KB เปลี่ยน < 5KB JSON",
"ใช้ CSS animation keyframes แทน rAF ใน WebM export — CSS anim ไม่ sync กับ audio clock"
],
"prereqs": [
"1.9",
"6.1"
],
"next_steps": [
"6.7"
],
"examples": [
"karaoke-studio"
],
"snippet": "video.ontimeupdate=()=>{for(let i=lines.length-1;i>=0;i--)if(t>=lines[i].start){line.textContent=lines[i].text;break}};",
"snippet_full": "// karaoke-studio style: LRC parse + scroll highlight via audio clock\\nconst audio = new Audio('song.mp3');\\nconst lrc = `[00:12.34]Line one\\n[00:15.67]Line two\\n[00:19.00]Line three`;\\nconst lines = lrc.split('\\n').map(l => {\\n const m = l.match(/\\[(\\d+):(\\d+\\.\\d+)\\](.*)/);\\n return m ? {start: +m[1]*60 + +m[2], text: m[3]} : null;\\n}).filter(Boolean);\\naudio.play();\\nconst ctx = document.querySelector('canvas').getContext('2d');\\nfunction draw(){\\n const t = audio.currentTime;\\n ctx.clearRect(0,0,canvas.width,canvas.height);\\n ctx.font = 'bold 48px Sarabun';\\n const active = lines.findIndex((l,i,arr) => t >= l.start && (i===arr.length-1 || t < arr[i+1].start));\\n lines.forEach((l,i) => {\\n ctx.fillStyle = i===active ? '#fde047' : '#64748b';\\n ctx.fillText(l.text, 50, 100 + i*60);\\n });\\n requestAnimationFrame(draw);\\n}\\ndraw();",
"tags": [
"lyric",
"lrc",
"เนื้อเพลง",
"karaoke",
"timing",
"subtitle",
"vtt"
],
"difficulty": "Intermediate",
"time_to_learn": "3 hours",
"docs_url": "https://en.wikipedia.org/wiki/LRC_(file_format)",
"compare_with": [
"Aegisub (native, desktop only)",
"Subtitle Edit (Windows only)",
"Hand-rolled WebVTT (simpler but no per-word)"
]
},
{
"id": "6.7",
"title": "Karaoke Studio (Full)",
"icon": "🎤",
"description": "End-to-end: FFT beat detection (1024-pt) + 11 visual themes + per-word timing + MP4 export via ffmpeg.wasm. Reference: karaoke-studio.",
"when_to_use": "Music creator publish karaoke video, social content (9:16 lyric clip), YouTube karaoke channel, karaoke-studio คือ reference implementation ของ pattern นี้",
"when_not_to_use": "Live karaoke (need pitch detection, microphone real-time, scoring); album-grade mastering; pro DAW with VST",
"pros": [
"AnalyserNode FFT size 1024 = 43 beats/sec @ 44100Hz, balance precision vs CPU",
"RMS threshold 0.55 + adaptive peak detection = beat sync ตามจังหวะเพลง 95%+",
"11 themes = JSON config (font/colors/animation curve) swap = render pipeline เดียว",
"ffmpeg.wasm MP4 export = H.264/AAC browser-side, ไฟล์เปิดได้ทุก platform (ต่างจาก WebM)",
"Per-word timing เก็บใน Enhanced LRC = karaoke effect จริง (highlight เคลื่อนที่ตามคำร้อง)",
"Export 1080p60 = ~1.5GB RAM, mobile อาจ crash — auto downscale 720p30 fallback"
],
"cons": [
"ffmpeg.wasm = 30MB download, first export รอ 5-10s ขณะ WASM compile + COOP/COEP headers",
"FFT 1024 = bass ไม่แม่น (low freq resolution 21Hz) — ใช้ 2048 สำหรับ beat-heavy song",
"Beat detection ผิดที่ genre ไม่ใช่ 4/4 (jazz, classical, rubato) — เพิ่ม onset detection fallback",
"MP4 export 3 นาที = 60-90s render time บน M1, 5-8 นาทีบน low-end laptop",
"Per-word timing ต้อง manual ใส่ (ไม่มี auto-align client-side แม่น) — Whisper API ช่วยได้",
"Audio mixing = Web Audio destination → MediaRecorder = mute issue ถ้า tab background"
],
"gotchas": [
"SharedArrayBuffer: COOP: same-origin + COEP: require-corp header จำเป็นสำหรับ ffmpeg.wasm multi-thread",
"FFT window: AnalyserNode.fftSize=1024, getByteFrequencyData ให้ 0-255 (map 0-1 RMS)",
"RMS formula: sqrt(sum(sample^2)/N) — window 1024 sample overlap 50% = smoother beat",
"Beat peak: rms[i] > max*0.55 AND rms[i] > localAvg*1.3 AND (i-prevBeat) > 0.2s (debounce)",
"ffmpeg.wasm: -c:v libx264 -preset ultrafast -crf 23 -c:a aac -b:a 128k for browser MP4",
"Canvas captureStream + MediaRecorder VP9 vs VP8 vs H.264 — เลือก H.264 ตอน export จริง (ffmpeg remux)",
"AudioContext: createMediaStreamDestination() ต่อไป MediaRecorder + ffmpeg.wasm mux เข้าด้วยกัน",
"Theme JSON: animation curve ease-out (cubic) = เนียนกว่า linear 5x, keyframe density 30/sec"
],
"anti_patterns": [
"Beat detection ด้วย zero-crossing — ใช้ energy/RMS + spectral flux ดีกว่า 10x",
"MP3 export ผ่าน lamejs — iOS/Safari เปิดไฟล์ได้ แต่ social media (TikTok/IG) ต้อง MP4/H.264",
"Manual frame-by-frame highlight เก็บ 30fps × 180s = 5400 entries ใน JSON — ใช้ onset times + ease interpolation",
"Export WebM แล้ว expect upload to YouTube — YouTube รับ WebM แต่ compress แย่กว่า MP4",
"ใช้ setInterval 16ms draw highlight — background tab หยุด, ใช้ AudioContext clock + rAF"
],
"prereqs": [
"6.6",
"6.1",
"1.9"
],
"next_steps": [
"6.10"
],
"examples": [
"karaoke-studio"
],
"snippet": "// Detect beats: for(let i=2;i<windows-2;i++)if(rms[i]>max*.55&&rms[i]>avg*1.3&&(t-lastBeat)>0.2)peaks.push({t,a:rms[i]});",
"snippet_full": "// karaoke-studio style: FFT beat detection + theme render + ffmpeg.wasm export\\nconst audio = new Audio('song.mp3');\\nconst audioCtx = new AudioContext();\\nconst src = audioCtx.createMediaElementSource(audio);\\nconst analyser = audioCtx.createAnalyser();\\nanalyser.fftSize = 1024;\\nsrc.connect(analyser); analyser.connect(audioCtx.destination);\\nconst data = new Uint8Array(analyser.frequencyBinCount);\\nconst beats = [];\\nlet lastBeat = 0;\\nconst THEME = {font:'Sarabun',accent:'#fde047',bg:'#0a0a14',ease:cubicOut};\\nfunction cubicOut(t){return 1-Math.pow(1-t,3);}\\naudio.play();\\nfunction loop(t){\\n analyser.getByteFrequencyData(data);\\n let rms=0; for(let v of data) rms+=v*v; rms=Math.sqrt(rms/data.length)/255;\\n if (rms > 0.55 && t - lastBeat > 0.2) {\\n beats.push({t, energy: rms}); lastBeat = t;\\n }\\n if (!audio.paused) requestAnimationFrame(loop);\\n}\\nrequestAnimationFrame(loop);",
"tags": [
"karaoke",
"beat",
"fft",
"lyric",
"mp4",
"ffmpeg",
"export",
"audio-analysis"
],
"difficulty": "Advanced",
"time_to_learn": "1 week",
"docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode",
"compare_with": [
"Aegisub (native desktop)",
"Karaoke Mug Editor (Windows)",
"Vrew (AI auto-sub, closed source)"
]
},
{
"id": "6.8",
"title": "Subtitle Editor (VTT/SRT)",
"icon": "💬",
"description": "VTT/SRT editor + waveform sync + auto-time-stretch + bilingual. WebVTT for web, SRT for video file. Reference: sj88-video-editor-pro caption panel.",
"when_to_use": "Course/tutorial (sj88-video-editor-pro), accessibility (a11y WCAG 2.1), multilingual video, social clip captions (TikTok/IG auto-import SRT)",
"when_not_to_use": "Auto-transcription (Whisper API needed); live broadcast closed-captioning (real-time STT); translate API (DeepL/Google)",
"pros": [
"WebVTT = W3C standard, native <track> support, ::cue pseudo-class for styling",
"SRT = 99% player support (VLC, mpv, OBS), human-readable 4-byte timestamp",
"Waveform thumbnail strip = visual cue place (เหมือน audio editor แต่ cue-based)",
"Bilingual stack: <track srclang='en' label='English'> + <track srclang='th' label='ไทย' default>",
"Import SRT → VTT converter ใช้ regex 1-pass, เก็บ cue ID auto-generate",
"Export ใช้ Blob + download attribute = ไม่ผ่าน server, GDPR-safe"
],
"cons": [
"VTT cue settings (line:80%, position:50%) browser-specific render — test ทุก target",
"SRT 1-based index, VTT optional cue ID = conversion ง่ายแต่ round-trip มี gotcha",
"Long lines > 42 chars wrap ไม่สวย — ใช้ VTT line:13, position:50% center ดีกว่า",
"Frame-accurate timing ไม่มีใน VTT/SRT (HH:MM:SS.mmm only) — video editor ใช้ FCPXML แทน",
"Right-to-left (Arabic/Hebrew) ต้อง mark <c.langdir='rtl'> + reverse word order (FFmpeg ช่วย)",
"Tag escape: VTT ห้ามมี '<' '>' '&' ภายใน cue text ต้อง < > &"
],
"gotchas": [
"VTT ต้องขึ้นต้นด้วย 'WEBVTT' + บรรทัดว่าง (header parser strict)",
"Timestamp format VTT: HH:MM:SS.mmm (dot ไม่ใช่ comma), SRT: HH:MM:SS,mmm (comma)",
"Cue settings: align:start|center|end, line:0-100%, position:0-100%, size:0-100%",
"VTT ไม่มี cue ID mandatory แต่ถ้าใส่ ต้อง unique ต่อ file (ใช้เป็น anchor ตอน update)",
"Default track: <track ... default> = browser เลือกอัตโนมัติ, ถ้ามีหลาย default = เอาอันแรก",
"srclang ต้องเป็น BCP 47 ('en', 'th', 'pt-BR') ไม่ใช่ display name",
"Cue ซ้อนทับกัน: VTT render ทั้งคู่ (player ไม่ enforce non-overlap), SRT ก็เหมือนกัน",
"Subtitle timing: 1.5-7s per cue (a11y guideline), 42 char/line (display) — editor ต้อง warn"
],
"anti_patterns": [
"Hardcode subtitle ใน video timeline text layer — export MP4 embed, search/edit ไม่ได้",
"Convert VTT → SRT ด้วย .replace(',', '.') เฉยๆ — SRT ใช้ comma, ไม่ใช่ dot, ผิด format",
"ใช้ setTimeout แสดง subtitle แทน <track> — drift 5s ใน 10 นาที, <track> sync กับ media time",
"Store subtitle ใน textarea เดียว — structured data (cues array) edit/undo ได้ดีกว่า 10x",
"Ignore newline ใน VTT (\\n) — display เป็น space ถ้าไม่ใส่ '\\n' explicit"
],
"prereqs": [
"1.3"
],
"next_steps": [
"6.3",
"6.6"
],
"examples": [
"sj88-video-editor-pro"
],
"snippet": "WEBVTT\\n\\n1\\n00:00:00.000 --> 00:00:03.000\\nHello world\\n\\n2\\n00:00:03.500 --> 00:00:06.000 align:center line:80%\\nSecond line centered",
"snippet_full": "// VTT/SRT dual-format editor (sj88-video-editor-pro style)\\nconst cues = [{id:1, start:0, end:3, text:'Hello world'}, {id:2, start:3.5, end:6, text:'Second line'}];\\nfunction toVTT(){let s='WEBVTT\\n\\n'; for(const c of cues) s+=`${c.id}\\n${fmt(c.start)} --> ${fmt(c.end)}\\n${c.text}\\n\\n`; return s;}\\nfunction toSRT(){let n=1,s=''; for(const c of cues){s+=`${n++}\\n${fmt(c.start,',')} --> ${fmt(c.end,',')}\\n${c.text}\\n\\n`;} return s;}\\nfunction fmt(sec, sep='.'){const h=Math.floor(sec/3600),m=Math.floor(sec%3600/60),sc=(sec%60).toFixed(3).padStart(6,'0'); return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${sc}`.replace('.', sep);}\\nconst vtt = new Blob([toVTT()], {type:'text/vtt'});\\nconst a = document.createElement('a'); a.href=URL.createObjectURL(vtt); a.download='subs.vtt'; a.click();",
"tags": [
"subtitle",
"vtt",
"srt",
"caption",
"คำบรรยาย",
"webvtt",
"a11y"
],
"difficulty": "Beginner",
"time_to_learn": "2 hours",
"docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/WebVTT_API",
"compare_with": [
"Subtitle Edit (Windows native)",
"Aegisub (advanced ASS format)",
"Amara (web collab, paid)"
]
},
{
"id": "6.9",
"title": "Meme Generator",
"icon": "😂",
"description": "Template picker + top/bottom Impact text + stroke + center-wrap + share. <50KB, no server. Reference: sj88cute-frame-extractor style.",
"when_to_use": "Social media share, internal comms, education example (captioning), viral marketing, drag-drop meme studio",
"when_not_to_use": "Brand-safe design (Canva/Figma); video/GIF meme (use sj88-video-editor-pro); AI meme generator (DALL-E/MJ API)",
"pros": [
"Impact font + black stroke = 95% readability บนทุก background (classic meme convention)",
"Canvas text wrapping (measureText + manual break) = center per-line ไม่ใช่ left-aligned",
"Text uppercasing 'hello' → 'HELLO' (meme style) = single CSS text-transform",
"Web Share API navigator.share() = mobile native share sheet (FB/IG/Twitter 1 tap)",
"toBlob('image/png') = ไฟล์ lossless, social media upload quality เต็ม",
"Template library: 20 popular meme template เก็บเป็น base64 = offline ready"
],
"cons": [
"Impact font ไม่มีบน Linux/Android (fallback Arial Black) — embed @font-face ถ้าจริงจัง",
"Canvas text baseline = alphabetic (descender 'g' ตัด) — ใช้ 'middle' baseline แล้ว offset y",
"Long text wrap by word = 1 line จะเล็กกว่า 5 lines (font size = canvas.height/numLines)",
"Copyright: ใช้ template ที่ไม่ใช่ IP (Drake meme ok, Disney character = infringement)",
"EXIF strip: meme ที่ upload จาก iPhone มี GPS — canvas re-render strip metadata แต่ watermark risk",
"Dark mode preview: meme ใน dark feed จะจางลง (Impact+black stroke ยังอ่านได้)"
],
"gotchas": [
"ctx.strokeText ก่อน ctx.fillText (ไม่งั้น stroke ทับ fill = readable แต่ ugly)",
"strokeWidth = fontSize × 0.08 (4px stroke สำหรับ 50px font) — vary ตาม view port",
"Multi-line wrap: วน split words, measureText accumulate, เมื่อ width > maxWidth → \\n",
"Font load delay: document.fonts.ready await ก่อน render ไม่งั้น fallback font = wrong layout",
"Image source: <input type='file' accept='image/*'> + URL.createObjectURL ห้ามลืม revoke",
"Twitter share: navigator.canShare เช็คก่อน (iOS Safari 14+ only), fallback download + manual share",
"Top text y position = fontSize × 0.85 (ไม่ใช่ 0) เพราะ visual baseline = ขอบล่างของ capital",
"Bottom text y = canvas.height - 10 (10px padding ล่าง) — centered per line by font baseline"
],
"anti_patterns": [
"ใช้ CSS text-shadow แทน canvas stroke — export แล้วหาย (CSS = render only, not baked)",
"Hardcode font 'Impact' บน Linux = fallback ugly — embed base64 TTF หรือ document.fonts.load check",
"Top/bottom text = 1 string เสมอ — render line-by-line wrap, ไม่ใช่ multiline string",
"Image source = base64 in URL → memory leak — Blob URL + URL.revokeObjectURL หลัง done",
"No watermark/source attribution = โดน claim copyright จาก template owner (Drake Hot Court)"
],
"prereqs": [
"1.6"
],
"next_steps": [
"6.10"
],
"examples": [
"sj88cute-frame-extractor",
"sj88-video-editor-pro"
],
"snippet": "ctx.font=`bold ${h*.08}px Impact, 'Arial Black', sans-serif`; ctx.textAlign='center'; ctx.strokeText(txt,x,y); ctx.fillText(txt,x,y);",
"snippet_full": "// Meme generator: top/bottom Impact text with stroke + auto-wrap\\nconst cvs = document.querySelector('canvas');\\nconst ctx = cvs.getContext('2d');\\nconst img = new Image(); img.src = 'drake-template.jpg';\\nawait img.decode();\\ncvs.width = img.width; cvs.height = img.height;\\nctx.drawImage(img, 0, 0);\\nconst top = 'When user says'; const bottom = 'It works on my machine';\\nfunction drawLine(lines, y, size){\\n ctx.font = `bold ${size}px Impact, 'Arial Black', sans-serif`;\\n ctx.textAlign = 'center';\\n ctx.lineWidth = size*0.08; ctx.strokeStyle = 'black'; ctx.fillStyle = 'white';\\n lines.forEach((line,i) => { const yy = y + i*size*1.1; ctx.strokeText(line, cvs.width/2, yy); ctx.fillText(line, cvs.width/2, yy); });\\n}\\nfunction wrap(t, maxW, size){\\n ctx.font = `bold ${size}px Impact`;\\n const words = t.split(' '); const lines = []; let cur='';\\n for (const w of words) { const test = cur ? cur+' '+w : w; if (ctx.measureText(test).width > maxW) { lines.push(cur); cur=w; } else cur=test; }\\n if (cur) lines.push(cur); return lines;\\n}\\nconst size = cvs.height*0.08;\\ndrawLine(wrap(top, cvs.width*0.9, size), size*0.9, size);\\ndrawLine(wrap(bottom, cvs.width*0.9, size), cvs.height - size*0.3*(wrap(bottom, cvs.width*0.9, size).length-1), size);\\ncvs.toBlob(b => navigator.share ? navigator.share({files:[new File([b],'meme.png',{type:'image/png'})]}) : downloadBlob(b,'meme.png'), 'image/png');",
"tags": [
"meme",
"generator",
"มีม",
"viral",
"impact",
"social",
"share"
],
"difficulty": "Beginner",
"time_to_learn": "1 hour",
"docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/strokeText",
"compare_with": [
"imgflip (web, ad-supported)",
"Canva (full editor, $)",
"Kapwing (video meme, paid)"
]
},
{
"id": "6.10",
"title": "Screen Recorder",
"icon": "🖥️",
"description": "getDisplayMedia + MediaRecorder (WebM VP9/Opus) + mic mixing via Web Audio. Reference: sj88-video-editor-pro recorder, sj88cute-frame-extractor capture flow.",
"when_to_use": "Tutorial/demo recording, bug report capture, internal training, social clip (sj88-video-editor-pro, sj88cute-frame-extractor ใช้ pattern เดียวกัน), product walkthrough",
"when_not_to_use": "Game capture > 60fps (OBS-native, hardware enc); facecam+game composite พร้อม overlay (OBS/Streamlabs); long-form > 1hr recording (memory)",
"pros": [
"navigator.mediaDevices.getDisplayMedia() = 1 call ได้ screen + system audio (Chrome/Edge)",
"MediaRecorder + VP9 codec = 1 hour recording ≈ 1.5GB, เล่น Chrome/Firefox ได้ทันที",
"Audio mixing: AudioContext.createMediaStreamDestination() mix mic + system → record 1 track",
"Web Speech API caption realtime overlay (browser built-in, no API key)",
"Picture-in-Picture recording = tab-only capture (Safari ได้, Chrome ตั้งแต่ 104)",
"Pause/Resume = MediaRecorder.pause()/resume() ไม่สร้าง file ใหม่ (single Blob)"
],
"cons": [
"Safari getDisplayMedia = tab only (ไม่ได้ full screen) ก่อน 16.4, ตอนนี้ full screen ได้",
"System audio (Chrome): ต้อง check 'Share tab audio' ตอนเลือก source — ไม่มี API enforce",
"Firefox ไม่ support system audio เลย (ต้อง mic only)",
"iOS Safari: ไม่รองรับ getDisplayMedia (ไม่มี screen capture extension)",
"WebM file ใหญ่ 720p30 1hr ≈ 2.5GB VP9, MP4 export ต้อง ffmpeg.wasm (30MB)",
"Memory: 1 hour recording = 2-3GB RAM (VideoEncoder + audio buffer pool)"
],
"gotchas": [
"getDisplayMedia = Promise ต้อง user gesture (button click) — ไม่งั้น NotAllowedError",
"videoBitsPerSecond: 2_500_000 (2.5Mbps) เป็น sweet spot — 5Mbps ไฟล์ใหญ่เกิน, 1Mbps เห็น artifact",
"audioBitsPerSecond: 128_000 Opus = podcast quality, 64_000 speech-only = OK",
"mimeType 'video/webm;codecs=vp9,opus' ตรวจ MediaRecorder.isTypeSupported ก่อนใช้ (fallback vp8)",
"Mic mixing: getUserMedia({audio:true}) → createMediaStreamSource → destination stream",
"Two stream: screen video + mixed audio → MediaRecorder รับได้แค่ 1 stream ต่อไป ต้อง addTrack merge",
"Pause/Resume ระหว่าง recording: dataavailable event ต้อง save chunk แยก, concat ตอน stop",
"Permission re-request: navigator.permissions.query({name:'camera'}) cache ไม่ได้, user ต้อง allow ใหม่ทุก navigation"
],
"anti_patterns": [
"Record เป็น data array สะสมใน memory — 1 hour = 2GB array, ใช้ stream + chunks เขียน disk incremental",
"getUserMedia({audio:true}) เพื่อ system audio — Chrome getDisplayMedia({audio:true}) เท่านั้นที่ได้ tab audio",
"WebM upload ตรงเข้า YouTube — YouTube รับ แต่ transcode 2x, MP4 เข้า transcode ครั้งเดียว",
"Bind record start บน page load — NotAllowedError ทันที, ต้อง user click handler",
"ไม่ handle 'ended' event ของ track — user click 'Stop sharing' ใน Chrome UI = MediaRecorder stop อัตโนมัติ, blob empty"
],
"prereqs": [
"1.9"
],
"next_steps": [
"6.3",
"6.8"
],
"examples": [
"sj88-video-editor-pro",
"sj88cute-frame-extractor"
],
"snippet": "const stream=await navigator.mediaDevices.getDisplayMedia({video:{frameRate:30},audio:true}); const rec=new MediaRecorder(stream,{mimeType:'video/webm;codecs=vp9,opus',videoBitsPerSecond:2_500_000});",
"snippet_full": "// Screen + mic + system audio mixed recorder (sj88-video-editor-pro style)\\nconst screen = await navigator.mediaDevices.getDisplayMedia({video:{frameRate:30,width:1920},audio:true});\\nconst mic = await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true}});\\nconst audioCtx = new AudioContext();\\nconst mixed = audioCtx.createMediaStreamDestination();\\naudioCtx.createMediaStreamSource(mic.getAudioTracks()[0]).connect(mixed);\\nif (screen.getAudioTracks()[0]) audioCtx.createMediaStreamSource(screen.getAudioTracks()[0]).connect(mixed);\\nconst out = new MediaStream([...screen.getVideoTracks(), mixed.stream.getAudioTracks()[0]]);\\nconst chunks = [];\\nconst rec = new MediaRecorder(out, {mimeType:'video/webm;codecs=vp9,opus', videoBitsPerSecond:2_500_000, audioBitsPerSecond:128_000});\\nrec.ondataavailable = e => chunks.push(e.data);\\nrec.onstop = () => downloadBlob(new Blob(chunks, {type:'video/webm'}), `rec-${Date.now()}.webm`);\\nrec.start(1000);\\nscreen.getVideoTracks()[0].onended = () => rec.stop();",
"tags": [
"screen",
"record",
"อัดหน้าจอ",
"getdisplaymedia",
"mediarecorder",
"tutorial",
"webm"
],
"difficulty": "Intermediate",
"time_to_learn": "2 hours",
"docs_url": "https://developer.mozilla.org/en-US/docs/Web/API/Screen_Capture_API",
"compare_with": [
"OBS Studio (native, scene+overlay)",
"Loom (cloud, paid)",
"Vimeo Record (web, paid)"
]
}
]
}