From a37b3b8f23ce47b70d83582d0f13a40474ad7ec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Gueraud?= Date: Sat, 7 Feb 2026 17:18:05 +1100 Subject: [PATCH 1/3] app: add overlay mode --- src/components/Renderer.svelte | 636 ++++++++++++++++++++++++++--- src/components/Renderer/2d.ts | 51 +++ src/components/Renderer/overlay.ts | 122 ++++++ src/components/VideoOverlay.svelte | 208 ++++++++++ src/components/View.svelte | 1 + 5 files changed, 960 insertions(+), 58 deletions(-) create mode 100644 src/components/Renderer/overlay.ts create mode 100644 src/components/VideoOverlay.svelte diff --git a/src/components/Renderer.svelte b/src/components/Renderer.svelte index 8c3113e..8417456 100644 --- a/src/components/Renderer.svelte +++ b/src/components/Renderer.svelte @@ -1,9 +1,13 @@ + +
+ + + {#if overlayItems.length > 0} + + {/if} +
diff --git a/src/components/View.svelte b/src/components/View.svelte index cdaac22..04af3ea 100644 --- a/src/components/View.svelte +++ b/src/components/View.svelte @@ -271,6 +271,7 @@ {/if} +
Date: Sat, 7 Feb 2026 17:32:10 +1100 Subject: [PATCH 2/3] app: add colors options for overlay --- src/components/Renderer.svelte | 65 +++++++++++++++++++++++++----- src/components/Renderer/2d.ts | 11 +++-- src/components/VideoOverlay.svelte | 14 +++++-- 3 files changed, 73 insertions(+), 17 deletions(-) diff --git a/src/components/Renderer.svelte b/src/components/Renderer.svelte index 8417456..531f90d 100644 --- a/src/components/Renderer.svelte +++ b/src/components/Renderer.svelte @@ -178,6 +178,8 @@ let overlayFieldsEnabled = new SavedState>('overlayFields', DEFAULT_OVERLAY_FIELDS); let overlayPosition = new SavedState('overlayPosition', DEFAULT_OVERLAY_POSITION); let overlayFieldPositions = new SavedState('overlayFieldPositions', {}); + let overlayBackgroundColor = new SavedState('overlayBackgroundColor', 'rgba(15, 23, 42, 0.85)'); + let overlayTextColor = new SavedState('overlayTextColor', '#e2e8f0'); const overlayItemsForPreview = $derived.by(() => { const row = overlayRows[overlaySelectedRowIndex] ?? overlayRows[0]; @@ -327,7 +329,11 @@ const pos = overlayFieldPositions.v[def.id] ?? defaultPositions[i]!; return { label: def.label, value: def.getValue(row), x: pos.x, y: pos.y }; }); - drawVideoOverlayHud(ctx, vw, vh, { items: hudItems }); + drawVideoOverlayHud(ctx, vw, vh, { + items: hudItems, + backgroundColor: overlayBackgroundColor.v, + textColor: overlayTextColor.v, + }); const frame = new VideoFrame(canvas, { timestamp: i * frameDurationMicros }); encoder.encode(frame, { keyFrame: i % 30 === 0 }); frame.close(); @@ -1028,14 +1034,17 @@
- (inputFps.v = e.currentTarget.value)} - /> +
+ (inputFps.v = e.currentTarget.value)} + /> +

Match your source video’s frame rate (e.g. 24, 25, 30) for 1:1 quality.

+
Ride start = video time βˆ’ offset
+
+
+ + + (overlayBackgroundColor.v = e.currentTarget.value)} + title="Pick background color" + /> +
+
+ + + (overlayTextColor.v = e.currentTarget.value)} + title="Pick text color" + /> +
+
{/if} @@ -1092,6 +1137,8 @@ setSelectedRowIndex={(i) => (overlaySelectedRowIndex = i)} timeOffset={overlayTimeOffset} overlayItems={overlayItemsForPreview} + overlayBackgroundColor={overlayBackgroundColor.v} + overlayTextColor={overlayTextColor.v} onPositionChange={(id, x, y) => { overlayFieldPositions.v = { ...overlayFieldPositions.v, [id]: { x, y } }; }} diff --git a/src/components/Renderer/2d.ts b/src/components/Renderer/2d.ts index 6ce2f5a..cf1c323 100644 --- a/src/components/Renderer/2d.ts +++ b/src/components/Renderer/2d.ts @@ -543,6 +543,10 @@ function drawFootpad(params: FootpadParams) { export interface VideoOverlayHudOptions { /** Each item has label, value, and position in 0–1 (relative to canvas size) */ items: { label: string; value: string; x: number; y: number }[]; + /** Background color for each field box (CSS color, e.g. rgba(15,23,42,0.85)) */ + backgroundColor?: string; + /** Text color for label and value (CSS color) */ + textColor?: string; } /** Draw overlay HUD on a canvas (e.g. over a video frame). Used when exporting video with burned-in stats. */ @@ -552,7 +556,7 @@ export function drawVideoOverlayHud( height: number, options: VideoOverlayHudOptions, ): void { - const { items } = options; + const { items, backgroundColor = 'rgba(15, 23, 42, 0.85)', textColor = colors.fg } = options; if (items.length === 0) return; // Use smaller padding so export box size matches preview (preview uses ~16px / tailwind px-4) @@ -573,7 +577,7 @@ export function drawVideoOverlayHud( if (py + boxHeight > height) py = height - boxHeight; if (py < 0) py = 0; - ctx.fillStyle = 'rgba(15, 23, 42, 0.85)'; + ctx.fillStyle = backgroundColor; ctx.strokeStyle = 'rgba(71, 85, 105, 0.6)'; ctx.lineWidth = 2; ctx.beginPath(); @@ -581,11 +585,10 @@ export function drawVideoOverlayHud( ctx.fill(); ctx.stroke(); - ctx.fillStyle = colors.fgSubtle; + ctx.fillStyle = textColor; ctx.textAlign = 'left'; ctx.fillText(label, px + pad * 0.5, py + boxHeight / 2); - ctx.fillStyle = colors.fg; ctx.textAlign = 'right'; ctx.fillText(value, px + boxWidth - pad * 0.5, py + boxHeight / 2); } diff --git a/src/components/VideoOverlay.svelte b/src/components/VideoOverlay.svelte index 26b30d3..e774d70 100644 --- a/src/components/VideoOverlay.svelte +++ b/src/components/VideoOverlay.svelte @@ -22,6 +22,10 @@ timeOffset?: number; /** Overlay items with position (x, y in 0–1) */ overlayItems?: OverlayItem[]; + /** Background color for all overlay fields (CSS color) */ + overlayBackgroundColor?: string; + /** Text color for all overlay fields (CSS color) */ + overlayTextColor?: string; /** Called when user drags a field to a new position */ onPositionChange?: (id: string, x: number, y: number) => void; } @@ -33,6 +37,8 @@ setSelectedRowIndex, timeOffset = 0, overlayItems = [], + overlayBackgroundColor = 'rgba(15, 23, 42, 0.85)', + overlayTextColor = '#e2e8f0', onPositionChange, }: Props = $props(); @@ -187,10 +193,10 @@ {@const x = isDragging ? dragX : item.x} {@const y = isDragging ? dragY : item.y}
onPositionChange && startDrag(e, item)} @@ -199,8 +205,8 @@ onpointercancel={endDrag} onpointerleave={(e) => e.buttons === 0 && endDrag(e)} > - {item.label} - {item.value} + {item.label} + {item.value}
{/each} From fae13acee183cb872cb5ef55d5758ad8ad5b5ba6 Mon Sep 17 00:00:00 2001 From: acheronfail Date: Tue, 21 Apr 2026 22:18:15 +0930 Subject: [PATCH 3/3] refactor: split out dir stuff and fix types --- src/components/Renderer.svelte | 146 ++++++++++------------------- src/components/Renderer/2d.ts | 7 +- src/components/Renderer/overlay.ts | 19 +--- src/components/VideoOverlay.svelte | 5 +- src/lib/output-directory-handle.ts | 74 +++++++++++++++ src/vite-env.d.ts | 1 + 6 files changed, 131 insertions(+), 121 deletions(-) create mode 100644 src/lib/output-directory-handle.ts diff --git a/src/components/Renderer.svelte b/src/components/Renderer.svelte index 531f90d..3ea82fd 100644 --- a/src/components/Renderer.svelte +++ b/src/components/Renderer.svelte @@ -31,6 +31,7 @@ type OverlayPosition, type FieldPositions, } from './Renderer/overlay'; + import { chooseOutputFolder, getOutputDirectory, loadOutputHandle } from '../lib/output-directory-handle'; const defaultFps = 20; const defaultWidth = 1080; @@ -78,75 +79,17 @@ let renderMode = new SavedState<'render' | 'overlay'>('renderMode', 'render'); // output folder (persisted to IndexedDB so it survives reload) - const OUTPUT_HANDLE_DB = 'float-renderer-output'; - const OUTPUT_HANDLE_KEY = 'outputDirectory'; - let outputDirectoryHandle = $state(null); let outputDirectoryName = $derived(outputDirectoryHandle?.name ?? null); - function saveOutputHandle(handle: FileSystemDirectoryHandle): void { - try { - const req = indexedDB.open(OUTPUT_HANDLE_DB, 1); - req.onupgradeneeded = () => req.result.createObjectStore('handles'); - req.onsuccess = () => { - req.result.transaction('handles', 'readwrite').objectStore('handles').put(handle, OUTPUT_HANDLE_KEY); - }; - } catch (e) { - console.warn('Could not save output folder:', e); - } + async function chooseAndSetOutputDirectory(): Promise { + outputDirectoryHandle = await chooseOutputFolder(); + return outputDirectoryHandle; } - function loadOutputHandle(): Promise { - return new Promise((resolve) => { - try { - const req = indexedDB.open(OUTPUT_HANDLE_DB, 1); - req.onupgradeneeded = () => req.result.createObjectStore('handles'); - req.onsuccess = () => { - const tx = req.result.transaction('handles', 'readonly'); - const get = tx.objectStore('handles').get(OUTPUT_HANDLE_KEY); - get.onsuccess = () => resolve(get.result ?? null); - get.onerror = () => resolve(null); - }; - req.onerror = () => resolve(null); - } catch { - resolve(null); - } - }); - } - - async function chooseOutputFolder(): Promise { - try { - const dir = await window.showDirectoryPicker({ - id: 'output', - mode: 'readwrite', - startIn: 'videos', - }); - outputDirectoryHandle = dir; - saveOutputHandle(dir); - return dir; - } catch (e) { - if ((e as Error).name !== 'AbortError') console.error(e); - return null; - } - } - - async function getOutputDirectory(): Promise { - if (outputDirectoryHandle) { - try { - if ('requestPermission' in outputDirectoryHandle && (outputDirectoryHandle as FileSystemDirectoryHandle).requestPermission) { - const perm = await (outputDirectoryHandle as FileSystemDirectoryHandle).requestPermission({ mode: 'readwrite' }); - if (perm !== 'granted') { - outputDirectoryHandle = null; - return await chooseOutputFolder(); - } - } - return outputDirectoryHandle; - } catch { - outputDirectoryHandle = null; - return await chooseOutputFolder(); - } - } - return await chooseOutputFolder(); + async function getAndSetOutputDirectory(): Promise { + outputDirectoryHandle = await getOutputDirectory(outputDirectoryHandle); + return outputDirectoryHandle; } // saved user input @@ -214,11 +157,13 @@ overlayRows = []; return; } - parse(rideFile).then((result) => { - overlayRows = result.data; - }).catch(() => { - overlayRows = []; - }); + parse(rideFile) + .then((result) => { + overlayRows = result.data; + }) + .catch(() => { + overlayRows = []; + }); }); function rowAtTime(rows: RowWithIndex[], rideTime: number): RowWithIndex { @@ -242,7 +187,7 @@ alert('Please load both a ride file and a video first.'); return; } - const dir = await getOutputDirectory(); + const dir = await getAndSetOutputDirectory(); if (!dir) return; Notification.requestPermission(); const overlayFilename = filename ? `${filename} - overlay` : 'ride-overlay'; @@ -341,6 +286,8 @@ elProgressBar2.value = i + 1; elProgressText2.textContent = `${(((i + 1) / totalFrames) * 100).toFixed(1)}% (${i + 1} frames)`; } + + // yield to event loop every 30 frames to keep UI responsive and allow cancellation if (i % 30 === 0) { await new Promise((r) => setTimeout(r, 0)); } @@ -465,7 +412,7 @@ alert('Please enter a filename!'); return; } - const directoryHandle = await getOutputDirectory(); + const directoryHandle = await getAndSetOutputDirectory(); if (!directoryHandle) return; Notification.requestPermission(); @@ -811,7 +758,10 @@

Output folder

- @@ -847,10 +797,7 @@ > πŸ“ Clear file - + {inputFile?.name ?? 'No ride file selected'}
@@ -916,24 +863,24 @@ πŸ“ Clear video
+ {:else if !inputFile} +

Load a ride file above first.

{:else} - {#if !inputFile} -

Load a ride file above first.

- {:else} - - {/if} + {/if} {/if} @@ -984,7 +931,10 @@ type="checkbox" checked={overlayFieldsEnabled.v[def.id] ?? false} onchange={() => { - overlayFieldsEnabled.v = { ...overlayFieldsEnabled.v, [def.id]: !overlayFieldsEnabled.v[def.id] }; + overlayFieldsEnabled.v = { + ...overlayFieldsEnabled.v, + [def.id]: !overlayFieldsEnabled.v[def.id], + }; }} /> {def.label} @@ -992,7 +942,9 @@ {/each} -

Drag fields on the preview to move them. Position is used when exporting.

+

+ Drag fields on the preview to move them. Position is used when exporting. +

Default position (for new fields)

@@ -1043,7 +995,9 @@ placeholder={`${defaultFps}`} onblur={(e) => (inputFps.v = e.currentTarget.value)} /> -

Match your source video’s frame rate (e.g. 24, 25, 30) for 1:1 quality.

+

+ Match your source video’s frame rate (e.g. 24, 25, 30) for 1:1 quality. +

diff --git a/src/components/Renderer/2d.ts b/src/components/Renderer/2d.ts index cf1c323..bf4b586 100644 --- a/src/components/Renderer/2d.ts +++ b/src/components/Renderer/2d.ts @@ -550,12 +550,7 @@ export interface VideoOverlayHudOptions { } /** Draw overlay HUD on a canvas (e.g. over a video frame). Used when exporting video with burned-in stats. */ -export function drawVideoOverlayHud( - ctx: Ctx, - width: number, - height: number, - options: VideoOverlayHudOptions, -): void { +export function drawVideoOverlayHud(ctx: Ctx, width: number, height: number, options: VideoOverlayHudOptions): void { const { items, backgroundColor = 'rgba(15, 23, 42, 0.85)', textColor = colors.fg } = options; if (items.length === 0) return; diff --git a/src/components/Renderer/overlay.ts b/src/components/Renderer/overlay.ts index 8f045df..b904f2f 100644 --- a/src/components/Renderer/overlay.ts +++ b/src/components/Renderer/overlay.ts @@ -40,18 +40,8 @@ export const DEFAULT_OVERLAY_FIELDS: Record = { /** CSS classes for positioning the overlay container (Tailwind-style: insets + flex). */ export function getOverlayPositionClasses(pos: OverlayPosition): string { const v = pos.vertical === 'top' ? 'top-4' : pos.vertical === 'bottom' ? 'bottom-4' : 'top-1/2 -translate-y-1/2'; - const h = - pos.horizontal === 'left' - ? 'left-4' - : pos.horizontal === 'right' - ? 'right-4' - : 'left-1/2 -translate-x-1/2'; - const flex = - pos.vertical === 'top' - ? 'flex-col' - : pos.vertical === 'bottom' - ? 'flex-col-reverse' - : 'flex-col'; + const h = pos.horizontal === 'left' ? 'left-4' : pos.horizontal === 'right' ? 'right-4' : 'left-1/2 -translate-x-1/2'; + const flex = pos.vertical === 'top' ? 'flex-col' : pos.vertical === 'bottom' ? 'flex-col-reverse' : 'flex-col'; return `${v} ${h} flex ${flex} gap-2`; } @@ -94,10 +84,7 @@ const GAP_FRAC = 0.02; const BOX_WIDTH_FRAC = 0.28; /** Default (x, y) in 0–1 for each item when stacked at the given position. Used when no custom position is set. */ -export function getDefaultItemPositions( - pos: OverlayPosition, - itemCount: number, -): { x: number; y: number }[] { +export function getDefaultItemPositions(pos: OverlayPosition, itemCount: number): { x: number; y: number }[] { const result: { x: number; y: number }[] = []; let x: number; if (pos.horizontal === 'left') x = PAD_FRAC; diff --git a/src/components/VideoOverlay.svelte b/src/components/VideoOverlay.svelte index e774d70..18f98f4 100644 --- a/src/components/VideoOverlay.svelte +++ b/src/components/VideoOverlay.svelte @@ -165,8 +165,6 @@ v.removeEventListener('seeked', onSeeked); }; }); - - const currentRow = $derived(rows[selectedRowIndex] ?? rows[0]);
@@ -196,7 +194,8 @@ class="absolute w-fit max-w-[85%] flex items-center gap-2 px-4 py-2 rounded-lg backdrop-blur border border-slate-600/50 cursor-grab active:cursor-grabbing select-none pointer-events-auto touch-none {isDragging ? 'ring-2 ring-cyan-400' : ''}" - style="left: {x * 100}%; top: {y * 100}%; transform: translate(0, 0); background: {overlayBackgroundColor}; color: {overlayTextColor};" + style="left: {x * 100}%; top: {y * + 100}%; transform: translate(0, 0); background: {overlayBackgroundColor}; color: {overlayTextColor};" role="button" tabindex="-1" onpointerdown={(e) => onPositionChange && startDrag(e, item)} diff --git a/src/lib/output-directory-handle.ts b/src/lib/output-directory-handle.ts new file mode 100644 index 0000000..20e5571 --- /dev/null +++ b/src/lib/output-directory-handle.ts @@ -0,0 +1,74 @@ +/** + * Required in order to save the chosen directory across reloads, etc. + * Only IndexedDB supports this. + */ + +const OUTPUT_HANDLE_DB = 'float-renderer-output'; +const OUTPUT_HANDLE_KEY = 'outputDirectory'; + +export function saveOutputHandle(handle: FileSystemDirectoryHandle): void { + try { + const req = indexedDB.open(OUTPUT_HANDLE_DB, 1); + req.onupgradeneeded = () => req.result.createObjectStore('handles'); + req.onsuccess = () => { + req.result.transaction('handles', 'readwrite').objectStore('handles').put(handle, OUTPUT_HANDLE_KEY); + }; + } catch (e) { + console.warn('Could not save output folder:', e); + } +} + +export function loadOutputHandle(): Promise { + return new Promise((resolve) => { + try { + const req = indexedDB.open(OUTPUT_HANDLE_DB, 1); + req.onupgradeneeded = () => req.result.createObjectStore('handles'); + req.onsuccess = () => { + const tx = req.result.transaction('handles', 'readonly'); + const get = tx.objectStore('handles').get(OUTPUT_HANDLE_KEY); + get.onsuccess = () => resolve(get.result ?? null); + get.onerror = () => resolve(null); + }; + req.onerror = () => resolve(null); + } catch { + resolve(null); + } + }); +} + +export async function chooseOutputFolder(): Promise { + try { + if (!window.showDirectoryPicker) return null; + + const dir = await window.showDirectoryPicker({ + id: 'output', + mode: 'readwrite', + startIn: 'videos', + }); + saveOutputHandle(dir); + return dir; + } catch (e) { + if ((e as Error).name !== 'AbortError') console.error(e); + return null; + } +} + +export async function getOutputDirectory( + outputDirectoryHandle: FileSystemDirectoryHandle | null, +): Promise { + if (outputDirectoryHandle) { + try { + if (outputDirectoryHandle.requestPermission) { + const perm = await outputDirectoryHandle.requestPermission({ mode: 'readwrite' }); + if (perm !== 'granted') { + return await chooseOutputFolder(); + } + } + return outputDirectoryHandle; + } catch { + return await chooseOutputFolder(); + } + } + + return await chooseOutputFolder(); +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 4078e74..8da3808 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,2 +1,3 @@ /// /// +///