From 87f2c7c83157ae92f9cdb82548f19ccb3a0021f6 Mon Sep 17 00:00:00 2001 From: CodeWithDennis Date: Sun, 26 Jul 2026 13:35:26 +0200 Subject: [PATCH 1/2] perf: coalesce preview renders and lazy-load video export Cache compositor layers, tile noise backgrounds, and defer MediaRecorder code until video export is selected so slider updates stay responsive. --- resources/js/app.js | 146 +++++++++----------------- resources/js/compositor.js | 189 ++++++++++++++++++++++++++-------- resources/js/patterns.js | 14 ++- resources/js/video-export.js | 137 ++++++++++++++++++++++++ resources/views/app.blade.php | 2 +- 5 files changed, 346 insertions(+), 142 deletions(-) create mode 100644 resources/js/video-export.js diff --git a/resources/js/app.js b/resources/js/app.js index 3ebf639..28addcb 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -88,6 +88,8 @@ document.addEventListener('alpine:init', () => { dropActive: false, previewMetrics: { scale: 1, drawWidth: 0, drawHeight: 0 }, compositor: null, + _renderRaf: 0, + _videoExportModule: null, layouts: [ { id: 'vertical', label: 'Vertical' }, @@ -167,15 +169,38 @@ document.addEventListener('alpine:init', () => { if (this.exporting || this.videoPreviewing) { return; } - this.render(); + this.scheduleRender(); }, ); - this.$nextTick(() => this.render()); + this.$nextTick(() => this.scheduleRender()); this._onResize = () => this.updateHandle(); window.addEventListener('resize', this._onResize); }, + scheduleRender() { + if (this.exporting || this.videoPreviewing) { + return; + } + if (this._renderRaf) { + return; + } + this._renderRaf = requestAnimationFrame(() => { + this._renderRaf = 0; + if (this.exporting || this.videoPreviewing) { + return; + } + this.render(); + }); + }, + + async loadVideoExport() { + if (!this._videoExportModule) { + this._videoExportModule = import('./video-export.js'); + } + return this._videoExportModule; + }, + setTheme(theme) { this.theme = theme; localStorage.setItem(THEME_STORAGE_KEY, theme); @@ -757,29 +782,6 @@ document.addEventListener('alpine:init', () => { return types.find((type) => this.isVideoMimeSupported(type)) || ''; }, - pickVideoMimeType() { - const mp4Types = ['video/mp4;codecs=avc1.42E01E', 'video/mp4;codecs=avc1', 'video/mp4']; - const webmTypes = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']; - - if (this.videoContainer === 'mp4') { - const mimeType = this.firstSupportedMime(mp4Types); - return mimeType ? { mimeType, extension: 'mp4', label: 'MP4' } : null; - } - - if (this.videoContainer === 'webm') { - const mimeType = this.firstSupportedMime(webmTypes); - return mimeType ? { mimeType, extension: 'webm', label: 'WebM' } : null; - } - - const mp4 = this.firstSupportedMime(mp4Types); - if (mp4) { - return { mimeType: mp4, extension: 'mp4', label: 'MP4' }; - } - - const webm = this.firstSupportedMime(webmTypes); - return webm ? { mimeType: webm, extension: 'webm', label: 'WebM' } : null; - }, - get supportsMp4Video() { return Boolean(this.firstSupportedMime(['video/mp4;codecs=avc1.42E01E', 'video/mp4;codecs=avc1', 'video/mp4'])); }, @@ -788,79 +790,28 @@ document.addEventListener('alpine:init', () => { return Boolean(this.firstSupportedMime(['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'])); }, - async recordWipeVideo(width, height) { - if (typeof MediaRecorder === 'undefined') { - throw new Error('MediaRecorder is not supported in this environment.'); - } - - const picked = this.pickVideoMimeType(); - if (!picked) { - const wanted = this.videoContainer === 'auto' ? 'MP4/WebM' : this.videoContainer.toUpperCase(); - throw new Error(`${wanted} video export is not supported in this environment.`); + selectExportFormat(format) { + this.exportFormat = format; + if (format === 'video') { + this.loadVideoExport(); } + }, - const { mimeType, extension, label } = picked; - const fps = this.videoFps; - const durationSec = this.videoDuration; - const frameCount = Math.max(2, Math.round(fps * durationSec)); - const frameDelay = 1000 / fps; - - const renderFrame = (split01) => { - const options = this.buildOptions(width, height); - options.splitPosition = clamp(split01, 0, 1); - this.compositor.render(options); - }; - - renderFrame(this.videoReverse ? 1 : 0); - - const canvas = this.compositor.getCanvas(); - const stream = canvas.captureStream(fps); - const track = stream.getVideoTracks()[0]; - if (!track) { - throw new Error('Could not capture a video track from the canvas.'); - } - - const bits = Math.round(Math.min(25_000_000, Math.max(6_000_000, width * height * 6))); - const recorder = new MediaRecorder(stream, { - mimeType, - videoBitsPerSecond: bits, - }); - const chunks = []; - recorder.ondataavailable = (event) => { - if (event.data?.size) { - chunks.push(event.data); - } - }; - - const stopped = new Promise((resolve, reject) => { - recorder.onstop = () => resolve(); - recorder.onerror = () => reject(recorder.error || new Error('Recording failed.')); + async recordWipeVideo(width, height) { + const { recordWipeVideo } = await this.loadVideoExport(); + return recordWipeVideo({ + width, + height, + videoContainer: this.videoContainer, + videoFps: this.videoFps, + videoDuration: this.videoDuration, + videoReverse: this.videoReverse, + compositor: this.compositor, + buildOptions: (w, h) => this.buildOptions(w, h), + onProgress: (message) => { + this.statusMessage = message; + }, }); - - recorder.start(100); - - for (let i = 0; i <= frameCount; i++) { - const t = i / frameCount; - const split = this.videoReverse ? 1 - t : t; - renderFrame(split); - if (typeof track.requestFrame === 'function') { - track.requestFrame(); - } - this.statusMessage = `Recording ${label}… ${Math.round((i / frameCount) * 100)}%`; - await wait(frameDelay); - } - - await wait(Math.max(frameDelay * 2, 250)); - recorder.stop(); - await stopped; - stream.getTracks().forEach((item) => item.stop()); - - const blob = new Blob(chunks, { type: extension === 'mp4' ? 'video/mp4' : 'video/webm' }); - if (!blob.size) { - throw new Error('Recording produced an empty file.'); - } - - return { blob, extension, label }; }, buildOptions(width, height) { @@ -913,6 +864,11 @@ document.addEventListener('alpine:init', () => { return; } + if (this._renderRaf) { + cancelAnimationFrame(this._renderRaf); + this._renderRaf = 0; + } + this.compositor.render(this.buildOptions(this.baseWidth, this.baseHeight)); const preview = this.$refs.previewCanvas; diff --git a/resources/js/compositor.js b/resources/js/compositor.js index f3027a8..d3369a3 100644 --- a/resources/js/compositor.js +++ b/resources/js/compositor.js @@ -51,6 +51,23 @@ function roundRectPath(ctx, x, y, w, h, r = 0) { ctx.closePath(); } +function ensureCanvasSize(canvas, width, height) { + const w = Math.max(1, Math.round(width)); + const h = Math.max(1, Math.round(height)); + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + return true; + } + return false; +} + +function clearCanvas(ctx, canvas, width, height) { + if (!ensureCanvasSize(canvas, width, height)) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + } +} + export function createCompositor() { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d', { willReadFrequently: false }); @@ -58,8 +75,18 @@ export function createCompositor() { const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: false }); const sideCanvas = document.createElement('canvas'); const sideCtx = sideCanvas.getContext('2d', { willReadFrequently: false }); + const bgCanvas = document.createElement('canvas'); + const bgCtx = bgCanvas.getContext('2d', { willReadFrequently: false }); + const sideACanvas = document.createElement('canvas'); + const sideACtx = sideACanvas.getContext('2d', { willReadFrequently: false }); + const sideBBaseCanvas = document.createElement('canvas'); + const sideBBaseCtx = sideBBaseCanvas.getContext('2d', { willReadFrequently: false }); let lastOptions = null; + let bgKey = ''; + let sideAKey = ''; + let sideBKey = ''; + let maskKey = ''; function resolveSides(options) { const light = options.lightImage; @@ -70,8 +97,76 @@ export function createCompositor() { return { sideA: light, sideB: dark }; } + function blitBackground(targetCtx, width, height, background) { + const bg = background || { type: 'solid', bg: '#FAFAFA' }; + const key = [ + width, + height, + bg.type || 'solid', + bg.bg || '', + bg.fg || '', + Number(bg.density) || 0, + ].join('|'); + + if (bgKey !== key) { + ensureCanvasSize(bgCanvas, width, height); + paintBackground(bgCtx, width, height, bg); + bgKey = key; + } + + targetCtx.drawImage(bgCanvas, 0, 0); + } + + function imageCacheKey(image, width, height, fitMode) { + if (!image) { + return `empty|${width}|${height}|${fitMode}`; + } + return [ + image.src || '', + image.naturalWidth || image.width || 0, + image.naturalHeight || image.height || 0, + width, + height, + fitMode, + ].join('|'); + } + + function ensureFittedSide(cacheCanvas, cacheCtx, currentKey, image, width, height, fitMode) { + const key = imageCacheKey(image, width, height, fitMode); + if (currentKey === key && cacheCanvas.width === width && cacheCanvas.height === height) { + return currentKey; + } + + clearCanvas(cacheCtx, cacheCanvas, width, height); + drawImageFitted(cacheCtx, image, 0, 0, width, height, fitMode); + return key; + } + + function ensureMask(width, height, maskOptions) { + const key = [ + width, + height, + maskOptions.layoutFamily || '', + maskOptions.layoutVariant || '', + Number(maskOptions.splitPosition) || 0, + Number(maskOptions.softEdge) || 0, + Boolean(maskOptions.flipDirection) ? 1 : 0, + Boolean(maskOptions.invertMask) ? 1 : 0, + Number(maskOptions.maskDensity) || 0, + Number(maskOptions.diagonalAngle) || 0, + ].join('|'); + + if (maskKey === key && maskCanvas.width === width && maskCanvas.height === height) { + return; + } + + ensureCanvasSize(maskCanvas, width, height); + paintSplitMask(maskCtx, width, height, maskOptions); + maskKey = key; + } + function renderOverlap(options, width, height, sideA, sideB) { - paintBackground(ctx, width, height, options.background); + blitBackground(ctx, width, height, options.background); const variant = options.layoutVariant || 'cards'; const offsetXRaw = Number(options.overlapOffsetX); @@ -104,9 +199,7 @@ export function createCompositor() { } if (radius > 0) { - sideCanvas.width = Math.max(1, Math.ceil(w)); - sideCanvas.height = Math.max(1, Math.ceil(h)); - sideCtx.clearRect(0, 0, sideCanvas.width, sideCanvas.height); + clearCanvas(sideCtx, sideCanvas, Math.ceil(w), Math.ceil(h)); drawImageFitted(sideCtx, image, 0, 0, w, h, 'cover'); sideCtx.globalCompositeOperation = 'destination-in'; sideCtx.fillStyle = '#000000'; @@ -174,36 +267,33 @@ export function createCompositor() { } function renderSplit(options, width, height, sideA, sideB) { - paintBackground(ctx, width, height, options.background); + blitBackground(ctx, width, height, options.background); const content = contentPadding(width, height, options); const radiusPct = clamp(Number(options.imageRadius) || 0, 0, 50) / 100; const radius = Math.min(content.width, content.height) * radiusPct; + const fitMode = options.fitMode || 'cover'; - ctx.save(); - if (radius > 0) { - roundRectPath(ctx, content.x, content.y, content.width, content.height, radius); - ctx.clip(); - } - - drawImageFitted( - ctx, + sideAKey = ensureFittedSide( + sideACanvas, + sideACtx, + sideAKey, sideA, - content.x, - content.y, content.width, content.height, - options.fitMode || 'cover', + fitMode, + ); + sideBKey = ensureFittedSide( + sideBBaseCanvas, + sideBBaseCtx, + sideBKey, + sideB, + content.width, + content.height, + fitMode, ); - sideCanvas.width = content.width; - sideCanvas.height = content.height; - sideCtx.clearRect(0, 0, content.width, content.height); - drawImageFitted(sideCtx, sideB, 0, 0, content.width, content.height, options.fitMode || 'cover'); - - maskCanvas.width = content.width; - maskCanvas.height = content.height; - paintSplitMask(maskCtx, content.width, content.height, { + const maskOptions = { layoutFamily: options.layoutFamily || options.layout, layoutVariant: options.layoutVariant, splitPosition: options.splitPosition, @@ -212,8 +302,19 @@ export function createCompositor() { invertMask: options.invertMask, maskDensity: options.maskDensity, diagonalAngle: options.diagonalAngle, - }); + }; + ensureMask(content.width, content.height, maskOptions); + ctx.save(); + if (radius > 0) { + roundRectPath(ctx, content.x, content.y, content.width, content.height, radius); + ctx.clip(); + } + + ctx.drawImage(sideACanvas, content.x, content.y); + + clearCanvas(sideCtx, sideCanvas, content.width, content.height); + sideCtx.drawImage(sideBBaseCanvas, 0, 0); sideCtx.globalCompositeOperation = 'destination-in'; sideCtx.drawImage(maskCanvas, 0, 0); sideCtx.globalCompositeOperation = 'source-over'; @@ -227,13 +328,14 @@ export function createCompositor() { const width = Math.max(1, Math.round(options.width || 1280)); const height = Math.max(1, Math.round(options.height || 720)); - canvas.width = width; - canvas.height = height; + if (!ensureCanvasSize(canvas, width, height)) { + ctx.clearRect(0, 0, width, height); + } const { sideA, sideB } = resolveSides(options); if (!sideA && !sideB) { - paintBackground(ctx, width, height, options.background || { type: 'solid', bg: '#FAFAFA' }); + blitBackground(ctx, width, height, options.background || { type: 'solid', bg: '#FAFAFA' }); ctx.fillStyle = '#A3A3A3'; ctx.font = '500 18px Inter, sans-serif'; ctx.textAlign = 'center'; @@ -253,7 +355,7 @@ export function createCompositor() { return canvas; } - function paintLabels(ctx, width, height, options) { + function paintLabels(labelCtx, width, height, options) { const labels = options.labels; if (!labels?.enabled) { return; @@ -291,11 +393,11 @@ export function createCompositor() { return; } - ctx.save(); - ctx.font = `600 ${fontSize}px Inter, ui-sans-serif, system-ui, sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - const textWidth = ctx.measureText(text).width; + labelCtx.save(); + labelCtx.font = `600 ${fontSize}px Inter, ui-sans-serif, system-ui, sans-serif`; + labelCtx.textAlign = 'center'; + labelCtx.textBaseline = 'middle'; + const textWidth = labelCtx.measureText(text).width; const padX = fontSize * 0.72; const padY = fontSize * 0.42; const pillW = textWidth + padX * 2; @@ -308,12 +410,12 @@ export function createCompositor() { } const topY = y - pillH / 2; - ctx.fillStyle = 'rgba(23, 23, 23, 0.72)'; - roundRectPath(ctx, left, topY, pillW, pillH, pillH / 2); - ctx.fill(); - ctx.fillStyle = '#ffffff'; - ctx.fillText(text, left + pillW / 2, y + 0.5); - ctx.restore(); + labelCtx.fillStyle = 'rgba(23, 23, 23, 0.72)'; + roundRectPath(labelCtx, left, topY, pillW, pillH, pillH / 2); + labelCtx.fill(); + labelCtx.fillStyle = '#ffffff'; + labelCtx.fillText(text, left + pillW / 2, y + 0.5); + labelCtx.restore(); }; if (horizontal) { @@ -341,11 +443,10 @@ export function createCompositor() { // 1:1 full resolution preview (scroll the stage; do not downscale) const drawWidth = canvas.width; const drawHeight = canvas.height; - previewCanvas.width = drawWidth; - previewCanvas.height = drawHeight; - const pctx = previewCanvas.getContext('2d'); - pctx.clearRect(0, 0, drawWidth, drawHeight); + if (!ensureCanvasSize(previewCanvas, drawWidth, drawHeight)) { + pctx.clearRect(0, 0, drawWidth, drawHeight); + } pctx.imageSmoothingEnabled = false; pctx.drawImage(canvas, 0, 0); diff --git a/resources/js/patterns.js b/resources/js/patterns.js index ed1a2ed..442141b 100644 --- a/resources/js/patterns.js +++ b/resources/js/patterns.js @@ -92,7 +92,12 @@ export function paintBackground(ctx, width, height, options = {}) { } ctx.stroke(); } else if (type === 'noise') { - const image = ctx.createImageData(width, height); + const tileSize = 128; + const tile = document.createElement('canvas'); + tile.width = tileSize; + tile.height = tileSize; + const tctx = tile.getContext('2d'); + const image = tctx.createImageData(tileSize, tileSize); const data = image.data; const bgRgb = hexToRgb(bg) || { r: 250, g: 250, b: 250 }; const fgRgb = hexToRgb(fg) || { r: 229, g: 229, b: 229 }; @@ -104,7 +109,12 @@ export function paintBackground(ctx, width, height, options = {}) { data[i + 2] = Math.round(bgRgb.b * (1 - t) + fgRgb.b * t); data[i + 3] = 255; } - ctx.putImageData(image, 0, 0); + tctx.putImageData(image, 0, 0); + const pattern = ctx.createPattern(tile, 'repeat'); + if (pattern) { + ctx.fillStyle = pattern; + ctx.fillRect(0, 0, width, height); + } } ctx.restore(); diff --git a/resources/js/video-export.js b/resources/js/video-export.js new file mode 100644 index 0000000..bfdfd13 --- /dev/null +++ b/resources/js/video-export.js @@ -0,0 +1,137 @@ +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function isVideoMimeSupported(type) { + return typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(type); +} + +export function firstSupportedMime(types) { + return types.find((type) => isVideoMimeSupported(type)) || ''; +} + +export function pickVideoMimeType(videoContainer) { + const mp4Types = ['video/mp4;codecs=avc1.42E01E', 'video/mp4;codecs=avc1', 'video/mp4']; + const webmTypes = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']; + + if (videoContainer === 'mp4') { + const mimeType = firstSupportedMime(mp4Types); + return mimeType ? { mimeType, extension: 'mp4', label: 'MP4' } : null; + } + + if (videoContainer === 'webm') { + const mimeType = firstSupportedMime(webmTypes); + return mimeType ? { mimeType, extension: 'webm', label: 'WebM' } : null; + } + + const mp4 = firstSupportedMime(mp4Types); + if (mp4) { + return { mimeType: mp4, extension: 'mp4', label: 'MP4' }; + } + + const webm = firstSupportedMime(webmTypes); + return webm ? { mimeType: webm, extension: 'webm', label: 'WebM' } : null; +} + +/** + * Record a wipe transition A→B (or reverse) from the compositor canvas. + * + * @param {object} params + * @param {number} params.width + * @param {number} params.height + * @param {string} params.videoContainer + * @param {number} params.videoFps + * @param {number} params.videoDuration + * @param {boolean} params.videoReverse + * @param {{ render: Function, getCanvas: Function }} params.compositor + * @param {(width: number, height: number) => object} params.buildOptions + * @param {(message: string) => void} [params.onProgress] + */ +export async function recordWipeVideo({ + width, + height, + videoContainer, + videoFps, + videoDuration, + videoReverse, + compositor, + buildOptions, + onProgress, +}) { + if (typeof MediaRecorder === 'undefined') { + throw new Error('MediaRecorder is not supported in this environment.'); + } + + const picked = pickVideoMimeType(videoContainer); + if (!picked) { + const wanted = videoContainer === 'auto' ? 'MP4/WebM' : String(videoContainer).toUpperCase(); + throw new Error(`${wanted} video export is not supported in this environment.`); + } + + const { mimeType, extension, label } = picked; + const fps = videoFps; + const durationSec = videoDuration; + const frameCount = Math.max(2, Math.round(fps * durationSec)); + const frameDelay = 1000 / fps; + + const renderFrame = (split01) => { + const options = buildOptions(width, height); + options.splitPosition = clamp(split01, 0, 1); + compositor.render(options); + }; + + renderFrame(videoReverse ? 1 : 0); + + const canvas = compositor.getCanvas(); + const stream = canvas.captureStream(fps); + const track = stream.getVideoTracks()[0]; + if (!track) { + throw new Error('Could not capture a video track from the canvas.'); + } + + const bits = Math.round(Math.min(25_000_000, Math.max(6_000_000, width * height * 6))); + const recorder = new MediaRecorder(stream, { + mimeType, + videoBitsPerSecond: bits, + }); + const chunks = []; + recorder.ondataavailable = (event) => { + if (event.data?.size) { + chunks.push(event.data); + } + }; + + const stopped = new Promise((resolve, reject) => { + recorder.onstop = () => resolve(); + recorder.onerror = () => reject(recorder.error || new Error('Recording failed.')); + }); + + recorder.start(100); + + for (let i = 0; i <= frameCount; i++) { + const t = i / frameCount; + const split = videoReverse ? 1 - t : t; + renderFrame(split); + if (typeof track.requestFrame === 'function') { + track.requestFrame(); + } + onProgress?.(`Recording ${label}… ${Math.round((i / frameCount) * 100)}%`); + await wait(frameDelay); + } + + await wait(Math.max(frameDelay * 2, 250)); + recorder.stop(); + await stopped; + stream.getTracks().forEach((item) => item.stop()); + + const blob = new Blob(chunks, { type: extension === 'mp4' ? 'video/mp4' : 'video/webm' }); + if (!blob.size) { + throw new Error('Recording produced an empty file.'); + } + + return { blob, extension, label }; +} diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 91ed62b..2999ca5 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -608,7 +608,7 @@ class="slider-value" class="px-2.5 py-1.5 text-xs font-medium" :class="segmentClass(exportFormat === 'video')" :title="usesSplit ? 'Wipe animation at source resolution' : 'Video needs a split layout'" - @click="exportFormat = 'video'" + @click="selectExportFormat('video')" >Video From e38b489f4527859465550f67985d0382d1cac5e6 Mon Sep 17 00:00:00 2001 From: CodeWithDennis Date: Sun, 26 Jul 2026 13:38:59 +0200 Subject: [PATCH 2/2] fix: clear failed video-export import so retries work --- resources/js/app.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/resources/js/app.js b/resources/js/app.js index 28addcb..6abb290 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -195,10 +195,15 @@ document.addEventListener('alpine:init', () => { }, async loadVideoExport() { - if (!this._videoExportModule) { - this._videoExportModule = import('./video-export.js'); + try { + if (!this._videoExportModule) { + this._videoExportModule = import('./video-export.js'); + } + return await this._videoExportModule; + } catch (error) { + this._videoExportModule = null; + throw error; } - return this._videoExportModule; }, setTheme(theme) {