captions.js 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,9 @@ A modern JavaScript library for adding captions to HTML5 videos with ease.
11
11
 
12
12
  [**Live Demo**](https://maskin25.github.io/captions.js/)
13
13
 
14
- [![Storybook](https://raw.githubusercontent.com/storybookjs/brand/refs/heads/main/badge/badge-storybook.svg)](https://main--68e681805917843931c33a87.chromatic.com/)
14
+ [**Docs & API Reference**](https://maskin25.github.io/captions.js/docs/)
15
+
16
+ <!-- [![Storybook](https://raw.githubusercontent.com/storybookjs/brand/refs/heads/main/badge/badge-storybook.svg)](https://main--68e681805917843931c33a87.chromatic.com/) -->
15
17
 
16
18
  ## Features
17
19
 
package/dist/index.d.mts CHANGED
@@ -1,5 +1,18 @@
1
+ /**
2
+ * Whitelisted Google Fonts that the renderer knows how to lazy-load.
3
+ *
4
+ * @remarks
5
+ * Restricting the list keeps bundle size predictable across consumers.
6
+ *
7
+ * @public
8
+ */
1
9
  declare const googleFontsList: readonly ["Roboto", "Open Sans", "Lato", "Montserrat", "Nunito", "Poppins", "Ubuntu", "PT Sans", "Merriweather Sans", "Lobster", "Amatic SC", "Pacifico", "Raleway", "Cinzel", "Quicksand", "Zilla Slab", "Caveat", "Crimson Pro", "Bebas Neue", "Comfortaa", "Satisfy", "Permanent Marker", "Oswald", "Onset", "Bangers", "Kanit", "Work Sans", "Fira Sans", "Anton", "Playfair Display", "Rubik", "Alumni Sans", "Righteous", "Comico", "Excon", "Kalam", "Tanker", "Arsenal", "Balsamiq Sans", "Bona Nova SC"];
2
10
 
11
+ /**
12
+ * Shared schema describing how a preset styles captions plus rough layout hints.
13
+ *
14
+ * @public
15
+ */
3
16
  interface StylePreset {
4
17
  id: number;
5
18
  captionsSettings: {
@@ -37,17 +50,37 @@ interface StylePreset {
37
50
  fitLayoutAspectRatio: string;
38
51
  };
39
52
  }
53
+ /**
54
+ * Curated set of presets that ship with captions.js out of the box.
55
+ *
56
+ * @remarks
57
+ * Downstream apps can reference them directly or clone/extend as needed.
58
+ *
59
+ * @public
60
+ */
40
61
  declare const stylePresets: StylePreset[];
41
62
 
63
+ /**
64
+ * Server/worker-friendly helper that paints a text string onto a provided canvas.
65
+ *
66
+ * @remarks
67
+ * Uses the same Konva pipeline as the video overlay renderer so the results
68
+ * match what users see in the browser.
69
+ *
70
+ * @param canvas - Destination canvas that should receive the rendered text.
71
+ * @param text - Arbitrary content that needs to be painted.
72
+ * @param options - Rendering options that include the style preset.
73
+ * @returns Resolves once the frame has been painted (always resolves to `true`).
74
+ */
42
75
  declare function renderString(canvas: HTMLCanvasElement, text: string, options: {
43
76
  preset: StylePreset;
44
77
  }): Promise<boolean>;
45
78
 
46
- declare const attachToVideo: (videoElement: HTMLVideoElement, container?: HTMLDivElement, options?: any) => {
47
- detach: () => void;
48
- update: (newOptions: any) => Promise<void>;
49
- };
50
-
79
+ /**
80
+ * Single timed word/segment that will be highlighted as audio plays.
81
+ *
82
+ * @public
83
+ */
51
84
  interface Caption {
52
85
  word: string;
53
86
  startTime: number;
@@ -55,6 +88,112 @@ interface Caption {
55
88
  highlightColor?: string;
56
89
  }
57
90
 
91
+ /**
92
+ * Configuration passed to the captions runtime when binding to a video element.
93
+ *
94
+ * @public
95
+ */
96
+ type CaptionsOptions = {
97
+ /** Video element that should receive overlays. */
98
+ video: HTMLVideoElement;
99
+ /** Optional custom container to host the Konva stage. */
100
+ container?: HTMLDivElement;
101
+ /** Initial preset controlling font, colors, animations. */
102
+ preset: StylePreset;
103
+ /** Initial caption track. */
104
+ captions?: Caption[] | null;
105
+ /** When false, caller must invoke {@link Captions.enable} manually. */
106
+ autoEnable?: boolean;
107
+ };
108
+ /**
109
+ * Imperative controller that owns the Konva stage lifecycle for a single video element.
110
+ *
111
+ * @public
112
+ */
113
+ declare class Captions {
114
+ private enabled;
115
+ private readonly video;
116
+ private readonly providedContainer?;
117
+ private presetState;
118
+ private captionsState;
119
+ private containerElement?;
120
+ private ownsContainer;
121
+ private stage;
122
+ private layer;
123
+ private resizeObserver?;
124
+ private animationFrameId;
125
+ private videoWidth;
126
+ private videoHeight;
127
+ private readonly handleResize;
128
+ private readonly handleMetadata;
129
+ private readonly animationLoop;
130
+ /**
131
+ * Create a controller bound to the provided video element and preset.
132
+ *
133
+ * @param options - Complete configuration for the controller.
134
+ */
135
+ constructor(options: CaptionsOptions);
136
+ /**
137
+ * Mount caption overlays onto the configured video if they are not active yet.
138
+ */
139
+ enable(): void;
140
+ /**
141
+ * Tear down overlays, observers and animation loops to free resources.
142
+ */
143
+ disable(): void;
144
+ /**
145
+ * Alias for {@link Captions.disable | disable()} to match typical imperative controller APIs.
146
+ */
147
+ destroy(): void;
148
+ /**
149
+ * Swap the active preset and re-render with updated typography/colors.
150
+ *
151
+ * @param nextPreset - Preset that becomes the new render baseline.
152
+ */
153
+ preset(nextPreset: StylePreset): void;
154
+ /**
155
+ * Replace the current caption track and repaint without reloading fonts.
156
+ *
157
+ * @param nextCaptions - Timed words that should drive the overlay.
158
+ */
159
+ captions(nextCaptions: Caption[] | null): void;
160
+ /**
161
+ * Whether the Konva overlay is currently attached to the video element.
162
+ *
163
+ * @returns `true` when the overlay is mounted on top of the video.
164
+ */
165
+ isEnabled(): boolean;
166
+ private refreshFrame;
167
+ private loadFontForCurrentPreset;
168
+ private updateFrame;
169
+ private syncStageDimensions;
170
+ private createOverlay;
171
+ }
172
+ /**
173
+ * Convenience alias for the concrete controller class.
174
+ *
175
+ * @public
176
+ */
177
+ type CaptionsInstance = Captions;
178
+ /**
179
+ * Factory mirroring the legacy default export for ergonomic imports.
180
+ *
181
+ * @param options - Same options accepted by the {@link Captions} constructor.
182
+ * @returns New controller instance.
183
+ */
184
+ declare function captionsjs(options: CaptionsOptions): Captions;
185
+
186
+ /**
187
+ * Simple canvas demo renderer used only for the docs playground.
188
+ *
189
+ * @remarks
190
+ * Keeps a reference implementation of drawing raw text to a canvas so we can
191
+ * showcase caption styling without a video element.
192
+ *
193
+ * @param ctx - Target 2D context to draw on.
194
+ * @param text - Arbitrary string that should be painted on the canvas.
195
+ * @returns Always returns `true` to match the historical API surface.
196
+ */
58
197
  declare function renderCaptions(ctx: CanvasRenderingContext2D, text: string): boolean;
59
198
 
60
- export { type Caption, type StylePreset, attachToVideo, googleFontsList, renderCaptions, renderString, stylePresets };
199
+ export { type Caption, Captions, type CaptionsInstance, type CaptionsOptions, type StylePreset, captionsjs, captionsjs as default, googleFontsList, renderCaptions, renderString, stylePresets };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,18 @@
1
+ /**
2
+ * Whitelisted Google Fonts that the renderer knows how to lazy-load.
3
+ *
4
+ * @remarks
5
+ * Restricting the list keeps bundle size predictable across consumers.
6
+ *
7
+ * @public
8
+ */
1
9
  declare const googleFontsList: readonly ["Roboto", "Open Sans", "Lato", "Montserrat", "Nunito", "Poppins", "Ubuntu", "PT Sans", "Merriweather Sans", "Lobster", "Amatic SC", "Pacifico", "Raleway", "Cinzel", "Quicksand", "Zilla Slab", "Caveat", "Crimson Pro", "Bebas Neue", "Comfortaa", "Satisfy", "Permanent Marker", "Oswald", "Onset", "Bangers", "Kanit", "Work Sans", "Fira Sans", "Anton", "Playfair Display", "Rubik", "Alumni Sans", "Righteous", "Comico", "Excon", "Kalam", "Tanker", "Arsenal", "Balsamiq Sans", "Bona Nova SC"];
2
10
 
11
+ /**
12
+ * Shared schema describing how a preset styles captions plus rough layout hints.
13
+ *
14
+ * @public
15
+ */
3
16
  interface StylePreset {
4
17
  id: number;
5
18
  captionsSettings: {
@@ -37,17 +50,37 @@ interface StylePreset {
37
50
  fitLayoutAspectRatio: string;
38
51
  };
39
52
  }
53
+ /**
54
+ * Curated set of presets that ship with captions.js out of the box.
55
+ *
56
+ * @remarks
57
+ * Downstream apps can reference them directly or clone/extend as needed.
58
+ *
59
+ * @public
60
+ */
40
61
  declare const stylePresets: StylePreset[];
41
62
 
63
+ /**
64
+ * Server/worker-friendly helper that paints a text string onto a provided canvas.
65
+ *
66
+ * @remarks
67
+ * Uses the same Konva pipeline as the video overlay renderer so the results
68
+ * match what users see in the browser.
69
+ *
70
+ * @param canvas - Destination canvas that should receive the rendered text.
71
+ * @param text - Arbitrary content that needs to be painted.
72
+ * @param options - Rendering options that include the style preset.
73
+ * @returns Resolves once the frame has been painted (always resolves to `true`).
74
+ */
42
75
  declare function renderString(canvas: HTMLCanvasElement, text: string, options: {
43
76
  preset: StylePreset;
44
77
  }): Promise<boolean>;
45
78
 
46
- declare const attachToVideo: (videoElement: HTMLVideoElement, container?: HTMLDivElement, options?: any) => {
47
- detach: () => void;
48
- update: (newOptions: any) => Promise<void>;
49
- };
50
-
79
+ /**
80
+ * Single timed word/segment that will be highlighted as audio plays.
81
+ *
82
+ * @public
83
+ */
51
84
  interface Caption {
52
85
  word: string;
53
86
  startTime: number;
@@ -55,6 +88,112 @@ interface Caption {
55
88
  highlightColor?: string;
56
89
  }
57
90
 
91
+ /**
92
+ * Configuration passed to the captions runtime when binding to a video element.
93
+ *
94
+ * @public
95
+ */
96
+ type CaptionsOptions = {
97
+ /** Video element that should receive overlays. */
98
+ video: HTMLVideoElement;
99
+ /** Optional custom container to host the Konva stage. */
100
+ container?: HTMLDivElement;
101
+ /** Initial preset controlling font, colors, animations. */
102
+ preset: StylePreset;
103
+ /** Initial caption track. */
104
+ captions?: Caption[] | null;
105
+ /** When false, caller must invoke {@link Captions.enable} manually. */
106
+ autoEnable?: boolean;
107
+ };
108
+ /**
109
+ * Imperative controller that owns the Konva stage lifecycle for a single video element.
110
+ *
111
+ * @public
112
+ */
113
+ declare class Captions {
114
+ private enabled;
115
+ private readonly video;
116
+ private readonly providedContainer?;
117
+ private presetState;
118
+ private captionsState;
119
+ private containerElement?;
120
+ private ownsContainer;
121
+ private stage;
122
+ private layer;
123
+ private resizeObserver?;
124
+ private animationFrameId;
125
+ private videoWidth;
126
+ private videoHeight;
127
+ private readonly handleResize;
128
+ private readonly handleMetadata;
129
+ private readonly animationLoop;
130
+ /**
131
+ * Create a controller bound to the provided video element and preset.
132
+ *
133
+ * @param options - Complete configuration for the controller.
134
+ */
135
+ constructor(options: CaptionsOptions);
136
+ /**
137
+ * Mount caption overlays onto the configured video if they are not active yet.
138
+ */
139
+ enable(): void;
140
+ /**
141
+ * Tear down overlays, observers and animation loops to free resources.
142
+ */
143
+ disable(): void;
144
+ /**
145
+ * Alias for {@link Captions.disable | disable()} to match typical imperative controller APIs.
146
+ */
147
+ destroy(): void;
148
+ /**
149
+ * Swap the active preset and re-render with updated typography/colors.
150
+ *
151
+ * @param nextPreset - Preset that becomes the new render baseline.
152
+ */
153
+ preset(nextPreset: StylePreset): void;
154
+ /**
155
+ * Replace the current caption track and repaint without reloading fonts.
156
+ *
157
+ * @param nextCaptions - Timed words that should drive the overlay.
158
+ */
159
+ captions(nextCaptions: Caption[] | null): void;
160
+ /**
161
+ * Whether the Konva overlay is currently attached to the video element.
162
+ *
163
+ * @returns `true` when the overlay is mounted on top of the video.
164
+ */
165
+ isEnabled(): boolean;
166
+ private refreshFrame;
167
+ private loadFontForCurrentPreset;
168
+ private updateFrame;
169
+ private syncStageDimensions;
170
+ private createOverlay;
171
+ }
172
+ /**
173
+ * Convenience alias for the concrete controller class.
174
+ *
175
+ * @public
176
+ */
177
+ type CaptionsInstance = Captions;
178
+ /**
179
+ * Factory mirroring the legacy default export for ergonomic imports.
180
+ *
181
+ * @param options - Same options accepted by the {@link Captions} constructor.
182
+ * @returns New controller instance.
183
+ */
184
+ declare function captionsjs(options: CaptionsOptions): Captions;
185
+
186
+ /**
187
+ * Simple canvas demo renderer used only for the docs playground.
188
+ *
189
+ * @remarks
190
+ * Keeps a reference implementation of drawing raw text to a canvas so we can
191
+ * showcase caption styling without a video element.
192
+ *
193
+ * @param ctx - Target 2D context to draw on.
194
+ * @param text - Arbitrary string that should be painted on the canvas.
195
+ * @returns Always returns `true` to match the historical API surface.
196
+ */
58
197
  declare function renderCaptions(ctx: CanvasRenderingContext2D, text: string): boolean;
59
198
 
60
- export { type Caption, type StylePreset, attachToVideo, googleFontsList, renderCaptions, renderString, stylePresets };
199
+ export { type Caption, Captions, type CaptionsInstance, type CaptionsOptions, type StylePreset, captionsjs, captionsjs as default, googleFontsList, renderCaptions, renderString, stylePresets };
package/dist/index.js CHANGED
@@ -30,7 +30,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
- attachToVideo: () => attachToVideo,
33
+ Captions: () => Captions,
34
+ captionsjs: () => captionsjs,
35
+ default: () => index_default,
34
36
  googleFontsList: () => googleFontsList,
35
37
  renderCaptions: () => renderCaptions,
36
38
  renderString: () => renderString,
@@ -952,7 +954,7 @@ async function renderString(canvas, text, options) {
952
954
  return true;
953
955
  }
954
956
 
955
- // src/render/attachToVideo.ts
957
+ // src/captions/Captions.ts
956
958
  var import_konva5 = __toESM(require("konva"));
957
959
 
958
960
  // src/canvas-captions/index.ts
@@ -1532,71 +1534,209 @@ var renderFrame = (captionsSettings, layoutSettings, captions, currentTime, targ
1532
1534
  layer.draw();
1533
1535
  };
1534
1536
 
1535
- // src/render/attachToVideo.ts
1536
- var attachedVideos = /* @__PURE__ */ new WeakMap();
1537
- var attachToVideo = (videoElement, container, options) => {
1538
- if (!container) {
1539
- container = document.createElement("div");
1540
- container.style.position = "absolute";
1541
- container.style.top = "0";
1542
- container.style.left = "0";
1543
- container.style.width = "100%";
1544
- container.style.height = "100%";
1545
- container.style.pointerEvents = "none";
1546
- videoElement.parentElement?.appendChild(container);
1537
+ // src/captions/Captions.ts
1538
+ var Captions = class {
1539
+ /**
1540
+ * Create a controller bound to the provided video element and preset.
1541
+ *
1542
+ * @param options - Complete configuration for the controller.
1543
+ */
1544
+ constructor(options) {
1545
+ this.enabled = false;
1546
+ this.ownsContainer = false;
1547
+ this.stage = null;
1548
+ this.layer = null;
1549
+ this.animationFrameId = null;
1550
+ this.videoWidth = 0;
1551
+ this.videoHeight = 0;
1552
+ this.handleResize = () => {
1553
+ this.syncStageDimensions();
1554
+ };
1555
+ this.handleMetadata = () => {
1556
+ this.syncStageDimensions();
1557
+ };
1558
+ this.animationLoop = () => {
1559
+ this.updateFrame();
1560
+ this.animationFrameId = requestAnimationFrame(this.animationLoop);
1561
+ };
1562
+ if (!options.video) {
1563
+ throw new Error("captionsjs requires a video element");
1564
+ }
1565
+ this.video = options.video;
1566
+ this.providedContainer = options.container;
1567
+ this.presetState = options.preset;
1568
+ this.captionsState = options.captions ?? null;
1569
+ if (options.autoEnable ?? true) {
1570
+ this.enable();
1571
+ }
1547
1572
  }
1548
- const stage = new import_konva5.default.Stage({
1549
- container,
1550
- width: videoElement.videoWidth,
1551
- height: videoElement.videoHeight
1552
- });
1553
- const layer = new import_konva5.default.Layer();
1554
- stage.add(layer);
1555
- stage.width(container.clientWidth);
1556
- stage.height(container.clientHeight);
1557
- const update = () => {
1558
- console.log(
1559
- "update captions at",
1560
- options.preset.captionsSettings.style.name
1561
- );
1562
- layer.destroyChildren();
1573
+ /**
1574
+ * Mount caption overlays onto the configured video if they are not active yet.
1575
+ */
1576
+ enable() {
1577
+ if (this.enabled) {
1578
+ return;
1579
+ }
1580
+ this.containerElement = this.providedContainer ?? this.createOverlay();
1581
+ this.ownsContainer = !this.providedContainer;
1582
+ this.stage = new import_konva5.default.Stage({
1583
+ container: this.containerElement
1584
+ });
1585
+ this.layer = new import_konva5.default.Layer();
1586
+ this.stage.add(this.layer);
1587
+ this.videoWidth = this.video.videoWidth;
1588
+ this.videoHeight = this.video.videoHeight;
1589
+ window.addEventListener("resize", this.handleResize);
1590
+ if (typeof ResizeObserver !== "undefined") {
1591
+ this.resizeObserver = new ResizeObserver(this.handleResize);
1592
+ this.resizeObserver.observe(this.video);
1593
+ }
1594
+ this.video.addEventListener("loadedmetadata", this.handleMetadata);
1595
+ this.syncStageDimensions();
1596
+ this.animationFrameId = requestAnimationFrame(this.animationLoop);
1597
+ this.enabled = true;
1598
+ void this.refreshFrame();
1599
+ }
1600
+ /**
1601
+ * Tear down overlays, observers and animation loops to free resources.
1602
+ */
1603
+ disable() {
1604
+ if (!this.enabled) {
1605
+ return;
1606
+ }
1607
+ if (this.animationFrameId !== null) {
1608
+ cancelAnimationFrame(this.animationFrameId);
1609
+ this.animationFrameId = null;
1610
+ }
1611
+ window.removeEventListener("resize", this.handleResize);
1612
+ this.video.removeEventListener("loadedmetadata", this.handleMetadata);
1613
+ this.resizeObserver?.disconnect();
1614
+ this.resizeObserver = void 0;
1615
+ if (this.ownsContainer && this.containerElement?.parentElement) {
1616
+ this.containerElement.parentElement.removeChild(this.containerElement);
1617
+ }
1618
+ this.stage?.destroy();
1619
+ this.stage = null;
1620
+ this.layer = null;
1621
+ this.containerElement = void 0;
1622
+ this.ownsContainer = false;
1623
+ this.enabled = false;
1624
+ }
1625
+ /**
1626
+ * Alias for {@link Captions.disable | disable()} to match typical imperative controller APIs.
1627
+ */
1628
+ destroy() {
1629
+ this.disable();
1630
+ }
1631
+ /**
1632
+ * Swap the active preset and re-render with updated typography/colors.
1633
+ *
1634
+ * @param nextPreset - Preset that becomes the new render baseline.
1635
+ */
1636
+ preset(nextPreset) {
1637
+ this.presetState = nextPreset;
1638
+ if (!this.enabled) {
1639
+ return;
1640
+ }
1641
+ void this.refreshFrame();
1642
+ }
1643
+ /**
1644
+ * Replace the current caption track and repaint without reloading fonts.
1645
+ *
1646
+ * @param nextCaptions - Timed words that should drive the overlay.
1647
+ */
1648
+ captions(nextCaptions) {
1649
+ this.captionsState = nextCaptions;
1650
+ if (!this.enabled) {
1651
+ return;
1652
+ }
1653
+ void this.refreshFrame(false);
1654
+ }
1655
+ /**
1656
+ * Whether the Konva overlay is currently attached to the video element.
1657
+ *
1658
+ * @returns `true` when the overlay is mounted on top of the video.
1659
+ */
1660
+ isEnabled() {
1661
+ return this.enabled;
1662
+ }
1663
+ async refreshFrame(loadFont = true) {
1664
+ if (!this.layer || !this.stage) {
1665
+ return;
1666
+ }
1667
+ if (loadFont) {
1668
+ await this.loadFontForCurrentPreset();
1669
+ }
1670
+ this.updateFrame();
1671
+ }
1672
+ async loadFontForCurrentPreset() {
1673
+ const fontFamily = this.presetState.captionsSettings.style.font.fontFamily;
1674
+ await loadGoogleFont2(fontFamily);
1675
+ }
1676
+ updateFrame() {
1677
+ if (!this.layer || !this.stage) {
1678
+ return;
1679
+ }
1680
+ if (!this.videoWidth || !this.videoHeight) {
1681
+ return;
1682
+ }
1683
+ this.layer.destroyChildren();
1563
1684
  renderFrame(
1564
- options.preset.captionsSettings,
1685
+ this.presetState.captionsSettings,
1565
1686
  void 0,
1566
- options.captions || [],
1567
- videoElement.currentTime,
1568
- [640, 360],
1569
- layer,
1687
+ this.captionsState || [],
1688
+ this.video.currentTime,
1689
+ [this.videoWidth, this.videoHeight],
1690
+ this.layer,
1570
1691
  1,
1571
1692
  { type: "bottom", positionTopOffset: 50 }
1572
1693
  );
1573
- };
1574
- let animationFrameId;
1575
- const animationLoop = () => {
1576
- update();
1577
- animationFrameId = requestAnimationFrame(animationLoop);
1578
- };
1579
- animationLoop();
1580
- const controls = {
1581
- detach: () => {
1582
- cancelAnimationFrame(animationFrameId);
1583
- if (container && container.parentElement) {
1584
- container.parentElement.removeChild(container);
1585
- }
1586
- stage.destroy();
1587
- attachedVideos.delete(videoElement);
1588
- },
1589
- update: async (newOptions) => {
1590
- options = { ...options, ...newOptions };
1591
- await loadGoogleFont2(
1592
- options.preset.captionsSettings.style.font.fontFamily
1593
- );
1594
- update();
1694
+ }
1695
+ syncStageDimensions() {
1696
+ if (!this.stage) {
1697
+ return;
1595
1698
  }
1596
- };
1597
- attachedVideos.set(videoElement, controls);
1598
- return controls;
1699
+ const currentVideoWidth = this.video.videoWidth || this.videoWidth;
1700
+ const currentVideoHeight = this.video.videoHeight || this.videoHeight;
1701
+ if (!currentVideoWidth || !currentVideoHeight) {
1702
+ return;
1703
+ }
1704
+ this.videoWidth = currentVideoWidth;
1705
+ this.videoHeight = currentVideoHeight;
1706
+ this.stage.width(this.videoWidth);
1707
+ this.stage.height(this.videoHeight);
1708
+ const rect = this.video.getBoundingClientRect();
1709
+ const stageContainer = this.stage.container();
1710
+ const displayWidth = rect.width || stageContainer.offsetWidth || this.videoWidth;
1711
+ const displayHeight = rect.height || stageContainer.offsetHeight || this.videoHeight;
1712
+ if (displayWidth && displayHeight) {
1713
+ stageContainer.style.width = `${displayWidth}px`;
1714
+ stageContainer.style.height = `${displayHeight}px`;
1715
+ const scaleX = displayWidth / this.videoWidth;
1716
+ const scaleY = displayHeight / this.videoHeight;
1717
+ this.stage.scale({ x: scaleX, y: scaleY });
1718
+ } else {
1719
+ this.stage.scale({ x: 1, y: 1 });
1720
+ stageContainer.style.width = `${this.videoWidth}px`;
1721
+ stageContainer.style.height = `${this.videoHeight}px`;
1722
+ }
1723
+ this.stage.batchDraw();
1724
+ }
1725
+ createOverlay() {
1726
+ const container = document.createElement("div");
1727
+ container.style.position = "absolute";
1728
+ container.style.top = "0";
1729
+ container.style.left = "0";
1730
+ container.style.width = "100%";
1731
+ container.style.height = "100%";
1732
+ container.style.pointerEvents = "none";
1733
+ this.video.parentElement?.appendChild(container);
1734
+ return container;
1735
+ }
1599
1736
  };
1737
+ function captionsjs(options) {
1738
+ return new Captions(options);
1739
+ }
1600
1740
 
1601
1741
  // src/index.ts
1602
1742
  function renderCaptions(ctx, text) {
@@ -1605,9 +1745,11 @@ function renderCaptions(ctx, text) {
1605
1745
  ctx.fillText(text, 150, 50);
1606
1746
  return true;
1607
1747
  }
1748
+ var index_default = captionsjs;
1608
1749
  // Annotate the CommonJS export names for ESM import in node:
1609
1750
  0 && (module.exports = {
1610
- attachToVideo,
1751
+ Captions,
1752
+ captionsjs,
1611
1753
  googleFontsList,
1612
1754
  renderCaptions,
1613
1755
  renderString,
package/dist/index.mjs CHANGED
@@ -912,7 +912,7 @@ async function renderString(canvas, text, options) {
912
912
  return true;
913
913
  }
914
914
 
915
- // src/render/attachToVideo.ts
915
+ // src/captions/Captions.ts
916
916
  import Konva5 from "konva";
917
917
 
918
918
  // src/canvas-captions/index.ts
@@ -1492,71 +1492,209 @@ var renderFrame = (captionsSettings, layoutSettings, captions, currentTime, targ
1492
1492
  layer.draw();
1493
1493
  };
1494
1494
 
1495
- // src/render/attachToVideo.ts
1496
- var attachedVideos = /* @__PURE__ */ new WeakMap();
1497
- var attachToVideo = (videoElement, container, options) => {
1498
- if (!container) {
1499
- container = document.createElement("div");
1500
- container.style.position = "absolute";
1501
- container.style.top = "0";
1502
- container.style.left = "0";
1503
- container.style.width = "100%";
1504
- container.style.height = "100%";
1505
- container.style.pointerEvents = "none";
1506
- videoElement.parentElement?.appendChild(container);
1495
+ // src/captions/Captions.ts
1496
+ var Captions = class {
1497
+ /**
1498
+ * Create a controller bound to the provided video element and preset.
1499
+ *
1500
+ * @param options - Complete configuration for the controller.
1501
+ */
1502
+ constructor(options) {
1503
+ this.enabled = false;
1504
+ this.ownsContainer = false;
1505
+ this.stage = null;
1506
+ this.layer = null;
1507
+ this.animationFrameId = null;
1508
+ this.videoWidth = 0;
1509
+ this.videoHeight = 0;
1510
+ this.handleResize = () => {
1511
+ this.syncStageDimensions();
1512
+ };
1513
+ this.handleMetadata = () => {
1514
+ this.syncStageDimensions();
1515
+ };
1516
+ this.animationLoop = () => {
1517
+ this.updateFrame();
1518
+ this.animationFrameId = requestAnimationFrame(this.animationLoop);
1519
+ };
1520
+ if (!options.video) {
1521
+ throw new Error("captionsjs requires a video element");
1522
+ }
1523
+ this.video = options.video;
1524
+ this.providedContainer = options.container;
1525
+ this.presetState = options.preset;
1526
+ this.captionsState = options.captions ?? null;
1527
+ if (options.autoEnable ?? true) {
1528
+ this.enable();
1529
+ }
1507
1530
  }
1508
- const stage = new Konva5.Stage({
1509
- container,
1510
- width: videoElement.videoWidth,
1511
- height: videoElement.videoHeight
1512
- });
1513
- const layer = new Konva5.Layer();
1514
- stage.add(layer);
1515
- stage.width(container.clientWidth);
1516
- stage.height(container.clientHeight);
1517
- const update = () => {
1518
- console.log(
1519
- "update captions at",
1520
- options.preset.captionsSettings.style.name
1521
- );
1522
- layer.destroyChildren();
1531
+ /**
1532
+ * Mount caption overlays onto the configured video if they are not active yet.
1533
+ */
1534
+ enable() {
1535
+ if (this.enabled) {
1536
+ return;
1537
+ }
1538
+ this.containerElement = this.providedContainer ?? this.createOverlay();
1539
+ this.ownsContainer = !this.providedContainer;
1540
+ this.stage = new Konva5.Stage({
1541
+ container: this.containerElement
1542
+ });
1543
+ this.layer = new Konva5.Layer();
1544
+ this.stage.add(this.layer);
1545
+ this.videoWidth = this.video.videoWidth;
1546
+ this.videoHeight = this.video.videoHeight;
1547
+ window.addEventListener("resize", this.handleResize);
1548
+ if (typeof ResizeObserver !== "undefined") {
1549
+ this.resizeObserver = new ResizeObserver(this.handleResize);
1550
+ this.resizeObserver.observe(this.video);
1551
+ }
1552
+ this.video.addEventListener("loadedmetadata", this.handleMetadata);
1553
+ this.syncStageDimensions();
1554
+ this.animationFrameId = requestAnimationFrame(this.animationLoop);
1555
+ this.enabled = true;
1556
+ void this.refreshFrame();
1557
+ }
1558
+ /**
1559
+ * Tear down overlays, observers and animation loops to free resources.
1560
+ */
1561
+ disable() {
1562
+ if (!this.enabled) {
1563
+ return;
1564
+ }
1565
+ if (this.animationFrameId !== null) {
1566
+ cancelAnimationFrame(this.animationFrameId);
1567
+ this.animationFrameId = null;
1568
+ }
1569
+ window.removeEventListener("resize", this.handleResize);
1570
+ this.video.removeEventListener("loadedmetadata", this.handleMetadata);
1571
+ this.resizeObserver?.disconnect();
1572
+ this.resizeObserver = void 0;
1573
+ if (this.ownsContainer && this.containerElement?.parentElement) {
1574
+ this.containerElement.parentElement.removeChild(this.containerElement);
1575
+ }
1576
+ this.stage?.destroy();
1577
+ this.stage = null;
1578
+ this.layer = null;
1579
+ this.containerElement = void 0;
1580
+ this.ownsContainer = false;
1581
+ this.enabled = false;
1582
+ }
1583
+ /**
1584
+ * Alias for {@link Captions.disable | disable()} to match typical imperative controller APIs.
1585
+ */
1586
+ destroy() {
1587
+ this.disable();
1588
+ }
1589
+ /**
1590
+ * Swap the active preset and re-render with updated typography/colors.
1591
+ *
1592
+ * @param nextPreset - Preset that becomes the new render baseline.
1593
+ */
1594
+ preset(nextPreset) {
1595
+ this.presetState = nextPreset;
1596
+ if (!this.enabled) {
1597
+ return;
1598
+ }
1599
+ void this.refreshFrame();
1600
+ }
1601
+ /**
1602
+ * Replace the current caption track and repaint without reloading fonts.
1603
+ *
1604
+ * @param nextCaptions - Timed words that should drive the overlay.
1605
+ */
1606
+ captions(nextCaptions) {
1607
+ this.captionsState = nextCaptions;
1608
+ if (!this.enabled) {
1609
+ return;
1610
+ }
1611
+ void this.refreshFrame(false);
1612
+ }
1613
+ /**
1614
+ * Whether the Konva overlay is currently attached to the video element.
1615
+ *
1616
+ * @returns `true` when the overlay is mounted on top of the video.
1617
+ */
1618
+ isEnabled() {
1619
+ return this.enabled;
1620
+ }
1621
+ async refreshFrame(loadFont = true) {
1622
+ if (!this.layer || !this.stage) {
1623
+ return;
1624
+ }
1625
+ if (loadFont) {
1626
+ await this.loadFontForCurrentPreset();
1627
+ }
1628
+ this.updateFrame();
1629
+ }
1630
+ async loadFontForCurrentPreset() {
1631
+ const fontFamily = this.presetState.captionsSettings.style.font.fontFamily;
1632
+ await loadGoogleFont2(fontFamily);
1633
+ }
1634
+ updateFrame() {
1635
+ if (!this.layer || !this.stage) {
1636
+ return;
1637
+ }
1638
+ if (!this.videoWidth || !this.videoHeight) {
1639
+ return;
1640
+ }
1641
+ this.layer.destroyChildren();
1523
1642
  renderFrame(
1524
- options.preset.captionsSettings,
1643
+ this.presetState.captionsSettings,
1525
1644
  void 0,
1526
- options.captions || [],
1527
- videoElement.currentTime,
1528
- [640, 360],
1529
- layer,
1645
+ this.captionsState || [],
1646
+ this.video.currentTime,
1647
+ [this.videoWidth, this.videoHeight],
1648
+ this.layer,
1530
1649
  1,
1531
1650
  { type: "bottom", positionTopOffset: 50 }
1532
1651
  );
1533
- };
1534
- let animationFrameId;
1535
- const animationLoop = () => {
1536
- update();
1537
- animationFrameId = requestAnimationFrame(animationLoop);
1538
- };
1539
- animationLoop();
1540
- const controls = {
1541
- detach: () => {
1542
- cancelAnimationFrame(animationFrameId);
1543
- if (container && container.parentElement) {
1544
- container.parentElement.removeChild(container);
1545
- }
1546
- stage.destroy();
1547
- attachedVideos.delete(videoElement);
1548
- },
1549
- update: async (newOptions) => {
1550
- options = { ...options, ...newOptions };
1551
- await loadGoogleFont2(
1552
- options.preset.captionsSettings.style.font.fontFamily
1553
- );
1554
- update();
1652
+ }
1653
+ syncStageDimensions() {
1654
+ if (!this.stage) {
1655
+ return;
1555
1656
  }
1556
- };
1557
- attachedVideos.set(videoElement, controls);
1558
- return controls;
1657
+ const currentVideoWidth = this.video.videoWidth || this.videoWidth;
1658
+ const currentVideoHeight = this.video.videoHeight || this.videoHeight;
1659
+ if (!currentVideoWidth || !currentVideoHeight) {
1660
+ return;
1661
+ }
1662
+ this.videoWidth = currentVideoWidth;
1663
+ this.videoHeight = currentVideoHeight;
1664
+ this.stage.width(this.videoWidth);
1665
+ this.stage.height(this.videoHeight);
1666
+ const rect = this.video.getBoundingClientRect();
1667
+ const stageContainer = this.stage.container();
1668
+ const displayWidth = rect.width || stageContainer.offsetWidth || this.videoWidth;
1669
+ const displayHeight = rect.height || stageContainer.offsetHeight || this.videoHeight;
1670
+ if (displayWidth && displayHeight) {
1671
+ stageContainer.style.width = `${displayWidth}px`;
1672
+ stageContainer.style.height = `${displayHeight}px`;
1673
+ const scaleX = displayWidth / this.videoWidth;
1674
+ const scaleY = displayHeight / this.videoHeight;
1675
+ this.stage.scale({ x: scaleX, y: scaleY });
1676
+ } else {
1677
+ this.stage.scale({ x: 1, y: 1 });
1678
+ stageContainer.style.width = `${this.videoWidth}px`;
1679
+ stageContainer.style.height = `${this.videoHeight}px`;
1680
+ }
1681
+ this.stage.batchDraw();
1682
+ }
1683
+ createOverlay() {
1684
+ const container = document.createElement("div");
1685
+ container.style.position = "absolute";
1686
+ container.style.top = "0";
1687
+ container.style.left = "0";
1688
+ container.style.width = "100%";
1689
+ container.style.height = "100%";
1690
+ container.style.pointerEvents = "none";
1691
+ this.video.parentElement?.appendChild(container);
1692
+ return container;
1693
+ }
1559
1694
  };
1695
+ function captionsjs(options) {
1696
+ return new Captions(options);
1697
+ }
1560
1698
 
1561
1699
  // src/index.ts
1562
1700
  function renderCaptions(ctx, text) {
@@ -1565,8 +1703,11 @@ function renderCaptions(ctx, text) {
1565
1703
  ctx.fillText(text, 150, 50);
1566
1704
  return true;
1567
1705
  }
1706
+ var index_default = captionsjs;
1568
1707
  export {
1569
- attachToVideo,
1708
+ Captions,
1709
+ captionsjs,
1710
+ index_default as default,
1570
1711
  googleFontsList,
1571
1712
  renderCaptions,
1572
1713
  renderString,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "captions.js",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,12 +30,6 @@
30
30
  "require": "./dist/index.js"
31
31
  }
32
32
  },
33
- "scripts": {
34
- "build": "tsup src/index.ts --format esm,cjs --dts",
35
- "dev": "tsup src/index.ts --format esm,cjs --dts --watch",
36
- "prepublishOnly": "cp ../../README.md ./README.md && pnpm run build",
37
- "postpublish": "rm -f README.md"
38
- },
39
33
  "devDependencies": {
40
34
  "@types/object-hash": "^3.0.6",
41
35
  "tsup": "^8.0.0",
@@ -44,5 +38,9 @@
44
38
  "dependencies": {
45
39
  "konva": "^10.0.2",
46
40
  "object-hash": "^3.0.0"
41
+ },
42
+ "scripts": {
43
+ "build": "tsup src/index.ts --format esm,cjs --dts",
44
+ "dev": "tsup src/index.ts --format esm,cjs --dts --watch"
47
45
  }
48
- }
46
+ }