Phase 6 of 10

🎬 Media Apps

Audio + video + image

10 sub-phases
60 pros
60 cons
80 gotchas
50 anti-patterns
8100 chars code
6.1

🎵 Audio Editor (Waveform)

Intermediate⏱ 4 hours

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 (6)
• 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 (6)
• 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 (8)
• 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
🔀 Alternatives
vs Tone.js (effect chain, larger 200KB)
vs Howler.js (playback only, no waveform)
vs Peak.js (BBC, frame-accurate but ~150KB)
📚 Prereqs: 1.5, 1.6
➡️ Next: 6.6, 6.7
💻 Minimal (131 chars) ▶ Full example (633 chars)
const ws=WaveSurfer.create({container:'#wf',waveColor:'#ec4899',progressColor:'#a855f7',url:'audio.mp3',peaks:[/*pre-computed*/]});
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});
audiowaveformเสียงwavesurferwebaudiotrimfade
6.2

▶️ Custom Video Player

Intermediate⏱ 3 hours

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 (6)
• 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 (6)
• 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 (8)
• 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
🔀 Alternatives
vs Video.js (full player ~200KB, plugins)
vs Plyr (lightweight ~30KB, opinionated)
vs HLS.js (live streaming, MSE-based)
📚 Prereqs: 1.6
➡️ Next: 6.3, 6.8
💻 Minimal (121 chars) ▶ Full example (441 chars)
const v=document.querySelector('video'); v.playbackRate=1.5; v.requestPictureInPicture(); v.textTracks[0].mode='showing';
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'});
videoplayerวิดีโอhtml5webvttpipplayback
6.3

🎥 Video Editor (Timeline)

Advanced⏱ 2 weeks

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 (6)
• 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 (6)
• 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 (8)
• 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
🔀 Alternatives
vs Remotion (React, server-render only)
vs MLT/Melted (C++ pipeline, native only)
vs CapCut Web (closed source)
📚 Prereqs: 1.9, 6.2
➡️ Next: 6.6, 6.7, 6.10
💻 Minimal (183 chars) ▶ Full example (655 chars)
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});
// 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');
videoeditorวิดีโอtimelinewebcodecsffmpegkeyframe
6.4

🖼️ Image Editor (Crop/Filter)

Intermediate⏱ 4 hours

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 (6)
• 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 (6)
• 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 (8)
• 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
🔀 Alternatives
vs Fabric.js (object-oriented, ~300KB)
vs Konva.js (DOM-based, ~150KB)
vs P5.js (creative coding, ~700KB)
📦 Used in (3)
📚 Prereqs: 1.6
➡️ Next: 6.5, 6.9
💻 Minimal (135 chars) ▶ Full example (700 chars)
const ctx=canvas.getContext('2d'); ctx.filter='brightness(1.2) saturate(1.5) contrast(1.1)'; ctx.drawImage(img,0,0); ctx.filter='none';
// 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);
imageeditorรูปภาพcanvasfiltercropsticker
6.5

📄 PDF Generation

Intermediate⏱ 1 day

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 (6)
• 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 (6)
• 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 (8)
• 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 เสมอ
🔀 Alternatives
vs pdfmake (declarative, ~500KB)
vs pdf-lib (edit existing, ~400KB)
vs react-pdf (React, server-side only)
📦 Used in (1)
📚 Prereqs: 1.7
💻 Minimal (160 chars) ▶ Full example (646 chars)
const doc=new jsPDF(); doc.setFont('Sarabun'); doc.text('ใบเสร็จรับเงิน',20,20); doc.setFontSize(12); doc.text('Total: 350 บาท',20,30); doc.save('receipt.pdf');
// 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`);
pdfjspdfinvoiceใบเสร็จreceipthtml2pdfpuppeteer
6.6

📝 Lyric Video Maker

Intermediate⏱ 3 hours

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 (6)
• 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 (6)
• 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 (8)
• 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
🔀 Alternatives
vs Aegisub (native, desktop only)
vs Subtitle Edit (Windows only)
vs Hand-rolled WebVTT (simpler but no per-word)
📦 Used in (1)
📚 Prereqs: 1.9, 6.1
➡️ Next: 6.7
💻 Minimal (119 chars) ▶ Full example (856 chars)
video.ontimeupdate=()=>{for(let i=lines.length-1;i>=0;i--)if(t>=lines[i].start){line.textContent=lines[i].text;break}};
// 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();
lyriclrcเนื้อเพลงkaraoketimingsubtitlevtt
6.7

🎤 Karaoke Studio (Full)

Advanced⏱ 1 week

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 (6)
• 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 (6)
• 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 (8)
• 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
🔀 Alternatives
vs Aegisub (native desktop)
vs Karaoke Mug Editor (Windows)
vs Vrew (AI auto-sub, closed source)
📦 Used in (1)
📚 Prereqs: 6.6, 6.1, 1.9
➡️ Next: 6.10
💻 Minimal (122 chars) ▶ Full example (901 chars)
// 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]});
// 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);
karaokebeatfftlyricmp4ffmpegexport
6.8

💬 Subtitle Editor (VTT/SRT)

Beginner⏱ 2 hours

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 (6)
• 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 (6)
• 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 ต้อง &lt; &gt; &amp;
⚡ Gotchas (8)
• 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
🔀 Alternatives
vs Subtitle Edit (Windows native)
vs Aegisub (advanced ASS format)
vs Amara (web collab, paid)
📦 Used in (1)
📚 Prereqs: 1.3
➡️ Next: 6.3, 6.6
💻 Minimal (135 chars) ▶ Full example (811 chars)
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
// 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();
subtitlevttsrtcaptionคำบรรยายwebvtta11y
6.9

😂 Meme Generator

Beginner⏱ 1 hour

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 (6)
• 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 (6)
• 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 (8)
• 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)
🔀 Alternatives
vs imgflip (web, ad-supported)
vs Canva (full editor, $)
vs Kapwing (video meme, paid)
📚 Prereqs: 1.6
➡️ Next: 6.10
💻 Minimal (133 chars) ▶ Full example (1412 chars)
ctx.font=`bold ${h*.08}px Impact, 'Arial Black', sans-serif`; ctx.textAlign='center'; ctx.strokeText(txt,x,y); ctx.fillText(txt,x,y);
// 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');
memegeneratorมีมviralimpactsocialshare
6.10

🖥️ Screen Recorder

Intermediate⏱ 2 hours

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 (6)
• 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 (6)
• 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 (8)
• 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
🔀 Alternatives
vs OBS Studio (native, scene+overlay)
vs Loom (cloud, paid)
vs Vimeo Record (web, paid)
📚 Prereqs: 1.9
➡️ Next: 6.3, 6.8
💻 Minimal (199 chars) ▶ Full example (1045 chars)
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});
// 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();
screenrecordอัดหน้าจอgetdisplaymediamediarecordertutorialwebm

🤖 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 ต้อง &lt; &gt; &amp;"
      ],
      "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)"
      ]
    }
  ]
}
🔗 /api/skill (all 100) 💾 Download media-apps.json