@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.
- package/package.json +2 -2
- package/src/engine/__tests__/scheduler.test.ts +35 -0
- package/src/engine/scheduler.ts +10 -2
- package/src/index.ts +33 -0
- package/src/lib/__tests__/font-faces.test.ts +244 -0
- package/src/lib/__tests__/font-loader-parity.test.tsx +236 -0
- package/src/lib/__tests__/google-fonts.test.ts +319 -2
- package/src/lib/font-families.ts +286 -0
- package/src/lib/google-fonts.ts +132 -10
- package/src/text/FontPicker.tsx +82 -11
- package/src/text/__tests__/FontPicker.baseUrl.test.tsx +112 -0
- package/src/types.ts +68 -0
- package/src/video/CaptionRegenModal.tsx +34 -8
- package/src/video/VersionPanel.tsx +1 -1
- package/src/video/VideoEditor.tsx +32 -4
- package/src/video/__tests__/CaptionRegenModal.test.tsx +161 -2
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +102 -0
- package/src/video/__tests__/captionProfileDefaults.test.ts +121 -0
- package/src/video/captionProfileDefaults.ts +86 -0
- package/src/video/panels/LeftPanelTabs.tsx +20 -2
- package/src/video/preview/PreviewPlayer.tsx +52 -2
- package/src/video/preview/__tests__/useVideoPlayback.muted.test.ts +239 -0
- package/src/video/preview/useEnginePlayback.ts +58 -8
- package/src/video/preview/useVideoPlayback.ts +48 -16
- package/src/video/timeline/canvas/TimelineCanvas.tsx +10 -8
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +38 -7
- package/src/video/timeline/canvas/draw.ts +78 -1
- package/src/video/timeline/timeline-model.ts +19 -0
package/src/lib/google-fonts.ts
CHANGED
|
@@ -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
|
-
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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/text/FontPicker.tsx
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
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) =>
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 =
|
|
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
|
|
17
|
-
*
|
|
18
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|