Last active
October 12, 2026 13:35
-
-
Save ericlaw1979/c4c25cee98e69d1c1ec9eb6deac59853 to your computer and use it in GitHub Desktop.
ffmpeg-WASM slideshow generator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <%@ Page language="C#"%> | |
| <% | |
| Response.AddHeader("Cross-Origin-Opener-Policy", "require-corp"); | |
| Response.AddHeader("Cross-Origin-Embedder-Policy", "same-origin"); | |
| %><!DOCTYPE html> | |
| <html lang="en" class="h-full bg-slate-950"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Native MP3 + Image to Video Converter</title> | |
| <!-- Tailwind CSS for styling --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <!-- Lucide Icons --> | |
| <script src="https://unpkg.com/lucide@latest"></script> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); | |
| body { font-family: 'Inter', sans-serif; } | |
| .drag-over { border-color: #6366f1 !important; background-color: rgba(99, 102, 241, 0.1) !important; } | |
| ::-webkit-scrollbar { width: 6px; height: 6px; } | |
| ::-webkit-scrollbar-track { background: #0f172a; } | |
| ::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; } | |
| ::-webkit-scrollbar-thumb:hover { background: #475569; } | |
| </style> | |
| </head> | |
| <body class="h-full text-slate-100 flex flex-col justify-between selection:bg-indigo-500 selection:text-white"> | |
| <!-- Header --> | |
| <header class="border-b border-slate-800 bg-slate-900/80 backdrop-blur-md sticky top-0 z-50"> | |
| <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between"> | |
| <div class="flex items-center gap-3"> | |
| <div class="p-2.5 bg-gradient-to-tr from-indigo-500 to-purple-600 rounded-xl shadow-lg shadow-indigo-500/20"> | |
| <i data-lucide="clapperboard" class="w-6 h-6 text-white"></i> | |
| </div> | |
| <div> | |
| <h1 class="font-bold text-lg leading-tight text-white">MP3 to Video Converter</h1> | |
| <p class="text-xs text-slate-400">Pure Web API Engine (MediaRecorder + Canvas Stream)</p> | |
| </div> | |
| </div> | |
| <div id="codec-badge" class="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-mono bg-slate-800 border border-slate-700 text-slate-300"> | |
| <span class="w-2 h-2 rounded-full bg-emerald-400"></span> | |
| <span id="supported-mime-text">Detecting MediaRecorder...</span> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- Main Container --> | |
| <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex-grow w-full"> | |
| <div class="grid grid-cols-1 lg:grid-cols-12 gap-8"> | |
| <!-- Left Column: Step 1 & Step 2 (Asset Uploads & Image Reordering) --> | |
| <div class="lg:col-span-7 space-y-6"> | |
| <!-- STEP 1: Upload Audio File --> | |
| <section class="bg-slate-900/90 rounded-2xl border border-slate-800 p-5 shadow-xl space-y-3"> | |
| <div class="flex items-center justify-between"> | |
| <h2 class="text-xs font-bold uppercase tracking-wider text-indigo-400 flex items-center gap-2"> | |
| <span class="w-5 h-5 rounded-full bg-indigo-500/20 text-indigo-400 text-[11px] flex items-center justify-center font-bold">1</span> | |
| Audio Track (MP3) | |
| </h2> | |
| <span id="audio-duration-display" class="text-xs font-mono text-slate-400 hidden">Duration: 00:00</span> | |
| </div> | |
| <div id="audio-dropzone" class="border-2 border-dashed border-slate-700 hover:border-indigo-500/70 transition-all rounded-xl p-5 text-center cursor-pointer bg-slate-950/40 hover:bg-slate-900/60 group relative"> | |
| <input type="file" id="audio-input" accept="audio/mp3,audio/mpeg,audio/wav,audio/m4a,audio/aac" class="hidden"> | |
| <div id="audio-empty-state" class="space-y-2"> | |
| <div class="w-10 h-10 rounded-full bg-indigo-500/10 text-indigo-400 flex items-center justify-center mx-auto group-hover:scale-110 transition-transform"> | |
| <i data-lucide="music" class="w-5 h-5"></i> | |
| </div> | |
| <p class="text-xs font-medium text-slate-300">Drop MP3 audio file here or <span class="text-indigo-400 group-hover:underline">browse</span></p> | |
| <p class="text-[11px] text-slate-500">Supports MP3, WAV, M4A, AAC</p> | |
| </div> | |
| <div id="audio-selected-state" class="hidden flex items-center justify-between bg-slate-800/80 p-3 rounded-lg border border-slate-700"> | |
| <div class="flex items-center gap-3 overflow-hidden"> | |
| <div class="p-2 bg-indigo-500/20 text-indigo-400 rounded-lg shrink-0"> | |
| <i data-lucide="disc" class="w-5 h-5 animate-spin"></i> | |
| </div> | |
| <div class="text-left truncate"> | |
| <p id="audio-file-name" class="text-xs font-medium text-slate-200 truncate">audio_track.mp3</p> | |
| <p id="audio-file-size" class="text-[11px] text-slate-400">0.0 MB</p> | |
| </div> | |
| </div> | |
| <button id="remove-audio-btn" type="button" class="p-1.5 hover:bg-slate-700 rounded-md text-slate-400 hover:text-rose-400 transition-colors"> | |
| <i data-lucide="x" class="w-4 h-4"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- STEP 2: Upload Images & Timeline Controls --> | |
| <section class="bg-slate-900/90 rounded-2xl border border-slate-800 p-5 shadow-xl space-y-4"> | |
| <div class="flex items-center justify-between"> | |
| <h2 class="text-xs font-bold uppercase tracking-wider text-purple-400 flex items-center gap-2"> | |
| <span class="w-5 h-5 rounded-full bg-purple-500/20 text-purple-400 text-[11px] flex items-center justify-center font-bold">2</span> | |
| Slideshow Images | |
| </h2> | |
| <span id="image-count-badge" class="text-xs bg-slate-800 text-slate-300 px-2.5 py-0.5 rounded-full border border-slate-700">0 images</span> | |
| </div> | |
| <!-- Images Dropzone --> | |
| <div id="images-dropzone" class="border-2 border-dashed border-slate-700 hover:border-purple-500/70 transition-all rounded-xl p-5 text-center cursor-pointer bg-slate-950/40 hover:bg-slate-900/60 group"> | |
| <input type="file" id="images-input" accept="image/png,image/jpeg,image/webp" multiple class="hidden"> | |
| <div class="space-y-2"> | |
| <div class="w-10 h-10 rounded-full bg-purple-500/10 text-purple-400 flex items-center justify-center mx-auto group-hover:scale-110 transition-transform"> | |
| <i data-lucide="images" class="w-5 h-5"></i> | |
| </div> | |
| <p class="text-xs font-medium text-slate-300">Drop image files here or <span class="text-purple-400 group-hover:underline">browse files</span></p> | |
| <p class="text-[11px] text-slate-500">JPG, PNG, WEBP (Multiple images allowed)</p> | |
| </div> | |
| </div> | |
| <!-- Duration Allocation Option --> | |
| <div id="duration-mode-box" class="hidden flex items-center justify-between p-3 bg-slate-950 rounded-xl border border-slate-800 text-xs"> | |
| <span class="text-slate-300 font-medium">Slide Timing Distribution:</span> | |
| <div class="flex items-center gap-2"> | |
| <button id="timing-equal-btn" type="button" class="px-3 py-1.5 rounded-lg bg-purple-600 text-white font-medium transition-all shadow">Fit Equal to MP3 Length</button> | |
| </div> | |
| </div> | |
| <!-- Image Thumbnails List --> | |
| <div id="image-list-container" class="space-y-2 max-h-[320px] overflow-y-auto pr-1"> | |
| <div id="images-empty-placeholder" class="text-center py-8 text-slate-500 border border-dashed border-slate-800/80 rounded-xl"> | |
| <i data-lucide="image-off" class="w-7 h-7 mx-auto mb-2 opacity-40"></i> | |
| <p class="text-xs">No images uploaded yet.</p> | |
| </div> | |
| <ul id="image-list" class="space-y-2"></ul> | |
| </div> | |
| </section> | |
| </div> | |
| <!-- Right Column: Step 3 (Video Customization, Live Canvas & Export) --> | |
| <div class="lg:col-span-5 space-y-6"> | |
| <!-- STEP 3: Video Settings & Transitions --> | |
| <section class="bg-slate-900/90 rounded-2xl border border-slate-800 p-5 shadow-xl space-y-4"> | |
| <h2 class="text-xs font-bold uppercase tracking-wider text-emerald-400 flex items-center gap-2"> | |
| <span class="w-5 h-5 rounded-full bg-emerald-500/20 text-emerald-400 text-[11px] flex items-center justify-center font-bold">3</span> | |
| Video & Transition Settings | |
| </h2> | |
| <!-- Aspect Ratio / Resolution Selection --> | |
| <div class="space-y-1.5"> | |
| <label class="text-xs font-medium text-slate-300 block">Aspect Ratio / Resolution</label> | |
| <select id="setting-resolution" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-indigo-500 transition-colors"> | |
| <option value="1920x1080" selected>16:9 Landscape - 1080p (1920 ׀ 1080)</option> | |
| <option value="1280x720">16:9 Landscape - 720p (1280 ׀ 720)</option> | |
| <option value="1080x1920">9:16 Vertical / Shorts (1080 ׀ 1920)</option> | |
| <option value="1080x1080">1:1 Square - Instagram (1080 ׀ 1080)</option> | |
| </select> | |
| </div> | |
| <!-- Slide Transition Style --> | |
| <div class="space-y-1.5"> | |
| <label class="text-xs font-medium text-slate-300 block">Transition Effect</label> | |
| <select id="setting-transition" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-indigo-500 transition-colors"> | |
| <option value="crossfade" selected>Smooth Crossfade (1s)</option> | |
| <option value="cut">Instant Cut (No Transition)</option> | |
| <option value="kenburns">Ken Burns (Pan & Zoom Effect)</option> | |
| </select> | |
| </div> | |
| <!-- Background Fill Style --> | |
| <div class="space-y-1.5"> | |
| <label class="text-xs font-medium text-slate-300 block">Image Fitting Strategy</label> | |
| <select id="setting-fit" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-indigo-500 transition-colors"> | |
| <option value="blur" selected>Contain with Blurred Fill Background</option> | |
| <option value="contain">Contain with Solid Black Letterbox</option> | |
| <option value="cover">Crop / Cover Entire Frame</option> | |
| </select> | |
| </div> | |
| <!-- Generate & Record Button --> | |
| <button id="start-recording-btn" disabled class="w-full py-3.5 px-4 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 hover:opacity-95 disabled:opacity-30 disabled:cursor-not-allowed rounded-xl font-semibold text-xs text-white shadow-lg shadow-indigo-500/25 transition-all flex items-center justify-center gap-2"> | |
| <i data-lucide="video" class="w-4 h-4"></i> | |
| <span id="record-btn-text">Render & Record Video</span> | |
| </button> | |
| </section> | |
| <!-- Live Canvas Render / Recording Card --> | |
| <section id="rendering-card" class="bg-slate-900/90 rounded-2xl border border-slate-800 p-5 shadow-xl space-y-4"> | |
| <div class="flex items-center justify-between"> | |
| <h3 class="text-xs font-bold uppercase tracking-wider text-slate-300 flex items-center gap-2"> | |
| <span id="recording-indicator" class="w-2.5 h-2.5 rounded-full bg-slate-600"></span> | |
| Live Canvas Studio | |
| </h3> | |
| <span id="recording-timer" class="text-xs font-mono text-indigo-400 font-bold">00:00 / 00:00</span> | |
| </div> | |
| <!-- Canvas Aspect Display Wrapper --> | |
| <div class="relative bg-black rounded-xl overflow-hidden border border-slate-800 aspect-video flex items-center justify-center group shadow-inner"> | |
| <canvas id="studio-canvas" class="w-full h-full object-contain"></canvas> | |
| <!-- Overlay during idle --> | |
| <div id="canvas-placeholder-overlay" class="absolute inset-0 bg-slate-950/80 backdrop-blur-xs flex flex-col items-center justify-center p-4 text-center"> | |
| <i data-lucide="play-circle" class="w-10 h-10 text-slate-600 mb-2"></i> | |
| <p class="text-xs text-slate-400 font-medium">Upload audio and image files to begin canvas generation.</p> | |
| </div> | |
| </div> | |
| <!-- Progress Bar --> | |
| <div id="progress-container" class="space-y-1.5"> | |
| <div class="w-full bg-slate-950 rounded-full h-2.5 overflow-hidden border border-slate-800"> | |
| <div id="recording-progress-bar" class="bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 h-full w-0 transition-all duration-200"></div> | |
| </div> | |
| <p id="render-status-log" class="text-[11px] text-slate-400 text-center font-mono">Status: Ready</p> | |
| </div> | |
| </section> | |
| <!-- Export Video Result Card --> | |
| <section id="result-card" class="bg-slate-900/90 rounded-2xl border border-slate-800 p-5 shadow-xl space-y-4 hidden border-emerald-500/30"> | |
| <div class="flex items-center justify-between"> | |
| <h3 class="text-xs font-bold uppercase tracking-wider text-emerald-400 flex items-center gap-2"> | |
| <i data-lucide="check-circle-2" class="w-4 h-4"></i> | |
| Export Ready | |
| </h3> | |
| <span id="result-file-size" class="text-xs text-slate-400 font-mono">0.0 MB</span> | |
| </div> | |
| <!-- Video Preview Player --> | |
| <div class="rounded-xl overflow-hidden bg-black border border-slate-800 aspect-video flex items-center justify-center"> | |
| <video id="output-video-player" controls class="w-full h-full object-contain"></video> | |
| </div> | |
| <!-- Action Buttons --> | |
| <a id="download-video-btn" href="#" download="slideshow_video.webm" class="w-full py-3 px-4 bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl font-semibold text-xs flex items-center justify-center gap-2 transition-colors shadow-lg shadow-emerald-600/20"> | |
| <i data-lucide="download" class="w-4 h-4"></i> | |
| <span>Download Video File</span> | |
| </a> | |
| </section> | |
| </div> | |
| </div> | |
| </main> | |
| <!-- Footer --> | |
| <footer class="border-t border-slate-800 bg-slate-950 py-4 mt-8 text-center text-xs text-slate-500"> | |
| Constructed using HTML5 Canvas Stream, Web Audio API & MediaRecorder. No external server or WebAssembly required. | |
| </footer> | |
| <!-- Hidden HTML Audio Element for decoding & driving audio stream --> | |
| <audio id="audio-decoder" class="hidden" crossorigin="anonymous"></audio> | |
| <script> | |
| // Initialize Lucide Icons | |
| lucide.createIcons(); | |
| // Application State | |
| let audioFile = null; | |
| let audioDuration = 0; // in seconds | |
| let imageItems = []; // Array of { id, file, name, dataUrl, duration, imgElement } | |
| let isRecording = false; | |
| let animationFrameId = null; | |
| // Audio API Nodes | |
| let audioCtx = null; | |
| let audioSourceNode = null; | |
| let mediaStreamDestination = null; | |
| // MediaRecorder State | |
| let mediaRecorder = null; | |
| let recordedChunks = []; | |
| let mimeTypeToUse = ''; | |
| // DOM Elements | |
| const audioDropzone = document.getElementById('audio-dropzone'); | |
| const audioInput = document.getElementById('audio-input'); | |
| const audioEmptyState = document.getElementById('audio-empty-state'); | |
| const audioSelectedState = document.getElementById('audio-selected-state'); | |
| const audioFileName = document.getElementById('audio-file-name'); | |
| const audioFileSize = document.getElementById('audio-file-size'); | |
| const removeAudioBtn = document.getElementById('remove-audio-btn'); | |
| const audioDurationDisplay = document.getElementById('audio-duration-display'); | |
| const audioDecoder = document.getElementById('audio-decoder'); | |
| const imagesDropzone = document.getElementById('images-dropzone'); | |
| const imagesInput = document.getElementById('images-input'); | |
| const imageList = document.getElementById('image-list'); | |
| const imagesEmptyPlaceholder = document.getElementById('images-empty-placeholder'); | |
| const imageCountBadge = document.getElementById('image-count-badge'); | |
| const durationModeBox = document.getElementById('duration-mode-box'); | |
| const timingEqualBtn = document.getElementById('timing-equal-btn'); | |
| const settingResolution = document.getElementById('setting-resolution'); | |
| const settingTransition = document.getElementById('setting-transition'); | |
| const settingFit = document.getElementById('setting-fit'); | |
| const startRecordingBtn = document.getElementById('start-recording-btn'); | |
| const recordBtnText = document.getElementById('record-btn-text'); | |
| const canvasPlaceholderOverlay = document.getElementById('canvas-placeholder-overlay'); | |
| const canvas = document.getElementById('studio-canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const recordingTimer = document.getElementById('recording-timer'); | |
| const recordingProgressBar = document.getElementById('recording-progress-bar'); | |
| const renderStatusLog = document.getElementById('render-status-log'); | |
| const recordingIndicator = document.getElementById('recording-indicator'); | |
| const resultCard = document.getElementById('result-card'); | |
| const outputVideoPlayer = document.getElementById('output-video-player'); | |
| const downloadVideoBtn = document.getElementById('download-video-btn'); | |
| const resultFileSize = document.getElementById('result-file-size'); | |
| const supportedMimeText = document.getElementById('supported-mime-text'); | |
| function checkSupportedMimeType() { | |
| const types = [ | |
| 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', | |
| 'video/mp4', | |
| 'video/webm;codecs=h264', | |
| 'video/webm;codecs=vp9,opus', | |
| 'video/webm;codecs=vp8,opus', | |
| 'video/webm' | |
| ]; | |
| for (const type of types) { | |
| if (MediaRecorder.isTypeSupported(type)) { | |
| mimeTypeToUse = type; | |
| break; | |
| } | |
| } | |
| if (mimeTypeToUse) { | |
| const shortLabel = mimeTypeToUse.includes('mp4') ? 'MP4 (H.264)' : 'WebM Container'; | |
| supportedMimeText.textContent = `Supported: ${shortLabel}`; | |
| } else { | |
| supportedMimeText.textContent = 'MediaRecorder not supported'; | |
| supportedMimeText.classList.add('text-rose-400'); | |
| } | |
| } | |
| checkSupportedMimeType(); | |
| audioDropzone.addEventListener('click', (e) => { | |
| if (e.target.closest('#remove-audio-btn')) return; | |
| audioInput.click(); | |
| }); | |
| audioInput.addEventListener('change', (e) => { | |
| if (e.target.files.length > 0) handleAudioFile(e.target.files[0]); | |
| }); | |
| setupDragAndDrop(audioDropzone, (files) => { | |
| const file = Array.from(files).find(f => f.type.startsWith('audio/')); | |
| if (file) handleAudioFile(file); | |
| }); | |
| function handleAudioFile(file) { | |
| audioFile = file; | |
| audioFileName.textContent = file.name; | |
| audioFileSize.textContent = `${(file.size / (1024 * 1024)).toFixed(2)} MB`; | |
| const fileUrl = URL.createObjectURL(file); | |
| audioDecoder.src = fileUrl; | |
| audioDecoder.onloadedmetadata = () => { | |
| audioDuration = audioDecoder.duration; | |
| audioDurationDisplay.textContent = `Duration: ${formatTime(audioDuration)}`; | |
| audioDurationDisplay.classList.remove('hidden'); | |
| audioEmptyState.classList.add('hidden'); | |
| audioSelectedState.classList.remove('hidden'); | |
| recalculateImageDurations(); | |
| updateUIState(); | |
| }; | |
| } | |
| removeAudioBtn.addEventListener('click', () => { | |
| audioFile = null; | |
| audioDuration = 0; | |
| audioDecoder.src = ''; | |
| audioDurationDisplay.classList.add('hidden'); | |
| audioEmptyState.classList.remove('hidden'); | |
| audioSelectedState.classList.add('hidden'); | |
| audioInput.value = ''; | |
| updateUIState(); | |
| }); | |
| imagesDropzone.addEventListener('click', () => imagesInput.click()); | |
| imagesInput.addEventListener('change', (e) => { | |
| if (e.target.files.length > 0) handleImageFiles(Array.from(e.target.files)); | |
| }); | |
| setupDragAndDrop(imagesDropzone, (files) => { | |
| const validImages = Array.from(files).filter(f => f.type.startsWith('image/')); | |
| if (validImages.length > 0) handleImageFiles(validImages); | |
| }); | |
| async function handleImageFiles(files) { | |
| for (const file of files) { | |
| const dataUrl = await readFileAsDataURL(file); | |
| const imgElement = await loadImageElement(dataUrl); | |
| imageItems.push({ | |
| id: Math.random().toString(36).substring(2, 9), | |
| file: file, | |
| name: file.name, | |
| dataUrl: dataUrl, | |
| imgElement: imgElement, | |
| duration: 3 // Default 3s fallback | |
| }); | |
| } | |
| recalculateImageDurations(); | |
| renderImageList(); | |
| updateUIState(); | |
| imagesInput.value = ''; | |
| } | |
| function recalculateImageDurations() { | |
| if (imageItems.length === 0) return; | |
| const perImageDuration = audioDuration > 0 ? (audioDuration / imageItems.length) : 3; | |
| imageItems.forEach(img => img.duration = perImageDuration); | |
| } | |
| function renderImageList() { | |
| imageCountBadge.textContent = `${imageItems.length} image${imageItems.length === 1 ? '' : 's'}`; | |
| if (imageItems.length === 0) { | |
| imagesEmptyPlaceholder.classList.remove('hidden'); | |
| durationModeBox.classList.add('hidden'); | |
| imageList.innerHTML = ''; | |
| return; | |
| } | |
| imagesEmptyPlaceholder.classList.add('hidden'); | |
| durationModeBox.classList.remove('hidden'); | |
| imageList.innerHTML = ''; | |
| imageItems.forEach((img, index) => { | |
| const li = document.createElement('li'); | |
| li.className = 'bg-slate-950 border border-slate-800 rounded-xl p-2.5 flex items-center justify-between gap-3 group hover:border-slate-700 transition-colors'; | |
| li.innerHTML = ` | |
| <div class="flex items-center gap-3 overflow-hidden"> | |
| <span class="text-xs font-mono font-bold text-slate-500 w-4 text-center">${index + 1}</span> | |
| <img src="${img.dataUrl}" class="w-10 h-10 object-cover rounded-lg border border-slate-800 bg-slate-900 shrink-0" alt="Thumbnail" /> | |
| <div class="truncate"> | |
| <p class="text-xs font-medium text-slate-200 truncate">${img.name}</p> | |
| <div class="flex items-center gap-1.5 mt-0.5"> | |
| <span class="text-[11px] text-slate-400">Duration:</span> | |
| <input type="number" step="0.5" min="0.5" value="${img.duration.toFixed(1)}" onchange="updateCustomDuration('${img.id}', this.value)" class="w-14 bg-slate-900 border border-slate-700 rounded px-1.5 py-0.5 text-[11px] text-indigo-400 text-center font-mono focus:outline-none focus:border-indigo-500" /> | |
| <span class="text-[11px] text-slate-400">s</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-1 shrink-0"> | |
| <button type="button" onclick="moveImage(${index}, -1)" ${index === 0 ? 'disabled' : ''} class="p-1 text-slate-400 hover:text-white disabled:opacity-20 rounded hover:bg-slate-800 transition-colors"> | |
| <i data-lucide="chevron-up" class="w-4 h-4"></i> | |
| </button> | |
| <button type="button" onclick="moveImage(${index}, 1)" ${index === imageItems.length - 1 ? 'disabled' : ''} class="p-1 text-slate-400 hover:text-white disabled:opacity-20 rounded hover:bg-slate-800 transition-colors"> | |
| <i data-lucide="chevron-down" class="w-4 h-4"></i> | |
| </button> | |
| <button type="button" onclick="removeImage('${img.id}')" class="p-1 text-slate-400 hover:text-rose-400 rounded hover:bg-slate-800 transition-colors"> | |
| <i data-lucide="trash-2" class="w-4 h-4"></i> | |
| </button> | |
| </div> | |
| `; | |
| imageList.appendChild(li); | |
| }); | |
| lucide.createIcons(); | |
| drawPreviewFrame(); | |
| } | |
| timingEqualBtn.addEventListener('click', () => { | |
| recalculateImageDurations(); | |
| renderImageList(); | |
| }); | |
| window.updateCustomDuration = function(id, val) { | |
| const num = parseFloat(val); | |
| if (!isNaN(num) && num > 0) { | |
| const item = imageItems.find(img => img.id === id); | |
| if (item) item.duration = num; | |
| } | |
| }; | |
| window.moveImage = function(index, direction) { | |
| const targetIndex = index + direction; | |
| if (targetIndex < 0 || targetIndex >= imageItems.length) return; | |
| const temp = imageItems[index]; | |
| imageItems[index] = imageItems[targetIndex]; | |
| imageItems[targetIndex] = temp; | |
| renderImageList(); | |
| }; | |
| window.removeImage = function(id) { | |
| imageItems = imageItems.filter(img => img.id !== id); | |
| recalculateImageDurations(); | |
| renderImageList(); | |
| updateUIState(); | |
| }; | |
| function updateUIState() { | |
| const canRecord = audioFile && imageItems.length > 0 && mimeTypeToUse && !isRecording; | |
| startRecordingBtn.disabled = !canRecord; | |
| if (imageItems.length > 0) { | |
| canvasPlaceholderOverlay.classList.add('hidden'); | |
| drawPreviewFrame(); | |
| } else { | |
| canvasPlaceholderOverlay.classList.remove('hidden'); | |
| } | |
| } | |
| settingResolution.addEventListener('change', () => drawPreviewFrame()); | |
| settingFit.addEventListener('change', () => drawPreviewFrame()); | |
| function drawPreviewFrame() { | |
| if (imageItems.length === 0) return; | |
| const [w, h] = settingResolution.value.split('x').map(Number); | |
| canvas.width = w; | |
| canvas.height = h; | |
| // Render first frame for static preview | |
| renderCanvasFrame(imageItems[0].imgElement, null, 1.0, 0, w, h, settingFit.value, settingTransition.value); | |
| } | |
| function renderCanvasFrame(currentImg, nextImg, progress, timeSec, width, height, fitMode, transitionStyle) { | |
| ctx.clearRect(0, 0, width, height); | |
| if (transitionStyle === 'crossfade' && nextImg && progress < 1.0) { | |
| // Draw base current image | |
| drawSingleImage(currentImg, width, height, fitMode, timeSec, 1.0); | |
| // Overlay next image with global alpha | |
| ctx.save(); | |
| ctx.globalAlpha = progress; | |
| drawSingleImage(nextImg, width, height, fitMode, timeSec, 1.0); | |
| ctx.restore(); | |
| } else { | |
| drawSingleImage(currentImg, width, height, fitMode, timeSec, 1.0, transitionStyle === 'kenburns' ? progress : 0); | |
| } | |
| } | |
| function drawSingleImage(img, targetWidth, targetHeight, fitMode, timeSec, alpha = 1.0, kenBurnsProgress = 0) { | |
| ctx.save(); | |
| ctx.globalAlpha = alpha; | |
| if (fitMode === 'blur') { | |
| // Background blurred fill | |
| ctx.save(); | |
| ctx.filter = 'blur(20px) brightness(0.6)'; | |
| ctx.drawImage(img, -20, -20, targetWidth + 40, targetHeight + 40); | |
| ctx.restore(); | |
| drawContainedImage(img, targetWidth, targetHeight, kenBurnsProgress); | |
| } else if (fitMode === 'contain') { | |
| ctx.fillStyle = '#000000'; | |
| ctx.fillRect(0, 0, targetWidth, targetHeight); | |
| drawContainedImage(img, targetWidth, targetHeight, kenBurnsProgress); | |
| } else if (fitMode === 'cover') { | |
| drawCoverImage(img, targetWidth, targetHeight, kenBurnsProgress); | |
| } | |
| ctx.restore(); | |
| } | |
| function drawContainedImage(img, targetWidth, targetHeight, kenBurnsProgress = 0) { | |
| const imgRatio = img.width / img.height; | |
| const canvasRatio = targetWidth / targetHeight; | |
| let drawW, drawH, drawX, drawY; | |
| if (imgRatio > canvasRatio) { | |
| drawW = targetWidth; | |
| drawH = targetWidth / imgRatio; | |
| drawX = 0; | |
| drawY = (targetHeight - drawH) / 2; | |
| } else { | |
| drawH = targetHeight; | |
| drawW = targetHeight * imgRatio; | |
| drawX = (targetWidth - drawW) / 2; | |
| drawY = 0; | |
| } | |
| if (kenBurnsProgress > 0) { | |
| const scale = 1.0 + (kenBurnsProgress * 0.12); | |
| ctx.save(); | |
| ctx.translate(targetWidth / 2, targetHeight / 2); | |
| ctx.scale(scale, scale); | |
| ctx.drawImage(img, drawX - targetWidth / 2, drawY - targetHeight / 2, drawW, drawH); | |
| ctx.restore(); | |
| } else { | |
| ctx.drawImage(img, drawX, drawY, drawW, drawH); | |
| } | |
| } | |
| function drawCoverImage(img, targetWidth, targetHeight, kenBurnsProgress = 0) { | |
| const imgRatio = img.width / img.height; | |
| const canvasRatio = targetWidth / targetHeight; | |
| let drawW, drawH, drawX, drawY; | |
| if (imgRatio > canvasRatio) { | |
| drawH = targetHeight; | |
| drawW = targetHeight * imgRatio; | |
| drawX = (targetWidth - drawW) / 2; | |
| drawY = 0; | |
| } else { | |
| drawW = targetWidth; | |
| drawH = targetWidth / imgRatio; | |
| drawX = 0; | |
| drawY = (targetHeight - drawH) / 2; | |
| } | |
| if (kenBurnsProgress > 0) { | |
| const scale = 1.0 + (kenBurnsProgress * 0.12); | |
| ctx.save(); | |
| ctx.translate(targetWidth / 2, targetHeight / 2); | |
| ctx.scale(scale, scale); | |
| ctx.drawImage(img, drawX - targetWidth / 2, drawY - targetHeight / 2, drawW, drawH); | |
| ctx.restore(); | |
| } else { | |
| ctx.drawImage(img, drawX, drawY, drawW, drawH); | |
| } | |
| } | |
| startRecordingBtn.addEventListener('click', async () => { | |
| if (isRecording || !audioFile || imageItems.length === 0) return; | |
| isRecording = true; | |
| recordBtnText.textContent = "Recording Canvas..."; | |
| startRecordingBtn.disabled = true; | |
| resultCard.classList.add('hidden'); | |
| recordingIndicator.className = "w-2.5 h-2.5 rounded-full bg-rose-500 animate-pulse"; | |
| renderStatusLog.textContent = "Status: Initializing Web Audio & Canvas Streams..."; | |
| const [targetWidth, targetHeight] = settingResolution.value.split('x').map(Number); | |
| canvas.width = targetWidth; | |
| canvas.height = targetHeight; | |
| // Initialize AudioContext | |
| audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| if (audioCtx.state === 'suspended') { | |
| await audioCtx.resume(); | |
| } | |
| // Create Audio Source Node from Decoder | |
| audioSourceNode = audioCtx.createMediaElementSource(audioDecoder); | |
| mediaStreamDestination = audioCtx.createMediaStreamDestination(); | |
| // Connect Audio to Destination Stream AND Audio Destination for preview | |
| audioSourceNode.connect(mediaStreamDestination); | |
| audioSourceNode.connect(audioCtx.destination); | |
| // Capture 30 FPS Canvas Stream | |
| const canvasStream = canvas.captureStream(30); | |
| // Combine Audio & Canvas Stream Tracks | |
| const combinedStream = new MediaStream([ | |
| ...canvasStream.getVideoTracks(), | |
| ...mediaStreamDestination.stream.getAudioTracks() | |
| ]); | |
| recordedChunks = []; | |
| // Setup MediaRecorder | |
| const options = { mimeType: mimeTypeToUse }; | |
| if (mimeTypeToUse.includes('mp4') || mimeTypeToUse.includes('h264')) { | |
| options.videoBitsPerSecond = 5000000; // 5 Mbps quality | |
| } | |
| mediaRecorder = new MediaRecorder(combinedStream, options); | |
| mediaRecorder.ondataavailable = (e) => { | |
| if (e.data && e.data.size > 0) { | |
| recordedChunks.push(e.data); | |
| } | |
| }; | |
| mediaRecorder.onstop = () => { | |
| const ext = mimeTypeToUse.includes('mp4') ? 'mp4' : 'webm'; | |
| const blob = new Blob(recordedChunks, { type: mimeTypeToUse }); | |
| const videoUrl = URL.createObjectURL(blob); | |
| outputVideoPlayer.src = videoUrl; | |
| downloadVideoBtn.href = videoUrl; | |
| downloadVideoBtn.download = `slideshow_video_${Date.now()}.${ext}`; | |
| resultFileSize.textContent = `${(blob.size / (1024 * 1024)).toFixed(2)} MB`; | |
| resultCard.classList.remove('hidden'); | |
| renderStatusLog.textContent = "Status: Recording finished successfully!"; | |
| recordingIndicator.className = "w-2.5 h-2.5 rounded-full bg-emerald-400"; | |
| // Cleanup Audio Context Connections | |
| try { | |
| audioSourceNode.disconnect(); | |
| audioCtx.close(); | |
| } catch (err) {} | |
| isRecording = false; | |
| recordBtnText.textContent = "Render & Record Video"; | |
| updateUIState(); | |
| }; | |
| // Calculate total time distribution | |
| const totalSlidesDuration = imageItems.reduce((acc, curr) => acc + curr.duration, 0); | |
| const totalDuration = Math.max(audioDuration, totalSlidesDuration); | |
| // Start Audio Playback and MediaRecorder | |
| audioDecoder.currentTime = 0; | |
| await audioDecoder.play(); | |
| mediaRecorder.start(100); | |
| const startTime = performance.now(); | |
| const fitMode = settingFit.value; | |
| const transitionStyle = settingTransition.value; | |
| const crossfadeDuration = 1.0; // 1s crossfade transition | |
| function animateFrame(now) { | |
| const elapsedSec = (now - startTime) / 1000; | |
| // Update Timer and Progress UI | |
| recordingTimer.textContent = `${formatTime(elapsedSec)} / ${formatTime(totalDuration)}`; | |
| const percent = Math.min((elapsedSec / totalDuration) * 100, 100); | |
| recordingProgressBar.style.width = `${percent}%`; | |
| renderStatusLog.textContent = `Status: Recording frame at ${elapsedSec.toFixed(1)}s (${percent.toFixed(0)}%)`; | |
| if (elapsedSec >= totalDuration || audioDecoder.ended) { | |
| audioDecoder.pause(); | |
| mediaRecorder.stop(); | |
| cancelAnimationFrame(animationFrameId); | |
| return; | |
| } | |
| // Determine active slide based on accumulated timing | |
| let accumulatedTime = 0; | |
| let activeIndex = 0; | |
| let currentSlideStart = 0; | |
| for (let i = 0; i < imageItems.length; i++) { | |
| if (elapsedSec >= accumulatedTime && elapsedSec < accumulatedTime + imageItems[i].duration) { | |
| activeIndex = i; | |
| currentSlideStart = accumulatedTime; | |
| break; | |
| } | |
| accumulatedTime += imageItems[i].duration; | |
| if (i === imageItems.length - 1) { | |
| activeIndex = imageItems.length - 1; | |
| currentSlideStart = accumulatedTime - imageItems[i].duration; | |
| } | |
| } | |
| const currentImg = imageItems[activeIndex].imgElement; | |
| const slideDuration = imageItems[activeIndex].duration; | |
| const slideElapsed = elapsedSec - currentSlideStart; | |
| let nextImg = null; | |
| let transitionProgress = 0; | |
| if (transitionStyle === 'crossfade' && activeIndex < imageItems.length - 1) { | |
| const fadeStart = slideDuration - crossfadeDuration; | |
| if (slideElapsed >= fadeStart && fadeStart > 0) { | |
| nextImg = imageItems[activeIndex + 1].imgElement; | |
| transitionProgress = (slideElapsed - fadeStart) / crossfadeDuration; | |
| } | |
| } else if (transitionStyle === 'kenburns') { | |
| transitionProgress = slideElapsed / slideDuration; | |
| } | |
| renderCanvasFrame(currentImg, nextImg, transitionProgress, elapsedSec, targetWidth, targetHeight, fitMode, transitionStyle); | |
| animationFrameId = requestAnimationFrame(animateFrame); | |
| } | |
| animationFrameId = requestAnimationFrame(animateFrame); | |
| }); | |
| function readFileAsDataURL(file) { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => resolve(reader.result); | |
| reader.onerror = reject; | |
| reader.readAsDataURL(file); | |
| }); | |
| } | |
| function loadImageElement(src) { | |
| return new Promise((resolve, reject) => { | |
| const img = new Image(); | |
| img.onload = () => resolve(img); | |
| img.onerror = reject; | |
| img.src = src; | |
| }); | |
| } | |
| function setupDragAndDrop(element, callback) { | |
| ['dragenter', 'dragover'].forEach(eventName => { | |
| element.addEventListener(eventName, (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| element.classList.add('drag-over'); | |
| }); | |
| }); | |
| ['dragleave', 'drop'].forEach(eventName => { | |
| element.addEventListener(eventName, (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| element.classList.remove('drag-over'); | |
| }); | |
| }); | |
| element.addEventListener('drop', (e) => { | |
| const files = e.dataTransfer.files; | |
| callback(files); | |
| }); | |
| } | |
| function formatTime(seconds) { | |
| if (isNaN(seconds) || seconds === Infinity) return "00:00"; | |
| const mins = Math.floor(seconds / 60); | |
| const secs = Math.floor(seconds % 60); | |
| return `${mins < 10 ? '0' : ''}${mins}:${secs < 10 ? '0' : ''}${secs}`; | |
| } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment