diff --git a/README.md b/README.md index 068cdee..f96fdb3 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Pairframe -NativePHP desktop app to compose two screenshots into split or overlap thumbnails, with background patterns and PNG, JPG, or transition-video export. +NativePHP desktop app to compose two screenshots into split or overlap thumbnails, with background patterns, overlays, and PNG, JPG, GIF, or transition-video export. - + **Heads up: this project is AI-assisted.** A lot of the code and docs were written with AI tools (reviewed and steered by me). Pairframe exists first for my own thumbnail workflow; I open-sourced it in case it helps someone else. Issues and pull requests are welcome, just know what you're looking at. @@ -94,7 +94,10 @@ Local builds are unsigned unless you configure Apple or Windows code signing. Pr - Layouts: vertical, horizontal, diagonal, and overlap (cards, side, stack) - Split edge styles (vertical / horizontal / diagonal): straight, wavy, zigzag, scallop, soft, torn, pixel (with density) - Split position, angle, padding, border radius, and optional shadow on overlap -- Theme (light / dark / auto), side labels, badge, and swap sides -- Background patterns with custom colors and density +- Theme (light / dark / auto), per-image labels (9-point presets + custom X/Y), badge, and swap sides +- Background patterns with custom colors and density (optional per-side A/B) +- Overlay pattern marks only (dots, grid, and more) with color, opacity, and density — no base fill; optional per-side A/B +- Collapsible sidebar groups with scrollable panels and per-group reset (shown when dirty) +- Undo / redo for settings (Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z) — images are not in the history - Save / load settings presets (images are not stored) -- Export PNG or JPG at optional scale (1×, 1.5×, 2×), or transition video (wipe, dissolve, push, iris, reveal → MP4 / WebM) +- Export PNG or JPG at optional scale (1×, 1.5×, 2×), transition video (MP4 / WebM), or animated GIF — with easing and hold-start/hold-end diff --git a/docs/pairframe.png b/docs/pairframe.png index 358ab31..e20607e 100644 Binary files a/docs/pairframe.png and b/docs/pairframe.png differ diff --git a/package-lock.json b/package-lock.json index 86cc79a..fedfe5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,8 @@ "": { "name": "pairframe", "dependencies": { - "alpinejs": "^3.15.12" + "alpinejs": "^3.15.12", + "gifenc": "^1.0.3" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", @@ -876,6 +877,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gifenc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz", + "integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==", + "license": "MIT" + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", diff --git a/package.json b/package.json index d8eb066..c9bfe79 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "vite": "^8.0.0" }, "dependencies": { - "alpinejs": "^3.15.12" + "alpinejs": "^3.15.12", + "gifenc": "^1.0.3" } } diff --git a/resources/css/app.css b/resources/css/app.css index cbf1e6b..d52ba6f 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -70,6 +70,8 @@ @layer base { html { + height: 100%; + overflow: hidden; font-size: 110%; color-scheme: light; } @@ -98,18 +100,26 @@ } input.slider-value { - width: 3.25rem; + width: auto; + min-width: 1ch; margin: 0; - padding: 0; - border: 0; - border-radius: 0; - background: transparent; + padding: 0.1rem 0.3rem; + border: 1px solid transparent; + border-radius: var(--radius-subtle); + background: color-mix(in srgb, var(--color-lumis-ink) 5%, transparent); color: #a1a1aa; font-size: 0.75rem; line-height: 1rem; text-align: right; outline: none; + field-sizing: content; -moz-appearance: textfield; + cursor: text; + } + + input.slider-value:hover { + color: var(--color-lumis-ink); + background: color-mix(in srgb, var(--color-lumis-ink) 8%, transparent); } input.preset-name-input, @@ -140,6 +150,40 @@ input.slider-value:focus { color: var(--color-lumis-ink); + border-color: var(--color-lumis-panel-line); + background: color-mix(in srgb, var(--color-lumis-ink) 8%, transparent); + } + + /* Keep number + unit in one subtle chip when wrapped together */ + .slider-value-wrap { + gap: 0.05rem; + padding: 0.1rem 0.3rem; + border: 1px solid transparent; + border-radius: var(--radius-subtle); + background: color-mix(in srgb, var(--color-lumis-ink) 5%, transparent); + } + + .slider-value-wrap:hover { + background: color-mix(in srgb, var(--color-lumis-ink) 8%, transparent); + } + + .slider-value-wrap:focus-within { + border-color: var(--color-lumis-panel-line); + background: color-mix(in srgb, var(--color-lumis-ink) 8%, transparent); + } + + .slider-value-wrap input.slider-value { + width: auto; + min-width: 1ch; + padding: 0; + border: 0; + background: transparent; + field-sizing: content; + } + + .slider-value-wrap input.slider-value:hover, + .slider-value-wrap input.slider-value:focus { + background: transparent; } input.slider-value::-webkit-inner-spin-button, @@ -189,4 +233,94 @@ border: none; border-radius: calc(var(--radius-subtle) - 1px); } + + .sidebar-scroll { + flex: 1 1 0%; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + display: flex; + flex-direction: column; + -webkit-overflow-scrolling: touch; + } + + .sidebar-group { + flex-shrink: 0; + border-bottom: 1px solid var(--color-lumis-panel-line); + background: var(--color-lumis-panel-surface); + } + + .sidebar-group.is-collapsed { + background: var(--color-lumis-segment-idle); + } + + .sidebar-group.is-collapsed .sidebar-group-toggle { + color: color-mix(in srgb, var(--color-lumis-ink) 72%, transparent); + } + + .sidebar-group-header { + display: flex; + align-items: stretch; + } + + .sidebar-group-toggle { + display: flex; + min-width: 0; + flex: 1 1 auto; + cursor: pointer; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.5rem 0.625rem 0.75rem; + text-align: left; + border-radius: 0; + background: transparent; + } + + .sidebar-group-toggle:hover, + .sidebar-group-reset:hover { + background: color-mix(in srgb, var(--color-lumis-ink) 4%, transparent); + } + + .dark .sidebar-group-toggle:hover, + .dark .sidebar-group-reset:hover { + background: color-mix(in srgb, #ffffff 8%, transparent); + } + + .sidebar-group-reset { + display: inline-flex; + width: 2rem; + flex-shrink: 0; + cursor: pointer; + align-items: center; + justify-content: center; + border-radius: 0; + color: #a3a3a3; + background: transparent; + } + + .sidebar-group-reset:hover { + color: var(--color-lumis-ink); + } + + .sidebar-group-body { + border-top: 1px solid var(--color-lumis-panel-line); + padding: 0.75rem; + background: var(--color-lumis-panel-surface); + } + + .label-preset-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.25rem; + } + + .label-preset-cell { + min-height: 1.75rem; + padding: 0.25rem 0.125rem; + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1; + } } diff --git a/resources/js/app.js b/resources/js/app.js index 0a67a2c..4ce9a3a 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -3,6 +3,63 @@ import { createCompositor } from './compositor.js'; const PRESETS_STORAGE_KEY = 'pairframe.presets'; const THEME_STORAGE_KEY = 'pairframe.theme'; +const PANELS_STORAGE_KEY = 'pairframe.panels.v2'; +const DEFAULT_PANELS = { + layout: true, + split: true, + adjust: false, + labels: false, + background: true, + overlay: false, + export: true, +}; + +const LABEL_PRESET_IDS = [ + 'top-left', + 'top-center', + 'top-right', + 'middle-left', + 'middle-center', + 'middle-right', + 'bottom-left', + 'bottom-center', + 'bottom-right', +]; + +const LABEL_PRESET_ANCHORS = { + 'top-left': { x: 0, y: 0 }, + 'top-center': { x: 50, y: 0 }, + 'top-right': { x: 100, y: 0 }, + 'middle-left': { x: 0, y: 50 }, + 'middle-center': { x: 50, y: 50 }, + 'middle-right': { x: 100, y: 50 }, + 'bottom-left': { x: 0, y: 100 }, + 'bottom-center': { x: 50, y: 100 }, + 'bottom-right': { x: 100, y: 100 }, +}; + +const LABEL_PRESET_SET = new Set(LABEL_PRESET_IDS); +const DEFAULT_DIAGONAL_ANGLE = Math.round(((Math.atan2(9, 16) * 180) / Math.PI) * 10) / 10; +const HISTORY_LIMIT = 50; +const HISTORY_DEBOUNCE_MS = 300; + +function labelPresetAnchor(preset) { + return LABEL_PRESET_ANCHORS[preset] || LABEL_PRESET_ANCHORS['bottom-left']; +} + +function migrateLegacyLabelPreset(oldPosition, side) { + const axis = oldPosition === 'top' || oldPosition === 'middle' || oldPosition === 'bottom' ? oldPosition : null; + if (!axis) { + return null; + } + if (side === 'a') { + return axis === 'top' ? 'top-left' : axis === 'middle' ? 'middle-left' : 'bottom-left'; + } + if (side === 'b') { + return axis === 'top' ? 'top-right' : axis === 'middle' ? 'middle-right' : 'bottom-right'; + } + return axis === 'top' ? 'top-center' : axis === 'middle' ? 'middle-center' : 'bottom-center'; +} document.addEventListener('alpine:init', () => { Alpine.data('pairframe', () => ({ @@ -18,7 +75,7 @@ document.addEventListener('alpine:init', () => { baseHeight: 720, layout: 'vertical', - diagonalAngle: Math.round(((Math.atan2(9, 16) * 180) / Math.PI) * 10) / 10, + diagonalAngle: DEFAULT_DIAGONAL_ANGLE, diagonalStyle: 'straight', diagonalDensity: 1.5, splitStyle: 'straight', @@ -32,24 +89,56 @@ document.addEventListener('alpine:init', () => { imageRadius: 0, labelsEnabled: false, - labelLeft: 'Left', - labelRight: 'Right', + labelA: 'Left', + labelB: 'Right', labelBadge: '', - labelLeftPosition: 'bottom', - labelRightPosition: 'bottom', - labelBadgePosition: 'top', + labelAMode: 'preset', + labelAPreset: 'bottom-left', + labelAX: 0, + labelAY: 100, + labelBMode: 'preset', + labelBPreset: 'bottom-right', + labelBX: 100, + labelBY: 100, + labelBadgeMode: 'preset', + labelBadgePreset: 'top-center', + labelBadgeX: 50, + labelBadgeY: 0, labelSize: 100, - labelPositions: [ - { id: 'top', label: 'Top', labelHorizontal: 'Left' }, - { id: 'middle', label: 'Middle', labelHorizontal: 'Center' }, - { id: 'bottom', label: 'Bottom', labelHorizontal: 'Right' }, + labelPresetOptions: [ + { id: 'top-left', short: 'TL' }, + { id: 'top-center', short: 'TC' }, + { id: 'top-right', short: 'TR' }, + { id: 'middle-left', short: 'ML' }, + { id: 'middle-center', short: 'MC' }, + { id: 'middle-right', short: 'MR' }, + { id: 'bottom-left', short: 'BL' }, + { id: 'bottom-center', short: 'BC' }, + { id: 'bottom-right', short: 'BR' }, ], - backgroundType: 'dots', + backgroundType: 'none', backgroundBg: '#FAFAFA', backgroundFg: '#E5E5E5', backgroundDensity: 24, + backgroundPerSide: false, + backgroundEditSide: 'a', + backgroundBType: 'none', + backgroundBBg: '#FAFAFA', + backgroundBFg: '#E5E5E5', + backgroundBDensity: 24, + + overlayType: 'none', + overlayColor: '#171717', + overlayOpacity: 25, + overlayDensity: 24, + overlayPerSide: false, + overlayEditSide: 'a', + overlayBType: 'none', + overlayBColor: '#171717', + overlayBOpacity: 25, + overlayBDensity: 24, exportScale: '1', exportFormat: 'png', @@ -59,12 +148,21 @@ document.addEventListener('alpine:init', () => { videoReverse: false, videoContainer: 'auto', videoTransition: 'wipe', + videoEasing: 'linear', + videoHoldStart: 0.25, + videoHoldEnd: 0.25, videoPreviewing: false, _videoPreviewToken: 0, exporting: false, statusMessage: '', theme: 'auto', + historyPast: [], + historyFuture: [], + _historyPaused: false, + _historyTimer: 0, + _historyReady: false, + videoFpsOptions: [ { id: 24, label: '24' }, { id: 30, label: '30' }, @@ -85,6 +183,13 @@ document.addEventListener('alpine:init', () => { { id: 'reveal', label: 'Reveal' }, ], + videoEasingOptions: [ + { id: 'linear', label: 'Linear' }, + { id: 'ease-in-out', label: 'Ease in-out' }, + ], + + panelOpen: { ...DEFAULT_PANELS }, + presets: [], presetName: '', activePresetId: null, @@ -125,7 +230,17 @@ document.addEventListener('alpine:init', () => { ], backgroundOptions: [ - { id: 'solid', label: 'Solid' }, + { id: 'none', label: 'None' }, + { id: 'dots', label: 'Dots' }, + { id: 'grid', label: 'Grid' }, + { id: 'stripes', label: 'Stripes' }, + { id: 'diagonal', label: 'Diagonal' }, + { id: 'chevron', label: 'Chevron' }, + { id: 'noise', label: 'Noise' }, + ], + + overlayOptions: [ + { id: 'none', label: 'None' }, { id: 'dots', label: 'Dots' }, { id: 'grid', label: 'Grid' }, { id: 'stripes', label: 'Stripes' }, @@ -146,7 +261,11 @@ document.addEventListener('alpine:init', () => { } }; this._themeMedia.addEventListener('change', this._onThemeMedia); + this.loadPanels(); this.loadPresets(); + this.historyPast = [this.cloneSettings(this.captureSettings())]; + this.historyFuture = []; + this._historyReady = true; this.$watch( () => [ this.layout, @@ -161,17 +280,40 @@ document.addEventListener('alpine:init', () => { this.imagePadding, this.imageRadius, this.labelsEnabled, - this.labelLeft, - this.labelRight, + this.labelA, + this.labelB, this.labelBadge, - this.labelLeftPosition, - this.labelRightPosition, - this.labelBadgePosition, + this.labelAMode, + this.labelAPreset, + this.labelAX, + this.labelAY, + this.labelBMode, + this.labelBPreset, + this.labelBX, + this.labelBY, + this.labelBadgeMode, + this.labelBadgePreset, + this.labelBadgeX, + this.labelBadgeY, this.labelSize, this.backgroundType, this.backgroundBg, this.backgroundFg, this.backgroundDensity, + this.backgroundPerSide, + this.backgroundBType, + this.backgroundBBg, + this.backgroundBFg, + this.backgroundBDensity, + this.overlayType, + this.overlayColor, + this.overlayOpacity, + this.overlayDensity, + this.overlayPerSide, + this.overlayBType, + this.overlayBColor, + this.overlayBOpacity, + this.overlayBDensity, this.baseWidth, this.baseHeight, this.imageA, @@ -184,10 +326,121 @@ document.addEventListener('alpine:init', () => { this.scheduleRender(); }, ); + this.$watch( + () => JSON.stringify(this.captureSettings()), + () => { + if (!this._historyReady) { + return; + } + this.scheduleHistoryCommit(); + }, + ); this.$nextTick(() => this.scheduleRender()); this._onResize = () => this.updateHandle(); window.addEventListener('resize', this._onResize); + this._onHistoryKeydown = (event) => this.handleHistoryShortcut(event); + window.addEventListener('keydown', this._onHistoryKeydown); + }, + + cloneSettings(settings) { + return JSON.parse(JSON.stringify(settings || {})); + }, + + get canUndo() { + return this.historyPast.length > 1; + }, + + get canRedo() { + return this.historyFuture.length > 0; + }, + + scheduleHistoryCommit() { + if (this._historyPaused || this.exporting || this.videoPreviewing || !this._historyReady) { + return; + } + if (this._historyTimer) { + clearTimeout(this._historyTimer); + } + this._historyTimer = setTimeout(() => { + this._historyTimer = 0; + this.commitHistoryNow(); + }, HISTORY_DEBOUNCE_MS); + }, + + commitHistoryNow() { + if (this._historyPaused || this.exporting || this.videoPreviewing || !this._historyReady) { + return; + } + if (this._historyTimer) { + clearTimeout(this._historyTimer); + this._historyTimer = 0; + } + const snap = this.cloneSettings(this.captureSettings()); + const last = this.historyPast[this.historyPast.length - 1]; + if (last && JSON.stringify(last) === JSON.stringify(snap)) { + return; + } + this.historyPast.push(snap); + if (this.historyPast.length > HISTORY_LIMIT) { + this.historyPast.splice(0, this.historyPast.length - HISTORY_LIMIT); + } + this.historyFuture = []; + }, + + undo() { + if (!this.canUndo) { + return; + } + if (this._historyTimer) { + clearTimeout(this._historyTimer); + this._historyTimer = 0; + } + this._historyPaused = true; + const current = this.historyPast.pop(); + this.historyFuture.push(current); + const previous = this.historyPast[this.historyPast.length - 1]; + this.applySettings(this.cloneSettings(previous)); + this._historyPaused = false; + this.scheduleRender(); + }, + + redo() { + if (!this.canRedo) { + return; + } + if (this._historyTimer) { + clearTimeout(this._historyTimer); + this._historyTimer = 0; + } + this._historyPaused = true; + const next = this.historyFuture.pop(); + this.historyPast.push(next); + this.applySettings(this.cloneSettings(next)); + this._historyPaused = false; + this.scheduleRender(); + }, + + handleHistoryShortcut(event) { + const target = event.target; + const tag = String(target?.tagName || '').toUpperCase(); + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) { + return; + } + const mod = event.metaKey || event.ctrlKey; + if (!mod) { + return; + } + const key = String(event.key || '').toLowerCase(); + if (key === 'z' && !event.shiftKey) { + event.preventDefault(); + this.undo(); + return; + } + if ((key === 'z' && event.shiftKey) || key === 'y') { + event.preventDefault(); + this.redo(); + } }, scheduleRender() { @@ -235,19 +488,24 @@ document.addEventListener('alpine:init', () => { if (!this.imageA || !this.imageB) { return false; } - if (this.exportFormat === 'video') { + if (this.exportFormat === 'video' || this.exportFormat === 'gif') { return this.usesSplit; } return this.exportFormat === 'png' || this.exportFormat === 'jpg'; }, + get usesMotionExport() { + return this.exportFormat === 'video' || this.exportFormat === 'gif'; + }, + get canPreviewVideo() { - return this.hasBothImages && this.usesSplit && this.exportFormat === 'video' && !this.exporting; + return this.hasBothImages && this.usesSplit && this.usesMotionExport && !this.exporting; }, get exportSizeLabel() { - const { width, height } = - this.exportFormat === 'video' ? this.resolveVideoSize() : this.resolveExportSize(); + const { width, height } = this.usesMotionExport + ? this.resolveVideoSize() + : this.resolveExportSize(); return `${width} × ${height}`; }, @@ -285,6 +543,303 @@ document.addEventListener('alpine:init', () => { this.diagonalStyle = id; }, + loadPanels() { + try { + const raw = localStorage.getItem(PANELS_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : null; + if (!parsed || typeof parsed !== 'object') { + return; + } + for (const key of Object.keys(DEFAULT_PANELS)) { + if (typeof parsed[key] === 'boolean') { + this.panelOpen[key] = parsed[key]; + } + } + } catch { + // keep defaults + } + }, + + togglePanel(id) { + if (!Object.hasOwn(this.panelOpen, id)) { + return; + } + this.panelOpen[id] = !this.panelOpen[id]; + localStorage.setItem(PANELS_STORAGE_KEY, JSON.stringify(this.panelOpen)); + }, + + sameColor(a, b) { + return String(a || '').toLowerCase() === String(b || '').toLowerCase(); + }, + + groupDirty(id) { + if (id === 'layout') { + return ( + this.layout !== 'vertical' || + this.splitStyle !== 'straight' || + this.diagonalStyle !== 'straight' || + Number(this.diagonalAngle) !== DEFAULT_DIAGONAL_ANGLE || + Number(this.diagonalDensity) !== 1.5 || + this.overlapVariant !== 'cards' || + Number(this.overlapOffset) !== 12 || + this.overlapShadow !== true + ); + } + if (id === 'split') { + return Number(this.splitPosition) !== 50; + } + if (id === 'adjust') { + return this.swapSides || Number(this.imagePadding) !== 0 || Number(this.imageRadius) !== 0; + } + if (id === 'labels') { + return ( + this.labelsEnabled || + this.labelA !== 'Left' || + this.labelB !== 'Right' || + this.labelBadge !== '' || + this.labelAMode !== 'preset' || + this.labelAPreset !== 'bottom-left' || + Number(this.labelAX) !== 0 || + Number(this.labelAY) !== 100 || + this.labelBMode !== 'preset' || + this.labelBPreset !== 'bottom-right' || + Number(this.labelBX) !== 100 || + Number(this.labelBY) !== 100 || + this.labelBadgeMode !== 'preset' || + this.labelBadgePreset !== 'top-center' || + Number(this.labelBadgeX) !== 50 || + Number(this.labelBadgeY) !== 0 || + Number(this.labelSize) !== 100 + ); + } + if (id === 'background') { + return ( + this.backgroundType !== 'none' || + !this.sameColor(this.backgroundBg, '#FAFAFA') || + !this.sameColor(this.backgroundFg, '#E5E5E5') || + Number(this.backgroundDensity) !== 24 || + this.backgroundPerSide || + this.backgroundBType !== 'none' || + !this.sameColor(this.backgroundBBg, '#FAFAFA') || + !this.sameColor(this.backgroundBFg, '#E5E5E5') || + Number(this.backgroundBDensity) !== 24 + ); + } + if (id === 'overlay') { + return ( + this.overlayType !== 'none' || + !this.sameColor(this.overlayColor, '#171717') || + Number(this.overlayOpacity) !== 25 || + Number(this.overlayDensity) !== 24 || + this.overlayPerSide || + this.overlayBType !== 'none' || + !this.sameColor(this.overlayBColor, '#171717') || + Number(this.overlayBOpacity) !== 25 || + Number(this.overlayBDensity) !== 24 + ); + } + if (id === 'export') { + return ( + String(this.exportScale) !== '1' || + this.exportFormat !== 'png' || + Number(this.jpgQuality) !== 92 || + Number(this.videoDuration) !== 2 || + Number(this.videoFps) !== 30 || + this.videoReverse || + this.videoContainer !== 'auto' || + this.videoTransition !== 'wipe' || + this.videoEasing !== 'linear' || + Number(this.videoHoldStart) !== 0.25 || + Number(this.videoHoldEnd) !== 0.25 + ); + } + return false; + }, + + resetGroup(id) { + if (id === 'layout') { + this.stopVideoPreview(); + this.layout = 'vertical'; + this.diagonalAngle = DEFAULT_DIAGONAL_ANGLE; + this.setSplitStyle('straight'); + this.diagonalDensity = 1.5; + this.overlapVariant = 'cards'; + this.overlapOffset = 12; + this.overlapShadow = true; + this.previewTool = 'position'; + return; + } + if (id === 'split') { + this.splitPosition = 50; + return; + } + if (id === 'adjust') { + this.swapSides = false; + this.imagePadding = 0; + this.imageRadius = 0; + return; + } + if (id === 'labels') { + this.labelsEnabled = false; + this.labelA = 'Left'; + this.labelB = 'Right'; + this.labelBadge = ''; + this.setLabelPreset('a', 'bottom-left'); + this.setLabelPreset('b', 'bottom-right'); + this.setLabelPreset('badge', 'top-center'); + this.labelSize = 100; + return; + } + if (id === 'background') { + this.backgroundType = 'none'; + this.backgroundBg = '#FAFAFA'; + this.backgroundFg = '#E5E5E5'; + this.backgroundDensity = 24; + this.backgroundPerSide = false; + this.backgroundEditSide = 'a'; + this.backgroundBType = 'none'; + this.backgroundBBg = '#FAFAFA'; + this.backgroundBFg = '#E5E5E5'; + this.backgroundBDensity = 24; + return; + } + if (id === 'overlay') { + this.overlayType = 'none'; + this.overlayColor = '#171717'; + this.overlayOpacity = 25; + this.overlayDensity = 24; + this.overlayPerSide = false; + this.overlayEditSide = 'a'; + this.overlayBType = 'none'; + this.overlayBColor = '#171717'; + this.overlayBOpacity = 25; + this.overlayBDensity = 24; + return; + } + if (id === 'export') { + this.stopVideoPreview(); + this.exportScale = '1'; + this.exportFormat = 'png'; + this.jpgQuality = 92; + this.videoDuration = 2; + this.videoFps = 30; + this.videoReverse = false; + this.videoContainer = 'auto'; + this.videoTransition = 'wipe'; + this.videoEasing = 'linear'; + this.videoHoldStart = 0.25; + this.videoHoldEnd = 0.25; + } + }, + + applyLabelPlacementSettings(settings, suffix, side, legacyPosition) { + const modeKey = `label${suffix}Mode`; + const presetKey = `label${suffix}Preset`; + const xField = suffix === 'Badge' ? 'labelBadgeX' : `label${suffix}X`; + const yField = suffix === 'Badge' ? 'labelBadgeY' : `label${suffix}Y`; + + if (settings[modeKey] === 'preset' || settings[modeKey] === 'custom') { + this[modeKey] = settings[modeKey]; + } + + if (LABEL_PRESET_SET.has(settings[presetKey])) { + this[presetKey] = settings[presetKey]; + } else { + const migrated = migrateLegacyLabelPreset(legacyPosition, side); + if (migrated) { + this[presetKey] = migrated; + if (settings[modeKey] !== 'custom') { + this[modeKey] = 'preset'; + } + } + } + + if (Number.isFinite(Number(settings[xField]))) { + this[xField] = Math.min(100, Math.max(0, Number(settings[xField]))); + } + if (Number.isFinite(Number(settings[yField]))) { + this[yField] = Math.min(100, Math.max(0, Number(settings[yField]))); + } + + if (this[modeKey] !== 'custom') { + this[modeKey] = 'preset'; + const anchor = labelPresetAnchor(this[presetKey]); + this[xField] = anchor.x; + this[yField] = anchor.y; + } + }, + + setLabelPreset(side, preset) { + if (!LABEL_PRESET_SET.has(preset)) { + return; + } + const anchor = labelPresetAnchor(preset); + if (side === 'a') { + this.labelAMode = 'preset'; + this.labelAPreset = preset; + this.labelAX = anchor.x; + this.labelAY = anchor.y; + return; + } + if (side === 'b') { + this.labelBMode = 'preset'; + this.labelBPreset = preset; + this.labelBX = anchor.x; + this.labelBY = anchor.y; + return; + } + this.labelBadgeMode = 'preset'; + this.labelBadgePreset = preset; + this.labelBadgeX = anchor.x; + this.labelBadgeY = anchor.y; + }, + + setLabelAxis(side, axis, value) { + const n = Math.min(100, Math.max(0, Number(value))); + const safe = Number.isFinite(n) ? n : 0; + if (side === 'a') { + this.labelAMode = 'custom'; + if (axis === 'x') { + this.labelAX = safe; + } else { + this.labelAY = safe; + } + return; + } + if (side === 'b') { + this.labelBMode = 'custom'; + if (axis === 'x') { + this.labelBX = safe; + } else { + this.labelBY = safe; + } + return; + } + this.labelBadgeMode = 'custom'; + if (axis === 'x') { + this.labelBadgeX = safe; + } else { + this.labelBadgeY = safe; + } + }, + + clampLabelAxis(side, axis) { + const field = + side === 'a' + ? axis === 'x' + ? 'labelAX' + : 'labelAY' + : side === 'b' + ? axis === 'x' + ? 'labelBX' + : 'labelBY' + : axis === 'x' + ? 'labelBadgeX' + : 'labelBadgeY'; + this.clampSlider(field, 0, 100); + this.setLabelAxis(side, axis, this[field]); + }, + get showsOverlapOffset() { return this.layout === 'overlap' && this.overlapVariant !== 'side'; }, @@ -321,27 +876,35 @@ document.addEventListener('alpine:init', () => { this.videoPreviewing = true; const token = ++this._videoPreviewToken; - const fps = this.videoFps; - const frameCount = Math.max(2, Math.round(fps * this.videoDuration)); - const frameDelay = 1000 / fps; const preview = this.$refs.previewCanvas; + const { buildTransitionFrames } = await this.loadVideoExport(); + const { frameDelay, frames } = buildTransitionFrames({ + videoFps: this.videoFps, + videoDuration: this.videoDuration, + videoHoldStart: this.videoHoldStart, + videoHoldEnd: this.videoHoldEnd, + videoEasing: this.videoEasing, + videoReverse: this.videoReverse, + }); try { - for (let i = 0; i <= frameCount; i++) { + for (let i = 0; i < frames.length; i++) { if (token !== this._videoPreviewToken || !this.canPreviewVideo) { return; } - const t = i / frameCount; - const progress = this.videoReverse ? 1 - t : t; const options = this.buildOptions(this.baseWidth, this.baseHeight); - this.compositor.renderVideoFrame(options, this.videoTransition, clamp(progress, 0, 1)); + this.compositor.renderVideoFrame( + options, + this.videoTransition, + clamp(frames[i].progress, 0, 1), + ); if (preview) { this.previewMetrics = this.compositor.drawPreview(preview); } - this.statusMessage = `Preview… ${Math.round((i / frameCount) * 100)}%`; + this.statusMessage = `Preview… ${Math.round((i / Math.max(1, frames.length - 1)) * 100)}%`; await wait(frameDelay); } @@ -384,17 +947,40 @@ document.addEventListener('alpine:init', () => { imagePadding: this.imagePadding, imageRadius: this.imageRadius, labelsEnabled: this.labelsEnabled, - labelLeft: this.labelLeft, - labelRight: this.labelRight, + labelA: this.labelA, + labelB: this.labelB, labelBadge: this.labelBadge, - labelLeftPosition: this.labelLeftPosition, - labelRightPosition: this.labelRightPosition, - labelBadgePosition: this.labelBadgePosition, + labelAMode: this.labelAMode, + labelAPreset: this.labelAPreset, + labelAX: this.labelAX, + labelAY: this.labelAY, + labelBMode: this.labelBMode, + labelBPreset: this.labelBPreset, + labelBX: this.labelBX, + labelBY: this.labelBY, + labelBadgeMode: this.labelBadgeMode, + labelBadgePreset: this.labelBadgePreset, + labelBadgeX: this.labelBadgeX, + labelBadgeY: this.labelBadgeY, labelSize: this.labelSize, backgroundType: this.backgroundType, backgroundBg: this.backgroundBg, backgroundFg: this.backgroundFg, backgroundDensity: this.backgroundDensity, + backgroundPerSide: this.backgroundPerSide, + backgroundBType: this.backgroundBType, + backgroundBBg: this.backgroundBBg, + backgroundBFg: this.backgroundBFg, + backgroundBDensity: this.backgroundBDensity, + overlayType: this.overlayType, + overlayColor: this.overlayColor, + overlayOpacity: this.overlayOpacity, + overlayDensity: this.overlayDensity, + overlayPerSide: this.overlayPerSide, + overlayBType: this.overlayBType, + overlayBColor: this.overlayBColor, + overlayBOpacity: this.overlayBOpacity, + overlayBDensity: this.overlayBDensity, exportScale: this.exportScale, exportFormat: this.exportFormat, jpgQuality: this.jpgQuality, @@ -403,6 +989,9 @@ document.addEventListener('alpine:init', () => { videoReverse: this.videoReverse, videoContainer: this.videoContainer, videoTransition: this.videoTransition, + videoEasing: this.videoEasing, + videoHoldStart: this.videoHoldStart, + videoHoldEnd: this.videoHoldEnd, }; }, @@ -415,6 +1004,7 @@ document.addEventListener('alpine:init', () => { const styles = new Set(this.splitStyles.map((item) => item.id)); const overlaps = new Set(this.overlapVariants.map((item) => item.id)); const backgrounds = new Set(this.backgroundOptions.map((item) => item.id)); + const overlays = new Set(this.overlayOptions.map((item) => item.id)); if (layouts.has(settings.layout)) { this.layout = settings.layout; @@ -454,29 +1044,28 @@ document.addEventListener('alpine:init', () => { if (typeof settings.labelsEnabled === 'boolean') { this.labelsEnabled = settings.labelsEnabled; } - if (typeof settings.labelLeft === 'string') { - this.labelLeft = settings.labelLeft; + if (typeof settings.labelA === 'string') { + this.labelA = settings.labelA; + } else if (typeof settings.labelLeft === 'string') { + this.labelA = settings.labelLeft; } - if (typeof settings.labelRight === 'string') { - this.labelRight = settings.labelRight; + if (typeof settings.labelB === 'string') { + this.labelB = settings.labelB; + } else if (typeof settings.labelRight === 'string') { + this.labelB = settings.labelRight; } if (typeof settings.labelBadge === 'string') { this.labelBadge = settings.labelBadge; } - const positions = new Set(this.labelPositions.map((item) => item.id)); - if (positions.has(settings.labelLeftPosition)) { - this.labelLeftPosition = settings.labelLeftPosition; - } - if (positions.has(settings.labelRightPosition)) { - this.labelRightPosition = settings.labelRightPosition; - } - if (positions.has(settings.labelBadgePosition)) { - this.labelBadgePosition = settings.labelBadgePosition; - } + this.applyLabelPlacementSettings(settings, 'A', 'a', settings.labelLeftPosition); + this.applyLabelPlacementSettings(settings, 'B', 'b', settings.labelRightPosition); + this.applyLabelPlacementSettings(settings, 'Badge', 'badge', settings.labelBadgePosition); if (Number.isFinite(Number(settings.labelSize))) { this.labelSize = Math.min(160, Math.max(50, Number(settings.labelSize))); } - if (backgrounds.has(settings.backgroundType)) { + if (settings.backgroundType === 'solid') { + this.backgroundType = 'none'; + } else if (backgrounds.has(settings.backgroundType)) { this.backgroundType = settings.backgroundType; } if (typeof settings.backgroundBg === 'string') { @@ -488,10 +1077,78 @@ document.addEventListener('alpine:init', () => { if (Number.isFinite(Number(settings.backgroundDensity))) { this.backgroundDensity = Math.min(80, Math.max(8, Number(settings.backgroundDensity))); } + this.backgroundPerSide = Boolean(settings.backgroundPerSide); + if (settings.backgroundBType === 'solid') { + this.backgroundBType = 'none'; + } else if (backgrounds.has(settings.backgroundBType)) { + this.backgroundBType = settings.backgroundBType; + } else if (this.backgroundPerSide) { + this.backgroundBType = this.backgroundType; + this.backgroundBBg = this.backgroundBg; + this.backgroundBFg = this.backgroundFg; + this.backgroundBDensity = this.backgroundDensity; + } + if (typeof settings.backgroundBBg === 'string') { + this.backgroundBBg = settings.backgroundBBg; + } + if (typeof settings.backgroundBFg === 'string') { + this.backgroundBFg = settings.backgroundBFg; + } + if (Number.isFinite(Number(settings.backgroundBDensity))) { + this.backgroundBDensity = Math.min(80, Math.max(8, Number(settings.backgroundBDensity))); + } + if (!this.backgroundPerSide) { + this.backgroundEditSide = 'a'; + } + if (settings.overlayType === 'solid') { + this.overlayType = 'none'; + } else if (overlays.has(settings.overlayType)) { + this.overlayType = settings.overlayType; + } else if ( + ['straight', 'wavy', 'zigzag', 'scallop', 'soft', 'torn', 'pixel'].includes(settings.overlayType) + ) { + this.overlayType = 'none'; + } + if (typeof settings.overlayColor === 'string') { + this.overlayColor = settings.overlayColor; + } + if (Number.isFinite(Number(settings.overlayOpacity))) { + this.overlayOpacity = Math.min(80, Math.max(5, Number(settings.overlayOpacity))); + } + if (Number.isFinite(Number(settings.overlayDensity))) { + this.overlayDensity = Math.min(80, Math.max(8, Number(settings.overlayDensity))); + } + this.overlayPerSide = Boolean(settings.overlayPerSide); + if (settings.overlayBType === 'solid') { + this.overlayBType = 'none'; + } else if (overlays.has(settings.overlayBType)) { + this.overlayBType = settings.overlayBType; + } else if ( + ['straight', 'wavy', 'zigzag', 'scallop', 'soft', 'torn', 'pixel'].includes(settings.overlayBType) + ) { + this.overlayBType = 'none'; + } else if (this.overlayPerSide) { + this.overlayBType = this.overlayType; + this.overlayBColor = this.overlayColor; + this.overlayBOpacity = this.overlayOpacity; + this.overlayBDensity = this.overlayDensity; + } + if (typeof settings.overlayBColor === 'string') { + this.overlayBColor = settings.overlayBColor; + } + if (Number.isFinite(Number(settings.overlayBOpacity))) { + this.overlayBOpacity = Math.min(80, Math.max(5, Number(settings.overlayBOpacity))); + } + if (Number.isFinite(Number(settings.overlayBDensity))) { + this.overlayBDensity = Math.min(80, Math.max(8, Number(settings.overlayBDensity))); + } + if (!this.overlayPerSide) { + this.overlayEditSide = 'a'; + } if (['1', '1.5', '2'].includes(String(settings.exportScale))) { this.exportScale = String(settings.exportScale); } - if (['png', 'jpg', 'video'].includes(settings.exportFormat)) { + if (['png', 'jpg', 'video', 'gif'].includes(settings.exportFormat)) { this.exportFormat = settings.exportFormat; } else if (settings.exportJpg && settings.exportPng !== true) { this.exportFormat = 'jpg'; @@ -515,6 +1172,16 @@ document.addEventListener('alpine:init', () => { if (transitions.has(settings.videoTransition)) { this.videoTransition = settings.videoTransition; } + const easings = new Set(this.videoEasingOptions.map((item) => item.id)); + if (easings.has(settings.videoEasing)) { + this.videoEasing = settings.videoEasing; + } + if (Number.isFinite(Number(settings.videoHoldStart))) { + this.videoHoldStart = Math.min(2, Math.max(0, Number(settings.videoHoldStart))); + } + if (Number.isFinite(Number(settings.videoHoldEnd))) { + this.videoHoldEnd = Math.min(2, Math.max(0, Number(settings.videoHoldEnd))); + } this.previewTool = this.layout === 'diagonal' ? 'angle' : 'position'; }, @@ -590,7 +1257,20 @@ document.addEventListener('alpine:init', () => { parts.push('labels'); } if (settings.backgroundType) { - parts.push(String(settings.backgroundType)); + parts.push( + settings.backgroundPerSide && settings.backgroundBType + ? `${settings.backgroundType}/${settings.backgroundBType}` + : String(settings.backgroundType), + ); + } + if (settings.overlayPerSide) { + const a = settings.overlayType && settings.overlayType !== 'none' ? settings.overlayType : 'none'; + const b = settings.overlayBType && settings.overlayBType !== 'none' ? settings.overlayBType : 'none'; + if (a !== 'none' || b !== 'none') { + parts.push(`overlay ${a}/${b}`); + } + } else if (settings.overlayType && settings.overlayType !== 'none') { + parts.push(`overlay ${settings.overlayType}`); } return parts.join(' · '); }, @@ -637,7 +1317,14 @@ document.addEventListener('alpine:init', () => { if (!preset?.settings) { return; } + this._historyPaused = true; + if (this._historyTimer) { + clearTimeout(this._historyTimer); + this._historyTimer = 0; + } this.applySettings(preset.settings); + this._historyPaused = false; + this.commitHistoryNow(); this.activePresetId = preset.id; this.statusMessage = `Preset “${preset.name}” applied.`; this.closeLoadPresetModal(); @@ -827,15 +1514,15 @@ document.addEventListener('alpine:init', () => { }, selectExportFormat(format) { + this.stopVideoPreview(); this.exportFormat = format; - if (format === 'video') { + if (format === 'video' || format === 'gif') { this.loadVideoExport(); } }, - async recordTransitionVideo(width, height) { - const { recordTransitionVideo } = await this.loadVideoExport(); - return recordTransitionVideo({ + motionExportParams(width, height) { + return { width, height, videoContainer: this.videoContainer, @@ -843,12 +1530,25 @@ document.addEventListener('alpine:init', () => { videoDuration: this.videoDuration, videoReverse: this.videoReverse, videoTransition: this.videoTransition, + videoHoldStart: this.videoHoldStart, + videoHoldEnd: this.videoHoldEnd, + videoEasing: this.videoEasing, compositor: this.compositor, buildOptions: (w, h) => this.buildOptions(w, h), onProgress: (message) => { this.statusMessage = message; }, - }); + }; + }, + + async recordTransitionVideo(width, height) { + const { recordTransitionVideo } = await this.loadVideoExport(); + return recordTransitionVideo(this.motionExportParams(width, height)); + }, + + async recordTransitionGif(width, height) { + const { recordTransitionGif } = await this.loadVideoExport(); + return recordTransitionGif(this.motionExportParams(width, height)); }, buildOptions(width, height) { @@ -879,12 +1579,27 @@ document.addEventListener('alpine:init', () => { imageRadius: this.imageRadius, labels: { enabled: this.labelsEnabled, - left: this.labelLeft, - right: this.labelRight, - badge: this.labelBadge, - leftPosition: this.labelLeftPosition, - rightPosition: this.labelRightPosition, - badgePosition: this.labelBadgePosition, + a: { + text: this.labelA, + mode: this.labelAMode, + preset: this.labelAPreset, + x: Number(this.labelAX) || 0, + y: Number(this.labelAY) || 0, + }, + b: { + text: this.labelB, + mode: this.labelBMode, + preset: this.labelBPreset, + x: Number(this.labelBX) || 0, + y: Number(this.labelBY) || 0, + }, + badge: { + text: this.labelBadge, + mode: this.labelBadgeMode, + preset: this.labelBadgePreset, + x: Number(this.labelBadgeX) || 0, + y: Number(this.labelBadgeY) || 0, + }, size: this.labelSize, }, background: { @@ -893,9 +1608,189 @@ document.addEventListener('alpine:init', () => { fg: this.backgroundFg, density: Number(this.backgroundDensity) || 24, }, + backgroundB: this.backgroundPerSide + ? { + type: this.backgroundBType, + bg: this.backgroundBBg, + fg: this.backgroundBFg, + density: Number(this.backgroundBDensity) || 24, + } + : null, + overlay: { + type: this.overlayType, + color: this.overlayColor, + opacity: Number(this.overlayOpacity) || 25, + density: Number(this.overlayDensity) || 24, + }, + overlayB: this.overlayPerSide + ? { + type: this.overlayBType, + color: this.overlayBColor, + opacity: Number(this.overlayBOpacity) || 25, + density: Number(this.overlayBDensity) || 24, + } + : null, }; }, + get backgroundSummary() { + if (this.backgroundPerSide) { + return `${this.backgroundType} / ${this.backgroundBType}`; + } + return this.backgroundType; + }, + + get overlaySummary() { + if (this.overlayPerSide) { + return `${this.overlayType} / ${this.overlayBType}`; + } + return this.overlayType; + }, + + get editingBackgroundB() { + return this.backgroundPerSide && this.backgroundEditSide === 'b'; + }, + + get editingOverlayB() { + return this.overlayPerSide && this.overlayEditSide === 'b'; + }, + + get activeBackgroundType() { + return this.editingBackgroundB ? this.backgroundBType : this.backgroundType; + }, + + set activeBackgroundType(value) { + if (this.editingBackgroundB) { + this.backgroundBType = value; + } else { + this.backgroundType = value; + } + }, + + get activeBackgroundBg() { + return this.editingBackgroundB ? this.backgroundBBg : this.backgroundBg; + }, + + set activeBackgroundBg(value) { + if (this.editingBackgroundB) { + this.backgroundBBg = value; + } else { + this.backgroundBg = value; + } + }, + + get activeBackgroundFg() { + return this.editingBackgroundB ? this.backgroundBFg : this.backgroundFg; + }, + + set activeBackgroundFg(value) { + if (this.editingBackgroundB) { + this.backgroundBFg = value; + } else { + this.backgroundFg = value; + } + }, + + get activeBackgroundDensity() { + return this.editingBackgroundB ? this.backgroundBDensity : this.backgroundDensity; + }, + + set activeBackgroundDensity(value) { + if (this.editingBackgroundB) { + this.backgroundBDensity = value; + } else { + this.backgroundDensity = value; + } + }, + + get activeOverlayType() { + return this.editingOverlayB ? this.overlayBType : this.overlayType; + }, + + set activeOverlayType(value) { + if (this.editingOverlayB) { + this.overlayBType = value; + } else { + this.overlayType = value; + } + }, + + get activeOverlayColor() { + return this.editingOverlayB ? this.overlayBColor : this.overlayColor; + }, + + set activeOverlayColor(value) { + if (this.editingOverlayB) { + this.overlayBColor = value; + } else { + this.overlayColor = value; + } + }, + + get activeOverlayOpacity() { + return this.editingOverlayB ? this.overlayBOpacity : this.overlayOpacity; + }, + + set activeOverlayOpacity(value) { + if (this.editingOverlayB) { + this.overlayBOpacity = value; + } else { + this.overlayOpacity = value; + } + }, + + get activeOverlayDensity() { + return this.editingOverlayB ? this.overlayBDensity : this.overlayDensity; + }, + + set activeOverlayDensity(value) { + if (this.editingOverlayB) { + this.overlayBDensity = value; + } else { + this.overlayDensity = value; + } + }, + + setBackgroundPerSide(enabled) { + const on = Boolean(enabled); + if (on && !this.backgroundPerSide) { + this.backgroundBType = this.backgroundType; + this.backgroundBBg = this.backgroundBg; + this.backgroundBFg = this.backgroundFg; + this.backgroundBDensity = this.backgroundDensity; + } + this.backgroundPerSide = on; + if (!on) { + this.backgroundEditSide = 'a'; + } + }, + + setOverlayPerSide(enabled) { + const on = Boolean(enabled); + if (on && !this.overlayPerSide) { + this.overlayBType = this.overlayType; + this.overlayBColor = this.overlayColor; + this.overlayBOpacity = this.overlayOpacity; + this.overlayBDensity = this.overlayDensity; + } + this.overlayPerSide = on; + if (!on) { + this.overlayEditSide = 'a'; + } + }, + + clampActiveBackgroundDensity() { + this.clampSlider(this.editingBackgroundB ? 'backgroundBDensity' : 'backgroundDensity', 8, 80); + }, + + clampActiveOverlayOpacity() { + this.clampSlider(this.editingOverlayB ? 'overlayBOpacity' : 'overlayOpacity', 5, 80); + }, + + clampActiveOverlayDensity() { + this.clampSlider(this.editingOverlayB ? 'overlayBDensity' : 'overlayDensity', 8, 80); + }, + render() { if (!this.compositor) { return; @@ -1106,13 +2001,21 @@ document.addEventListener('alpine:init', () => { try { const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - if (this.exportFormat === 'video') { + if (this.exportFormat === 'video' || this.exportFormat === 'gif') { if (!this.usesSplit) { - this.statusMessage = 'Video export needs a split layout (not Overlap).'; + this.statusMessage = `${this.exportFormat === 'gif' ? 'GIF' : 'Video'} export needs a split layout (not Overlap).`; return; } const { width, height } = this.resolveVideoSize(); + if (this.exportFormat === 'gif') { + this.statusMessage = 'Encoding GIF…'; + const { blob, extension, label } = await this.recordTransitionGif(width, height); + this.downloadBlob(blob, `pairframe-${width}x${height}-${stamp}.${extension}`); + this.statusMessage = `Exported ${label} (${width} × ${height}).`; + return; + } + this.statusMessage = 'Recording video…'; const { blob, extension, label } = await this.recordTransitionVideo(width, height); this.downloadBlob(blob, `pairframe-${width}x${height}-${stamp}.${extension}`); diff --git a/resources/js/compositor.js b/resources/js/compositor.js index ac3a044..42cecfd 100644 --- a/resources/js/compositor.js +++ b/resources/js/compositor.js @@ -1,4 +1,4 @@ -import { paintBackground, paintSplitMask } from './patterns.js'; +import { paintBackground, paintOverlay, paintSplitMask } from './patterns.js'; function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); @@ -85,12 +85,17 @@ export function createCompositor() { const frameACtx = frameACanvas.getContext('2d', { willReadFrequently: false }); const frameBCanvas = document.createElement('canvas'); const frameBCtx = frameBCanvas.getContext('2d', { willReadFrequently: false }); + const overlayCanvas = document.createElement('canvas'); + const overlayCtx = overlayCanvas.getContext('2d', { willReadFrequently: false }); + const bgBCanvas = document.createElement('canvas'); + const bgBCtx = bgBCanvas.getContext('2d', { willReadFrequently: false }); let lastOptions = null; let bgKey = ''; let sideAKey = ''; let sideBKey = ''; let maskKey = ''; + let overlayKey = ''; let transitionFrameKey = ''; function resolveSides(options) { @@ -102,26 +107,158 @@ 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('|'); + function backgroundLayerKey(layer) { + const bg = layer || {}; + return [bg.type || 'none', bg.bg || '', bg.fg || '', Number(bg.density) || 0].join(':'); + } + + function overlayLayerKey(layer) { + const ov = layer || {}; + return [ov.type || 'none', ov.color || '', Number(ov.opacity) || 0, Number(ov.density) || 0].join(':'); + } + + function splitMaskOptions(options) { + return { + layoutFamily: options.layoutFamily || options.layout, + layoutVariant: options.layoutVariant, + splitPosition: options.splitPosition, + softEdge: options.softEdge, + flipDirection: options.flipDirection, + invertMask: options.invertMask, + maskDensity: options.maskDensity, + diagonalAngle: options.diagonalAngle, + }; + } + + function ensureLayerSideBMask(width, height, options) { + const family = options.layoutFamily || options.layout; + if (family === 'overlap') { + const flip = Boolean(options.flipDirection) || Boolean(options.swapSides); + const key = `overlap-half|${width}|${height}|${flip ? 1 : 0}`; + if (maskKey === key && maskCanvas.width === width && maskCanvas.height === height) { + return maskCanvas; + } + ensureCanvasSize(maskCanvas, width, height); + maskCtx.clearRect(0, 0, width, height); + maskCtx.fillStyle = '#ffffff'; + if (flip) { + maskCtx.fillRect(0, 0, Math.ceil(width / 2), height); + } else { + maskCtx.fillRect(Math.floor(width / 2), 0, Math.ceil(width / 2), height); + } + maskKey = key; + return maskCanvas; + } + + ensureMask(width, height, splitMaskOptions(options)); + return maskCanvas; + } + + function blitBackground(targetCtx, width, height, background, backgroundB, options) { + const bg = background || { type: 'none', bg: '#FAFAFA' }; + const hasB = Boolean(backgroundB && typeof backgroundB === 'object'); + const maskPart = hasB + ? [ + options?.layoutFamily || options?.layout || '', + options?.layoutVariant || '', + Number(options?.splitPosition) || 0, + Number(options?.softEdge) || 0, + Boolean(options?.flipDirection) ? 1 : 0, + Boolean(options?.invertMask) ? 1 : 0, + Boolean(options?.swapSides) ? 1 : 0, + Number(options?.maskDensity) || 0, + Number(options?.diagonalAngle) || 0, + ].join('|') + : ''; + const key = [width, height, backgroundLayerKey(bg), hasB ? backgroundLayerKey(backgroundB) : '', maskPart].join( + '|', + ); if (bgKey !== key) { ensureCanvasSize(bgCanvas, width, height); paintBackground(bgCtx, width, height, bg); + + if (hasB) { + ensureCanvasSize(bgBCanvas, width, height); + paintBackground(bgBCtx, width, height, backgroundB); + const mask = ensureLayerSideBMask(width, height, options || {}); + bgBCtx.globalCompositeOperation = 'destination-in'; + bgBCtx.drawImage(mask, 0, 0); + bgBCtx.globalCompositeOperation = 'source-over'; + bgCtx.drawImage(bgBCanvas, 0, 0); + } + bgKey = key; } targetCtx.drawImage(bgCanvas, 0, 0); } + function blitOverlay(targetCtx, width, height, overlay, overlayB, options) { + const layerA = overlay || { type: 'none' }; + const layerB = overlayB && typeof overlayB === 'object' ? overlayB : null; + const typeA = layerA.type || 'none'; + const typeB = layerB ? layerB.type || 'none' : 'none'; + const hasB = Boolean(layerB); + const activeA = typeA && typeA !== 'none'; + const activeB = hasB && typeB && typeB !== 'none'; + + if (!activeA && !activeB) { + return; + } + + if (!hasB) { + const key = [width, height, overlayLayerKey(layerA)].join('|'); + if (overlayKey !== key) { + clearCanvas(overlayCtx, overlayCanvas, width, height); + paintOverlay(overlayCtx, width, height, layerA); + overlayKey = key; + } + targetCtx.drawImage(overlayCanvas, 0, 0); + return; + } + + const maskPart = [ + options?.layoutFamily || options?.layout || '', + options?.layoutVariant || '', + Number(options?.splitPosition) || 0, + Number(options?.softEdge) || 0, + Boolean(options?.flipDirection) ? 1 : 0, + Boolean(options?.invertMask) ? 1 : 0, + Boolean(options?.swapSides) ? 1 : 0, + Number(options?.maskDensity) || 0, + Number(options?.diagonalAngle) || 0, + ].join('|'); + const key = [width, height, overlayLayerKey(layerA), overlayLayerKey(layerB), maskPart].join('|'); + + if (overlayKey !== key) { + clearCanvas(overlayCtx, overlayCanvas, width, height); + const mask = ensureLayerSideBMask(width, height, options || {}); + + if (activeA) { + clearCanvas(sideCtx, sideCanvas, width, height); + paintOverlay(sideCtx, width, height, layerA); + sideCtx.globalCompositeOperation = 'destination-out'; + sideCtx.drawImage(mask, 0, 0); + sideCtx.globalCompositeOperation = 'source-over'; + overlayCtx.drawImage(sideCanvas, 0, 0); + } + + if (activeB) { + clearCanvas(sideCtx, sideCanvas, width, height); + paintOverlay(sideCtx, width, height, layerB); + sideCtx.globalCompositeOperation = 'destination-in'; + sideCtx.drawImage(mask, 0, 0); + sideCtx.globalCompositeOperation = 'source-over'; + overlayCtx.drawImage(sideCanvas, 0, 0); + } + + overlayKey = key; + } + + targetCtx.drawImage(overlayCanvas, 0, 0); + } + function imageCacheKey(image, width, height, fitMode) { if (!image) { return `empty|${width}|${height}|${fitMode}`; @@ -171,7 +308,7 @@ export function createCompositor() { } function renderOverlap(options, width, height, sideA, sideB) { - blitBackground(ctx, width, height, options.background); + blitBackground(ctx, width, height, options.background, options.backgroundB, options); const variant = options.layoutVariant || 'cards'; const offsetXRaw = Number(options.overlapOffsetX); @@ -272,7 +409,7 @@ export function createCompositor() { } function renderSplit(options, width, height, sideA, sideB) { - blitBackground(ctx, width, height, options.background); + blitBackground(ctx, width, height, options.background, options.backgroundB, options); const content = contentPadding(width, height, options); const radiusPct = clamp(Number(options.imageRadius) || 0, 0, 50) / 100; @@ -298,17 +435,7 @@ export function createCompositor() { fitMode, ); - const maskOptions = { - layoutFamily: options.layoutFamily || options.layout, - layoutVariant: options.layoutVariant, - splitPosition: options.splitPosition, - softEdge: options.softEdge, - flipDirection: options.flipDirection, - invertMask: options.invertMask, - maskDensity: options.maskDensity, - diagonalAngle: options.diagonalAngle, - }; - ensureMask(content.width, content.height, maskOptions); + ensureMask(content.width, content.height, splitMaskOptions(options)); ctx.save(); if (radius > 0) { @@ -340,7 +467,14 @@ export function createCompositor() { const { sideA, sideB } = resolveSides(options); if (!sideA && !sideB) { - blitBackground(ctx, width, height, options.background || { type: 'solid', bg: '#FAFAFA' }); + blitBackground( + ctx, + width, + height, + options.background || { type: 'none', bg: '#FAFAFA' }, + options.backgroundB, + options, + ); ctx.fillStyle = '#A3A3A3'; ctx.font = '500 18px Inter, sans-serif'; ctx.textAlign = 'center'; @@ -355,49 +489,105 @@ export function createCompositor() { renderSplit(options, width, height, sideA || sideB, sideB || sideA); } + blitOverlay(ctx, width, height, options.overlay, options.overlayB, options); paintLabels(ctx, width, height, options); return canvas; } + function labelPlacementXY(placement) { + const anchors = { + 'top-left': { x: 0, y: 0 }, + 'top-center': { x: 50, y: 0 }, + 'top-right': { x: 100, y: 0 }, + 'middle-left': { x: 0, y: 50 }, + 'middle-center': { x: 50, y: 50 }, + 'middle-right': { x: 100, y: 50 }, + 'bottom-left': { x: 0, y: 100 }, + 'bottom-center': { x: 50, y: 100 }, + 'bottom-right': { x: 100, y: 100 }, + }; + if (placement?.mode === 'custom') { + return { + x: clamp(Number(placement.x) || 0, 0, 100), + y: clamp(Number(placement.y) || 0, 0, 100), + }; + } + return anchors[placement?.preset] || anchors['bottom-left']; + } + + function labelAlignFromXY(x, y) { + const alignX = x <= 25 ? 'left' : x >= 75 ? 'right' : 'center'; + const alignY = y <= 25 ? 'top' : y >= 75 ? 'bottom' : 'middle'; + return { alignX, alignY }; + } + function paintLabels(labelCtx, width, height, options) { const labels = options.labels; if (!labels?.enabled) { return; } - const leftText = String(labels.left || '').trim(); - const rightText = String(labels.right || '').trim(); - const badgeText = String(labels.badge || '').trim(); - if (!leftText && !rightText && !badgeText) { + // Support legacy left/right shape during transition + const placementA = labels.a || { + text: labels.left, + mode: 'preset', + preset: + labels.leftPosition === 'top' + ? 'top-left' + : labels.leftPosition === 'middle' + ? 'middle-left' + : 'bottom-left', + }; + const placementB = labels.b || { + text: labels.right, + mode: 'preset', + preset: + labels.rightPosition === 'top' + ? 'top-right' + : labels.rightPosition === 'middle' + ? 'middle-right' + : 'bottom-right', + }; + const placementBadge = + labels.badge && typeof labels.badge === 'object' + ? labels.badge + : { + text: typeof labels.badge === 'string' ? labels.badge : '', + mode: 'preset', + preset: + labels.badgePosition === 'bottom' + ? 'bottom-center' + : labels.badgePosition === 'middle' + ? 'middle-center' + : 'top-center', + }; + + const textA = String(placementA.text || '').trim(); + const textB = String(placementB.text || '').trim(); + const textBadge = String(placementBadge.text || '').trim(); + if (!textA && !textB && !textBadge) { return; } - const family = options.layoutFamily || options.layout || 'vertical'; - const leftPosition = labels.leftPosition || 'bottom'; - const rightPosition = labels.rightPosition || 'bottom'; - const badgePosition = labels.badgePosition || 'top'; const scale = clamp(Number(labels.size) || 100, 50, 160) / 100; const fontSize = Math.max(11, Math.round(Math.min(width, height) * 0.032 * scale)); const inset = Math.max(10, Math.round(Math.min(width, height) * 0.025)); + // Anchor to the full content box so split-position changes do not drag labels around. const content = contentPadding(width, height, options); - const horizontal = family === 'horizontal' || (family === 'fade' && (options.layoutVariant || '') === 'tb'); - const axisPoint = (start, size, position) => { - if (position === 'top') { - return start + inset + fontSize * 0.9; - } - if (position === 'bottom') { - return start + size - inset - fontSize * 0.9; - } - return start + size / 2; - }; - - const drawPill = (text, x, y, align = 'center') => { + const drawPill = (text, placement) => { if (!text) { return; } + const { x: px, y: py } = labelPlacementXY(placement); + const { alignX, alignY } = labelAlignFromXY(px, py); + const innerW = Math.max(1, content.width - inset * 2); + const innerH = Math.max(1, content.height - inset * 2); + const x = content.x + inset + (innerW * px) / 100; + const y = content.y + inset + (innerH * py) / 100; + labelCtx.save(); labelCtx.font = `600 ${fontSize}px Inter, ui-sans-serif, system-ui, sans-serif`; labelCtx.textAlign = 'center'; @@ -407,37 +597,32 @@ export function createCompositor() { const padY = fontSize * 0.42; const pillW = textWidth + padX * 2; const pillH = fontSize + padY * 2; + let left = x - pillW / 2; - if (align === 'left') { + if (alignX === 'left') { left = x; - } else if (align === 'right') { + } else if (alignX === 'right') { left = x - pillW; } - const topY = y - pillH / 2; + + let topY = y - pillH / 2; + if (alignY === 'top') { + topY = y; + } else if (alignY === 'bottom') { + topY = y - pillH; + } 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.fillText(text, left + pillW / 2, topY + pillH / 2 + 0.5); labelCtx.restore(); }; - if (horizontal) { - const topY = content.y + inset + fontSize * 0.9; - const bottomY = content.y + content.height - inset - fontSize * 0.9; - const leftAlign = leftPosition === 'top' ? 'left' : leftPosition === 'bottom' ? 'right' : 'center'; - const rightAlign = rightPosition === 'top' ? 'left' : rightPosition === 'bottom' ? 'right' : 'center'; - drawPill(leftText, axisPoint(content.x, content.width, leftPosition), topY, leftAlign); - drawPill(rightText, axisPoint(content.x, content.width, rightPosition), bottomY, rightAlign); - } else { - drawPill(leftText, content.x + inset, axisPoint(content.y, content.height, leftPosition), 'left'); - drawPill(rightText, content.x + content.width - inset, axisPoint(content.y, content.height, rightPosition), 'right'); - } - - if (badgeText) { - drawPill(badgeText, content.x + content.width / 2, axisPoint(content.y, content.height, badgePosition), 'center'); - } + drawPill(textA, placementA); + drawPill(textB, placementB); + drawPill(textBadge, placementBadge); } function drawPreview(previewCanvas) { @@ -493,7 +678,6 @@ export function createCompositor() { } function transitionFramesCacheKey(options, width, height) { - const bg = options.background || {}; const light = options.lightImage; const dark = options.darkImage; return [ @@ -509,10 +693,10 @@ export function createCompositor() { Number(options.imagePadding) || 0, Number(options.imageRadius) || 0, options.fitMode || 'cover', - bg.type || '', - bg.bg || '', - bg.fg || '', - Number(bg.density) || 0, + backgroundLayerKey(options.background), + options.backgroundB ? backgroundLayerKey(options.backgroundB) : '', + overlayLayerKey(options.overlay), + options.overlayB ? overlayLayerKey(options.overlayB) : '', light?.src || '', light?.naturalWidth || light?.width || 0, light?.naturalHeight || light?.height || 0, diff --git a/resources/js/patterns.js b/resources/js/patterns.js index 4443940..cdbcc82 100644 --- a/resources/js/patterns.js +++ b/resources/js/patterns.js @@ -22,27 +22,25 @@ function hexToRgb(hex) { }; } -export function paintBackground(ctx, width, height, options = {}) { - const type = options.type || 'solid'; - const bg = options.bg || '#FAFAFA'; - const fg = options.fg || '#E5E5E5'; - const density = clamp(Number(options.density) || 24, 4, 120); - - ctx.save(); - ctx.fillStyle = bg; - ctx.fillRect(0, 0, width, height); - - if (type === 'solid') { - ctx.restore(); - return; - } +/** Deterministic 0..1 noise so wipe frames and cached tiles do not flicker. */ +function hashNoise(n) { + let x = Math.imul(Number(n) ^ 0x9e3779b9, 0x85ebca6b); + x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35); + x = (x ^ (x >>> 16)) >>> 0; + return x / 4294967296; +} - ctx.fillStyle = fg; - ctx.strokeStyle = fg; +/** + * Draw pattern marks only (no opaque base). Used by background + overlay. + * @param {'opaque'|'grain'} noiseMode opaque blends two colors; grain uses color alpha speckles + */ +function paintPatternMarks(ctx, width, height, type, color, density, noiseMode = 'opaque', noiseBg = null) { + const step = density; + ctx.fillStyle = color; + ctx.strokeStyle = color; ctx.lineWidth = 1; if (type === 'dots') { - const step = density; const radius = Math.max(1, density * 0.12); for (let y = step / 2; y < height; y += step) { for (let x = step / 2; x < width; x += step) { @@ -51,8 +49,10 @@ export function paintBackground(ctx, width, height, options = {}) { ctx.fill(); } } - } else if (type === 'grid') { - const step = density; + return; + } + + if (type === 'grid') { ctx.beginPath(); for (let x = 0; x <= width; x += step) { ctx.moveTo(x + 0.5, 0); @@ -63,13 +63,17 @@ export function paintBackground(ctx, width, height, options = {}) { ctx.lineTo(width, y + 0.5); } ctx.stroke(); - } else if (type === 'stripes') { - const step = density; + return; + } + + if (type === 'stripes') { for (let x = -height; x < width; x += step) { ctx.fillRect(x, 0, Math.max(2, step * 0.45), height); } - } else if (type === 'diagonal') { - const step = density; + return; + } + + if (type === 'diagonal') { ctx.lineWidth = Math.max(1, step * 0.2); ctx.beginPath(); for (let i = -height; i < width + height; i += step) { @@ -77,8 +81,10 @@ export function paintBackground(ctx, width, height, options = {}) { ctx.lineTo(i + height, height); } ctx.stroke(); - } else if (type === 'chevron') { - const step = density; + return; + } + + if (type === 'chevron') { const amp = step * 0.45; ctx.lineWidth = Math.max(1.5, step * 0.12); ctx.beginPath(); @@ -91,7 +97,10 @@ export function paintBackground(ctx, width, height, options = {}) { } } ctx.stroke(); - } else if (type === 'noise') { + return; + } + + if (type === 'noise') { const tileSize = 128; const tile = document.createElement('canvas'); tile.width = tileSize; @@ -99,16 +108,27 @@ export function paintBackground(ctx, width, height, options = {}) { 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 }; + const fgRgb = hexToRgb(color) || { r: 229, g: 229, b: 229 }; const strength = clamp(density / 100, 0.08, 0.9); - for (let i = 0; i < data.length; i += 4) { - const t = Math.random() * strength; - data[i] = Math.round(bgRgb.r * (1 - t) + fgRgb.r * t); - data[i + 1] = Math.round(bgRgb.g * (1 - t) + fgRgb.g * t); - data[i + 2] = Math.round(bgRgb.b * (1 - t) + fgRgb.b * t); - data[i + 3] = 255; + + if (noiseMode === 'grain') { + for (let i = 0, p = 0; i < data.length; i += 4, p += 1) { + data[i] = fgRgb.r; + data[i + 1] = fgRgb.g; + data[i + 2] = fgRgb.b; + data[i + 3] = Math.round(hashNoise(p + 17) * strength * 255); + } + } else { + const bgRgb = hexToRgb(noiseBg) || { r: 250, g: 250, b: 250 }; + for (let i = 0, p = 0; i < data.length; i += 4, p += 1) { + const t = hashNoise(p + 3) * strength; + data[i] = Math.round(bgRgb.r * (1 - t) + fgRgb.r * t); + data[i + 1] = Math.round(bgRgb.g * (1 - t) + fgRgb.g * t); + data[i + 2] = Math.round(bgRgb.b * (1 - t) + fgRgb.b * t); + data[i + 3] = 255; + } } + tctx.putImageData(image, 0, 0); const pattern = ctx.createPattern(tile, 'repeat'); if (pattern) { @@ -116,7 +136,42 @@ export function paintBackground(ctx, width, height, options = {}) { ctx.fillRect(0, 0, width, height); } } +} + +export function paintBackground(ctx, width, height, options = {}) { + const type = options.type || 'none'; + const bg = options.bg || '#FAFAFA'; + const fg = options.fg || '#E5E5E5'; + const density = clamp(Number(options.density) || 24, 4, 120); + + ctx.save(); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, width, height); + + if (type && type !== 'none' && type !== 'solid') { + paintPatternMarks(ctx, width, height, type, fg, density, 'opaque', bg); + } + + ctx.restore(); +} + +/** + * Translucent pattern marks on top of composed images (no base fill). + * options: { type, color, opacity (0-100), density } + */ +export function paintOverlay(ctx, width, height, options = {}) { + const type = options.type || 'none'; + if (!type || type === 'none' || type === 'solid') { + return; + } + + const color = options.color || '#171717'; + const opacity = clamp((Number(options.opacity) ?? 25) / 100, 0.05, 0.8); + const density = clamp(Number(options.density) || 24, 4, 120); + ctx.save(); + ctx.globalAlpha = opacity; + paintPatternMarks(ctx, width, height, type, color, density, 'grain'); ctx.restore(); } @@ -135,14 +190,6 @@ function blurMask(maskCtx, width, height, softEdge) { maskCtx.filter = 'none'; } -/** Deterministic 0..1 noise so wipe frames do not flicker. */ -function hashNoise(n) { - let x = Math.imul(Number(n) ^ 0x9e3779b9, 0x85ebca6b); - x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35); - x = (x ^ (x >>> 16)) >>> 0; - return x / 4294967296; -} - /** * Build an alpha mask into maskCtx. * Opaque white = show side B. Transparent = keep side A. diff --git a/resources/js/video-export.js b/resources/js/video-export.js index 5e4cdf9..284a905 100644 --- a/resources/js/video-export.js +++ b/resources/js/video-export.js @@ -1,3 +1,5 @@ +import { GIFEncoder, applyPalette, quantize } from 'gifenc'; + function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } @@ -37,20 +39,81 @@ export function pickVideoMimeType(videoContainer) { return webm ? { mimeType: webm, extension: 'webm', label: 'WebM' } : null; } +export function easeInOutCubic(t) { + const x = clamp(t, 0, 1); + return x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2; +} + +/** + * Map linear timeline 0..1 across hold-start → eased transition → hold-end. + * Holds are seconds within total durationSec; remainder is the transition. + */ +export function transitionProgressAt(timelineT, { + durationSec, + holdStartSec = 0, + holdEndSec = 0, + easing = 'linear', + reverse = false, +} = {}) { + const duration = Math.max(0.5, Number(durationSec) || 2); + let holdStart = clamp(Number(holdStartSec) || 0, 0, duration); + let holdEnd = clamp(Number(holdEndSec) || 0, 0, duration); + if (holdStart + holdEnd > duration - 0.25) { + const scale = (duration - 0.25) / (holdStart + holdEnd || 1); + holdStart *= scale; + holdEnd *= scale; + } + + const t = clamp(timelineT, 0, 1); + const startRatio = holdStart / duration; + const endRatio = 1 - holdEnd / duration; + + let progress = 0; + if (t <= startRatio) { + progress = 0; + } else if (t >= endRatio) { + progress = 1; + } else { + const u = (t - startRatio) / Math.max(0.0001, endRatio - startRatio); + progress = easing === 'ease-in-out' ? easeInOutCubic(u) : u; + } + + return reverse ? 1 - progress : progress; +} + +export function buildTransitionFrames({ + videoFps, + videoDuration, + videoHoldStart = 0, + videoHoldEnd = 0, + videoEasing = 'linear', + videoReverse = false, +}) { + const fps = Math.max(1, Number(videoFps) || 30); + const durationSec = Math.max(0.5, Number(videoDuration) || 2); + const frameCount = Math.max(2, Math.round(fps * durationSec)); + const frameDelay = 1000 / fps; + const frames = []; + + for (let i = 0; i <= frameCount; i++) { + const timelineT = i / frameCount; + frames.push({ + timelineT, + progress: transitionProgressAt(timelineT, { + durationSec, + holdStartSec: videoHoldStart, + holdEndSec: videoHoldEnd, + easing: videoEasing, + reverse: videoReverse, + }), + }); + } + + return { fps, durationSec, frameCount, frameDelay, frames }; +} + /** * Record an A→B (or reverse) transition 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 {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 recordTransitionVideo({ width, @@ -60,6 +123,9 @@ export async function recordTransitionVideo({ videoDuration, videoReverse, videoTransition = 'wipe', + videoHoldStart = 0, + videoHoldEnd = 0, + videoEasing = 'linear', compositor, buildOptions, onProgress, @@ -75,21 +141,25 @@ export async function recordTransitionVideo({ } 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 transition = videoTransition || 'wipe'; + const { frameDelay, frames } = buildTransitionFrames({ + videoFps, + videoDuration, + videoHoldStart, + videoHoldEnd, + videoEasing, + videoReverse, + }); const renderFrame = (progress) => { const options = buildOptions(width, height); compositor.renderVideoFrame(options, transition, clamp(progress, 0, 1)); }; - renderFrame(videoReverse ? 1 : 0); + renderFrame(frames[0]?.progress ?? (videoReverse ? 1 : 0)); const canvas = compositor.getCanvas(); - const stream = canvas.captureStream(fps); + const stream = canvas.captureStream(videoFps); const track = stream.getVideoTracks()[0]; if (!track) { throw new Error('Could not capture a video track from the canvas.'); @@ -114,14 +184,12 @@ export async function recordTransitionVideo({ recorder.start(100); - for (let i = 0; i <= frameCount; i++) { - const t = i / frameCount; - const progress = videoReverse ? 1 - t : t; - renderFrame(progress); + for (let i = 0; i < frames.length; i++) { + renderFrame(frames[i].progress); if (typeof track.requestFrame === 'function') { track.requestFrame(); } - onProgress?.(`Recording ${label}… ${Math.round((i / frameCount) * 100)}%`); + onProgress?.(`Recording ${label}… ${Math.round((i / Math.max(1, frames.length - 1)) * 100)}%`); await wait(frameDelay); } @@ -138,6 +206,66 @@ export async function recordTransitionVideo({ return { blob, extension, label }; } +/** + * Encode the same transition timeline as an animated GIF. + */ +export async function recordTransitionGif({ + width, + height, + videoFps, + videoDuration, + videoReverse, + videoTransition = 'wipe', + videoHoldStart = 0, + videoHoldEnd = 0, + videoEasing = 'linear', + compositor, + buildOptions, + onProgress, +}) { + const transition = videoTransition || 'wipe'; + const { frameDelay, frames } = buildTransitionFrames({ + videoFps, + videoDuration, + videoHoldStart, + videoHoldEnd, + videoEasing, + videoReverse, + }); + + const gif = GIFEncoder(); + const delayCentis = Math.max(2, Math.round(frameDelay / 10)); + + for (let i = 0; i < frames.length; i++) { + const options = buildOptions(width, height); + compositor.renderVideoFrame(options, transition, clamp(frames[i].progress, 0, 1)); + const canvas = compositor.getCanvas(); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + const image = ctx.getImageData(0, 0, width, height); + const palette = quantize(image.data, 256); + const index = applyPalette(image.data, palette); + gif.writeFrame(index, width, height, { + palette, + delay: delayCentis, + dispose: 1, + }); + onProgress?.(`Encoding GIF… ${Math.round((i / Math.max(1, frames.length - 1)) * 100)}%`); + // Yield so the UI can update status on long encodes + if (i % 3 === 0) { + await wait(0); + } + } + + gif.finish(); + const bytes = gif.bytes(); + const blob = new Blob([bytes], { type: 'image/gif' }); + if (!blob.size) { + throw new Error('GIF encoding produced an empty file.'); + } + + return { blob, extension: 'gif', label: 'GIF' }; +} + /** @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 74e0fea..ea52fd8 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -86,7 +86,7 @@ } -
+ {{-- Shown before Vite/Alpine are ready --}}