Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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)
28 changes: 21 additions & 7 deletions resources/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ document.addEventListener('alpine:init', () => {
videoFps: 30,
videoReverse: false,
videoContainer: 'auto',
videoTransition: 'wipe',
videoPreviewing: false,
_videoPreviewToken: 0,
exporting: false,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -381,6 +389,7 @@ document.addEventListener('alpine:init', () => {
videoFps: this.videoFps,
videoReverse: this.videoReverse,
videoContainer: this.videoContainer,
videoTransition: this.videoTransition,
};
},

Expand Down Expand Up @@ -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';
},
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;
Expand Down
154 changes: 154 additions & 0 deletions resources/js/compositor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 15 additions & 8 deletions resources/js/video-export.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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();
}
Expand All @@ -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' });
}
20 changes: 17 additions & 3 deletions resources/views/app.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'))"
></span>
<span class="text-xs text-zinc-400" x-show="videoPreviewing" x-cloak>Playing wipe preview</span>
<span class="text-xs text-zinc-400" x-show="videoPreviewing" x-cloak x-text="'Playing ' + (videoTransitionOptions.find((item) => item.id === videoTransition)?.label || 'video').toLowerCase() + ' preview'"></span>
<span class="text-xs text-lumis-status-uploading" x-show="sizeWarning" x-text="sizeWarning" x-cloak></span>
<button
type="button"
Expand Down Expand Up @@ -607,7 +607,7 @@ class="slider-value"
type="button"
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'"
:title="usesSplit ? 'Transition video at source resolution' : 'Video needs a split layout'"
@click="selectExportFormat('video')"
>Video</button>
</div>
Expand All @@ -633,8 +633,22 @@ class="slider-value"
</div>

<div class="mt-3 space-y-3" x-show="exportFormat === 'video'" x-cloak>
<p class="text-xs text-zinc-400" x-show="usesSplit" x-cloak>Wipe animation · source resolution</p>
<p class="text-xs text-zinc-400" x-show="usesSplit" x-cloak>Transition · source resolution</p>
<p class="text-xs text-lumis-status-uploading" x-show="!usesSplit" x-cloak>Switch to Vertical, Horizontal, or Diagonal for video.</p>
<div x-show="usesSplit" x-cloak>
<p class="mb-1.5 text-xs font-medium text-lumis-ink">Transition</p>
<div class="flex flex-wrap gap-1.5">
<template x-for="option in videoTransitionOptions" :key="option.id">
<button
type="button"
class="px-2.5 py-1.5 text-xs font-medium"
:class="segmentClass(videoTransition === option.id)"
@click="stopVideoPreview(); videoTransition = option.id"
x-text="option.label"
></button>
</template>
</div>
</div>
<div x-show="usesSplit" x-cloak>
<p class="mb-1.5 text-xs font-medium text-lumis-ink">Container</p>
<div class="flex flex-wrap gap-1.5">
Expand Down
Loading