Skip to content

Commit 2c55c31

Browse files
authored
feat(record-tour): save and load tour setup as a JSON file (#898)
* feat(record-tour): save and load tour setup as a JSON file The Record Map Tour panel could only export the recorded video; the underlying keyframes, durations, and frame rate were lost when the panel closed, so a tour could not be paused, refined, or reused. Add a Save setup / Load setup pair near the top of the panel. Save writes the keyframes and FPS to a JSON file; Load reads one back, repopulating the keyframe list and frame rate (fresh ids are minted so reloaded rows never collide). The serializer and parser share the FPS/segment bounds with the controls, so a hand-edited or stale file is clamped to the supported range, and a malformed file surfaces a translated error instead of crashing. Closes #897 * Address Claude review feedback - parseTourConfig now rejects a config written by a newer, incompatible format version (was stamped on save but never read back); a missing or older version is still accepted. - Clamp zoom/pitch/bearing into MapLibre's supported ranges on load to match the documented validation contract (only durationMs/fps were clamped). - Rename the config file default to "map-tour-setup" so it is genuinely distinct from the video name, and fix the misleading comment. - Grammar: "Saved setup as {{name}}". - Add tests for camera clamping and newer-version rejection. * Address Claude review feedback (round 2) - Wrap bearing onto (-180, 180] instead of clamping, so a hand-edited 270 maps to -90 (west) rather than 180 (south). - Reject a keyframe whose latitude is outside ±90 (a real out-of-range coordinate), matching the validation the comment claims. - Cap parsed keyframes at 500 so a crafted/huge file can't make the parser allocate a giant array and loop createId() over it. - handleLoadConfig: only clear the result banner once a file is actually chosen, so cancelling the picker no longer wipes a prior "Saved setup…". - Confirm before loading when the panel already has keyframes, so a misclick on "Load setup" can't silently discard in-progress work (new confirmLoad string). - Tests for bearing wrap, latitude rejection, and the keyframe cap. * Address CodeRabbit review feedback - Guard the raw config text length (1 MB) before JSON.parse, so a pathological file is rejected without being fully allocated first (completes the MAX_KEYFRAMES DoS hardening, which only ran post-parse). * Address Claude review feedback (round 3) - handleSaveConfig now clears the result banner only after the file is actually written, matching handleLoadConfig, so cancelling the save dialog no longer wipes a prior "Saved setup…" message. - handleLoadConfig short-circuits only on a cancelled picker (null result); an empty file now flows through to parseTourConfig so it surfaces a real error instead of silently doing nothing. * Address Claude review feedback (round 4) - Clamp each keyframe's durationMs on serialize too, mirroring parseKeyframe, so save/load is symmetric and a programmatic caller can't persist an out-of-range duration. - Tighten the version gate to also reject a present-but-non-numeric version (e.g. "2"); a missing version is still accepted as legacy v1. Add tests for the string-version rejection and the missing-version acceptance. * Address Claude review feedback (round 5) - Fix openLocalDataFileWithFallback hanging forever when the browser file picker is dismissed without a selection: the input only had an onchange handler (which never fires on cancel), so handleLoadConfig's await never settled. Add a "cancel" listener that resolves null, matching the existing pickImageFilesWithFallback pattern. - Assert the missing-fps fallback to DEFAULT_FPS in the parse test. * Address Claude review feedback (round 6) - Tighten the version gate: a present version must be an integer in [1, TOUR_CONFIG_VERSION], so an unrecognized 0 or negative value is now rejected too (only a missing version still defaults to v1). Add a test for the version-below-1 case.
1 parent 9a9f62c commit 2c55c31

5 files changed

Lines changed: 544 additions & 7 deletions

File tree

apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx

Lines changed: 118 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
ArrowUp,
66
Camera,
77
Circle,
8+
Download,
9+
FolderOpen,
810
GripHorizontal,
911
MapPin,
1012
Plus,
@@ -15,11 +17,23 @@ import {
1517
} from "lucide-react";
1618
import { useRef, useState } from "react";
1719
import { useTranslation } from "react-i18next";
18-
import { saveBinaryFileWithFallback } from "../../lib/tauri-io";
1920
import {
21+
openLocalDataFileWithFallback,
22+
saveBinaryFileWithFallback,
23+
saveTextFileWithFallback,
24+
} from "../../lib/tauri-io";
25+
import {
26+
DEFAULT_FPS,
27+
DEFAULT_SEGMENT_SECONDS,
2028
estimateTourDurationMs,
2129
isTourRecordingSupported,
30+
MAX_FPS,
31+
MAX_SEGMENT_SECONDS,
32+
MIN_FPS,
33+
MIN_SEGMENT_SECONDS,
34+
parseTourConfig,
2235
recordTour,
36+
serializeTourConfig,
2337
type TourKeyframe,
2438
TourRecordingUnsupportedError,
2539
} from "../../lib/tour-recorder";
@@ -35,12 +49,10 @@ interface RecordTourDialogProps {
3549
type Status = "idle" | "recording" | "ready" | "saving";
3650

3751
const DEFAULT_FILE_NAME = "map-tour";
38-
const DEFAULT_FPS = 30;
39-
const MIN_FPS = 10;
40-
const MAX_FPS = 60;
41-
const DEFAULT_SEGMENT_SECONDS = 4;
42-
const MIN_SEGMENT_SECONDS = 0.5;
43-
const MAX_SEGMENT_SECONDS = 30;
52+
// Default leaf name for the saved tour *configuration* (the editable JSON),
53+
// distinct from the recorded video's name so the two exports are easy to tell
54+
// apart in a downloads folder.
55+
const DEFAULT_CONFIG_FILE_NAME = "map-tour-setup";
4456

4557
function createId(): string {
4658
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
@@ -92,6 +104,9 @@ export function RecordTourDialog({
92104
const [error, setError] = useState<string | null>(null);
93105
const [savedName, setSavedName] = useState<string | null>(null);
94106
const [saveCancelled, setSaveCancelled] = useState(false);
107+
// Outcome banner for the configuration save/load actions (kept separate from
108+
// the video save banner so the two messages never clobber each other).
109+
const [configMessage, setConfigMessage] = useState<string | null>(null);
95110
// The finished recording, held until the user names it and clicks Save.
96111
const [pendingBlob, setPendingBlob] = useState<Blob | null>(null);
97112
const [fileName, setFileName] = useState(DEFAULT_FILE_NAME);
@@ -126,6 +141,7 @@ export function RecordTourDialog({
126141
const clearResultMessages = () => {
127142
setSavedName(null);
128143
setSaveCancelled(false);
144+
setConfigMessage(null);
129145
setError(null);
130146
};
131147

@@ -357,6 +373,69 @@ export function RecordTourDialog({
357373
setStatus("idle");
358374
};
359375

376+
// Export the editable tour setup (keyframes, durations, FPS) as a JSON file so
377+
// it can be reloaded and refined later, independent of the recorded video.
378+
const handleSaveConfig = async () => {
379+
if (keyframes.length === 0) return;
380+
try {
381+
const content = serializeTourConfig(keyframes, fps);
382+
const fileType = t("recordTour.configFileType");
383+
const name = await saveTextFileWithFallback(content, {
384+
defaultName: `${DEFAULT_CONFIG_FILE_NAME}.json`,
385+
filters: [{ name: fileType, extensions: ["json"] }],
386+
browserTypes: [
387+
{ description: fileType, accept: { "application/json": [".json"] } },
388+
],
389+
mimeType: "application/json",
390+
});
391+
// Cancelling the dialog returns null and is a no-op, so only clear a prior
392+
// result banner once the file is actually written.
393+
if (name) {
394+
clearResultMessages();
395+
setConfigMessage(t("recordTour.configSaved", { name }));
396+
}
397+
} catch (err) {
398+
console.warn("Tour configuration save failed", err);
399+
clearResultMessages();
400+
setError(t("recordTour.configSaveError"));
401+
}
402+
};
403+
404+
// Load a previously saved tour setup, replacing the current keyframe list and
405+
// frame rate. Fresh ids are minted so reloaded rows never collide. A bad file
406+
// throws a parse error, surfaced as a translated message rather than a crash.
407+
const handleLoadConfig = async () => {
408+
// Loading replaces the whole tour, so confirm first when there is existing
409+
// work a misclick would otherwise wipe.
410+
if (keyframes.length > 0 && !window.confirm(t("recordTour.confirmLoad"))) {
411+
return;
412+
}
413+
try {
414+
const fileType = t("recordTour.configFileType");
415+
const result = await openLocalDataFileWithFallback({
416+
filters: [{ name: fileType, extensions: ["json"] }],
417+
accept: ".json,application/json",
418+
readText: true,
419+
});
420+
// Only a null result means the picker was cancelled (a no-op). An empty
421+
// file still flows through so parseTourConfig surfaces a real error rather
422+
// than silently doing nothing after the user explicitly chose a file.
423+
if (result == null) return;
424+
clearResultMessages();
425+
const config = parseTourConfig(result.text ?? "");
426+
setKeyframes(config.keyframes.map((kf) => ({ ...kf, id: createId() })));
427+
setFps(config.fps);
428+
setFpsText(String(config.fps));
429+
setConfigMessage(
430+
t("recordTour.configLoaded", { count: config.keyframes.length }),
431+
);
432+
} catch (err) {
433+
console.warn("Tour configuration load failed", err);
434+
clearResultMessages();
435+
setError(t("recordTour.configLoadError"));
436+
}
437+
};
438+
360439
const totalSeconds = estimateTourDurationMs(keyframes) / 1000;
361440
const canRecord =
362441
keyframes.length >= 2 && RECORDING_SUPPORTED && status === "idle";
@@ -411,6 +490,33 @@ export function RecordTourDialog({
411490
</p>
412491
)}
413492

493+
{/* Save / load the editable tour setup so work can be paused, resumed,
494+
and reused across sessions (independent of the recorded video). */}
495+
<div className="flex items-center gap-2">
496+
<Button
497+
type="button"
498+
variant="outline"
499+
size="sm"
500+
className="flex-1"
501+
disabled={editingFrozen}
502+
onClick={handleLoadConfig}
503+
>
504+
<FolderOpen className="mr-1.5 h-3.5 w-3.5" />
505+
{t("recordTour.loadConfig")}
506+
</Button>
507+
<Button
508+
type="button"
509+
variant="outline"
510+
size="sm"
511+
className="flex-1"
512+
disabled={editingFrozen || keyframes.length === 0}
513+
onClick={handleSaveConfig}
514+
>
515+
<Download className="mr-1.5 h-3.5 w-3.5" />
516+
{t("recordTour.saveConfig")}
517+
</Button>
518+
</div>
519+
414520
<div className="flex items-center justify-between gap-2">
415521
<Button
416522
type="button"
@@ -510,6 +616,11 @@ export function RecordTourDialog({
510616
{t("recordTour.saveCancelled")}
511617
</p>
512618
)}
619+
{configMessage && (
620+
<p className="text-sm text-emerald-600 dark:text-emerald-400">
621+
{configMessage}
622+
</p>
623+
)}
513624
{error && <p className="text-sm text-destructive">{error}</p>}
514625

515626
{busy ? (

apps/geolibre-desktop/src/i18n/locales/en.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,15 @@
479479
"hint": "Pan and zoom the map, then capture each view. The map stays interactive while this panel is open.",
480480
"unsupported": "This browser cannot record the map canvas. Try a recent version of Chrome, Edge, or Firefox.",
481481
"addView": "Add current view",
482+
"loadConfig": "Load setup",
483+
"saveConfig": "Save setup",
484+
"confirmLoad": "Replace the current keyframes with the loaded setup?",
485+
"configFileType": "Tour Configuration",
486+
"configSaved": "Saved setup as {{name}}",
487+
"configLoaded_one": "Loaded setup with {{count}} keyframe.",
488+
"configLoaded_other": "Loaded setup with {{count}} keyframes.",
489+
"configSaveError": "Could not save the tour setup. Please try again.",
490+
"configLoadError": "Could not load the tour setup. The file may be invalid.",
482491
"keyframeCount_one": "{{count}} keyframe",
483492
"keyframeCount_other": "{{count}} keyframes",
484493
"empty": "Pan and zoom the map to a starting view, then add it as the first keyframe. Add at least two keyframes to record a tour.",

apps/geolibre-desktop/src/lib/tauri-io.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,6 +1118,9 @@ export async function openLocalDataFileWithFallback(
11181118
reject(error);
11191119
}
11201120
};
1121+
// Resolve (rather than hang) when the dialog is dismissed without a pick;
1122+
// `change` never fires on cancel, so without this the Promise never settles.
1123+
input.addEventListener("cancel", () => resolve(null));
11211124
input.click();
11221125
});
11231126
}

0 commit comments

Comments
 (0)