tripkit 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +30 -2
- package/agent/{AGENT-SKILL.md → SKILL.md} +18 -1
- package/convert.js +63 -0
- package/docs/MEDIA-GUIDE.md +298 -0
- package/docs/README.md +1 -0
- package/examples/oregon-spring-2026.yaml +15 -0
- package/media-ingest.js +389 -0
- package/package.json +10 -3
- package/renderers/html/tripkit-renderer.html +274 -25
- package/schema/tripkit.schema.yaml +18 -0
- package/scripts/check-skill-coverage.js +6 -6
- package/scripts/test-media-lifecycle.js +190 -0
- package/validate.js +30 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to TripKit are documented here. Versioning follows [SemVer](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## [1.4.0] — 2026-06-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Post-trip media — interactive map mode.** Attach a trip's real geotagged photos/videos to stops and see them on the map.
|
|
9
|
+
- **`tripkit media <folder> <trip.yaml>`** — scans a media folder, reads EXIF GPS + timestamps (`exifr`), auto-matches each item to the nearest stop on the matching day (haversine, day-gated by photo date), optionally generates thumbnails (`sharp`, optional dependency — gracefully skipped if absent), and writes a `<trip>.media-review.yaml` handoff file. Low-confidence / geotag-less / too-far items go to an `unmatched:` bucket for review.
|
|
10
|
+
- **`tripkit media apply <review.yaml> <trip.yaml>`** — merges the reviewed/captioned media into `stops[].media[]`. Idempotent.
|
|
11
|
+
- **Schema:** new optional `stops[].media[]` (also valid on `lodging`): `{ src, type: photo|video, thumb?, caption?, lat?, lng?, taken_at? }`. `src` is a relative path **or** a URL, so output can stay a shareable single file. Purely additive — stops without media render exactly as before.
|
|
12
|
+
- **Renderer:** stop cards and map popups show a thumbnail strip + a `📷 N` count badge; **stop markers with media get a corner `📷 N` badge that opens the lightbox directly** (the marker body still opens the normal popup); a full-screen **lightbox** (keyboard ←/→/Esc, click, swipe; inline video playback) opens from any thumbnail; a toggleable **photo-pin map layer** plots every media item that carries its own EXIF GPS, distinct from the day-numbered stop markers.
|
|
13
|
+
- **Validation:** `tripkit validate` checks each media item has a `src` and valid `type`, and warns when a media item's GPS is >25 mi from its stop (likely a wrong match).
|
|
14
|
+
- **Agent skill:** new Phase 5 documents the post-trip ingest → review → apply flow, with the agent owning the captioning/match-fixing review step.
|
|
15
|
+
- **Guide:** `docs/MEDIA-GUIDE.md` — end-to-end walkthrough (CLI steps, the review-file format field by field, AI-agent orchestration, the `media` schema, URLs vs local files, troubleshooting, command reference). Linked from the README and the agent skill.
|
|
16
|
+
- Dependencies: `exifr` (required for ingest) added to `dependencies`; `sharp` (thumbnails) added to `optionalDependencies`.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- **Lightbox close button.** The full-width photo counter (`1 / 2`) overlay was intercepting clicks aimed at the `×` button, so closing by click failed (Esc still worked). The counter is now click-through and the close button sits above it.
|
|
20
|
+
- **Origin/destination pins now connect to the route.** Auto-generated polylines (no explicit `routes[]`) now anchor Day 1 to `trip.origin` and the final day back to the destination (or origin, for a round trip). Previously the green "A" Start pin floated unattached — most visible when the last day's lodging is `name: "Home"` at the origin, which the route generator excludes from geometry.
|
|
21
|
+
|
|
22
|
+
## [1.3.0] — 2026-04-30
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- GitHub Actions CI: runs `npm test` (validator + skill-coverage gate) and validates every YAML in `examples/` on Node 18/20/22 for every push and PR to `main`.
|
|
26
|
+
- **Claude Code skill bundle.** `agent/SKILL.md` now ships with `name`/`description` YAML frontmatter so it discovers as a proper Claude Code skill. New `npx tripkit install-skill` command copies the skill to `~/.claude/skills/tripkit/` (or `--project` for `.claude/skills/`); after install, invoke with `/skills tripkit`.
|
|
27
|
+
- **Dark mode.** `theme.dark_mode: true` now applies a `data-theme="dark"` attribute to the document and overrides the full color system: warm near-black background, lifted accent green, translucent callout tints, dark Leaflet popups. Stop badges, callouts, and chrome all theme automatically.
|
|
28
|
+
- **Day-status visual treatment.** The `status: completed | active | upcoming` field on each day is now reflected in the day-nav: completed days are muted, the day flagged `active` (today) gets a small accent dot. Selecting a muted day still highlights it fully.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- Renamed `agent/AGENT-SKILL.md` → `agent/SKILL.md` to match Claude Code's skill convention. The body is unchanged and still agent-agnostic; the new frontmatter is ignored by non-Claude tooling.
|
|
32
|
+
- Renderer color system refactored to CSS variables. Stop badge colors, callout backgrounds, and shadows are all theme-aware via `--b-*-bg/fg`, `--*-soft`, and `--shadow*` variables — single source of truth for both light and dark modes.
|
|
33
|
+
- Stop cards lift on hover (subtle shadow + 1px translate) for a more tactile feel.
|
|
34
|
+
- Removed unused `--coral` variable (was a duplicate of `--warn`).
|
|
35
|
+
|
|
5
36
|
## [1.2.0] — 2026-04-30
|
|
6
37
|
|
|
7
38
|
### Added
|
package/README.md
CHANGED
|
@@ -75,10 +75,31 @@ npx tripkit validate my-trip.yaml
|
|
|
75
75
|
|
|
76
76
|
It checks required fields, lat/lng ranges, valid stop types, hex colors, day numbering, and warns when `trip.total_stops` doesn't match the actual count. Validation also runs automatically before each render — pass `--no-validate` to skip it.
|
|
77
77
|
|
|
78
|
+
### Add your trip photos (after the trip)
|
|
79
|
+
|
|
80
|
+
Turn a folder of geotagged photos/videos into an interactive map where each stop shows what you actually captured:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npx tripkit media ./my-photos my-trip.yaml # 1. EXIF-match photos to stops → writes my-trip.media-review.yaml
|
|
84
|
+
# edit captions / fix any mismatches in the review file
|
|
85
|
+
npx tripkit media apply my-trip.media-review.yaml my-trip.yaml # 2. merge into the trip
|
|
86
|
+
npx tripkit my-trip.yaml my-trip.html # 3. re-render with galleries
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Step 1 reads EXIF GPS + timestamps and auto-matches each photo to the nearest stop on the matching day. Items it can't place confidently (no GPS, too far from any stop) land in an `unmatched:` bucket you can fix by hand — or hand to an AI agent to caption and place. Thumbnails are generated automatically if [`sharp`](https://www.npmjs.com/package/sharp) is installed (optional). In the rendered page, stops gain a photo strip + count badge, a full-screen lightbox (arrow keys / swipe, inline video), and a toggleable map layer pinning every geotagged shot.
|
|
90
|
+
|
|
91
|
+
`src` values can be relative paths (the default) **or** URLs — use URLs if you want the output to stay a single shareable HTML file.
|
|
92
|
+
|
|
93
|
+
📖 **Full walkthrough:** [docs/MEDIA-GUIDE.md](docs/MEDIA-GUIDE.md) — the review-file format, how matching works, letting an AI agent caption and place your photos, and troubleshooting.
|
|
94
|
+
|
|
78
95
|
### Option 3: With an AI Agent (everyone)
|
|
79
96
|
|
|
80
97
|
1. Start a conversation with your preferred AI agent (Claude recommended)
|
|
81
|
-
2. Share the `agent/
|
|
98
|
+
2. Share the `agent/SKILL.md` as context — or, if you're using **Claude Code**, install it as a skill (one-time):
|
|
99
|
+
```bash
|
|
100
|
+
npx tripkit install-skill # installs to ~/.claude/skills/tripkit/
|
|
101
|
+
```
|
|
102
|
+
then invoke with `/skills tripkit` in any conversation.
|
|
82
103
|
3. Tell it about your trip: _"I'm planning a 5-day road trip through the Pacific Northwest with my family..."_
|
|
83
104
|
4. The agent uses the questionnaire to gather details, researches routes/hotels/restaurants, and generates a TripKit YAML file
|
|
84
105
|
5. Convert to HTML with `npx tripkit your-trip.yaml`, or ask the agent to render it directly
|
|
@@ -108,7 +129,7 @@ tripkit/
|
|
|
108
129
|
│ └── (coming soon) # React component library
|
|
109
130
|
├── agent/
|
|
110
131
|
│ ├── questionnaire.yaml # Elicitation template
|
|
111
|
-
│ └──
|
|
132
|
+
│ └── SKILL.md # System prompt + Claude Code skill bundle
|
|
112
133
|
├── docs/
|
|
113
134
|
│ └── screenshot.png
|
|
114
135
|
├── convert.js # YAML → HTML CLI tool
|
|
@@ -152,6 +173,13 @@ See `schema/tripkit.schema.yaml` for the complete specification.
|
|
|
152
173
|
- Click a day → map zooms to that segment
|
|
153
174
|
- Click a stop → map zooms and opens popup
|
|
154
175
|
|
|
176
|
+
### Trip Photos & Videos
|
|
177
|
+
- `tripkit media` ingests a folder of geotagged photos/videos and EXIF-matches them to stops
|
|
178
|
+
- Per-stop photo strip + count badge in cards and map popups
|
|
179
|
+
- Full-screen lightbox: arrow keys / swipe to navigate, inline video playback
|
|
180
|
+
- Toggleable map layer that pins every geotagged shot at its own location
|
|
181
|
+
- Photos stay as relative paths or URLs — the output can still be a single shareable file
|
|
182
|
+
|
|
155
183
|
### Day-by-Day Sidebar
|
|
156
184
|
- Weather callouts with forecasts
|
|
157
185
|
- Alert banners (road closures, permit requirements, age restrictions)
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tripkit
|
|
3
|
+
description: Plan road trips and itineraries that render as interactive map visualizations. Produces YAML conforming to the TripKit schema, validates it with `tripkit validate`, and renders to a self-contained HTML file via `npx tripkit`.
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# TripKit Agent Skill
|
|
2
7
|
|
|
3
|
-
System prompt / skill for AI agents that plan trips using the TripKit framework.
|
|
8
|
+
System prompt / skill for AI agents that plan trips using the TripKit framework. The YAML frontmatter above is for Claude Code's `/skills` discovery; the body itself is agent-agnostic — works with Claude, GPT, Gemini, or any capable LLM with web search.
|
|
4
9
|
|
|
5
10
|
## Role
|
|
6
11
|
|
|
@@ -36,6 +41,17 @@ The plan WILL change. Track every change in `agent_context.iteration_log` with d
|
|
|
36
41
|
- **Real-time adaptation** — weather changes, road closures, fatigue.
|
|
37
42
|
- **Schedule conflicts** — work meetings, reservations.
|
|
38
43
|
|
|
44
|
+
### Phase 5: Post-trip media (optional)
|
|
45
|
+
After the trip, the traveler can attach their real geotagged photos/videos to stops so the map shows what they actually saw. This is a **review-in-the-middle** flow — your job is the "review" step:
|
|
46
|
+
|
|
47
|
+
1. **Build** — the user runs `tripkit media <media-folder> <trip>.yaml`. This reads EXIF GPS + timestamps, auto-matches each item to the nearest stop on the matching day (haversine), generates thumbnails if `sharp` is installed, and writes `<trip>.media-review.yaml`. Items with no GPS, no stops on the day, or a nearest stop too far away land in an `unmatched:` bucket.
|
|
48
|
+
2. **Review (your role)** — open the review file. For each item: write a short, specific `caption` (what it shows, not "photo 1"). Fix any mis-matched `stop_index`. For `unmatched` items, reason from the filename, `taken_at`, and the trip plan to assign a `day` + `stop_index` — or leave them unmatched if genuinely unplaceable. Use the `suggested_stop`/`reason` hints.
|
|
49
|
+
3. **Apply** — the user runs `tripkit media apply <trip>.media-review.yaml <trip>.yaml` to merge the reviewed items into `stops[].media[]`, then re-renders. Apply is idempotent (re-running won't duplicate).
|
|
50
|
+
|
|
51
|
+
Captions are the high-value artifact here — they're what the lightbox shows. Keep them concise and grounded in what's actually visible.
|
|
52
|
+
|
|
53
|
+
Full reference (review-file format, matching rules, edge cases): `docs/MEDIA-GUIDE.md`.
|
|
54
|
+
|
|
39
55
|
## Schema Reference (must match `schema/tripkit.schema.yaml`)
|
|
40
56
|
|
|
41
57
|
### `trip` block
|
|
@@ -69,6 +85,7 @@ Each day:
|
|
|
69
85
|
- `reservation_required: bool` + `reservation_url` — for permit/timed-entry stops.
|
|
70
86
|
- `image` — Unsplash or other URL. If omitted, the renderer falls back to a type-specific default.
|
|
71
87
|
- `navigate_url` — Google Maps directions link.
|
|
88
|
+
- `media: [...]` — **post-trip only.** A gallery of the traveler's actual photos/videos at this stop. Usually populated by the `tripkit media` ingest flow (see Phase 5), not hand-authored during planning. Each item: `{ src, type: photo|video, thumb?, caption?, lat?, lng?, taken_at? }`. `src` is a relative path or URL. Items with `lat`/`lng` also drive the renderer's toggleable photo-pin map layer.
|
|
72
89
|
|
|
73
90
|
### `routes[]` block (optional but strongly recommended for road trips)
|
|
74
91
|
Each entry:
|
package/convert.js
CHANGED
|
@@ -29,6 +29,13 @@ TripKit — AI-friendly trip planning toolkit
|
|
|
29
29
|
Usage:
|
|
30
30
|
${invokedAs} <trip.yaml> [output.html] Render YAML to interactive HTML
|
|
31
31
|
${invokedAs} validate <trip.yaml> Check a trip YAML for schema errors
|
|
32
|
+
${invokedAs} media <folder> <trip.yaml> Geo-match a folder of trip photos/videos to
|
|
33
|
+
stops and write <trip>.media-review.yaml
|
|
34
|
+
${invokedAs} media apply <review.yaml> <trip.yaml>
|
|
35
|
+
Merge a reviewed media file into the trip YAML
|
|
36
|
+
${invokedAs} install-skill [--project] Install the agent skill for Claude Code
|
|
37
|
+
(default: ~/.claude/skills/tripkit/;
|
|
38
|
+
--project: ./.claude/skills/tripkit/)
|
|
32
39
|
|
|
33
40
|
Flags:
|
|
34
41
|
-h, --help Show this help
|
|
@@ -76,6 +83,23 @@ function reportFindings(errors, warnings) {
|
|
|
76
83
|
});
|
|
77
84
|
}
|
|
78
85
|
|
|
86
|
+
// === SUBCOMMAND: install-skill ===
|
|
87
|
+
if (args[0] === 'install-skill') {
|
|
88
|
+
const projectScope = args.includes('--project');
|
|
89
|
+
const dest = projectScope
|
|
90
|
+
? path.join(process.cwd(), '.claude', 'skills', 'tripkit')
|
|
91
|
+
: path.join(require('os').homedir(), '.claude', 'skills', 'tripkit');
|
|
92
|
+
const src = path.join(__dirname, 'agent');
|
|
93
|
+
|
|
94
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
95
|
+
for (const f of fs.readdirSync(src)) {
|
|
96
|
+
fs.copyFileSync(path.join(src, f), path.join(dest, f));
|
|
97
|
+
}
|
|
98
|
+
console.log(c.green('✓') + ` Installed TripKit skill to ${dest}`);
|
|
99
|
+
console.log(c.dim(` Invoke in Claude Code with: /skills tripkit`));
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}
|
|
102
|
+
|
|
79
103
|
// === SUBCOMMAND: validate ===
|
|
80
104
|
if (args[0] === 'validate') {
|
|
81
105
|
const inputFile = args[1];
|
|
@@ -96,6 +120,45 @@ if (args[0] === 'validate') {
|
|
|
96
120
|
process.exit(errors.length > 0 ? 1 : 0);
|
|
97
121
|
}
|
|
98
122
|
|
|
123
|
+
// === SUBCOMMAND: media (ingest geotagged photos/videos) ===
|
|
124
|
+
if (args[0] === 'media') {
|
|
125
|
+
const io = {
|
|
126
|
+
log: (m) => console.log(m),
|
|
127
|
+
warn: (m) => console.error(` ${c.yellow('⚠')} ${m}`),
|
|
128
|
+
c,
|
|
129
|
+
};
|
|
130
|
+
const { buildReview, applyReview } = require('./media-ingest');
|
|
131
|
+
|
|
132
|
+
(async () => {
|
|
133
|
+
try {
|
|
134
|
+
if (args[1] === 'apply') {
|
|
135
|
+
const reviewFile = args[2];
|
|
136
|
+
const tripFile = args[3];
|
|
137
|
+
if (!reviewFile || !tripFile) {
|
|
138
|
+
console.error(c.red('Usage: ') + `${invokedAs} media apply <review.yaml> <trip.yaml>`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
if (!fs.existsSync(reviewFile)) { console.error(c.red(`Error: file not found: ${reviewFile}`)); process.exit(1); }
|
|
142
|
+
if (!fs.existsSync(tripFile)) { console.error(c.red(`Error: file not found: ${tripFile}`)); process.exit(1); }
|
|
143
|
+
await applyReview(reviewFile, tripFile, io);
|
|
144
|
+
} else {
|
|
145
|
+
const folder = args[1];
|
|
146
|
+
const tripFile = args[2];
|
|
147
|
+
if (!folder || !tripFile) {
|
|
148
|
+
console.error(c.red('Usage: ') + `${invokedAs} media <folder> <trip.yaml>`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
if (!fs.existsSync(tripFile)) { console.error(c.red(`Error: file not found: ${tripFile}`)); process.exit(1); }
|
|
152
|
+
await buildReview(folder, tripFile, io);
|
|
153
|
+
}
|
|
154
|
+
} catch (e) {
|
|
155
|
+
console.error(c.red('✖ ') + e.message);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
99
162
|
// === DEFAULT: render ===
|
|
100
163
|
const skipValidate = args.includes('--no-validate');
|
|
101
164
|
const positional = args.filter(a => !a.startsWith('-'));
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# TripKit Media Guide
|
|
2
|
+
|
|
3
|
+
Turn the photos and videos from a finished trip into an interactive map where every
|
|
4
|
+
stop shows what you actually captured there — and an optional, agent-assisted workflow
|
|
5
|
+
for captioning and fixing matches.
|
|
6
|
+
|
|
7
|
+
This guide covers:
|
|
8
|
+
|
|
9
|
+
1. [What you get](#what-you-get)
|
|
10
|
+
2. [Quick start](#quick-start)
|
|
11
|
+
3. [How matching works](#how-matching-works)
|
|
12
|
+
4. [The review file, field by field](#the-review-file-field-by-field)
|
|
13
|
+
5. [Letting an AI agent do the review](#letting-an-ai-agent-do-the-review)
|
|
14
|
+
6. [The `media` schema](#the-media-schema)
|
|
15
|
+
7. [Using URLs instead of local files](#using-urls-instead-of-local-files)
|
|
16
|
+
8. [Troubleshooting](#troubleshooting)
|
|
17
|
+
9. [Command reference](#command-reference)
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## What you get
|
|
22
|
+
|
|
23
|
+
Once media is attached to a trip, the rendered HTML gains:
|
|
24
|
+
|
|
25
|
+
- **Per-stop galleries** — a thumbnail strip and a `📷 N` count badge on each stop card and map popup.
|
|
26
|
+
- **A marker cue** — stops with media show a `📷 N` badge on their map marker; click it to jump straight to the photos.
|
|
27
|
+
- **A full-screen lightbox** — arrow keys / swipe / click to navigate, `Esc` to close, inline video playback, with each item's caption and capture time/GPS.
|
|
28
|
+
- **A photo-pin layer** — a toggleable map layer (the **📷 Photos** button, top-right) that drops a pin at the exact GPS of every geotagged item, separate from the day-numbered stop markers.
|
|
29
|
+
|
|
30
|
+
Stops without media render exactly as before — everything here is additive and optional.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
You need: a finished `trip.yaml` (the same file the renderer already uses) and a folder of
|
|
37
|
+
photos/videos from the trip. Photos with EXIF GPS + timestamps match automatically; others
|
|
38
|
+
can be placed by hand (or by an agent) in the review step.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# 1. Match your media folder to the trip's stops.
|
|
42
|
+
# Writes trip.media-review.yaml — nothing in trip.yaml changes yet.
|
|
43
|
+
npx tripkit media ./my-photos trip.yaml
|
|
44
|
+
|
|
45
|
+
# 2. Open trip.media-review.yaml. Add captions, fix any wrong matches,
|
|
46
|
+
# and place the items under "unmatched" that you want to keep.
|
|
47
|
+
|
|
48
|
+
# 3. Merge the reviewed media into the trip.
|
|
49
|
+
npx tripkit media apply trip.media-review.yaml trip.yaml
|
|
50
|
+
|
|
51
|
+
# 4. Re-render. The map now shows your photos.
|
|
52
|
+
npx tripkit trip.yaml trip.html
|
|
53
|
+
open trip.html
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
> Running from a git clone instead of npx? Use `node convert.js …` in place of `npx tripkit …`.
|
|
57
|
+
|
|
58
|
+
**Thumbnails (optional).** If [`sharp`](https://www.npmjs.com/package/sharp) is installed,
|
|
59
|
+
step 1 also writes downscaled thumbnails to `my-photos/thumb/` and references them for fast
|
|
60
|
+
map/gallery loading. If `sharp` isn't installed, the step still works — it just uses the
|
|
61
|
+
full-resolution images directly. Install with `npm install sharp` to enable thumbnails.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## How matching works
|
|
66
|
+
|
|
67
|
+
`tripkit media` reads each file's EXIF metadata and assigns it to a stop using two signals:
|
|
68
|
+
|
|
69
|
+
1. **Timestamp → day.** The photo's `DateTimeOriginal` is matched to the day whose `date`
|
|
70
|
+
falls on the same calendar date. (The year comes from `trip.dates`.) This keeps a photo
|
|
71
|
+
taken on Day 3 from matching a closer-looking stop on Day 5.
|
|
72
|
+
2. **GPS → nearest stop.** Within that day, the item is assigned to the geographically
|
|
73
|
+
nearest stop (great-circle distance). The result is tagged with a `confidence`:
|
|
74
|
+
- `high` — within ~3 miles of the stop.
|
|
75
|
+
- `medium` — within ~25 miles.
|
|
76
|
+
- Anything farther, or with **no GPS**, or whose date matches **no day**, goes to the
|
|
77
|
+
`unmatched` bucket for you to handle.
|
|
78
|
+
|
|
79
|
+
Videos are routed to `unmatched` by default — most video formats don't expose GPS the way
|
|
80
|
+
photos do, so they're left for you to place rather than guessed at.
|
|
81
|
+
|
|
82
|
+
Nothing is destructive: matching only ever *proposes*. Your `trip.yaml` is untouched until
|
|
83
|
+
you run `media apply`.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## The review file, field by field
|
|
88
|
+
|
|
89
|
+
`tripkit media` writes `<trip>.media-review.yaml`. It's meant to be read and edited before
|
|
90
|
+
applying. A trimmed example:
|
|
91
|
+
|
|
92
|
+
```yaml
|
|
93
|
+
trip_file: trip.yaml
|
|
94
|
+
summary:
|
|
95
|
+
total: 6 # files scanned
|
|
96
|
+
with_gps: 5 # had EXIF GPS
|
|
97
|
+
with_timestamp: 5 # had an EXIF capture time
|
|
98
|
+
matched: 4 # confidently assigned to a stop
|
|
99
|
+
unmatched: 2 # need your attention
|
|
100
|
+
thumbnails: generated # or: "skipped (sharp not installed)"
|
|
101
|
+
|
|
102
|
+
stops:
|
|
103
|
+
- day: 1 # ← which day (matches days[].number in trip.yaml)
|
|
104
|
+
stop_index: 0 # ← which stop within that day (0-based)
|
|
105
|
+
stop: Lower Yosemite Fall # name, for your reference (not used on apply)
|
|
106
|
+
media:
|
|
107
|
+
- src: media/fall_01.jpg
|
|
108
|
+
type: photo
|
|
109
|
+
caption: "" # ← WRITE THIS. Shown in the lightbox.
|
|
110
|
+
thumb: media/thumb/fall_01.jpg
|
|
111
|
+
lat: 37.7560 # original EXIF GPS (drives the photo-pin layer)
|
|
112
|
+
lng: -119.5965
|
|
113
|
+
taken_at: '2026-05-15T17:15:00Z'
|
|
114
|
+
confidence: high # advisory only; removed on apply
|
|
115
|
+
|
|
116
|
+
unmatched:
|
|
117
|
+
- src: media/random_sf.jpg
|
|
118
|
+
type: photo
|
|
119
|
+
caption: ""
|
|
120
|
+
lat: 37.7749
|
|
121
|
+
lng: -122.4194
|
|
122
|
+
taken_at: '2026-05-16T12:00:00Z'
|
|
123
|
+
reason: nearest stop "Glacier Point" is 156 mi away # why it wasn't auto-matched
|
|
124
|
+
suggested_stop: Glacier Point
|
|
125
|
+
- src: media/no_gps.jpg
|
|
126
|
+
type: photo
|
|
127
|
+
caption: ""
|
|
128
|
+
reason: no GPS in EXIF
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**What to edit:**
|
|
132
|
+
|
|
133
|
+
| Goal | Do this |
|
|
134
|
+
|------|---------|
|
|
135
|
+
| Add a caption | Fill in `caption:` (this is the highest-value edit — it's what the lightbox shows). |
|
|
136
|
+
| Move a photo to a different stop | Change its `day:` / `stop_index:` (under `stops:`), or move the item between entries. |
|
|
137
|
+
| Keep an unmatched item | Give it a `day:` and `stop_index:` — then it's applied like any matched item. |
|
|
138
|
+
| Drop an unmatched item | Leave it in `unmatched` without a `day`/`stop_index`. It's ignored on apply. |
|
|
139
|
+
| Remove a wrong match | Delete the item from the file before applying. |
|
|
140
|
+
|
|
141
|
+
**What's ignored on apply:** the `confidence`, `reason`, `suggested_stop`, and `stop` (name)
|
|
142
|
+
fields are review aids — they're stripped when merging into `trip.yaml`. Only
|
|
143
|
+
`src`, `type`, `thumb`, `caption`, `lat`, `lng`, and `taken_at` are written through.
|
|
144
|
+
|
|
145
|
+
**`media apply` is idempotent.** Re-running it won't duplicate items already present (it
|
|
146
|
+
matches on `src`), so it's safe to apply, render, tweak captions, and apply again.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Letting an AI agent do the review
|
|
151
|
+
|
|
152
|
+
The review step — captioning every photo and placing the unmatched ones — is exactly the
|
|
153
|
+
kind of judgement work an AI agent is good at. The flow is designed so a human or an agent
|
|
154
|
+
can do it; the agent never touches your originals, only the review YAML.
|
|
155
|
+
|
|
156
|
+
A typical agent session:
|
|
157
|
+
|
|
158
|
+
> **You:** "I ran `tripkit media ./trip-photos yosemite.yaml`. Here's the review file —
|
|
159
|
+
> caption the photos and place the unmatched ones, then tell me the apply command."
|
|
160
|
+
>
|
|
161
|
+
> *(paste or share `yosemite.media-review.yaml`)*
|
|
162
|
+
|
|
163
|
+
The agent should:
|
|
164
|
+
|
|
165
|
+
1. **Caption each matched item** — concise, specific, grounded in what the photo shows and
|
|
166
|
+
the stop it's at. "Lower Yosemite Fall roaring with May snowmelt," not "Photo 1."
|
|
167
|
+
It can lean on `stop`, `taken_at`, and the trip's own descriptions for context.
|
|
168
|
+
2. **Resolve `unmatched` items** — use `suggested_stop`, the `reason`, the filename, the
|
|
169
|
+
`taken_at` time, and the trip itinerary to decide a `day` + `stop_index`, or leave
|
|
170
|
+
genuinely unplaceable items unmatched. A `reason: no GPS` selfie taken at 8pm on Day 2
|
|
171
|
+
probably belongs at that day's dinner or lodging stop.
|
|
172
|
+
3. **Flag the suspicious ones** — if `suggested_stop` is 150 miles away, that photo likely
|
|
173
|
+
isn't from a planned stop at all; better to leave it out than force a wrong pin.
|
|
174
|
+
4. **Hand back the edited review file** and the exact `tripkit media apply …` command.
|
|
175
|
+
|
|
176
|
+
If you use **Claude Code**, install the skill once and the agent already knows this flow
|
|
177
|
+
(it's Phase 5 of the TripKit agent skill):
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
npx tripkit install-skill # → ~/.claude/skills/tripkit/
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Then: *"Use the tripkit skill to caption and place the media in this review file."*
|
|
184
|
+
|
|
185
|
+
**Why review-in-the-middle?** The agent works on a separate YAML, not your photo files or
|
|
186
|
+
your trip. You see every proposed caption and placement before anything is written, and the
|
|
187
|
+
`media apply` step is a plain mechanical merge you run yourself. The intelligence is
|
|
188
|
+
auditable, not a black box.
|
|
189
|
+
|
|
190
|
+
After the agent returns the file, validate and render:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
npx tripkit media apply yosemite.media-review.yaml yosemite.yaml
|
|
194
|
+
npx tripkit validate yosemite.yaml # surfaces e.g. media GPS far from its stop
|
|
195
|
+
npx tripkit yosemite.yaml yosemite.html
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## The `media` schema
|
|
201
|
+
|
|
202
|
+
Each stop (and, optionally, `lodging`) may carry a `media` array. It's purely additive to
|
|
203
|
+
the [existing schema](../schema/tripkit.schema.yaml).
|
|
204
|
+
|
|
205
|
+
```yaml
|
|
206
|
+
stops:
|
|
207
|
+
- name: "Multnomah Falls"
|
|
208
|
+
lat: 45.576
|
|
209
|
+
lng: -122.116
|
|
210
|
+
image: "https://…" # unchanged: the single hero/primary image
|
|
211
|
+
media: # the gallery
|
|
212
|
+
- src: "media/IMG_2401.jpg" # relative path OR full URL (required)
|
|
213
|
+
type: photo # "photo" | "video"
|
|
214
|
+
thumb: "media/thumb/IMG_2401.jpg" # optional; renderer falls back to src
|
|
215
|
+
caption: "620-ft waterfall from the lower viewpoint" # optional
|
|
216
|
+
lat: 45.5762 # optional EXIF GPS; powers the photo-pin layer
|
|
217
|
+
lng: -122.1158
|
|
218
|
+
taken_at: "2026-04-07T14:32:00" # optional ISO 8601 capture time
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
- `image` (the original single-image field) is untouched and still used as the hero/primary.
|
|
222
|
+
`media` is the new gallery shown in cards, popups, the lightbox, and the marker badge.
|
|
223
|
+
- Only `src` and a valid `type` are required per item; everything else is optional.
|
|
224
|
+
- Items that include `lat`/`lng` also appear as individual pins on the **📷 Photos** map layer.
|
|
225
|
+
|
|
226
|
+
`tripkit validate` checks every media item has a `src` and a valid `type`, and **warns**
|
|
227
|
+
when an item's GPS is more than ~25 miles from its stop — a strong hint it was placed on the
|
|
228
|
+
wrong stop.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Using URLs instead of local files
|
|
233
|
+
|
|
234
|
+
`src` (and `thumb`) accept either a **relative path** or a **full URL**.
|
|
235
|
+
|
|
236
|
+
- **Relative paths** (the default from `tripkit media`) keep full-resolution media, but the
|
|
237
|
+
output is now an HTML file *plus* its media folder — ship them together.
|
|
238
|
+
- **URLs** (e.g. photos you've uploaded to S3, Cloudinary, Google Photos, or anywhere with a
|
|
239
|
+
direct link) keep the output a **single, self-contained, shareable HTML file** with no
|
|
240
|
+
media folder to carry around.
|
|
241
|
+
|
|
242
|
+
You can mix both in one trip. To use URLs, either point `src` at them when hand-editing the
|
|
243
|
+
review file, or swap the relative paths for URLs in `trip.yaml` after applying.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Troubleshooting
|
|
248
|
+
|
|
249
|
+
**"Missing dependency exifr."** The ingest step needs it: `npm install exifr` (or just
|
|
250
|
+
`npm install` in a clone — it's a normal dependency). Only the `media` command needs it; the
|
|
251
|
+
renderer and `convert` don't.
|
|
252
|
+
|
|
253
|
+
**Thumbnails were skipped.** That's the `sharp`-not-installed path; it's fine. Run
|
|
254
|
+
`npm install sharp` to enable them. The review file's `summary.thumbnails` tells you which
|
|
255
|
+
path ran.
|
|
256
|
+
|
|
257
|
+
**Everything landed in `unmatched`.** Usually one of:
|
|
258
|
+
- The photos have no GPS (phone privacy settings, screenshots, exported/edited copies that
|
|
259
|
+
stripped EXIF). Place them by hand using `day` + `stop_index`.
|
|
260
|
+
- The photo dates don't line up with any day's `date`, so the day filter excludes every
|
|
261
|
+
stop. Check that `trip.dates` has the right year and each day's `date` is correct.
|
|
262
|
+
|
|
263
|
+
**A photo matched the wrong stop.** Edit its `day`/`stop_index` in the review file before
|
|
264
|
+
applying, or move it to the right `stops:` entry. If you already applied, fix it in
|
|
265
|
+
`trip.yaml` (the `media` array under that stop) and re-render.
|
|
266
|
+
|
|
267
|
+
**Videos didn't match.** Expected — they're routed to `unmatched` by default. Give them a
|
|
268
|
+
`day` + `stop_index` to place them; they play inline in the lightbox.
|
|
269
|
+
|
|
270
|
+
**The marker badge / photo-pin layer didn't appear.** The badge only shows on stops that
|
|
271
|
+
have `media`; the **📷 Photos** toggle only appears when at least one media item has its own
|
|
272
|
+
`lat`/`lng`. If your items lack GPS, the galleries and lightbox still work — just not the pins.
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## Command reference
|
|
277
|
+
|
|
278
|
+
```text
|
|
279
|
+
tripkit media <folder> <trip.yaml>
|
|
280
|
+
Scan <folder> for photos/videos, read EXIF GPS + timestamps, auto-match each
|
|
281
|
+
to the nearest stop on the matching day, optionally generate thumbnails, and
|
|
282
|
+
write <trip>.media-review.yaml. Does not modify <trip.yaml>.
|
|
283
|
+
|
|
284
|
+
tripkit media apply <review.yaml> <trip.yaml>
|
|
285
|
+
Merge the reviewed media into <trip.yaml>'s stops[].media[]. Idempotent.
|
|
286
|
+
|
|
287
|
+
tripkit validate <trip.yaml>
|
|
288
|
+
Validate the trip, including media (src present, type valid, GPS sanity).
|
|
289
|
+
|
|
290
|
+
tripkit <trip.yaml> [output.html]
|
|
291
|
+
Render to interactive HTML (auto-validates first).
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Supported media extensions: photos — `.jpg .jpeg .png .heic .heif .webp .tif .tiff`;
|
|
295
|
+
videos — `.mp4 .mov .m4v .avi .mkv .webm`.
|
|
296
|
+
|
|
297
|
+
See also: the [README](../README.md) for the project overview and the
|
|
298
|
+
[agent skill](../agent/SKILL.md) (Phase 5) for the agent-facing version of this flow.
|
package/docs/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Screenshot placeholder — replace with actual screenshot of the TripKit visualizer
|
|
@@ -307,6 +307,21 @@ days:
|
|
|
307
307
|
duration: "2–3 hrs (loop)"
|
|
308
308
|
reservation_required: false
|
|
309
309
|
navigate_url: "https://www.google.com/maps/dir/?api=1&destination=Multnomah+Falls+Oregon"
|
|
310
|
+
# Post-trip media demo — `src` can be a relative path (from `tripkit media`) or a URL.
|
|
311
|
+
# These carry lat/lng, so they also appear in the toggleable photo-pin map layer.
|
|
312
|
+
media:
|
|
313
|
+
- src: "https://images.unsplash.com/photo-1546587348-d12660c30c50?w=1600"
|
|
314
|
+
type: photo
|
|
315
|
+
caption: "Multnomah Falls roaring with spring snowmelt — lower viewpoint"
|
|
316
|
+
lat: 45.5762
|
|
317
|
+
lng: -122.1158
|
|
318
|
+
taken_at: "2026-04-07T14:32:00"
|
|
319
|
+
- src: "https://images.unsplash.com/photo-1559521783-1d1599583485?w=1600"
|
|
320
|
+
type: photo
|
|
321
|
+
caption: "Benson Bridge between the upper and lower falls"
|
|
322
|
+
lat: 45.5765
|
|
323
|
+
lng: -122.1155
|
|
324
|
+
taken_at: "2026-04-07T14:40:00"
|
|
310
325
|
|
|
311
326
|
- name: "Latourell Falls"
|
|
312
327
|
lat: 45.537
|