Skip to content

Instantly share code, notes, and snippets.

@ericlaw1979
Last active October 12, 2026 13:35
Show Gist options
  • Select an option

  • Save ericlaw1979/c4c25cee98e69d1c1ec9eb6deac59853 to your computer and use it in GitHub Desktop.

Select an option

Save ericlaw1979/c4c25cee98e69d1c1ec9eb6deac59853 to your computer and use it in GitHub Desktop.
ffmpeg-WASM slideshow generator
<%@ 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