Plate Comparison Generator

STAFF TOOL v1.0

Live Preview

🖼️

Upload a before & after image to get started

`; downloadFile(html, 'plate-comparison-slider.html', 'text/html'); setStatus('✅ Slider HTML downloaded!'); } // ─── Generate Video (MP4 + GIF) ─── let mp4Blob = null; let gifBlob = null; async function generateVideo() { if (!beforeImg || !afterImg) return; const speedMap = { slow: 6000, medium: 4000, fast: 2500 }; const totalDuration = speedMap[document.getElementById('videoSpeed').value] || 4000; const fullCycleDuration = totalDuration * 2; const canvas = document.createElement('canvas'); const W = 1080; const ratio = beforeImg.naturalWidth / beforeImg.naturalHeight; let H = Math.round(W / ratio); if (H % 2 !== 0) H++; canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d'); showProgress(true); document.getElementById('btnGenVideo').disabled = true; document.getElementById('downloadBtns').classList.add('hidden'); mp4Blob = null; setStatus('🎬 Encoding MP4 video...'); mp4Blob = await renderToMP4(canvas, ctx, W, H, totalDuration, fullCycleDuration, 0); document.getElementById('downloadBtns').classList.remove('hidden'); document.getElementById('btnGenVideo').disabled = false; document.getElementById('mp4Size').textContent = '(' + formatBytes(mp4Blob.size) + ')'; showProgress(false); setStatus('✅ MP4 ready!'); } function renderFrame(ctx, W, H, elapsed, pauseDuration, wipeDuration, fullCycleDuration) { const t1 = pauseDuration; const t2 = t1 + wipeDuration; const t3 = t2 + pauseDuration; const t4 = t3 + wipeDuration; const e = elapsed % fullCycleDuration; let splitX = 0; if (e < t1) { splitX = 0; } else if (e < t2) { let p = (e - t1) / wipeDuration; p = p * p * (3 - 2 * p); splitX = p * W; } else if (e < t3) { splitX = W; } else if (e < t4) { let p = (e - t3) / wipeDuration; p = p * p * (3 - 2 * p); splitX = (1 - p) * W; } ctx.clearRect(0, 0, W, H); ctx.drawImage(beforeImg, 0, 0, W, H); if (splitX > 0) { ctx.save(); ctx.beginPath(); ctx.rect(0, 0, splitX, H); ctx.clip(); ctx.drawImage(afterImg, 0, 0, W, H); ctx.restore(); } if (splitX > 5 && splitX < W - 5) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(splitX, 0); ctx.lineTo(splitX, H); ctx.stroke(); } const labelY = H - 30; if (splitX < W - 5) { const bx = splitX < 5 ? W / 2 : splitX / 2; if (bx > 50) drawPill(ctx, bx, labelY, 'BEFORE', 'rgba(200,50,50,0.85)'); } if (splitX > 5) { const ax = splitX > W - 5 ? W / 2 : splitX + (W - splitX) / 2; if (ax < W - 50) drawPill(ctx, ax, labelY, 'AFTER', 'rgba(40,180,80,0.85)'); } } async function renderToMP4(canvas, ctx, W, H, totalDuration, fullCycleDuration, progressOffset) { const fps = 30; const totalFrames = Math.round(fullCycleDuration / 1000 * fps); const pauseDuration = totalDuration * 0.25; const wipeDuration = totalDuration * 0.25; // Set up mp4-muxer const muxer = new Mp4Muxer.Muxer({ target: new Mp4Muxer.ArrayBufferTarget(), video: { codec: 'avc', width: W, height: H }, fastStart: 'in-memory' }); // Set up VideoEncoder const encoder = new VideoEncoder({ output: (chunk, meta) => muxer.addVideoChunk(chunk, meta), error: (e) => console.error('VideoEncoder error:', e) }); encoder.configure({ codec: 'avc1.42001f', width: W, height: H, bitrate: 5_000_000, bitrateMode: 'constant' }); // Render and encode each frame for (let i = 0; i <= totalFrames; i++) { const elapsed = i / fps * 1000; renderFrame(ctx, W, H, elapsed, pauseDuration, wipeDuration, fullCycleDuration); const frame = new VideoFrame(canvas, { timestamp: i * (1_000_000 / fps) }); const keyFrame = i % (fps * 2) === 0; // keyframe every 2 seconds encoder.encode(frame, { keyFrame }); frame.close(); updateProgress(progressOffset + (i / totalFrames * 50)); // Yield to keep UI responsive every 15 frames if (i % 15 === 0) await new Promise(r => setTimeout(r, 0)); } await encoder.flush(); encoder.close(); muxer.finalize(); return new Blob([muxer.target.buffer], { type: 'video/mp4' }); } async function renderToGIF(canvas, ctx, W, H, totalDuration, fullCycleDuration) { // Render frames at lower res for reasonable GIF size const scale = 0.6; const gW = Math.round(W * scale); let gH = Math.round(H * scale); if (gH % 2 !== 0) gH++; const gifCanvas = document.createElement('canvas'); gifCanvas.width = gW; gifCanvas.height = gH; const gCtx = gifCanvas.getContext('2d'); const pauseDuration = totalDuration * 0.25; const wipeDuration = totalDuration * 0.25; const fps = 15; // lower fps for GIF const totalFrames = Math.round(fullCycleDuration / 1000 * fps); const delay = Math.round(1000 / fps); // Collect frames as ImageData const frames = []; for (let i = 0; i <= totalFrames; i++) { const elapsed = i / fps * 1000; // Draw at full res first renderFrame(ctx, W, H, elapsed, pauseDuration, wipeDuration, fullCycleDuration); // Scale down gCtx.clearRect(0, 0, gW, gH); gCtx.drawImage(canvas, 0, 0, gW, gH); // Get as blob const dataUrl = gifCanvas.toDataURL('image/png'); frames.push(dataUrl); updateProgress(50 + (i / totalFrames * 50)); // Yield to keep UI responsive if (i % 5 === 0) await new Promise(r => setTimeout(r, 0)); } // Encode GIF using a minimal encoder // We'll use an approach that creates an animated GIF from PNG frames via canvas // For browser compatibility, we'll bundle a tiny GIF encoder return await encodeGIF(frames, gW, gH, delay); } // ─── Minimal GIF Encoder (LZW) ─── async function encodeGIF(frameDataUrls, width, height, delay) { // Create a temporary canvas to extract pixel data const cvs = document.createElement('canvas'); cvs.width = width; cvs.height = height; const ctx2 = cvs.getContext('2d', { willReadFrequently: true }); // Build GIF binary const buf = []; function writeByte(b) { buf.push(b & 0xFF); } function writeShort(s) { writeByte(s & 0xFF); writeByte((s >> 8) & 0xFF); } function writeStr(s) { for (let i = 0; i < s.length; i++) writeByte(s.charCodeAt(i)); } function writeBytes(arr) { for (let i = 0; i < arr.length; i++) buf.push(arr[i]); } // ── Quantize to 256 colors using median cut (simplified) ── // For speed, we'll sample the first frame to build a global palette const firstImg = new Image(); await new Promise((resolve) => { firstImg.onload = resolve; firstImg.src = frameDataUrls[0]; }); ctx2.drawImage(firstImg, 0, 0); const sampleData = ctx2.getImageData(0, 0, width, height).data; // Simple uniform quantization: 6x7x6 = 252 colors const palette = []; const colorMap = {}; for (let r = 0; r < 6; r++) { for (let g = 0; g < 7; g++) { for (let b = 0; b < 6; b++) { const cr = Math.round(r * 255 / 5); const cg = Math.round(g * 255 / 6); const cb = Math.round(b * 255 / 5); palette.push([cr, cg, cb]); } } } // Pad to 256 while (palette.length < 256) palette.push([0, 0, 0]); function findClosest(r, g, b) { const key = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3); if (colorMap[key] !== undefined) return colorMap[key]; let best = 0, bestD = Infinity; for (let i = 0; i < 256; i++) { const dr = r - palette[i][0], dg = g - palette[i][1], db = b - palette[i][2]; const d = dr * dr + dg * dg + db * db; if (d < bestD) { bestD = d; best = i; } } colorMap[key] = best; return best; } // ── GIF Header ── writeStr('GIF89a'); writeShort(width); writeShort(height); writeByte(0xF7); // GCT flag, 8 bits writeByte(0); // bg color writeByte(0); // pixel aspect // Global Color Table (256 * 3 bytes) for (let i = 0; i < 256; i++) { writeByte(palette[i][0]); writeByte(palette[i][1]); writeByte(palette[i][2]); } // Netscape extension for looping writeByte(0x21); writeByte(0xFF); writeByte(0x0B); writeStr('NETSCAPE2.0'); writeByte(0x03); writeByte(0x01); writeShort(0); // loop forever writeByte(0x00); // ── Encode each frame ── for (let f = 0; f < frameDataUrls.length; f++) { const img = new Image(); await new Promise((resolve) => { img.onload = resolve; img.src = frameDataUrls[f]; }); ctx2.drawImage(img, 0, 0); const pixels = ctx2.getImageData(0, 0, width, height).data; // Graphic Control Extension writeByte(0x21); writeByte(0xF9); writeByte(0x04); writeByte(0x00); // no transparency writeShort(Math.round(delay / 10)); // delay in 1/100s writeByte(0x00); // transparent color writeByte(0x00); // Image Descriptor writeByte(0x2C); writeShort(0); writeShort(0); // left, top writeShort(width); writeShort(height); writeByte(0x00); // no local color table // LZW encode const minCodeSize = 8; writeByte(minCodeSize); // Index the pixels const indices = new Uint8Array(width * height); for (let i = 0; i < width * height; i++) { const off = i * 4; indices[i] = findClosest(pixels[off], pixels[off + 1], pixels[off + 2]); } // LZW compression const lzwData = lzwEncode(minCodeSize, indices); // Write sub-blocks let pos = 0; while (pos < lzwData.length) { const chunkSize = Math.min(255, lzwData.length - pos); writeByte(chunkSize); for (let i = 0; i < chunkSize; i++) { buf.push(lzwData[pos + i]); } pos += chunkSize; } writeByte(0x00); // block terminator // Yield if (f % 3 === 0) await new Promise(r => setTimeout(r, 0)); } // Trailer writeByte(0x3B); return new Blob([new Uint8Array(buf)], { type: 'image/gif' }); } function lzwEncode(minCodeSize, data) { const clearCode = 1 << minCodeSize; const eoiCode = clearCode + 1; let codeSize = minCodeSize + 1; let nextCode = eoiCode + 1; // Build initial table let table = {}; for (let i = 0; i < clearCode; i++) table[String(i)] = i; const output = []; let bitBuf = 0; let bitCount = 0; function writeBits(code, size) { bitBuf |= (code << bitCount); bitCount += size; while (bitCount >= 8) { output.push(bitBuf & 0xFF); bitBuf >>= 8; bitCount -= 8; } } writeBits(clearCode, codeSize); let prefix = String(data[0]); for (let i = 1; i < data.length; i++) { const k = String(data[i]); const combined = prefix + ',' + k; if (table[combined] !== undefined) { prefix = combined; } else { writeBits(table[prefix], codeSize); if (nextCode < 4096) { table[combined] = nextCode++; if (nextCode > (1 << codeSize) && codeSize < 12) codeSize++; } else { writeBits(clearCode, codeSize); table = {}; for (let j = 0; j < clearCode; j++) table[String(j)] = j; nextCode = eoiCode + 1; codeSize = minCodeSize + 1; } prefix = k; } } writeBits(table[prefix], codeSize); writeBits(eoiCode, codeSize); if (bitCount > 0) output.push(bitBuf & 0xFF); return output; } function downloadMp4() { if (!mp4Blob) return; const url = URL.createObjectURL(mp4Blob); const a = document.createElement('a'); a.href = url; a.download = 'plate-comparison-ad.mp4'; a.click(); URL.revokeObjectURL(url); } function downloadGif() { if (!gifBlob) return; const url = URL.createObjectURL(gifBlob); const a = document.createElement('a'); a.href = url; a.download = 'plate-comparison-ad.gif'; a.click(); URL.revokeObjectURL(url); } function formatBytes(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1048576) return (bytes / 1024).toFixed(0) + ' KB'; return (bytes / 1048576).toFixed(1) + ' MB'; } // ─── Utilities ─── function downloadFile(content, filename, type) { const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } function showProgress(show) { document.getElementById('progressBar').classList.toggle('active', show); if (!show) document.getElementById('progressFill').style.width = '0%'; } function updateProgress(pct) { document.getElementById('progressFill').style.width = Math.min(100, pct) + '%'; } function setStatus(msg) { document.getElementById('status').textContent = msg; } // ─── Settings change triggers preview update ─── document.getElementById('videoSpeed').addEventListener('change', function() { if (currentTab === 'video' && beforeImg && afterImg) previewVideoAnimation(); }); // ─── Start Again ─── function startAgain() { // Stop any running animation if (animFrameId) { cancelAnimationFrame(animFrameId); animFrameId = null; } // Reset state beforeImg = null; afterImg = null; videoBlob = null; currentTab = 'slider'; // Reset file inputs document.getElementById('beforeInput').value = ''; document.getElementById('afterInput').value = ''; // Reset before zone document.getElementById('beforePreview').classList.add('hidden'); document.getElementById('beforePreview').src = ''; document.getElementById('beforePlaceholder').classList.remove('hidden'); document.getElementById('beforeZone').classList.remove('has-image'); // Reset after zone document.getElementById('afterPreview').classList.add('hidden'); document.getElementById('afterPreview').src = ''; document.getElementById('afterPlaceholder').classList.remove('hidden'); document.getElementById('afterZone').classList.remove('has-image'); // Reset buttons document.getElementById('btnGenSlider').disabled = true; document.getElementById('btnGenVideo').disabled = true; document.getElementById('downloadBtns').classList.add('hidden'); mp4Blob = null; gifBlob = null; // Reset preview document.getElementById('sliderPreview').classList.add('hidden'); document.getElementById('videoPreview').classList.add('hidden'); document.getElementById('emptyState').classList.remove('hidden'); // Reset tabs document.querySelectorAll('.preview-tab').forEach((t, i) => { t.classList.toggle('active', i === 0); }); // Reset progress and status showProgress(false); setStatus(''); // Reset settings to defaults document.getElementById('headingText').value = 'See The Difference'; document.getElementById('subText').value = 'Standard vs Premium Plates'; document.getElementById('ctaText').value = 'Order Your Plates Today'; document.getElementById('ctaUrl').value = 'https://www.platesexpress.co.uk'; document.getElementById('videoSpeed').value = 'medium'; } // ─── Brand Configuration ─── const BRANDS = { number1plates: { name: 'Number 1 Plates', url: 'https://www.number1plates.com', logo: 'brands/number1plates.png', primary: '#ffcc00', accent: '#32373c', ctaText: 'Order Your Plates Today', heading: 'See The Difference', sub: 'Standard vs Number 1 Plates' }, premiumplates: { name: 'Premium Plates', url: 'https://www.premiumplates.com', logo: 'brands/premiumplates.png', primary: '#000000', accent: '#f68221', ctaText: 'Order Your Plates Today', heading: 'See The Difference', sub: 'Standard vs Premium Plates' }, showplatesworld: { name: 'Show Plates World', url: 'https://www.showplatesworld.com', logo: 'brands/showplatesworld.webp', primary: '#1a1a2e', accent: '#2997ff', ctaText: 'Order Your Plates Today', heading: 'See The Difference', sub: 'Standard vs Show Plates World' }, platesexpress: { name: 'Plates Express', url: 'https://www.platesexpress.co.uk', logo: 'brands/platesexpress.png', primary: '#1a2744', accent: '#ffc10a', ctaText: 'Order Your Plates Today', heading: 'See The Difference', sub: 'Standard vs Premium Plates' }, carplatesdirect: { name: 'Car Plates Direct', url: 'https://www.carplatesdirect.com', logo: 'brands/carplatesdirect.png', primary: '#f68221', accent: '#2a4c99', ctaText: 'Order Your Plates Today', heading: 'See The Difference', sub: 'Standard vs Premium Plates' }, bikeplatesdirect: { name: 'Bike Plates Direct', url: 'https://www.bikeplatesdirect.com', logo: 'brands/bikeplatesdirect.png', primary: '#000000', accent: '#f68221', ctaText: 'Order Your Plates Today', heading: 'See The Difference', sub: 'Standard vs Premium Plates' } }; function applyBrand() { const key = document.getElementById('brandSelect').value; if (!key || !BRANDS[key]) return; const b = BRANDS[key]; document.getElementById('headingText').value = b.heading; document.getElementById('subText').value = b.sub; document.getElementById('ctaText').value = b.ctaText; document.getElementById('ctaUrl').value = b.url; }