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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
unchanged, and an older shell (or a multi-file recording) falls back to
the previous exporter automatically. Requires a LapWing build with the
`video_export_*` pipeline (plan 0024).
- **Drag mode** — drop in a log from a drag strip (or any venue the app doesn't
know) and standing-start runs are detected automatically: pick 1/8 mile,
1000 ft or 1/4 mile and every pass gets a time-slip style breakdown — ET at
the scoring distance plus 60 ft / 330 ft / 660 ft / 1000 ft splits. Passes
that lifted early stay listed as incomplete with the splits they did reach,
and your chosen distance is remembered per file and switchable from the
session header. (Plan 0022.)

### Changed
- **New LapWing look.** The app now wears the LapWing brand from the
Expand All @@ -116,9 +123,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **The footer now links to PerchWerks.** "LapWing is a PerchWerks product",
with the shared bird mark, takes you to perchwerks.com (it replaces the old
"Operated by PerchWerks LLC" line).
- **The shared-session "read-only" banner text is readable again** — it was
drawn in a colour meant for text on a solid amber fill, so it nearly
vanished against the banner's pale tint.
- **The app no longer loses its whole offline cache to a flaky connection.**
Caching the app was previously one ~10 MB all-or-nothing download: if anything
failed partway through — losing signal at a track, say — *nothing* was cached
Expand Down Expand Up @@ -149,16 +153,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
burn-in export will call this same renderer, so all three outputs match by
construction.

### Added
- **Drag mode** — drop in a log from a drag strip (or any venue the app doesn't
know) and standing-start runs are detected automatically: pick 1/8 mile,
1000 ft or 1/4 mile and every pass gets a time-slip style breakdown — ET at
the scoring distance plus 60 ft / 330 ft / 660 ft / 1000 ft splits. Passes
that lifted early stay listed as incomplete with the splits they did reach,
and your chosen distance is remembered per file and switchable from the
session header.

### Fixed
- **The shared-session "read-only" banner text is readable again** — it was
drawn in a colour meant for text on a solid amber fill, so it nearly
vanished against the banner's pale tint.
- **Setup history no longer hangs on "Loading…" or fails silently.** If the
history can't be read, the panel now says so with a Retry button, and a
rollback or duplicate that fails shows an error instead of doing nothing.
(Plan 0028.)
- **One damaged telemetry packet no longer rejects a whole GoPro import.** A
malformed GPS payload is skipped (a one-second gap) and the rest of the
recording imports. A corrupt or hostile MP4 whose sample table claims
billions of entries is also capped instead of freezing the tab. (Plan 0029.)
- **Insta360 (Android app): switching recordings quickly could stop the new
stream.** A previous recording's player closing late no longer shuts down
the one you just opened. (Plan 0025.)
- **The Insta360 import dialog no longer spins forever when reopened.** In the
Android app, reopening the camera dialog while a camera was still connected
got stuck on its loading spinner, and only unloading the video got you out.
Expand Down
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ src/
│ ├── graphview/ # Pro mode: GraphPanel, GraphViewPanel, MiniMap, SingleSeriesChart, GGDiagram, InfoBox, PanelCard (resizable card chrome for relocated Video/Mini-Map panels). The left column collapses via a divider flag tab (any screen size), and Video/Mini-Map can be relocated into the resizable graph stack from the top of the "Add Graph" picker (GraphPanel reports which are active so the host drops its duplicate VideoPlayer — single shared video ref). Split graphs (tablet+): SecondaryGraphStack mirrors the main panel's graph set for a chosen overlay lap in a draggable two-up view, overriding PlaybackContext for its subtree (nested PlaybackProvider) so one cursor lands on the same track position in both laps (distance-mapped via lib/referenceUtils mapIndexByDistance); SecondaryVideo is a literal second, lap-synced <video> for in-session overlay laps.
│ ├── drawer/ # File-manager drawer tabs (Files, Vehicles/Karts, Device*); SetupsTab + NotesTab also here but mounted as main-view tabs
│ ├── track-editor/ # Track editor: VisualEditor, SectorListEditor, CourseSectorEditor, Add*Dialog
│ ├── video-overlays/ # Video-export overlay system: registry + themes + per-widget *Overlay
│ ├── video-overlays/ # Video-overlay system: registry + themes + settings/export UI + OverlayCanvas; ONE scene renderer (lib/overlayCanvasRenderer, plan 0023) draws every widget for preview, web export and native export alike
│ ├── RaceLineView.tsx # Leaflet map: race line, speed heatmap, braking zones
│ ├── TelemetryChart.tsx # Canvas speed/telemetry chart (simple mode)
│ ├── VideoPlayer.tsx # Synced video playback + overlay system (multi-chunk GoPro playlists via lib/videoPlaylist); native: a camera stream renders as an <img> over lib/insta360's NativePlayerElement (plan 0025), with components/insta360/ (import dialog, 360° drag-to-point layer)
Expand All @@ -111,7 +111,7 @@ src/
│ ├── datalogParser.ts # ★ Format auto-detection router (entry point for all parsing)
│ ├── gpsQualityFilter.ts # ★ Post-parse cleanup (plan 0014): rebuilds samples dropping provably-bad rows (negative sats/accuracy/DOP, DOP>10, or a position jump implying >MAX_SPEED_MPS) — all formats; quality values are never fabricated onto rows (see xrk/xrkResample)
│ ├── *Parser.ts # nmea, ubx, iracing (.ibt), vbo, dove, dovex, alfano, aim, motec
│ ├── gopro/ # ★ GoPro video import (plan 0029): mp4Boxes (ranged-read ISO-BMFF → `gpmd` sample table), gpmf (KLV + GPS5/GPS9), goproTelemetry (pure → Dove CSV + known video sync offset), goproImport (File glue; async like XRK), videoHandoff (one-shot video → useVideoSync)
│ ├── gopro/ # ★ GoPro video import (plan 0029): mp4Boxes (ranged-read ISO-BMFF → `gpmd` sample table), gpmf (KLV + GPS5/GPS9), goproTelemetry (pure → Dove CSV + known video sync offset), goproImport (File glue; async like XRK — dynamic-imported, off the main chunk), goproDetect (the cheap eager name/`ftyp` gates), videoHandoff (one-shot video → useVideoSync)
│ ├── xrk/ # ★ AiM .xrk/.xrz importer — libxrk (Rust→WASM) in a Web Worker (→ docs/subsystems.md)
│ ├── channels.ts # ★ Canonical channel registry + normalizeChannels()
│ ├── courseDetection.ts # ★ Auto track/course/direction detection + waypoint mode (→ docs/subsystems.md)
Expand Down Expand Up @@ -422,7 +422,7 @@ and the seeder: **→ `docs/i18n.md`**.
outside preview/iframe contexts); `public/sw.js` is a legacy kill-switch. **The
precache install is all-or-nothing — one failed request discards the whole
service worker — so keep it small: heavy public assets belong in
`DEFERRED_ASSET_DIRS` (runtime-cached + warmed after activation), never in the
`DEFERRED_ASSET_DIRS` (`scripts/deferredAssets.ts`; runtime-cached + warmed after activation), never in the
install-blocking glob. See plan 0027 before adding anything bulky to `public/`.** `vite.config.ts`
also emits `/version.json` per build (the freshness signal for `versionCheck.ts`); it's
excluded from the Workbox precache (`globIgnores`) and fetched uncached. Static
Expand Down Expand Up @@ -522,7 +522,8 @@ menu still opens instantly; `CourseSectorEditor` (carries
(`lapwing-light` → `:root`, `lapwing-dark` → `.dark`); change colours there
first, then mirror them here. Logos: `BrandLogo` (inline bird mark,
`currentColor`) and `BrandLockup` (raster LAPWING lockup in `public/brand/` —
the logotype has no vector master yet). Archivo is display-only (`font-brand`).
the logotype has no vector master yet). Archivo is display-only (`font-brand`)
and only its italic face ships.
- **Admin/cloud code** is fully optional and env-gated — the core app has zero
admin/cloud dependencies on the eager graph.
- **Edge functions** live in `supabase/functions/`, auto-deployed, configured in
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ All formats are auto-detected on import:
| MoTeC CSV | MoTeC i2 Pro export | `.csv` |
| MoTeC LD | MoTeC native binary | `.ld` |
| NMEA | Standard GPS sentences | `.nmea`, `.txt`, `.csv` |
| GoPro video | GPS-tagged GoPro footage (HERO5+, GPS on) — the embedded GPMF telemetry track | `.mp4`, `.mov`, `.360` |
| GoPro video | GPS-tagged GoPro footage (HERO5+, GPS on) — the embedded GPMF telemetry track | `.mp4`, `.mov` (`.360` experimental) |

> **AiM XRK/XRZ** is parsed by [libxrk](https://github.com/m3rlin45/libxrk)'s
> pure-Rust core **compiled to a small (~200 KB) WebAssembly module** — no
Expand All @@ -109,7 +109,9 @@ All formats are auto-detected on import:
> saved as an ordinary Dove log, and because telemetry and footage share the
> camera's clock the video opens alongside the session **already synced**.
> Select all chapters of a split recording together to import them as one
> session. Design notes: `docs/plans/0029-gopro-gps-video-import.md`.
> session. **`.360` (MAX/Fusion) is experimental:** it carries the same
> telemetry track and is accepted, but hasn't been tested against a real file
> yet. Design notes: `docs/plans/0029-gopro-gps-video-import.md`.

> **iRacing IBT** is the sim's only native on-disk telemetry export — the binary
> `.ibt` file iRacing writes (at the session tick rate, typically 60 Hz) once
Expand Down
5 changes: 3 additions & 2 deletions docs/plans/0022-drag-mode.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Drag Mode — standing-start runs at unknown venues

> Status: **IN PROGRESS** — detector landed first (logic-first, like plan 0015);
> orchestration and UI follow in separate commits on this plan.
> Status: **DONE** — all four commits below landed (detector first,
> logic-first like plan 0015, then mapping, orchestration and UI); ships in
> 4.2.0. The follow-ups section is deliberately out of scope for v1.

## Why this exists

Expand Down
6 changes: 4 additions & 2 deletions docs/plans/0027-precache-resilience.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ service worker at all.

### Keep install small; defer the heavy extras

`DEFERRED_ASSET_DIRS` in `vite.config.ts` (`samples/`, `loggers/`) is the single
source of truth. It feeds three things:
`DEFERRED_ASSET_DIRS` (`samples/`, `loggers/`) is the single source of truth —
it lives in `scripts/deferredAssets.ts` (moved out of `vite.config.ts` after the
4.2.0 release review so the runtime-cache route, previously a hand-copied regex,
could be derived from it and unit-tested). It feeds three things:

1. `globIgnores` — those directories stay out of the install-blocking precache.
2. A `CacheFirst` runtime-caching route (`app-deferred-assets`), so once present
Expand Down
10 changes: 8 additions & 2 deletions docs/plans/0029-gopro-gps-video-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,14 @@ the C library).
- `src/lib/gopro/gpmf.ts` — pure KLV decoder + GPS5/GPS9 stream extraction.
- `src/lib/gopro/goproTelemetry.ts` — pure: payloads + timing → GPS rows →
Dove CSV text, plus the sync offset.
- `src/lib/gopro/goproImport.ts` — File glue (`isGoProVideoFile`,
`extractGoProTelemetry`, `importGoProVideo`).
- `src/lib/gopro/goproImport.ts` — File glue (`extractGoProTelemetry`,
`importGoProVideo`). Dynamic-imported by `datalogParser`/`FileImport`, so the
extractor stays off the main chunk.
- `src/lib/gopro/goproDetect.ts` — the cheap eager gates (`isGoProVideoFile`,
`isGoProVideoBuffer`) the routers call on every import.
- Hardening (4.2.0 review): a malformed GPS stream skips its payload instead of
aborting the import, and a uniform `stsz` count is capped at
`fileSize / sampleSize` so a hostile MP4 can't allocate billions of entries.
- `src/lib/gopro/videoHandoff.ts` — one-shot pending video for a session.
- `src/components/FileImport.tsx`, `src/lib/datalogParser.ts`,
`src/hooks/useVideoSync.ts` — wiring.
Expand Down
42 changes: 42 additions & 0 deletions scripts/deferredAssets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { DEFERRED_ASSET_DIRS, deferredAssetPathPattern, sameOriginPathMatcher } from "./deferredAssets";

const at = (path: string) => new URL(path, "https://lapwingdata.com");

describe("deferredAssetPathPattern", () => {
it("derives the route test from the directory list", () => {
const re = deferredAssetPathPattern(DEFERRED_ASSET_DIRS);
expect(re.source).toBe("^\\/(?:samples|loggers)\\/");
for (const dir of DEFERRED_ASSET_DIRS) expect(re.test(`/${dir}/file.bin`)).toBe(true);
});

it("matches only whole top-level directories", () => {
const re = deferredAssetPathPattern(DEFERRED_ASSET_DIRS);
expect(re.test("/samplesX/file")).toBe(false);
expect(re.test("/s/samples/file")).toBe(false);
expect(re.test("/samples")).toBe(false);
});

it("escapes regex metacharacters in directory names", () => {
const re = deferredAssetPathPattern(["a.b"]);
expect(re.test("/a.b/x")).toBe(true);
expect(re.test("/aXb/x")).toBe(false);
});
});

describe("sameOriginPathMatcher", () => {
const matcher = sameOriginPathMatcher(deferredAssetPathPattern(DEFERRED_ASSET_DIRS));

it("matches same-origin deferred paths only", () => {
expect(matcher({ url: at("/loggers/fledgling.webp"), sameOrigin: true })).toBe(true);
expect(matcher({ url: at("/loggers/fledgling.webp"), sameOrigin: false })).toBe(false);
expect(matcher({ url: at("/index.html"), sameOrigin: true })).toBe(false);
});

it("keeps working after the toString round-trip workbox-build does", () => {
// The worker gets the function's source text, not the closure.
const revived = new Function(`return (${matcher.toString()});`)() as typeof matcher;
expect(revived({ url: at("/samples/okc.dovex"), sameOrigin: true })).toBe(true);
expect(revived({ url: at("/sim/birdseye-sim.mjs"), sameOrigin: true })).toBe(false);
});
});
33 changes: 33 additions & 0 deletions scripts/deferredAssets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Build-time helpers for the offline "deferred asset" directories (plan 0027):
// the big public/ folders held out of the service-worker precache and runtime-
// cached instead. `vite.config.ts` feeds the one list below into the precache
// `globIgnores`, the emitted manifest AND the runtime-cache route, so adding a
// directory can never leave the three disagreeing.
//
// Pure build tooling (Node, runs in `vite build`), never shipped to the client.

/** `public/` subdirectories served from the runtime cache instead of the precache. */
export const DEFERRED_ASSET_DIRS = ["samples", "loggers"] as const;

const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");

/** Pathname test for "is this under one of `dirs`" — e.g. `/^\/(?:samples|loggers)\//`. */
export function deferredAssetPathPattern(dirs: readonly string[]): RegExp {
return new RegExp(`^\\/(?:${dirs.map(escapeRegExp).join("|")})\\/`);
}

export type SameOriginRouteMatcher = (ctx: { url: URL; sameOrigin: boolean }) => boolean;

/**
* A Workbox `urlPattern` callback matching same-origin requests whose pathname
* fits `pattern`. workbox-build serializes the callback into the generated
* worker with `Function.prototype.toString`, where nothing from this module's
* scope survives — so the regex is spliced into the function's SOURCE as a
* literal instead of being captured by a closure.
*/
export function sameOriginPathMatcher(pattern: RegExp): SameOriginRouteMatcher {
return new Function(
"{ url, sameOrigin }",
`return sameOrigin && ${pattern.toString()}.test(url.pathname);`,
) as SameOriginRouteMatcher;
}
Binary file removed src/assets/fonts/Archivo-Variable.woff2
Binary file not shown.
4 changes: 3 additions & 1 deletion src/components/FileImport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { lazy, Suspense, useCallback, useRef, useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import { Upload, Loader2, LifeBuoy } from "lucide-react";
import { parseDatalogContent, parseDatalogFile } from "@/lib/datalogParser";
import { importGoProVideo, isGoProVideoFile } from "@/lib/gopro/goproImport";
import { isGoProVideoFile } from "@/lib/gopro/goproDetect";
import { stageGoProVideo, type StagedVideoFile } from "@/lib/gopro/videoHandoff";
import { groupVideoRecordings } from "@/lib/videoPlaylist";
import { ParsedData } from "@/types/racing";
Expand Down Expand Up @@ -95,6 +95,8 @@ export function FileImport({ onDataLoaded, autoSave, autoSaveFile }: FileImportP
// One session per recording: a mixed selection is ambiguous, so say so
// rather than silently picking one.
if (!recording || groups.length > 1) throw new Error(t("fileImport.goproOneRecording"));
// The extractor is loaded on demand so it stays off the landing payload.
const { importGoProVideo } = await import("@/lib/gopro/goproImport");
const result = await importGoProVideo(
recording.files.map((f) => f.file),
(p) => setProgress(t("fileImport.goproProgress", { ...p })),
Expand Down
17 changes: 10 additions & 7 deletions src/components/VideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ import { OverlaySettingsPanel } from "@/components/video-overlays/OverlaySetting
import { VideoExportDialog, ExportOptions } from "@/components/video-overlays/VideoExportDialog";
import { OverlayCanvas } from "@/components/video-overlays/OverlayCanvas";
import { startVideoExport, downloadBlob, ExportContext, ExportSource } from "@/lib/videoExport";
import { startNativeVideoExport } from "@/lib/nativeVideoExport";
import { isNativeApp } from "@/lib/platform";
import { insta360SdkInfo } from "@/lib/insta360/ipc";
import { NativePlayerElement } from "@/lib/insta360/nativePlayer";
import { DEFAULT_VIEW_POSE } from "@/lib/insta360/types";
import { Insta360ViewLayer } from "@/components/insta360/Insta360ViewLayer";
Expand Down Expand Up @@ -309,7 +307,9 @@ export const VideoPlayer = memo(function VideoPlayer({
useEffect(() => {
if (!isNativeApp()) return;
let alive = true;
void insta360SdkInfo().then((info) => { if (alive) setInsta360Available(info.available); });
void import("@/lib/insta360/ipc")
.then(({ insta360SdkInfo }) => insta360SdkInfo())
.then((info) => { if (alive) setInsta360Available(info.available); });
return () => { alive = false; };
}, []);
const nativePlayer = state.nativeSource ? actions.videoRef.current : null;
Expand Down Expand Up @@ -612,7 +612,6 @@ export const VideoPlayer = memo(function VideoPlayer({
const exportType = options.range === "lap" ? "lap" as const : "session" as const;
const lapNum = options.range === "lap" && selectedLapNumber != null ? selectedLapNumber : undefined;
saveSessionVideo(sessionFileName, blob, vidName, exportType, options.includeOverlays, lapNum).then(() => {
console.log("Video saved to app storage");
actions.refreshStoredMeta();
}).catch(err => {
console.error("Failed to save video:", err);
Expand All @@ -635,15 +634,19 @@ export const VideoPlayer = memo(function VideoPlayer({
onSavedToDevice: (uri) => {
setIsExporting(false);
setShowExportDialog(false);
console.log("Video saved to gallery:", uri);
toast({ title: t("export.savedToGallery") });
},
} satisfies Parameters<typeof startVideoExport>[3];

// In the native shell, the hardware pipeline does the transcode (plan
// 0024); it resolves null when this shell can't (web, desktop stub, old
// app), and then the in-WebView exporter takes over unchanged.
void startNativeVideoExport(exportSource, exportContext, exportOptions, exportCallbacks).then(
// app), and then the in-WebView exporter takes over unchanged. The bridge
// is native-only, so the web build never downloads it.
const nativeExport = isNativeApp()
? import("@/lib/nativeVideoExport").then(({ startNativeVideoExport }) =>
startNativeVideoExport(exportSource, exportContext, exportOptions, exportCallbacks))
: Promise.resolve(null);
void nativeExport.then(
(native) => {
if (!native) startVideoExport(exportSource, exportContext, exportOptions, exportCallbacks);
},
Expand Down
Loading
Loading