diff --git a/README.md b/README.md index d9a16fe..3b8e92c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Pairframe -NativePHP desktop app to compose two screenshots into split or overlap thumbnails, with background patterns and PNG, JPG, or wipe-video export. +NativePHP desktop app to compose two screenshots into split or overlap thumbnails, with background patterns and PNG, JPG, or transition-video export. ![Pairframe desktop app with diagonal wavy split preview and video export selected](docs/pairframe.png) @@ -97,4 +97,4 @@ Local builds are unsigned unless you configure Apple or Windows code signing. Pr - Theme (light / dark / auto), side labels, badge, and swap sides - Background patterns with custom colors and density - Save / load settings presets (images are not stored) -- Export PNG or JPG at optional scale (1×, 1.5×, 2×), or wipe video (MP4 / WebM) +- Export PNG or JPG at optional scale (1×, 1.5×, 2×), or transition video (wipe, dissolve, push, iris, reveal → MP4 / WebM) diff --git a/resources/js/app.js b/resources/js/app.js index 6abb290..cdff2bd 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -57,6 +57,7 @@ document.addEventListener('alpine:init', () => { videoFps: 30, videoReverse: false, videoContainer: 'auto', + videoTransition: 'wipe', videoPreviewing: false, _videoPreviewToken: 0, exporting: false, @@ -75,6 +76,14 @@ document.addEventListener('alpine:init', () => { { id: 'webm', label: 'WebM' }, ], + videoTransitionOptions: [ + { id: 'wipe', label: 'Wipe' }, + { id: 'dissolve', label: 'Dissolve' }, + { id: 'push', label: 'Push' }, + { id: 'iris', label: 'Iris' }, + { id: 'reveal', label: 'Reveal' }, + ], + presets: [], presetName: '', activePresetId: null, @@ -312,10 +321,9 @@ document.addEventListener('alpine:init', () => { } const t = i / frameCount; - const split = this.videoReverse ? 1 - t : t; + const progress = this.videoReverse ? 1 - t : t; const options = this.buildOptions(this.baseWidth, this.baseHeight); - options.splitPosition = clamp(split, 0, 1); - this.compositor.render(options); + this.compositor.renderVideoFrame(options, this.videoTransition, clamp(progress, 0, 1)); if (preview) { this.previewMetrics = this.compositor.drawPreview(preview); @@ -381,6 +389,7 @@ document.addEventListener('alpine:init', () => { videoFps: this.videoFps, videoReverse: this.videoReverse, videoContainer: this.videoContainer, + videoTransition: this.videoTransition, }; }, @@ -485,6 +494,10 @@ document.addEventListener('alpine:init', () => { if (['auto', 'mp4', 'webm'].includes(settings.videoContainer)) { this.videoContainer = settings.videoContainer; } + const transitions = new Set(this.videoTransitionOptions.map((item) => item.id)); + if (transitions.has(settings.videoTransition)) { + this.videoTransition = settings.videoTransition; + } this.previewTool = this.layout === 'diagonal' ? 'angle' : 'position'; }, @@ -802,15 +815,16 @@ document.addEventListener('alpine:init', () => { } }, - async recordWipeVideo(width, height) { - const { recordWipeVideo } = await this.loadVideoExport(); - return recordWipeVideo({ + async recordTransitionVideo(width, height) { + const { recordTransitionVideo } = await this.loadVideoExport(); + return recordTransitionVideo({ width, height, videoContainer: this.videoContainer, videoFps: this.videoFps, videoDuration: this.videoDuration, videoReverse: this.videoReverse, + videoTransition: this.videoTransition, compositor: this.compositor, buildOptions: (w, h) => this.buildOptions(w, h), onProgress: (message) => { @@ -1082,7 +1096,7 @@ document.addEventListener('alpine:init', () => { const { width, height } = this.resolveVideoSize(); this.statusMessage = 'Recording video…'; - const { blob, extension, label } = await this.recordWipeVideo(width, height); + const { blob, extension, label } = await this.recordTransitionVideo(width, height); this.downloadBlob(blob, `pairframe-${width}x${height}-${stamp}.${extension}`); this.statusMessage = `Exported ${label} (${width} × ${height}).`; return; diff --git a/resources/js/compositor.js b/resources/js/compositor.js index d3369a3..ac3a044 100644 --- a/resources/js/compositor.js +++ b/resources/js/compositor.js @@ -81,12 +81,17 @@ export function createCompositor() { const sideACtx = sideACanvas.getContext('2d', { willReadFrequently: false }); const sideBBaseCanvas = document.createElement('canvas'); const sideBBaseCtx = sideBBaseCanvas.getContext('2d', { willReadFrequently: false }); + const frameACanvas = document.createElement('canvas'); + const frameACtx = frameACanvas.getContext('2d', { willReadFrequently: false }); + const frameBCanvas = document.createElement('canvas'); + const frameBCtx = frameBCanvas.getContext('2d', { willReadFrequently: false }); let lastOptions = null; let bgKey = ''; let sideAKey = ''; let sideBKey = ''; let maskKey = ''; + let transitionFrameKey = ''; function resolveSides(options) { const light = options.lightImage; @@ -487,8 +492,157 @@ export function createCompositor() { return { axis: 'x', position: split, padding }; } + function transitionFramesCacheKey(options, width, height) { + const bg = options.background || {}; + const light = options.lightImage; + const dark = options.darkImage; + return [ + width, + height, + options.layoutFamily || options.layout || '', + options.layoutVariant || '', + Boolean(options.swapSides) ? 1 : 0, + Boolean(options.flipDirection) ? 1 : 0, + Boolean(options.invertMask) ? 1 : 0, + Number(options.maskDensity) || 0, + Number(options.diagonalAngle) || 0, + Number(options.imagePadding) || 0, + Number(options.imageRadius) || 0, + options.fitMode || 'cover', + bg.type || '', + bg.bg || '', + bg.fg || '', + Number(bg.density) || 0, + light?.src || '', + light?.naturalWidth || light?.width || 0, + light?.naturalHeight || light?.height || 0, + dark?.src || '', + dark?.naturalWidth || dark?.width || 0, + dark?.naturalHeight || dark?.height || 0, + ].join('|'); + } + + function ensureTransitionFrames(options, width, height) { + const key = transitionFramesCacheKey(options, width, height); + if ( + key === transitionFrameKey && + frameACanvas.width === width && + frameACanvas.height === height && + frameBCanvas.width === width && + frameBCanvas.height === height + ) { + return; + } + + const unlabeled = { + ...options, + width, + height, + labels: { ...(options.labels || {}), enabled: false }, + }; + + render({ ...unlabeled, splitPosition: 0 }); + ensureCanvasSize(frameACanvas, width, height); + frameACtx.drawImage(canvas, 0, 0); + + render({ ...unlabeled, splitPosition: 1 }); + ensureCanvasSize(frameBCanvas, width, height); + frameBCtx.drawImage(canvas, 0, 0); + + transitionFrameKey = key; + } + + function compositeTransition(transition, progress, width, height, layoutFamily) { + const t = clamp(progress, 0, 1); + + if (!ensureCanvasSize(canvas, width, height)) { + ctx.clearRect(0, 0, width, height); + } + + if (transition === 'dissolve') { + ctx.drawImage(frameACanvas, 0, 0); + if (t > 0) { + ctx.save(); + ctx.globalAlpha = t; + ctx.drawImage(frameBCanvas, 0, 0); + ctx.restore(); + } + return; + } + + if (transition === 'push') { + const horizontal = layoutFamily === 'horizontal'; + if (horizontal) { + const offset = Math.round(t * height); + ctx.drawImage(frameACanvas, 0, -offset); + ctx.drawImage(frameBCanvas, 0, height - offset); + } else { + const offset = Math.round(t * width); + ctx.drawImage(frameACanvas, -offset, 0); + ctx.drawImage(frameBCanvas, width - offset, 0); + } + return; + } + + if (transition === 'iris') { + ctx.drawImage(frameACanvas, 0, 0); + if (t <= 0) { + return; + } + const radius = (t * Math.hypot(width, height)) / 2; + ctx.save(); + ctx.beginPath(); + ctx.arc(width / 2, height / 2, radius, 0, Math.PI * 2); + ctx.clip(); + ctx.drawImage(frameBCanvas, 0, 0); + ctx.restore(); + return; + } + + // reveal: expanding rectangle from center + ctx.drawImage(frameACanvas, 0, 0); + if (t <= 0) { + return; + } + const rw = Math.max(1, Math.round(t * width)); + const rh = Math.max(1, Math.round(t * height)); + const rx = Math.round((width - rw) / 2); + const ry = Math.round((height - rh) / 2); + ctx.save(); + ctx.beginPath(); + ctx.rect(rx, ry, rw, rh); + ctx.clip(); + ctx.drawImage(frameBCanvas, 0, 0); + ctx.restore(); + } + + /** + * Render one video transition frame. progress 0 = side A, 1 = side B. + * wipe reuses the layout split mask; other modes composite full A/B frames. + */ + function renderVideoFrame(options, transition = 'wipe', progress = 0) { + const t = clamp(Number(progress) || 0, 0, 1); + const mode = transition || 'wipe'; + + if (mode === 'wipe') { + return render({ ...options, splitPosition: t }); + } + + const width = Math.max(1, Math.round(options.width || 1280)); + const height = Math.max(1, Math.round(options.height || 720)); + const family = options.layoutFamily || options.layout || 'vertical'; + + ensureTransitionFrames(options, width, height); + compositeTransition(mode, t, width, height, family); + paintLabels(ctx, width, height, options); + lastOptions = options; + + return canvas; + } + return { render, + renderVideoFrame, drawPreview, exportBlob, getCanvas, diff --git a/resources/js/video-export.js b/resources/js/video-export.js index bfdfd13..5e4cdf9 100644 --- a/resources/js/video-export.js +++ b/resources/js/video-export.js @@ -38,7 +38,7 @@ export function pickVideoMimeType(videoContainer) { } /** - * Record a wipe transition A→B (or reverse) from the compositor canvas. + * Record an A→B (or reverse) transition from the compositor canvas. * * @param {object} params * @param {number} params.width @@ -47,17 +47,19 @@ export function pickVideoMimeType(videoContainer) { * @param {number} params.videoFps * @param {number} params.videoDuration * @param {boolean} params.videoReverse - * @param {{ render: Function, getCanvas: Function }} params.compositor + * @param {string} [params.videoTransition] + * @param {{ renderVideoFrame: Function, getCanvas: Function }} params.compositor * @param {(width: number, height: number) => object} params.buildOptions * @param {(message: string) => void} [params.onProgress] */ -export async function recordWipeVideo({ +export async function recordTransitionVideo({ width, height, videoContainer, videoFps, videoDuration, videoReverse, + videoTransition = 'wipe', compositor, buildOptions, onProgress, @@ -77,11 +79,11 @@ export async function recordWipeVideo({ const durationSec = videoDuration; const frameCount = Math.max(2, Math.round(fps * durationSec)); const frameDelay = 1000 / fps; + const transition = videoTransition || 'wipe'; - const renderFrame = (split01) => { + const renderFrame = (progress) => { const options = buildOptions(width, height); - options.splitPosition = clamp(split01, 0, 1); - compositor.render(options); + compositor.renderVideoFrame(options, transition, clamp(progress, 0, 1)); }; renderFrame(videoReverse ? 1 : 0); @@ -114,8 +116,8 @@ export async function recordWipeVideo({ for (let i = 0; i <= frameCount; i++) { const t = i / frameCount; - const split = videoReverse ? 1 - t : t; - renderFrame(split); + const progress = videoReverse ? 1 - t : t; + renderFrame(progress); if (typeof track.requestFrame === 'function') { track.requestFrame(); } @@ -135,3 +137,8 @@ export async function recordWipeVideo({ return { blob, extension, label }; } + +/** @deprecated Use recordTransitionVideo */ +export async function recordWipeVideo(params) { + return recordTransitionVideo({ ...params, videoTransition: params.videoTransition || 'wipe' }); +} diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 2999ca5..cc1cb43 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -476,7 +476,7 @@ class="text-xs text-zinc-400" x-cloak x-text="previewSizeLabel + (layout === 'overlap' ? '' : (layout === 'diagonal' ? (previewTool === 'angle' ? ' · drag to rotate' : ' · drag to move') : ' · drag to fine-tune'))" > - Playing wipe preview + @@ -633,8 +633,22 @@ class="slider-value"
-

Wipe animation · source resolution

+

Transition · source resolution

Switch to Vertical, Horizontal, or Diagonal for video.

+
+

Transition

+
+ +
+

Container