@bycrux/editor 1.1.0 → 1.2.1

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.
Files changed (39) hide show
  1. package/package.json +2 -2
  2. package/src/ControlsInfoModal.tsx +1 -0
  3. package/src/engine/__tests__/scheduler.test.ts +56 -1
  4. package/src/engine/scheduler.ts +23 -5
  5. package/src/index.ts +36 -0
  6. package/src/lib/__tests__/font-faces.test.ts +244 -0
  7. package/src/lib/__tests__/font-loader-parity.test.tsx +236 -0
  8. package/src/lib/__tests__/google-fonts.test.ts +319 -2
  9. package/src/lib/font-families.ts +286 -0
  10. package/src/lib/google-fonts.ts +132 -10
  11. package/src/schema.ts +21 -0
  12. package/src/text/FontPicker.tsx +82 -11
  13. package/src/text/__tests__/FontPicker.baseUrl.test.tsx +112 -0
  14. package/src/video/VersionPanel.tsx +1 -1
  15. package/src/video/VideoEditor.tsx +98 -4
  16. package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
  17. package/src/video/__tests__/cuts.test.ts +84 -0
  18. package/src/video/__tests__/exportDurationSec.test.ts +60 -0
  19. package/src/video/__tests__/markerDropTime.test.ts +25 -0
  20. package/src/video/cuts.ts +30 -2
  21. package/src/video/preview/PreviewPlayer.tsx +52 -2
  22. package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
  23. package/src/video/preview/__tests__/useVideoPlayback.muted.test.ts +239 -0
  24. package/src/video/preview/useEnginePlayback.ts +58 -8
  25. package/src/video/preview/useVideoPlayback.ts +64 -17
  26. package/src/video/timeline/Timeline.tsx +6 -0
  27. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
  28. package/src/video/timeline/__tests__/markers.test.ts +125 -0
  29. package/src/video/timeline/canvas/TimelineCanvas.tsx +110 -12
  30. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +38 -7
  31. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
  32. package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
  33. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
  34. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +100 -1
  35. package/src/video/timeline/canvas/draw.ts +182 -8
  36. package/src/video/timeline/canvas/hit-test.ts +72 -1
  37. package/src/video/timeline/canvas/pointer-machine.ts +83 -4
  38. package/src/video/timeline/markers.ts +109 -0
  39. package/src/video/timeline/timeline-model.ts +19 -0
@@ -0,0 +1,286 @@
1
+ // The vendored-font partition, shared by the package's two Google Fonts
2
+ // loaders (`lib/google-fonts.ts` and `text/FontPicker.tsx`).
3
+ //
4
+ // The two loaders stay independent — separate setters, separate injection
5
+ // state, neither imports the other — but they must reach the SAME answer about
6
+ // which families a vendored stylesheet covers. That is not a style preference:
7
+ // a family served locally by one and from Google by the other is a caption
8
+ // laid out in one face while editing and a different one at export, which is
9
+ // the bug `skills/write-overlay/SKILL.md`'s Syne case study exists to document.
10
+ //
11
+ // The two renderers (`montaj_assets/render/bundle.js`,
12
+ // `render-carousel.js`) keep hand-copied versions of these same three
13
+ // functions, textually pinned against each other by `shim-bake.test.mjs`. They
14
+ // have to copy: they are separate CLI entry points that share no module and
15
+ // cannot import this package. These two loaders CAN import, so they do —
16
+ // a copy that cannot drift beats a copy pinned not to.
17
+
18
+ /** A `googleFonts` entry is a SPEC, not a family name: "Baloo+2:wght@400;500",
19
+ * "Playfair+Display:ital@1", "Anton". Everything from the first ':' is the
20
+ * axis list, and '+' is how Google's API encodes the space in a family name —
21
+ * strip the one, undo the other, and what is left is the family exactly as
22
+ * `fonts.css` spells it in its `font-family` declarations, which is what the
23
+ * vendored family list holds.
24
+ *
25
+ * Case-folded because CSS font-family matching is case-insensitive: a spec
26
+ * that differs from the list only in case names a family the vendored
27
+ * stylesheet genuinely serves, and treating it as unvendored would buy
28
+ * nothing but a fetch from Google. */
29
+ export function fontFamilyKey(spec: string): string {
30
+ return String(spec).split(':')[0].replace(/\+/g, ' ').trim().toLowerCase()
31
+ }
32
+
33
+ /** Normalise a host-supplied family list into the comparison set.
34
+ *
35
+ * List entries go through `fontFamilyKey` too, not just the requested specs.
36
+ * Deliberate leniency: a host that passes Google's '+'-encoded spelling
37
+ * ("Open+Sans") rather than the `fonts.css` one still matches, and the
38
+ * failure it avoids is silent egress for a family sitting right there on
39
+ * disk. Non-strings are dropped rather than coerced — a number in the list
40
+ * is a host bug, and `String(7)` would silently become a "family". */
41
+ export function vendoredKeySet(families: readonly unknown[] | undefined): Set<string> {
42
+ if (!Array.isArray(families)) return new Set()
43
+ return new Set(families.filter((f): f is string => typeof f === 'string').map(fontFamilyKey))
44
+ }
45
+
46
+ /** Split requested specs into the ones the vendored stylesheet covers and the
47
+ * ones that must still come from Google. Order within each list is the
48
+ * caller's original order, so the googleapis URL reads the way the project
49
+ * declared it. */
50
+ export function partitionFontSpecs(
51
+ specs: readonly string[],
52
+ vendoredKeys: Set<string>,
53
+ faceIndex?: FaceIndex,
54
+ ): { vendored: string[]; fellThrough: string[] } {
55
+ const vendored: string[] = []
56
+ const fellThrough: string[] = []
57
+ for (const spec of specs) {
58
+ const covered =
59
+ vendoredKeys.has(fontFamilyKey(spec)) && (!faceIndex || specFacesAvailable(spec, faceIndex))
60
+ ;(covered ? vendored : fellThrough).push(spec)
61
+ }
62
+ return { vendored, fellThrough }
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Faces: the family list is not precise enough on its own
67
+ // ---------------------------------------------------------------------------
68
+ //
69
+ // The vendored set is family + STYLE + WEIGHT, not family. `fonts.css` carries
70
+ // only the faces the vendoring pass actually received — every face is
71
+ // `font-style: normal`, and the weights are only the ones it asked for. So a
72
+ // family-level partition gets `Playfair+Display:ital@1` wrong: the family
73
+ // matches, the spec is treated as vendored, the real italic is never fetched,
74
+ // and the browser synthesises an oblique from the upright. `Inter:wght@300` is
75
+ // the same shape one axis over. Both are silent — no failed request, nothing
76
+ // on screen — and both change what the user sees, which is the entire defect
77
+ // this refinement removes.
78
+ //
79
+ // The face index REFINES the family partition and can only ever move a spec
80
+ // from "vendored" to "fell through", never the reverse. Passing no index means
81
+ // no refinement, which is the family-level behaviour that predates this.
82
+
83
+ export type FaceList = { normal?: readonly number[]; italic?: readonly number[] }
84
+ export type FaceMap = Readonly<Record<string, FaceList>>
85
+ export type FaceIndex = Map<string, { normal: Set<number>; italic: Set<number> }>
86
+
87
+ /** Resolve a `googleFonts` SPEC to the concrete faces it asks Google for, or
88
+ * `null` for "I cannot parse this confidently".
89
+ *
90
+ * `null` MUST be treated as a fall-through by the caller. That is the safe
91
+ * direction and it is this feature's established philosophy: fetching a font
92
+ * we happen to have costs one request, while silently dropping one we lack
93
+ * costs the author a wrong face in a finished export with no visible cause.
94
+ *
95
+ * A spec is `Family[:axes@tuples]`, where the axes are named in one
96
+ * comma-separated list and their values in another, POSITIONALLY:
97
+ *
98
+ * Anton → normal 400 (Google's default)
99
+ * Inter:wght@400;700 → normal 400, normal 700
100
+ * Playfair+Display:ital@1 → italic 400
101
+ * Playfair+Display:ital,wght@1,700 → italic 700
102
+ * Playfair+Display:ital,wght@0,400;1,700 → normal 400, italic 700
103
+ *
104
+ * `ital@0` is normal and `ital@1` is italic. Any other axis (`opsz`, `slnt`,
105
+ * a custom one like `GRAD`), any variable RANGE (`wght@100..900`), a
106
+ * duplicated axis, or a tuple whose arity does not match the axis list all
107
+ * return `null` rather than a guess. */
108
+ export function requiredFaces(spec: string): { style: 'normal' | 'italic'; weight: number }[] | null {
109
+ const s = String(spec)
110
+ const colon = s.indexOf(':')
111
+ // No axis list: Google serves the family's default face, which is normal 400.
112
+ if (colon === -1) return [{ style: 'normal', weight: 400 }]
113
+
114
+ const axisPart = s.slice(colon + 1)
115
+ const at = axisPart.indexOf('@')
116
+ // `Family:` with no '@' at all, or more than one — not a shape we model.
117
+ if (at === -1 || axisPart.indexOf('@', at + 1) !== -1) return null
118
+
119
+ const axes = axisPart.slice(0, at).split(',')
120
+ const tuples = axisPart.slice(at + 1).split(';')
121
+ const iItal = axes.indexOf('ital')
122
+ const iWght = axes.indexOf('wght')
123
+ // Every axis must be one we model. An unmodelled, duplicated or empty axis
124
+ // name makes the face set unknowable, and a guess here is the silent-wrong
125
+ // answer this whole refinement exists to delete.
126
+ for (let i = 0; i < axes.length; i++) if (i !== iItal && i !== iWght) return null
127
+
128
+ const faces: { style: 'normal' | 'italic'; weight: number }[] = []
129
+ for (const tuple of tuples) {
130
+ const values = tuple.split(',')
131
+ if (values.length !== axes.length) return null
132
+ let style: 'normal' | 'italic' = 'normal'
133
+ let weight = 400
134
+ if (iItal !== -1) {
135
+ const v = values[iItal]
136
+ if (v === '0') style = 'normal'
137
+ else if (v === '1') style = 'italic'
138
+ else return null // an `ital` range (0..1), or junk
139
+ }
140
+ if (iWght !== -1) {
141
+ const v = values[iWght]
142
+ if (!/^\d{1,4}$/.test(v)) return null // a `wght` range (100..900), or junk
143
+ weight = Number(v)
144
+ if (weight < 1 || weight > 1000) return null
145
+ }
146
+ faces.push({ style, weight })
147
+ }
148
+ return faces
149
+ }
150
+
151
+ /** Build the face index from the manifest's `faces` and `requested` maps.
152
+ *
153
+ * A face counts as AVAILABLE if it is in `faces` (we have the file) or in
154
+ * `requested` (we asked Google for it and were refused). The second half is
155
+ * not a special case: the gap between the two maps is exactly "weights Google
156
+ * does not publish", and falling through for one of those fetches a
157
+ * stylesheet that declines identically — a guaranteed-useless request rather
158
+ * than a probably-useless one. `Bebas+Neue:wght@400;700` is the only spec
159
+ * that exercises it today; Bebas Neue ships no 700 face at all.
160
+ *
161
+ * Family keys go through `fontFamilyKey`, matching `vendoredKeySet`'s
162
+ * leniency, so a manifest written with Google's '+'-encoded spelling still
163
+ * matches. Malformed entries are skipped rather than thrown on: a manifest
164
+ * this code cannot read must degrade to "no information", never to a crash
165
+ * inside a fire-and-forget font load. */
166
+ export function vendoredFaceIndex(faces?: FaceMap, requested?: FaceMap): FaceIndex | undefined {
167
+ const usable = [faces, requested].filter((m) => m && typeof m === 'object' && !Array.isArray(m))
168
+ // NO face information at all is different from face information that covers
169
+ // nothing. The first leaves the partition at family level (the behaviour
170
+ // that predates this refinement, for a manifest or a host that predates it
171
+ // too); the second says every requested face is genuinely absent. Returning
172
+ // an empty Map for both would silently turn an old manifest into "nothing is
173
+ // vendored", which is safe but wrong to do without saying so.
174
+ if (!usable.length) return undefined
175
+
176
+ const index: FaceIndex = new Map()
177
+ for (const source of usable as FaceMap[]) {
178
+ for (const [family, styles] of Object.entries(source)) {
179
+ if (!styles || typeof styles !== 'object') continue
180
+ const key = fontFamilyKey(family)
181
+ let entry = index.get(key)
182
+ if (!entry) index.set(key, (entry = { normal: new Set(), italic: new Set() }))
183
+ for (const style of ['normal', 'italic'] as const) {
184
+ const weights = styles[style]
185
+ if (!Array.isArray(weights)) continue
186
+ for (const w of weights) if (Number.isInteger(w)) entry[style].add(w as number)
187
+ }
188
+ }
189
+ }
190
+ return index
191
+ }
192
+
193
+ /** Whether every face `spec` requires is available locally.
194
+ *
195
+ * A family with no entry in the index is NOT covered — the index is built
196
+ * from the same manifest as the family list, so a family present in one and
197
+ * absent from the other means the two disagree, and the safe reading of a
198
+ * disagreement is "fall through".
199
+ *
200
+ * A PARTIALLY vendored spec falls through WHOLE. `Inter:wght@400;300` goes to
201
+ * Google as one spec rather than being split into a vendored half and a
202
+ * fetched half. Splitting would mean synthesising a new spec string, and a
203
+ * spec is the author's — ours to honour or to pass on untouched, never to
204
+ * rewrite. */
205
+ export function specFacesAvailable(spec: string, index: FaceIndex): boolean {
206
+ const entry = index.get(fontFamilyKey(spec))
207
+ if (!entry) return false
208
+ const required = requiredFaces(spec)
209
+ if (!required) return false
210
+ return required.every((f) => entry[f.style].has(f.weight))
211
+ }
212
+
213
+ /** A short, stable fingerprint of a vendored set, logged once by each loader
214
+ * and once per render by each renderer. It turns an invisible divergence into
215
+ * two visibly different strings: if the editor's digest and the render's
216
+ * digest disagree, the two sides are partitioning against different vendored
217
+ * sets and captions WILL differ between editing and export. QA compares two
218
+ * hashes instead of trying to observe a partition.
219
+ *
220
+ * **It fingerprints the FACES, not just the families, when face information
221
+ * is available.** A families-only digest would report a match across a set
222
+ * that materially changed — re-vendor at a different weight, or drop one, and
223
+ * every family name is still identical while what the stylesheet can actually
224
+ * resolve is not. That is precisely the silent drift this exists to make
225
+ * loud, so the weights and styles go into the input too.
226
+ *
227
+ * With NO face index the input is byte-for-byte what it was before faces
228
+ * existed, so a family-only manifest keeps producing its old digest and stays
229
+ * comparable against an older renderer. A face index therefore changes the
230
+ * value exactly when there is new information to report, never incidentally.
231
+ *
232
+ * FNV-1a over the sorted lines, not a crypto hash, and that is deliberate: it
233
+ * must be computable synchronously in a browser (`crypto.subtle` is async),
234
+ * and it is a comparison token, never a security primitive.
235
+ *
236
+ * The renderers carry the same algorithm in plain JS. The gate that keeps the
237
+ * two languages honest is a literal digest pinned in BOTH test suites for the
238
+ * same manifest — this TS↔JS seam is the one place a textual comparison
239
+ * cannot reach, which is why the literal is the pin. */
240
+ export function familiesDigest(keys: Iterable<string>, faceIndex?: FaceIndex): string {
241
+ const lines = [...keys].sort().map((key) => {
242
+ const entry = faceIndex ? faceIndex.get(key) : undefined
243
+ if (!entry) return key
244
+ const axis = (style: 'normal' | 'italic') =>
245
+ `${style}:${[...entry[style]].sort((a, b) => a - b).join(',')}`
246
+ return `${key}\t${axis('normal')}\t${axis('italic')}`
247
+ })
248
+ let h = 0x811c9dc5
249
+ for (const ch of lines.join('\n')) {
250
+ h = Math.imul(h ^ (ch.codePointAt(0) as number), 0x01000193) >>> 0
251
+ }
252
+ return h.toString(16).padStart(8, '0')
253
+ }
254
+
255
+ /** One line naming the vendored set a loader is about to partition against,
256
+ * or — when a base was set without one — the loud warning that nothing will
257
+ * be treated as vendored.
258
+ *
259
+ * "No list" means every requested family goes to Google and the vendored
260
+ * stylesheet is not linked at all. The tempting opposite, assuming the sheet
261
+ * covers what was asked for, is the SILENT-wrong option: an unvendored family
262
+ * would get no stylesheet at all and preview as a system fallback, while the
263
+ * render — reading its own manifest off local disk, where it cannot fail
264
+ * independently — would fetch that same family from Google and get it right.
265
+ * Preview and export would disagree with nothing on screen to say so. This
266
+ * way is loud-wrong instead: every glyph is correct, both sides agree, and
267
+ * the only cost is egress, which is the one failure this logging detects. */
268
+ export function reportVendoredSet(base: string, vendoredKeys: Set<string>, faceIndex?: FaceIndex): void {
269
+ if (!vendoredKeys.size) {
270
+ console.warn(
271
+ `[montaj] fonts: a base was set (${base}) with no vendored family list — treating NOTHING as vendored, `
272
+ + 'so the vendored stylesheet is not linked and every requested family is fetched from fonts.googleapis.com',
273
+ )
274
+ return
275
+ }
276
+ console.info(`[montaj] fonts: vendored set ${familiesDigest(vendoredKeys, faceIndex)} (${vendoredKeys.size} families) at ${base}`)
277
+ }
278
+
279
+ /** Name the families that crossed to Google. An author who names a family the
280
+ * vendored set does not carry should learn it while editing, not by noticing
281
+ * the wrong face in a finished export. Mirrors the renderers' line of the
282
+ * same text so a log from either side reads identically. */
283
+ export function reportUnvendoredFonts(fellThrough: readonly string[]): void {
284
+ if (!fellThrough.length) return
285
+ console.warn(`[montaj] fonts: not in the vendored set, fetching from fonts.googleapis.com: ${fellThrough.join(', ')}`)
286
+ }
@@ -5,14 +5,112 @@
5
5
  // (bundle.js) fetches. Used by both the video overlay layer and the carousel
6
6
  // overlay render path. Resilient by design: a font-load failure must never
7
7
  // break the render (we only append a <link>; the browser handles the fetch).
8
+ import {
9
+ familiesDigest,
10
+ partitionFontSpecs,
11
+ reportUnvendoredFonts,
12
+ reportVendoredSet,
13
+ vendoredFaceIndex,
14
+ vendoredKeySet,
15
+ } from './font-families'
16
+ import type { FaceIndex, FaceMap } from './font-families'
8
17
 
9
18
  // Track Google Fonts URLs already injected so we don't add the same <link>
10
19
  // twice when multiple overlays declare overlapping fonts. Keyed by the full
11
20
  // stylesheet URL — the same URL never produces a duplicate fetch from
12
21
  // Chromium regardless, but the duplicate <link> tags would still clutter
13
22
  // document.head across long editing sessions.
23
+ //
24
+ // Keying on the full URL (rather than e.g. the family list) also means a
25
+ // setFontsBaseUrl() call transparently invalidates this cache: the base is
26
+ // part of the URL, so switching it produces a URL this Set has never seen
27
+ // and the new <link> is injected regardless of what was injected before.
14
28
  const __injectedFontUrls = new Set<string>()
15
29
 
30
+ // Unset (OSS default): ensureGoogleFontsLoaded builds a per-family
31
+ // fonts.googleapis.com/css2 URL, exactly as before. Set: it links
32
+ // `${base}/fonts.css` for the families `vendoredFamilies` declares and falls
33
+ // through to fonts.googleapis.com for the rest.
34
+ //
35
+ // This setter is private to this module; FontPicker.tsx's own loader has its
36
+ // own, separate setter. The two loaders keep separate injection state by
37
+ // design — but they share the partition itself (`lib/font-families.ts`), so
38
+ // they cannot disagree about which families a base covers.
39
+ let fontsBaseUrl: string | undefined
40
+ let vendoredFamilies = new Set<string>()
41
+ // `undefined` means "no face information was supplied", which leaves the
42
+ // partition at family level. An EMPTY index is different: it says the host
43
+ // supplied face data that covers nothing, so nothing is vendored.
44
+ let vendoredFaces: FaceIndex | undefined
45
+
46
+ /**
47
+ * Point the loader at a vendored stylesheet.
48
+ *
49
+ * @param url Base URL holding `fonts.css`, or undefined for the OSS
50
+ * default (everything from fonts.googleapis.com).
51
+ * @param families The families that stylesheet declares, spelled as its
52
+ * `font-family` rules spell them.
53
+ * @param faceData The manifest's `faces` and `requested` maps, which say
54
+ * WHICH FACES of each family are actually available. The
55
+ * family list alone cannot answer that, and getting it wrong
56
+ * is silent: `Playfair+Display:ital@1` names a vendored
57
+ * family whose italic does not exist, so the browser
58
+ * synthesises an oblique and nothing reports a problem.
59
+ * Omitting it leaves the partition at family level and keeps
60
+ * that hazard — the host should always pass it.
61
+ *
62
+ * **The family list is passed IN, never fetched here.** The host reads its
63
+ * `families.json` once at app init and hands both values over. That keeps this
64
+ * loader fully synchronous, which it has to be: `ensureGoogleFontsLoaded` is
65
+ * called from effects and must decide the partition before it can act. A fetch
66
+ * inside it would create a "manifest has not arrived yet" state with no good
67
+ * exit — it cannot block (it is sync), guessing is silently wrong, and
68
+ * re-injecting once the answer lands leaves two competing stylesheets on the
69
+ * page with the stale one never pruned.
70
+ *
71
+ * Omitting `families`, or passing an empty list, means NOTHING is treated as
72
+ * vendored: the vendored stylesheet is not linked and every requested family
73
+ * comes from Google. See `reportVendoredSet` for why that direction, and not
74
+ * the tempting opposite.
75
+ */
76
+ export function setFontsBaseUrl(
77
+ url: string | undefined,
78
+ families?: readonly string[],
79
+ faceData?: { faces?: FaceMap; requested?: FaceMap },
80
+ ): void {
81
+ fontsBaseUrl = url
82
+ vendoredFamilies = vendoredKeySet(families)
83
+ vendoredFaces = faceData ? vendoredFaceIndex(faceData.faces, faceData.requested) : undefined
84
+ if (url) reportVendoredSet(url, vendoredFamilies, vendoredFaces)
85
+ }
86
+
87
+ /** The digest of the family list currently in force, for a host that wants to
88
+ * compare it against the renderer's own logged digest without scraping the
89
+ * console. Empty string when no base is set. */
90
+ export function vendoredFamiliesDigest(): string {
91
+ return fontsBaseUrl ? familiesDigest(vendoredFamilies, vendoredFaces) : ''
92
+ }
93
+
94
+ function googleFontsUrl(specs: string[]): string {
95
+ return `https://fonts.googleapis.com/css2?${specs.map((f) => `family=${f}`).join('&')}&display=swap`
96
+ }
97
+
98
+ /** Append a stylesheet <link> unless this exact URL was already requested.
99
+ * Returns whether it was newly requested, so a caller can log once per
100
+ * distinct URL rather than once per overlay. The Set is updated before the
101
+ * `document` guard, matching the original loader: a server-side call still
102
+ * counts as "requested". */
103
+ function injectStylesheet(url: string): boolean {
104
+ if (__injectedFontUrls.has(url)) return false
105
+ __injectedFontUrls.add(url)
106
+ if (typeof document === 'undefined') return false
107
+ const link = document.createElement('link')
108
+ link.rel = 'stylesheet'
109
+ link.href = url
110
+ document.head.appendChild(link)
111
+ return true
112
+ }
113
+
16
114
  export function ensureGoogleFontsLoaded(googleFonts: string[] | string | undefined): void {
17
115
  // Defensive coercion: persisted project items have occasionally stored the
18
116
  // `googleFonts` field as a bare string (e.g. "Anton") instead of the typed
@@ -26,14 +124,38 @@ export function ensureGoogleFontsLoaded(googleFonts: string[] | string | undefin
26
124
  ? googleFonts.split(',').map((s) => s.trim()).filter(Boolean)
27
125
  : googleFonts
28
126
  if (!Array.isArray(families) || !families.length) return
29
- // Match the format bundle.js uses for the render pipeline so preview and
30
- // render fetch identical CSS (and identical glyphs / metrics).
31
- const url = `https://fonts.googleapis.com/css2?${families.map((f) => `family=${f}`).join('&')}&display=swap`
32
- if (__injectedFontUrls.has(url)) return
33
- __injectedFontUrls.add(url)
34
- if (typeof document === 'undefined') return
35
- const link = document.createElement('link')
36
- link.rel = 'stylesheet'
37
- link.href = url
38
- document.head.appendChild(link)
127
+
128
+ // Trailing slashes are stripped so `/fonts/editor` and `/fonts/editor/`
129
+ // resolve identically. This mirrors `montaj_assets/render/bundle.js`'s
130
+ // `vendoredFontsHref`, which applies the same `.replace(/\/+$/, '')` — and
131
+ // that symmetry is the point, not tidiness. The render base and this one
132
+ // are set by DIFFERENT mechanisms (an env var there, this setter here) and
133
+ // must name the same stylesheet; if one tolerates a trailing slash and the
134
+ // other emits `//fonts.css`, preview and render disagree about the URL for
135
+ // a base a host reasonably considers the same.
136
+ const base = fontsBaseUrl ? fontsBaseUrl.replace(/\/+$/, '') : ''
137
+
138
+ // The vendored set covers the families the host declared — and only those.
139
+ // It comes from the editor's picker list, while `googleFonts` comes out of
140
+ // project data, and skills/write-overlay documents arbitrary Google families
141
+ // as first-class (its own worked example names "Anton", which the picker does
142
+ // not carry). Linking the vendored sheet and dropping the requested entries
143
+ // would render those in a fallback face with nothing on screen to say so.
144
+ //
145
+ // With no base, `vendoredFamilies` is empty, everything falls through, and
146
+ // the emitted URL is byte-identical to the pre-vendoring one — the format
147
+ // bundle.js emits for the render pipeline, so preview and render fetch
148
+ // identical CSS and therefore identical glyphs / metrics.
149
+ const { vendored, fellThrough } = partitionFontSpecs(
150
+ families,
151
+ base ? vendoredFamilies : new Set(),
152
+ base ? vendoredFaces : undefined,
153
+ )
154
+ if (vendored.length) injectStylesheet(`${base}/fonts.css`)
155
+ if (!fellThrough.length) return
156
+ // Log once per distinct URL rather than once per overlay — a project with
157
+ // fifty overlays naming the same unvendored family should say so once.
158
+ // `base &&`: with no base nothing has "fallen through" to report, that is
159
+ // simply how the OSS default works.
160
+ if (injectStylesheet(googleFontsUrl(fellThrough)) && base) reportUnvendoredFonts(fellThrough)
39
161
  }
package/src/schema.ts CHANGED
@@ -290,6 +290,23 @@ export interface Asset {
290
290
  name?: string
291
291
  }
292
292
 
293
+ /**
294
+ * An operator's flag on the timeline — a moment worth coming back to.
295
+ *
296
+ * Markers are an EDITING and COMMUNICATION aid: they are drawn in the editor's
297
+ * marker strip and handed to the agent through the context endpoint, and the
298
+ * renderer ignores them completely. Nothing about a marker reaches the export.
299
+ *
300
+ * `label` is always present. A new marker gets an auto-number so dropping one
301
+ * never interrupts the edit to type; renaming it is a separate, deliberate act.
302
+ */
303
+ export interface Marker {
304
+ id: string
305
+ /** Timeline position in seconds. Never negative. */
306
+ t: number
307
+ label: string
308
+ }
309
+
293
310
  // ── Carousel types ─────────────────────────────────────────────────────────
294
311
  export interface ImageElement {
295
312
  id: string
@@ -385,6 +402,10 @@ export interface EditorProject {
385
402
  captions?: Captions
386
403
  audio?: { tracks: AudioTrack[] }
387
404
  assets?: Asset[]
405
+ /** Operator markers, kept sorted by `t`. Absent — not `[]` — when the
406
+ * project has none, so a marker-less project is byte-identical to one from
407
+ * before the feature existed (the same discipline `captions` follows). */
408
+ markers?: Marker[]
388
409
  carousel?: { aspect: string }
389
410
  profile?: string
390
411
  derivedFrom?: string // ID of the source project this was derived from (e.g. clips workflow)
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
2
  import { ChevronDown } from 'lucide-react'
3
3
  import { NumberField, stepValue } from '../ui'
4
+ import { partitionFontSpecs, reportUnvendoredFonts, reportVendoredSet, vendoredFaceIndex, vendoredKeySet } from '../lib/font-families'
5
+ import type { FaceIndex, FaceMap } from '../lib/font-families'
4
6
 
5
7
  export type FontOption = {
6
8
  label: string
@@ -53,22 +55,91 @@ export const FONT_OPTIONS: FontOption[] = [
53
55
  // 300/500/600/800), but that's the correct trade: the picker preview and
54
56
  // the persisted spec fetch identical weights, so what you see in the
55
57
  // dropdown is what actually renders.
56
- const GOOGLE_FONTS_URL = (() => {
57
- const params = FONT_OPTIONS
58
+ //
59
+ // This loader is private to FontPicker.tsx and unrelated to the one in
60
+ // `lib/google-fonts.ts` despite sharing a name — see that file's header. Each
61
+ // keeps its own setFontsBaseUrl() and its own injection state. What they do
62
+ // NOT keep separate is the partition itself: both import it from
63
+ // `lib/font-families.ts`, so they cannot disagree about which families a
64
+ // vendored stylesheet covers. Two loaders reaching different answers is a
65
+ // caption in one face while editing and another at export.
66
+
67
+ // Unset (OSS default): the per-family fonts.googleapis.com/css2 URL below,
68
+ // exactly as before. Set: `${base}/fonts.css` for the families the host says
69
+ // that stylesheet declares, plus a googleapis URL for any picker family it
70
+ // does not.
71
+ //
72
+ // The picker's own families are the vendored set's reason for existing, so
73
+ // in a correctly configured app nothing here ever falls through. It is
74
+ // guarded anyway: the day a family is added to FONT_OPTIONS and not yet
75
+ // vendored, the alternative is a picker preview silently showing a system
76
+ // fallback — which is the exact rot a declared family list exists to catch.
77
+ let fontsBaseUrl: string | undefined
78
+ let vendoredFamilies = new Set<string>()
79
+ let vendoredFaces: FaceIndex | undefined
80
+
81
+ /** Point the picker's preview loader at a vendored stylesheet. Same contract
82
+ * as `lib/google-fonts.ts`'s setter of the same name, including that the
83
+ * family list is passed IN rather than fetched — see that file for why. A
84
+ * host must call BOTH setters; neither covers the other's surface.
85
+ *
86
+ * `faceData` matters more here than anywhere else: the picker requests
87
+ * `Bebas+Neue:wght@400;700`, and only the 400 is on disk because Bebas Neue
88
+ * publishes no 700. Without the `requested` half of `faceData` that spec
89
+ * falls through and a picker-only project stops being zero-egress. */
90
+ export function setFontsBaseUrl(
91
+ url: string | undefined,
92
+ families?: readonly string[],
93
+ faceData?: { faces?: FaceMap; requested?: FaceMap },
94
+ ): void {
95
+ fontsBaseUrl = url
96
+ vendoredFamilies = vendoredKeySet(families)
97
+ vendoredFaces = faceData ? vendoredFaceIndex(faceData.faces, faceData.requested) : undefined
98
+ if (url) reportVendoredSet(url, vendoredFamilies, vendoredFaces)
99
+ }
100
+
101
+ /** Every Google family the picker previews, as fetch specs. Falls back to a
102
+ * label-derived guess only if `spec` is somehow missing, so a stale/partial
103
+ * FontOption still resolves. */
104
+ function pickerFontSpecs(): string[] {
105
+ return FONT_OPTIONS
58
106
  .filter((f) => f.isGoogleFont)
59
- .map((f) => `family=${f.spec ?? `${f.label.replace(/ /g, '+')}:wght@400;700`}`)
60
- .join('&')
61
- return `https://fonts.googleapis.com/css2?${params}&display=swap`
62
- })()
107
+ .map((f) => f.spec ?? `${f.label.replace(/ /g, '+')}:wght@400;700`)
108
+ }
63
109
 
64
- let fontsInjected = false
65
- function ensureGoogleFontsLoaded(): void {
66
- if (fontsInjected || typeof document === 'undefined') return
67
- fontsInjected = true
110
+ // Keyed on the URLs actually injected (not a bare boolean, and not a single
111
+ // last-URL string) so that neither a base change after a prior injection nor
112
+ // the two-stylesheet partition case is suppressed by a stale "already done"
113
+ // flag. A new base produces URLs this Set has never seen, so it injects again.
114
+ const injectedFontsUrls = new Set<string>()
115
+ function injectOnce(url: string): boolean {
116
+ if (injectedFontsUrls.has(url)) return false
117
+ injectedFontsUrls.add(url)
118
+ if (typeof document === 'undefined') return false
68
119
  const link = document.createElement('link')
69
120
  link.rel = 'stylesheet'
70
- link.href = GOOGLE_FONTS_URL
121
+ link.href = url
71
122
  document.head.appendChild(link)
123
+ return true
124
+ }
125
+
126
+ function ensureGoogleFontsLoaded(): void {
127
+ if (typeof document === 'undefined') return
128
+ // Trailing slash stripped, matching `lib/google-fonts.ts` and
129
+ // `montaj_assets/render/bundle.js`'s `vendoredFontsHref`. All three build a
130
+ // URL from a base set elsewhere, and a host that passes `/fonts/editor/` to
131
+ // one and `/fonts/editor` to another must not get two different stylesheets.
132
+ const base = fontsBaseUrl ? fontsBaseUrl.replace(/\/+$/, '') : ''
133
+ const { vendored, fellThrough } = partitionFontSpecs(
134
+ pickerFontSpecs(),
135
+ base ? vendoredFamilies : new Set(),
136
+ base ? vendoredFaces : undefined,
137
+ )
138
+ if (vendored.length) injectOnce(`${base}/fonts.css`)
139
+ if (!fellThrough.length) return
140
+ if (injectOnce(`https://fonts.googleapis.com/css2?${fellThrough.map((f) => `family=${f}`).join('&')}&display=swap`) && base) {
141
+ reportUnvendoredFonts(fellThrough)
142
+ }
72
143
  }
73
144
 
74
145
  function firstFontToken(value: string): string {