diff --git a/resources/js/app.js b/resources/js/app.js index 655c67a..3ebf639 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -21,6 +21,9 @@ document.addEventListener('alpine:init', () => { diagonalAngle: Math.round(((Math.atan2(9, 16) * 180) / Math.PI) * 10) / 10, diagonalStyle: 'straight', diagonalDensity: 1.5, + overlapVariant: 'cards', + overlapOffset: 12, + overlapShadow: true, splitPosition: 50, previewTool: 'position', swapSides: false, @@ -48,13 +51,30 @@ document.addEventListener('alpine:init', () => { backgroundDensity: 24, exportScale: '1', - exportPng: true, - exportJpg: false, + exportFormat: 'png', jpgQuality: 92, + videoDuration: 2, + videoFps: 30, + videoReverse: false, + videoContainer: 'auto', + videoPreviewing: false, + _videoPreviewToken: 0, exporting: false, statusMessage: '', theme: 'auto', + videoFpsOptions: [ + { id: 24, label: '24' }, + { id: 30, label: '30' }, + { id: 60, label: '60' }, + ], + + videoContainerOptions: [ + { id: 'auto', label: 'Auto' }, + { id: 'mp4', label: 'MP4' }, + { id: 'webm', label: 'WebM' }, + ], + presets: [], presetName: '', activePresetId: null, @@ -73,6 +93,7 @@ document.addEventListener('alpine:init', () => { { id: 'vertical', label: 'Vertical' }, { id: 'horizontal', label: 'Horizontal' }, { id: 'diagonal', label: 'Diagonal' }, + { id: 'overlap', label: 'Overlap' }, ], diagonalStyles: [ @@ -83,6 +104,12 @@ document.addEventListener('alpine:init', () => { { id: 'soft', label: 'Soft' }, ], + overlapVariants: [ + { id: 'cards', label: 'Cards' }, + { id: 'side', label: 'Side' }, + { id: 'stack', label: 'Stack' }, + ], + backgroundOptions: [ { id: 'solid', label: 'Solid' }, { id: 'dots', label: 'Dots' }, @@ -112,6 +139,9 @@ document.addEventListener('alpine:init', () => { this.diagonalAngle, this.diagonalStyle, this.diagonalDensity, + this.overlapVariant, + this.overlapOffset, + this.overlapShadow, this.splitPosition, this.swapSides, this.imagePadding, @@ -133,7 +163,12 @@ document.addEventListener('alpine:init', () => { this.imageA, this.imageB, ], - () => this.render(), + () => { + if (this.exporting || this.videoPreviewing) { + return; + } + this.render(); + }, ); this.$nextTick(() => this.render()); @@ -155,11 +190,22 @@ document.addEventListener('alpine:init', () => { }, get canExport() { - return Boolean(this.imageA && this.imageB) && (this.exportPng || this.exportJpg); + if (!this.imageA || !this.imageB) { + return false; + } + if (this.exportFormat === 'video') { + return this.usesSplit; + } + return this.exportFormat === 'png' || this.exportFormat === 'jpg'; + }, + + get canPreviewVideo() { + return this.hasBothImages && this.usesSplit && this.exportFormat === 'video' && !this.exporting; }, get exportSizeLabel() { - const { width, height } = this.resolveExportSize(); + const { width, height } = + this.exportFormat === 'video' ? this.resolveVideoSize() : this.resolveExportSize(); return `${width} × ${height}`; }, @@ -172,7 +218,7 @@ document.addEventListener('alpine:init', () => { }, get showsDragHandle() { - return this.hasBothImages && (this.previewHovered || this.dragging); + return this.hasBothImages && !this.videoPreviewing && (this.previewHovered || this.dragging); }, setPreviewHovered(hovered) { @@ -184,6 +230,14 @@ document.addEventListener('alpine:init', () => { return this.layout === 'diagonal' && this.diagonalStyle !== 'straight' && this.diagonalStyle !== 'soft'; }, + get usesSplit() { + return this.layout !== 'overlap'; + }, + + get showsOverlapOffset() { + return this.layout === 'overlap' && this.overlapVariant !== 'side'; + }, + segmentClass(active) { return active ? 'bg-lumis-ink text-lumis-canvas' @@ -196,10 +250,70 @@ document.addEventListener('alpine:init', () => { }, selectLayout(id) { + this.stopVideoPreview(); this.layout = id; this.previewTool = id === 'diagonal' ? 'angle' : 'position'; }, + stopVideoPreview() { + this._videoPreviewToken += 1; + if (this.videoPreviewing) { + this.videoPreviewing = false; + this.render(); + } + }, + + async playVideoPreview() { + if (!this.canPreviewVideo || this.videoPreviewing) { + return; + } + + 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; + + try { + for (let i = 0; i <= frameCount; i++) { + if (token !== this._videoPreviewToken || !this.canPreviewVideo) { + return; + } + + const t = i / frameCount; + const split = this.videoReverse ? 1 - t : t; + const options = this.buildOptions(this.baseWidth, this.baseHeight); + options.splitPosition = clamp(split, 0, 1); + this.compositor.render(options); + + if (preview) { + this.previewMetrics = this.compositor.drawPreview(preview); + } + + this.statusMessage = `Preview… ${Math.round((i / frameCount) * 100)}%`; + await wait(frameDelay); + } + + if (token === this._videoPreviewToken) { + this.statusMessage = 'Preview finished.'; + } + } finally { + if (token === this._videoPreviewToken) { + this.videoPreviewing = false; + this.render(); + } + } + }, + + toggleVideoPreview() { + if (this.videoPreviewing) { + this.stopVideoPreview(); + return; + } + this.playVideoPreview(); + }, + setPreviewTool(tool) { this.previewTool = tool; this.$nextTick(() => this.updateHandle()); @@ -211,6 +325,9 @@ document.addEventListener('alpine:init', () => { diagonalAngle: this.diagonalAngle, diagonalStyle: this.diagonalStyle, diagonalDensity: this.diagonalDensity, + overlapVariant: this.overlapVariant, + overlapOffset: this.overlapOffset, + overlapShadow: this.overlapShadow, splitPosition: this.splitPosition, swapSides: this.swapSides, imagePadding: this.imagePadding, @@ -228,9 +345,12 @@ document.addEventListener('alpine:init', () => { backgroundFg: this.backgroundFg, backgroundDensity: this.backgroundDensity, exportScale: this.exportScale, - exportPng: this.exportPng, - exportJpg: this.exportJpg, + exportFormat: this.exportFormat, jpgQuality: this.jpgQuality, + videoDuration: this.videoDuration, + videoFps: this.videoFps, + videoReverse: this.videoReverse, + videoContainer: this.videoContainer, }; }, @@ -241,6 +361,7 @@ document.addEventListener('alpine:init', () => { const layouts = new Set(this.layouts.map((item) => item.id)); const styles = new Set(this.diagonalStyles.map((item) => item.id)); + const overlaps = new Set(this.overlapVariants.map((item) => item.id)); const backgrounds = new Set(this.backgroundOptions.map((item) => item.id)); if (layouts.has(settings.layout)) { @@ -255,6 +376,15 @@ document.addEventListener('alpine:init', () => { if (Number.isFinite(Number(settings.diagonalDensity))) { this.diagonalDensity = Math.min(100, Math.max(0.1, Number(settings.diagonalDensity))); } + if (overlaps.has(settings.overlapVariant)) { + this.overlapVariant = settings.overlapVariant; + } + if (Number.isFinite(Number(settings.overlapOffset))) { + this.overlapOffset = Math.min(40, Math.max(0, Number(settings.overlapOffset))); + } + if (typeof settings.overlapShadow === 'boolean') { + this.overlapShadow = settings.overlapShadow; + } if (Number.isFinite(Number(settings.splitPosition))) { this.splitPosition = Math.min(100, Math.max(0, Number(settings.splitPosition))); } @@ -305,15 +435,26 @@ document.addEventListener('alpine:init', () => { if (['1', '1.5', '2'].includes(String(settings.exportScale))) { this.exportScale = String(settings.exportScale); } - if (typeof settings.exportPng === 'boolean') { - this.exportPng = settings.exportPng; - } - if (typeof settings.exportJpg === 'boolean') { - this.exportJpg = settings.exportJpg; + if (['png', 'jpg', 'video'].includes(settings.exportFormat)) { + this.exportFormat = settings.exportFormat; + } else if (settings.exportJpg && settings.exportPng !== true) { + this.exportFormat = 'jpg'; } if (Number.isFinite(Number(settings.jpgQuality))) { this.jpgQuality = Math.min(100, Math.max(50, Number(settings.jpgQuality))); } + if (Number.isFinite(Number(settings.videoDuration))) { + this.videoDuration = Math.min(6, Math.max(1, Number(settings.videoDuration))); + } + if ([24, 30, 60].includes(Number(settings.videoFps))) { + this.videoFps = Number(settings.videoFps); + } + if (typeof settings.videoReverse === 'boolean') { + this.videoReverse = settings.videoReverse; + } + if (['auto', 'mp4', 'webm'].includes(settings.videoContainer)) { + this.videoContainer = settings.videoContainer; + } this.previewTool = this.layout === 'diagonal' ? 'angle' : 'position'; }, @@ -372,7 +513,10 @@ document.addEventListener('alpine:init', () => { if (settings.layout === 'diagonal' && settings.diagonalStyle) { parts.push(String(settings.diagonalStyle)); } - if (Number.isFinite(Number(settings.splitPosition))) { + if (settings.layout === 'overlap' && settings.overlapVariant) { + parts.push(String(settings.overlapVariant)); + } + if (settings.layout !== 'overlap' && Number.isFinite(Number(settings.splitPosition))) { parts.push(`${Math.round(Number(settings.splitPosition))}%`); } if (Number(settings.imagePadding) > 0) { @@ -595,6 +739,130 @@ document.addEventListener('alpine:init', () => { }; }, + resolveVideoSize() { + const image = this.imageA || this.imageB; + let width = image?.naturalWidth || this.baseWidth; + let height = image?.naturalHeight || this.baseHeight; + // Encoders are happier with even dimensions. + width = Math.max(2, width - (width % 2)); + height = Math.max(2, height - (height % 2)); + return { width, height }; + }, + + isVideoMimeSupported(type) { + return typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(type); + }, + + firstSupportedMime(types) { + return types.find((type) => this.isVideoMimeSupported(type)) || ''; + }, + + pickVideoMimeType() { + const mp4Types = ['video/mp4;codecs=avc1.42E01E', 'video/mp4;codecs=avc1', 'video/mp4']; + const webmTypes = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']; + + if (this.videoContainer === 'mp4') { + const mimeType = this.firstSupportedMime(mp4Types); + return mimeType ? { mimeType, extension: 'mp4', label: 'MP4' } : null; + } + + if (this.videoContainer === 'webm') { + const mimeType = this.firstSupportedMime(webmTypes); + return mimeType ? { mimeType, extension: 'webm', label: 'WebM' } : null; + } + + const mp4 = this.firstSupportedMime(mp4Types); + if (mp4) { + return { mimeType: mp4, extension: 'mp4', label: 'MP4' }; + } + + const webm = this.firstSupportedMime(webmTypes); + return webm ? { mimeType: webm, extension: 'webm', label: 'WebM' } : null; + }, + + get supportsMp4Video() { + return Boolean(this.firstSupportedMime(['video/mp4;codecs=avc1.42E01E', 'video/mp4;codecs=avc1', 'video/mp4'])); + }, + + get supportsWebmVideo() { + return Boolean(this.firstSupportedMime(['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'])); + }, + + async recordWipeVideo(width, height) { + if (typeof MediaRecorder === 'undefined') { + throw new Error('MediaRecorder is not supported in this environment.'); + } + + const picked = this.pickVideoMimeType(); + if (!picked) { + const wanted = this.videoContainer === 'auto' ? 'MP4/WebM' : this.videoContainer.toUpperCase(); + throw new Error(`${wanted} video export is not supported in this environment.`); + } + + const { mimeType, extension, label } = picked; + const fps = this.videoFps; + const durationSec = this.videoDuration; + const frameCount = Math.max(2, Math.round(fps * durationSec)); + const frameDelay = 1000 / fps; + + const renderFrame = (split01) => { + const options = this.buildOptions(width, height); + options.splitPosition = clamp(split01, 0, 1); + this.compositor.render(options); + }; + + renderFrame(this.videoReverse ? 1 : 0); + + const canvas = this.compositor.getCanvas(); + const stream = canvas.captureStream(fps); + const track = stream.getVideoTracks()[0]; + if (!track) { + throw new Error('Could not capture a video track from the canvas.'); + } + + const bits = Math.round(Math.min(25_000_000, Math.max(6_000_000, width * height * 6))); + const recorder = new MediaRecorder(stream, { + mimeType, + videoBitsPerSecond: bits, + }); + const chunks = []; + recorder.ondataavailable = (event) => { + if (event.data?.size) { + chunks.push(event.data); + } + }; + + const stopped = new Promise((resolve, reject) => { + recorder.onstop = () => resolve(); + recorder.onerror = () => reject(recorder.error || new Error('Recording failed.')); + }); + + recorder.start(100); + + for (let i = 0; i <= frameCount; i++) { + const t = i / frameCount; + const split = this.videoReverse ? 1 - t : t; + renderFrame(split); + if (typeof track.requestFrame === 'function') { + track.requestFrame(); + } + this.statusMessage = `Recording ${label}… ${Math.round((i / frameCount) * 100)}%`; + await wait(frameDelay); + } + + await wait(Math.max(frameDelay * 2, 250)); + recorder.stop(); + await stopped; + stream.getTracks().forEach((item) => item.stop()); + + const blob = new Blob(chunks, { type: extension === 'mp4' ? 'video/mp4' : 'video/webm' }); + if (!blob.size) { + throw new Error('Recording produced an empty file.'); + } + + return { blob, extension, label }; + }, + buildOptions(width, height) { return { lightImage: this.imageA, @@ -602,7 +870,12 @@ document.addEventListener('alpine:init', () => { width, height, layoutFamily: this.layout, - layoutVariant: this.layout === 'diagonal' ? this.diagonalStyle : 'hard', + layoutVariant: + this.layout === 'diagonal' + ? this.diagonalStyle + : this.layout === 'overlap' + ? this.overlapVariant + : 'hard', splitPosition: this.splitPosition / 100, softEdge: 0, swapSides: this.swapSides, @@ -611,6 +884,9 @@ document.addEventListener('alpine:init', () => { maskDensity: this.diagonalDensity, fitMode: 'cover', diagonalAngle: this.diagonalAngle, + overlapOffsetX: this.overlapOffset, + overlapOffsetY: this.overlapOffset, + overlapShadow: this.overlapShadow ? 28 : 0, imagePadding: this.imagePadding, imageRadius: this.imageRadius, labels: { @@ -830,44 +1106,45 @@ document.addEventListener('alpine:init', () => { return; } + this.stopVideoPreview(); this.exporting = true; this.statusMessage = 'Exporting…'; try { - const { width, height } = this.resolveExportSize(); - this.compositor.render(this.buildOptions(width, height)); const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - const jobs = []; - - if (this.exportPng) { - jobs.push({ - mime: 'image/png', - quality: undefined, - name: `pairframe-${width}x${height}-${stamp}.png`, - }); - } - if (this.exportJpg) { - jobs.push({ - mime: 'image/jpeg', - quality: clamp(this.jpgQuality / 100, 0.1, 1), - name: `pairframe-${width}x${height}-${stamp}.jpg`, - }); + if (this.exportFormat === 'video') { + if (!this.usesSplit) { + this.statusMessage = 'Video export needs a split layout (not Overlap).'; + return; + } + + const { width, height } = this.resolveVideoSize(); + this.statusMessage = 'Recording video…'; + const { blob, extension, label } = await this.recordWipeVideo(width, height); + this.downloadBlob(blob, `pairframe-${width}x${height}-${stamp}.${extension}`); + this.statusMessage = `Exported ${label} (${width} × ${height}).`; + return; } - for (const job of jobs) { - const blob = await this.compositor.exportBlob(job.mime, job.quality); - if (!blob) { - continue; - } - this.downloadBlob(blob, job.name); - await wait(120); + const { width, height } = this.resolveExportSize(); + this.compositor.render(this.buildOptions(width, height)); + const isJpg = this.exportFormat === 'jpg'; + const blob = await this.compositor.exportBlob( + isJpg ? 'image/jpeg' : 'image/png', + isJpg ? clamp(this.jpgQuality / 100, 0.1, 1) : undefined, + ); + + if (!blob) { + this.statusMessage = 'Nothing exported.'; + return; } - this.statusMessage = `Exported ${jobs.length} file${jobs.length === 1 ? '' : 's'} (${width} × ${height}).`; + this.downloadBlob(blob, `pairframe-${width}x${height}-${stamp}.${isJpg ? 'jpg' : 'png'}`); + this.statusMessage = `Exported ${isJpg ? 'JPG' : 'PNG'} (${width} × ${height}).`; } catch (error) { console.error(error); - this.statusMessage = 'Export failed. Try again.'; + this.statusMessage = error?.message || 'Export failed. Try again.'; } finally { this.exporting = false; this.render(); diff --git a/resources/js/compositor.js b/resources/js/compositor.js index 060c7ec..f3027a8 100644 --- a/resources/js/compositor.js +++ b/resources/js/compositor.js @@ -74,91 +74,90 @@ export function createCompositor() { paintBackground(ctx, width, height, options.background); const variant = options.layoutVariant || 'cards'; - const offsetX = ((Number(options.overlapOffsetX) || 12) / 100) * width * 0.25; - const offsetY = ((Number(options.overlapOffsetY) || 10) / 100) * height * 0.25; - const shadowBlur = Number(options.overlapShadow) || 28; + const offsetXRaw = Number(options.overlapOffsetX); + const offsetYRaw = Number(options.overlapOffsetY); + const offsetX = ((Number.isFinite(offsetXRaw) ? offsetXRaw : 12) / 100) * width * 0.25; + const offsetY = ((Number.isFinite(offsetYRaw) ? offsetYRaw : 10) / 100) * height * 0.25; + const shadowBlur = Number.isFinite(Number(options.overlapShadow)) + ? Math.max(0, Number(options.overlapShadow)) + : 28; const flip = Boolean(options.flipDirection); + const radiusPct = clamp(Number(options.imageRadius) || 0, 0, 50) / 100; + + const sizeCard = (image, maxW, maxH) => { + const iw = Math.max(1, image?.naturalWidth || image?.width || maxW); + const ih = Math.max(1, image?.naturalHeight || image?.height || maxH); + const scale = Math.min(maxW / iw, maxH / ih); + return { + w: Math.max(1, Math.round(iw * scale)), + h: Math.max(1, Math.round(ih * scale)), + }; + }; - const drawCard = (image, x, y, w, h, withChrome = true) => { + const drawCard = (image, x, y, w, h) => { + const radius = Math.min(w, h) * radiusPct; ctx.save(); - ctx.shadowColor = 'rgba(23, 23, 23, 0.28)'; - ctx.shadowBlur = shadowBlur; - ctx.shadowOffsetY = shadowBlur * 0.25; - ctx.fillStyle = '#ffffff'; - roundRectPath(ctx, x, y, w, h, 0); - ctx.fill(); - ctx.shadowColor = 'transparent'; - ctx.shadowBlur = 0; - - let contentY = y; - let contentH = h; - if (withChrome) { - ctx.fillStyle = '#F5F5F5'; - ctx.fillRect(x, y, w, 22); - ctx.fillStyle = '#EBEBEB'; - ctx.fillRect(x, y + 22, w, 1); - ctx.fillStyle = '#D4D4D4'; - ctx.beginPath(); - ctx.arc(x + 12, y + 11, 4, 0, Math.PI * 2); - ctx.arc(x + 24, y + 11, 4, 0, Math.PI * 2); - ctx.arc(x + 36, y + 11, 4, 0, Math.PI * 2); - ctx.fill(); - contentY = y + 23; - contentH = h - 23; + if (shadowBlur > 0) { + ctx.shadowColor = 'rgba(23, 23, 23, 0.28)'; + ctx.shadowBlur = shadowBlur; + ctx.shadowOffsetY = shadowBlur * 0.25; } - const inset = 1; - ctx.save(); - ctx.beginPath(); - ctx.rect(x + inset, contentY, w - inset * 2, contentH - inset); - ctx.clip(); - drawImageFitted(ctx, image, x + inset, contentY, w - inset * 2, contentH - inset, options.fitMode || 'cover'); - ctx.restore(); + if (radius > 0) { + sideCanvas.width = Math.max(1, Math.ceil(w)); + sideCanvas.height = Math.max(1, Math.ceil(h)); + sideCtx.clearRect(0, 0, sideCanvas.width, sideCanvas.height); + drawImageFitted(sideCtx, image, 0, 0, w, h, 'cover'); + sideCtx.globalCompositeOperation = 'destination-in'; + sideCtx.fillStyle = '#000000'; + roundRectPath(sideCtx, 0, 0, w, h, radius); + sideCtx.fill(); + sideCtx.globalCompositeOperation = 'source-over'; + ctx.drawImage(sideCanvas, x, y); + } else { + drawImageFitted(ctx, image, x, y, w, h, 'cover'); + } - ctx.strokeStyle = '#EBEBEB'; - ctx.lineWidth = 1; - ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); ctx.restore(); }; if (variant === 'side') { const gap = Math.max(16, width * 0.02); const pad = Math.min(width, height) * 0.06; - const cardW = (width - pad * 2 - gap) / 2; - const cardH = height - pad * 2; + const maxW = (width - pad * 2 - gap) / 2; + const maxH = height - pad * 2; const first = flip ? sideB : sideA; const second = flip ? sideA : sideB; - drawCard(first, pad, pad, cardW, cardH); - drawCard(second, pad + cardW + gap, pad, cardW, cardH); + const a = sizeCard(first, maxW, maxH); + const b = sizeCard(second, maxW, maxH); + drawCard(first, pad + (maxW - a.w) / 2, pad + (maxH - a.h) / 2, a.w, a.h); + drawCard(second, pad + maxW + gap + (maxW - b.w) / 2, pad + (maxH - b.h) / 2, b.w, b.h); return; } if (variant === 'stack') { - const pad = Math.min(width, height) * 0.1; - const cardW = width - pad * 2; - const cardH = height - pad * 2; + const pad = Math.min(width, height) * 0.08; + const maxW = width - pad * 2 - Math.abs(offsetX); + const maxH = height - pad * 2 - Math.abs(offsetY); const back = flip ? sideA : sideB; const front = flip ? sideB : sideA; - drawCard(back, pad + offsetX, pad + offsetY, cardW * 0.92, cardH * 0.92, false); - drawCard(front, pad, pad, cardW * 0.92, cardH * 0.92); + const sized = sizeCard(front || back, maxW, maxH); + const x = pad + (maxW - sized.w) / 2; + const y = pad + (maxH - sized.h) / 2; + drawCard(back, x + offsetX, y + offsetY, sized.w, sized.h); + drawCard(front, x, y, sized.w, sized.h); return; } - const pad = Math.min(width, height) * 0.08; - const cardW = width * 0.58; - const cardH = height * 0.7; - const ax = pad; - const ay = pad + offsetY * 0.3; - const bx = width - pad - cardW; - const by = height - pad - cardH; - - if (flip) { - drawCard(sideB, ax + offsetX * 0.2, ay, cardW, cardH); - drawCard(sideA, bx - offsetX, by - offsetY, cardW, cardH); - } else { - drawCard(sideA, ax, ay, cardW, cardH); - drawCard(sideB, bx - offsetX * 0.15, by - offsetY * 0.15, cardW, cardH); - } + const pad = Math.min(width, height) * 0.06; + const maxW = width * 0.62; + const maxH = height * 0.72; + const first = flip ? sideB : sideA; + const second = flip ? sideA : sideB; + const a = sizeCard(first, maxW, maxH); + const b = sizeCard(second, maxW, maxH); + drawCard(first, pad, pad + offsetY * 0.15, a.w, a.h); + drawCard(second, width - pad - b.w, height - pad - b.h, b.w, b.h); } function contentPadding(width, height, options) { diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 50c382e..91ed62b 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -258,9 +258,51 @@ class="slider-value" + +
+

Style

+
+ +
+
+ +
+
+
+ +
+ + % +
+
+ +
+
-
+

Split

@@ -286,7 +328,7 @@ class="slider-value"
-
+
@@ -430,17 +472,26 @@ class="px-2 py-1 text-xs font-medium"
+ Playing wipe preview + @@ -539,20 +590,29 @@ class="slider-value"
-

Scale

-
- - - +
+

Scale

+
+ + + +
-

Formats

+

Format

- - + + +
-
+
@@ -571,6 +631,75 @@ class="slider-value"
+ +
+

Wipe animation · source resolution

+

Switch to Vertical, Horizontal, or Diagonal for video.

+
+

Container

+
+ +
+
+
+
+ +
+ + s +
+
+ +
+
+

FPS

+
+ +
+
+
+

Direction

+
+ + +
+
+
+ +
+