@bycrux/editor 1.2.0 → 1.2.2

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.
@@ -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
  }
@@ -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 {
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { render, cleanup } from '@testing-library/react'
3
+ import { FontFamilyPicker, FONT_OPTIONS, setFontsBaseUrl } from '../FontPicker'
4
+
5
+ // FontFamilyPicker's own `ensureGoogleFontsLoaded` is private to FontPicker.tsx
6
+ // (unrelated to, and independent from, `lib/google-fonts.ts`'s loader of the
7
+ // same name — see that file's header comment). It has no exported entry point,
8
+ // so we drive it the way the app does: mount the component, whose first-mount
9
+ // useEffect calls it.
10
+ function mountPicker() {
11
+ return render(<FontFamilyPicker value="" onChange={() => {}} />)
12
+ }
13
+
14
+ function injectedHrefs(): string[] {
15
+ return Array.from(document.head.querySelectorAll('link[rel="stylesheet"]')).map(
16
+ (l) => (l as HTMLLinkElement).href,
17
+ )
18
+ }
19
+
20
+ /** Every Google family the picker previews, as the labels a vendored family
21
+ * list would spell. Derived from FONT_OPTIONS rather than hardcoded, so
22
+ * adding a family to the picker cannot leave this test asserting a stale set. */
23
+ const PICKER_FAMILIES = FONT_OPTIONS.filter((f) => f.isGoogleFont).map((f) => f.label)
24
+
25
+ beforeEach(() => {
26
+ document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
27
+ vi.spyOn(console, 'info').mockImplementation(() => {})
28
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
29
+ })
30
+
31
+ afterEach(() => {
32
+ cleanup()
33
+ setFontsBaseUrl(undefined)
34
+ vi.restoreAllMocks()
35
+ })
36
+
37
+ describe('FontFamilyPicker google fonts base URL', () => {
38
+ // Must run before any other test in this file sets a base: the injection
39
+ // guard is keyed on the computed URL, and the default URL can only be
40
+ // observed as "not yet injected" once per module lifetime.
41
+ it('unset produces exactly the default per-family googleapis URL (byte-identical to pre-base behaviour)', () => {
42
+ mountPicker()
43
+ const hrefs = injectedHrefs()
44
+ expect(hrefs).toEqual([
45
+ 'https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Roboto:wght@400;700&family=Open+Sans:wght@400;700&family=Lato:wght@400;700&family=Montserrat:wght@400;700&family=Poppins:wght@400;700&family=Raleway:wght@400;700&family=Nunito:wght@400;700&family=Work+Sans:wght@400;700&family=DM+Sans:wght@400;700&family=Rubik:wght@400;700&family=Oswald:wght@400;700&family=Bebas+Neue:wght@400;700&family=Playfair+Display:wght@400;700&family=Merriweather:wght@400;700&family=Source+Serif+4:wght@400;700&family=JetBrains+Mono:wght@400;700&family=Baloo+2:wght@400;500;600;700;800&family=Fredoka:wght@300;400;500;600;700&family=Sniglet:wght@400;800&display=swap',
46
+ ])
47
+ })
48
+
49
+ it('a base whose family list covers the picker produces <base>/fonts.css ONLY — zero egress', () => {
50
+ setFontsBaseUrl('https://example.com/fonts/editor', PICKER_FAMILIES)
51
+ mountPicker()
52
+ expect(injectedHrefs()).toEqual(['https://example.com/fonts/editor/fonts.css'])
53
+ })
54
+
55
+ it('setting a base after a prior injection still injects — a stale guard must not suppress it', () => {
56
+ // Bases unique to this test so it doesn't depend on injection history left
57
+ // by the tests above (module state, not DOM state, outlives a test — the
58
+ // beforeEach above only clears document.head).
59
+ setFontsBaseUrl('https://example.com/fonts/one', PICKER_FAMILIES)
60
+ mountPicker()
61
+ expect(injectedHrefs()).toEqual(['https://example.com/fonts/one/fonts.css'])
62
+
63
+ setFontsBaseUrl('https://example.com/fonts/two', PICKER_FAMILIES)
64
+ mountPicker()
65
+ const hrefs = injectedHrefs()
66
+ expect(hrefs).toHaveLength(2)
67
+ expect(hrefs).toContain('https://example.com/fonts/two/fonts.css')
68
+ })
69
+
70
+ // The picker's families ARE the vendored set's reason for existing, so in a
71
+ // correctly configured app this never fires. It is guarded because the day a
72
+ // family is added to FONT_OPTIONS and not yet vendored, the alternative is a
73
+ // picker preview silently showing a system fallback — the exact rot a
74
+ // declared family list exists to catch.
75
+ it('a family the vendored list omits falls through to Google, alongside the vendored sheet', () => {
76
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
77
+ const partial = PICKER_FAMILIES.filter((f) => f !== 'Sniglet')
78
+ setFontsBaseUrl('https://example.com/fonts/partial', partial)
79
+ mountPicker()
80
+
81
+ const hrefs = injectedHrefs()
82
+ expect(hrefs).toHaveLength(2)
83
+ expect(hrefs[0]).toBe('https://example.com/fonts/partial/fonts.css')
84
+ expect(hrefs[1]).toBe('https://fonts.googleapis.com/css2?family=Sniglet:wght@400;800&display=swap')
85
+ expect(warn.mock.calls[0][0]).toContain('Sniglet')
86
+ })
87
+
88
+ it('a base with no family list links nothing vendored and emits exactly the default googleapis URL', async () => {
89
+ // A fresh module instance, because the URL this produces IS the default
90
+ // URL — every picker family falls through — and the module-level dedupe
91
+ // would (correctly) suppress it after the first test above already
92
+ // injected it. Asserting against the live module would therefore pass
93
+ // vacuously, on an empty list.
94
+ vi.resetModules()
95
+ const fresh = await import('../FontPicker')
96
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
97
+
98
+ fresh.setFontsBaseUrl('https://example.com/fonts/nolist')
99
+ render(<fresh.FontFamilyPicker value="" onChange={() => {}} />)
100
+
101
+ const hrefs = injectedHrefs()
102
+ expect(hrefs).toHaveLength(1)
103
+ expect(hrefs[0]).toContain('fonts.googleapis.com')
104
+ expect(hrefs[0]).not.toContain('fonts.css')
105
+ // Every picker family is in it — nothing was withheld on the strength of
106
+ // a base we were given no family list for.
107
+ for (const label of PICKER_FAMILIES) expect(hrefs[0]).toContain(`family=${label.replace(/ /g, '+')}`)
108
+ expect(warn.mock.calls[0][0]).toContain('treating NOTHING as vendored')
109
+
110
+ fresh.setFontsBaseUrl(undefined)
111
+ })
112
+ })
package/src/types.ts CHANGED
@@ -186,6 +186,50 @@ export interface GenerateCaptionsOptions {
186
186
  style?: string
187
187
  }
188
188
 
189
+ /**
190
+ * The caption look a host has on file for one of its named profiles —
191
+ * everything this package can seed a freshly transcribed track with.
192
+ *
193
+ * This package owns NO notion of what a profile is beyond `Project.profile`'s
194
+ * bare name: whether profiles are files on disk, rows in the host's database,
195
+ * or nothing at all is the host's business, and asking the host through
196
+ * `getCaptionProfileDefaults` is how the editor stays ignorant of it. Every
197
+ * field is optional and the whole thing is nullable, so "this host has no
198
+ * profiles", "this profile has no styling" and "this profile sets only a
199
+ * color" are all expressible without the editor knowing which it got.
200
+ *
201
+ * Field names are the *editor's* vocabulary, not any host's — each one is
202
+ * named after the field it seeds, so the mapping from a host's own schema
203
+ * happens once, in that host's adapter, rather than leaking a host's column
204
+ * names into this package.
205
+ */
206
+ export interface CaptionProfileDefaults {
207
+ /**
208
+ * Seeds `GenerateCaptionsOptions.style` on the regeneration request — the
209
+ * style the new track is transcribed INTO. Deliberately `string` rather
210
+ * than `Captions['style']`, matching the field it feeds: the host is what
211
+ * validates a style name, and a host storing one in a free-text column
212
+ * should not have to narrow before it can answer.
213
+ *
214
+ * Never written onto the returned track: the host reports the style it
215
+ * actually used, and that report wins (see `mergeCaptionProfileDefaults`).
216
+ */
217
+ style?: string
218
+ /** Seeds `Captions.fontFamily` — a CSS font-family stack. */
219
+ fontFamily?: string
220
+ /**
221
+ * Seeds `Captions.googleFonts` alongside `fontFamily`, and only alongside
222
+ * it. The two travel together (see `Captions.fontFamily`'s own note): a
223
+ * family whose font file is not also fetched renders as the fallback face,
224
+ * in the editor preview and the export alike. A host that seeds a Google
225
+ * family without its spec here gets a silent half-application — the right
226
+ * stack, the wrong glyphs.
227
+ */
228
+ googleFonts?: string[]
229
+ /** Seeds `Captions.color` — the base caption text color. */
230
+ color?: string
231
+ }
232
+
189
233
  // ── Overlay library types ─────────────────────────────────────────────────────
190
234
  // Copied verbatim from Montaj's `ui/src/lib/api.ts` so the package owns the
191
235
  // shape the editor consumes. A host's overlay-listing endpoints return these;
@@ -794,6 +838,30 @@ export interface EditorAdapter<P extends Project = Project> {
794
838
  */
795
839
  generateCaptions?(id: string, opts?: GenerateCaptionsOptions): AsyncIterable<CaptionEvent>
796
840
 
841
+ /**
842
+ * Optional: resolve the caption look the host has on file for `profile` —
843
+ * the value of `Project.profile`, which this package treats as an opaque
844
+ * name and nothing more. Used to seed a freshly transcribed caption track
845
+ * with the style, font and color the profile already implies, instead of
846
+ * leaving the user to re-pick all three every regeneration.
847
+ *
848
+ * THE PROFILE CONCEPT ITSELF STAYS ON THE HOST SIDE, which is the whole
849
+ * reason this is a seam rather than a lookup. `Project.profile` is a bare
850
+ * string here; what it resolves to is the host's — a local file for the
851
+ * OSS `serve` UI, an account-scoped database row for a hosted app, nothing
852
+ * at all for a host with no profile concept. A host that cannot answer
853
+ * omits this method; the editor feature-detects its absence and generates
854
+ * captions exactly as it did before this existed, with no second argument
855
+ * on `generateCaptions` and no merge on the result.
856
+ *
857
+ * Best-effort on the editor's side: a rejection is swallowed and treated as
858
+ * `null`. Caption regeneration is the user's actual request and must never
859
+ * fail because a styling convenience could not be looked up.
860
+ *
861
+ * Returns `null` when the host has no defaults for that name.
862
+ */
863
+ getCaptionProfileDefaults?(profile: string): Promise<CaptionProfileDefaults | null>
864
+
797
865
  /**
798
866
  * Optional: report the editor's live playhead and selection to the host.
799
867
  *
@@ -1,21 +1,35 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import type { EditorAdapter, Project } from '../types'
3
+ import type { CaptionProfileDefaults, EditorAdapter, Project } from '../types'
4
4
  import type { Captions } from '../schema'
5
+ import { resolveCaptionProfileDefaults } from './captionProfileDefaults'
5
6
 
6
7
  interface CaptionRegenModalProps<P extends Project = Project> {
7
8
  projectId: string
8
9
  /** Adapter driving the caption-regeneration stream. Must implement
9
10
  * `generateCaptions` — callers gate rendering on its presence. */
10
11
  adapter: EditorAdapter<P>
12
+ /** The project's attached profile name (`Project.profile`), if it has one.
13
+ * Passed to `adapter.getCaptionProfileDefaults` to seed the new track's
14
+ * style/font/color. Absent — or a host with no such seam — and the run is
15
+ * byte-identical to what it was before profile seeding existed. */
16
+ profile?: string
11
17
  /** Caption rows the project has right now (`maxCaptionLane(segments) + 1`,
12
18
  * so 1 for a lane-less or empty track). Regeneration replaces the whole
13
19
  * track with a single fresh row, so this is the count the warning banner
14
20
  * below reports as about to be discarded. */
15
21
  existingRowCount: number
16
- /** Fired on terminal success with the freshly transcribed caption track. The
17
- * caller patches `project.captions` from this; the modal then closes. */
18
- onDone: (captions: Captions) => void
22
+ /** Fired on terminal success with the freshly transcribed caption track,
23
+ * plus whatever the host answered for `profile` (null when it answered
24
+ * nothing). The caller patches `project.captions` from the first and folds
25
+ * the second onto it — see `mergeCaptionProfileDefaults`. The modal then
26
+ * closes.
27
+ *
28
+ * The defaults are handed OVER rather than re-fetched by the caller so the
29
+ * profile is resolved exactly once per run: the modal already had to ask
30
+ * before the stream started (it needs the style for the request), and a
31
+ * second lookup at apply time could answer differently. */
32
+ onDone: (captions: Captions, profileDefaults: CaptionProfileDefaults | null) => void
19
33
  /** Fired when the modal closes (cancel, error dismiss, or post-done). */
20
34
  onClose: () => void
21
35
  /** Editor theme mode — light/dark. The panel and log box follow
@@ -38,7 +52,7 @@ function LogLine({ text, mode = 'dark' }: { text: string; mode?: 'light' | 'dark
38
52
  )
39
53
  }
40
54
 
41
- export default function CaptionRegenModal<P extends Project = Project>({ projectId, adapter, existingRowCount, onDone, onClose, mode = 'dark' }: CaptionRegenModalProps<P>) {
55
+ export default function CaptionRegenModal<P extends Project = Project>({ projectId, adapter, profile, existingRowCount, onDone, onClose, mode = 'dark' }: CaptionRegenModalProps<P>) {
42
56
  const [logs, setLogs] = useState<string[]>([])
43
57
  const [status, setStatus] = useState<'running' | 'done' | 'error'>('running')
44
58
  const [errorMsg, setError] = useState<string | null>(null)
@@ -66,13 +80,25 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
66
80
 
67
81
  void (async () => {
68
82
  try {
69
- for await (const ev of adapter.generateCaptions!(projectId)) {
83
+ // Seeding, before anything is spawned: the style has to be on the
84
+ // request, so this one await sits in front of the stream. It cannot
85
+ // reject (see `resolveCaptionProfileDefaults`) and it cannot stop the
86
+ // run — a host with no profile seam, or a lookup that failed, simply
87
+ // produces `null` and everything below behaves exactly as it did
88
+ // before this existed, down to the arity of the call.
89
+ const profileDefaults = await resolveCaptionProfileDefaults(adapter, profile)
90
+ if (unmountedRef.current || cancelledRef.current) return
91
+ const stream = profileDefaults?.style
92
+ ? adapter.generateCaptions!(projectId, { style: profileDefaults.style })
93
+ : adapter.generateCaptions!(projectId)
94
+
95
+ for await (const ev of stream) {
70
96
  if (unmountedRef.current || cancelledRef.current) break
71
97
  if (ev.type === 'log') {
72
98
  setLogs(l => [...l, ev.message])
73
99
  } else if (ev.type === 'done') {
74
100
  setStatus('done')
75
- onDone(ev.captions)
101
+ onDone(ev.captions, profileDefaults)
76
102
  } else {
77
103
  setError(ev.message)
78
104
  setStatus('error')
@@ -94,7 +120,7 @@ export default function CaptionRegenModal<P extends Project = Project>({ project
94
120
  unmountedRef.current = true
95
121
  }, 0)
96
122
  }
97
- }, [projectId, adapter, onDone])
123
+ }, [projectId, adapter, profile, onDone])
98
124
 
99
125
  // Auto-scroll logs
100
126
  useEffect(() => {
@@ -111,7 +111,7 @@ export default function VersionPanel({ versions, restoring, onRestore, onSaveVer
111
111
  <button
112
112
  onClick={handleSaveClick}
113
113
  disabled={saving || !onSaveVersion}
114
- className="shrink-0 flex items-center gap-1.5 h-8 px-3 rounded-md bg-[var(--editor-accent)] text-white text-xs font-medium hover:opacity-90 transition-opacity disabled:opacity-40 disabled:hover:opacity-40"
114
+ className="shrink-0 flex items-center gap-1.5 h-8 px-3 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] text-xs font-medium hover:opacity-90 transition-opacity disabled:opacity-40 disabled:hover:opacity-40"
115
115
  >
116
116
  <Save size={13} />
117
117
  {saving ? 'Saving…' : 'Save version'}
@@ -14,9 +14,10 @@ import { collapseGaps, rippleDelete, splitAtTime } from './cuts'
14
14
  import { addMarker } from './timeline/markers'
15
15
  import { repairCaptionWords } from './captionRepair'
16
16
  import { maxCaptionLane, normalizeCaptionLanes } from './captionLanes'
17
+ import { mergeCaptionProfileDefaults } from './captionProfileDefaults'
17
18
  import Timeline, { type TimelineActions, type TimelineMode } from './timeline/Timeline'
18
19
  import { visualDuration } from '@bycrux/timeline-core'
19
- import { computeAutoCrossfade, computeDerivedTiming, computeVisualCrossfade, enabledTrackItems, mapTrackItems, normalizeAudioTracks, trackItems, withEnabledItemTracks } from './timeline/timeline-model'
20
+ import { audioEnd, computeAutoCrossfade, computeDerivedTiming, computeVisualCrossfade, enabledTrackItems, mapTrackItems, normalizeAudioTracks, trackItems, withEnabledItemTracks } from './timeline/timeline-model'
20
21
  import { makeCaptionEdit, type CaptionEditPatch } from './timeline/makeCaptionEdit'
21
22
  import PreviewPlayer, { type TransportHandle, type ScrubHandle } from './preview/PreviewPlayer'
22
23
  import SocialPreviewMenu, { PlatformGlyph, platformOption } from './preview/SocialPreviewMenu'
@@ -1307,7 +1308,25 @@ function ReviewSurface<P extends Project>({
1307
1308
  }, [project.id, project.captions])
1308
1309
 
1309
1310
  const clips = trackItems(project)[0] ?? []
1310
- const hasContent = clips.length > 0 || (trackItems(project).slice(1).flat().length ?? 0) > 0 || (project.captions?.segments?.length ?? 0) > 0
1311
+ // Gates the ENTIRE preview region — transport, click-to-play surface and the
1312
+ // multi-track audio elements all live inside it, so anything this misses is
1313
+ // not merely invisible, it is unplayable with no UI saying why.
1314
+ //
1315
+ // Audio counts. An audio-only timeline is the normal state of an animations
1316
+ // project between wiring music and authoring the first overlay, and gating
1317
+ // the region out there left the operator a populated timeline, a drawn
1318
+ // waveform lane, and no transport to press: space did nothing at all.
1319
+ //
1320
+ // `trackItems` is deliberately NOT sliced to `[1:]`. Track 0 is a content
1321
+ // track in a canvas project — an animations-workflow project is frequently
1322
+ // ONE track holding nothing but overlays — and `clips` only counts track-0
1323
+ // *video* items, so a track-0 overlay fell through both terms. Same blind
1324
+ // spot `transportEndFor` and `canvasMaxEndRef` were fixed for.
1325
+ const hasContent =
1326
+ clips.length > 0
1327
+ || trackItems(project).flat().length > 0
1328
+ || (project.captions?.segments?.length ?? 0) > 0
1329
+ || audioEnd(project) > 0
1311
1330
 
1312
1331
  // Preview controls row's timecode readout. `currentTime` is the same
1313
1332
  // `usePlaybackTime(clock)` subscription `CaptionListPanelWithClock` uses
@@ -2936,10 +2955,19 @@ function ReviewSurface<P extends Project>({
2936
2955
  <CaptionRegenModal
2937
2956
  adapter={adapter}
2938
2957
  projectId={project.id}
2958
+ profile={project.profile}
2939
2959
  existingRowCount={maxCaptionLane(project.captions?.segments ?? []) + 1}
2940
2960
  onClose={() => setRegenCaptionsOpen(false)}
2941
- onDone={(captions) => {
2942
- sync.applyExternal({ ...syncProjectRef.current, captions } as P)
2961
+ onDone={(captions, profileDefaults) => {
2962
+ // The modal resolved the profile once, before the stream; we fold
2963
+ // its font/color onto the fresh track here rather than there
2964
+ // because this is the seam that decides what lands on the project
2965
+ // — and the merge only ever fills fields the host left unset, so
2966
+ // a host that starts returning them keeps authoring them.
2967
+ // `mergeCaptionProfileDefaults` returns `captions` itself when
2968
+ // there is nothing to seed, which is every host without the seam.
2969
+ const seeded = mergeCaptionProfileDefaults(captions, profileDefaults)
2970
+ sync.applyExternal({ ...syncProjectRef.current, captions: seeded } as P)
2943
2971
  setRegenCaptionsOpen(false)
2944
2972
  }}
2945
2973
  mode={timelineMode}