@glissade/narrate 0.67.0 → 0.68.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +107 -19
  2. package/dist/index.js +140 -19
  3. package/package.json +3 -3
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AssetRef, AudioClip, Key, Track } from "@glissade/core";
2
- import { FilterSpec, Text } from "@glissade/scene";
2
+ import { FilterSpec, Text, TextMeasurer } from "@glissade/scene";
3
3
  import { SafeArea } from "@glissade/scene/diagnostics";
4
4
 
5
5
  //#region src/index.d.ts
@@ -86,14 +86,13 @@ interface NarrationScript {
86
86
  */
87
87
  align?: string;
88
88
  /**
89
- * Split long caption segments into timed sub-cues at ~`maxChars` (on word
90
- * boundaries, using per-word timings when present). Persisted into the timing
91
- * manifest so the burned track and the .srt/.vtt sidecars split identically.
89
+ * Split long caption segments into timed sub-cues. `{ maxChars }` = the legacy
90
+ * char-budget word-boundary split; `{ mode: 'band' }` = the measured band-fit split
91
+ * (per-locale sentence→clause→word, needs the render size/style via captionTrack's
92
+ * opts). Persisted so the burned track and the .srt/.vtt sidecars split identically.
92
93
  * Omit for no split (the default).
93
94
  */
94
- captionSplit?: {
95
- maxChars: number;
96
- };
95
+ captionSplit?: CaptionSplitPolicy;
97
96
  /**
98
97
  * Anchor budgets (s): per-id ceilings on a segment's (or pause's) beat length,
99
98
  * checked by `gs narration-lint` (Tier-1, can fail CI). Committed with the
@@ -165,10 +164,8 @@ interface NarrationTiming {
165
164
  segments: TimedSegment[];
166
165
  /** explicit pause windows, addressable like segments; omitted when none */
167
166
  pauses?: TimedPause[];
168
- /** caption split budget, committed so burned + sidecar split identically */
169
- captionSplit?: {
170
- maxChars: number;
171
- };
167
+ /** caption split policy, committed so burned + sidecar split identically */
168
+ captionSplit?: CaptionSplitPolicy;
172
169
  /**
173
170
  * Anchor budgets (s) carried from the script — per id, segments + pauses.
174
171
  * `gs narration-lint` reads them from the committed manifest (Tier-1). A
@@ -240,6 +237,23 @@ interface CaptionTrackOptions {
240
237
  target?: string;
241
238
  /** v1 granularity is per segment; 'word' is reserved (karaoke highlight, later) */
242
239
  granularity?: 'segment';
240
+ /**
241
+ * Render context for `captionSplit: { mode: 'band' }` — the SAME `size` + `style`
242
+ * the paired `captionNode(size, style)` uses, so the band-fit split measures what
243
+ * the render lays out (measure-consistency). REQUIRED when the policy is band mode
244
+ * (throws without `size`); ignored for the legacy `{ maxChars }` path.
245
+ */
246
+ size?: {
247
+ w: number;
248
+ h: number;
249
+ };
250
+ style?: CaptionStyle;
251
+ /** the measurer the render lays out with (band mode) — defaults to the process fallback. */
252
+ measurer?: TextMeasurer;
253
+ /** band-mode measurer-fail-loud opt-out (LOUD: an estimate-split may overflow at render). */
254
+ estimate?: boolean;
255
+ /** band-mode boundary locale (falls back to the policy's `locale`). */
256
+ locale?: string;
243
257
  }
244
258
  /** One caption cue within a segment's window. */
245
259
  interface CaptionCue {
@@ -247,6 +261,23 @@ interface CaptionCue {
247
261
  start: number;
248
262
  end: number;
249
263
  }
264
+ /**
265
+ * How to split long caption segments into sub-cues:
266
+ * - `{ maxChars }` — the legacy char-budget, word-boundary split ({@link splitCaption}).
267
+ * - `{ mode: 'band', locale? }` — the measured band-fit split ({@link captionAutoSplit}):
268
+ * breaks at per-locale sentence → clause → word so every cue fits the caption band at
269
+ * the min-legible floor. Needs the render context (size + style) supplied via
270
+ * {@link CaptionTrackOptions}. `maxLines` / `minLegiblePx` deliberately live in
271
+ * {@link CaptionStyle} (NOT here) — they must match the paired `captionNode`'s layout
272
+ * for measure-consistency, so there is ONE source of truth for them; only `locale`
273
+ * (a split-only, render-irrelevant concern) rides the policy.
274
+ */
275
+ type CaptionSplitPolicy = {
276
+ maxChars: number;
277
+ } | {
278
+ mode: 'band';
279
+ locale?: string;
280
+ };
250
281
  /**
251
282
  * Split a segment's caption into timed sub-cues at ~`maxChars` (word-boundary).
252
283
  * With per-word timings, each sub-cue is timed from its first word; without
@@ -285,16 +316,73 @@ interface CaptionStyle {
285
316
  maxLines?: number;
286
317
  /** floor for auto-shrink, as a fraction of the base font size (autoFit only); default 0.7 */
287
318
  minScale?: number;
319
+ /**
320
+ * Absolute legibility floor in px — the auto-shrink (and the band-split fit)
321
+ * never go below this, on top of the `minScale` fraction. Off by default
322
+ * (byte-identical), so the floor stays `round(baseFont·minScale)` unless set.
323
+ * `captionAutoSplit` splits so every cue fits at this same floor.
324
+ */
325
+ minLegiblePx?: number;
288
326
  }
289
- /**
290
- * Bottom-centered captions inside the platform-safe area: portrait scenes
291
- * (9:16 cutdowns live under reels/shorts UI chrome) sit higher than
292
- * landscape. Same node id pairs with captionTrack's default target.
293
- */
294
327
  declare function captionNode(size: {
295
328
  w: number;
296
329
  h: number;
297
330
  }, style?: CaptionStyle): Text;
331
+ /**
332
+ * Thrown by {@link captionAutoSplit} when a single word in a cue is too wide to fit
333
+ * the caption band even at the min-legible floor — it can't be split further, so the
334
+ * author must intervene rather than the caption silently degrading legibility or
335
+ * dropping words (a hard throw, no clamp). Names the word + its segment id + the fixes
336
+ * in priority order: reword/shorten the word FIRST (it is almost always a URL or a long
337
+ * token), and only as a last resort — since they trade the legibility the split exists
338
+ * to protect — widen the band or lower the min font.
339
+ */
340
+ declare class CaptionFitError extends Error {
341
+ readonly word: string;
342
+ readonly segmentId: string;
343
+ readonly bandWidth: number;
344
+ readonly minFontPx: number;
345
+ constructor(word: string, segmentId: string, bandWidth: number, minFontPx: number);
346
+ }
347
+ /**
348
+ * Band-fit caption auto-split options. The render CONTEXT so the split's fit-measure
349
+ * matches the render's layout (measure-consistency): `size` + `style` MUST be the same
350
+ * a paired `captionNode(size, style)` uses — both derive the band via `captionBandParams`.
351
+ */
352
+ interface CaptionAutoSplitOpts {
353
+ /** the scene size the caption renders into (drives band width + base font). */
354
+ size: {
355
+ w: number;
356
+ h: number;
357
+ };
358
+ /** the SAME style passed to captionNode (widthFrac / fontSize / minScale / minLegiblePx / maxLines). */
359
+ style?: CaptionStyle;
360
+ /**
361
+ * the measurer the RENDER lays out with — MUST be the same for measure-consistency.
362
+ * Defaults to the process fallback (fails loud without a real one — see `estimate`).
363
+ */
364
+ measurer?: TextMeasurer;
365
+ /**
366
+ * measurer-fail-loud opt-out, and LOUD for captions: an estimate-split may OVERFLOW
367
+ * the band when rendered with real metrics (cues fit only if the render also
368
+ * estimates). Prefer a real measurer for a guaranteed fit. Default false (fail loud).
369
+ */
370
+ estimate?: boolean;
371
+ /** BCP-47 locale for per-locale boundary detection (sentence + word segmentation). */
372
+ locale?: string;
373
+ }
374
+ /**
375
+ * Split a segment's caption into timed sub-cues that each FIT the caption band at the
376
+ * min-legible floor — the measured, band-aware alternative to {@link splitCaption}'s
377
+ * char-count budget. Splits at per-locale **sentence → clause → word** boundaries (via
378
+ * `splitToFit`), so a long line breaks at meaningful points, never mid-thought. A cue
379
+ * that already fits stays whole (a short caption is a single cue → the default is a
380
+ * no-op). Sub-cue timing mirrors splitCaption: with per-word timings each cue starts at
381
+ * its first word; without them the segment window divides evenly. Throws
382
+ * {@link CaptionFitError} when a single word can't fit even alone. Pure / build-time
383
+ * (like splitText) — the fit measurer never enters the render or cert hash.
384
+ */
385
+ declare function captionAutoSplit(segment: TimedSegment, opts: CaptionAutoSplitOpts): CaptionCue[];
298
386
  /**
299
387
  * The TOP (device px) of the reserved caption band — the Y above which foreground
300
388
  * art is safe and below which it collides with captions. Derived from `captionNode`'s
@@ -415,7 +503,7 @@ interface MusicAnchors {
415
503
  declare function validateMusicTiming(timing: MusicTiming): void;
416
504
  /** Beat-grid anchors over a music manifest; `at` places the clip on the timeline. */
417
505
  declare function music(timing: MusicTiming, at?: number): MusicAnchors;
418
- declare function toSrt(timing: NarrationTiming): string;
419
- declare function toVtt(timing: NarrationTiming): string;
506
+ declare function toSrt(timing: NarrationTiming, ctx?: Pick<CaptionTrackOptions, 'size' | 'style' | 'measurer' | 'estimate' | 'locale'>): string;
507
+ declare function toVtt(timing: NarrationTiming, ctx?: Pick<CaptionTrackOptions, 'size' | 'style' | 'measurer' | 'estimate' | 'locale'>): string;
420
508
  //#endregion
421
- export { BedMode, CAPTION_NODE_ID, CaptionCue, CaptionStyle, CaptionTrackOptions, DuckOptions, MusicAnchors, MusicClipOptions, MusicTiming, NarrationAnchors, NarrationElement, NarrationError, NarrationPause, NarrationScript, NarrationSegment, NarrationTiming, TimedPause, TimedSegment, TimedWord, VoiceBlend, VoiceBlendEntry, VoiceSpec, captionNode, captionSafeArea, captionTop, captionTrack, duckEnvelope, isPause, isVoiceBlend, music, narration, splitCaption, toSrt, toVtt, validateMusicTiming };
509
+ export { BedMode, CAPTION_NODE_ID, CaptionAutoSplitOpts, CaptionCue, CaptionFitError, CaptionSplitPolicy, CaptionStyle, CaptionTrackOptions, DuckOptions, MusicAnchors, MusicClipOptions, MusicTiming, NarrationAnchors, NarrationElement, NarrationError, NarrationPause, NarrationScript, NarrationSegment, NarrationTiming, TimedPause, TimedSegment, TimedWord, VoiceBlend, VoiceBlendEntry, VoiceSpec, captionAutoSplit, captionNode, captionSafeArea, captionTop, captionTrack, duckEnvelope, isPause, isVoiceBlend, music, narration, splitCaption, toSrt, toVtt, validateMusicTiming };
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { key, track } from "@glissade/core";
2
2
  import { Text, breakLines, estimatingMeasurer, glow, quantize } from "@glissade/scene";
3
3
  import "@glissade/scene/diagnostics";
4
+ import { TextFitError, splitToFit } from "@glissade/scene/type";
4
5
  //#region src/index.ts
5
6
  /**
6
7
  * @glissade/narrate — narration + captions, the PURE side. TTS happens only
@@ -164,34 +165,58 @@ function splitCaption(segment, maxChars) {
164
165
  * are subtree-matched exempt in CAPTION_COLLISION).
165
166
  */
166
167
  const CAPTION_NODE_ID = "captions";
168
+ /**
169
+ * Dispatch a segment to its cues by the {@link CaptionSplitPolicy}: band mode →
170
+ * {@link captionAutoSplit} (needs the render `size`/`style` from `ctx`, fails loud
171
+ * without `size`); `{ maxChars }` or absent → {@link splitCaption} (byte-identical to
172
+ * pre-0.68). The SAME dispatch drives the burned track AND the .srt/.vtt sidecars, so
173
+ * they match by construction.
174
+ */
175
+ function segmentCues(segment, policy, ctx) {
176
+ if (policy && "mode" in policy && policy.mode === "band") {
177
+ if (!ctx.size) throw new Error("captionTrack: captionSplit { mode: 'band' } needs the render size — pass { size } (and the same { style } you pass to captionNode) so the band-fit split matches the caption band.");
178
+ const locale = policy.locale ?? ctx.locale;
179
+ return captionAutoSplit(segment, {
180
+ size: ctx.size,
181
+ ...ctx.style !== void 0 ? { style: ctx.style } : {},
182
+ ...ctx.measurer !== void 0 ? { measurer: ctx.measurer } : {},
183
+ ...ctx.estimate !== void 0 ? { estimate: ctx.estimate } : {},
184
+ ...locale !== void 0 ? { locale } : {}
185
+ });
186
+ }
187
+ return splitCaption(segment, policy && "maxChars" in policy ? policy.maxChars : void 0);
188
+ }
167
189
  function captionTrack(timing, opts = {}) {
168
190
  const target = opts.target ?? `captions/text`;
169
- const budget = timing.captionSplit?.maxChars;
170
191
  const keys = [key(0, "", { interp: "hold" })];
171
192
  let cursor = 0;
172
193
  for (const s of timing.segments) {
173
194
  if (s.start > cursor + 1e-9) {
174
195
  if (keys[keys.length - 1].value !== "") keys.push(key(cursor, "", { interp: "hold" }));
175
196
  }
176
- for (const cue of splitCaption(s, budget)) if (cue.start <= 1e-9) keys[0] = key(0, cue.text, { interp: "hold" });
197
+ for (const cue of segmentCues(s, timing.captionSplit, opts)) if (cue.start <= 1e-9) keys[0] = key(0, cue.text, { interp: "hold" });
177
198
  else keys.push(key(cue.start, cue.text, { interp: "hold" }));
178
199
  cursor = s.start + s.duration;
179
200
  }
180
201
  keys.push(key(cursor, "", { interp: "hold" }));
181
202
  return track(target, "string", keys);
182
203
  }
183
- /**
184
- * Bottom-centered captions inside the platform-safe area: portrait scenes
185
- * (9:16 cutdowns live under reels/shorts UI chrome) sit higher than
186
- * landscape. Same node id pairs with captionTrack's default target.
187
- */
188
- function captionNode(size, style = {}) {
204
+ function captionBandParams(size, style) {
189
205
  const portrait = size.h > size.w;
190
- const inset = style.bottomInsetFrac ?? (portrait ? .18 : .1);
191
206
  const baseFont = style.fontSize ?? Math.round(Math.min(size.w, size.h) * (portrait ? .052 : .06));
192
- const fontFamily = style.fontFamily ?? "sans-serif";
193
- const width = Math.round(size.w * (style.widthFrac ?? .82));
194
- const lineHeight = style.lineHeight ?? 1.3;
207
+ return {
208
+ portrait,
209
+ fontFamily: style.fontFamily ?? "sans-serif",
210
+ baseFont,
211
+ width: Math.round(size.w * (style.widthFrac ?? .82)),
212
+ lineHeight: style.lineHeight ?? 1.3,
213
+ maxLines: Math.max(1, style.maxLines ?? 2),
214
+ minFont: Math.max(1, style.minLegiblePx ?? 0, Math.round(baseFont * (style.minScale ?? .7)))
215
+ };
216
+ }
217
+ function captionNode(size, style = {}) {
218
+ const { portrait, fontFamily, baseFont, width, lineHeight, maxLines, minFont } = captionBandParams(size, style);
219
+ const inset = style.bottomInsetFrac ?? (portrait ? .18 : .1);
195
220
  const bottomY = Math.round(size.h * (1 - inset));
196
221
  const node = new Text({
197
222
  id: CAPTION_NODE_ID,
@@ -206,8 +231,6 @@ function captionNode(size, style = {}) {
206
231
  filters: style.filters ?? glow("#000000cc", 3, 1)
207
232
  });
208
233
  if (style.autoFit) {
209
- const maxLines = Math.max(1, style.maxLines ?? 2);
210
- const minFont = Math.max(1, Math.round(baseFont * (style.minScale ?? .7)));
211
234
  const lineCountAt = (font, m) => {
212
235
  const t = node.text();
213
236
  if (!t) return 0;
@@ -235,6 +258,89 @@ function captionNode(size, style = {}) {
235
258
  return node;
236
259
  }
237
260
  /**
261
+ * Thrown by {@link captionAutoSplit} when a single word in a cue is too wide to fit
262
+ * the caption band even at the min-legible floor — it can't be split further, so the
263
+ * author must intervene rather than the caption silently degrading legibility or
264
+ * dropping words (a hard throw, no clamp). Names the word + its segment id + the fixes
265
+ * in priority order: reword/shorten the word FIRST (it is almost always a URL or a long
266
+ * token), and only as a last resort — since they trade the legibility the split exists
267
+ * to protect — widen the band or lower the min font.
268
+ */
269
+ var CaptionFitError = class extends Error {
270
+ word;
271
+ segmentId;
272
+ bandWidth;
273
+ minFontPx;
274
+ constructor(word, segmentId, bandWidth, minFontPx) {
275
+ super(`captionAutoSplit: segment ${JSON.stringify(segmentId)} has a word ${JSON.stringify(word)} too wide to fit the ${bandWidth}px caption band at the ${minFontPx}px min-legible floor and cannot be split further — reword/shorten the word, or (last resort, trades legibility) widen the caption widthFrac or lower minScale/minLegiblePx.`);
276
+ this.word = word;
277
+ this.segmentId = segmentId;
278
+ this.bandWidth = bandWidth;
279
+ this.minFontPx = minFontPx;
280
+ this.name = "CaptionFitError";
281
+ }
282
+ };
283
+ /**
284
+ * Split a segment's caption into timed sub-cues that each FIT the caption band at the
285
+ * min-legible floor — the measured, band-aware alternative to {@link splitCaption}'s
286
+ * char-count budget. Splits at per-locale **sentence → clause → word** boundaries (via
287
+ * `splitToFit`), so a long line breaks at meaningful points, never mid-thought. A cue
288
+ * that already fits stays whole (a short caption is a single cue → the default is a
289
+ * no-op). Sub-cue timing mirrors splitCaption: with per-word timings each cue starts at
290
+ * its first word; without them the segment window divides evenly. Throws
291
+ * {@link CaptionFitError} when a single word can't fit even alone. Pure / build-time
292
+ * (like splitText) — the fit measurer never enters the render or cert hash.
293
+ */
294
+ function captionAutoSplit(segment, opts) {
295
+ const { fontFamily, width, maxLines, minFont } = captionBandParams(opts.size, opts.style ?? {});
296
+ const font = {
297
+ family: fontFamily,
298
+ size: minFont,
299
+ weight: 400
300
+ };
301
+ const end = segment.start + segment.duration;
302
+ let parts;
303
+ try {
304
+ parts = splitToFit(segment.text, {
305
+ maxWidth: width,
306
+ font,
307
+ maxLines,
308
+ ...opts.measurer !== void 0 ? { measurer: opts.measurer } : {},
309
+ ...opts.estimate !== void 0 ? { estimate: opts.estimate } : {},
310
+ ...opts.locale !== void 0 ? { locale: opts.locale } : {}
311
+ });
312
+ } catch (e) {
313
+ if (e instanceof TextFitError) throw new CaptionFitError(e.token, segment.id, width, minFont);
314
+ throw e;
315
+ }
316
+ if (parts.length <= 1) return [{
317
+ text: segment.text,
318
+ start: segment.start,
319
+ end
320
+ }];
321
+ if (segment.words && segment.words.length > 0) {
322
+ const cues = [];
323
+ let wi = 0;
324
+ for (let pi = 0; pi < parts.length; pi++) {
325
+ const startWord = segment.words[Math.min(wi, segment.words.length - 1)];
326
+ wi += Math.max(1, parts[pi].split(/\s+/).filter(Boolean).length);
327
+ const cueEnd = pi + 1 < parts.length && wi < segment.words.length ? segment.words[wi].start : end;
328
+ cues.push({
329
+ text: parts[pi],
330
+ start: startWord.start,
331
+ end: cueEnd
332
+ });
333
+ }
334
+ return cues;
335
+ }
336
+ const span = segment.duration / parts.length;
337
+ return parts.map((text, i) => ({
338
+ text,
339
+ start: segment.start + i * span,
340
+ end: segment.start + (i + 1) * span
341
+ }));
342
+ }
343
+ /**
238
344
  * The TOP (device px) of the reserved caption band — the Y above which foreground
239
345
  * art is safe and below which it collides with captions. Derived from `captionNode`'s
240
346
  * OWN layout: the caption baseline sits at `h·(1−inset)` (inset 0.10 landscape /
@@ -426,11 +532,26 @@ function srtTime(t, sep) {
426
532
  const p = (n, w) => String(n).padStart(w, "0");
427
533
  return `${p(h, 2)}:${p(m, 2)}:${p(s, 2)}${sep}${p(f, 3)}`;
428
534
  }
429
- function toSrt(timing) {
430
- return timing.segments.flatMap((s) => splitCaption(s, timing.captionSplit?.maxChars)).map((c, i) => `${i + 1}\n${srtTime(c.start, ",")} --> ${srtTime(c.end, ",")}\n${c.text}`).join("\n\n") + "\n";
535
+ /**
536
+ * Sidecar cues, tolerant of band mode: with a render `ctx` (size/style) band mode
537
+ * splits exactly like the burned track (burned == sidecar); WITHOUT one — a text
538
+ * sidecar has no pixel band — a band-mode segment stays a single cue (the split is a
539
+ * burned-caption concern). The legacy `{ maxChars }` path needs no context.
540
+ */
541
+ function sidecarCues(timing, ctx) {
542
+ const policy = timing.captionSplit;
543
+ const bandNoContext = policy !== void 0 && "mode" in policy && !ctx.size;
544
+ return timing.segments.flatMap((s) => bandNoContext ? [{
545
+ text: s.text,
546
+ start: s.start,
547
+ end: s.start + s.duration
548
+ }] : segmentCues(s, policy, ctx));
549
+ }
550
+ function toSrt(timing, ctx = {}) {
551
+ return sidecarCues(timing, ctx).map((c, i) => `${i + 1}\n${srtTime(c.start, ",")} --> ${srtTime(c.end, ",")}\n${c.text}`).join("\n\n") + "\n";
431
552
  }
432
- function toVtt(timing) {
433
- return "WEBVTT\n\n" + timing.segments.flatMap((s) => splitCaption(s, timing.captionSplit?.maxChars)).map((c) => `${srtTime(c.start, ".")} --> ${srtTime(c.end, ".")}\n${c.text}`).join("\n\n") + "\n";
553
+ function toVtt(timing, ctx = {}) {
554
+ return "WEBVTT\n\n" + sidecarCues(timing, ctx).map((c) => `${srtTime(c.start, ".")} --> ${srtTime(c.end, ".")}\n${c.text}`).join("\n\n") + "\n";
434
555
  }
435
556
  //#endregion
436
- export { CAPTION_NODE_ID, NarrationError, captionNode, captionSafeArea, captionTop, captionTrack, duckEnvelope, isPause, isVoiceBlend, music, narration, splitCaption, toSrt, toVtt, validateMusicTiming };
557
+ export { CAPTION_NODE_ID, CaptionFitError, NarrationError, captionAutoSplit, captionNode, captionSafeArea, captionTop, captionTrack, duckEnvelope, isPause, isVoiceBlend, music, narration, splitCaption, toSrt, toVtt, validateMusicTiming };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/narrate",
3
- "version": "0.67.0",
3
+ "version": "0.68.0",
4
4
  "description": "glissade narration + captions: TTS at prepare time (gs narrate), deterministic caching, narration-anchored timeline beats, and captions as plain tracks. Render stays offline.",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -22,8 +22,8 @@
22
22
  "dist"
23
23
  ],
24
24
  "dependencies": {
25
- "@glissade/core": "0.67.0",
26
- "@glissade/scene": "0.67.0"
25
+ "@glissade/core": "0.68.0",
26
+ "@glissade/scene": "0.68.0"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "kokoro-js": "^1.2.0"