diff --git a/.gitignore b/.gitignore index c23796c..6b88216 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ patch_target_styles.py # Temp *.tmp electron/resources/bin/ +.test-packaged-data/ +backend/vendor/ diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc5..f3f510d 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,2 @@ npx lint-staged +npm run typecheck diff --git a/FEATURES_IMPLEMENTED.md b/FEATURES_IMPLEMENTED.md deleted file mode 100644 index 71c7121..0000000 --- a/FEATURES_IMPLEMENTED.md +++ /dev/null @@ -1,280 +0,0 @@ -# PidioForge Desktop - New Features Implementation - -## Overview -This document describes the new features implemented to enhance the PidioForge Desktop application. - -## 🎯 Features Implemented - -### 1. Templates System -**Location:** `frontend/src/components/panels/TemplatesPanel.tsx` - -A comprehensive template management system that allows users to save and reuse complete project configurations. - -**Features:** -- Create custom templates with name, description, and module selections -- Save current project configuration as a template -- Apply templates to quickly set up new projects -- Delete unwanted templates -- Templates include all module configurations (Target, Branding, Audio, Lyrics, Spectrum, Overlay, Looping) - -**Backend API Endpoints:** -- `GET /api/templates` - List all templates -- `POST /api/templates` - Create new template -- `POST /api/templates/:id/apply` - Apply template to active project -- `DELETE /api/templates/:id` - Delete template - -**Usage:** -1. Configure your project with desired settings across all modules -2. Navigate to Templates panel -3. Click "Buat Template" -4. Enter name and description -5. Select which modules to include -6. Save template for future use - -### 2. Audio Waveform Visualization -**Location:** `frontend/src/components/ui/WaveformDisplay.tsx` - -A canvas-based waveform display component for visualizing audio peaks and playback progress. - -**Features:** -- Real-time waveform rendering from audio peak data -- Progress indicator showing current playback position -- Color-coded visualization (progress vs remaining) -- Click-to-seek functionality -- Responsive canvas rendering with device pixel ratio support -- Skeleton loader for loading states - -**Props:** -- `peaks: number[]` - Array of normalized peak values (0-1) -- `duration?: number` - Total audio duration in seconds -- `currentTime?: number` - Current playback position -- `height?: number` - Canvas height in pixels (default: 80) -- `color?: string` - Waveform color (default: #38bdf8) -- `progressColor?: string` - Progress indicator color (default: #22c55e) -- `backgroundColor?: string` - Canvas background (default: transparent) -- `onClick?: (time: number) => void` - Callback for seek functionality - -**Integration:** -Integrated into AudioMixingPanel to display waveform analysis results from the backend `/api/audio/analyze` and `/api/audio/validate` endpoints. - -### 3. Drag & Drop File Input -**Location:** `frontend/src/components/ui/DragDropZone.tsx` - -A flexible drag-and-drop zone component for file uploads with validation. - -**Features:** -- Visual feedback during drag operations -- File type validation (accept prop) -- File size validation (maxSize prop) -- Single or multiple file support -- Error messaging for invalid files -- Click-to-browse fallback -- Accessible file input integration - -**Props:** -- `onFileDrop: (files: File[]) => void` - Callback when files are dropped -- `accept?: string` - Accepted file types (MIME types or extensions) -- `multiple?: boolean` - Allow multiple files (default: false) -- `maxSize?: number` - Maximum file size in MB (default: 500) -- `className?: string` - Additional CSS classes -- `disabled?: boolean` - Disable the drop zone - -**Integration:** -Integrated into TargetPanel for visual and audio file inputs: -- Visual files: Accepts video/* and image/* formats -- Audio files: Accepts audio/* formats - -**Note:** Browser File API doesn't expose full file paths for security reasons. In production, this would integrate with Electron/Tauri file dialog APIs to get absolute paths. - -### 4. Enhanced Module System -**Updates to:** `frontend/src/types/app.types.ts`, `frontend/src/constants/modules.ts` - -Extended the module system to include the new Templates module. - -**Changes:** -- Added `'templates'` to `ModuleKey` type -- Added Templates module to navigation array -- Added Templates display configuration -- Added Templates icon (grid layout icon) -- Integrated Templates panel into SettingsPanel routing - -## 🎨 UI/UX Improvements - -### Audio Mixing Panel Enhancements -- **Loudness Metering:** Visual LUFS and Peak meters with color-coded zones -- **Waveform Display:** Real-time audio waveform visualization -- **Beat Detection:** Visual beat count display -- **Safety Indicators:** Warning system for audio levels - -### Target Panel Enhancements -- **Drag & Drop Zones:** Intuitive file input for visual and audio files -- **Visual Feedback:** Clear drop zones with icons and instructions -- **File Validation:** Automatic validation of file types and sizes - -## 🔧 Technical Details - -### TypeScript Compliance -All new components are fully typed with TypeScript: -- Proper prop interfaces -- Type-safe event handlers -- Generic type support where applicable -- No `any` types in production code - -### Performance Optimizations -- Canvas rendering uses `requestAnimationFrame` for smooth updates -- Waveform data is efficiently rendered with optimized bar calculations -- Drag counter prevents unnecessary re-renders during drag operations -- Device pixel ratio support for crisp rendering on high-DPI displays - -### Accessibility -- Semantic HTML structure -- ARIA labels and roles -- Keyboard navigation support -- Screen reader friendly - -## 📦 File Structure - -``` -frontend/src/ -├── components/ -│ ├── panels/ -│ │ ├── TemplatesPanel.tsx # NEW: Template management -│ │ ├── AudioMixingPanel.tsx # UPDATED: Waveform integration -│ │ ├── TargetPanel.tsx # UPDATED: Drag & drop integration -│ │ └── SettingsPanel.tsx # UPDATED: Templates routing -│ └── ui/ -│ ├── WaveformDisplay.tsx # NEW: Audio visualization -│ ├── DragDropZone.tsx # NEW: File drag & drop -│ └── ModuleIcon.tsx # UPDATED: Templates icon -├── types/ -│ └── app.types.ts # UPDATED: Templates module type -└── constants/ - └── modules.ts # UPDATED: Templates module config -``` - -## 🚀 Future Enhancements - -### Real-time Preview (Pending) -Requires backend implementation for FFmpeg streaming: -- Live preview generation endpoint -- WebSocket/SSE for progress updates -- Temporary file management -- Preview caching system - -**Backend Requirements:** -```javascript -// Proposed endpoint -POST /api/preview/generate -{ - config: ProjectConfig, - duration: number, // Preview duration in seconds - startTime: number // Start position in source -} - -// Response -{ - url: string, // Preview video URL - duration: number, - size: number -} -``` - -## 🧪 Testing - -### Manual Testing Checklist -- [x] Templates can be created with all module configurations -- [x] Templates can be applied to projects -- [x] Templates can be deleted -- [x] Waveform displays correctly with audio analysis data -- [x] Waveform responds to different peak data -- [x] Drag & drop accepts valid file types -- [x] Drag & drop rejects invalid file types -- [x] Drag & drop validates file sizes -- [x] TypeScript compilation passes without errors -- [x] All components render without console errors - -### Browser Compatibility -- Chrome/Edge: ✅ Full support -- Firefox: ✅ Full support -- Safari: ✅ Full support (with webkit prefixes) - -## 📝 API Integration - -### Templates API -All template operations are handled through the existing backend API: -- Templates are stored in application state -- Persisted to disk via state management -- Loaded on application startup - -### Audio Analysis API -Waveform data is fetched from: -- `/api/audio/analyze` - Full audio analysis with waveform -- `/api/audio/validate` - Quick validation with optional waveform - -### File Handling -Drag & drop currently uses browser File API. For production: -- Integrate with Electron `dialog.showOpenDialog()` -- Or Tauri `dialog::FileDialogBuilder` -- To get absolute file paths for FFmpeg processing - -## 🎓 Usage Examples - -### Creating a Template -```typescript -// User workflow: -1. Configure project settings in various panels -2. Navigate to Templates panel -3. Click "Buat Template" -4. Enter template details -5. Select modules to include -6. Click "Simpan Template" -``` - -### Using Waveform Display -```typescript -import { WaveformDisplay } from '../ui/WaveformDisplay'; - - -``` - -### Using Drag & Drop -```typescript -import { DragDropZone } from '../ui/DragDropZone'; - - { - updateConfig('input.audio', files[0].name); - }} - accept="audio/*,.mp3,.wav" - maxSize={500} -/> -``` - -## 📊 Impact - -### User Experience -- **Faster Workflow:** Templates reduce setup time by 80% -- **Better Visualization:** Waveform display improves audio understanding -- **Easier Input:** Drag & drop reduces friction in file selection - -### Code Quality -- **Type Safety:** 100% TypeScript coverage -- **Reusability:** All components are modular and reusable -- **Maintainability:** Clear separation of concerns - -## 🔗 Related Documentation -- [Backend API Documentation](./backend/README.md) -- [Component Library](./frontend/src/components/README.md) -- [State Management](./frontend/src/lib/README.md) - ---- - -**Last Updated:** 2026-07-14 -**Version:** 1.0.0 -**Author:** Development Team diff --git a/README.md b/README.md index 5a51d39..39d339d 100644 --- a/README.md +++ b/README.md @@ -117,14 +117,32 @@ Common errors: | `/api/loop/start` | POST | Start loop job | | `/api/lyrics/auto-align` | POST | Align lyrics | +## Documentation + +Extended documentation lives in [`docs/`](./docs/README.md): +- Testing guides and strategy +- Performance optimization +- Preview/realtime implementation +- UI redesign plan +- Audit reports +- Feature log + ## Validation Current release validation: - `npm run electron:build` — passed - `npm run typecheck` — passed -- Frontend tests — 31 passed +- `npm run lint` — 0 errors, 0 warnings +- Frontend tests (Vitest) — 192 passed across 28 test files - Electron native dependency rebuild — passed +- `npm run smoke:api` — API health + diagnostics + preview cleanup verified +- `npm run smoke:render` — Full render pipeline verified (portable, generates sample media) +- `npm run smoke:loop` — Loop flow verified +- `npm run smoke:queue` — Queue CRUD, duplicate, send-to-queue, start/pause verified +- `npm run smoke:project` — Project CRUD, export/import, presets, templates verified +- `npm run smoke:pipeline` — Config → validate → render → history → clear-done verified +- `npm run smoke:presets` — All preset lists, save/apply/delete, validate verified ## Technology diff --git a/REDESIGN.txt b/REDESIGN.txt new file mode 100644 index 0000000..749be03 --- /dev/null +++ b/REDESIGN.txt @@ -0,0 +1,100 @@ +BRIEF REDESIGN TOTAL — PidioForge Desktop + +TUJUAN +Merombak seluruh UI aplikasi (Kontrol Preview, Layer Editor, Rendering Settings, +Queue, Overlay, Seamless Looping, Bantuan) agar konsisten, rapi, dan profesional, +mengikuti referensi visual pola OBS Studio (dockable panel, hierarki warna jelas, +komponen modular). + +=================================================== +FASE 0 — FONDASI DESIGN SYSTEM (kerjakan sebelum menyentuh kode UI) +=================================================== +1. Audit semua komponen UI unik yang sudah ada di aplikasi. +2. Tetapkan design tokens: + - Palet warna: base panel, card, 1 warna aksen primary untuk semua CTA, + warna status (hijau=normal, kuning=warning, merah=error/aktif-rekam) + - Skala spacing tetap: 4 / 8 / 12 / 16 / 24 px + - Skala tipografi: judul section, label field, isi/nilai, caption + - Radius border & ketebalan garis konsisten +3. Definisikan komponen dasar reusable (tulis di style guide, belum diimplementasi): + - FieldRow: label kiri fixed-width + input kanan, SATU field per baris + (jangan pernah 2-3 field sejajar dalam satu baris) + - SliderControl: warna isi hijau solid + label persentase di kanan + - CheckboxRow: spacing vertikal 8px antar item + - Callout: biru + ikon lampu = tips, oranye + ikon peringatan = warning + - PresetButtonGroup: grid tombol preset, ukuran seragam, ikon + label + - StatRow: kolom rata otomatis, angka besar bold + label kecil + - Card: padding 16px, radius 8px, judul bold + deskripsi (gaya panel Bantuan) + +=================================================== +FASE 1 — PERBAIKAN STRUKTURAL (bug-fix, prioritas tertinggi) +=================================================== +4. Ganti SEMUA pola "3 field sejajar dalam 1 baris" (penyebab teks terpotong + di Rendering Settings, Seamless Looping, Overlay, Queue) menjadi FieldRow + satu-per-baris. +5. Pastikan lebar minimum window/panel cukup agar tidak ada elemen terpotong + saat resize. +CHECKPOINT: ambil ulang screenshot semua panel, pastikan tidak ada teks +terpotong sebelum lanjut ke Fase 2. + +=================================================== +FASE 2 — BANGUN KOMPONEN REUSABLE +=================================================== +6. Implementasikan semua komponen dari Fase 0 sebagai komponen kode reusable, + bukan styling manual per panel. +7. Uji setiap komponen secara terisolasi di satu panel kecil sebelum dipakai + massal ke seluruh aplikasi. + +=================================================== +FASE 3 — TERAPKAN KE PANEL (urutan wajib, satu panel tuntas sebelum lanjut) +=================================================== +8. Kontrol Preview + Layer Editor + - Ubah Layer Editor jadi list bergaya "Sources" (ikon show/hide + lock per + item), bukan tombol kotak sejajar. +9. Rendering Settings + - Tampilkan hanya field penting (Resolusi, FPS, Bitrate, Codec) di layar + utama; sembunyikan Smart Optimization & Batch Processing di balik + tombol "Advanced Settings". +10. Queue + - Terapkan StatRow untuk Total Job/Siaga/Memproses/Selesai/Gagal/Progress + dan untuk Beban CPU/Memori/Disk/Encoder. + - Terapkan PresetButtonGroup untuk AUTO OPTIMIZE/TURBO/ECO/Kualitas. +11. Overlay + - Terapkan SliderControl secara konsisten ke semua slider (Opacity, + Vignette, Grain, Color Grading). +12. Seamless Looping + - Perbaiki FieldRow di semua baris field. + - Pertahankan Visual Timeline Editor (drag marker Start/End) sebagai + elemen unggulan — jangan diubah, hanya disesuaikan warnanya ke token. +13. Bantuan + - Rapikan spacing & Card mengikuti token baru; struktur kontennya sudah + baik, tidak perlu dirombak total. + +=================================================== +FASE 4 — HIERARKI WARNA & AKSI +=================================================== +14. Satu warna aksen tetap untuk semua tombol CTA utama (Render, Mulai + Antrian, Buat Looping, dll) di SELURUH aplikasi — tidak boleh beda + warna antar panel. +15. Terapkan warna status (aktif/nonaktif/warning/error) secara identik di + semua indikator: status bar, stat row, checkbox, badge. + +=================================================== +FASE 5 — QA & REVIEW AKHIR +=================================================== +16. Review semua panel berurutan side-by-side, pastikan tombol/slider/ + checkbox terlihat identik gayanya di mana pun muncul. +17. Uji resize window di beberapa ukuran, pastikan tidak ada regresi + pemotongan teks. +18. Uji dengan 1 pengguna baru: minta selesaikan alur pilih file → render, + catat titik kebingungan. + +=================================================== +ATURAN KERJA SELAMA EKSEKUSI +=================================================== +- Jangan kerjakan banyak panel secara paralel; tuntaskan satu panel penuh + (termasuk QA) sebelum pindah ke panel berikutnya. +- Setiap keputusan visual HARUS merujuk ke token Fase 0, bukan keputusan + ad-hoc per panel. +- Jika dikerjakan lewat AI coding assistant, sertakan file style guide + Fase 0 ini di setiap prompt perombakan panel. \ No newline at end of file diff --git a/backend/audio-engine.mjs b/backend/audio-engine.mjs index 6fa18f5..f1a551e 100644 --- a/backend/audio-engine.mjs +++ b/backend/audio-engine.mjs @@ -6,44 +6,89 @@ import { runCmd } from './ffmpeg-utils.mjs'; export async function analyzeLoudness(file) { if (!file || !existsSync(file)) return { ok: false, error: 'file tidak ditemukan' }; - const result = await runCmd(FFMPEG, ['-hide_banner', '-nostats', '-i', file, '-af', 'loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json', '-f', 'null', '-']); + const result = await runCmd(FFMPEG, [ + '-hide_banner', + '-nostats', + '-i', + file, + '-af', + 'loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json', + '-f', + 'null', + '-', + ]); const text = `${result.stderr}\n${result.stdout}`; const m = text.match(/\{[\s\S]*?"target_offset"[\s\S]*?\}/); if (!m) return { ok: false, error: 'loudness analysis gagal', raw: text.slice(-1000) }; try { const j = JSON.parse(m[0]); - const inputI = Number(j.input_i); const inputTp = Number(j.input_tp); const inputLra = Number(j.input_lra); const targetOffset = Number(j.target_offset); + const inputI = Number(j.input_i); + const inputTp = Number(j.input_tp); + const inputLra = Number(j.input_lra); + const targetOffset = Number(j.target_offset); const warnings = []; if (inputTp > -1) warnings.push('true peak dekat/di atas batas aman'); if (inputI > -10) warnings.push('audio terlalu keras untuk standar upload'); if (inputI < -20) warnings.push('audio cenderung terlalu pelan'); - return { ok: true, integratedLufs: inputI, truePeak: inputTp, lra: inputLra, targetOffset, recommendation: `${targetOffset > 0 ? '+' : ''}${targetOffset.toFixed(1)} dB`, warnings }; - } catch (error) { return { ok: false, error: error.message }; } + return { + ok: true, + integratedLufs: inputI, + truePeak: inputTp, + lra: inputLra, + targetOffset, + recommendation: `${targetOffset > 0 ? '+' : ''}${targetOffset.toFixed(1)} dB`, + warnings, + }; + } catch (error) { + return { ok: false, error: error.message }; + } } export async function waveformData(file, seconds = 45, buckets = 160) { if (!file || !existsSync(file)) return { ok: false, error: 'file tidak ditemukan', peaks: [] }; const maxSeconds = Math.max(1, Math.min(300, Number(seconds) || 45)); return await new Promise(resolve => { - const child = spawn(FFMPEG, ['-v', 'error', '-i', file, '-t', String(maxSeconds), '-ac', '1', '-ar', '8000', '-f', 's16le', 'pipe:1'], { stdio: ['ignore', 'pipe', 'pipe'] }); - const chunks = []; let stderr = ''; + const child = spawn( + FFMPEG, + ['-v', 'error', '-i', file, '-t', String(maxSeconds), '-ac', '1', '-ar', '8000', '-f', 's16le', 'pipe:1'], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + const chunks = []; + let stderr = ''; child.stdout.on('data', b => chunks.push(b)); - child.stderr.on('data', b => stderr += b.toString()); + child.stderr.on('data', b => (stderr += b.toString())); child.on('error', error => resolve({ ok: false, error: error.message, peaks: [] })); child.on('close', code => { if (code !== 0) return resolve({ ok: false, error: stderr || 'waveform gagal', peaks: [] }); - const buf = Buffer.concat(chunks); const samples = Math.floor(buf.length / 2); const count = Math.max(20, Math.min(500, Number(buckets) || 160)); - const per = Math.max(1, Math.floor(samples / count)); const peaks = []; - for (let i = 0; i < count; i++) { let peak = 0; const start = i * per; const end = Math.min(samples, start + per); for (let j = start; j < end; j++) peak = Math.max(peak, Math.abs(buf.readInt16LE(j * 2)) / 32768); peaks.push(Number(peak.toFixed(3))); } + const buf = Buffer.concat(chunks); + const samples = Math.floor(buf.length / 2); + const count = Math.max(20, Math.min(500, Number(buckets) || 160)); + const per = Math.max(1, Math.floor(samples / count)); + const peaks = []; + for (let i = 0; i < count; i++) { + let peak = 0; + const start = i * per; + const end = Math.min(samples, start + per); + for (let j = start; j < end; j++) peak = Math.max(peak, Math.abs(buf.readInt16LE(j * 2)) / 32768); + peaks.push(Number(peak.toFixed(3))); + } resolve({ ok: true, seconds: maxSeconds, sampleRate: 8000, peaks }); }); }); } export function detectBeatsFromPeaks(peaks, seconds = 45) { - const arr = (peaks || []).map(Number); if (!arr.length) return []; - const avg = arr.reduce((a,b)=>a+b,0) / arr.length; const threshold = Math.max(avg * 1.45, 0.18); const beats = []; - arr.forEach((v, i) => { const prev = arr[i-1] || 0; const next = arr[i+1] || 0; if (v >= threshold && v >= prev && v >= next) beats.push({ time: Number(((i / arr.length) * seconds).toFixed(2)), strength: Number(v.toFixed(3)) }); }); + const arr = (peaks || []).map(Number); + if (!arr.length) return []; + const avg = arr.reduce((a, b) => a + b, 0) / arr.length; + const threshold = Math.max(avg * 1.45, 0.18); + const beats = []; + arr.forEach((v, i) => { + const prev = arr[i - 1] || 0; + const next = arr[i + 1] || 0; + if (v >= threshold && v >= prev && v >= next) + beats.push({ time: Number(((i / arr.length) * seconds).toFixed(2)), strength: Number(v.toFixed(3)) }); + }); return beats.slice(0, 300); } @@ -54,18 +99,23 @@ export function audioSafetyReport(config, assets, loudness) { else { if (!a.hasAudio) warnings.push(`${path.basename(a.file)}: tidak ada audio stream`); if ((a.bitrate || 0) > 0 && a.bitrate < 96000) warnings.push(`${path.basename(a.file)}: bitrate rendah`); - const ch = a.streams?.find(s => s.type === 'audio'); if (ch?.duration && Number(ch.duration) < 1) warnings.push(`${path.basename(a.file)}: durasi terlalu pendek`); + const ch = a.streams?.find(s => s.type === 'audio'); + if (ch?.duration && Number(ch.duration) < 1) warnings.push(`${path.basename(a.file)}: durasi terlalu pendek`); } } if (loudness?.truePeak > -1) warnings.push('clipping risk / true peak terlalu tinggi'); - if (config.audio?.normalize === false && loudness?.integratedLufs < -20) warnings.push('normalize disarankan karena audio pelan'); + if (config.audio?.normalize === false && loudness?.integratedLufs < -20) + warnings.push('normalize disarankan karena audio pelan'); if (config.audio?.limiter === false) warnings.push('limiter off: risiko pecah saat gain tinggi'); return { ok: warnings.length === 0, warnings }; } -export function tuneSpectrumFromWaveform(waveform, beats, config = {}) { - const peaks = waveform?.peaks || []; const avg = peaks.length ? peaks.reduce((a,b)=>a+Number(b),0)/peaks.length : 0; const max = peaks.length ? Math.max(...peaks.map(Number)) : 0; - const dynamicRange = max - avg; const beatDensity = (beats?.length || 0) / Math.max(1, Number(waveform?.seconds || 45)); +export function tuneSpectrumFromWaveform(waveform, beats, _config = {}) { + const peaks = waveform?.peaks || []; + const avg = peaks.length ? peaks.reduce((a, b) => a + Number(b), 0) / peaks.length : 0; + const max = peaks.length ? Math.max(...peaks.map(Number)) : 0; + const dynamicRange = max - avg; + const beatDensity = (beats?.length || 0) / Math.max(1, Number(waveform?.seconds || 45)); const sensitivity = Math.max(25, Math.min(85, Math.round(45 + dynamicRange * 70 - beatDensity * 8))); const height = dynamicRange > 0.45 ? 120 : dynamicRange > 0.25 ? 150 : 180; const transparency = avg > 0.35 ? 72 : 84; @@ -73,5 +123,14 @@ export function tuneSpectrumFromWaveform(waveform, beats, config = {}) { if (!peaks.length) warnings.push('waveform tidak tersedia'); if (max < 0.08) warnings.push('audio terlalu pelan untuk spectrum reaktif'); if (beats?.length === 0) warnings.push('beat tidak terdeteksi, gunakan fallback reactive'); - return { sensitivity, height, transparency, dynamicRange: Number(dynamicRange.toFixed(3)), avgPeak: Number(avg.toFixed(3)), maxPeak: Number(max.toFixed(3)), beatDensity: Number(beatDensity.toFixed(3)), warnings }; + return { + sensitivity, + height, + transparency, + dynamicRange: Number(dynamicRange.toFixed(3)), + avgPeak: Number(avg.toFixed(3)), + maxPeak: Number(max.toFixed(3)), + beatDensity: Number(beatDensity.toFixed(3)), + warnings, + }; } diff --git a/backend/config.mjs b/backend/config.mjs index 2f34bb7..0ccae11 100644 --- a/backend/config.mjs +++ b/backend/config.mjs @@ -9,44 +9,360 @@ export const dbPath = path.join(workspaceDir, 'state.json'); export function deepMerge(a, b) { if (!b || typeof b !== 'object' || Array.isArray(b)) return b; const out = { ...(a || {}) }; - for (const [k, v] of Object.entries(b)) out[k] = v && typeof v === 'object' && !Array.isArray(v) ? deepMerge(a?.[k] || {}, v) : v; + for (const [k, v] of Object.entries(b)) + out[k] = v && typeof v === 'object' && !Array.isArray(v) ? deepMerge(a?.[k] || {}, v) : v; return out; } export const defaultConfig = { target: { - engine: 'Auto GPU', modeVideo: 'Video/Gambar Fixed', modeRender: 'FFmpeg', bitrate: '4500k', fps: 30, - quality: 'balanced', duration: 0, totalBatch: 1, resolution: '1280x720', width: 1280, height: 720, - aspect: '16:9', maxZoom: 110, speed: 100, hardwareAccel: 'auto', overwrite: false, - outputPattern: '{title}-{date}-{num}', crf: 22, pixelFormat: 'yuv420p', faststart: true, + engine: 'Auto GPU', + modeVideo: 'Video/Gambar Fixed', + modeRender: 'FFmpeg', + bitrate: '4500k', + fps: 30, + quality: 'balanced', + duration: 0, + totalBatch: 1, + resolution: '1280x720', + width: 1280, + height: 720, + aspect: '16:9', + maxZoom: 110, + speed: 100, + hardwareAccel: 'auto', + overwrite: false, + outputPattern: '{title}-{date}-{num}', + crf: 22, + pixelFormat: 'yuv420p', + faststart: true, }, input: { - bahanFolder: '', visualFolder: '', audioFolder: '', lyricFolder: '', visual: '', audio: '', title: '', output: 'Hasil', - autoPairByName: true, pairMode: 'by-name', excludeOutputOnScan: true, + bahanFolder: '', + visualFolder: '', + audioFolder: '', + lyricFolder: '', + visual: '', + audio: '', + title: '', + output: 'Hasil', + autoPairByName: true, + pairMode: 'by-name', + excludeOutputOnScan: true, ignoreWords: 'official,lyrics,lyric,visual,full,hd,video,audio,music', }, - branding: { bumperVideo: '', bumperEnabled: false, bumperPosition: 'Akhir', bumperTransition: 'fade', bumperDuration: 3, bumperFadeDuration: 0.45, bumperAudioMode: 'after-intro', logo: '', logoEnabled: true, logoPosition: 'Kanan Atas', logoScale: 18, logoOpacity: 100, logoMarginX: 20, logoMarginY: 20, logoStart: 0, logoEnd: 0, logoAnimation: 'none', logoFadeDuration: 0.6, safeAreaPreset: 'youtube', ctaGreenscreen: '', ctaEnabled: false, ctaPreset: 'subscribe-lower-right', ctaPosition: 'Kanan Bawah', ctaScale: 26, ctaAt: 2, ctaDuration: 8, ctaChromaPreset: 'green', ctaChromaColor: '0x00ff00', ctaSimilarity: 0.35, ctaBlend: 0.08, watermarkText: '', watermarkEnabled: false, watermarkPosition: 'Kiri Bawah', watermarkOpacity: 70, watermarkMode: 'always', watermarkInterval: 12, watermarkVisibleDuration: 5, layerOrder: 'bumper,particle,logo,cta,spectrum,lyrics,watermark', brandPreset: 'custom' }, - audio: { videoVolume: 0, bgmVolume: 100, audioBitrate: '192k', order: 'acak', mixMode: 'single', platformPreset: 'youtube-music', normalize: true, limiter: true, autoGain: true, fadeIn: 0.6, fadeOut: 1.2, masterGain: 100, pan: 0, bassGain: 0, midGain: 0, trebleGain: 0, highPass: 0, lowPass: 0, deHum: false, noiseGate: false, noiseGateThreshold: -45, compressor: false, compressorThreshold: -18, compressorRatio: 3, asmMode: false, ambientLoop: '', ambientVolume: 15, voiceTrack: '', voiceVolume: 100, effectTrack: '', effectVolume: 80, reactiveFx: 'Beat Flash', reactiveStrength: 40, beatFlashColor: 'white', beatDetection: true, songs: [], introSongs: [], endingSong: '', endingVolume: 100, crossfade: 0.8, silenceBetween: 0, autoDuck: false, duckingLevel: 35, stems: [] }, - lyrics: { enabled: true, file: '', srtPath: '', outputFile: '', ai: 'off', autoMode: 'from-text', model: 'Cepat', language: 'Auto', align: 'Rata Tengah', position: 'Bawah', scale: 28, spacing: 110, outline: 1, shadow: 1, color: '#ffffff', highlightColor: '#22c55e', font: 'Arial', glow: false, karaoke: false, wordByWord: false, uppercase: false, maxChars: 42, lineDuration: 3, leadIn: 0.15, offset: 0, safeArea: true, stylePreset: 'modern', exportFormat: 'srt', autoSave: true, smartTiming: true, beatSnap: true, beatSnapWindow: 0.22, minLineDuration: 1.1, maxLineDuration: 5.0, readingSpeedCps: 14, qualityGate: 82 }, - spectrum: { enabled: true, model: 'Bar', analyzerMode: 'frequency', stylePreset: 'clean-wave', mirror: 'Off', position: 'Bawah', zoom: 100, crop: 0, y: 24, previewY: 74, height: 128, widthMode: 'full', marginX: 0, marginY: 34, transparency: 80, colors: ['white'], color1: 'white', color2: '#22c55e', glow: false, glowStrength: 35, progressBar: true, progressStyle: 'line', progressColor: 'white', nowPlaying: true, nowPlayingTemplate: '{title}', nowPlayingPosition: 'Atas', nowPlayingX: 50, nowPlayingY: 14, nowPlayingArtist: '', nowPlayingAlbum: '', nowPlayingAutoFromFile: true, beatReactive: true, beatSensitivity: 55, autoTune: true, smoothing: 45, gain: 1.0, analyzerQuality: 'balanced' }, - overlay: { enabled: true, timestamp: false, timestampText: 'Rendered by PidioForge', timestampPosition: 'Kiri Atas', songTransition: 'fade', particleFile: '', videoParticle: true, particleSpeed: 100, particleOpacity: 70, particleBlend: 'normal', overlayFile: '', overlayEnabled: false, overlayOpacity: 80, overlayScale: 100, overlayPosition: 'Tengah', overlayStart: 0, overlayEnd: 0, lowerThirdEnabled: false, lowerThirdText: '', lowerThirdPosition: 'Bawah', lowerThirdAt: 2, lowerThirdDuration: 5, playlist: true, playlistPosition: 'Kanan Bawah', glow: false, glowColor: '#22c55e', glowOpacity: 18, vignette: false, vignetteStrength: 0.35, filmGrain: false, grainStrength: 12, scanlines: false, scanlineOpacity: 6, frameBorder: false, borderColor: 'white', borderThickness: 6, letterbox: false, letterboxSize: 80, darken: false, darkenOpacity: 15, stylePreset: 'clean' }, - performance: { mode: 'balanced', liveMonitor: true, refreshMs: 1500, queueConcurrency: 1, ffmpegThreads: 0, x264Preset: 'medium', hardwareAccel: 'auto', thermalGuard: true, maxCpu: 85, maxMemory: 85, autoTune: true, previewQuality: 'normal', logLevel: 'normal', safeMode: false }, - preview: { enabled: true, duration: 4, startAt: 0, region: 'custom', quality: 'draft', width: 640, height: 360, fps: 18, safeAreaPreset: 'youtube', autoRefresh: false, showSafeArea: true, showGrid: false, outputFormat: 'mp4', lastUrl: '', lastOutput: '', lastSnapshotUrl: '', lastSnapshotOutput: '', lastGeneratedAt: '' }, + branding: { + bumperVideo: '', + bumperEnabled: false, + bumperPosition: 'Akhir', + bumperTransition: 'fade', + bumperDuration: 3, + bumperFadeDuration: 0.45, + bumperAudioMode: 'after-intro', + logo: '', + logoEnabled: true, + logoPosition: 'Kanan Atas', + logoScale: 18, + logoOpacity: 100, + logoMarginX: 20, + logoMarginY: 20, + logoStart: 0, + logoEnd: 0, + logoAnimation: 'none', + logoFadeDuration: 0.6, + safeAreaPreset: 'youtube', + ctaGreenscreen: '', + ctaEnabled: false, + ctaPreset: 'subscribe-lower-right', + ctaPosition: 'Kanan Bawah', + ctaScale: 26, + ctaAt: 2, + ctaDuration: 8, + ctaChromaPreset: 'green', + ctaChromaColor: '0x00ff00', + ctaSimilarity: 0.35, + ctaBlend: 0.08, + watermarkText: '', + watermarkEnabled: false, + watermarkPosition: 'Kiri Bawah', + watermarkOpacity: 70, + watermarkMode: 'always', + watermarkInterval: 12, + watermarkVisibleDuration: 5, + layerOrder: 'bumper,particle,logo,cta,spectrum,lyrics,watermark,nowPlaying,timestamp,lowerThird', + brandPreset: 'custom', + }, + audio: { + videoVolume: 0, + bgmVolume: 100, + audioBitrate: '192k', + order: 'acak', + mixMode: 'single', + platformPreset: 'youtube-music', + normalize: true, + limiter: true, + autoGain: true, + fadeIn: 0.6, + fadeOut: 1.2, + masterGain: 100, + pan: 0, + bassGain: 0, + midGain: 0, + trebleGain: 0, + highPass: 0, + lowPass: 0, + deHum: false, + noiseGate: false, + noiseGateThreshold: -45, + compressor: false, + compressorThreshold: -18, + compressorRatio: 3, + asmMode: false, + ambientLoop: '', + ambientVolume: 15, + voiceTrack: '', + voiceVolume: 100, + effectTrack: '', + effectVolume: 80, + reactiveFx: 'Beat Flash', + reactiveStrength: 40, + beatFlashColor: 'white', + beatDetection: true, + songs: [], + introSongs: [], + endingSong: '', + endingVolume: 100, + crossfade: 0.8, + silenceBetween: 0, + autoDuck: false, + duckingLevel: 35, + stems: [], + }, + lyrics: { + enabled: true, + file: '', + srtPath: '', + outputFile: '', + ai: 'off', + autoMode: 'from-text', + model: 'Cepat', + language: 'Auto', + align: 'Rata Tengah', + position: 'Bawah', + scale: 28, + spacing: 110, + outline: 1, + shadow: 1, + color: '#ffffff', + highlightColor: '#22c55e', + font: 'Arial', + glow: false, + karaoke: false, + wordByWord: false, + uppercase: false, + maxChars: 42, + lineDuration: 3, + leadIn: 0.15, + offset: 0, + safeArea: true, + stylePreset: 'modern', + exportFormat: 'srt', + autoSave: true, + smartTiming: true, + beatSnap: true, + beatSnapWindow: 0.22, + minLineDuration: 1.1, + maxLineDuration: 5.0, + readingSpeedCps: 14, + qualityGate: 82, + }, + spectrum: { + enabled: true, + model: 'Bar', + analyzerMode: 'frequency', + stylePreset: 'clean-wave', + mirror: 'Off', + position: 'Bawah', + zoom: 100, + crop: 0, + y: 24, + previewY: 74, + height: 128, + widthMode: 'full', + marginX: 0, + marginY: 34, + transparency: 80, + colors: ['white'], + color1: 'white', + color2: '#22c55e', + glow: false, + glowStrength: 35, + progressBar: true, + progressStyle: 'line', + progressColor: 'white', + nowPlaying: true, + nowPlayingTemplate: '{title}', + nowPlayingPosition: 'Atas', + nowPlayingX: 50, + nowPlayingY: 14, + nowPlayingArtist: '', + nowPlayingAlbum: '', + nowPlayingAutoFromFile: true, + beatReactive: true, + beatSensitivity: 55, + autoTune: true, + smoothing: 45, + gain: 1.0, + analyzerQuality: 'balanced', + gallery: { + enabled: false, + images: [], + activeIndex: 0, + transition: 'fade', + duration: 5, + shuffle: false, + kenBurns: false, + kenBurnsMode: 'random', + }, + }, + overlay: { + enabled: true, + timestamp: false, + timestampText: 'Rendered by PidioForge', + timestampPosition: 'Kiri Atas', + songTransition: 'fade', + particleFile: '', + videoParticle: true, + particleSpeed: 100, + particleOpacity: 70, + particleBlend: 'normal', + overlayFile: '', + overlayEnabled: false, + overlayOpacity: 80, + overlayScale: 100, + overlayPosition: 'Tengah', + overlayStart: 0, + overlayEnd: 0, + lowerThirdEnabled: false, + lowerThirdText: '', + lowerThirdPosition: 'Bawah', + lowerThirdAt: 2, + lowerThirdDuration: 5, + playlist: true, + playlistPosition: 'Kanan Bawah', + glow: false, + glowColor: '#22c55e', + glowOpacity: 18, + vignette: false, + vignetteStrength: 0.35, + filmGrain: false, + grainStrength: 12, + scanlines: false, + scanlineOpacity: 6, + frameBorder: false, + borderColor: 'white', + borderThickness: 6, + letterbox: false, + letterboxSize: 80, + darken: false, + darkenOpacity: 15, + stylePreset: 'clean', + }, + performance: { + mode: 'balanced', + liveMonitor: true, + refreshMs: 1500, + queueConcurrency: 1, + ffmpegThreads: 0, + x264Preset: 'medium', + hardwareAccel: 'auto', + thermalGuard: true, + maxCpu: 85, + maxMemory: 85, + autoTune: true, + previewQuality: 'normal', + logLevel: 'normal', + safeMode: false, + }, + preview: { + enabled: true, + duration: 4, + startAt: 0, + region: 'custom', + quality: 'draft', + width: 640, + height: 360, + fps: 18, + safeAreaPreset: 'youtube', + autoRefresh: false, + showSafeArea: true, + showGrid: false, + outputFormat: 'mp4', + lastUrl: '', + lastOutput: '', + lastSnapshotUrl: '', + lastSnapshotOutput: '', + lastGeneratedAt: '', + }, }; export const socialPresets = [ - { id: 'preset-youtube-720', name: 'YouTube HD Ringan 720p', config: { target: { resolution: '1280x720', width: 1280, height: 720, bitrate: '4500k', quality: 'balanced', fps: 30 } } }, - { id: 'preset-youtube-1080', name: 'YouTube Full HD 1080p', config: { target: { resolution: '1920x1080', width: 1920, height: 1080, bitrate: '8000k', quality: 'high', fps: 30 } } }, - { id: 'preset-shorts', name: 'Shorts / TikTok / Reels', config: { target: { resolution: '1080x1920', width: 1080, height: 1920, bitrate: '7000k', quality: 'balanced', fps: 30 } } }, - { id: 'preset-square', name: 'Square Feed 1080x1080', config: { target: { resolution: '1080x1080', width: 1080, height: 1080, bitrate: '6000k', quality: 'balanced', fps: 30 } } }, - { id: 'preset-fast', name: 'Thumbnail / Preview Cepat', config: { target: { quality: 'fast', bitrate: '2500k', fps: 24 } } }, + { + id: 'preset-youtube-720', + name: 'YouTube HD Ringan 720p', + config: { + target: { resolution: '1280x720', width: 1280, height: 720, bitrate: '4500k', quality: 'balanced', fps: 30 }, + }, + }, + { + id: 'preset-youtube-1080', + name: 'YouTube Full HD 1080p', + config: { + target: { resolution: '1920x1080', width: 1920, height: 1080, bitrate: '8000k', quality: 'high', fps: 30 }, + }, + }, + { + id: 'preset-shorts', + name: 'Shorts / TikTok / Reels', + config: { + target: { resolution: '1080x1920', width: 1080, height: 1920, bitrate: '7000k', quality: 'balanced', fps: 30 }, + }, + }, + { + id: 'preset-square', + name: 'Square Feed 1080x1080', + config: { + target: { resolution: '1080x1080', width: 1080, height: 1080, bitrate: '6000k', quality: 'balanced', fps: 30 }, + }, + }, + { + id: 'preset-fast', + name: 'Thumbnail / Preview Cepat', + config: { target: { quality: 'fast', bitrate: '2500k', fps: 24 } }, + }, ]; export const defaultState = { activeProjectId: 'default', - projects: [{ id: 'default', name: 'Project Utama', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), config: defaultConfig }], + projects: [ + { + id: 'default', + name: 'Project Utama', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + config: defaultConfig, + }, + ], presets: socialPresets.map(p => ({ ...p, config: deepMerge(defaultConfig, p.config) })), jobs: [], - queue: { running: false, paused: false, concurrency: 1, autoStart: false, stopOnError: false, startedAt: '', finishedAt: '' }, + queue: { + running: false, + paused: false, + concurrency: 1, + autoStart: false, + stopOnError: false, + startedAt: '', + finishedAt: '', + }, logs: ['GUI siap.'], }; diff --git a/backend/ffmpeg-utils.mjs b/backend/ffmpeg-utils.mjs index 0f6fca6..aa4c997 100644 --- a/backend/ffmpeg-utils.mjs +++ b/backend/ffmpeg-utils.mjs @@ -73,6 +73,12 @@ export function mimeFor(file) { const e = path.extname(file || '').toLowerCase(); if (e === '.mp4') return 'video/mp4'; if (e === '.webm') return 'video/webm'; + if (e === '.mp3') return 'audio/mpeg'; + if (e === '.wav') return 'audio/wav'; + if (e === '.m4a') return 'audio/mp4'; + if (e === '.aac') return 'audio/aac'; + if (e === '.flac') return 'audio/flac'; + if (e === '.ogg') return 'audio/ogg'; if (e === '.jpg' || e === '.jpeg') return 'image/jpeg'; if (e === '.png') return 'image/png'; if (e === '.gif') return 'image/gif'; diff --git a/backend/http-utils.mjs b/backend/http-utils.mjs index 97e90b9..39c5ffd 100644 --- a/backend/http-utils.mjs +++ b/backend/http-utils.mjs @@ -5,12 +5,27 @@ export class HttpError extends Error { } } +/** + * Sanitize an error before it reaches the client. Server errors (5xx) never + * expose the raw message/stack to the frontend — only a generic message, + * with the real error logged server-side for debugging. Client errors (4xx, + * e.g. HttpError, validation failures) keep their specific message since + * those are meant to guide the user. + */ +export function sanitizeErrorMessage(error, status = 500) { + if (status >= 500) { + console.error('Internal server error:', error); + return 'Terjadi kesalahan pada server. Periksa log untuk detail.'; + } + return error?.message || String(error); +} + export function createHttpUtils({ allowedOrigin, maxBodyBytes }) { function corsHeaders(req = null) { const origin = req?.headers?.origin || allowedOrigin; const allowed = new Set([allowedOrigin, 'http://127.0.0.1:1420', 'http://localhost:1420', 'file://', 'null']); return { - 'access-control-allow-origin': (allowed.has(origin) || origin === 'null' || !origin) ? '*' : allowedOrigin, + 'access-control-allow-origin': allowed.has(origin) || origin === 'null' || !origin ? '*' : allowedOrigin, 'access-control-allow-methods': 'GET,POST,PUT,DELETE,OPTIONS', 'access-control-allow-headers': 'content-type', }; @@ -35,9 +50,17 @@ export function createHttpUtils({ allowedOrigin, maxBodyBytes }) { } const text = Buffer.concat(chunks).toString('utf8'); if (!text) return {}; - try { return JSON.parse(text); } - catch { throw new HttpError(400, 'JSON tidak valid'); } + try { + return JSON.parse(text); + } catch { + throw new HttpError(400, 'JSON tidak valid'); + } + } + + function sendCaughtError(res, error) { + const status = Number(error?.status || 500); + return sendError(res, status, sanitizeErrorMessage(error, status)); } - return { body, corsHeaders, json, sendError }; + return { body, corsHeaders, json, sendError, sendCaughtError }; } diff --git a/backend/loop-engine.mjs b/backend/loop-engine.mjs index c160087..7601f08 100644 --- a/backend/loop-engine.mjs +++ b/backend/loop-engine.mjs @@ -7,22 +7,58 @@ import { runCmd, runBuffer, runLoopFfmpeg, ffprobeInfo, diskStats } from './ffmp import { workspaceDir } from './config.mjs'; export function outputPresetArgs(preset = 'source') { - if (preset === 'youtube1080') return ['-vf', 'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2']; + if (preset === 'youtube1080') + return ['-vf', 'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2']; if (preset === 'shorts') return ['-vf', 'scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920']; if (preset === 'tiktok') return ['-vf', 'scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920']; if (preset === 'square') return ['-vf', 'scale=1080:1080:force_original_aspect_ratio=increase,crop=1080:1080']; - if (preset === 'wallpaper4k') return ['-vf', 'scale=3840:2160:force_original_aspect_ratio=decrease,pad=3840:2160:(ow-iw)/2:(oh-ih)/2']; + if (preset === 'wallpaper4k') + return ['-vf', 'scale=3840:2160:force_original_aspect_ratio=decrease,pad=3840:2160:(ow-iw)/2:(oh-ih)/2']; return []; } -export async function makeLoopUnit({ source, unitPath, trimStart, segmentDuration, loopStyle, crossfade, muteAudio, audioFade, inputInfo }) { +export async function makeLoopUnit({ + source, + unitPath, + trimStart, + segmentDuration, + loopStyle, + crossfade, + muteAudio, + audioFade, + inputInfo, +}) { const ss = trimStart > 0 ? ['-ss', String(trimStart)] : []; const t = ['-t', String(segmentDuration)]; if (loopStyle === 'pingpong') { const filters = ['[0:v]split[vf][vr]', '[vr]reverse[rv]', '[vf][rv]concat=n=2:v=1:a=0[v]']; const args = ['-y', ...ss, ...t, '-i', source, '-filter_complex', filters.join(';'), '-map', '[v]']; - if (!muteAudio && inputInfo.hasAudio) args.push('-map', '0:a?', '-af', audioFade ? `afade=t=in:st=0:d=${Math.min(1, segmentDuration / 4)},afade=t=out:st=${Math.max(0, segmentDuration - Math.min(1, segmentDuration / 4))}:d=${Math.min(1, segmentDuration / 4)}` : 'anull', '-c:a', 'aac', '-b:a', '192k'); - args.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', unitPath); + if (!muteAudio && inputInfo.hasAudio) + args.push( + '-map', + '0:a?', + '-af', + audioFade + ? `afade=t=in:st=0:d=${Math.min(1, segmentDuration / 4)},afade=t=out:st=${Math.max(0, segmentDuration - Math.min(1, segmentDuration / 4))}:d=${Math.min(1, segmentDuration / 4)}` + : 'anull', + '-c:a', + 'aac', + '-b:a', + '192k', + ); + args.push( + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-crf', + '20', + '-pix_fmt', + 'yuv420p', + '-movflags', + '+faststart', + unitPath, + ); const result = await runCmd(FFMPEG, args); if (!result.ok) throw new Error(result.stderr || 'Ping-pong loop gagal.'); return; @@ -30,9 +66,37 @@ export async function makeLoopUnit({ source, unitPath, trimStart, segmentDuratio if (loopStyle === 'crossfade' && crossfade > 0.05) { const fade = Math.min(crossfade, Math.max(0.05, segmentDuration / 3)); const offset = Math.max(0.05, segmentDuration - fade); - const filters = [`[0:v][1:v]xfade=transition=fade:duration=${fade}:offset=${offset},trim=duration=${segmentDuration},setpts=PTS-STARTPTS,format=yuv420p[v]`]; - const args = ['-y', ...ss, ...t, '-i', source, ...ss, ...t, '-i', source, '-filter_complex', filters.join(';'), '-map', '[v]']; - if (!muteAudio && inputInfo.hasAudio) args.push('-map', '0:a?', '-af', audioFade ? `afade=t=in:st=0:d=${fade},afade=t=out:st=${Math.max(0, segmentDuration - fade)}:d=${fade}` : 'anull', '-c:a', 'aac', '-b:a', '192k'); + const filters = [ + `[0:v][1:v]xfade=transition=fade:duration=${fade}:offset=${offset},trim=duration=${segmentDuration},setpts=PTS-STARTPTS,format=yuv420p[v]`, + ]; + const args = [ + '-y', + ...ss, + ...t, + '-i', + source, + ...ss, + ...t, + '-i', + source, + '-filter_complex', + filters.join(';'), + '-map', + '[v]', + ]; + if (!muteAudio && inputInfo.hasAudio) + args.push( + '-map', + '0:a?', + '-af', + audioFade + ? `afade=t=in:st=0:d=${fade},afade=t=out:st=${Math.max(0, segmentDuration - fade)}:d=${fade}` + : 'anull', + '-c:a', + 'aac', + '-b:a', + '192k', + ); args.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-movflags', '+faststart', unitPath); const result = await runCmd(FFMPEG, args); if (!result.ok) throw new Error(result.stderr || 'Crossfade loop gagal.'); @@ -40,13 +104,48 @@ export async function makeLoopUnit({ source, unitPath, trimStart, segmentDuratio } const args = ['-y', ...ss, ...t, '-i', source]; if (muteAudio) args.push('-an'); - else if (audioFade && inputInfo.hasAudio) args.push('-af', `afade=t=in:st=0:d=${Math.min(1, segmentDuration / 4)},afade=t=out:st=${Math.max(0, segmentDuration - Math.min(1, segmentDuration / 4))}:d=${Math.min(1, segmentDuration / 4)}`); - args.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', unitPath); + else if (audioFade && inputInfo.hasAudio) + args.push( + '-af', + `afade=t=in:st=0:d=${Math.min(1, segmentDuration / 4)},afade=t=out:st=${Math.max(0, segmentDuration - Math.min(1, segmentDuration / 4))}:d=${Math.min(1, segmentDuration / 4)}`, + ); + args.push( + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-crf', + '20', + '-pix_fmt', + 'yuv420p', + '-c:a', + 'aac', + '-b:a', + '192k', + '-movflags', + '+faststart', + unitPath, + ); const result = await runCmd(FFMPEG, args); if (!result.ok) throw new Error(result.stderr || 'Loop unit gagal.'); } -export async function renderLoopVideo({ input = '', duration = 60, output = '', mode = 'copy', loopStyle = 'normal', crossfade = 0.5, trimStart = 0, trimEnd = 0, muteAudio = false, audioFade = false, preset = 'source' } = {}, callbacks = {}) { +export async function renderLoopVideo( + { + input = '', + duration = 60, + output = '', + mode = 'copy', + loopStyle = 'normal', + crossfade = 0.5, + trimStart = 0, + trimEnd = 0, + muteAudio = false, + audioFade = false, + preset = 'source', + } = {}, + callbacks = {}, +) { const source = path.resolve(String(input || '')); const targetDuration = Math.max(1, Math.min(24 * 60 * 60, Number(duration || 60))); if (!source || !existsSync(source)) throw new Error('File video loop tidak ditemukan.'); @@ -60,14 +159,61 @@ export async function renderLoopVideo({ input = '', duration = 60, output = '', const start = Math.max(0, Number(trimStart || 0)); const end = Number(trimEnd || 0); const sourceDuration = Number(inputInfo.duration || 0); - const segmentDuration = Math.max(0.2, end > start ? end - start : Math.max(0.2, sourceDuration - start || sourceDuration || targetDuration)); - const needsUnit = loopStyle !== 'normal' || start > 0 || end > 0 || muteAudio || audioFade || mode === 'encode' || preset !== 'source'; + const segmentDuration = Math.max( + 0.2, + end > start ? end - start : Math.max(0.2, sourceDuration - start || sourceDuration || targetDuration), + ); + const needsUnit = + loopStyle !== 'normal' || + start > 0 || + end > 0 || + muteAudio || + audioFade || + mode === 'encode' || + preset !== 'source'; const loopInput = needsUnit ? path.join(outDir, `${safeId('loop-unit')}.mp4`) : source; - if (needsUnit) await makeLoopUnit({ source, unitPath: loopInput, trimStart: start, segmentDuration, loopStyle, crossfade: Number(crossfade || 0), muteAudio, audioFade, inputInfo }); - const args = ['-y', '-stream_loop', '-1', '-i', loopInput, '-t', String(targetDuration), '-map', '0:v:0', '-map', '0:a?']; + if (needsUnit) + await makeLoopUnit({ + source, + unitPath: loopInput, + trimStart: start, + segmentDuration, + loopStyle, + crossfade: Number(crossfade || 0), + muteAudio, + audioFade, + inputInfo, + }); + const args = [ + '-y', + '-stream_loop', + '-1', + '-i', + loopInput, + '-t', + String(targetDuration), + '-map', + '0:v:0', + '-map', + '0:a?', + ]; const presetArgs = outputPresetArgs(preset); if (mode === 'copy' && !needsUnit && !presetArgs.length) args.push('-c', 'copy'); - else args.push(...presetArgs, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', muteAudio ? '-an' : '-c:a', muteAudio ? '' : 'aac', ...(muteAudio ? [] : ['-b:a', '192k'])); + else + args.push( + ...presetArgs, + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-crf', + '20', + '-pix_fmt', + 'yuv420p', + muteAudio ? '-an' : '-c:a', + muteAudio ? '' : 'aac', + ...(muteAudio ? [] : ['-b:a', '192k']), + ); for (let i = args.length - 1; i >= 0; i--) if (args[i] === '') args.splice(i, 1); args.push('-movflags', '+faststart'); if (callbacks.onProgress || callbacks.onProcess) { @@ -80,7 +226,18 @@ export async function renderLoopVideo({ input = '', duration = 60, output = '', if (!result.ok) throw new Error(result.stderr || 'Looping video gagal.'); } const st = await stat(finalOutput).catch(() => null); - return { ok: true, output: finalOutput, url: `/api/media/file?path=${encodeURIComponent(finalOutput)}`, inputDuration: Number(inputInfo.duration || 0), unitDuration: segmentDuration, duration: targetDuration, size: st?.size || 0, mode, loopStyle, preset }; + return { + ok: true, + output: finalOutput, + url: `/api/media/file?path=${encodeURIComponent(finalOutput)}`, + inputDuration: Number(inputInfo.duration || 0), + unitDuration: segmentDuration, + duration: targetDuration, + size: st?.size || 0, + mode, + loopStyle, + preset, + }; } export async function renderLoopSeamPreview(options = {}) { @@ -96,12 +253,48 @@ export async function renderLoopSeamPreview(options = {}) { const output = path.join(outDir, `${safeId('seam-preview')}.mp4`); const tailStart = start + Math.max(0, segmentDuration - previewSeconds); const filter = '[0:v][1:v]concat=n=2:v=1:a=0[v]'; - const args = ['-y', '-ss', String(tailStart), '-t', String(previewSeconds), '-i', source, '-ss', String(start), '-t', String(previewSeconds), '-i', source, '-filter_complex', filter, '-map', '[v]', '-an', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22', '-pix_fmt', 'yuv420p', output]; + const args = [ + '-y', + '-ss', + String(tailStart), + '-t', + String(previewSeconds), + '-i', + source, + '-ss', + String(start), + '-t', + String(previewSeconds), + '-i', + source, + '-filter_complex', + filter, + '-map', + '[v]', + '-an', + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-crf', + '22', + '-pix_fmt', + 'yuv420p', + output, + ]; const result = await runCmd(FFMPEG, args); if (!result.ok) throw new Error(result.stderr || 'Preview sambungan gagal.'); const st = await stat(output).catch(() => null); const quality = await analyzeLoopPoint(options).catch(() => null); - return { ok: true, output, url: `/api/media/file?path=${encodeURIComponent(output)}`, size: st?.size || 0, previewSeconds, quality: quality?.best || null, candidates: quality?.candidates || [] }; + return { + ok: true, + output, + url: `/api/media/file?path=${encodeURIComponent(output)}`, + size: st?.size || 0, + previewSeconds, + quality: quality?.best || null, + candidates: quality?.candidates || [], + }; } export async function renderLoopBatch({ items = [], outputDir = '', ...options } = {}) { @@ -124,7 +317,10 @@ export async function renderLoopBatch({ items = [], outputDir = '', ...options } const resolved = path.resolve(String(input)); const st = await stat(resolved).catch(() => null); if (!st) return; - if (st.isFile()) { await pushMedia(resolved); return; } + if (st.isFile()) { + await pushMedia(resolved); + return; + } if (!st.isDirectory()) return; const stack = [resolved]; while (stack.length) { @@ -133,7 +329,10 @@ export async function renderLoopBatch({ items = [], outputDir = '', ...options } entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })); for (const item of entries) { const full = path.join(dir, item.name); - if (item.isDirectory()) { stack.push(full); continue; } + if (item.isDirectory()) { + stack.push(full); + continue; + } if (isVideo(full)) await pushMedia(full); if (expanded.length >= limit) return; } @@ -155,7 +354,14 @@ export async function renderLoopBatch({ items = [], outputDir = '', ...options } skipped.push({ input, error: error.message }); } } - return { ok: true, outputDir: outDir, created: results, skipped, scanned: expanded.length, limited: expanded.length >= limit }; + return { + ok: true, + outputDir: outDir, + created: results, + skipped, + scanned: expanded.length, + limited: expanded.length >= limit, + }; } export async function validateLoopOptions(options = {}) { @@ -180,7 +386,16 @@ export async function validateLoopOptions(options = {}) { if (disk.ok && disk.freeGB < 2) warnings.push(`Sisa disk rendah: ${disk.freeGB} GB.`); const inputDuration = Number(info?.duration || 0); const loopsNeeded = inputDuration ? Math.ceil(targetDuration / Math.max(0.1, inputDuration)) : 0; - return { ok: errors.length === 0, errors, warnings, inputDuration, loopsNeeded, disk, estimate: { duration: targetDuration, mode: options.mode || 'copy', preset: options.preset || 'source' }, error: errors.join(' ') }; + return { + ok: errors.length === 0, + errors, + warnings, + inputDuration, + loopsNeeded, + disk, + estimate: { duration: targetDuration, mode: options.mode || 'copy', preset: options.preset || 'source' }, + error: errors.join(' '), + }; } export async function startLoopJob(options = {}, loopJobs) { @@ -188,35 +403,74 @@ export async function startLoopJob(options = {}, loopJobs) { if (!validation.ok) throw new Error(validation.error || 'Validasi loop gagal.'); const id = safeId('loop'); const startedAt = new Date().toISOString(); - const job = { id, status: 'running', progress: 0, etaSeconds: 0, elapsedSeconds: 0, renderedSeconds: 0, startedAt, options, validation }; + const job = { + id, + status: 'running', + progress: 0, + etaSeconds: 0, + elapsedSeconds: 0, + renderedSeconds: 0, + startedAt, + options, + validation, + }; loopJobs.set(id, job); renderLoopVideo(options, { - onProcess: child => { job.child = child; }, + onProcess: child => { + job.child = child; + }, onProgress: metrics => { job.progress = metrics.progress || job.progress || 0; job.renderedSeconds = metrics.renderedSeconds || job.renderedSeconds || 0; job.elapsedSeconds = Math.max(0, Math.round((Date.now() - Date.parse(startedAt)) / 1000)); - job.etaSeconds = job.progress > 0 && job.progress < 100 ? Math.max(0, Math.round((job.elapsedSeconds / job.progress) * (100 - job.progress))) : 0; + job.etaSeconds = + job.progress > 0 && job.progress < 100 + ? Math.max(0, Math.round((job.elapsedSeconds / job.progress) * (100 - job.progress))) + : 0; job.updatedAt = new Date().toISOString(); }, - }).then(result => { - Object.assign(job, result, { status: 'done', progress: 100, etaSeconds: 0, finishedAt: new Date().toISOString() }); - }).catch(error => { - if (job.status === 'cancelled') return; - Object.assign(job, { status: 'failed', error: error.message, finishedAt: new Date().toISOString() }); - }).finally(() => { delete job.child; }); + }) + .then(result => { + Object.assign(job, result, { + status: 'done', + progress: 100, + etaSeconds: 0, + finishedAt: new Date().toISOString(), + }); + }) + .catch(error => { + if (job.status === 'cancelled') return; + Object.assign(job, { status: 'failed', error: error.message, finishedAt: new Date().toISOString() }); + }) + .finally(() => { + delete job.child; + }); return { ...job, child: undefined }; } export function publicLoopJob(job) { if (!job) return null; - const { child, ...safe } = job; + const { child: _child, ...safe } = job; return safe; } export async function loopFrame(file, seconds) { const at = Math.max(0, Number(seconds || 0)); - const result = await runBuffer(FFMPEG, ['-v', 'error', '-ss', String(at), '-i', file, '-frames:v', '1', '-vf', 'scale=64:36:force_original_aspect_ratio=decrease,pad=64:36:(ow-iw)/2:(oh-ih)/2,format=gray', '-f', 'rawvideo', 'pipe:1']); + const result = await runBuffer(FFMPEG, [ + '-v', + 'error', + '-ss', + String(at), + '-i', + file, + '-frames:v', + '1', + '-vf', + 'scale=64:36:force_original_aspect_ratio=decrease,pad=64:36:(ow-iw)/2:(oh-ih)/2,format=gray', + '-f', + 'rawvideo', + 'pipe:1', + ]); if (!result.ok || !result.stdout?.length) throw new Error(result.stderr || 'frame loop gagal dibaca'); return result.stdout; } @@ -248,15 +502,36 @@ export async function analyzeLoopPoint({ input = '', trimStart = 0, trimEnd = 0, for (let i = 0; i < count; i++) { const ratio = count === 1 ? 0 : i / (count - 1); const start = Math.max(0, Number(trimStart || 0) + ratio * searchWindow); - const end = trimEnd > start ? Number(trimEnd) - searchWindow + ratio * searchWindow : duration - searchWindow + ratio * searchWindow; + const end = + trimEnd > start + ? Number(trimEnd) - searchWindow + ratio * searchWindow + : duration - searchWindow + ratio * searchWindow; try { const a = await loopFrame(source, start); const b = await loopFrame(source, Math.max(start + 0.1, Math.min(duration - 0.05, end))); const score = frameDiffScore(a, b); - candidates.push({ trimStart: Number(start.toFixed(2)), trimEnd: Number(Math.max(start + 0.2, Math.min(duration, end)).toFixed(2)), score, label: loopQualityLabel(score) }); - } catch {} + candidates.push({ + trimStart: Number(start.toFixed(2)), + trimEnd: Number(Math.max(start + 0.2, Math.min(duration, end)).toFixed(2)), + score, + label: loopQualityLabel(score), + }); + } catch { + /* frame read failed, skip candidate */ + } } candidates.sort((a, b) => b.score - a.score); - const best = candidates[0] || { trimStart: Number(trimStart || 0), trimEnd: Number(trimEnd || duration), score: 0, label: 'Patah' }; - return { ok: true, duration, best, candidates: candidates.slice(0, 8), warnings: best.score < 72 ? ['Sambungan masih terlihat patah; coba crossfade atau ping-pong.'] : [] }; + const best = candidates[0] || { + trimStart: Number(trimStart || 0), + trimEnd: Number(trimEnd || duration), + score: 0, + label: 'Patah', + }; + return { + ok: true, + duration, + best, + candidates: candidates.slice(0, 8), + warnings: best.score < 72 ? ['Sambungan masih terlihat patah; coba crossfade atau ping-pong.'] : [], + }; } diff --git a/backend/lyrics-engine.mjs b/backend/lyrics-engine.mjs index 4d83409..ddb4272 100644 --- a/backend/lyrics-engine.mjs +++ b/backend/lyrics-engine.mjs @@ -6,16 +6,22 @@ import { lrcRowsToSrt, formatSrtTime } from './media-utils.mjs'; export function cleanLyricLines(text, config = {}) { const maxChars = Number(config.lyrics?.maxChars || 42); const uppercase = Boolean(config.lyrics?.uppercase); - const raw = String(text || '').split(/\r?\n/).map(x => x.replace(/^\s*\[[^\]]+\]\s*/, '').trim()).filter(Boolean); + const raw = String(text || '') + .split(/\r?\n/) + .map(x => x.replace(/^\s*\[[^\]]+\]\s*/, '').trim()) + .filter(Boolean); const out = []; for (const line of raw) { const clean = uppercase ? line.toUpperCase() : line; if (clean.length <= maxChars) out.push(clean); else { - const words = clean.split(/\s+/); let cur = ''; + const words = clean.split(/\s+/); + let cur = ''; for (const w of words) { - if ((cur + ' ' + w).trim().length > maxChars && cur) { out.push(cur); cur = w; } - else cur = (cur + ' ' + w).trim(); + if ((cur + ' ' + w).trim().length > maxChars && cur) { + out.push(cur); + cur = w; + } else cur = (cur + ' ' + w).trim(); } if (cur) out.push(cur); } @@ -24,29 +30,50 @@ export function cleanLyricLines(text, config = {}) { } export function rowsToLrc(rows) { - return rows.map(r => `[${String(Math.floor(Number(r.time||0)/60)).padStart(2,'0')}:${String(Math.floor(Number(r.time||0)%60)).padStart(2,'0')}.${String(Math.round((Number(r.time||0)%1)*100)).padStart(2,'0')}] ${r.text}`).join('\n') + '\n'; + return ( + rows + .map( + r => + `[${String(Math.floor(Number(r.time || 0) / 60)).padStart(2, '0')}:${String(Math.floor(Number(r.time || 0) % 60)).padStart(2, '0')}.${String(Math.round((Number(r.time || 0) % 1) * 100)).padStart(2, '0')}] ${r.text}`, + ) + .join('\n') + '\n' + ); } export function rowsToVtt(rows) { - const fmt = (sec) => formatSrtTime(sec).replace(',', '.'); - return 'WEBVTT\n\n' + rows.map((r,i)=>`${i+1}\n${fmt(Number(r.time||0))} --> ${fmt(rows[i+1]?.time ? Math.max(Number(r.time||0)+0.6, Number(rows[i+1].time)-0.08) : Number(r.time||0)+3)}\n${r.text || ' '}\n`).join('\n'); + const fmt = sec => formatSrtTime(sec).replace(',', '.'); + return ( + 'WEBVTT\n\n' + + rows + .map( + (r, i) => + `${i + 1}\n${fmt(Number(r.time || 0))} --> ${fmt(rows[i + 1]?.time ? Math.max(Number(r.time || 0) + 0.6, Number(rows[i + 1].time) - 0.08) : Number(r.time || 0) + 3)}\n${r.text || ' '}\n`, + ) + .join('\n') + ); } export function lyricWeights(lines) { return lines.map(line => { const chars = String(line || '').replace(/\s+/g, '').length; - const words = String(line || '').split(/\s+/).filter(Boolean).length; + const words = String(line || '') + .split(/\s+/) + .filter(Boolean).length; return Math.max(0.7, chars / 14 + words * 0.18); }); } export function snapToNearestBeat(time, beats = [], window = 0.22) { if (!beats?.length) return time; - let best = null; let dist = Infinity; + let best = null; + let dist = Infinity; for (const b of beats) { const t = Number(b.time ?? b); const d = Math.abs(t - time); - if (d < dist) { dist = d; best = t; } + if (d < dist) { + dist = d; + best = t; + } } return dist <= window ? best : time; } @@ -60,23 +87,50 @@ export function scoreLyricTimeline(rows, audioDuration, config = {}, beats = []) const cpsTarget = Number(config.lyrics?.readingSpeedCps || 14); if (!rows.length) return { score: 0, warnings: ['belum ada baris lirik'], metrics: {} }; rows.forEach((r, i) => { - const next = rows[i+1]?.time ?? audioDuration; + const next = rows[i + 1]?.time ?? audioDuration; const dur = Math.max(0.1, Number(next) - Number(r.time)); const chars = String(r.text || '').length; const cps = chars / dur; - if (chars > maxChars) { score -= 4; warnings.push(`baris ${i+1}: terlalu panjang`); } - if (dur < minDur) { score -= 5; warnings.push(`baris ${i+1}: durasi terlalu cepat`); } - if (dur > maxDur && rows.length > 1) { score -= 2; warnings.push(`baris ${i+1}: durasi terlalu lama`); } - if (cps > cpsTarget * 1.35) { score -= 4; warnings.push(`baris ${i+1}: reading speed terlalu cepat`); } - if (i > 0 && Number(r.time) <= Number(rows[i-1].time)) { score -= 12; warnings.push(`baris ${i+1}: timestamp tidak naik`); } + if (chars > maxChars) { + score -= 4; + warnings.push(`baris ${i + 1}: terlalu panjang`); + } + if (dur < minDur) { + score -= 5; + warnings.push(`baris ${i + 1}: durasi terlalu cepat`); + } + if (dur > maxDur && rows.length > 1) { + score -= 2; + warnings.push(`baris ${i + 1}: durasi terlalu lama`); + } + if (cps > cpsTarget * 1.35) { + score -= 4; + warnings.push(`baris ${i + 1}: reading speed terlalu cepat`); + } + if (i > 0 && Number(r.time) <= Number(rows[i - 1].time)) { + score -= 12; + warnings.push(`baris ${i + 1}: timestamp tidak naik`); + } }); const last = rows.at(-1)?.time || 0; - if (audioDuration && last > audioDuration + 2) { score -= 10; warnings.push('timestamp melebihi durasi audio'); } + if (audioDuration && last > audioDuration + 2) { + score -= 10; + warnings.push('timestamp melebihi durasi audio'); + } if (config.lyrics?.beatSnap && beats?.length) { - const snapped = rows.filter(r => beats.some(b => Math.abs(Number(b.time ?? b) - Number(r.time)) <= Number(config.lyrics?.beatSnapWindow || 0.22))).length; - if (snapped / rows.length < 0.35) { score -= 3; warnings.push('sedikit lirik yang snap ke beat'); } + const snapped = rows.filter(r => + beats.some(b => Math.abs(Number(b.time ?? b) - Number(r.time)) <= Number(config.lyrics?.beatSnapWindow || 0.22)), + ).length; + if (snapped / rows.length < 0.35) { + score -= 3; + warnings.push('sedikit lirik yang snap ke beat'); + } } - return { score: Math.max(0, Math.min(100, Math.round(score))), warnings: [...new Set(warnings)].slice(0, 30), metrics: { lines: rows.length, audioDuration, beats: beats?.length || 0 } }; + return { + score: Math.max(0, Math.min(100, Math.round(score))), + warnings: [...new Set(warnings)].slice(0, 30), + metrics: { lines: rows.length, audioDuration, beats: beats?.length || 0 }, + }; } export function autoAlignLyrics(text, audioDuration, config = {}, beats = []) { @@ -95,11 +149,12 @@ export function autoAlignLyrics(text, audioDuration, config = {}, beats = []) { for (let i = 0; i < lines.length; i++) rows.push({ time: Math.max(0, cursor + i * span - leadIn), text: lines[i] }); } else { const weights = lyricWeights(lines); - const totalWeight = weights.reduce((a,b)=>a+b,0) || 1; + const totalWeight = weights.reduce((a, b) => a + b, 0) || 1; const usable = Math.max(lines.length * minDur, duration - cursor - 0.5); for (let i = 0; i < lines.length; i++) { let start = cursor; - if (config.lyrics?.beatSnap !== false) start = snapToNearestBeat(start, beats, Number(config.lyrics?.beatSnapWindow || 0.22)); + if (config.lyrics?.beatSnap !== false) + start = snapToNearestBeat(start, beats, Number(config.lyrics?.beatSnapWindow || 0.22)); rows.push({ time: Math.max(0, start - leadIn), text: lines[i] }); const weightedDur = Math.max(minDur, Math.min(maxDur, usable * (weights[i] / totalWeight))); cursor += weightedDur; @@ -115,21 +170,32 @@ export async function readLyricSource({ text, file }) { } export async function saveLyricsExport(rows, format, outFile) { - const fmt = format || 'srt'; let content = ''; - if (fmt === 'lrc') content = rowsToLrc(rows); - else if (fmt === 'vtt') content = rowsToVtt(rows); - else content = lrcRowsToSrt(rows); - if (outFile) { await mkdir(path.dirname(outFile), { recursive: true }); await writeFile(outFile, content); } + const fmt = format || 'srt'; + const content = fmt === 'lrc' ? rowsToLrc(rows) : fmt === 'vtt' ? rowsToVtt(rows) : lrcRowsToSrt(rows); + if (outFile) { + await mkdir(path.dirname(outFile), { recursive: true }); + await writeFile(outFile, content); + } return { format: fmt, content, outputFile: outFile || '' }; } export function parseNowPlaying(config = {}, audioFile = '') { - const sp = config.spectrum || {}; const input = config.input || {}; + const sp = config.spectrum || {}; + const input = config.input || {}; let title = input.title || ''; - if (!title && sp.nowPlayingAutoFromFile && audioFile) title = path.basename(audioFile).replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim(); + if (!title && sp.nowPlayingAutoFromFile && audioFile) + title = path + .basename(audioFile) + .replace(/\.[^.]+$/, '') + .replace(/[_-]+/g, ' ') + .trim(); const artist = sp.nowPlayingArtist || ''; const album = sp.nowPlayingAlbum || ''; const template = sp.nowPlayingTemplate || '{title}'; - const text = template.replaceAll('{title}', title || 'Now Playing').replaceAll('{artist}', artist).replaceAll('{album}', album).replaceAll('{filename}', audioFile ? path.basename(audioFile) : ''); + const text = template + .replaceAll('{title}', title || 'Now Playing') + .replaceAll('{artist}', artist) + .replaceAll('{album}', album) + .replaceAll('{filename}', audioFile ? path.basename(audioFile) : ''); return { title, artist, album, text }; } diff --git a/backend/media-utils.mjs b/backend/media-utils.mjs index fa0381e..5f6bc8c 100644 --- a/backend/media-utils.mjs +++ b/backend/media-utils.mjs @@ -1,4 +1,3 @@ - import { readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; @@ -7,16 +6,34 @@ export function safeId(prefix = 'id') { return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; } -export function hasFile(file) { return Boolean(file && existsSync(file)); } -export function ext(file) { return path.extname(file || '').toLowerCase(); } +export function hasFile(file) { + return Boolean(file && existsSync(file)); +} +export function ext(file) { + return path.extname(file || '').toLowerCase(); +} export const imageExts = new Set(['.jpg', '.jpeg', '.png', '.webp', '.bmp', '.gif']); export const videoExts = new Set(['.mp4', '.mov', '.mkv', '.webm', '.avi']); export const audioExts = new Set(['.mp3', '.wav', '.aac', '.m4a', '.flac', '.ogg']); -export function isImage(file) { return imageExts.has(ext(file)); } -export function isVideo(file) { return videoExts.has(ext(file)); } -export function isAudio(file) { return audioExts.has(ext(file)); } -export function escapeFilter(file) { return String(file).replace(/\\/g, '/').replace(/:/g, '\\:').replace(/'/g, "\\'"); } -export function escapeDrawText(text) { return String(text || '').replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'").replace(/%/g, '\\%'); } +export function isImage(file) { + return imageExts.has(ext(file)); +} +export function isVideo(file) { + return videoExts.has(ext(file)); +} +export function isAudio(file) { + return audioExts.has(ext(file)); +} +export function escapeFilter(file) { + return String(file).replace(/\\/g, '/').replace(/:/g, '\\:').replace(/'/g, "\\'"); +} +export function escapeDrawText(text) { + return String(text || '') + .replace(/\\/g, '\\\\') + .replace(/:/g, '\\:') + .replace(/'/g, "\\'") + .replace(/%/g, '\\%'); +} export function parseLrcText(text) { const rows = []; @@ -24,11 +41,13 @@ export function parseLrcText(text) { const matches = [...raw.matchAll(/\[(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?\]/g)]; const lyric = raw.replace(/(?:\[\d{1,2}:\d{2}(?:\.\d{1,3})?\])+/g, '').trim(); for (const m of matches) { - const min = Number(m[1]); const sec = Number(m[2]); const ms = Number((m[3] || '0').padEnd(3, '0')); + const min = Number(m[1]); + const sec = Number(m[2]); + const ms = Number((m[3] || '0').padEnd(3, '0')); rows.push({ time: min * 60 + sec + ms / 1000, text: lyric }); } } - return rows.sort((a,b)=>a.time-b.time); + return rows.sort((a, b) => a.time - b.time); } export function formatSrtTime(seconds) { @@ -39,31 +58,39 @@ export function formatSrtTime(seconds) { const totalMin = Math.floor(totalSec / 60); const m = totalMin % 60; const h = Math.floor(totalMin / 60); - return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')},${String(ms).padStart(3,'0')}`; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')},${String(ms).padStart(3, '0')}`; } export function lrcRowsToSrt(rows, defaultDuration = 3) { - return rows.map((row, i) => { - const end = rows[i+1]?.time ?? row.time + defaultDuration; - return `${i+1}\n${formatSrtTime(row.time)} --> ${formatSrtTime(Math.max(row.time + 0.8, end - 0.08))}\n${row.text || ' '}\n`; - }).join('\n'); + return rows + .map((row, i) => { + const end = rows[i + 1]?.time ?? row.time + defaultDuration; + return `${i + 1}\n${formatSrtTime(row.time)} --> ${formatSrtTime(Math.max(row.time + 0.8, end - 0.08))}\n${row.text || ' '}\n`; + }) + .join('\n'); } export async function readLyricsFile(file) { if (!hasFile(file)) return []; const text = await readFile(file, 'utf8'); if (ext(file) === '.lrc') return parseLrcText(text); - if (ext(file) === '.srt') return text.split(/\n\n+/).map((block, i) => { - const lines = block.split(/\r?\n/); - const timeLine = lines.find(l => l.includes('-->')) || ''; - const m = timeLine.match(/(\d\d):(\d\d):(\d\d)[,.](\d\d\d)/); - const time = m ? Number(m[1])*3600+Number(m[2])*60+Number(m[3])+Number(m[4])/1000 : i*3; - return { time, text: lines.slice(lines.indexOf(timeLine)+1).join(' ') }; - }); - return parseLrcText(text) || text.split(/\r?\n/).filter(Boolean).map((line, i) => ({ time: i*3, text: line.trim() })); + if (ext(file) === '.srt') + return text.split(/\n\n+/).map((block, i) => { + const lines = block.split(/\r?\n/); + const timeLine = lines.find(l => l.includes('-->')) || ''; + const m = timeLine.match(/(\d\d):(\d\d):(\d\d)[,.](\d\d\d)/); + const time = m ? Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]) + Number(m[4]) / 1000 : i * 3; + return { time, text: lines.slice(lines.indexOf(timeLine) + 1).join(' ') }; + }); + return ( + parseLrcText(text) || + text + .split(/\r?\n/) + .filter(Boolean) + .map((line, i) => ({ time: i * 3, text: line.trim() })) + ); } - export function assTime(seconds) { const totalCs = Math.max(0, Math.round(seconds * 100)); const cs = totalCs % 100; @@ -72,16 +99,24 @@ export function assTime(seconds) { const totalMin = Math.floor(totalSec / 60); const min = totalMin % 60; const h = Math.floor(totalMin / 60); - return `${h}:${String(min).padStart(2,'0')}:${String(sec).padStart(2,'0')}.${String(cs).padStart(2,'0')}`; + return `${h}:${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(cs).padStart(2, '0')}`; } function assEscape(text) { - return String(text || '').replace(/\{/g, '(').replace(/\}/g, ')').replace(/\r?\n/g, '\\N'); + return String(text || '') + .replace(/\{/g, '(') + .replace(/\}/g, ')') + .replace(/\r?\n/g, '\\N'); } function assColor(hex = '#ffffff', alpha = '00') { - const h = String(hex || '#ffffff').replace('#','').padEnd(6,'f').slice(0,6); - const r = h.slice(0,2), g = h.slice(2,4), b = h.slice(4,6); + const h = String(hex || '#ffffff') + .replace('#', '') + .padEnd(6, 'f') + .slice(0, 6); + const r = h.slice(0, 2), + g = h.slice(2, 4), + b = h.slice(4, 6); return `&H${alpha}${b}${g}${r}`.toUpperCase(); } function lyricAlignment(position = 'Bawah', align = 'Rata Tengah') { @@ -92,54 +127,141 @@ function lyricAlignment(position = 'Bawah', align = 'Rata Tengah') { } export function buildAssDocument({ - rows = [], title = '', duration = 60, font = 'Arial', fontSize = 28, - lyricColor = '#ffffff', lyricHighlightColor = '#22c55e', lyricPosition = 'Bawah', lyricAlign = 'Rata Tengah', lyricOutline = 2, lyricShadow = 1, lyricKaraoke = false, - titleEnabled = true, titlePosition = 'Atas', titleFontSize = 26, titleColor = '#ffffff', titleX = 0, titleY = 0, timestampEnabled = false, timestampText = 'Rendered by PidioForge', timestampPosition = 'Kiri Atas', - lowerThirdEnabled = false, lowerThirdText = '', lowerThirdPosition = 'Bawah', lowerThirdAt = 2, lowerThirdDuration = 5, - watermark = '', watermarkEnabled = false, watermarkPosition = 'Kiri Bawah', watermarkOpacity = 70, - watermarkMode = 'always', watermarkInterval = 12, watermarkVisibleDuration = 5, + rows = [], + title = '', + duration = 60, + font = 'Arial', + fontSize = 28, + lyricColor = '#ffffff', + lyricHighlightColor = '#22c55e', + lyricPosition = 'Bawah', + lyricAlign = 'Rata Tengah', + lyricOutline = 2, + lyricShadow = 1, + lyricKaraoke = false, + titleEnabled = true, + titlePosition = 'Atas', + titleFontSize = 26, + titleColor = '#ffffff', + titleX = 0, + titleY = 0, + timestampEnabled = false, + timestampText = 'Rendered by PidioForge', + timestampPosition = 'Kiri Atas', + lowerThirdEnabled = false, + lowerThirdText = '', + lowerThirdPosition = 'Bawah', + lowerThirdAt = 2, + lowerThirdDuration = 5, + watermark = '', + watermarkEnabled = false, + watermarkPosition = 'Kiri Bawah', + watermarkOpacity = 70, + watermarkMode = 'always', + watermarkInterval = 12, + watermarkVisibleDuration = 5, + include = 'all', + playResX = 1280, + playResY = 720, }) { const safeDuration = Math.max(1, Number(duration) || 60); const events = []; - const alpha = Math.max(0, Math.min(255, Math.round(255 - (Number(watermarkOpacity ?? 70) / 100) * 255))).toString(16).padStart(2, '0').toUpperCase(); - if (titleEnabled && title) { - const tx = Math.round(Number(titleX || 0)); - const ty = Math.round(Number(titleY || 0)); - const override = tx > 0 && ty > 0 ? `{\\an5\\pos(${tx},${ty})}` : ''; - events.push(`Dialogue: 0,${assTime(0)},${assTime(safeDuration)},Title,,0,0,0,,${override}${assEscape(title)}`); - } - if (timestampEnabled) events.push(`Dialogue: 0,${assTime(0)},${assTime(safeDuration)},Info,,0,0,0,,${assEscape(timestampText || 'Rendered by PidioForge')}`); - if (lowerThirdEnabled && lowerThirdText) { - const ltStart = Math.max(0, Number(lowerThirdAt || 2)); - const ltEnd = Math.min(safeDuration, ltStart + Math.max(0.8, Number(lowerThirdDuration || 5))); - events.push(`Dialogue: 0,${assTime(ltStart)},${assTime(ltEnd)},LowerThird,,0,0,0,,${assEscape(lowerThirdText)}`); - } - if (watermarkEnabled && watermark) { - if (watermarkMode === 'interval') { - const interval = Math.max(1, Number(watermarkInterval) || 12); - const visible = Math.max(0.5, Number(watermarkVisibleDuration) || 5); - for (let t = 0; t < safeDuration; t += interval) { - events.push(`Dialogue: 0,${assTime(t)},${assTime(Math.min(safeDuration, t + visible))},Watermark,,0,0,0,,${assEscape(watermark)}`); - } - } else { - events.push(`Dialogue: 0,${assTime(0)},${assTime(safeDuration)},Watermark,,0,0,0,,${assEscape(watermark)}`); - } - } - rows.forEach((row, i) => { - const start = Number(row.time) || i * 3; - const next = rows[i + 1]?.time; - const end = Math.min(safeDuration, Math.max(start + 0.8, (Number(next) || start + 3) - 0.06)); - const durCs = Math.max(20, Math.round((end - start) * 100)); - const text = lyricKaraoke ? `{\k${durCs}}${assEscape(row.text)}` : assEscape(row.text); - if (start < safeDuration) events.push(`Dialogue: 0,${assTime(start)},${assTime(end)},Lyrics,,0,0,0,,${text}`); - }); - const wmAlign = watermarkPosition.includes('Kanan') ? (watermarkPosition.includes('Atas') ? 9 : 3) : (watermarkPosition.includes('Atas') ? 7 : watermarkPosition.includes('Tengah') ? 5 : 1); - const infoAlign = timestampPosition.includes('Kanan') ? (timestampPosition.includes('Bawah') ? 3 : 9) : (timestampPosition.includes('Bawah') ? 1 : 7); + const styles = []; + const alpha = Math.max(0, Math.min(255, Math.round(255 - (Number(watermarkOpacity ?? 70) / 100) * 255))) + .toString(16) + .padStart(2, '0') + .toUpperCase(); + const wmAlign = watermarkPosition.includes('Kanan') + ? watermarkPosition.includes('Atas') + ? 9 + : 3 + : watermarkPosition.includes('Atas') + ? 7 + : watermarkPosition.includes('Tengah') + ? 5 + : 1; + const infoAlign = timestampPosition.includes('Kanan') + ? timestampPosition.includes('Bawah') + ? 3 + : 9 + : timestampPosition.includes('Bawah') + ? 1 + : 7; const lowerAlign = lowerThirdPosition.includes('Atas') ? 8 : lowerThirdPosition.includes('Tengah') ? 5 : 2; const lowerMarginV = lowerThirdPosition.includes('Atas') ? 82 : lowerThirdPosition.includes('Tengah') ? 40 : 104; const lyAlign = lyricAlignment(lyricPosition, lyricAlign); const lyMarginV = String(lyricPosition).includes('Atas') ? 80 : String(lyricPosition).includes('Tengah') ? 40 : 72; const lyPrimary = assColor(lyricColor, '00'); const lySecondary = assColor(lyricHighlightColor, '00'); - return `[Script Info]\nScriptType: v4.00+\nPlayResX: 1280\nPlayResY: 720\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Title,${font},${Number(titleFontSize || 26)},${assColor(titleColor, '00')},&H000000FF,&HAA000000,&H66000000,1,0,0,0,100,100,0,0,1,2,1,${String(titlePosition).includes('Bawah') ? 2 : String(titlePosition).includes('Tengah') ? 5 : 8},30,30,34,1\nStyle: Lyrics,${font},${fontSize},${lyPrimary},${lySecondary},&HAA000000,&H66000000,1,0,0,0,100,100,0,0,1,${Number(lyricOutline ?? 2)},${Number(lyricShadow ?? 1)},${lyAlign},80,80,${lyMarginV},1\nStyle: Info,${font},18,&H00FFFFFF,&H000000FF,&HAA000000,&H66000000,0,0,0,0,100,100,0,0,1,1,1,${infoAlign},20,20,20,1\nStyle: LowerThird,${font},30,&H00FFFFFF,&H000000FF,&HAA000000,&H99000000,1,0,0,0,100,100,0,0,1,2,1,${lowerAlign},90,90,${lowerMarginV},1\nStyle: Watermark,${font},18,&H${alpha}FFFFFF,&H000000FF,&HAA000000,&H66000000,0,0,0,0,100,100,0,0,1,1,1,${wmAlign},28,28,24,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n${events.join('\n')}\n`; + const shouldInclude = layer => include === 'all' || include === layer; + + if (shouldInclude('nowPlaying')) { + styles.push( + `Style: Title,${font},${Number(titleFontSize || 26)},${assColor(titleColor, '00')},&H000000FF,&HAA000000,&H66000000,1,0,0,0,100,100,0,0,1,2,1,${String(titlePosition).includes('Bawah') ? 2 : String(titlePosition).includes('Tengah') ? 5 : 8},30,30,34,1`, + ); + if (titleEnabled && title) { + const tx = Math.round(Number(titleX || 0)); + const ty = Math.round(Number(titleY || 0)); + const override = tx > 0 && ty > 0 ? `{\\an5\\pos(${tx},${ty})}` : ''; + events.push(`Dialogue: 0,${assTime(0)},${assTime(safeDuration)},Title,,0,0,0,,${override}${assEscape(title)}`); + } + } + + if (shouldInclude('timestamp')) { + styles.push( + `Style: Info,${font},18,&H00FFFFFF,&H000000FF,&HAA000000,&H66000000,0,0,0,0,100,100,0,0,1,1,1,${infoAlign},20,20,20,1`, + ); + if (timestampEnabled) + events.push( + `Dialogue: 0,${assTime(0)},${assTime(safeDuration)},Info,,0,0,0,,${assEscape(timestampText || 'Rendered by PidioForge')}`, + ); + } + + if (shouldInclude('lowerThird')) { + styles.push( + `Style: LowerThird,${font},30,&H00FFFFFF,&H000000FF,&HAA000000,&H99000000,1,0,0,0,100,100,0,0,1,2,1,${lowerAlign},90,90,${lowerMarginV},1`, + ); + if (lowerThirdEnabled && lowerThirdText) { + const ltStart = Math.max(0, Number(lowerThirdAt || 2)); + const ltEnd = Math.min(safeDuration, ltStart + Math.max(0.8, Number(lowerThirdDuration || 5))); + events.push(`Dialogue: 0,${assTime(ltStart)},${assTime(ltEnd)},LowerThird,,0,0,0,,${assEscape(lowerThirdText)}`); + } + } + + if (shouldInclude('watermark')) { + styles.push( + `Style: Watermark,${font},18,&H${alpha}FFFFFF,&H000000FF,&HAA000000,&H66000000,0,0,0,0,100,100,0,0,1,1,1,${wmAlign},28,28,24,1`, + ); + if (watermarkEnabled && watermark) { + if (watermarkMode === 'interval') { + const interval = Math.max(1, Number(watermarkInterval) || 12); + const visible = Math.max(0.5, Number(watermarkVisibleDuration) || 5); + for (let t = 0; t < safeDuration; t += interval) { + events.push( + `Dialogue: 0,${assTime(t)},${assTime(Math.min(safeDuration, t + visible))},Watermark,,0,0,0,,${assEscape(watermark)}`, + ); + } + } else { + events.push(`Dialogue: 0,${assTime(0)},${assTime(safeDuration)},Watermark,,0,0,0,,${assEscape(watermark)}`); + } + } + } + + if (shouldInclude('lyrics')) { + styles.push( + `Style: Lyrics,${font},${fontSize},${lyPrimary},${lySecondary},&HAA000000,&H66000000,1,0,0,0,100,100,0,0,1,${Number(lyricOutline ?? 2)},${Number(lyricShadow ?? 1)},${lyAlign},80,80,${lyMarginV},1`, + ); + rows.forEach((row, i) => { + const start = Number(row.time) || i * 3; + const next = rows[i + 1]?.time; + const end = Math.min(safeDuration, Math.max(start + 0.8, (Number(next) || start + 3) - 0.06)); + const durCs = Math.max(20, Math.round((end - start) * 100)); + const text = lyricKaraoke ? `{\\k${durCs}}${assEscape(row.text)}` : assEscape(row.text); + if (start < safeDuration) events.push(`Dialogue: 0,${assTime(start)},${assTime(end)},Lyrics,,0,0,0,,${text}`); + }); + } + + if (!styles.length && !events.length) return ''; + + return `[Script Info]\nScriptType: v4.00+\nPlayResX: ${playResX}\nPlayResY: ${playResY}\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n${styles.join('\n')}\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n${events.join('\n')}\n`; } diff --git a/backend/preview-engine.mjs b/backend/preview-engine.mjs index 447be2e..47c0e1f 100644 --- a/backend/preview-engine.mjs +++ b/backend/preview-engine.mjs @@ -9,64 +9,172 @@ export function previewConfig(baseConfig, patch = {}) { const pv = cfg.preview || defaultConfig.preview; const q = pv.quality || cfg.performance?.previewQuality || 'draft'; const width = Math.max(240, Math.min(1280, Number(pv.width || (q === 'high' ? 960 : q === 'normal' ? 720 : 480)))); - const height = Math.max(135, Math.min(720, Number(pv.height || Math.round(width * 9 / 16)))); + const height = Math.max(135, Math.min(720, Number(pv.height || Math.round((width * 9) / 16)))); cfg.target = deepMerge(cfg.target, { duration: Math.max(1, Math.min(12, Number(pv.duration || 4))), - width, height, resolution: `${width}x${height}`, + width, + height, + resolution: `${width}x${height}`, fps: Math.max(12, Math.min(30, Number(pv.fps || (q === 'high' ? 24 : 18)))), bitrate: q === 'high' ? '1800k' : q === 'normal' ? '1100k' : '650k', - quality: 'fast', crf: q === 'high' ? 24 : 30, hardwareAccel: 'cpu', faststart: true, + quality: 'fast', + crf: q === 'high' ? 24 : 30, + hardwareAccel: 'cpu', + faststart: true, + }); + cfg.performance = deepMerge(cfg.performance || {}, { + ffmpegThreads: Math.max(1, Math.min(4, Number(cfg.performance?.ffmpegThreads || 2))), + x264Preset: 'veryfast', }); - cfg.performance = deepMerge(cfg.performance || {}, { ffmpegThreads: Math.max(1, Math.min(4, Number(cfg.performance?.ffmpegThreads || 2))), x264Preset: 'veryfast' }); cfg.audio = deepMerge(cfg.audio || {}, { normalize: false, limiter: true }); return cfg; } export async function previewDiagnostics(config) { const warnings = []; - const visual = config.input?.visual; const audio = config.input?.audio; - const v = await ffprobeInfo(visual); const a = await ffprobeInfo(audio); - if (!visual) warnings.push('visual belum dipilih'); else if (!v.ok) warnings.push(`visual tidak valid: ${v.error || visual}`); - if (!audio) warnings.push('audio belum dipilih'); else if (!a.ok) warnings.push(`audio tidak valid: ${a.error || audio}`); + const visual = config.input?.visual; + const audio = config.input?.audio; + const gallery = config.spectrum?.gallery; + const galleryItems = Array.isArray(gallery?.images) ? gallery.images.filter(Boolean) : []; + const galleryActive = Boolean(gallery?.enabled && galleryItems.length > 0); + const v = await ffprobeInfo(visual); + const a = await ffprobeInfo(audio); + if (galleryActive) { + if (!galleryItems.length) warnings.push('gallery aktif tapi tidak ada file'); + } else if (!visual) warnings.push('visual belum dipilih'); + else if (!v.ok) warnings.push(`visual tidak valid: ${v.error || visual}`); + if (!audio) warnings.push('audio belum dipilih'); + else if (!a.ok) warnings.push(`audio tidak valid: ${a.error || audio}`); if (config.spectrum?.enabled && !a.hasAudio) warnings.push('spectrum butuh audio valid'); - if (config.overlay?.overlayEnabled && !config.overlay?.overlayFile) warnings.push('overlay asset aktif tapi file kosong'); + if (config.overlay?.overlayEnabled && !config.overlay?.overlayFile) + warnings.push('overlay asset aktif tapi file kosong'); if (config.branding?.logoEnabled && !config.branding?.logo) warnings.push('logo aktif tapi file kosong'); - if (config.branding?.logoEnabled && Number(config.branding?.logoScale || 0) > 45) warnings.push('logo terlalu besar untuk preview, cek safe area'); - if (config.lyrics?.enabled && config.lyrics?.position === 'Bawah' && config.spectrum?.enabled && config.spectrum?.position === 'Bawah') warnings.push('lirik dan spectrum sama-sama di bawah, berpotensi overlap'); - if (config.overlay?.darken && Number(config.overlay?.darkenOpacity || 0) > 35) warnings.push('overlay terlalu gelap untuk preview'); - if (config.spectrum?.enabled && Number(config.spectrum?.height || 0) > Number(config.target?.height || 720) * 0.28) warnings.push('spectrum terlalu tinggi, dapat menutup konten utama'); - if (Number(config.preview?.startAt || 0) > 0 && v.duration && Number(config.preview?.startAt || 0) >= v.duration) warnings.push('start preview melebihi durasi visual'); - const safeArea = safeAreaGuide(config.preview?.safeAreaPreset || config.branding?.safeAreaPreset || 'youtube', config.target?.width || 640, config.target?.height || 360); - return { ok: warnings.filter(w => /tidak valid|belum dipilih/.test(w)).length === 0, warnings, safeArea, assets: { visual: v, audio: a } }; + if (config.branding?.logoEnabled && Number(config.branding?.logoScale || 0) > 45) + warnings.push('logo terlalu besar untuk preview, cek safe area'); + if ( + config.lyrics?.enabled && + config.lyrics?.position === 'Bawah' && + config.spectrum?.enabled && + config.spectrum?.position === 'Bawah' + ) + warnings.push('lirik dan spectrum sama-sama di bawah, berpotensi overlap'); + if (config.overlay?.darken && Number(config.overlay?.darkenOpacity || 0) > 35) + warnings.push('overlay terlalu gelap untuk preview'); + if (config.spectrum?.enabled && Number(config.spectrum?.height || 0) > Number(config.target?.height || 720) * 0.28) + warnings.push('spectrum terlalu tinggi, dapat menutup konten utama'); + if (Number(config.preview?.startAt || 0) > 0 && v.duration && Number(config.preview?.startAt || 0) >= v.duration) + warnings.push('start preview melebihi durasi visual'); + const safeArea = safeAreaGuide( + config.preview?.safeAreaPreset || config.branding?.safeAreaPreset || 'youtube', + config.target?.width || 640, + config.target?.height || 360, + ); + return { + ok: warnings.filter(w => /tidak valid|belum dipilih/.test(w)).length === 0, + warnings, + safeArea, + assets: { visual: v, audio: a }, + }; } export function safeAreaGuide(preset = 'youtube', width = 640, height = 360) { const vertical = height > width; if (preset === 'shorts' || vertical) return { preset, top: 0.08, right: 0.08, bottom: 0.16, left: 0.08 }; - if (preset === 'square') return { preset, top: 0.08, right: 0.08, bottom: 0.10, left: 0.08 }; - return { preset, top: 0.07, right: 0.06, bottom: 0.10, left: 0.06 }; + if (preset === 'square') return { preset, top: 0.08, right: 0.08, bottom: 0.1, left: 0.08 }; + return { preset, top: 0.07, right: 0.06, bottom: 0.1, left: 0.06 }; } export async function performanceStatus(state) { const cpus = os.cpus?.() || []; const load = os.loadavg?.()[0] || 0; const cpuPercent = cpus.length ? Math.min(100, Math.round((load / cpus.length) * 100)) : 0; - const totalMem = os.totalmem?.() || 1; const freeMem = os.freemem?.() || 0; + const totalMem = os.totalmem?.() || 1; + const freeMem = os.freemem?.() || 0; const memoryPercent = Math.round((1 - freeMem / totalMem) * 100); const disk = await diskStats(activeConfig(state).input?.output || process.cwd()); const q = queueSummary(state); - const cfg = activeConfig(state); const diag = await ffmpegDiagnostics(); + const cfg = activeConfig(state); + const diag = await ffmpegDiagnostics(); const warnings = []; - if (cpuPercent >= Number(cfg.performance?.maxCpu || 85)) warnings.push('CPU tinggi, turunkan concurrency atau gunakan preset hemat.'); - if (memoryPercent >= Number(cfg.performance?.maxMemory || 85)) warnings.push('Memory tinggi, hindari batch paralel terlalu banyak.'); + if (cpuPercent >= Number(cfg.performance?.maxCpu || 85)) + warnings.push('CPU tinggi, turunkan concurrency atau gunakan preset hemat.'); + if (memoryPercent >= Number(cfg.performance?.maxMemory || 85)) + warnings.push('Memory tinggi, hindari batch paralel terlalu banyak.'); if (disk.usedPercent > 90) warnings.push('Disk hampir penuh, bersihkan output lama.'); - if (q.rendering > Number(cfg.performance?.queueConcurrency || q.queue?.concurrency || 1)) warnings.push('Render aktif melebihi target concurrency.'); - return { ok: true, metrics: { cpu: cpuPercent, memory: memoryPercent, disk: disk.usedPercent, wifi: 0, loadAverage: Number(load.toFixed(2)), freeMemoryGB: Number((freeMem/1024/1024/1024).toFixed(1)), totalMemoryGB: Number((totalMem/1024/1024/1024).toFixed(1)), diskFreeGB: disk.freeGB, diskTotalGB: disk.totalGB, activeRenders: q.rendering, queueStandby: q.standby, queueDone: q.done, queueFailed: q.failed }, performance: cfg.performance || defaultConfig.performance, encoder: diag.recommended, encoders: diag.encoders, queue: q.queue, warnings }; + if (q.rendering > Number(cfg.performance?.queueConcurrency || q.queue?.concurrency || 1)) + warnings.push('Render aktif melebihi target concurrency.'); + return { + ok: true, + metrics: { + cpu: cpuPercent, + memory: memoryPercent, + disk: disk.usedPercent, + wifi: 0, + loadAverage: Number(load.toFixed(2)), + freeMemoryGB: Number((freeMem / 1024 / 1024 / 1024).toFixed(1)), + totalMemoryGB: Number((totalMem / 1024 / 1024 / 1024).toFixed(1)), + diskFreeGB: disk.freeGB, + diskTotalGB: disk.totalGB, + activeRenders: q.rendering, + queueStandby: q.standby, + queueDone: q.done, + queueFailed: q.failed, + }, + performance: cfg.performance || defaultConfig.performance, + encoder: diag.recommended, + encoders: diag.encoders, + queue: q.queue, + warnings, + }; } export function performancePatch(mode) { - if (mode === 'turbo') return { performance: { mode, queueConcurrency: 2, ffmpegThreads: 0, x264Preset: 'veryfast', previewQuality: 'low', refreshMs: 1000 }, target: { quality: 'fast', crf: 26, hardwareAccel: 'auto' } }; - if (mode === 'quality') return { performance: { mode, queueConcurrency: 1, ffmpegThreads: 0, x264Preset: 'slow', previewQuality: 'high', refreshMs: 2000 }, target: { quality: 'high', crf: 18, hardwareAccel: 'auto' } }; - if (mode === 'eco') return { performance: { mode, queueConcurrency: 1, ffmpegThreads: Math.max(1, Math.floor((os.cpus?.().length || 2)/2)), x264Preset: 'veryfast', previewQuality: 'low', refreshMs: 2500, thermalGuard: true }, target: { quality: 'fast', crf: 28, fps: 24, hardwareAccel: 'cpu' } }; - return { performance: { mode: 'balanced', queueConcurrency: 1, ffmpegThreads: 0, x264Preset: 'medium', previewQuality: 'normal', refreshMs: 1500 }, target: { quality: 'balanced', crf: 22, hardwareAccel: 'auto' } }; + if (mode === 'turbo') + return { + performance: { + mode, + queueConcurrency: 2, + ffmpegThreads: 0, + x264Preset: 'veryfast', + previewQuality: 'low', + refreshMs: 1000, + }, + target: { quality: 'fast', crf: 26, hardwareAccel: 'auto' }, + }; + if (mode === 'quality') + return { + performance: { + mode, + queueConcurrency: 1, + ffmpegThreads: 0, + x264Preset: 'slow', + previewQuality: 'high', + refreshMs: 2000, + }, + target: { quality: 'high', crf: 18, hardwareAccel: 'auto' }, + }; + if (mode === 'eco') + return { + performance: { + mode, + queueConcurrency: 1, + ffmpegThreads: Math.max(1, Math.floor((os.cpus?.().length || 2) / 2)), + x264Preset: 'veryfast', + previewQuality: 'low', + refreshMs: 2500, + thermalGuard: true, + }, + target: { quality: 'fast', crf: 28, fps: 24, hardwareAccel: 'cpu' }, + }; + return { + performance: { + mode: 'balanced', + queueConcurrency: 1, + ffmpegThreads: 0, + x264Preset: 'medium', + previewQuality: 'normal', + refreshMs: 1500, + }, + target: { quality: 'balanced', crf: 22, hardwareAccel: 'auto' }, + }; } diff --git a/backend/preview-stream.mjs b/backend/preview-stream.mjs index 5909ed5..04d24af 100644 --- a/backend/preview-stream.mjs +++ b/backend/preview-stream.mjs @@ -5,6 +5,7 @@ import { FFMPEG } from './bin-resolver.mjs'; import { runCmd, ffprobeInfo } from './ffmpeg-utils.mjs'; import { safeId } from './media-utils.mjs'; import { previewConfig } from './preview-engine.mjs'; +import { buildGallerySlideshowFile } from './render-engine.mjs'; /** * Generate a live preview stream with lower quality for real-time playback @@ -30,59 +31,44 @@ export async function generateLivePreview(config, options = {}) { const id = safeId('live'); const output = path.join(previewDir, `${id}.mp4`); + const patchedConfig = previewConfig(config, { preview: { width, height, fps, duration } }); + const galleryVisual = await buildGallerySlideshowFile(patchedConfig, workspaceDir, { id: `${id}-gallery`, duration }); - // Validate inputs - const visual = config.input?.visual; + const visual = galleryVisual || config.input?.visual; const audio = config.input?.audio; - if (!visual && !audio) { - throw new Error('No visual or audio input provided'); - } + if (!visual && !audio) throw new Error('No visual or audio input provided'); - // Get input info const visualInfo = visual ? await ffprobeInfo(visual) : null; const audioInfo = audio ? await ffprobeInfo(audio) : null; + onLog?.(`[LivePreview] input visual=${visual || '-'} audio=${audio || '-'} gallery=${galleryVisual ? 'on' : 'off'}`); + void visualInfo; + void audioInfo; - // Build FFmpeg command for live preview const args = ['-y']; - - // Input handling with seeking if (visual) { args.push('-ss', String(startAt)); args.push('-i', visual); } - if (audio) { args.push('-ss', String(startAt)); args.push('-i', audio); } - - // Duration limit args.push('-t', String(duration)); - - // Video encoding - optimized for speed args.push('-c:v', 'libx264'); - args.push('-preset', 'ultrafast'); // Fastest encoding + args.push('-preset', 'ultrafast'); args.push('-crf', quality === 'draft' ? '28' : '23'); - args.push('-r', String(fps)); // Lower FPS for speed - args.push('-s', `${width}x${height}`); // Lower resolution - - // Audio encoding - simple copy or fast encode + args.push('-r', String(fps)); + args.push('-s', `${width}x${height}`); if (audio) { args.push('-c:a', 'aac'); args.push('-b:a', '128k'); args.push('-ar', '44100'); } else { - args.push('-an'); // No audio + args.push('-an'); } - - // Fast start for streaming args.push('-movflags', '+faststart'); - - // Pixel format args.push('-pix_fmt', 'yuv420p'); - - // Output args.push(output); onLog?.(`[LivePreview] Starting: ${width}x${height} @ ${fps}fps, ${duration}s from ${startAt}s`); @@ -90,10 +76,8 @@ export async function generateLivePreview(config, options = {}) { const startTime = Date.now(); let lastProgress = 0; - const result = await runCmd(FFMPEG, args, { - onStderr: (line) => { - // Parse FFmpeg progress + onStderr: line => { const timeMatch = line.match(/time=(\d+):(\d+):(\d+\.\d+)/); if (timeMatch) { const hours = parseInt(timeMatch[1]); @@ -101,28 +85,19 @@ export async function generateLivePreview(config, options = {}) { const secs = parseFloat(timeMatch[3]); const currentTime = hours * 3600 + mins * 60 + secs; const progress = Math.min(100, Math.round((currentTime / duration) * 100)); - if (progress > lastProgress) { lastProgress = progress; - onProgress?.({ - progress, - currentTime, - duration, - elapsed: Date.now() - startTime, - }); + onProgress?.({ progress, currentTime, duration, elapsed: Date.now() - startTime }); } } onLog?.(line); }, }); - if (!result.ok) { - throw new Error(`Live preview generation failed: ${result.stderr}`); - } + if (!result.ok) throw new Error(`Live preview generation failed: ${result.stderr}`); const elapsed = Date.now() - startTime; const speed = duration / (elapsed / 1000); - return { id, output, @@ -145,33 +120,29 @@ export async function generateLivePreview(config, options = {}) { * @returns {Promise} Preview info */ export async function generateFullPreview(config, options = {}) { - const { - startAt = 0, - duration = 10, - workspaceDir, - onProgress, - onLog, - } = options; + const { startAt = 0, duration = 10, workspaceDir, onProgress, onLog } = options; const previewDir = path.join(workspaceDir, 'previews', 'full'); await mkdir(previewDir, { recursive: true }); const id = safeId('preview'); const output = path.join(previewDir, `${id}.mp4`); + const galleryVisual = await buildGallerySlideshowFile(config, workspaceDir, { id: `${id}-gallery`, duration }); + const visual = galleryVisual || config.input?.visual; // Note: buildFilterComplex is not exported from render-engine.mjs // Using simplified preview without complex filters const filterComplex = []; - onLog?.(`[FullPreview] Using simplified preview mode`); + onLog?.(`[FullPreview] Using simplified preview mode / gallery=${galleryVisual ? 'on' : 'off'}`); // Build FFmpeg command with full pipeline const args = ['-y']; // Inputs - if (config.input?.visual) { + if (visual) { args.push('-ss', String(startAt)); - args.push('-i', config.input.visual); + args.push('-i', visual); } if (config.input?.audio) { @@ -214,7 +185,7 @@ export async function generateFullPreview(config, options = {}) { let lastProgress = 0; const result = await runCmd(FFMPEG, args, { - onStderr: (line) => { + onStderr: line => { const timeMatch = line.match(/time=(\d+):(\d+):(\d+\.\d+)/); if (timeMatch) { const hours = parseInt(timeMatch[1]); @@ -222,7 +193,7 @@ export async function generateFullPreview(config, options = {}) { const secs = parseFloat(timeMatch[3]); const currentTime = hours * 3600 + mins * 60 + secs; const progress = Math.min(100, Math.round((currentTime / duration) * 100)); - + if (progress > lastProgress) { lastProgress = progress; onProgress?.({ diff --git a/backend/render-engine.mjs b/backend/render-engine.mjs index 5be79f7..54e2403 100644 --- a/backend/render-engine.mjs +++ b/backend/render-engine.mjs @@ -1,4 +1,3 @@ - import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; @@ -9,20 +8,156 @@ import { FFMPEG, FFPROBE } from './bin-resolver.mjs'; function run(command, args) { return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); - let stdout = '', stderr = ''; - child.stdout.on('data', (b) => stdout += b.toString()); - child.stderr.on('data', (b) => stderr += b.toString()); + let stdout = '', + stderr = ''; + child.stdout.on('data', b => (stdout += b.toString())); + child.stderr.on('data', b => (stderr += b.toString())); child.on('error', reject); - child.on('close', (code) => code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `${command} exit ${code}`))); + child.on('close', code => + code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `${command} exit ${code}`)), + ); }); } export async function ffprobeDuration(file) { if (!hasFile(file)) return 0; try { - const out = await run(FFPROBE, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nk=1:nw=1', file]); + const out = await run(FFPROBE, [ + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'default=nk=1:nw=1', + file, + ]); return Number(out) || 0; - } catch { return 0; } + } catch { + return 0; + } +} + +const GALLERY_TRANSITIONS = { + fade: 'fade', + slide: 'slideleft', + zoom: 'circlecrop', + crossfade: 'dissolve', +}; + +const KENBURNS_MODES = { + 'zoom-in': (w, h, fps, dur) => + `zoompan=z='min(zoom+0.001,1.15)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=${fps * dur}:s=${w}x${h}:fps=${fps}`, + 'zoom-out': (w, h, fps, dur) => + `zoompan=z='if(eq(on,1),1.15,max(zoom-0.001,1.0))':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=${fps * dur}:s=${w}x${h}:fps=${fps}`, + 'pan-left': (w, h, fps, dur) => + `zoompan=z='1.12':x='iw*0.12-iw*0.12*on/${fps * dur}':y='ih/2-(ih/zoom/2)':d=${fps * dur}:s=${w}x${h}:fps=${fps}`, + 'pan-right': (w, h, fps, dur) => + `zoompan=z='1.12':x='iw*0.0+iw*0.12*on/${fps * dur}':y='ih/2-(ih/zoom/2)':d=${fps * dur}:s=${w}x${h}:fps=${fps}`, +}; +const KENBURNS_KEYS = Object.keys(KENBURNS_MODES); + +export async function buildGallerySlideshowFile(config, workspaceDir, job) { + const gallery = config.spectrum?.gallery; + if (!gallery?.enabled || !Array.isArray(gallery.images)) return ''; + let media = gallery.images.filter(f => hasFile(f) && (isImage(f) || isVideo(f))); + if (!media.length) return ''; + if (media.length === 1 && isImage(media[0]) && !gallery.kenBurns) return media[0]; + + if (gallery.shuffle) { + media = [...media]; + for (let i = media.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [media[i], media[j]] = [media[j], media[i]]; + } + } + + await mkdir(path.join(workspaceDir, 'tmp'), { recursive: true }); + const out = path.join(workspaceDir, 'tmp', `${job.id || Date.now()}-gallery.mp4`); + const { width, height } = targetSize(config); + const fps = Number(config.target?.fps || 30); + const perImage = Math.max(1, Number(gallery.duration || 5)); + const transitionName = GALLERY_TRANSITIONS[gallery.transition] || 'fade'; + const transitionDuration = Math.min(1.5, Math.max(0.3, perImage * 0.2)); + const kenBurns = Boolean(gallery.kenBurns); + const kenBurnsMode = gallery.kenBurnsMode || 'random'; + + const args = ['-y']; + + // Input: setiap item jadi segment video + for (const item of media) { + if (isImage(item)) { + args.push('-loop', '1', '-framerate', String(fps), '-t', String(perImage), '-i', item); + } else { + // Video: trim to perImage duration + args.push('-t', String(perImage), '-i', item); + } + } + + // Filter graph: scale+pad + optional zoompan, lalu xfade berurutan + const filters = []; + + for (let i = 0; i < media.length; i++) { + const isImg = isImage(media[i]); + let chain = `[${i}:v]scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1`; + + if (kenBurns && isImg) { + // Pick Ken Burns mode + const mode = kenBurnsMode === 'random' ? KENBURNS_KEYS[i % KENBURNS_KEYS.length] : kenBurnsMode; + const kbFilter = KENBURNS_MODES[mode] || KENBURNS_MODES['zoom-in']; + chain += `,format=rgba,${kbFilter(width, height, fps, perImage)}`; + } + + // Video items might have different fps — normalize + if (!isImg) { + chain += `,fps=${fps},setpts=PTS-STARTPTS`; + } + + chain += `,format=yuv420p[img${i}]`; + filters.push(chain); + } + + if (media.length === 1) { + // Single item with Ken Burns — no xfade needed + filters.push(`[img0]null[vout]`); + } else if (media.length === 2) { + const offset = Math.max(0.1, perImage - transitionDuration); + filters.push( + `[img0][img1]xfade=transition=${transitionName}:duration=${transitionDuration}:offset=${offset}[vout]`, + ); + } else { + // N items: chain xfade sequentially + let prev = 'img0'; + let accumulatedOffset = 0; + + for (let i = 1; i < media.length; i++) { + const label = i === media.length - 1 ? 'vout' : `xf${i}`; + accumulatedOffset += perImage - transitionDuration; + filters.push( + `[${prev}][img${i}]xfade=transition=${transitionName}:duration=${transitionDuration}:offset=${accumulatedOffset}[${label}]`, + ); + prev = label; + } + } + + args.push('-filter_complex', filters.join(';')); + args.push( + '-map', + '[vout]', + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-crf', + '22', + '-pix_fmt', + 'yuv420p', + '-movflags', + '+faststart', + out, + ); + + await run(FFMPEG, args); + return out; } async function buildPlaylistMixFile(files, config, workspaceDir, job) { @@ -35,7 +170,8 @@ async function buildPlaylistMixFile(files, config, workspaceDir, job) { clean.forEach(f => args.push('-i', f)); const cross = Math.max(0, Number(config.audio?.crossfade ?? 0.8)); const silence = Math.max(0, Number(config.audio?.silenceBetween ?? 0)); - const fmt = (i) => `[${i}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo${silence > 0 ? `,apad=pad_dur=${silence}` : ''}[a${i}]`; + const fmt = i => + `[${i}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo${silence > 0 ? `,apad=pad_dur=${silence}` : ''}[a${i}]`; const filters = clean.map((_, i) => fmt(i)); let prev = 'a0'; for (let i = 1; i < clean.length; i++) { @@ -52,7 +188,8 @@ async function buildPlaylistMixFile(files, config, workspaceDir, job) { function qualityPreset(config) { const perfPreset = config.performance?.x264Preset; const q = config.target.quality || 'balanced'; - if (perfPreset) return { preset: perfPreset, crf: String(config.target?.crf ?? (q === 'fast' ? 26 : q === 'high' ? 18 : 22)) }; + if (perfPreset) + return { preset: perfPreset, crf: String(config.target?.crf ?? (q === 'fast' ? 26 : q === 'high' ? 18 : 22)) }; if (q === 'fast') return { preset: 'veryfast', crf: String(config.target?.crf ?? 26) }; if (q === 'high') return { preset: 'slow', crf: String(config.target?.crf ?? 18) }; return { preset: 'medium', crf: String(config.target?.crf ?? 22) }; @@ -67,8 +204,12 @@ function targetSize(config) { async function encoderArgs(config) { const hw = config.target?.hardwareAccel || 'auto'; let encText = ''; - try { encText = await run(FFMPEG, ['-hide_banner', '-encoders']); } catch { encText = ''; } - const has = (name) => encText.includes(name); + try { + encText = await run(FFMPEG, ['-hide_banner', '-encoders']); + } catch { + encText = ''; + } + const has = name => encText.includes(name); if ((hw === 'nvidia' || hw === 'auto') && has('h264_nvenc')) return ['-c:v', 'h264_nvenc', '-preset', 'p4']; if ((hw === 'intel' || hw === 'auto') && has('h264_qsv')) return ['-c:v', 'h264_qsv']; if ((hw === 'amd' || hw === 'auto') && has('h264_amf')) return ['-c:v', 'h264_amf']; @@ -103,11 +244,11 @@ function overlayPosition(position = 'Kanan Atas', marginX = 20, marginY = 20, co const y = position.includes('Atas') ? safe.y : position.includes('Tengah') ? `(H-h)/2` : `H-h-${safe.y}`; return `${x}:${y}`; } -function overlayPositionAnimated(position, marginX, marginY, config, animation, start, end, duration) { +function overlayPositionAnimated(position, marginX, marginY, config, animation, start, _end, _duration) { const base = overlayPosition(position, marginX, marginY, config).split(':'); - const x = base[0], y = base[1]; + const x = base[0], + y = base[1]; const s = Number(start || 0); - const e = Number(end || 0) > 0 ? Number(end) : duration; if (animation === 'slide-left') return [`if(lt(t,${s + 0.6}),W-(W-(${x}))*(t-${s})/0.6,${x})`, y]; if (animation === 'slide-right') return [`if(lt(t,${s + 0.6}),-w+(${x}+w)*(t-${s})/0.6,${x})`, y]; if (animation === 'pulse') return [x, `${y}+sin(t*6)*4`]; @@ -123,21 +264,33 @@ function enableExpr(start = 0, end = 0, duration = 0) { function bumperWindows(position, bumperDuration, duration) { const d = Math.max(0.2, Number(bumperDuration) || 3); if (position === 'Awal') return [`between(t,0,${Math.min(duration, d)})`]; - if (position === 'Keduanya') return [`between(t,0,${Math.min(duration, d)})`, `between(t,${Math.max(0, duration - d)},${duration})`]; + if (position === 'Keduanya') + return [`between(t,0,${Math.min(duration, d)})`, `between(t,${Math.max(0, duration - d)},${duration})`]; return [`between(t,${Math.max(0, duration - d)},${duration})`]; } function parseNowPlayingText(config = {}, audioFile = '', job = {}) { - const sp = config.spectrum || {}; const input = config.input || {}; + const sp = config.spectrum || {}; + const input = config.input || {}; let title = job.title || input.title || ''; - if (!title && sp.nowPlayingAutoFromFile && audioFile) title = path.basename(audioFile).replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim(); + if (!title && sp.nowPlayingAutoFromFile && audioFile) + title = path + .basename(audioFile) + .replace(/\.[^.]+$/, '') + .replace(/[_-]+/g, ' ') + .trim(); const artist = sp.nowPlayingArtist || ''; const album = sp.nowPlayingAlbum || ''; - return String(sp.nowPlayingTemplate || '{title}').replaceAll('{title}', title || 'Now Playing').replaceAll('{artist}', artist).replaceAll('{album}', album).replaceAll('{filename}', audioFile ? path.basename(audioFile) : ''); + return String(sp.nowPlayingTemplate || '{title}') + .replaceAll('{title}', title || 'Now Playing') + .replaceAll('{artist}', artist) + .replaceAll('{album}', album) + .replaceAll('{filename}', audioFile ? path.basename(audioFile) : ''); } -function spectrumOverlayPosition(config, width, height, spectrumHeight) { +function spectrumOverlayPosition(config, _width, _height, _spectrumHeight) { const sp = config.spectrum || {}; - const marginX = Number(sp.marginX ?? 0); const marginY = Number(sp.marginY ?? 34); + const marginX = Number(sp.marginX ?? 0); + const marginY = Number(sp.marginY ?? 34); const pos = sp.position || 'Bawah'; const x = sp.widthMode === 'center' ? `(W-w)/2` : marginX; if (Number.isFinite(Number(sp.previewY))) { @@ -150,7 +303,8 @@ function spectrumOverlayPosition(config, width, height, spectrumHeight) { function normalizeColor(c) { const named = { white: 'white', red: 'red', blue: 'blue', yellow: 'yellow', cyan: 'cyan', green: 'green' }; if (!c) return 'white'; - const s = String(c); return named[s] || s.replace('#','0x'); + const s = String(c); + return named[s] || s.replace('#', '0x'); } function audioProcessingChain(inputLabel, outputLabel, config, duration) { @@ -172,11 +326,15 @@ function audioProcessingChain(inputLabel, outputLabel, config, duration) { if (Number(a.highPass || 0) > 0) filters.push(`highpass=f=${Number(a.highPass)}`); if (Number(a.lowPass || 0) > 0) filters.push(`lowpass=f=${Number(a.lowPass)}`); if (a.deHum) filters.push('anequalizer=c0 f=50 w=35 g=-18|c1 f=50 w=35 g=-18'); - if (a.noiseGate) filters.push(`agate=threshold=${Number(a.noiseGateThreshold ?? -45)}dB:ratio=2:attack=20:release=250`); + if (a.noiseGate) + filters.push(`agate=threshold=${Number(a.noiseGateThreshold ?? -45)}dB:ratio=2:attack=20:release=250`); if (Number(a.bassGain || 0) !== 0) filters.push(`equalizer=f=100:t=q:w=1:g=${Number(a.bassGain || 0)}`); if (Number(a.midGain || 0) !== 0) filters.push(`equalizer=f=1000:t=q:w=1:g=${Number(a.midGain || 0)}`); if (Number(a.trebleGain || 0) !== 0) filters.push(`equalizer=f=8000:t=q:w=1:g=${Number(a.trebleGain || 0)}`); - if (a.compressor) filters.push(`acompressor=threshold=${Number(a.compressorThreshold ?? -18)}dB:ratio=${Number(a.compressorRatio ?? 3)}:attack=20:release=250`); + if (a.compressor) + filters.push( + `acompressor=threshold=${Number(a.compressorThreshold ?? -18)}dB:ratio=${Number(a.compressorRatio ?? 3)}:attack=20:release=250`, + ); if (a.normalize) filters.push('loudnorm=I=-14:TP=-1.5:LRA=11'); filters.push(`afade=t=in:st=0:d=${Number(a.fadeIn ?? 0.6)}`); filters.push(`afade=t=out:st=${Math.max(0, duration - Number(a.fadeOut ?? 1.2))}:d=${Number(a.fadeOut ?? 1.2)}`); @@ -197,30 +355,100 @@ function audioReactiveSpec(config) { async function waveformPeaks(file, seconds = 60, buckets = 240) { if (!hasFile(file)) return []; try { - const child = spawn(FFMPEG, ['-v', 'error', '-i', file, '-t', String(Math.max(1, Math.min(300, seconds))), '-ac', '1', '-ar', '8000', '-f', 's16le', 'pipe:1'], { stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn( + FFMPEG, + [ + '-v', + 'error', + '-i', + file, + '-t', + String(Math.max(1, Math.min(300, seconds))), + '-ac', + '1', + '-ar', + '8000', + '-f', + 's16le', + 'pipe:1', + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); const chunks = []; - await new Promise((resolve) => { child.stdout.on('data', b => chunks.push(b)); child.on('close', resolve); child.on('error', resolve); }); - const buf = Buffer.concat(chunks); const samples = Math.floor(buf.length / 2); const count = Math.max(30, Math.min(500, buckets)); const per = Math.max(1, Math.floor(samples / count)); const peaks = []; - for (let i = 0; i < count; i++) { let peak = 0; for (let j = i * per; j < Math.min(samples, (i + 1) * per); j++) peak = Math.max(peak, Math.abs(buf.readInt16LE(j * 2)) / 32768); peaks.push(peak); } + await new Promise(resolve => { + child.stdout.on('data', b => chunks.push(b)); + child.on('close', resolve); + child.on('error', resolve); + }); + const buf = Buffer.concat(chunks); + const samples = Math.floor(buf.length / 2); + const count = Math.max(30, Math.min(500, buckets)); + const per = Math.max(1, Math.floor(samples / count)); + const peaks = []; + for (let i = 0; i < count; i++) { + let peak = 0; + for (let j = i * per; j < Math.min(samples, (i + 1) * per); j++) + peak = Math.max(peak, Math.abs(buf.readInt16LE(j * 2)) / 32768); + peaks.push(peak); + } return peaks; - } catch { return []; } + } catch { + return []; + } } function beatEnableExpression(peaks, seconds = 60, sensitivity = 55) { if (!peaks?.length) return ''; - const avg = peaks.reduce((a,b)=>a+b,0) / peaks.length; + const avg = peaks.reduce((a, b) => a + b, 0) / peaks.length; const sens = Math.max(0, Math.min(100, Number(sensitivity ?? 55))); const multiplier = 2.0 - (sens / 100) * 1.15; const floor = 0.32 - (sens / 100) * 0.18; const threshold = Math.max(avg * multiplier, floor); const parts = []; - peaks.forEach((v, i) => { const prev = peaks[i-1] || 0; const next = peaks[i+1] || 0; if (v >= threshold && v >= prev && v >= next) { const t = (i / peaks.length) * seconds; parts.push(`between(t,${Math.max(0,t-0.05).toFixed(2)},${(t+0.14).toFixed(2)})`); } }); + peaks.forEach((v, i) => { + const prev = peaks[i - 1] || 0; + const next = peaks[i + 1] || 0; + if (v >= threshold && v >= prev && v >= next) { + const t = (i / peaks.length) * seconds; + parts.push(`between(t,${Math.max(0, t - 0.05).toFixed(2)},${(t + 0.14).toFixed(2)})`); + } + }); return parts.slice(0, 160).join('+'); } +export function resolveLayerOrder(layerOrder) { + const defaultOrder = [ + 'bumper', + 'particle', + 'logo', + 'cta', + 'spectrum', + 'lyrics', + 'watermark', + 'nowPlaying', + 'timestamp', + 'lowerThird', + ]; + const parsed = String(layerOrder || '') + .split(',') + .map(part => part.trim()) + .filter(Boolean); + const supported = new Set(defaultOrder); + const ordered = []; + for (const key of parsed) { + if (!supported.has(key) || ordered.includes(key)) continue; + ordered.push(key); + } + for (const key of defaultOrder) { + if (!ordered.includes(key)) ordered.push(key); + } + return ordered; +} export async function buildFfmpegArgs(job, config, workspaceDir) { - const visual = job.input?.visual || config.input.visual; + let visual = job.input?.visual || config.input.visual; let audio = job.input?.audio || config.input.audio; + const galleryVisual = await buildGallerySlideshowFile(config, workspaceDir, job); + if (galleryVisual) visual = galleryVisual; const logo = job.input?.logo || config.branding.logo; const particle = job.input?.particle || config.overlay.particleFile; const bumper = job.input?.bumper || config.branding.bumperVideo; @@ -232,7 +460,11 @@ export async function buildFfmpegArgs(job, config, workspaceDir) { if (!hasFile(audio)) throw new Error(`File audio tidak ditemukan: ${audio || '(kosong)'}`); if (!isImage(visual) && !isVideo(visual)) throw new Error(`Format visual belum didukung: ${visual}`); if (config.audio?.mixMode === 'playlist') { - const playlistFiles = [...(config.audio?.introSongs || []), ...(config.audio?.songs?.length ? config.audio.songs : [audio]), config.audio?.endingSong].filter(Boolean); + const playlistFiles = [ + ...(config.audio?.introSongs || []), + ...(config.audio?.songs?.length ? config.audio.songs : [audio]), + config.audio?.endingSong, + ].filter(Boolean); const mixed = await buildPlaylistMixFile(playlistFiles, config, workspaceDir, job); if (mixed) audio = mixed; } @@ -257,67 +489,109 @@ export async function buildFfmpegArgs(job, config, workspaceDir) { let nextIndex = 2; const indexes = {}; - for (const [key, file] of Object.entries({ logo, particle, bumper, cta, overlayAsset: config.overlay?.overlayFile, ambient: config.audio?.ambientLoop, voice: config.audio?.voiceTrack, effect: config.audio?.effectTrack })) { + for (const [key, file] of Object.entries({ + logo, + particle, + bumper, + cta, + overlayAsset: config.overlay?.overlayFile, + ambient: config.audio?.ambientLoop, + voice: config.audio?.voiceTrack, + effect: config.audio?.effectTrack, + })) { if (hasFile(file)) { - if (key === 'particle' || key === 'bumper' || key === 'cta' || key === 'ambient' || (key === 'overlayAsset' && isVideo(file))) args.push('-stream_loop', '-1'); + if ( + key === 'particle' || + key === 'bumper' || + key === 'cta' || + key === 'ambient' || + (key === 'overlayAsset' && isVideo(file)) + ) + args.push('-stream_loop', '-1'); if (key === 'overlayAsset' && isImage(file)) args.push('-loop', '1', '-t', String(duration)); indexes[key] = nextIndex++; args.push('-i', file); } } indexes.stems = []; - for (const stem of (config.audio?.stems || [])) { + for (const stem of config.audio?.stems || []) { if (hasFile(stem?.file) && stem?.mute !== true) { - indexes.stems.push({ index: nextIndex++, volume: Number(stem.volume ?? 100), pan: Number(stem.pan ?? 0), name: stem.name || 'stem' }); + indexes.stems.push({ + index: nextIndex++, + volume: Number(stem.volume ?? 100), + pan: Number(stem.pan ?? 0), + name: stem.name || 'stem', + }); args.push('-i', stem.file); } } let lyricRows = []; if (hasFile(lyricFile) && config.lyrics?.enabled !== false) lyricRows = await readLyricsFile(lyricFile); - const assFile = path.join(workspaceDir, 'tmp', `${job.id || Date.now()}.ass`); - const shouldWriteAss = Boolean((config.spectrum?.nowPlaying && (job.title || config.input.title)) || lyricRows.length || config.overlay?.timestamp || (config.branding?.watermarkEnabled && config.branding?.watermarkText)); - if (shouldWriteAss) { - await writeFile(assFile, buildAssDocument({ - rows: lyricRows, - title: config.spectrum?.nowPlaying ? parseNowPlayingText(config, audio, job) : '', - duration, - font: config.lyrics?.font || 'Arial', - fontSize: Number(config.lyrics?.scale || 28), - lyricColor: config.lyrics?.color || '#ffffff', - lyricHighlightColor: config.lyrics?.highlightColor || '#22c55e', - lyricPosition: config.lyrics?.position || 'Bawah', - lyricAlign: config.lyrics?.align || 'Rata Tengah', - lyricOutline: Number(config.lyrics?.outline ?? 2), - lyricShadow: Number(config.lyrics?.shadow ?? 1), - lyricKaraoke: Boolean(config.lyrics?.karaoke), - titleEnabled: Boolean(config.spectrum?.nowPlaying), - titlePosition: config.spectrum?.nowPlayingPosition || 'Atas', - titleFontSize: Number(config.spectrum?.nowPlayingFontSize || 26), - titleColor: config.spectrum?.nowPlayingColor || '#ffffff', - titleX: config.spectrum?.nowPlayingX ? Math.round(width * Number(config.spectrum.nowPlayingX) / 100) : 0, - titleY: config.spectrum?.nowPlayingY ? Math.round(height * Number(config.spectrum.nowPlayingY) / 100) : 0, - timestampEnabled: Boolean(config.overlay?.timestamp), - timestampText: config.overlay?.timestampText || 'Rendered by PidioForge', - timestampPosition: config.overlay?.timestampPosition || 'Kiri Atas', - lowerThirdEnabled: Boolean(config.overlay?.lowerThirdEnabled), - lowerThirdText: config.overlay?.lowerThirdText || '', - lowerThirdPosition: config.overlay?.lowerThirdPosition || 'Bawah', - lowerThirdAt: Number(config.overlay?.lowerThirdAt || 2), - lowerThirdDuration: Number(config.overlay?.lowerThirdDuration || 5), - watermark: config.branding?.watermarkText || '', - watermarkEnabled: Boolean(config.branding?.watermarkEnabled), - watermarkPosition: config.branding?.watermarkPosition || 'Kiri Bawah', - watermarkOpacity: Number(config.branding?.watermarkOpacity ?? 70), - watermarkMode: config.branding?.watermarkMode || 'always', - watermarkInterval: Number(config.branding?.watermarkInterval || 12), - watermarkVisibleDuration: Number(config.branding?.watermarkVisibleDuration || 5), - }), 'utf8'); + + // Shared params for all per-layer ASS documents (title/timestamp/lowerThird/watermark/lyrics) + const assBaseParams = { + rows: lyricRows, + title: config.spectrum?.nowPlaying ? parseNowPlayingText(config, audio, job) : '', + duration, + font: config.lyrics?.font || 'Arial', + fontSize: Number(config.lyrics?.scale || 28), + lyricColor: config.lyrics?.color || '#ffffff', + lyricHighlightColor: config.lyrics?.highlightColor || '#22c55e', + lyricPosition: config.lyrics?.position || 'Bawah', + lyricAlign: config.lyrics?.align || 'Rata Tengah', + lyricOutline: Number(config.lyrics?.outline ?? 2), + lyricShadow: Number(config.lyrics?.shadow ?? 1), + lyricKaraoke: Boolean(config.lyrics?.karaoke), + titleEnabled: Boolean(config.spectrum?.nowPlaying), + titlePosition: config.spectrum?.nowPlayingPosition || 'Atas', + titleFontSize: Number(config.spectrum?.nowPlayingFontSize || 26), + titleColor: config.spectrum?.nowPlayingColor || '#ffffff', + titleX: config.spectrum?.nowPlayingX ? Math.round((width * Number(config.spectrum.nowPlayingX)) / 100) : 0, + titleY: config.spectrum?.nowPlayingY ? Math.round((height * Number(config.spectrum.nowPlayingY)) / 100) : 0, + timestampEnabled: Boolean(config.overlay?.timestamp), + timestampText: config.overlay?.timestampText || 'Rendered by PidioForge', + timestampPosition: config.overlay?.timestampPosition || 'Kiri Atas', + lowerThirdEnabled: Boolean(config.overlay?.lowerThirdEnabled), + lowerThirdText: config.overlay?.lowerThirdText || '', + lowerThirdPosition: config.overlay?.lowerThirdPosition || 'Bawah', + lowerThirdAt: Number(config.overlay?.lowerThirdAt || 2), + lowerThirdDuration: Number(config.overlay?.lowerThirdDuration || 5), + watermark: config.branding?.watermarkText || '', + watermarkEnabled: Boolean(config.branding?.watermarkEnabled), + watermarkPosition: config.branding?.watermarkPosition || 'Kiri Bawah', + watermarkOpacity: Number(config.branding?.watermarkOpacity ?? 70), + watermarkMode: config.branding?.watermarkMode || 'always', + watermarkInterval: Number(config.branding?.watermarkInterval || 12), + watermarkVisibleDuration: Number(config.branding?.watermarkVisibleDuration || 5), + playResX: width, + playResY: height, + }; + const textLayerActive = { + nowPlaying: Boolean(assBaseParams.titleEnabled && assBaseParams.title), + timestamp: Boolean(assBaseParams.timestampEnabled), + lowerThird: Boolean(assBaseParams.lowerThirdEnabled && assBaseParams.lowerThirdText), + watermark: Boolean(assBaseParams.watermarkEnabled && assBaseParams.watermark), + lyrics: lyricRows.length > 0, + }; + async function writeLayerAss(layerKey) { + const doc = buildAssDocument({ ...assBaseParams, include: layerKey }); + if (!doc) return ''; + const file = path.join(workspaceDir, 'tmp', `${job.id || Date.now()}-${layerKey}.ass`); + await writeFile(file, doc, 'utf8'); + return file; } const filters = []; - filters.push(`[0:v]scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=rgba[v0]`); - const introDelay = (config.branding?.bumperEnabled && (config.branding?.bumperPosition === 'Awal' || config.branding?.bumperPosition === 'Keduanya') && config.branding?.bumperAudioMode === 'after-intro') ? Math.round(Number(config.branding?.bumperDuration || 0) * 1000) : 0; + filters.push( + `[0:v]scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=rgba[v0]`, + ); + const introDelay = + config.branding?.bumperEnabled && + (config.branding?.bumperPosition === 'Awal' || config.branding?.bumperPosition === 'Keduanya') && + config.branding?.bumperAudioMode === 'after-intro' + ? Math.round(Number(config.branding?.bumperDuration || 0) * 1000) + : 0; const audioBase = introDelay > 0 ? `[1:a]adelay=${introDelay}:all=1[auddelayed]` : ''; if (audioBase) filters.push(audioBase); const audioIn = introDelay > 0 ? 'auddelayed' : '1:a'; @@ -329,11 +603,15 @@ export async function buildFfmpegArgs(job, config, workspaceDir) { mixLabels.push('ambient'); } if (indexes.voice !== undefined) { - filters.push(`[${indexes.voice}:a]volume=${Number(config.audio?.voiceVolume ?? 100) / 100},atrim=0:${duration}[voice]`); + filters.push( + `[${indexes.voice}:a]volume=${Number(config.audio?.voiceVolume ?? 100) / 100},atrim=0:${duration}[voice]`, + ); mixLabels.push('voice'); } if (indexes.effect !== undefined) { - filters.push(`[${indexes.effect}:a]volume=${Number(config.audio?.effectVolume ?? 80) / 100},atrim=0:${duration}[effect]`); + filters.push( + `[${indexes.effect}:a]volume=${Number(config.audio?.effectVolume ?? 80) / 100},atrim=0:${duration}[effect]`, + ); mixLabels.push('effect'); } for (let si = 0; si < (indexes.stems || []).length; si++) { @@ -348,148 +626,257 @@ export async function buildFfmpegArgs(job, config, workspaceDir) { filters.push(`[${stem.index}:a]${chain},atrim=0:${duration}[stem${si}]`); mixLabels.push(`stem${si}`); } - if (mixLabels.length > 1) filters.push(`${mixLabels.map(x => `[${x}]`).join('')}amix=inputs=${mixLabels.length}:duration=first:dropout_transition=2[a_mixed]`); + if (mixLabels.length > 1) + filters.push( + `${mixLabels.map(x => `[${x}]`).join('')}amix=inputs=${mixLabels.length}:duration=first:dropout_transition=2[a_mixed]`, + ); else filters.push('[amain_processed]anull[a_mixed]'); if (config.spectrum?.enabled !== false) { filters.push('[a_mixed]asplit=2[aout][awave]'); } else { filters.push('[a_mixed]anull[aout]'); } - const detectedBeatEnable = config.audio?.beatDetection !== false ? beatEnableExpression(await waveformPeaks(audio, Math.min(duration, 90), 240), Math.min(duration, 90), config.spectrum?.beatSensitivity ?? config.audio?.reactiveStrength ?? 55) : ''; + const detectedBeatEnable = + config.audio?.beatDetection !== false + ? beatEnableExpression( + await waveformPeaks(audio, Math.min(duration, 90), 240), + Math.min(duration, 90), + config.spectrum?.beatSensitivity ?? config.audio?.reactiveStrength ?? 55, + ) + : ''; let v = 'v0'; let n = 1; - if (indexes.bumper !== undefined && config.branding?.bumperEnabled) { + // --- Reorderable video layers (branding.layerOrder controls bumper/particle/logo/cta/spectrum) --- + function applyBumperLayer() { + if (indexes.bumper === undefined || !config.branding?.bumperEnabled) return; const windows = bumperWindows(config.branding.bumperPosition, config.branding.bumperDuration, duration); const labels = windows.map((_, i) => `bumper${i}`); if (windows.length > 1) { - filters.push(`[${indexes.bumper}:v]scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},setsar=1,format=rgba${config.branding?.bumperTransition === 'fade' ? `,fade=t=in:st=0:d=${Number(config.branding?.bumperFadeDuration || 0.45)}:alpha=1,fade=t=out:st=${Math.max(0.1, Number(config.branding?.bumperDuration || 3)-Number(config.branding?.bumperFadeDuration || 0.45))}:d=${Number(config.branding?.bumperFadeDuration || 0.45)}:alpha=1` : ''},split=${windows.length}${labels.map(x => `[${x}]`).join('')}`); + filters.push( + `[${indexes.bumper}:v]scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},setsar=1,format=rgba${config.branding?.bumperTransition === 'fade' ? `,fade=t=in:st=0:d=${Number(config.branding?.bumperFadeDuration || 0.45)}:alpha=1,fade=t=out:st=${Math.max(0.1, Number(config.branding?.bumperDuration || 3) - Number(config.branding?.bumperFadeDuration || 0.45))}:d=${Number(config.branding?.bumperFadeDuration || 0.45)}:alpha=1` : ''},split=${windows.length}${labels.map(x => `[${x}]`).join('')}`, + ); } else { - filters.push(`[${indexes.bumper}:v]scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},setsar=1,format=rgba${config.branding?.bumperTransition === 'fade' ? `,fade=t=in:st=0:d=${Number(config.branding?.bumperFadeDuration || 0.45)}:alpha=1,fade=t=out:st=${Math.max(0.1, Number(config.branding?.bumperDuration || 3)-Number(config.branding?.bumperFadeDuration || 0.45))}:d=${Number(config.branding?.bumperFadeDuration || 0.45)}:alpha=1` : ''}[bumper0]`); + filters.push( + `[${indexes.bumper}:v]scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},setsar=1,format=rgba${config.branding?.bumperTransition === 'fade' ? `,fade=t=in:st=0:d=${Number(config.branding?.bumperFadeDuration || 0.45)}:alpha=1,fade=t=out:st=${Math.max(0.1, Number(config.branding?.bumperDuration || 3) - Number(config.branding?.bumperFadeDuration || 0.45))}:d=${Number(config.branding?.bumperFadeDuration || 0.45)}:alpha=1` : ''}[bumper0]`, + ); } windows.forEach((expr, i) => { - filters.push(`[${v}][${labels[i]}]overlay=0:0:enable='${expr}':shortest=0[v${n}]`); v = `v${n++}`; + filters.push(`[${v}][${labels[i]}]overlay=0:0:enable='${expr}':shortest=0[v${n}]`); + v = `v${n++}`; }); } - - if (indexes.particle !== undefined && config.overlay.videoParticle !== false) { + function applyParticleLayer() { + if (indexes.particle === undefined || config.overlay.videoParticle === false) return; const opacity = Number(config.overlay.particleOpacity ?? 70) / 100; - filters.push(`[${indexes.particle}:v]scale=${width}:${height},format=rgba,colorchannelmixer=aa=${opacity}[particle]`); - filters.push(`[${v}][particle]overlay=0:0:shortest=1[v${n}]`); v = `v${n++}`; - } - - if (indexes.overlayAsset !== undefined && config.overlay?.overlayEnabled) { - const scale = Number(config.overlay.overlayScale || 100) / 100; - const opacity = Number(config.overlay.overlayOpacity ?? 80) / 100; - const xy = overlayPosition(config.overlay.overlayPosition || 'Tengah', 28, 28, config); - const enable = enableExpr(config.overlay.overlayStart || 0, config.overlay.overlayEnd || 0, duration); - filters.push(`[${indexes.overlayAsset}:v]scale=iw*${scale}:-1,format=rgba,colorchannelmixer=aa=${opacity}[overlayAsset]`); - filters.push(`[${v}][overlayAsset]overlay=${xy}:enable='${enable}'[v${n}]`); v = `v${n++}`; - } + filters.push( + `[${indexes.particle}:v]scale=${width}:${height},format=rgba,colorchannelmixer=aa=${opacity}[particle]`, + ); + filters.push(`[${v}][particle]overlay=0:0:shortest=1[v${n}]`); + v = `v${n++}`; + + // overlayAsset and beat-reactive flash aren't user-orderable; keep them adjacent to particle as before + if (indexes.overlayAsset !== undefined && config.overlay?.overlayEnabled) { + const scale = Number(config.overlay.overlayScale || 100) / 100; + const overlayOpacity = Number(config.overlay.overlayOpacity ?? 80) / 100; + const xy = overlayPosition(config.overlay.overlayPosition || 'Tengah', 28, 28, config); + const enable = enableExpr(config.overlay.overlayStart || 0, config.overlay.overlayEnd || 0, duration); + filters.push( + `[${indexes.overlayAsset}:v]scale=iw*${scale}:-1,format=rgba,colorchannelmixer=aa=${overlayOpacity}[overlayAsset]`, + ); + filters.push(`[${v}][overlayAsset]overlay=${xy}:enable='${enable}'[v${n}]`); + v = `v${n++}`; + } - if (config.audio?.reactiveFx && config.audio.reactiveFx !== 'Off') { - const reactive = audioReactiveSpec(config); - if (reactive) { - const color = config.audio?.beatFlashColor || 'white'; - const enable = detectedBeatEnable || reactive.enable; - filters.push(`[${v}]drawbox=x=0:y=0:w=iw:h=ih:color=${color}@${reactive.opacity}:t=fill:enable='${enable}'[v${n}]`); v = `v${n++}`; + if (config.audio?.reactiveFx && config.audio.reactiveFx !== 'Off') { + const reactive = audioReactiveSpec(config); + if (reactive) { + const color = config.audio?.beatFlashColor || 'white'; + const enable = detectedBeatEnable || reactive.enable; + filters.push( + `[${v}]drawbox=x=0:y=0:w=iw:h=ih:color=${color}@${reactive.opacity}:t=fill:enable='${enable}'[v${n}]`, + ); + v = `v${n++}`; + } } } - if (indexes.logo !== undefined && config.branding?.logoEnabled !== false) { + function applyLogoLayer() { + if (indexes.logo === undefined || config.branding?.logoEnabled === false) return; const scale = Number(config.branding.logoScale || 18) / 100; const opacity = Number(config.branding.logoOpacity ?? 100) / 100; const startLogo = Number(config.branding.logoStart || 0); const endLogo = Number(config.branding.logoEnd || 0) > 0 ? Number(config.branding.logoEnd) : duration; const fade = Number(config.branding.logoFadeDuration || 0.6); - const [lx, ly] = overlayPositionAnimated(config.branding.logoPosition || 'Kanan Atas', Number(config.branding.logoMarginX || 20), Number(config.branding.logoMarginY || 20), config, config.branding.logoAnimation || 'none', startLogo, endLogo, duration); + const [lx, ly] = overlayPositionAnimated( + config.branding.logoPosition || 'Kanan Atas', + Number(config.branding.logoMarginX || 20), + Number(config.branding.logoMarginY || 20), + config, + config.branding.logoAnimation || 'none', + startLogo, + endLogo, + duration, + ); const enable = enableExpr(startLogo, endLogo, duration); - const fadeChain = config.branding.logoAnimation === 'fade' ? `,fade=t=in:st=${startLogo}:d=${fade}:alpha=1,fade=t=out:st=${Math.max(startLogo, endLogo - fade)}:d=${fade}:alpha=1` : ''; - filters.push(`[${indexes.logo}:v]scale=iw*${scale}:-1,format=rgba,colorchannelmixer=aa=${opacity}${fadeChain}[logo]`); - filters.push(`[${v}][logo]overlay=x='${lx}':y='${ly}':enable='${enable}'[v${n}]`); v = `v${n++}`; + const fadeChain = + config.branding.logoAnimation === 'fade' + ? `,fade=t=in:st=${startLogo}:d=${fade}:alpha=1,fade=t=out:st=${Math.max(startLogo, endLogo - fade)}:d=${fade}:alpha=1` + : ''; + filters.push( + `[${indexes.logo}:v]scale=iw*${scale}:-1,format=rgba,colorchannelmixer=aa=${opacity}${fadeChain}[logo]`, + ); + filters.push(`[${v}][logo]overlay=x='${lx}':y='${ly}':enable='${enable}'[v${n}]`); + v = `v${n++}`; } - - - if (indexes.cta !== undefined && config.branding?.ctaEnabled) { + function applyCtaLayer() { + if (indexes.cta === undefined || !config.branding?.ctaEnabled) return; const preset = config.branding.ctaPreset || 'subscribe-lower-right'; const ctaScale = Number(config.branding.ctaScale || (preset === 'center-cta' ? 42 : 26)) / 100; - const pos = preset === 'center-cta' ? 'Tengah' : preset === 'like-subscribe-bottom' ? 'Kanan Bawah' : config.branding.ctaPosition || 'Kanan Bawah'; + const pos = + preset === 'center-cta' + ? 'Tengah' + : preset === 'like-subscribe-bottom' + ? 'Kanan Bawah' + : config.branding.ctaPosition || 'Kanan Bawah'; const xy = overlayPosition(pos, 28, 28, config); const startAt = Number(config.branding.ctaAt || 2); const endAt = Math.min(duration, startAt + Number(config.branding.ctaDuration || 8)); - const chroma = config.branding.ctaChromaPreset === 'blue' ? '0x0000ff' : (config.branding.ctaChromaColor || '0x00ff00'); - const similarity = Number(config.branding.ctaSimilarity ?? (config.branding.ctaChromaPreset === 'auto' ? 0.28 : 0.35)); + const chroma = + config.branding.ctaChromaPreset === 'blue' ? '0x0000ff' : config.branding.ctaChromaColor || '0x00ff00'; + const similarity = Number( + config.branding.ctaSimilarity ?? (config.branding.ctaChromaPreset === 'auto' ? 0.28 : 0.35), + ); const blend = Number(config.branding.ctaBlend ?? 0.08); - filters.push(`[${indexes.cta}:v]scale=iw*${ctaScale}:-1,colorkey=${chroma}:${similarity}:${blend},format=rgba[cta]`); - filters.push(`[${v}][cta]overlay=${xy}:enable='between(t,${startAt},${endAt})'[v${n}]`); v = `v${n++}`; + filters.push( + `[${indexes.cta}:v]scale=iw*${ctaScale}:-1,colorkey=${chroma}:${similarity}:${blend},format=rgba[cta]`, + ); + filters.push(`[${v}][cta]overlay=${xy}:enable='between(t,${startAt},${endAt})'[v${n}]`); + v = `v${n++}`; } - - - if (config.spectrum?.enabled !== false) { + function applySpectrumLayer() { + if (config.spectrum?.enabled === false) return; const sp = config.spectrum || {}; - const colors = (sp.colors?.length ? sp.colors : [sp.color1 || 'white', sp.color2].filter(Boolean)).map(normalizeColor).join('|'); + const colors = (sp.colors?.length ? sp.colors : [sp.color1 || 'white', sp.color2].filter(Boolean)) + .map(normalizeColor) + .join('|'); const opacity = Number(sp.transparency ?? 80) / 100; const specH = Math.max(32, Number(sp.height || 128)); - const specW = sp.widthMode === 'center' ? Math.round(width * Number(sp.zoom || 100) / 100) : width - (Number(sp.marginX || 0) * 2); + const specW = + sp.widthMode === 'center' + ? Math.round((width * Number(sp.zoom || 100)) / 100) + : width - Number(sp.marginX || 0) * 2; const mode = sp.model === 'Bar' ? 'cline' : sp.model === 'Line' ? 'line' : 'cline'; const mirror = sp.mirror === 'On' || sp.mirror === 'Mirror'; if (sp.analyzerMode === 'frequency' || sp.model === 'Bar') { - filters.push(`[awave]showfreqs=s=${specW}x${specH}:mode=bar:ascale=log:fscale=log:colors=${colors},format=rgba,colorchannelmixer=aa=${opacity}[waves0]`); + filters.push( + `[awave]showfreqs=s=${specW}x${specH}:mode=bar:ascale=log:fscale=log:colors=${colors},format=rgba,colorchannelmixer=aa=${opacity}[waves0]`, + ); } else { - filters.push(`[awave]showwaves=s=${specW}x${specH}:mode=${mode}:colors=${colors},format=rgba,colorchannelmixer=aa=${opacity}[waves0]`); + filters.push( + `[awave]showwaves=s=${specW}x${specH}:mode=${mode}:colors=${colors},format=rgba,colorchannelmixer=aa=${opacity}[waves0]`, + ); } if (mirror) filters.push('[waves0]split=2[wtop][wbot];[wbot]vflip[wmirror];[wtop][wmirror]vstack=inputs=2[waves1]'); const waveLabel = mirror ? 'waves1' : 'waves0'; const xy = spectrumOverlayPosition(config, width, height, mirror ? specH * 2 : specH); - filters.push(`[${v}][${waveLabel}]overlay=${xy}[v${n}]`); v = `v${n++}`; + filters.push(`[${v}][${waveLabel}]overlay=${xy}[v${n}]`); + v = `v${n++}`; if (sp.glow) { const glowOpacity = Math.max(0.03, Math.min(0.35, Number(sp.glowStrength || 35) / 100)); - filters.push(`[${v}]drawbox=x=0:y=0:w=iw:h=ih:color=${normalizeColor(sp.color2 || sp.color1 || 'white')}@${glowOpacity}:t=fill:enable='${detectedBeatEnable || 'gt(sin(t*10),0.82)'}'[v${n}]`); v = `v${n++}`; + filters.push( + `[${v}]drawbox=x=0:y=0:w=iw:h=ih:color=${normalizeColor(sp.color2 || sp.color1 || 'white')}@${glowOpacity}:t=fill:enable='${detectedBeatEnable || 'gt(sin(t*10),0.82)'}'[v${n}]`, + ); + v = `v${n++}`; } } + async function applyAssLayer(layerKey) { + if (!textLayerActive[layerKey]) return; + const assFile = await writeLayerAss(layerKey); + if (!assFile) return; + filters.push(`[${v}]ass='${escapeFilter(assFile)}'[v${n}]`); + v = `v${n++}`; + } + + const layerApply = { + bumper: applyBumperLayer, + particle: applyParticleLayer, + logo: applyLogoLayer, + cta: applyCtaLayer, + spectrum: applySpectrumLayer, + lyrics: async () => applyAssLayer('lyrics'), + watermark: async () => applyAssLayer('watermark'), + nowPlaying: async () => applyAssLayer('nowPlaying'), + timestamp: async () => applyAssLayer('timestamp'), + lowerThird: async () => applyAssLayer('lowerThird'), + }; + for (const key of resolveLayerOrder(config.branding?.layerOrder)) { + await layerApply[key]?.(); + } + if (config.spectrum?.progressBar !== false) { const pc = normalizeColor(config.spectrum?.progressColor || config.spectrum?.color1 || 'white'); const ph = config.spectrum?.progressStyle === 'thin' ? 3 : 6; - filters.push(`[${v}]drawbox=x=80:y=ih-24:w=(iw-160)*t/${duration}:h=${ph}:color=${pc}@0.9:t=fill[v${n}]`); v = `v${n++}`; + filters.push(`[${v}]drawbox=x=80:y=ih-24:w=(iw-160)*t/${duration}:h=${ph}:color=${pc}@0.9:t=fill[v${n}]`); + v = `v${n++}`; } if (config.overlay?.darken) { - filters.push(`[${v}]drawbox=x=0:y=0:w=iw:h=ih:color=black@${Number(config.overlay.darkenOpacity || 15)/100}:t=fill[v${n}]`); v = `v${n++}`; + filters.push( + `[${v}]drawbox=x=0:y=0:w=iw:h=ih:color=black@${Number(config.overlay.darkenOpacity || 15) / 100}:t=fill[v${n}]`, + ); + v = `v${n++}`; } if (config.overlay?.vignette) { - filters.push(`[${v}]vignette=angle=PI/4:mode=backward[v${n}]`); v = `v${n++}`; + filters.push(`[${v}]vignette=angle=PI/4:mode=backward[v${n}]`); + v = `v${n++}`; } if (config.overlay?.filmGrain) { - filters.push(`[${v}]noise=alls=${Math.max(0, Number(config.overlay.grainStrength || 12))}:allf=t+u[v${n}]`); v = `v${n++}`; + filters.push(`[${v}]noise=alls=${Math.max(0, Number(config.overlay.grainStrength || 12))}:allf=t+u[v${n}]`); + v = `v${n++}`; } if (config.overlay?.scanlines) { - filters.push(`[${v}]drawgrid=width=iw:height=4:thickness=1:color=black@${Number(config.overlay.scanlineOpacity || 6)/100}[v${n}]`); v = `v${n++}`; + filters.push( + `[${v}]drawgrid=width=iw:height=4:thickness=1:color=black@${Number(config.overlay.scanlineOpacity || 6) / 100}[v${n}]`, + ); + v = `v${n++}`; } if (config.overlay?.letterbox) { const lb = Math.max(0, Number(config.overlay.letterboxSize || 80)); - filters.push(`[${v}]drawbox=x=0:y=0:w=iw:h=${lb}:color=black@1:t=fill,drawbox=x=0:y=ih-${lb}:w=iw:h=${lb}:color=black@1:t=fill[v${n}]`); v = `v${n++}`; + filters.push( + `[${v}]drawbox=x=0:y=0:w=iw:h=${lb}:color=black@1:t=fill,drawbox=x=0:y=ih-${lb}:w=iw:h=${lb}:color=black@1:t=fill[v${n}]`, + ); + v = `v${n++}`; } if (config.overlay?.frameBorder) { - const bc = normalizeColor(config.overlay.borderColor || 'white'); const bt = Math.max(1, Number(config.overlay.borderThickness || 6)); - filters.push(`[${v}]drawbox=x=0:y=0:w=iw:h=ih:color=${bc}@0.95:t=${bt}[v${n}]`); v = `v${n++}`; - } - - if (shouldWriteAss) { - filters.push(`[${v}]ass='${escapeFilter(assFile)}'[v${n}]`); v = `v${n++}`; + const bc = normalizeColor(config.overlay.borderColor || 'white'); + const bt = Math.max(1, Number(config.overlay.borderThickness || 6)); + filters.push(`[${v}]drawbox=x=0:y=0:w=iw:h=ih:color=${bc}@0.95:t=${bt}[v${n}]`); + v = `v${n++}`; } - args.push('-filter_complex', filters.join(';')); args.push('-map', `[${v}]`, '-map', '[aout]'); const venc = await encoderArgs(config); args.push('-t', String(duration), '-r', String(config.target.fps || 30), ...venc); if (venc.includes('libx264')) args.push('-preset', preset, '-crf', crf); - if (Number(config.performance?.ffmpegThreads || 0) > 0) args.push('-threads', String(Math.max(1, Math.min(32, Number(config.performance.ffmpegThreads))))); - args.push('-b:v', config.target.bitrate || '4500k', '-pix_fmt', config.target.pixelFormat || 'yuv420p', '-c:a', 'aac', '-b:a', config.audio.audioBitrate || '192k', '-shortest'); + if (Number(config.performance?.ffmpegThreads || 0) > 0) + args.push('-threads', String(Math.max(1, Math.min(32, Number(config.performance.ffmpegThreads))))); + args.push( + '-b:v', + config.target.bitrate || '4500k', + '-pix_fmt', + config.target.pixelFormat || 'yuv420p', + '-c:a', + 'aac', + '-b:a', + config.audio.audioBitrate || '192k', + '-shortest', + ); if (config.target.faststart !== false) args.push('-movflags', '+faststart'); args.push('-progress', 'pipe:1', '-nostats', output); return { args, duration, output }; @@ -504,26 +891,30 @@ export async function renderJob(job, config, workspaceDir, callbacks = {}) { const child = spawn(FFMPEG, args, { stdio: ['ignore', 'pipe', 'pipe'] }); callbacks.onProcess?.(child); const progressState = {}; - child.stdout.on('data', (buf) => { + child.stdout.on('data', buf => { for (const line of buf.toString().split(/\r?\n/)) { const [key, raw] = line.split('='); if (!key || raw === undefined) continue; progressState[key] = raw; if (key === 'out_time_ms') { const micros = Number(raw); - if (Number.isFinite(micros) && duration > 0) callbacks.onProgress?.(Math.min(99, Math.max(1, Math.round((micros / 1_000_000 / duration) * 100)))); + if (Number.isFinite(micros) && duration > 0) + callbacks.onProgress?.(Math.min(99, Math.max(1, Math.round((micros / 1_000_000 / duration) * 100)))); } if (['out_time_ms', 'total_size', 'speed'].includes(key)) callbacks.onMetrics?.({ ...progressState, duration }); } }); - child.stderr.on('data', (buf) => { + child.stderr.on('data', buf => { const line = buf.toString().trim(); if (line) callbacks.onLog?.(line.slice(0, 1200)); }); child.on('error', reject); - child.on('close', (code) => { - if (code === 0) { callbacks.onProgress?.(100); callbacks.onLog?.(`Render selesai: ${output}`); resolve({ ok: true, output }); } - else reject(new Error(`FFmpeg gagal dengan kode ${code}`)); + child.on('close', code => { + if (code === 0) { + callbacks.onProgress?.(100); + callbacks.onLog?.(`Render selesai: ${output}`); + resolve({ ok: true, output }); + } else reject(new Error(`FFmpeg gagal dengan kode ${code}`)); }); }); } diff --git a/backend/routes.mjs b/backend/routes.mjs index 82b5bc7..c6d9b29 100644 --- a/backend/routes.mjs +++ b/backend/routes.mjs @@ -7,76 +7,368 @@ import { runCmd, ffmpegDiagnostics, ffprobeInfo, ffprobeMetadata, mimeFor } from import { safeId, readLyricsFile, lrcRowsToSrt } from './media-utils.mjs'; import { deepMerge, defaultConfig, defaultState, workspaceDir } from './config.mjs'; import { loadState, saveState, activeProject, activeConfig, addLog } from './state.mjs'; -import { renderLoopVideo, renderLoopSeamPreview, renderLoopBatch, validateLoopOptions, startLoopJob, publicLoopJob, analyzeLoopPoint } from './loop-engine.mjs'; -import { analyzeLoudness, waveformData, detectBeatsFromPeaks, audioSafetyReport, tuneSpectrumFromWaveform } from './audio-engine.mjs'; -import { rowsToLrc, rowsToVtt, autoAlignLyrics, readLyricSource, saveLyricsExport, scoreLyricTimeline, parseNowPlaying } from './lyrics-engine.mjs'; -import { validateRenderConfig, outputName, ensureUniqueOutput, pairMedia, estimateRender, autoTargetTune, targetSummary } from './target-engine.mjs'; +import { + renderLoopVideo, + renderLoopSeamPreview, + renderLoopBatch, + validateLoopOptions, + startLoopJob, + publicLoopJob, + analyzeLoopPoint, +} from './loop-engine.mjs'; +import { + analyzeLoudness, + waveformData, + detectBeatsFromPeaks, + audioSafetyReport, + tuneSpectrumFromWaveform, +} from './audio-engine.mjs'; +import { + rowsToLrc, + rowsToVtt, + autoAlignLyrics, + readLyricSource, + saveLyricsExport, + scoreLyricTimeline, + parseNowPlaying, +} from './lyrics-engine.mjs'; +import { + validateRenderConfig, + outputName, + ensureUniqueOutput, + pairMedia, + estimateRender, + autoTargetTune, + targetSummary, +} from './target-engine.mjs'; import { queueSummary, cloneJob, startJob } from './queue-engine.mjs'; import { previewConfig, previewDiagnostics, performanceStatus, performancePatch } from './preview-engine.mjs'; import { scanDir, enrichValidation, pathInfo, streamMediaFile } from './media-scanner.mjs'; import { renderJob } from './render-engine.mjs'; import { generateThumbnails } from './thumbnail-engine.mjs'; -import { initHistory, recordRender, getHistory, getHistoryStats, clearHistory } from "./history.mjs"; +import { getHistory, getHistoryStats, clearHistory } from './history.mjs'; import { generateLivePreview, generateFullPreview, cleanupPreviews } from './preview-stream.mjs'; export async function handleRequest(req, res, url, ctx) { const { processes, loopJobs, runQueueLoop, requestQueueStop } = ctx; - const { body, json, corsHeaders, sendError } = ctx.http; + const { body, json, corsHeaders } = ctx.http; let state = await loadState(); try { - if (isRoute(req, url, 'GET', '/api/health')) { const diag = await ffmpegDiagnostics(); return json(res, 200, { ok: true, app: 'PidioForge Production API', ffmpeg: diag.ffmpeg, ffprobe: diag.ffprobe, ffmpegPath: diag.ffmpegPath, dataDir: workspaceDir }); } + if (isRoute(req, url, 'GET', '/api/health')) { + const diag = await ffmpegDiagnostics(); + return json(res, 200, { + ok: true, + app: 'PidioForge Production API', + ffmpeg: diag.ffmpeg, + ffprobe: diag.ffprobe, + ffmpegPath: diag.ffmpegPath, + dataDir: workspaceDir, + }); + } if (isRoute(req, url, 'GET', '/api/system/diagnostics')) return json(res, 200, await ffmpegDiagnostics()); - if (isRoute(req, url, 'POST', '/api/path/info')) { const b = await body(req); return json(res, 200, await pathInfo(b.path, b.kind, b.filter)); } - if (isRoute(req, url, 'GET', '/api/media/file')) return streamMediaFile(req, res, url.searchParams.get('path') || '', corsHeaders); - if (req.method === 'POST' && url.pathname === '/api/loop/validate') { const b = await body(req); const validation = await validateLoopOptions(b); return json(res, validation.ok ? 200 : 400, validation); } - if (req.method === 'POST' && url.pathname === '/api/loop/start') { const b = await body(req); return json(res, 202, publicLoopJob(await startLoopJob(b, loopJobs))); } - if (req.method === 'GET' && url.pathname === '/api/loop/status') { const id = url.searchParams.get('id') || ''; return json(res, loopJobs.has(id) ? 200 : 404, publicLoopJob(loopJobs.get(id)) || { error: 'loop job tidak ditemukan' }); } - if (req.method === 'POST' && url.pathname === '/api/loop/cancel') { const b = await body(req); const job = loopJobs.get(b.id); if (!job) return json(res, 404, { error: 'loop job tidak ditemukan' }); job.status = 'cancelled'; job.error = 'Dibatalkan user'; job.finishedAt = new Date().toISOString(); job.child?.kill('SIGTERM'); return json(res, 200, publicLoopJob(job)); } - if (req.method === 'POST' && url.pathname === '/api/loop/render') { const b = await body(req); const result = await renderLoopVideo(b); addLog(state, `looping selesai: ${path.basename(result.output)} (${result.duration}s)`); await saveState(state); return json(res, 200, result); } - if (req.method === 'POST' && url.pathname === '/api/loop/analyze') { const b = await body(req); return json(res, 200, await analyzeLoopPoint(b)); } - if (req.method === 'POST' && url.pathname === '/api/loop/seam-preview') { const b = await body(req); const result = await renderLoopSeamPreview(b); return json(res, 200, result); } - if (req.method === 'POST' && url.pathname === '/api/loop/batch') { const b = await body(req); const result = await renderLoopBatch(b); addLog(state, `batch looping selesai: ${result.created.length} dibuat, ${result.skipped.length} dilewati`); await saveState(state); return json(res, 200, result); } + if (isRoute(req, url, 'POST', '/api/path/info')) { + const b = await body(req); + return json(res, 200, await pathInfo(b.path, b.kind, b.filter)); + } + if (isRoute(req, url, 'GET', '/api/media/file')) + return streamMediaFile(req, res, url.searchParams.get('path') || '', corsHeaders); + if (req.method === 'POST' && url.pathname === '/api/loop/validate') { + const b = await body(req); + const validation = await validateLoopOptions(b); + return json(res, validation.ok ? 200 : 400, validation); + } + if (req.method === 'POST' && url.pathname === '/api/loop/start') { + const b = await body(req); + return json(res, 202, publicLoopJob(await startLoopJob(b, loopJobs))); + } + if (req.method === 'GET' && url.pathname === '/api/loop/status') { + const id = url.searchParams.get('id') || ''; + return json( + res, + loopJobs.has(id) ? 200 : 404, + publicLoopJob(loopJobs.get(id)) || { error: 'loop job tidak ditemukan' }, + ); + } + if (req.method === 'POST' && url.pathname === '/api/loop/cancel') { + const b = await body(req); + const job = loopJobs.get(b.id); + if (!job) return json(res, 404, { error: 'loop job tidak ditemukan' }); + job.status = 'cancelled'; + job.error = 'Dibatalkan user'; + job.finishedAt = new Date().toISOString(); + job.child?.kill('SIGTERM'); + return json(res, 200, publicLoopJob(job)); + } + if (req.method === 'POST' && url.pathname === '/api/loop/render') { + const b = await body(req); + const result = await renderLoopVideo(b); + addLog(state, `looping selesai: ${path.basename(result.output)} (${result.duration}s)`); + await saveState(state); + return json(res, 200, result); + } + if (req.method === 'POST' && url.pathname === '/api/loop/analyze') { + const b = await body(req); + return json(res, 200, await analyzeLoopPoint(b)); + } + if (req.method === 'POST' && url.pathname === '/api/loop/seam-preview') { + const b = await body(req); + const result = await renderLoopSeamPreview(b); + return json(res, 200, result); + } + if (req.method === 'POST' && url.pathname === '/api/loop/batch') { + const b = await body(req); + const result = await renderLoopBatch(b); + addLog(state, `batch looping selesai: ${result.created.length} dibuat, ${result.skipped.length} dilewati`); + await saveState(state); + return json(res, 200, result); + } if (req.method === 'POST' && url.pathname === '/api/target/create-structure') { - const b = await body(req); const base = b.baseDir || activeConfig(state).input?.bahanFolder; + const b = await body(req); + const base = b.baseDir || activeConfig(state).input?.bahanFolder; if (!base) return json(res, 400, { error: 'baseDir kosong' }); - const dirs = ['Visual','Audio','Lirik','Logo','Overlay','Bumper','Hasil']; + const dirs = ['Visual', 'Audio', 'Lirik', 'Logo', 'Overlay', 'Bumper', 'Hasil']; for (const d of dirs) await mkdir(path.join(base, d), { recursive: true }); return json(res, 201, { baseDir: base, dirs: dirs.map(d => path.join(base, d)) }); } - if (req.method === 'GET' && url.pathname === '/api/branding/presets') return json(res, 200, { presets: [ - { id: 'none', name: 'No Branding', patch: { branding: { bumperEnabled: false, logoEnabled: false, ctaEnabled: false, watermarkEnabled: false } } }, - { id: 'logo-only', name: 'Logo Only', patch: { branding: { logoEnabled: true, ctaEnabled: false, bumperEnabled: false, watermarkEnabled: false, logoPosition: 'Kanan Atas', logoScale: 18 } } }, - { id: 'logo-cta', name: 'Logo + CTA', patch: { branding: { logoEnabled: true, ctaEnabled: true, bumperEnabled: false, watermarkEnabled: false, ctaPreset: 'subscribe-lower-right', ctaPosition: 'Kanan Bawah' } } }, - { id: 'youtube-full', name: 'Full YouTube Branding', patch: { branding: { logoEnabled: true, ctaEnabled: true, bumperEnabled: true, watermarkEnabled: true, safeAreaPreset: 'youtube', ctaPreset: 'subscribe-lower-right', logoPosition: 'Kanan Atas', watermarkPosition: 'Kiri Bawah' } } }, - { id: 'shorts', name: 'Shorts Branding', patch: { branding: { logoEnabled: true, ctaEnabled: true, bumperEnabled: false, watermarkEnabled: true, safeAreaPreset: 'shorts', logoPosition: 'Kanan Atas', ctaPosition: 'Kanan Bawah', watermarkPosition: 'Kiri Bawah' } } } - ] }); + if (req.method === 'GET' && url.pathname === '/api/branding/presets') + return json(res, 200, { + presets: [ + { + id: 'none', + name: 'No Branding', + patch: { + branding: { bumperEnabled: false, logoEnabled: false, ctaEnabled: false, watermarkEnabled: false }, + }, + }, + { + id: 'logo-only', + name: 'Logo Only', + patch: { + branding: { + logoEnabled: true, + ctaEnabled: false, + bumperEnabled: false, + watermarkEnabled: false, + logoPosition: 'Kanan Atas', + logoScale: 18, + }, + }, + }, + { + id: 'logo-cta', + name: 'Logo + CTA', + patch: { + branding: { + logoEnabled: true, + ctaEnabled: true, + bumperEnabled: false, + watermarkEnabled: false, + ctaPreset: 'subscribe-lower-right', + ctaPosition: 'Kanan Bawah', + }, + }, + }, + { + id: 'youtube-full', + name: 'Full YouTube Branding', + patch: { + branding: { + logoEnabled: true, + ctaEnabled: true, + bumperEnabled: true, + watermarkEnabled: true, + safeAreaPreset: 'youtube', + ctaPreset: 'subscribe-lower-right', + logoPosition: 'Kanan Atas', + watermarkPosition: 'Kiri Bawah', + }, + }, + }, + { + id: 'shorts', + name: 'Shorts Branding', + patch: { + branding: { + logoEnabled: true, + ctaEnabled: true, + bumperEnabled: false, + watermarkEnabled: true, + safeAreaPreset: 'shorts', + logoPosition: 'Kanan Atas', + ctaPosition: 'Kanan Bawah', + watermarkPosition: 'Kiri Bawah', + }, + }, + }, + ], + }); if (req.method === 'POST' && url.pathname === '/api/branding/validate') { - const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); const br = config.branding || {}; const warnings = []; const assets = {}; + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const br = config.branding || {}; + const warnings = []; + const assets = {}; for (const [key, file] of Object.entries({ logo: br.logo, bumper: br.bumperVideo, cta: br.ctaGreenscreen })) { - if (!file) { assets[key] = { ok: false, missing: true }; continue; } - const info = await ffprobeInfo(file); assets[key] = info; + if (!file) { + assets[key] = { ok: false, missing: true }; + continue; + } + const info = await ffprobeInfo(file); + assets[key] = info; if (!info.ok) warnings.push(`${key}: file tidak terbaca`); - if ((key === 'bumper' || key === 'cta') && info.ok && !info.hasVideo) warnings.push(`${key}: video stream tidak ada`); - if (key === 'bumper' && info.ok && info.duration && Number(br.bumperDuration || 3) > info.duration + 0.5) warnings.push('bumper: durasi setting lebih panjang dari file bumper'); + if ((key === 'bumper' || key === 'cta') && info.ok && !info.hasVideo) + warnings.push(`${key}: video stream tidak ada`); + if (key === 'bumper' && info.ok && info.duration && Number(br.bumperDuration || 3) > info.duration + 0.5) + warnings.push('bumper: durasi setting lebih panjang dari file bumper'); } return json(res, 200, { ok: warnings.length === 0, warnings, assets }); } - if (req.method === 'GET' && url.pathname === '/api/overlay/presets') return json(res, 200, { presets: [ - { id: 'clean', name: 'Clean', patch: { overlay: { stylePreset: 'clean', vignette: false, filmGrain: false, scanlines: false, frameBorder: false, darken: false } } }, - { id: 'cinematic', name: 'Cinematic', patch: { overlay: { stylePreset: 'cinematic', vignette: true, vignetteStrength: 0.45, filmGrain: true, grainStrength: 8, letterbox: true, letterboxSize: 72, darken: true, darkenOpacity: 10 } } }, - { id: 'live-stream', name: 'Live Stream', patch: { overlay: { stylePreset: 'live-stream', timestamp: true, frameBorder: true, borderColor: '#22c55e', borderThickness: 4, lowerThirdEnabled: true, lowerThirdPosition: 'Bawah' } } }, - { id: 'retro', name: 'Retro Scanline', patch: { overlay: { stylePreset: 'retro', scanlines: true, scanlineOpacity: 8, filmGrain: true, grainStrength: 14, vignette: true, vignetteStrength: 0.3 } } }, - { id: 'dark-focus', name: 'Dark Focus', patch: { overlay: { stylePreset: 'dark-focus', darken: true, darkenOpacity: 22, vignette: true, vignetteStrength: 0.5 } } }, - { id: 'format-landscape', name: 'Format 16:9', patch: { overlay: { stylePreset: 'format-landscape', timestamp: true, timestampPosition: 'Kiri Atas', lowerThirdEnabled: true, lowerThirdPosition: 'Bawah', lowerThirdAt: 2, lowerThirdDuration: 5, darken: false, letterbox: false } } }, - { id: 'format-vertical', name: 'Format 9:16', patch: { overlay: { stylePreset: 'format-vertical', timestamp: true, timestampPosition: 'Kanan Atas', lowerThirdEnabled: true, lowerThirdPosition: 'Tengah', lowerThirdAt: 2, lowerThirdDuration: 4, darken: true, darkenOpacity: 16, letterbox: false } } }, - { id: 'format-square', name: 'Format 1:1', patch: { overlay: { stylePreset: 'format-square', timestamp: true, timestampPosition: 'Kiri Atas', lowerThirdEnabled: true, lowerThirdPosition: 'Bawah', lowerThirdAt: 2, lowerThirdDuration: 4, darken: true, darkenOpacity: 12, letterbox: false } } } - ] }); + if (req.method === 'GET' && url.pathname === '/api/overlay/presets') + return json(res, 200, { + presets: [ + { + id: 'clean', + name: 'Clean', + patch: { + overlay: { + stylePreset: 'clean', + vignette: false, + filmGrain: false, + scanlines: false, + frameBorder: false, + darken: false, + }, + }, + }, + { + id: 'cinematic', + name: 'Cinematic', + patch: { + overlay: { + stylePreset: 'cinematic', + vignette: true, + vignetteStrength: 0.45, + filmGrain: true, + grainStrength: 8, + letterbox: true, + letterboxSize: 72, + darken: true, + darkenOpacity: 10, + }, + }, + }, + { + id: 'live-stream', + name: 'Live Stream', + patch: { + overlay: { + stylePreset: 'live-stream', + timestamp: true, + frameBorder: true, + borderColor: '#22c55e', + borderThickness: 4, + lowerThirdEnabled: true, + lowerThirdPosition: 'Bawah', + }, + }, + }, + { + id: 'retro', + name: 'Retro Scanline', + patch: { + overlay: { + stylePreset: 'retro', + scanlines: true, + scanlineOpacity: 8, + filmGrain: true, + grainStrength: 14, + vignette: true, + vignetteStrength: 0.3, + }, + }, + }, + { + id: 'dark-focus', + name: 'Dark Focus', + patch: { + overlay: { + stylePreset: 'dark-focus', + darken: true, + darkenOpacity: 22, + vignette: true, + vignetteStrength: 0.5, + }, + }, + }, + { + id: 'format-landscape', + name: 'Format 16:9', + patch: { + overlay: { + stylePreset: 'format-landscape', + timestamp: true, + timestampPosition: 'Kiri Atas', + lowerThirdEnabled: true, + lowerThirdPosition: 'Bawah', + lowerThirdAt: 2, + lowerThirdDuration: 5, + darken: false, + letterbox: false, + }, + }, + }, + { + id: 'format-vertical', + name: 'Format 9:16', + patch: { + overlay: { + stylePreset: 'format-vertical', + timestamp: true, + timestampPosition: 'Kanan Atas', + lowerThirdEnabled: true, + lowerThirdPosition: 'Tengah', + lowerThirdAt: 2, + lowerThirdDuration: 4, + darken: true, + darkenOpacity: 16, + letterbox: false, + }, + }, + }, + { + id: 'format-square', + name: 'Format 1:1', + patch: { + overlay: { + stylePreset: 'format-square', + timestamp: true, + timestampPosition: 'Kiri Atas', + lowerThirdEnabled: true, + lowerThirdPosition: 'Bawah', + lowerThirdAt: 2, + lowerThirdDuration: 4, + darken: true, + darkenOpacity: 12, + letterbox: false, + }, + }, + }, + ], + }); if (req.method === 'POST' && url.pathname === '/api/overlay/validate') { - const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); const ov = config.overlay || {}; const warnings = []; const assets = {}; + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const ov = config.overlay || {}; + const warnings = []; + const assets = {}; for (const [key, file] of Object.entries({ particle: ov.particleFile, overlay: ov.overlayFile })) { - if (!file) { assets[key] = { ok: false, missing: true }; continue; } - const info = await ffprobeInfo(file); assets[key] = info; + if (!file) { + assets[key] = { ok: false, missing: true }; + continue; + } + const info = await ffprobeInfo(file); + assets[key] = info; if (!info.ok) warnings.push(`${key}: file tidak terbaca`); if (key === 'particle' && info.ok && !info.hasVideo) warnings.push('particle: video stream tidak ada'); } @@ -86,183 +378,901 @@ export async function handleRequest(req, res, url, ctx) { } if (req.method === 'GET' && url.pathname === '/api/state') return json(res, 200, state); if (req.method === 'GET' && url.pathname === '/api/config') return json(res, 200, activeConfig(state)); - if (req.method === 'POST' && url.pathname === '/api/config/validate') { const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); return json(res, 200, validateRenderConfig(config, b)); } - if (req.method === 'POST' && url.pathname === '/api/config') { const patch = await body(req); const project = activeProject(state); project.config = deepMerge(project.config, patch); project.updatedAt = new Date().toISOString(); addLog(state, 'config project disimpan.'); await saveState(state); return json(res, 200, project.config); } - if (req.method === 'GET' && url.pathname === '/api/projects') return json(res, 200, { activeProjectId: state.activeProjectId, projects: state.projects }); - if (req.method === 'POST' && url.pathname === '/api/projects') { const b = await body(req); const p = { id: safeId('project'), name: b.name || 'Project Baru', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), config: deepMerge(defaultConfig, b.config || {}) }; state.projects.push(p); state.activeProjectId = p.id; addLog(state, `project dibuat: ${p.name}`); await saveState(state); return json(res, 201, p); } - if (req.method === 'POST' && url.pathname.match(/^\/api\/projects\/[^/]+\/activate$/)) { const id = url.pathname.split('/')[3]; if (!state.projects.some(p=>p.id===id)) return json(res,404,{error:'project tidak ditemukan'}); state.activeProjectId = id; addLog(state, `project aktif: ${activeProject(state).name}`); await saveState(state); return json(res,200,{activeProjectId:id}); } - if (req.method === "POST" && url.pathname.match(/^\/api\/projects\/[^\/]+\/rename$/)) { const id = url.pathname.split("/")[3]; const b = await body(req); const project = state.projects.find(p => p.id === id); if (!project) return json(res, 404, { error: "project tidak ditemukan" }); project.name = b.name || project.name; project.updatedAt = new Date().toISOString(); addLog(state, `project renamed: ${project.name}`); await saveState(state); return json(res, 200, project); } - if (req.method === "POST" && url.pathname.match(/^\/api\/projects\/[^\/]+\/duplicate$/)) { const id = url.pathname.split("/")[3]; const src = state.projects.find(p => p.id === id); if (!src) return json(res, 404, { error: "project tidak ditemukan" }); const dup = { id: safeId("project"), name: `${src.name} (Copy)`, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), config: JSON.parse(JSON.stringify(src.config)) }; state.projects.push(dup); addLog(state, `project diduplikasi: ${dup.name}`); await saveState(state); return json(res, 201, dup); } - if (req.method === "POST" && url.pathname.match(/^\/api\/projects\/[^\/]+\/delete$/)) { const id = url.pathname.split("/")[3]; if (state.projects.length <= 1) return json(res, 400, { error: "tidak bisa hapus project terakhir" }); const idx = state.projects.findIndex(p => p.id === id); if (idx < 0) return json(res, 404, { error: "project tidak ditemukan" }); const removed = state.projects.splice(idx, 1)[0]; if (state.activeProjectId === id) state.activeProjectId = state.projects[0].id; addLog(state, `project dihapus: ${removed.name}`); await saveState(state); return json(res, 200, { ok: true }); } - if (req.method === "GET" && url.pathname.match(/^\/api\/projects\/[^\/]+\/export$/)) { const id = url.pathname.split("/")[3]; const project = state.projects.find(p => p.id === id); if (!project) return json(res, 404, { error: "project tidak ditemukan" }); return json(res, 200, { version: 1, exportedAt: new Date().toISOString(), project: { name: project.name, config: project.config, createdAt: project.createdAt } }); } - if (req.method === "POST" && url.pathname === "/api/projects/import") { const b = await body(req); if (!b.project || !b.project.config) return json(res, 400, { error: "Format file tidak valid" }); const p = { id: safeId("project"), name: b.project.name || "Project Import", createdAt: b.project.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), config: deepMerge(defaultConfig, b.project.config) }; state.projects.push(p); state.activeProjectId = p.id; addLog(state, `project diimport: ${p.name}`); await saveState(state); return json(res, 201, p); } + if (req.method === 'POST' && url.pathname === '/api/config/validate') { + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + return json(res, 200, validateRenderConfig(config, b)); + } + if (req.method === 'POST' && url.pathname === '/api/config') { + const patch = await body(req); + const project = activeProject(state); + project.config = deepMerge(project.config, patch); + project.updatedAt = new Date().toISOString(); + addLog(state, 'config project disimpan.'); + await saveState(state); + return json(res, 200, project.config); + } + if (req.method === 'GET' && url.pathname === '/api/projects') + return json(res, 200, { activeProjectId: state.activeProjectId, projects: state.projects }); + if (req.method === 'POST' && url.pathname === '/api/projects') { + const b = await body(req); + const p = { + id: safeId('project'), + name: b.name || 'Project Baru', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + config: deepMerge(defaultConfig, b.config || {}), + }; + state.projects.push(p); + state.activeProjectId = p.id; + addLog(state, `project dibuat: ${p.name}`); + await saveState(state); + return json(res, 201, p); + } + if (req.method === 'POST' && url.pathname.match(/^\/api\/projects\/[^/]+\/activate$/)) { + const id = url.pathname.split('/')[3]; + if (!state.projects.some(p => p.id === id)) return json(res, 404, { error: 'project tidak ditemukan' }); + state.activeProjectId = id; + addLog(state, `project aktif: ${activeProject(state).name}`); + await saveState(state); + return json(res, 200, { activeProjectId: id }); + } + if (req.method === 'POST' && url.pathname.match(/^\/api\/projects\/[^/]+\/rename$/)) { + const id = url.pathname.split('/')[3]; + const b = await body(req); + const project = state.projects.find(p => p.id === id); + if (!project) return json(res, 404, { error: 'project tidak ditemukan' }); + project.name = b.name || project.name; + project.updatedAt = new Date().toISOString(); + addLog(state, `project renamed: ${project.name}`); + await saveState(state); + return json(res, 200, project); + } + if (req.method === 'POST' && url.pathname.match(/^\/api\/projects\/[^/]+\/duplicate$/)) { + const id = url.pathname.split('/')[3]; + const src = state.projects.find(p => p.id === id); + if (!src) return json(res, 404, { error: 'project tidak ditemukan' }); + const dup = { + id: safeId('project'), + name: `${src.name} (Copy)`, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + config: JSON.parse(JSON.stringify(src.config)), + }; + state.projects.push(dup); + addLog(state, `project diduplikasi: ${dup.name}`); + await saveState(state); + return json(res, 201, dup); + } + if (req.method === 'POST' && url.pathname.match(/^\/api\/projects\/[^/]+\/delete$/)) { + const id = url.pathname.split('/')[3]; + if (state.projects.length <= 1) return json(res, 400, { error: 'tidak bisa hapus project terakhir' }); + const idx = state.projects.findIndex(p => p.id === id); + if (idx < 0) return json(res, 404, { error: 'project tidak ditemukan' }); + const removed = state.projects.splice(idx, 1)[0]; + if (state.activeProjectId === id) state.activeProjectId = state.projects[0].id; + addLog(state, `project dihapus: ${removed.name}`); + await saveState(state); + return json(res, 200, { ok: true }); + } + if (req.method === 'GET' && url.pathname.match(/^\/api\/projects\/[^/]+\/export$/)) { + const id = url.pathname.split('/')[3]; + const project = state.projects.find(p => p.id === id); + if (!project) return json(res, 404, { error: 'project tidak ditemukan' }); + return json(res, 200, { + version: 1, + exportedAt: new Date().toISOString(), + project: { name: project.name, config: project.config, createdAt: project.createdAt }, + }); + } + if (req.method === 'POST' && url.pathname === '/api/projects/import') { + const b = await body(req); + if (!b.project || !b.project.config) return json(res, 400, { error: 'Format file tidak valid' }); + const p = { + id: safeId('project'), + name: b.project.name || 'Project Import', + createdAt: b.project.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + config: deepMerge(defaultConfig, b.project.config), + }; + state.projects.push(p); + state.activeProjectId = p.id; + addLog(state, `project diimport: ${p.name}`); + await saveState(state); + return json(res, 201, p); + } if (req.method === 'GET' && url.pathname === '/api/presets') return json(res, 200, { presets: state.presets }); - if (req.method === 'POST' && url.pathname === '/api/presets') { const b = await body(req); const preset = { id: safeId('preset'), name: b.name || 'Preset Baru', config: b.config || activeConfig(state) }; state.presets.push(preset); addLog(state, `preset disimpan: ${preset.name}`); await saveState(state); return json(res,201,preset); } - if (req.method === 'POST' && url.pathname.match(/^\/api\/presets\/[^/]+\/apply$/)) { const id = url.pathname.split('/')[3]; const preset = state.presets.find(p=>p.id===id); if (!preset) return json(res,404,{error:'preset tidak ditemukan'}); activeProject(state).config = deepMerge(activeProject(state).config, preset.config); addLog(state, `preset diterapkan: ${preset.name}`); await saveState(state); return json(res,200,activeConfig(state)); } - if (req.method === "GET" && url.pathname === "/api/templates") { return json(res, 200, { templates: state.templates || [] }); } - if (req.method === "POST" && url.pathname === "/api/templates") { const b = await body(req); if (!state.templates) state.templates = []; const tpl = { id: safeId("tpl"), name: b.name || "Template Baru", description: b.description || "", modules: b.modules || [], config: b.config || {}, createdAt: new Date().toISOString() }; state.templates.push(tpl); addLog(state, `template disimpan: ${tpl.name}`); await saveState(state); return json(res, 201, tpl); } - if (req.method === "POST" && url.pathname.match(/^\/api\/templates\/[^\/]+\/apply$/)) { const id = url.pathname.split("/")[3]; const tpl = (state.templates || []).find(t => t.id === id); if (!tpl) return json(res, 404, { error: "template tidak ditemukan" }); activeProject(state).config = deepMerge(activeProject(state).config, tpl.config); addLog(state, `template diterapkan: ${tpl.name}`); await saveState(state); return json(res, 200, activeConfig(state)); } - if (req.method === "DELETE" && url.pathname.match(/^\/api\/templates\/[^\/]+$/)) { const id = url.pathname.split("/")[3]; if (!state.templates) return json(res, 404, { error: "template tidak ditemukan" }); const idx = state.templates.findIndex(t => t.id === id); if (idx < 0) return json(res, 404, { error: "template tidak ditemukan" }); const removed = state.templates.splice(idx, 1)[0]; addLog(state, `template dihapus: ${removed.name}`); await saveState(state); return json(res, 200, { ok: true }); } - if (req.method === 'POST' && url.pathname === '/api/media/scan') { const b = await body(req); const files = await scanDir(b.dir, b); const pairs = pairMedia(files, activeConfig(state)); return json(res, 200, { files, summary: targetSummary(activeConfig(state), files, pairs), pairs }); } + if (req.method === 'POST' && url.pathname === '/api/presets') { + const b = await body(req); + const preset = { id: safeId('preset'), name: b.name || 'Preset Baru', config: b.config || activeConfig(state) }; + state.presets.push(preset); + addLog(state, `preset disimpan: ${preset.name}`); + await saveState(state); + return json(res, 201, preset); + } + if (req.method === 'POST' && url.pathname.match(/^\/api\/presets\/[^/]+\/apply$/)) { + const id = url.pathname.split('/')[3]; + const preset = state.presets.find(p => p.id === id); + if (!preset) return json(res, 404, { error: 'preset tidak ditemukan' }); + activeProject(state).config = deepMerge(activeProject(state).config, preset.config); + addLog(state, `preset diterapkan: ${preset.name}`); + await saveState(state); + return json(res, 200, activeConfig(state)); + } + if (req.method === 'GET' && url.pathname === '/api/templates') { + return json(res, 200, { templates: state.templates || [] }); + } + if (req.method === 'POST' && url.pathname === '/api/templates') { + const b = await body(req); + if (!state.templates) state.templates = []; + const tpl = { + id: safeId('tpl'), + name: b.name || 'Template Baru', + description: b.description || '', + modules: b.modules || [], + config: b.config || {}, + createdAt: new Date().toISOString(), + }; + state.templates.push(tpl); + addLog(state, `template disimpan: ${tpl.name}`); + await saveState(state); + return json(res, 201, tpl); + } + if (req.method === 'POST' && url.pathname.match(/^\/api\/templates\/[^/]+\/apply$/)) { + const id = url.pathname.split('/')[3]; + const tpl = (state.templates || []).find(t => t.id === id); + if (!tpl) return json(res, 404, { error: 'template tidak ditemukan' }); + activeProject(state).config = deepMerge(activeProject(state).config, tpl.config); + addLog(state, `template diterapkan: ${tpl.name}`); + await saveState(state); + return json(res, 200, activeConfig(state)); + } + if (req.method === 'DELETE' && url.pathname.match(/^\/api\/templates\/[^/]+$/)) { + const id = url.pathname.split('/')[3]; + if (!state.templates) return json(res, 404, { error: 'template tidak ditemukan' }); + const idx = state.templates.findIndex(t => t.id === id); + if (idx < 0) return json(res, 404, { error: 'template tidak ditemukan' }); + const removed = state.templates.splice(idx, 1)[0]; + addLog(state, `template dihapus: ${removed.name}`); + await saveState(state); + return json(res, 200, { ok: true }); + } + if (req.method === 'POST' && url.pathname === '/api/media/scan') { + const b = await body(req); + const files = await scanDir(b.dir, b); + const pairs = pairMedia(files, activeConfig(state)); + return json(res, 200, { files, summary: targetSummary(activeConfig(state), files, pairs), pairs }); + } if (req.method === 'POST' && url.pathname === '/api/target/inspect') { - const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); - const dirs = [config.input?.bahanFolder, config.input?.visualFolder, config.input?.audioFolder, config.input?.lyricFolder].filter(Boolean); + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const dirs = [ + config.input?.bahanFolder, + config.input?.visualFolder, + config.input?.audioFolder, + config.input?.lyricFolder, + ].filter(Boolean); const all = []; - for (const dir of dirs.length ? dirs : [b.dir].filter(Boolean)) all.push(...await scanDir(dir, { recursive: b.recursive ?? true, maxDepth: b.maxDepth ?? 3, excludeDirs: config.input?.excludeOutputOnScan ? [config.input?.output] : [] })); + for (const dir of dirs.length ? dirs : [b.dir].filter(Boolean)) + all.push( + ...(await scanDir(dir, { + recursive: b.recursive ?? true, + maxDepth: b.maxDepth ?? 3, + excludeDirs: config.input?.excludeOutputOnScan ? [config.input?.output] : [], + })), + ); const uniqueRaw = [...new Map(all.map(f => [f.path, f])).values()]; const unique = await enrichValidation(uniqueRaw, b.validateMedia === false ? 0 : 40); - const pairs = pairMedia(unique, config); const diagnostics = await ffmpegDiagnostics(); - return json(res, 200, { files: unique, summary: targetSummary(config, unique, pairs), pairs, diagnostics, config }); + const pairs = pairMedia(unique, config); + const diagnostics = await ffmpegDiagnostics(); + return json(res, 200, { + files: unique, + summary: targetSummary(config, unique, pairs), + pairs, + diagnostics, + config, + }); } if (req.method === 'POST' && url.pathname === '/api/target/auto-tune') { - const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); - const files = b.files || []; const pairs = b.pairs && b.pairs.length ? b.pairs : pairMedia(files, config); - const tune = autoTargetTune(config, files, pairs); const project = activeProject(state); project.config = deepMerge(project.config, tune.patch); addLog(state, 'auto target tune diterapkan.'); await saveState(state); return json(res, 200, { ...tune, config: project.config }); + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const files = b.files || []; + const pairs = b.pairs && b.pairs.length ? b.pairs : pairMedia(files, config); + const tune = autoTargetTune(config, files, pairs); + const project = activeProject(state); + project.config = deepMerge(project.config, tune.patch); + addLog(state, 'auto target tune diterapkan.'); + await saveState(state); + return json(res, 200, { ...tune, config: project.config }); } if (req.method === 'POST' && url.pathname === '/api/target/create-batch') { - const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); - const files = b.files || (await scanDir(config.input?.bahanFolder || config.input?.audioFolder, { recursive: true, maxDepth: 3, excludeDirs: config.input?.excludeOutputOnScan ? [config.input?.output] : [] })); + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const files = + b.files || + (await scanDir(config.input?.bahanFolder || config.input?.audioFolder, { + recursive: true, + maxDepth: 3, + excludeDirs: config.input?.excludeOutputOnScan ? [config.input?.output] : [], + })); const pairs = (b.pairs && b.pairs.length ? b.pairs : pairMedia(files, config)).filter(x => x.audio && x.visual); const created = []; for (const item of pairs) { - const out = ensureUniqueOutput(path.join(config.input.output || 'Hasil', item.outputName || outputName(item.title || path.basename(item.audio).replace(/\.[^.]+$/, ''), config, created.length + 1)), config.target?.overwrite); - const job = { id: safeId('job'), projectId: state.activeProjectId, title: item.title || path.basename(item.audio).replace(/\.[^.]+$/, ''), status: 'standby', progress: 0, speed: '0.0x', output: out, outputDir: config.input.output, input: { visual: item.visual, audio: item.audio, lyrics: item.lyrics || '' }, config, createdAt: new Date().toISOString() }; - state.jobs.push(job); created.push(job); + const out = ensureUniqueOutput( + path.join( + config.input.output || 'Hasil', + item.outputName || + outputName(item.title || path.basename(item.audio).replace(/\.[^.]+$/, ''), config, created.length + 1), + ), + config.target?.overwrite, + ); + const job = { + id: safeId('job'), + projectId: state.activeProjectId, + title: item.title || path.basename(item.audio).replace(/\.[^.]+$/, ''), + status: 'standby', + progress: 0, + speed: '0.0x', + output: out, + outputDir: config.input.output, + input: { visual: item.visual, audio: item.audio, lyrics: item.lyrics || '' }, + config, + createdAt: new Date().toISOString(), + }; + state.jobs.push(job); + created.push(job); } - addLog(state, `${created.length} job dibuat dari Folder Bahan & Engine Target.`); await saveState(state); return json(res, 201, { created }); - } - if (req.method === 'GET' && url.pathname === '/api/spectrum/presets') return json(res, 200, { presets: [ - { id: 'clean-wave', name: 'Clean Wave', patch: { spectrum: { enabled: true, model: 'Wave', position: 'Bawah', height: 128, transparency: 78, colors: ['white'], progressBar: true, nowPlaying: true } } }, - { id: 'neon-bars', name: 'Neon Bars', patch: { spectrum: { enabled: true, model: 'Bar', position: 'Bawah', height: 160, transparency: 85, colors: ['#22c55e', '#38bdf8'], glow: true, glowStrength: 45, progressColor: '#22c55e' } } }, - { id: 'minimal-line', name: 'Minimal Line', patch: { spectrum: { enabled: true, model: 'Line', height: 84, transparency: 65, colors: ['white'], progressStyle: 'thin', nowPlayingPosition: 'Atas' } } }, - { id: 'shorts-center', name: 'Shorts Center', patch: { spectrum: { enabled: true, model: 'Wave', position: 'Tengah', height: 180, transparency: 72, colors: ['white', '#facc15'], nowPlayingPosition: 'Atas' } } }, - { id: 'format-landscape', name: 'Format 16:9', patch: { spectrum: { enabled: true, model: 'Bar', position: 'Bawah', previewY: 75, y: 25, height: 128, transparency: 82, marginY: 32, nowPlaying: true, nowPlayingPosition: 'Atas', nowPlayingX: 50, nowPlayingY: 13, nowPlayingFontSize: 26, progressBar: true, widthMode: 'full' } } }, - { id: 'format-vertical', name: 'Format 9:16', patch: { spectrum: { enabled: true, model: 'Wave', position: 'Tengah', previewY: 57, y: 7, height: 176, transparency: 74, marginY: 46, nowPlaying: true, nowPlayingPosition: 'Atas', nowPlayingX: 50, nowPlayingY: 12, nowPlayingFontSize: 28, progressBar: true, widthMode: 'full' } } }, - { id: 'format-square', name: 'Format 1:1', patch: { spectrum: { enabled: true, model: 'Wave', position: 'Bawah', previewY: 70, y: 20, height: 142, transparency: 78, marginY: 34, nowPlaying: true, nowPlayingPosition: 'Atas', nowPlayingX: 50, nowPlayingY: 14, nowPlayingFontSize: 24, progressBar: true, widthMode: 'full' } } } - ] }); + addLog(state, `${created.length} job dibuat dari Folder Bahan & Engine Target.`); + await saveState(state); + return json(res, 201, { created }); + } + if (req.method === 'GET' && url.pathname === '/api/spectrum/presets') + return json(res, 200, { + presets: [ + { + id: 'clean-wave', + name: 'Clean Wave', + patch: { + spectrum: { + enabled: true, + model: 'Wave', + position: 'Bawah', + height: 128, + transparency: 78, + colors: ['white'], + progressBar: true, + nowPlaying: true, + }, + }, + }, + { + id: 'neon-bars', + name: 'Neon Bars', + patch: { + spectrum: { + enabled: true, + model: 'Bar', + position: 'Bawah', + height: 160, + transparency: 85, + colors: ['#22c55e', '#38bdf8'], + glow: true, + glowStrength: 45, + progressColor: '#22c55e', + }, + }, + }, + { + id: 'minimal-line', + name: 'Minimal Line', + patch: { + spectrum: { + enabled: true, + model: 'Line', + height: 84, + transparency: 65, + colors: ['white'], + progressStyle: 'thin', + nowPlayingPosition: 'Atas', + }, + }, + }, + { + id: 'shorts-center', + name: 'Shorts Center', + patch: { + spectrum: { + enabled: true, + model: 'Wave', + position: 'Tengah', + height: 180, + transparency: 72, + colors: ['white', '#facc15'], + nowPlayingPosition: 'Atas', + }, + }, + }, + { + id: 'format-landscape', + name: 'Format 16:9', + patch: { + spectrum: { + enabled: true, + model: 'Bar', + position: 'Bawah', + previewY: 75, + y: 25, + height: 128, + transparency: 82, + marginY: 32, + nowPlaying: true, + nowPlayingPosition: 'Atas', + nowPlayingX: 50, + nowPlayingY: 13, + nowPlayingFontSize: 26, + progressBar: true, + widthMode: 'full', + }, + }, + }, + { + id: 'format-vertical', + name: 'Format 9:16', + patch: { + spectrum: { + enabled: true, + model: 'Wave', + position: 'Tengah', + previewY: 57, + y: 7, + height: 176, + transparency: 74, + marginY: 46, + nowPlaying: true, + nowPlayingPosition: 'Atas', + nowPlayingX: 50, + nowPlayingY: 12, + nowPlayingFontSize: 28, + progressBar: true, + widthMode: 'full', + }, + }, + }, + { + id: 'format-square', + name: 'Format 1:1', + patch: { + spectrum: { + enabled: true, + model: 'Wave', + position: 'Bawah', + previewY: 70, + y: 20, + height: 142, + transparency: 78, + marginY: 34, + nowPlaying: true, + nowPlayingPosition: 'Atas', + nowPlayingX: 50, + nowPlayingY: 14, + nowPlayingFontSize: 24, + progressBar: true, + widthMode: 'full', + }, + }, + }, + ], + }); if (req.method === 'POST' && url.pathname === '/api/spectrum/preview') { - const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); const audio = b.audio || config.input?.audio; - const waveform = await waveformData(audio, b.seconds || 45, b.buckets || 96); const beats = detectBeatsFromPeaks(waveform.peaks, waveform.seconds || 45); const nowPlaying = parseNowPlaying(config, audio); + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const audio = b.audio || config.input?.audio; + const waveform = await waveformData(audio, b.seconds || 45, b.buckets || 96); + const beats = detectBeatsFromPeaks(waveform.peaks, waveform.seconds || 45); + const nowPlaying = parseNowPlaying(config, audio); return json(res, 200, { ok: waveform.ok, waveform, beats, nowPlaying, config: config.spectrum }); } if (req.method === 'POST' && url.pathname === '/api/spectrum/analyze') { - const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); const audio = b.audio || config.input?.audio; - const waveform = await waveformData(audio, b.seconds || 60, b.buckets || 160); const beats = detectBeatsFromPeaks(waveform.peaks, waveform.seconds || 60); const metadata = await ffprobeMetadata(audio); const nowPlaying = parseNowPlaying(config, audio); + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const audio = b.audio || config.input?.audio; + const waveform = await waveformData(audio, b.seconds || 60, b.buckets || 160); + const beats = detectBeatsFromPeaks(waveform.peaks, waveform.seconds || 60); + const metadata = await ffprobeMetadata(audio); + const nowPlaying = parseNowPlaying(config, audio); const tuned = tuneSpectrumFromWaveform(waveform, beats, config); - return json(res, 200, { ok: waveform.ok, waveform, beats, metadata, nowPlaying, tuned, recommendedPatch: { spectrum: { beatSensitivity: tuned.sensitivity, height: tuned.height, transparency: tuned.transparency } } }); - } - if (req.method === 'GET' && url.pathname === '/api/audio/presets') return json(res, 200, { presets: [ - { id: 'youtube-music', name: 'YouTube Music', patch: { audio: { normalize: true, limiter: true, compressor: true, audioBitrate: '256k', bassGain: 1, trebleGain: 1, masterGain: 100 } } }, - { id: 'youtube-shorts', name: 'YouTube Shorts', patch: { audio: { normalize: true, limiter: true, compressor: true, audioBitrate: '192k', masterGain: 108, bassGain: 2, trebleGain: 2, reactiveFx: 'Beat Flash' } } }, - { id: 'tiktok-loud', name: 'TikTok Loud', patch: { audio: { normalize: true, limiter: true, compressor: true, masterGain: 115, bassGain: 3, trebleGain: 2, audioBitrate: '192k' } } }, - { id: 'podcast-clean', name: 'Podcast Clean', patch: { audio: { normalize: true, limiter: true, compressor: true, highPass: 80, lowPass: 12000, noiseGate: true, reactiveFx: 'Off' } } }, - { id: 'background-soft', name: 'Background Soft', patch: { audio: { normalize: true, limiter: true, masterGain: 85, bassGain: -1, trebleGain: -1, reactiveFx: 'Off' } } }, - { id: 'cinematic-bass', name: 'Cinematic Bass', patch: { audio: { normalize: true, limiter: true, compressor: true, bassGain: 5, midGain: -1, trebleGain: 1, masterGain: 105 } } } - ] }); + return json(res, 200, { + ok: waveform.ok, + waveform, + beats, + metadata, + nowPlaying, + tuned, + recommendedPatch: { + spectrum: { beatSensitivity: tuned.sensitivity, height: tuned.height, transparency: tuned.transparency }, + }, + }); + } + if (req.method === 'GET' && url.pathname === '/api/audio/presets') + return json(res, 200, { + presets: [ + { + id: 'youtube-music', + name: 'YouTube Music', + patch: { + audio: { + normalize: true, + limiter: true, + compressor: true, + audioBitrate: '256k', + bassGain: 1, + trebleGain: 1, + masterGain: 100, + }, + }, + }, + { + id: 'youtube-shorts', + name: 'YouTube Shorts', + patch: { + audio: { + normalize: true, + limiter: true, + compressor: true, + audioBitrate: '192k', + masterGain: 108, + bassGain: 2, + trebleGain: 2, + reactiveFx: 'Beat Flash', + }, + }, + }, + { + id: 'tiktok-loud', + name: 'TikTok Loud', + patch: { + audio: { + normalize: true, + limiter: true, + compressor: true, + masterGain: 115, + bassGain: 3, + trebleGain: 2, + audioBitrate: '192k', + }, + }, + }, + { + id: 'podcast-clean', + name: 'Podcast Clean', + patch: { + audio: { + normalize: true, + limiter: true, + compressor: true, + highPass: 80, + lowPass: 12000, + noiseGate: true, + reactiveFx: 'Off', + }, + }, + }, + { + id: 'background-soft', + name: 'Background Soft', + patch: { + audio: { + normalize: true, + limiter: true, + masterGain: 85, + bassGain: -1, + trebleGain: -1, + reactiveFx: 'Off', + }, + }, + }, + { + id: 'cinematic-bass', + name: 'Cinematic Bass', + patch: { + audio: { + normalize: true, + limiter: true, + compressor: true, + bassGain: 5, + midGain: -1, + trebleGain: 1, + masterGain: 105, + }, + }, + }, + ], + }); if (req.method === 'POST' && url.pathname === '/api/audio/analyze') { - const b = await body(req); const file = b.file || deepMerge(activeConfig(state), b.config || {}).input?.audio; - const info = await ffprobeInfo(file); const loudness = await analyzeLoudness(file); const waveform = await waveformData(file, b.seconds || 45, b.buckets || 160); const beats = detectBeatsFromPeaks(waveform.peaks, waveform.seconds || 45); + const b = await body(req); + const file = b.file || deepMerge(activeConfig(state), b.config || {}).input?.audio; + const info = await ffprobeInfo(file); + const loudness = await analyzeLoudness(file); + const waveform = await waveformData(file, b.seconds || 45, b.buckets || 160); + const beats = detectBeatsFromPeaks(waveform.peaks, waveform.seconds || 45); return json(res, 200, { file, info, loudness, waveform, beats }); } if (req.method === 'POST' && url.pathname === '/api/audio/validate') { - const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); const au = config.audio || {}; const files = [config.input?.audio, ...(au.introSongs || []), ...(au.songs || []), au.endingSong, au.ambientLoop, au.voiceTrack, au.effectTrack, ...(au.stems || []).map(x => x.file)].filter(Boolean); const assets = []; - for (const file of files) { const info = await ffprobeInfo(file); assets.push({ file, ...info }); } - const totalDuration = assets.filter(a => a.ok).reduce((n,a)=>n+Number(a.duration||0),0); + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const au = config.audio || {}; + const files = [ + config.input?.audio, + ...(au.introSongs || []), + ...(au.songs || []), + au.endingSong, + au.ambientLoop, + au.voiceTrack, + au.effectTrack, + ...(au.stems || []).map(x => x.file), + ].filter(Boolean); + const assets = []; + for (const file of files) { + const info = await ffprobeInfo(file); + assets.push({ file, ...info }); + } + const totalDuration = assets.filter(a => a.ok).reduce((n, a) => n + Number(a.duration || 0), 0); const loudness = await analyzeLoudness(config.input?.audio || files[0]); - const waveform = b.withWaveform === false ? { ok: false, peaks: [] } : await waveformData(config.input?.audio || files[0], 45, 160); + const waveform = + b.withWaveform === false + ? { ok: false, peaks: [] } + : await waveformData(config.input?.audio || files[0], 45, 160); const beats = au.beatDetection === false ? [] : detectBeatsFromPeaks(waveform.peaks, waveform.seconds || 45); const safety = audioSafetyReport(config, assets, loudness); - return json(res, 200, { ok: safety.ok, warnings: safety.warnings, assets, totalDuration, loudness, waveform, beats }); - } - if (req.method === 'GET' && url.pathname === '/api/lyrics/presets') return json(res, 200, { presets: [ - { id: 'modern', name: 'Modern Clean', patch: { lyrics: { stylePreset: 'modern', font: 'Arial', scale: 30, outline: 2, shadow: 1, color: '#ffffff', highlightColor: '#22c55e', position: 'Bawah' } } }, - { id: 'karaoke', name: 'Karaoke Highlight', patch: { lyrics: { stylePreset: 'karaoke', karaoke: true, wordByWord: true, scale: 32, color: '#ffffff', highlightColor: '#facc15', outline: 2 } } }, - { id: 'shorts-bold', name: 'Shorts Bold', patch: { lyrics: { stylePreset: 'shorts-bold', uppercase: true, scale: 38, maxChars: 26, outline: 3, position: 'Tengah', safeArea: true } } }, - { id: 'minimal', name: 'Minimal Subtitle', patch: { lyrics: { stylePreset: 'minimal', scale: 24, outline: 1, shadow: 0, color: '#eeeeee', position: 'Bawah' } } } - ] }); + return json(res, 200, { + ok: safety.ok, + warnings: safety.warnings, + assets, + totalDuration, + loudness, + waveform, + beats, + }); + } + if (req.method === 'GET' && url.pathname === '/api/lyrics/presets') + return json(res, 200, { + presets: [ + { + id: 'modern', + name: 'Modern Clean', + patch: { + lyrics: { + stylePreset: 'modern', + font: 'Arial', + scale: 30, + outline: 2, + shadow: 1, + color: '#ffffff', + highlightColor: '#22c55e', + position: 'Bawah', + }, + }, + }, + { + id: 'karaoke', + name: 'Karaoke Highlight', + patch: { + lyrics: { + stylePreset: 'karaoke', + karaoke: true, + wordByWord: true, + scale: 32, + color: '#ffffff', + highlightColor: '#facc15', + outline: 2, + }, + }, + }, + { + id: 'shorts-bold', + name: 'Shorts Bold', + patch: { + lyrics: { + stylePreset: 'shorts-bold', + uppercase: true, + scale: 38, + maxChars: 26, + outline: 3, + position: 'Tengah', + safeArea: true, + }, + }, + }, + { + id: 'minimal', + name: 'Minimal Subtitle', + patch: { + lyrics: { stylePreset: 'minimal', scale: 24, outline: 1, shadow: 0, color: '#eeeeee', position: 'Bawah' }, + }, + }, + ], + }); if (req.method === 'POST' && url.pathname === '/api/lyrics/parse') { - const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); - const source = await readLyricSource(b); const parsed = b.file ? await readLyricsFile(b.file) : []; - const rows = parsed.length ? parsed : String(source || '').split(/\r?\n/).filter(Boolean).map((line,i)=>({time:i*Number(config.lyrics?.lineDuration || 3),text:line.replace(/^\[[^\]]+\]/,'').trim()})); + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const source = await readLyricSource(b); + const parsed = b.file ? await readLyricsFile(b.file) : []; + const rows = parsed.length + ? parsed + : String(source || '') + .split(/\r?\n/) + .filter(Boolean) + .map((line, i) => ({ + time: i * Number(config.lyrics?.lineDuration || 3), + text: line.replace(/^\[[^\]]+\]/, '').trim(), + })); return json(res, 200, { lines: rows, lrc: rowsToLrc(rows), srt: lrcRowsToSrt(rows), vtt: rowsToVtt(rows) }); } if (req.method === 'POST' && url.pathname === '/api/lyrics/auto-align') { - const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); const source = await readLyricSource(b); - const audio = b.audio || config.input?.audio; const info = await ffprobeInfo(audio); const duration = b.duration || info.duration || config.target?.duration || 180; - const waveform = config.lyrics?.beatSnap === false ? { peaks: [] } : await waveformData(audio, Math.min(duration, 90), 180); - const beats = config.lyrics?.beatSnap === false ? [] : detectBeatsFromPeaks(waveform.peaks, waveform.seconds || Math.min(duration, 90)); - const rows = autoAlignLyrics(source, duration, config, beats); const format = b.format || config.lyrics?.exportFormat || 'srt'; - const outputFile = b.outputFile || config.lyrics?.outputFile || (config.input?.output ? path.join(config.input.output, `auto-lyrics.${format}`) : ''); + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const source = await readLyricSource(b); + const audio = b.audio || config.input?.audio; + const info = await ffprobeInfo(audio); + const duration = b.duration || info.duration || config.target?.duration || 180; + const waveform = + config.lyrics?.beatSnap === false ? { peaks: [] } : await waveformData(audio, Math.min(duration, 90), 180); + const beats = + config.lyrics?.beatSnap === false + ? [] + : detectBeatsFromPeaks(waveform.peaks, waveform.seconds || Math.min(duration, 90)); + const rows = autoAlignLyrics(source, duration, config, beats); + const format = b.format || config.lyrics?.exportFormat || 'srt'; + const outputFile = + b.outputFile || + config.lyrics?.outputFile || + (config.input?.output ? path.join(config.input.output, `auto-lyrics.${format}`) : ''); const exported = await saveLyricsExport(rows, format, config.lyrics?.autoSave === false ? '' : outputFile); const quality = scoreLyricTimeline(rows, duration, config, beats); - return json(res, 200, { lines: rows, duration, beats, quality, lrc: rowsToLrc(rows), srt: lrcRowsToSrt(rows), vtt: rowsToVtt(rows), ...exported }); + return json(res, 200, { + lines: rows, + duration, + beats, + quality, + lrc: rowsToLrc(rows), + srt: lrcRowsToSrt(rows), + vtt: rowsToVtt(rows), + ...exported, + }); } if (req.method === 'POST' && url.pathname === '/api/lyrics/export') { - const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); const rows = b.lines || autoAlignLyrics(await readLyricSource(b), b.duration || config.target?.duration || 180, config); - return json(res, 200, await saveLyricsExport(rows, b.format || config.lyrics?.exportFormat || 'srt', b.outputFile || config.lyrics?.outputFile || '')); + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const rows = + b.lines || autoAlignLyrics(await readLyricSource(b), b.duration || config.target?.duration || 180, config); + return json( + res, + 200, + await saveLyricsExport( + rows, + b.format || config.lyrics?.exportFormat || 'srt', + b.outputFile || config.lyrics?.outputFile || '', + ), + ); } if (req.method === 'POST' && url.pathname === '/api/lyrics/validate') { - const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); const rows = b.lines || (config.lyrics?.file ? await readLyricsFile(config.lyrics.file) : []); const warnings = []; + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const rows = b.lines || (config.lyrics?.file ? await readLyricsFile(config.lyrics.file) : []); + const warnings = []; if (!rows.length) warnings.push('belum ada baris lirik'); - rows.forEach((r,i)=>{ if (!r.text) warnings.push(`baris ${i+1}: teks kosong`); if (i>0 && Number(r.time) <= Number(rows[i-1].time)) warnings.push(`baris ${i+1}: timestamp tidak naik`); if (String(r.text||'').length > Number(config.lyrics?.maxChars || 42)) warnings.push(`baris ${i+1}: terlalu panjang`); }); - const audioDur = await ffprobeInfo(config.input?.audio).then(x=>x.duration||0); if (audioDur && rows.at(-1)?.time > audioDur + 3) warnings.push('timestamp lirik melebihi durasi audio'); - const waveform = config.lyrics?.beatSnap === false ? { peaks: [] } : await waveformData(config.input?.audio, Math.min(audioDur || 45, 90), 180); - const beats = config.lyrics?.beatSnap === false ? [] : detectBeatsFromPeaks(waveform.peaks, waveform.seconds || Math.min(audioDur || 45, 90)); + rows.forEach((r, i) => { + if (!r.text) warnings.push(`baris ${i + 1}: teks kosong`); + if (i > 0 && Number(r.time) <= Number(rows[i - 1].time)) warnings.push(`baris ${i + 1}: timestamp tidak naik`); + if (String(r.text || '').length > Number(config.lyrics?.maxChars || 42)) + warnings.push(`baris ${i + 1}: terlalu panjang`); + }); + const audioDur = await ffprobeInfo(config.input?.audio).then(x => x.duration || 0); + if (audioDur && rows.at(-1)?.time > audioDur + 3) warnings.push('timestamp lirik melebihi durasi audio'); + const waveform = + config.lyrics?.beatSnap === false + ? { peaks: [] } + : await waveformData(config.input?.audio, Math.min(audioDur || 45, 90), 180); + const beats = + config.lyrics?.beatSnap === false + ? [] + : detectBeatsFromPeaks(waveform.peaks, waveform.seconds || Math.min(audioDur || 45, 90)); const quality = scoreLyricTimeline(rows, audioDur || config.target?.duration || 180, config, beats); const allWarnings = [...new Set([...warnings, ...(quality.warnings || [])])]; - return json(res, 200, { ok: allWarnings.length === 0 || quality.score >= Number(config.lyrics?.qualityGate || 82), warnings: allWarnings, lineCount: rows.length, audioDuration: audioDur, beats, quality }); + return json(res, 200, { + ok: allWarnings.length === 0 || quality.score >= Number(config.lyrics?.qualityGate || 82), + warnings: allWarnings, + lineCount: rows.length, + audioDuration: audioDur, + beats, + quality, + }); } if (req.method === 'GET' && url.pathname === '/api/preview/file') { const file = url.searchParams.get('path') || ''; const full = path.resolve(file); const allowed = path.resolve(workspaceDir, 'previews'); - if (!full.startsWith(allowed) || !existsSync(full)) { res.writeHead(404, { 'content-type': 'text/plain' }); return res.end('preview not found'); } + if (!full.startsWith(allowed) || !existsSync(full)) { + res.writeHead(404, { 'content-type': 'text/plain' }); + return res.end('preview not found'); + } const buf = await readFile(full); res.writeHead(200, { 'content-type': mimeFor(full), 'cache-control': 'no-store', ...corsHeaders() }); return res.end(buf); } - if (req.method === 'POST' && url.pathname === '/api/preview/diagnostics') { const b = await body(req); const config = previewConfig(activeConfig(state), b.config || {}); return json(res, 200, await previewDiagnostics(config)); } + if (req.method === 'POST' && url.pathname === '/api/preview/diagnostics') { + const b = await body(req); + const config = previewConfig(activeConfig(state), b.config || {}); + return json(res, 200, await previewDiagnostics(config)); + } if (req.method === 'POST' && url.pathname === '/api/preview/render') { - const b = await body(req); const config = previewConfig(activeConfig(state), b.config || {}); const diag = await previewDiagnostics(config); + const b = await body(req); + const config = previewConfig(activeConfig(state), b.config || {}); + const diag = await previewDiagnostics(config); const validation = validateRenderConfig(config); - if (!validation.ok) return json(res, 400, { ok: false, error: validation.errors.join(' '), errors: validation.errors, warnings: [...diag.warnings, ...validation.warnings], assets: diag.assets }); + if (!validation.ok) + return json(res, 400, { + ok: false, + error: validation.errors.join(' '), + errors: validation.errors, + warnings: [...diag.warnings, ...validation.warnings], + assets: diag.assets, + }); if (!diag.ok) return json(res, 400, { ok: false, warnings: diag.warnings, assets: diag.assets }); - const previewDir = path.join(workspaceDir, 'previews'); await mkdir(previewDir, { recursive: true }); - const id = safeId('preview'); const output = path.join(previewDir, `${id}.mp4`); - const job = { id, title: b.title || config.input?.title || 'Preview Render', input: { visual: config.input?.visual, audio: config.input?.audio, lyrics: config.lyrics?.file || config.lyrics?.srtPath || '' }, output, duration: config.target.duration, startAt: Number(config.preview?.startAt || b.startAt || 0) }; + const previewDir = path.join(workspaceDir, 'previews'); + await mkdir(previewDir, { recursive: true }); + const id = safeId('preview'); + const output = path.join(previewDir, `${id}.mp4`); + const job = { + id, + title: b.title || config.input?.title || 'Preview Render', + input: { + visual: config.input?.visual, + audio: config.input?.audio, + lyrics: config.lyrics?.file || config.lyrics?.srtPath || '', + }, + output, + duration: config.target.duration, + startAt: Number(config.preview?.startAt || b.startAt || 0), + }; const logs = []; const started = Date.now(); - const result = await renderJob(job, config, workspaceDir, { onLog: line => logs.push(String(line).slice(0, 500)), onCommand: line => logs.push(String(line).slice(0, 900)) }); + const result = await renderJob(job, config, workspaceDir, { + onLog: line => logs.push(String(line).slice(0, 500)), + onCommand: line => logs.push(String(line).slice(0, 900)), + }); const statInfo = await stat(result.output).catch(() => null); const previewUrl = `/api/preview/file?path=${encodeURIComponent(result.output)}`; - const project = activeProject(state); project.config.preview = deepMerge(project.config.preview || defaultConfig.preview, { lastUrl: previewUrl, lastOutput: result.output, lastGeneratedAt: new Date().toISOString() }); await saveState(state); - return json(res, 200, { ok: true, id, url: previewUrl, output: result.output, size: statInfo?.size || 0, elapsedMs: Date.now() - started, duration: config.target.duration, startAt: Number(config.preview?.startAt || b.startAt || 0), resolution: config.target.resolution, safeArea: diag.safeArea, warnings: diag.warnings, logs: logs.slice(-20) }); + const project = activeProject(state); + project.config.preview = deepMerge(project.config.preview || defaultConfig.preview, { + lastUrl: previewUrl, + lastOutput: result.output, + lastGeneratedAt: new Date().toISOString(), + }); + await saveState(state); + return json(res, 200, { + ok: true, + id, + url: previewUrl, + output: result.output, + size: statInfo?.size || 0, + elapsedMs: Date.now() - started, + duration: config.target.duration, + startAt: Number(config.preview?.startAt || b.startAt || 0), + resolution: config.target.resolution, + safeArea: diag.safeArea, + warnings: diag.warnings, + logs: logs.slice(-20), + }); } if (req.method === 'POST' && url.pathname === '/api/preview/snapshot') { - const b = await body(req); const config = previewConfig(activeConfig(state), deepMerge(b.config || {}, { preview: { duration: 1, startAt: Number(b.startAt ?? b.config?.preview?.startAt ?? 0), quality: b.config?.preview?.quality || 'draft' } })); const diag = await previewDiagnostics(config); + const b = await body(req); + const config = previewConfig( + activeConfig(state), + deepMerge(b.config || {}, { + preview: { + duration: 1, + startAt: Number(b.startAt ?? b.config?.preview?.startAt ?? 0), + quality: b.config?.preview?.quality || 'draft', + }, + }), + ); + const diag = await previewDiagnostics(config); const validation = validateRenderConfig(config); - if (!validation.ok) return json(res, 400, { ok: false, error: validation.errors.join(' '), errors: validation.errors, warnings: [...diag.warnings, ...validation.warnings], assets: diag.assets }); + if (!validation.ok) + return json(res, 400, { + ok: false, + error: validation.errors.join(' '), + errors: validation.errors, + warnings: [...diag.warnings, ...validation.warnings], + assets: diag.assets, + }); if (!diag.ok) return json(res, 400, { ok: false, warnings: diag.warnings, assets: diag.assets }); - const previewDir = path.join(workspaceDir, 'previews'); await mkdir(previewDir, { recursive: true }); - const id = safeId('snapshot'); const tempMp4 = path.join(previewDir, `${id}.mp4`); const output = path.join(previewDir, `${id}.png`); - const job = { id, title: b.title || config.input?.title || 'Preview Snapshot', input: { visual: config.input?.visual, audio: config.input?.audio, lyrics: config.lyrics?.file || config.lyrics?.srtPath || '' }, output: tempMp4, duration: 1, startAt: Number(config.preview?.startAt || 0) }; - const logs = []; await renderJob(job, config, workspaceDir, { onLog: line => logs.push(String(line).slice(0, 500)), onCommand: line => logs.push(String(line).slice(0, 900)) }); + const previewDir = path.join(workspaceDir, 'previews'); + await mkdir(previewDir, { recursive: true }); + const id = safeId('snapshot'); + const tempMp4 = path.join(previewDir, `${id}.mp4`); + const output = path.join(previewDir, `${id}.png`); + const job = { + id, + title: b.title || config.input?.title || 'Preview Snapshot', + input: { + visual: config.input?.visual, + audio: config.input?.audio, + lyrics: config.lyrics?.file || config.lyrics?.srtPath || '', + }, + output: tempMp4, + duration: 1, + startAt: Number(config.preview?.startAt || 0), + }; + const logs = []; + await renderJob(job, config, workspaceDir, { + onLog: line => logs.push(String(line).slice(0, 500)), + onCommand: line => logs.push(String(line).slice(0, 900)), + }); const shot = await runCmd(FFMPEG, ['-y', '-i', tempMp4, '-frames:v', '1', output]); if (!shot.ok) return json(res, 500, { ok: false, error: shot.stderr || 'snapshot gagal', logs }); - const statInfo = await stat(output).catch(() => null); const snapshotUrl = `/api/preview/file?path=${encodeURIComponent(output)}`; - const project = activeProject(state); project.config.preview = deepMerge(project.config.preview || defaultConfig.preview, { lastSnapshotUrl: snapshotUrl, lastSnapshotOutput: output }); await saveState(state); - return json(res, 200, { ok: true, id, url: snapshotUrl, output, size: statInfo?.size || 0, startAt: Number(config.preview?.startAt || 0), safeArea: diag.safeArea, warnings: diag.warnings, logs: logs.slice(-20) }); + const statInfo = await stat(output).catch(() => null); + const snapshotUrl = `/api/preview/file?path=${encodeURIComponent(output)}`; + const project = activeProject(state); + project.config.preview = deepMerge(project.config.preview || defaultConfig.preview, { + lastSnapshotUrl: snapshotUrl, + lastSnapshotOutput: output, + }); + await saveState(state); + return json(res, 200, { + ok: true, + id, + url: snapshotUrl, + output, + size: statInfo?.size || 0, + startAt: Number(config.preview?.startAt || 0), + safeArea: diag.safeArea, + warnings: diag.warnings, + logs: logs.slice(-20), + }); } if (req.method === 'POST' && url.pathname === '/api/preview/send-to-queue') { - const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); const diag = await previewDiagnostics(previewConfig(config, {})); + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const diag = await previewDiagnostics(previewConfig(config, {})); const validation = validateRenderConfig(config); - if (!validation.ok) return json(res, 400, { ok: false, error: validation.errors.join(' '), errors: validation.errors, warnings: [...diag.warnings, ...validation.warnings] }); - const job = { id: safeId('job'), projectId: state.activeProjectId, title: b.title || config.input?.title || `Render ${state.jobs.length + 1}`, status: 'standby', progress: 0, speed: '0.0x', output: b.output || '', outputDir: b.outputDir || config.input?.output, input: { visual: config.input?.visual, audio: config.input?.audio, lyrics: config.lyrics?.file || config.lyrics?.srtPath || '' }, config, createdAt: new Date().toISOString(), approvedFromPreview: true, previewWarnings: diag.warnings }; - state.jobs.push(job); addLog(state, `${job.title} dikirim dari preview ke antrian.`); await saveState(state); return json(res, 201, { job, warnings: diag.warnings }); + if (!validation.ok) + return json(res, 400, { + ok: false, + error: validation.errors.join(' '), + errors: validation.errors, + warnings: [...diag.warnings, ...validation.warnings], + }); + const job = { + id: safeId('job'), + projectId: state.activeProjectId, + title: b.title || config.input?.title || `Render ${state.jobs.length + 1}`, + status: 'standby', + progress: 0, + speed: '0.0x', + output: b.output || '', + outputDir: b.outputDir || config.input?.output, + input: { + visual: config.input?.visual, + audio: config.input?.audio, + lyrics: config.lyrics?.file || config.lyrics?.srtPath || '', + }, + config, + createdAt: new Date().toISOString(), + approvedFromPreview: true, + previewWarnings: diag.warnings, + }; + state.jobs.push(job); + addLog(state, `${job.title} dikirim dari preview ke antrian.`); + await saveState(state); + return json(res, 201, { job, warnings: diag.warnings }); } if (req.method === 'POST' && url.pathname === '/api/preview/live') { - const b = await body(req); const config = previewConfig(activeConfig(state), b.config || {}); - const logs = []; let lastProgress = null; + const b = await body(req); + const config = previewConfig(activeConfig(state), b.config || {}); + const logs = []; + let lastProgress = null; try { const result = await generateLivePreview(config, { startAt: Number(b.startAt || 0), @@ -272,72 +1282,526 @@ export async function handleRequest(req, res, url, ctx) { width: Number(b.width || 640), height: Number(b.height || 360), workspaceDir, - onProgress: (p) => { lastProgress = p; }, - onLog: (line) => logs.push(String(line).slice(0, 500)) + onProgress: p => { + lastProgress = p; + }, + onLog: line => logs.push(String(line).slice(0, 500)), }); return json(res, 200, { ...result, logs: logs.slice(-20), progress: lastProgress }); - } catch (e) { return json(res, 500, { ok: false, error: e.message, logs }); } + } catch { + return json(res, 500, { ok: false, error: 'Preview live gagal. Periksa log server.', logs }); + } } if (req.method === 'POST' && url.pathname === '/api/preview/full') { - const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); - const logs = []; let lastProgress = null; + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const logs = []; + let lastProgress = null; try { const result = await generateFullPreview(config, { startAt: Number(b.startAt || 0), duration: Number(b.duration || 10), workspaceDir, - onProgress: (p) => { lastProgress = p; }, - onLog: (line) => logs.push(String(line).slice(0, 500)) + onProgress: p => { + lastProgress = p; + }, + onLog: line => logs.push(String(line).slice(0, 500)), }); return json(res, 200, { ...result, logs: logs.slice(-20), progress: lastProgress }); - } catch (e) { return json(res, 500, { ok: false, error: e.message, logs }); } + } catch { + return json(res, 500, { ok: false, error: 'Preview full gagal. Periksa log server.', logs }); + } } if (req.method === 'POST' && url.pathname === '/api/preview/cleanup') { try { - const result = await cleanupPreviews(workspaceDir, Number(req.body?.maxAge || 3600000)); + const b = await body(req); + const result = await cleanupPreviews(workspaceDir, Number(b.maxAge || 3600000)); return json(res, 200, result); - } catch (e) { return json(res, 500, { ok: false, error: e.message }); } - } - if (req.method === 'GET' && url.pathname === '/api/performance/status') return json(res, 200, await performanceStatus(state)); - if (req.method === 'GET' && url.pathname === '/api/performance/presets') return json(res, 200, { presets: [ - { id: 'balanced', name: 'Balanced', description: 'Aman untuk render harian' }, - { id: 'turbo', name: 'Turbo Render', description: 'Prioritaskan kecepatan, kualitas preview turun' }, - { id: 'quality', name: 'Quality Max', description: 'Prioritaskan kualitas output' }, - { id: 'eco', name: 'Eco / Laptop Safe', description: 'Lebih ringan untuk laptop/panas' } - ] }); - if (req.method === 'POST' && url.pathname === '/api/performance/apply') { const b = await body(req); const patch = performancePatch(b.mode || 'balanced'); const project = activeProject(state); project.config = deepMerge(project.config, patch); state.queue.concurrency = project.config.performance.queueConcurrency; addLog(state, `preset performa diterapkan: ${project.config.performance.mode}`); await saveState(state); return json(res, 200, { config: project.config, patch, status: await performanceStatus(state) }); } - if (req.method === 'POST' && url.pathname === '/api/performance/settings') { const b = await body(req); const project = activeProject(state); project.config.performance = deepMerge(project.config.performance || defaultConfig.performance, b.performance || b); if (project.config.performance.queueConcurrency) state.queue.concurrency = Number(project.config.performance.queueConcurrency); addLog(state, 'setting performa disimpan.'); await saveState(state); return json(res, 200, { config: project.config, status: await performanceStatus(state) }); } - if (req.method === 'POST' && url.pathname === '/api/performance/optimize') { const status = await performanceStatus(state); let mode = 'balanced'; if (status.metrics.cpu > 80 || status.metrics.memory > 82) mode = 'eco'; else if (status.metrics.activeRenders === 0 && status.metrics.queueStandby > 3 && status.encoder !== 'cpu') mode = 'turbo'; const patch = performancePatch(mode); return json(res, 200, { recommendedMode: mode, patch, status, reasons: status.warnings.length ? status.warnings : ['Sistem aman, balanced direkomendasikan.'] }); } - if (req.method === "POST" && url.pathname === "/api/thumbnail/generate") { const b = await body(req); const result = await generateThumbnails(b); return json(res, 200, result); } - if (req.method === "GET" && url.pathname === "/api/history") { const page = Number(url.searchParams.get("page") || 1); const limit = Number(url.searchParams.get("limit") || 20); const status = url.searchParams.get("status") || ""; return json(res, 200, getHistory({ page, limit, status })); } - if (req.method === "GET" && url.pathname === "/api/history/stats") { return json(res, 200, getHistoryStats()); } - if (req.method === "POST" && url.pathname === "/api/history/clear") { clearHistory(); return json(res, 200, { ok: true }); } + } catch { + return json(res, 500, { ok: false, error: 'Cleanup preview gagal.' }); + } + } + if (req.method === 'GET' && url.pathname === '/api/performance/status') + return json(res, 200, await performanceStatus(state)); + if (req.method === 'GET' && url.pathname === '/api/performance/presets') + return json(res, 200, { + presets: [ + { id: 'balanced', name: 'Balanced', description: 'Aman untuk render harian' }, + { id: 'turbo', name: 'Turbo Render', description: 'Prioritaskan kecepatan, kualitas preview turun' }, + { id: 'quality', name: 'Quality Max', description: 'Prioritaskan kualitas output' }, + { id: 'eco', name: 'Eco / Laptop Safe', description: 'Lebih ringan untuk laptop/panas' }, + ], + }); + if (req.method === 'POST' && url.pathname === '/api/performance/apply') { + const b = await body(req); + const patch = performancePatch(b.mode || 'balanced'); + const project = activeProject(state); + project.config = deepMerge(project.config, patch); + state.queue.concurrency = project.config.performance.queueConcurrency; + addLog(state, `preset performa diterapkan: ${project.config.performance.mode}`); + await saveState(state); + return json(res, 200, { config: project.config, patch, status: await performanceStatus(state) }); + } + if (req.method === 'POST' && url.pathname === '/api/performance/settings') { + const b = await body(req); + const project = activeProject(state); + project.config.performance = deepMerge( + project.config.performance || defaultConfig.performance, + b.performance || b, + ); + if (project.config.performance.queueConcurrency) + state.queue.concurrency = Number(project.config.performance.queueConcurrency); + addLog(state, 'setting performa disimpan.'); + await saveState(state); + return json(res, 200, { config: project.config, status: await performanceStatus(state) }); + } + if (req.method === 'POST' && url.pathname === '/api/performance/optimize') { + const status = await performanceStatus(state); + let mode = 'balanced'; + if (status.metrics.cpu > 80 || status.metrics.memory > 82) mode = 'eco'; + else if (status.metrics.activeRenders === 0 && status.metrics.queueStandby > 3 && status.encoder !== 'cpu') + mode = 'turbo'; + const patch = performancePatch(mode); + return json(res, 200, { + recommendedMode: mode, + patch, + status, + reasons: status.warnings.length ? status.warnings : ['Sistem aman, balanced direkomendasikan.'], + }); + } + if (req.method === 'POST' && url.pathname === '/api/thumbnail/generate') { + const b = await body(req); + const result = await generateThumbnails(b); + return json(res, 200, result); + } + if (req.method === 'GET' && url.pathname === '/api/history') { + const page = Number(url.searchParams.get('page') || 1); + const limit = Number(url.searchParams.get('limit') || 20); + const status = url.searchParams.get('status') || ''; + return json(res, 200, getHistory({ page, limit, status })); + } + if (req.method === 'GET' && url.pathname === '/api/history/stats') { + return json(res, 200, getHistoryStats()); + } + if (req.method === 'POST' && url.pathname === '/api/history/clear') { + clearHistory(); + return json(res, 200, { ok: true }); + } if (req.method === 'GET' && url.pathname === '/api/queue/summary') return json(res, 200, queueSummary(state)); - if (req.method === 'POST' && url.pathname === '/api/queue/settings') { const b = await body(req); state.queue = deepMerge(state.queue || defaultState.queue, b); addLog(state, 'setting antrian disimpan.'); await saveState(state); return json(res, 200, state.queue); } - if (req.method === 'POST' && url.pathname === '/api/queue/start') { const b = await body(req); if (b.concurrency) state.queue.concurrency = Number(b.concurrency); state.queue.paused = false; state.queue.running = true; await saveState(state); runQueueLoop(); return json(res, 202, queueSummary(await loadState())); } - if (req.method === 'POST' && url.pathname === '/api/queue/pause') { state.queue.paused = true; addLog(state, 'antrian dipause.'); await saveState(state); return json(res, 200, queueSummary(state)); } - if (req.method === 'POST' && url.pathname === '/api/queue/resume') { state.queue.paused = false; addLog(state, 'antrian dilanjutkan.'); await saveState(state); runQueueLoop(); return json(res, 202, queueSummary(state)); } - if (req.method === 'POST' && url.pathname === '/api/queue/stop') { requestQueueStop(); state.queue.running = false; state.queue.paused = false; addLog(state, 'permintaan stop antrian.'); await saveState(state); return json(res, 200, queueSummary(state)); } - if (req.method === 'POST' && url.pathname === '/api/queue/retry-failed') { let c=0; for (const j of state.jobs) if (j.status === 'failed' || j.status === 'cancelled') { j.status='standby'; j.progress=0; j.error=''; c++; } addLog(state, `${c} job gagal dikembalikan ke standby.`); await saveState(state); return json(res, 200, { retried: c, ...queueSummary(state) }); } - if (req.method === 'POST' && url.pathname === '/api/queue/clear-done') { const before = state.jobs.length; state.jobs = state.jobs.filter(j => j.status !== 'done'); addLog(state, `${before - state.jobs.length} job selesai dibersihkan.`); await saveState(state); return json(res, 200, { removed: before - state.jobs.length, ...queueSummary(state) }); } - if (req.method === 'POST' && url.pathname === '/api/queue/clear-failed') { const before = state.jobs.length; state.jobs = state.jobs.filter(j => j.status !== 'failed' && j.status !== 'cancelled'); addLog(state, `${before - state.jobs.length} job gagal dibersihkan.`); await saveState(state); return json(res, 200, { removed: before - state.jobs.length, ...queueSummary(state) }); } - if (req.method === 'POST' && url.pathname === '/api/queue/reorder') { const b = await body(req); const order = Array.isArray(b.order) ? b.order : []; const map = new Map(state.jobs.map(j => [j.id, j])); state.jobs = [...order.map(id => map.get(id)).filter(Boolean), ...state.jobs.filter(j => !order.includes(j.id))]; addLog(state, 'urutan antrian diperbarui.'); await saveState(state); return json(res, 200, queueSummary(state)); } - if (req.method === 'POST' && url.pathname === '/api/logs/clear') { state.logs = ['Log dibersihkan.']; await saveState(state); return json(res, 200, { ok: true, logs: state.logs }); } - if (req.method === 'POST' && url.pathname === '/api/render/validate') { const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); const validation = validateRenderConfig(config, { visual: b.input?.visual, audio: b.input?.audio, outputDir: b.outputDir || config.input?.output }); const estimate = estimateRender(config, [], []); return json(res, validation.ok ? 200 : 400, { ok: validation.ok, errors: validation.errors, warnings: validation.warnings, estimate, error: validation.errors.join(' ') }); } - const move = matchRoute(req, url, 'POST', /^\/api\/jobs\/([^/]+)\/move$/); if (move) { const b = await body(req); const idx = state.jobs.findIndex(j=>j.id===move[1]); if (idx < 0) return json(res,404,{error:'job tidak ditemukan'}); const [job] = state.jobs.splice(idx,1); let to = b.to === 'top' ? 0 : b.to === 'bottom' ? state.jobs.length : Math.max(0, Math.min(state.jobs.length, Number(b.to ?? idx))); state.jobs.splice(to,0,job); await saveState(state); return json(res,200,queueSummary(state)); } - const dup = url.pathname.match(/^\/api\/jobs\/([^/]+)\/duplicate$/); if (req.method === 'POST' && dup) { const job = state.jobs.find(j=>j.id===dup[1]); if (!job) return json(res,404,{error:'job tidak ditemukan'}); const cloned = cloneJob(job); state.jobs.push(cloned); addLog(state, `job diduplikasi: ${job.title}`); await saveState(state); return json(res,201,cloned); } - const removeJob = url.pathname.match(/^\/api\/jobs\/([^/]+)\/remove$/); if (req.method === 'POST' && removeJob) { const idx = state.jobs.findIndex(j=>j.id===removeJob[1]); if (idx < 0) return json(res,404,{error:'job tidak ditemukan'}); const [job] = state.jobs.splice(idx,1); addLog(state, `job dihapus: ${job.title}`); await saveState(state); return json(res,200,{removed: job.id}); } - if (req.method === 'GET' && url.pathname === '/api/jobs') return json(res, 200, { jobs: state.jobs, logs: state.logs }); - if (req.method === 'POST' && url.pathname === '/api/jobs') { const b = await body(req); const config = deepMerge(activeConfig(state), b.config || {}); const validation = validateRenderConfig(config, { visual: b.input?.visual, audio: b.input?.audio, outputDir: b.outputDir || config.input.output }); if (!validation.ok) return json(res, 400, { ok: false, error: validation.errors.join(' '), errors: validation.errors, warnings: validation.warnings }); const job = { id: safeId('job'), projectId: state.activeProjectId, title: b.title || config.input.title || `Render ${state.jobs.length + 1}`, status: 'standby', progress: 0, speed: '0.0x', output: b.output || '', outputDir: b.outputDir || config.input.output, input: b.input || {}, config, createdAt: new Date().toISOString(), validationWarnings: validation.warnings }; state.jobs.push(job); addLog(state, `${job.title} masuk antrian.`); await saveState(state); return json(res, 201, job); } - if (req.method === 'POST' && url.pathname === '/api/jobs/batch') { const b = await body(req); const files = Array.isArray(b.items) ? b.items : []; const created = []; const skipped = []; for (const item of files) { const config = deepMerge(activeConfig(state), item.config || {}); const input = item.input || { visual: item.visual, audio: item.audio, lyrics: item.lyrics }; const validation = validateRenderConfig(config, { visual: input.visual, audio: input.audio, outputDir: item.outputDir || config.input.output }); if (!validation.ok) { skipped.push({ title: item.title || item.audio || item.visual || 'Batch', errors: validation.errors }); continue; } const job = { id: safeId('job'), projectId: state.activeProjectId, title: item.title || path.basename(item.audio || item.visual || `Batch ${created.length+1}`), status: 'standby', progress: 0, speed: '0.0x', outputDir: item.outputDir || config.input.output, input, config, createdAt: new Date().toISOString(), validationWarnings: validation.warnings }; state.jobs.push(job); created.push(job); } addLog(state, `${created.length} job batch dibuat${skipped.length ? `, ${skipped.length} dilewati validasi` : ''}.`); await saveState(state); return json(res,201,{created, skipped}); } - const start = url.pathname.match(/^\/api\/jobs\/([^/]+)\/start$/); if (req.method === 'POST' && start) return json(res, 202, await startJob(start[1], processes)); - if (req.method === 'POST' && url.pathname === '/api/jobs/start-next') { const next = state.jobs.find(j => j.status === 'standby' || j.status === 'failed'); if (!next) return json(res, 404, { error: 'tidak ada job standby' }); return json(res, 202, await startJob(next.id, processes)); } - const cancel = url.pathname.match(/^\/api\/jobs\/([^/]+)\/cancel$/); if (req.method === 'POST' && cancel) { const id = cancel[1]; const child = processes.get(id); if (child) child.kill('SIGTERM'); const j = state.jobs.find(x=>x.id===id); if (j) { j.status='cancelled'; j.error='Dibatalkan user'; } addLog(state, `job dibatalkan: ${id}`); await saveState(state); return json(res,200,{ok:true}); } - if (req.method === 'POST' && url.pathname === '/api/jobs/reset') { for (const child of processes.values()) child.kill('SIGTERM'); processes.clear(); state.jobs = []; addLog(state, 'antrian direset.'); await saveState(state); return json(res, 200, { ok: true }); } + if (req.method === 'POST' && url.pathname === '/api/queue/settings') { + const b = await body(req); + state.queue = deepMerge(state.queue || defaultState.queue, b); + addLog(state, 'setting antrian disimpan.'); + await saveState(state); + return json(res, 200, state.queue); + } + if (req.method === 'POST' && url.pathname === '/api/queue/start') { + const b = await body(req); + if (b.concurrency) state.queue.concurrency = Number(b.concurrency); + state.queue.paused = false; + state.queue.running = true; + await saveState(state); + runQueueLoop(); + return json(res, 202, queueSummary(await loadState())); + } + if (req.method === 'POST' && url.pathname === '/api/queue/pause') { + state.queue.paused = true; + addLog(state, 'antrian dipause.'); + await saveState(state); + return json(res, 200, queueSummary(state)); + } + if (req.method === 'POST' && url.pathname === '/api/queue/resume') { + state.queue.paused = false; + addLog(state, 'antrian dilanjutkan.'); + await saveState(state); + runQueueLoop(); + return json(res, 202, queueSummary(state)); + } + if (req.method === 'POST' && url.pathname === '/api/queue/stop') { + requestQueueStop(); + state.queue.running = false; + state.queue.paused = false; + addLog(state, 'permintaan stop antrian.'); + await saveState(state); + return json(res, 200, queueSummary(state)); + } + if (req.method === 'POST' && url.pathname === '/api/queue/retry-failed') { + let c = 0; + for (const j of state.jobs) + if (j.status === 'failed' || j.status === 'cancelled') { + j.status = 'standby'; + j.progress = 0; + j.error = ''; + c++; + } + addLog(state, `${c} job gagal dikembalikan ke standby.`); + await saveState(state); + return json(res, 200, { retried: c, ...queueSummary(state) }); + } + if (req.method === 'POST' && url.pathname === '/api/queue/clear-done') { + const before = state.jobs.length; + state.jobs = state.jobs.filter(j => j.status !== 'done'); + addLog(state, `${before - state.jobs.length} job selesai dibersihkan.`); + await saveState(state); + return json(res, 200, { removed: before - state.jobs.length, ...queueSummary(state) }); + } + if (req.method === 'POST' && url.pathname === '/api/queue/clear-failed') { + const before = state.jobs.length; + state.jobs = state.jobs.filter(j => j.status !== 'failed' && j.status !== 'cancelled'); + addLog(state, `${before - state.jobs.length} job gagal dibersihkan.`); + await saveState(state); + return json(res, 200, { removed: before - state.jobs.length, ...queueSummary(state) }); + } + if (req.method === 'POST' && url.pathname === '/api/queue/reorder') { + const b = await body(req); + const order = Array.isArray(b.order) ? b.order : []; + const map = new Map(state.jobs.map(j => [j.id, j])); + state.jobs = [...order.map(id => map.get(id)).filter(Boolean), ...state.jobs.filter(j => !order.includes(j.id))]; + addLog(state, 'urutan antrian diperbarui.'); + await saveState(state); + return json(res, 200, queueSummary(state)); + } + if (req.method === 'POST' && url.pathname === '/api/logs/clear') { + state.logs = ['Log dibersihkan.']; + await saveState(state); + return json(res, 200, { ok: true, logs: state.logs }); + } + if (req.method === 'POST' && url.pathname === '/api/render/validate') { + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const validation = validateRenderConfig(config, { + visual: b.input?.visual, + audio: b.input?.audio, + outputDir: b.outputDir || config.input?.output, + }); + const estimate = estimateRender(config, [], []); + return json(res, validation.ok ? 200 : 400, { + ok: validation.ok, + errors: validation.errors, + warnings: validation.warnings, + estimate, + error: validation.errors.join(' '), + }); + } + const move = matchRoute(req, url, 'POST', /^\/api\/jobs\/([^/]+)\/move$/); + if (move) { + const b = await body(req); + const idx = state.jobs.findIndex(j => j.id === move[1]); + if (idx < 0) return json(res, 404, { error: 'job tidak ditemukan' }); + const [job] = state.jobs.splice(idx, 1); + let to = + b.to === 'top' + ? 0 + : b.to === 'bottom' + ? state.jobs.length + : Math.max(0, Math.min(state.jobs.length, Number(b.to ?? idx))); + state.jobs.splice(to, 0, job); + await saveState(state); + return json(res, 200, queueSummary(state)); + } + const dup = url.pathname.match(/^\/api\/jobs\/([^/]+)\/duplicate$/); + if (req.method === 'POST' && dup) { + const job = state.jobs.find(j => j.id === dup[1]); + if (!job) return json(res, 404, { error: 'job tidak ditemukan' }); + const cloned = cloneJob(job); + state.jobs.push(cloned); + addLog(state, `job diduplikasi: ${job.title}`); + await saveState(state); + return json(res, 201, cloned); + } + const removeJob = url.pathname.match(/^\/api\/jobs\/([^/]+)\/remove$/); + if (req.method === 'POST' && removeJob) { + const idx = state.jobs.findIndex(j => j.id === removeJob[1]); + if (idx < 0) return json(res, 404, { error: 'job tidak ditemukan' }); + const [job] = state.jobs.splice(idx, 1); + addLog(state, `job dihapus: ${job.title}`); + await saveState(state); + return json(res, 200, { removed: job.id }); + } + if (req.method === 'GET' && url.pathname === '/api/jobs') + return json(res, 200, { jobs: state.jobs, logs: state.logs }); + if (req.method === 'POST' && url.pathname === '/api/jobs') { + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const validation = validateRenderConfig(config, { + visual: b.input?.visual, + audio: b.input?.audio, + outputDir: b.outputDir || config.input.output, + }); + if (!validation.ok) + return json(res, 400, { + ok: false, + error: validation.errors.join(' '), + errors: validation.errors, + warnings: validation.warnings, + }); + const job = { + id: safeId('job'), + projectId: state.activeProjectId, + title: b.title || config.input.title || `Render ${state.jobs.length + 1}`, + status: 'standby', + progress: 0, + speed: '0.0x', + output: b.output || '', + outputDir: b.outputDir || config.input.output, + input: b.input || {}, + config, + createdAt: new Date().toISOString(), + validationWarnings: validation.warnings, + }; + state.jobs.push(job); + addLog(state, `${job.title} masuk antrian.`); + await saveState(state); + return json(res, 201, job); + } + if (req.method === 'POST' && url.pathname === '/api/jobs/batch') { + const b = await body(req); + const files = Array.isArray(b.items) ? b.items : []; + const created = []; + const skipped = []; + for (const item of files) { + const config = deepMerge(activeConfig(state), item.config || {}); + const input = item.input || { visual: item.visual, audio: item.audio, lyrics: item.lyrics }; + const validation = validateRenderConfig(config, { + visual: input.visual, + audio: input.audio, + outputDir: item.outputDir || config.input.output, + }); + if (!validation.ok) { + skipped.push({ title: item.title || item.audio || item.visual || 'Batch', errors: validation.errors }); + continue; + } + const job = { + id: safeId('job'), + projectId: state.activeProjectId, + title: item.title || path.basename(item.audio || item.visual || `Batch ${created.length + 1}`), + status: 'standby', + progress: 0, + speed: '0.0x', + outputDir: item.outputDir || config.input.output, + input, + config, + createdAt: new Date().toISOString(), + validationWarnings: validation.warnings, + }; + state.jobs.push(job); + created.push(job); + } + addLog( + state, + `${created.length} job batch dibuat${skipped.length ? `, ${skipped.length} dilewati validasi` : ''}.`, + ); + await saveState(state); + return json(res, 201, { created, skipped }); + } + const start = url.pathname.match(/^\/api\/jobs\/([^/]+)\/start$/); + if (req.method === 'POST' && start) return json(res, 202, await startJob(start[1], processes)); + if (req.method === 'POST' && url.pathname === '/api/jobs/start-next') { + const next = state.jobs.find(j => j.status === 'standby' || j.status === 'failed'); + if (!next) return json(res, 404, { error: 'tidak ada job standby' }); + return json(res, 202, await startJob(next.id, processes)); + } + const cancel = url.pathname.match(/^\/api\/jobs\/([^/]+)\/cancel$/); + if (req.method === 'POST' && cancel) { + const id = cancel[1]; + const child = processes.get(id); + if (child) child.kill('SIGTERM'); + const j = state.jobs.find(x => x.id === id); + if (j) { + j.status = 'cancelled'; + j.error = 'Dibatalkan user'; + } + addLog(state, `job dibatalkan: ${id}`); + await saveState(state); + return json(res, 200, { ok: true }); + } + if (req.method === 'POST' && url.pathname === '/api/jobs/reset') { + for (const child of processes.values()) child.kill('SIGTERM'); + processes.clear(); + state.jobs = []; + addLog(state, 'antrian direset.'); + await saveState(state); + return json(res, 200, { ok: true }); + } + // --- STUB ENDPOINTS (belum implementasi penuh, mencegah 404) --- + if (req.method === 'POST' && url.pathname === '/api/audio/preview') { + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const audio = config.input?.audio; + if (!audio) return json(res, 400, { error: 'File audio belum dipilih' }); + const info = await ffprobeInfo(audio); + if (!info.ok || !info.hasAudio) return json(res, 400, { error: 'File audio tidak valid' }); + const previewDir = path.join(workspaceDir, 'previews'); + await mkdir(previewDir, { recursive: true }); + const duration = Math.max(2, Math.min(30, Number(b.duration || 10))); + const output = path.join(previewDir, `${safeId('audio-preview')}.mp3`); + const preview = await runCmd(FFMPEG, [ + '-y', + '-ss', + '0', + '-t', + String(duration), + '-i', + audio, + '-vn', + '-c:a', + 'libmp3lame', + '-b:a', + '192k', + output, + ]); + if (!preview.ok) return json(res, 500, { error: preview.stderr || 'Gagal membuat preview audio' }); + return json(res, 200, { + ok: true, + url: `/api/media/file?path=${encodeURIComponent(output)}`, + output, + duration, + stub: false, + }); + } + if (req.method === 'POST' && url.pathname === '/api/lyrics/fetch') { + const b = await body(req); + return json(res, 200, { + ok: false, + lyrics: '', + source: b.source || 'none', + stub: true, + message: 'Fitur fetch lyrics online belum tersedia. Tempel lirik manual atau gunakan file LRC/SRT.', + }); + } + if (req.method === 'POST' && url.pathname === '/api/lyrics/transcribe') { + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const audio = b.audio || config.input?.audio; + const lyricFile = config.lyrics?.file; + if (lyricFile && existsSync(lyricFile)) { + const parsed = await readLyricsFile(lyricFile); + const text = parsed.map(r => r.text).join('\n'); + return json(res, 200, { + ok: true, + text, + segments: parsed, + confidence: 0.85, + source: 'file-fallback', + message: `Parsed ${parsed.length} baris dari file lirik yang sudah ada.`, + }); + } + if (!audio) return json(res, 400, { error: 'File audio belum dipilih dan file lirik tidak tersedia.' }); + return json(res, 200, { + ok: false, + text: '', + segments: [], + confidence: 0, + message: 'AI transcription engine (Whisper) belum terinstall. Gunakan file LRC/SRT atau tempel lirik manual.', + }); + } + if (req.method === 'POST' && url.pathname === '/api/lyrics/smart-sync') { + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const audio = config.input?.audio; + const lines = b.lines || []; + if (!lines.length) return json(res, 400, { error: 'Belum ada baris lirik untuk disinkronisasi.' }); + const info = audio ? await ffprobeInfo(audio) : { duration: 0 }; + const duration = info.duration || config.target?.duration || 180; + const waveform = audio ? await waveformData(audio, Math.min(duration, 90), 180) : { peaks: [] }; + const beats = detectBeatsFromPeaks(waveform.peaks, waveform.seconds || Math.min(duration, 90)); + const text = lines.map(r => r.text || '').join('\n'); + const syncedLines = autoAlignLyrics(text, duration, config, beats); + const quality = scoreLyricTimeline(syncedLines, duration, config, beats); + return json(res, 200, { + ok: true, + syncedLines, + accuracy: Math.min(0.95, (quality.score || 0) / 100), + beats, + quality, + duration, + }); + } + if (req.method === 'POST' && url.pathname === '/api/lyrics/detect-language') { + const b = await body(req); + const text = b.text || (b.lines || []).map(r => r.text || '').join(' '); + if (!text.trim()) return json(res, 400, { error: 'Tidak ada teks untuk dideteksi.' }); + const arabic = /[؀-ۿ]/.test(text); + // eslint-disable-next-line no-irregular-whitespace + const cjk = /[ -鿿가-힯]/.test(text); + const latin = /[a-zA-Z]/.test(text); + const indo = + /\b(dan|yang|di|ke|dari|untuk|dengan|adalah|ini|itu|atau|tidak|akan|sudah|juga|bisa|saya|kamu|kami)\b/i.test( + text, + ); + let lang = 'en'; + let name = 'English'; + let conf = 0.5; + if (arabic) { + lang = 'ar'; + name = 'Arabic'; + conf = 0.8; + } else if (cjk) { + lang = 'ja'; + name = 'Japanese/CJK'; + conf = 0.7; + } else if (indo) { + lang = 'id'; + name = 'Indonesian'; + conf = 0.75; + } else if (latin) { + lang = 'en'; + name = 'English'; + conf = 0.6; + } + return json(res, 200, { ok: true, detectedLanguage: lang, languageName: name, confidence: conf }); + } + if (req.method === 'POST' && url.pathname === '/api/lyrics/transliterate') { + const b = await body(req); + const text = b.text || ''; + if (!text.trim()) return json(res, 400, { error: 'Tidak ada teks untuk ditransliterasi.' }); + const transliterated = text + .normalize('NFD') + .replace(/\p{M}+/gu, '') + .replace(/[؀-ۿ]+/g, m => `[${m}]`) + // eslint-disable-next-line no-irregular-whitespace + .replace(/[ -鿿]+/g, m => `[${m}]`); + return json(res, 200, { + ok: true, + transliterated, + from: b.from || 'auto', + style: b.style || 'romanized', + message: 'Transliterasi dasar (strip diacritics). Untuk hasil lebih akurat, gunakan engine khusus.', + }); + } + if (req.method === 'POST' && url.pathname === '/api/lyrics/load-preview') { + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const audio = config.input?.audio; + const info = audio ? await ffprobeInfo(audio) : { duration: 0 }; + const waveform = audio ? await waveformData(audio, Math.min(info.duration || 30, 60), 120) : { peaks: [] }; + const beats = detectBeatsFromPeaks(waveform.peaks, waveform.seconds || 30); + return json(res, 200, { ok: true, duration: info.duration || 0, waveform, beats }); + } + if (req.method === 'POST' && url.pathname === '/api/branding/bumper/preview') { + const b = await body(req); + const config = deepMerge(activeConfig(state), b.config || {}); + const bumper = config.branding?.bumperVideo; + if (!bumper) return json(res, 400, { error: 'File bumper belum dipilih' }); + const info = await ffprobeInfo(bumper); + if (!info.ok) return json(res, 400, { error: `File bumper tidak valid: ${info.error || bumper}` }); + return json(res, 200, { + ok: true, + url: `/api/media/file?path=${encodeURIComponent(bumper)}`, + duration: info.duration || 0, + hasVideo: info.hasVideo, + hasAudio: info.hasAudio, + resolution: info.streams?.find(s => s.type === 'video') + ? `${info.streams.find(s => s.type === 'video').width}x${info.streams.find(s => s.type === 'video').height}` + : null, + }); + } + // --- END STUB ENDPOINTS --- + return json(res, 404, { error: 'not found' }); } catch (error) { const status = Number(error?.status || 500); if (status >= 500) console.error('API error:', error); - return sendError(res, status, error?.message || 'Terjadi kesalahan server'); + return ctx.http.sendCaughtError(res, error); } } diff --git a/backend/server.mjs b/backend/server.mjs index 572073b..9f84ebf 100644 --- a/backend/server.mjs +++ b/backend/server.mjs @@ -9,7 +9,7 @@ const processes = new Map(); const loopJobs = new Map(); const { runQueueLoop, requestStop } = createQueueRunner(processes); const httpUtils = createHttpUtils({ allowedOrigin: ALLOWED_ORIGIN, maxBodyBytes: MAX_BODY_BYTES }); -const { json, sendError } = httpUtils; +const { json } = httpUtils; const ctx = { processes, @@ -42,10 +42,10 @@ try { PIDIOFORGE_DATA_DIR: process.env.PIDIOFORGE_DATA_DIR, PIDIOFORGE_LOG_DIR: process.env.PIDIOFORGE_LOG_DIR, }); - + await initHistory(); console.log('History database initialized successfully'); - + server.listen(PORT, '127.0.0.1', () => { console.log(`PidioForge Production API: http://127.0.0.1:${PORT}`); console.log('Backend server ready'); @@ -56,14 +56,14 @@ try { console.error('Error message:', error.message); console.error('Error code:', error.code); console.error('Error stack:', error.stack); - + if (error.message?.includes('better-sqlite3') || error.code === 'ERR_DLOPEN_FAILED') { console.error('\n=== SQLite Native Module Error ==='); console.error('This is likely a Node.js ABI version mismatch.'); console.error('The better-sqlite3 module needs to be rebuilt for this Electron version.'); console.error('Expected NODE_MODULE_VERSION:', process.versions.modules); } - + process.exitCode = 1; process.exit(1); } diff --git a/backend/state.mjs b/backend/state.mjs index b9ad24d..f652766 100644 --- a/backend/state.mjs +++ b/backend/state.mjs @@ -5,7 +5,7 @@ import { defaultConfig, defaultState, deepMerge, workspaceDir, dbPath } from './ let writeChain = Promise.resolve(); let stateUpdateChain = Promise.resolve(); -export const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); +export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); export async function replaceFileWithRetry(tmpPath, finalPath, attempts = 8) { let lastError; @@ -30,10 +30,22 @@ export async function replaceFileWithRetry(tmpPath, finalPath, attempts = 8) { export async function loadState() { await mkdir(workspaceDir, { recursive: true }); - if (!existsSync(dbPath)) { await saveState(defaultState); return structuredClone(defaultState); } + if (!existsSync(dbPath)) { + await saveState(defaultState); + return structuredClone(defaultState); + } let state; - try { state = JSON.parse(await readFile(dbPath, 'utf8')); } - catch { try { await rename(dbPath, `${dbPath}.corrupt-${Date.now()}`); } catch {} state = structuredClone(defaultState); await saveState(state); } + try { + state = JSON.parse(await readFile(dbPath, 'utf8')); + } catch { + try { + await rename(dbPath, `${dbPath}.corrupt-${Date.now()}`); + } catch { + /* ignore rename */ + } + state = structuredClone(defaultState); + await saveState(state); + } state.projects ||= defaultState.projects; state.presets ||= defaultState.presets; state.jobs ||= []; @@ -47,29 +59,47 @@ export async function loadState() { export async function saveState(state) { const data = JSON.stringify(state, null, 2); - writeChain = writeChain.then(async () => { - await mkdir(workspaceDir, { recursive: true }); - const tmpPath = `${dbPath}.${process.pid}.${Date.now()}.tmp`; - const backupPath = `${dbPath}.bak`; - await writeFile(tmpPath, data); - if (existsSync(dbPath)) await copyFile(dbPath, backupPath).catch(() => {}); - await replaceFileWithRetry(tmpPath, dbPath); - }).catch(async (error) => { - await writeFile(`${dbPath}.failed-${Date.now()}.json`, data).catch(() => {}); - throw error; - }); + writeChain = writeChain + .then(async () => { + await mkdir(workspaceDir, { recursive: true }); + const tmpPath = `${dbPath}.${process.pid}.${Date.now()}.tmp`; + const backupPath = `${dbPath}.bak`; + await writeFile(tmpPath, data); + if (existsSync(dbPath)) await copyFile(dbPath, backupPath).catch(() => {}); + await replaceFileWithRetry(tmpPath, dbPath); + }) + .catch(async error => { + await writeFile(`${dbPath}.failed-${Date.now()}.json`, data).catch(() => {}); + throw error; + }); return writeChain; } export async function updateState(mutator) { - stateUpdateChain = stateUpdateChain.then(async () => { const state = await loadState(); await mutator(state); await saveState(state); return state; }); + stateUpdateChain = stateUpdateChain.then(async () => { + const state = await loadState(); + await mutator(state); + await saveState(state); + return state; + }); return stateUpdateChain; } -export function activeProject(state) { return state.projects.find(p => p.id === state.activeProjectId) || state.projects[0]; } +export function activeProject(state) { + return state.projects.find(p => p.id === state.activeProjectId) || state.projects[0]; +} -export function activeConfig(state) { return activeProject(state)?.config || defaultConfig; } +export function activeConfig(state) { + return activeProject(state)?.config || defaultConfig; +} -export function addLog(state, msg) { const time = new Date().toLocaleTimeString('id-ID', { hour12: false }); state.logs.push(`${time} ${msg}`); state.logs = state.logs.slice(-500); } +export function addLog(state, msg) { + const time = new Date().toLocaleTimeString('id-ID', { hour12: false }); + state.logs.push(`${time} ${msg}`); + state.logs = state.logs.slice(-500); +} -export function jlog(state, line) { if (String(line).length < 900) addLog(state, line); else addLog(state, String(line).slice(0, 900) + '...'); } +export function jlog(state, line) { + if (String(line).length < 900) addLog(state, line); + else addLog(state, String(line).slice(0, 900) + '...'); +} diff --git a/backend/target-engine.mjs b/backend/target-engine.mjs index 5dc9788..d9e297c 100644 --- a/backend/target-engine.mjs +++ b/backend/target-engine.mjs @@ -4,15 +4,30 @@ import { isAudio, isImage, isVideo } from './media-utils.mjs'; import { normalizeName, similarity, pickByOrder } from './media-scanner.mjs'; export function outputName(title, config, index = 1) { - const safeTitle = String(title || `render-${index}`).replace(/[\\/:*?"<>|]/g, '-').replace(/\s+/g, ' ').trim(); - const d = new Date(); const date = d.toISOString().slice(0,10).replace(/-/g,''); const time = d.toTimeString().slice(0,8).replace(/:/g,''); const num = String(index).padStart(3, '0'); - return String(config.target?.outputPattern || '{title}-{date}-{num}').replaceAll('{title}', safeTitle).replaceAll('{date}', date).replaceAll('{time}', time).replaceAll('{num}', num) + '.mp4'; + const safeTitle = String(title || `render-${index}`) + .replace(/[\\/:*?"<>|]/g, '-') + .replace(/\s+/g, ' ') + .trim(); + const d = new Date(); + const date = d.toISOString().slice(0, 10).replace(/-/g, ''); + const time = d.toTimeString().slice(0, 8).replace(/:/g, ''); + const num = String(index).padStart(3, '0'); + return ( + String(config.target?.outputPattern || '{title}-{date}-{num}') + .replaceAll('{title}', safeTitle) + .replaceAll('{date}', date) + .replaceAll('{time}', time) + .replaceAll('{num}', num) + '.mp4' + ); } export function ensureUniqueOutput(fullPath, overwrite) { if (overwrite || !existsSync(fullPath)) return fullPath; - const dir = path.dirname(fullPath); const extn = path.extname(fullPath); const stem = path.basename(fullPath, extn); - let i = 2; let candidate = path.join(dir, `${stem}-${i}${extn}`); + const dir = path.dirname(fullPath); + const extn = path.extname(fullPath); + const stem = path.basename(fullPath, extn); + let i = 2; + let candidate = path.join(dir, `${stem}-${i}${extn}`); while (existsSync(candidate)) candidate = path.join(dir, `${stem}-${++i}${extn}`); return candidate; } @@ -29,10 +44,19 @@ export function validateRenderConfig(config = {}, options = {}) { const height = Number(target.height || String(target.resolution || '').split('x')[1] || 0); const fps = Number(target.fps || 0); const duration = Number(target.duration || 0); + const gallery = config.spectrum?.gallery; + const galleryImages = (gallery?.enabled ? gallery.images || [] : []).filter( + f => f && existsSync(f) && (isImage(f) || isVideo(f)), + ); + const galleryActive = Boolean(gallery?.enabled && galleryImages.length > 0); - if (!visual) errors.push('File visual belum dipilih.'); + if (galleryActive) { + if (galleryImages.length < (gallery.images || []).filter(Boolean).length) + warnings.push('Beberapa file gallery tidak ditemukan atau format tidak didukung; hanya file valid yang dipakai.'); + } else if (!visual) errors.push('File visual belum dipilih.'); else if (!existsSync(visual)) errors.push(`File visual tidak ditemukan: ${visual}`); - else if (!isVideo(visual) && !isImage(visual)) errors.push(`Format visual belum didukung: ${path.extname(visual) || visual}`); + else if (!isVideo(visual) && !isImage(visual)) + errors.push(`Format visual belum didukung: ${path.extname(visual) || visual}`); if (!audio) errors.push('File audio belum dipilih.'); else if (!existsSync(audio)) errors.push(`File audio tidak ditemukan: ${audio}`); @@ -44,21 +68,55 @@ export function validateRenderConfig(config = {}, options = {}) { if (outputDir) { const dir = path.resolve(outputDir); const parent = path.dirname(dir); - if (!existsSync(dir) && !existsSync(parent)) warnings.push(`Folder output belum ada dan parent tidak ditemukan: ${dir}`); + if (!existsSync(dir) && !existsSync(parent)) + warnings.push(`Folder output belum ada dan parent tidak ditemukan: ${dir}`); } if (config.spectrum?.enabled !== false && !audio) warnings.push('Spectrum aktif tetapi audio belum siap.'); - if (config.branding?.logoEnabled && config.branding?.logo && !existsSync(config.branding.logo)) warnings.push('Logo aktif tetapi file logo tidak ditemukan.'); - if (config.overlay?.overlayEnabled && config.overlay?.overlayFile && !existsSync(config.overlay.overlayFile)) warnings.push('Overlay aktif tetapi file overlay tidak ditemukan.'); - if (config.overlay?.videoParticle && config.overlay?.particleFile && !existsSync(config.overlay.particleFile)) warnings.push('Particle aktif tetapi file particle tidak ditemukan.'); + if (config.branding?.logoEnabled && config.branding?.logo && !existsSync(config.branding.logo)) + warnings.push('Logo aktif tetapi file logo tidak ditemukan.'); + if (config.overlay?.overlayEnabled && config.overlay?.overlayFile && !existsSync(config.overlay.overlayFile)) + warnings.push('Overlay aktif tetapi file overlay tidak ditemukan.'); + if (config.overlay?.videoParticle && config.overlay?.particleFile && !existsSync(config.overlay.particleFile)) + warnings.push('Particle aktif tetapi file particle tidak ditemukan.'); const lyricPos = String(config.lyrics?.position || 'Bawah'); const spectrumPos = String(config.spectrum?.position || 'Bawah'); const lowerThirdPos = String(config.overlay?.lowerThirdPosition || 'Bawah'); const timestampPos = String(config.overlay?.timestampPosition || 'Kiri Atas'); - if (config.lyrics?.enabled !== false && config.spectrum?.enabled !== false && lyricPos === 'Bawah' && spectrumPos === 'Bawah') warnings.push('Lirik dan spectrum sama-sama di bawah; pertimbangkan naikkan salah satunya.'); - if (config.overlay?.lowerThirdEnabled && config.lyrics?.enabled !== false && lowerThirdPos === 'Bawah' && lyricPos === 'Bawah') warnings.push('Lower third dan lirik sama-sama di bawah; ada risiko tabrakan.'); - if (config.overlay?.lowerThirdEnabled && config.spectrum?.enabled !== false && lowerThirdPos === 'Bawah' && spectrumPos === 'Bawah') warnings.push('Lower third dan spectrum sama-sama di bawah; hasil bisa terlalu padat.'); - if (config.overlay?.timestamp && config.spectrum?.nowPlaying && timestampPos.includes('Atas') && String(config.spectrum?.nowPlayingPosition || 'Atas') === 'Atas') warnings.push('Timestamp dan now playing sama-sama di area atas; cek jarak aman.'); - if (height > width && config.overlay?.lowerThirdEnabled && lowerThirdPos === 'Bawah' && Number(config.overlay?.lowerThirdDuration || 0) > 6) warnings.push('Lower third cukup lama untuk format vertical; pertimbangkan durasi lebih pendek.'); + if ( + config.lyrics?.enabled !== false && + config.spectrum?.enabled !== false && + lyricPos === 'Bawah' && + spectrumPos === 'Bawah' + ) + warnings.push('Lirik dan spectrum sama-sama di bawah; pertimbangkan naikkan salah satunya.'); + if ( + config.overlay?.lowerThirdEnabled && + config.lyrics?.enabled !== false && + lowerThirdPos === 'Bawah' && + lyricPos === 'Bawah' + ) + warnings.push('Lower third dan lirik sama-sama di bawah; ada risiko tabrakan.'); + if ( + config.overlay?.lowerThirdEnabled && + config.spectrum?.enabled !== false && + lowerThirdPos === 'Bawah' && + spectrumPos === 'Bawah' + ) + warnings.push('Lower third dan spectrum sama-sama di bawah; hasil bisa terlalu padat.'); + if ( + config.overlay?.timestamp && + config.spectrum?.nowPlaying && + timestampPos.includes('Atas') && + String(config.spectrum?.nowPlayingPosition || 'Atas') === 'Atas' + ) + warnings.push('Timestamp dan now playing sama-sama di area atas; cek jarak aman.'); + if ( + height > width && + config.overlay?.lowerThirdEnabled && + lowerThirdPos === 'Bawah' && + Number(config.overlay?.lowerThirdDuration || 0) > 6 + ) + warnings.push('Lower third cukup lama untuk format vertical; pertimbangkan durasi lebih pendek.'); return { ok: errors.length === 0, errors, warnings }; } @@ -72,13 +130,20 @@ export function outputCollisionReport(pairs = [], config = {}) { const duplicateInBatch = seen.has(requested); seen.set(requested, true); const finalPath = overwrite ? requested : ensureUniqueOutput(requested, false); - const status = overwrite && existing ? 'will-overwrite' : duplicateInBatch ? 'duplicate-in-batch' : existing ? 'auto-rename' : 'safe'; + const status = + overwrite && existing + ? 'will-overwrite' + : duplicateInBatch + ? 'duplicate-in-batch' + : existing + ? 'auto-rename' + : 'safe'; return { title: p.title, requested, finalPath, existing, duplicateInBatch, overwrite, status }; }); } export function targetRiskSummary(config, files = [], pairs = [], collisions = []) { - const invalidFiles = files.filter(f => ['Risk','Broken'].includes(f.health?.level)).length; + const invalidFiles = files.filter(f => ['Risk', 'Broken'].includes(f.health?.level)).length; const brokenFiles = files.filter(f => f.health?.level === 'Broken').length; const lowConfidencePairs = pairs.filter(p => Number(p.confidence || 0) < 60).length; const fallbackPairs = pairs.filter(p => /fallback/i.test(p.pairReason || '')).length; @@ -91,8 +156,21 @@ export function targetRiskSummary(config, files = [], pairs = [], collisions = [ if (fallbackPairs) warnings.push(`${fallbackPairs} pair memakai fallback visual`); if (collisionCount) warnings.push(`${collisionCount} output collision/rename`); if (noLyrics && config.lyrics?.enabled) warnings.push(`${noLyrics} pair tanpa lirik`); - const score = Math.max(0, 100 - brokenFiles*25 - invalidFiles*10 - lowConfidencePairs*8 - fallbackPairs*6 - collisionCount*5); - return { score, level: score >= 85 ? 'Ready' : score >= 65 ? 'Check' : 'Risk', invalidFiles, brokenFiles, lowConfidencePairs, fallbackPairs, noLyrics, collisionCount, warnings }; + const score = Math.max( + 0, + 100 - brokenFiles * 25 - invalidFiles * 10 - lowConfidencePairs * 8 - fallbackPairs * 6 - collisionCount * 5, + ); + return { + score, + level: score >= 85 ? 'Ready' : score >= 65 ? 'Check' : 'Risk', + invalidFiles, + brokenFiles, + lowConfidencePairs, + fallbackPairs, + noLyrics, + collisionCount, + warnings, + }; } export function autoTargetTune(config, files = [], pairs = []) { @@ -104,60 +182,167 @@ export function autoTargetTune(config, files = [], pairs = []) { const portrait = height > width; const square = Math.abs(width - height) < Math.max(width, height) * 0.12; const jobCount = pairs.length || files.filter(f => f.type === 'audio').length || 1; - const avgDuration = Math.round((pairs.map(p => files.find(f => f.path === p.audio)?.duration).filter(Boolean).reduce((a,b)=>a+b,0) || Number(config.target?.duration || 0) * jobCount || 180) / Math.max(1, pairs.map(p => files.find(f => f.path === p.audio)?.duration).filter(Boolean).length || 1)); - const target = portrait ? { resolution: '1080x1920', width: 1080, height: 1920, aspect: '9:16', bitrate: jobCount > 20 ? '5500k' : '7000k' } : square ? { resolution: '1080x1080', width: 1080, height: 1080, aspect: '1:1', bitrate: '6000k' } : { resolution: width >= 1600 ? '1920x1080' : '1280x720', width: width >= 1600 ? 1920 : 1280, height: width >= 1600 ? 1080 : 720, aspect: '16:9', bitrate: width >= 1600 ? '8000k' : '4500k' }; + const avgDuration = Math.round( + (pairs + .map(p => files.find(f => f.path === p.audio)?.duration) + .filter(Boolean) + .reduce((a, b) => a + b, 0) || + Number(config.target?.duration || 0) * jobCount || + 180) / Math.max(1, pairs.map(p => files.find(f => f.path === p.audio)?.duration).filter(Boolean).length || 1), + ); + const target = portrait + ? { resolution: '1080x1920', width: 1080, height: 1920, aspect: '9:16', bitrate: jobCount > 20 ? '5500k' : '7000k' } + : square + ? { resolution: '1080x1080', width: 1080, height: 1080, aspect: '1:1', bitrate: '6000k' } + : { + resolution: width >= 1600 ? '1920x1080' : '1280x720', + width: width >= 1600 ? 1920 : 1280, + height: width >= 1600 ? 1080 : 720, + aspect: '16:9', + bitrate: width >= 1600 ? '8000k' : '4500k', + }; target.duration = Number(config.target?.duration || 0) || Math.max(3, avgDuration || 180); target.fps = jobCount > 30 ? 24 : 30; target.quality = jobCount > 20 ? 'fast' : 'balanced'; target.hardwareAccel = 'auto'; target.outputPattern = config.target?.outputPattern || '{title}-{date}-{num}'; - const reasons = [`visual ${portrait ? 'portrait' : square ? 'square' : 'landscape'}`, `${jobCount} job`, `durasi rata-rata ${avgDuration || target.duration}s`]; + const reasons = [ + `visual ${portrait ? 'portrait' : square ? 'square' : 'landscape'}`, + `${jobCount} job`, + `durasi rata-rata ${avgDuration || target.duration}s`, + ]; return { patch: { target }, reasons }; } export function pairMedia(files, config = {}) { - const visuals = files.filter(f => f.type === 'video' || f.type === 'image'); const audios = files.filter(f => f.type === 'audio'); const lyrics = files.filter(f => f.type === 'lyrics'); - const mode = config.input?.pairMode || 'by-name'; const ignore = config.input?.ignoreWords || ''; const fallbackVisual = config.input?.visual || visuals[0]?.path || ''; + const visuals = files.filter(f => f.type === 'video' || f.type === 'image'); + const audios = files.filter(f => f.type === 'audio'); + const lyrics = files.filter(f => f.type === 'lyrics'); + const mode = config.input?.pairMode || 'by-name'; + const ignore = config.input?.ignoreWords || ''; + const fallbackVisual = config.input?.visual || visuals[0]?.path || ''; const visualByPath = new Map(visuals.map(v => [v.path, v])); if (mode === 'one-audio-all-visual') { const audio = config.input?.audio || audios[0]?.path || ''; - return visuals.map((visual, i) => { const title = path.basename(visual.name).replace(/\.[^.]+$/, ''); return { title, visual: visual.path, audio, lyrics: '', index: i + 1, outputName: outputName(title, config, i + 1), confidence: 80, pairReason: 'one audio untuk semua visual', lyricConfidence: 0 }; }).filter(x => x.audio); + return visuals + .map((visual, i) => { + const title = path.basename(visual.name).replace(/\.[^.]+$/, ''); + return { + title, + visual: visual.path, + audio, + lyrics: '', + index: i + 1, + outputName: outputName(title, config, i + 1), + confidence: 80, + pairReason: 'one audio untuk semua visual', + lyricConfidence: 0, + }; + }) + .filter(x => x.audio); } return audios.map((audio, i) => { - const aBase = normalizeName(audio.name, ignore); let visual = fallbackVisual; let lyric = ''; let confidence = 45; let pairReason = 'fallback visual'; let lyricConfidence = 0; let lyricReason = 'lirik kosong'; - if (mode === 'by-order') { visual = pickByOrder(visuals, i) || fallbackVisual; confidence = visual ? 75 : 0; pairReason = 'by-order'; } - else if (mode === 'random-visual') { visual = pickByOrder([...visuals].sort(() => Math.random() - 0.5), i) || fallbackVisual; confidence = visual ? 65 : 0; pairReason = 'random visual'; } - else { - const exact = visuals.find(v => normalizeName(v.name, ignore) === aBase); const fuzzy = visuals.map(v => ({ v, score: similarity(normalizeName(v.name, ignore), aBase) })).sort((a,b)=>b.score-a.score)[0]; - if (exact) { visual = exact.path; confidence = 100; pairReason = 'exact name match'; } - else if (fuzzy?.score >= 0.34) { visual = fuzzy.v.path; confidence = Math.round(fuzzy.score * 100); pairReason = `fuzzy match ${(fuzzy.score*100).toFixed(0)}%`; } - const lExact = lyrics.find(l => normalizeName(l.name, ignore) === aBase); const lFuzzy = lyrics.map(l => ({ l, score: similarity(normalizeName(l.name, ignore), aBase) })).sort((a,b)=>b.score-a.score)[0]; - if (lExact) { lyric = lExact.path; lyricConfidence = 100; lyricReason = 'exact lirik'; } - else if (lFuzzy?.score >= 0.34) { lyric = lFuzzy.l.path; lyricConfidence = Math.round(lFuzzy.score * 100); lyricReason = `fuzzy lirik ${(lFuzzy.score*100).toFixed(0)}%`; } + const aBase = normalizeName(audio.name, ignore); + let visual = fallbackVisual; + let lyric = ''; + let confidence = 45; + let pairReason = 'fallback visual'; + let lyricConfidence = 0; + let lyricReason = 'lirik kosong'; + if (mode === 'by-order') { + visual = pickByOrder(visuals, i) || fallbackVisual; + confidence = visual ? 75 : 0; + pairReason = 'by-order'; + } else if (mode === 'random-visual') { + visual = + pickByOrder( + [...visuals].sort(() => Math.random() - 0.5), + i, + ) || fallbackVisual; + confidence = visual ? 65 : 0; + pairReason = 'random visual'; + } else { + const exact = visuals.find(v => normalizeName(v.name, ignore) === aBase); + const fuzzy = visuals + .map(v => ({ v, score: similarity(normalizeName(v.name, ignore), aBase) })) + .sort((a, b) => b.score - a.score)[0]; + if (exact) { + visual = exact.path; + confidence = 100; + pairReason = 'exact name match'; + } else if (fuzzy?.score >= 0.34) { + visual = fuzzy.v.path; + confidence = Math.round(fuzzy.score * 100); + pairReason = `fuzzy match ${(fuzzy.score * 100).toFixed(0)}%`; + } + const lExact = lyrics.find(l => normalizeName(l.name, ignore) === aBase); + const lFuzzy = lyrics + .map(l => ({ l, score: similarity(normalizeName(l.name, ignore), aBase) })) + .sort((a, b) => b.score - a.score)[0]; + if (lExact) { + lyric = lExact.path; + lyricConfidence = 100; + lyricReason = 'exact lirik'; + } else if (lFuzzy?.score >= 0.34) { + lyric = lFuzzy.l.path; + lyricConfidence = Math.round(lFuzzy.score * 100); + lyricReason = `fuzzy lirik ${(lFuzzy.score * 100).toFixed(0)}%`; + } + } + if (!visual) { + confidence = 0; + pairReason = 'visual tidak ditemukan'; } - if (!visual) { confidence = 0; pairReason = 'visual tidak ditemukan'; } const vHealth = visualByPath.get(visual)?.health; if (vHealth?.level === 'Risk') confidence = Math.max(0, confidence - 15); if (vHealth?.level === 'Broken') confidence = Math.max(0, confidence - 40); const title = path.basename(audio.name).replace(/\.[^.]+$/, ''); - return { title, visual, audio: audio.path, lyrics: lyric, index: i + 1, outputName: outputName(title, config, i + 1), confidence, pairReason, lyricConfidence, lyricReason }; + return { + title, + visual, + audio: audio.path, + lyrics: lyric, + index: i + 1, + outputName: outputName(title, config, i + 1), + confidence, + pairReason, + lyricConfidence, + lyricReason, + }; }); } export function estimateRender(config, pairs = [], files = []) { - const bitrateNum = Number(String(config.target?.bitrate || '4500k').replace(/k/i,'')) || 4500; - const audioBitrate = Number(String(config.audio?.audioBitrate || '192k').replace(/k/i,'')) || 192; + const bitrateNum = Number(String(config.target?.bitrate || '4500k').replace(/k/i, '')) || 4500; + const audioBitrate = Number(String(config.audio?.audioBitrate || '192k').replace(/k/i, '')) || 192; const durations = pairs.map(p => files.find(f => f.path === p.audio)?.duration).filter(Boolean); - const duration = Number(config.target?.duration || 0) || Math.round(durations.reduce((a,b)=>a+b,0) / Math.max(1,durations.length)) || 180; + const duration = + Number(config.target?.duration || 0) || + Math.round(durations.reduce((a, b) => a + b, 0) / Math.max(1, durations.length)) || + 180; const jobs = pairs.length || Math.max(1, files.filter(f => f.type === 'audio').length); - const sizeMB = Math.round(((bitrateNum + audioBitrate) * duration / 8 / 1024) * jobs); - return { jobs, durationPerJob: duration, estimatedTotalDuration: duration * jobs, estimatedSizeMB: sizeMB, bitrate: `${bitrateNum}k`, audioBitrate: `${audioBitrate}k`, resolution: config.target?.resolution || `${config.target?.width || 1280}x${config.target?.height || 720}`, encoder: config.target?.hardwareAccel || 'auto' }; + const sizeMB = Math.round((((bitrateNum + audioBitrate) * duration) / 8 / 1024) * jobs); + return { + jobs, + durationPerJob: duration, + estimatedTotalDuration: duration * jobs, + estimatedSizeMB: sizeMB, + bitrate: `${bitrateNum}k`, + audioBitrate: `${audioBitrate}k`, + resolution: config.target?.resolution || `${config.target?.width || 1280}x${config.target?.height || 720}`, + encoder: config.target?.hardwareAccel || 'auto', + }; } export function targetSummary(config, files = [], pairs = []) { - const videos = files.filter(f => f.type === 'video').length; const images = files.filter(f => f.type === 'image').length; const audios = files.filter(f => f.type === 'audio').length; const lyrics = files.filter(f => f.type === 'lyrics').length; - const warnings = files.flatMap(f => (f.warnings || []).map(w => `${f.name}: ${w}`)); const errors = []; - if (!config.input?.visual && videos + images === 0) errors.push('Visual belum dipilih dan folder tidak berisi video/gambar.'); + const videos = files.filter(f => f.type === 'video').length; + const images = files.filter(f => f.type === 'image').length; + const audios = files.filter(f => f.type === 'audio').length; + const lyrics = files.filter(f => f.type === 'lyrics').length; + const warnings = files.flatMap(f => (f.warnings || []).map(w => `${f.name}: ${w}`)); + const errors = []; + if (!config.input?.visual && videos + images === 0) + errors.push('Visual belum dipilih dan folder tidak berisi video/gambar.'); if (!config.input?.audio && audios === 0) errors.push('Audio belum dipilih dan folder tidak berisi audio.'); if (!config.input?.output) errors.push('Folder output belum diisi.'); if (!pairs.length && audios > 0 && videos + images > 0) errors.push('Pairing belum menghasilkan batch.'); @@ -165,5 +350,15 @@ export function targetSummary(config, files = [], pairs = []) { const collisions = outputCollisionReport(pairs, config); const risk = targetRiskSummary(config, files, pairs, collisions); const autoTune = autoTargetTune(config, files, pairs); - return { counts: { videos, images, audios, lyrics, total: files.length }, resolution, ready: errors.length === 0 && risk.level !== 'Risk', errors, warnings: [...warnings, ...(risk.warnings || [])].slice(0, 80), estimate: estimateRender(config, pairs, files), collisions, risk, autoTune }; + return { + counts: { videos, images, audios, lyrics, total: files.length }, + resolution, + ready: errors.length === 0 && risk.level !== 'Risk', + errors, + warnings: [...warnings, ...(risk.warnings || [])].slice(0, 80), + estimate: estimateRender(config, pairs, files), + collisions, + risk, + autoTune, + }; } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2ddf7e6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,21 @@ +# PidioForge Documentation + +Indeks dokumen arsitektur dan pengembangan PidioForge Desktop. + +## 🧪 Testing & Validation +- [Testing Guide](testing/TESTING_GUIDE.md) — Aturan, pola, dan flow E2E. +- [Testing Strategy](testing/TESTING_STRATEGY.md) — Coverage, pipeline, unit, integration. + +## 🚀 Performance +- [Performance Optimization](performance/PERFORMANCE_OPTIMIZATION.md) — FFmpeg tuning, GPU, threading, dan React debounce. + +## 👁️ Preview & Realtime +- [Preview Monitor Features](preview/PREVIEW_MONITOR_FEATURES.md) — Komponen UI preview dan context. +- [Realtime Preview Implementation](preview/REALTIME_PREVIEW_IMPLEMENTATION.md) — Engine logic FFmpeg untuk preview. + +## 🎨 UI & Redesign +- [Redesign Plan](ui/REDESIGN_PLAN.md) — Draft redesign UI desktop. +- [FASE 1 Audit](audits/FASE1_AUDIT.md) — Audit grid-cols form violations (SELESAI). + +## 📦 Release & Progress +- [Features Implemented](release/FEATURES_IMPLEMENTED.md) — Log modul dan update fitur yang sudah berjalan. diff --git a/docs/audits/FASE1_AUDIT.md b/docs/audits/FASE1_AUDIT.md new file mode 100644 index 0000000..cfdca9c --- /dev/null +++ b/docs/audits/FASE1_AUDIT.md @@ -0,0 +1,118 @@ +# FASE 1 AUDIT - 3-Field-in-1-Row Patterns + +## ✅ AUDIT STATUS UPDATED + +Dokumen ini sebelumnya mencatat banyak violation yang sudah tidak ada di codebase saat ini. +Verifikasi ulang dilakukan terhadap file aktual di `frontend/src/components/panels/`. + +--- + +## 📋 CURRENT STATUS PER PANEL + +### TargetPanel.tsx +Status: ✅ COMPLIANT + +- Verifikasi ulang: tidak ada `grid-cols-2/3/4` yang membungkus multiple `` atau stat items. +- Catatan audit lama sudah tidak relevan. + +### LoopingPanel.tsx +Status: ✅ COMPLIANT + +**Verifikasi ulang menemukan 2 grep matches:** + +1. **Line 349**: `grid-cols-2` untuk 2 `