pdfjs-reader-core 0.4.3 → 0.5.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/README.md CHANGED
@@ -1278,6 +1278,7 @@ default. Clicking it clears every overlay and returns the camera to fit-page.
1278
1278
  | `backgroundColor` | `string` | `'#ffffff'` | Surround colour visible around the PDF when the viewport is larger than the page fit. v0.4.1+ |
1279
1279
  | `loadingComponent` | `ReactNode` | default spinner | Custom loading state shown while the PDF document/page is still fetching. v0.4.1+ |
1280
1280
  | `onPageChange` | `(page: number) => void` | — | Called when the viewer's page changes from any source (agent API, sidebar, programmatic). Pair with the `pageNumber` prop for bidirectional sync. **Required** when agents call `goToPage`/`nextPage`/`previousPage`. v0.4.2+ |
1281
+ | `storyboardProvider` | `(input) => Promise<Storyboard \| null>` | — | Consumer-owned director — when provided, called per chunk INSTEAD of the built-in LLM director. Your backend owns the system prompt + bbox context and returns the storyboard JSON. v0.5.0+ |
1281
1282
  | `className` | `string` | — | Passes through to the root container for custom theming |
1282
1283
 
1283
1284
  ### LlmConfig
@@ -1299,6 +1300,56 @@ interface LlmConfig {
1299
1300
  multiple consumers and each owns its own inference endpoint. Pass the URL as
1300
1301
  a prop at the call site, sourced from an env var or runtime config.
1301
1302
 
1303
+ ### Alternative: `storyboardProvider` (v0.5.0+)
1304
+
1305
+ When your backend owns the system prompt and bbox context, use
1306
+ `storyboardProvider` instead of `llm`. The library will call your provider per
1307
+ chunk, validate its return value against `StoryboardSchema`, and execute the
1308
+ result.
1309
+
1310
+ ```tsx
1311
+ <TutorModeContainer
1312
+ pageNumber={currentPage}
1313
+ bboxData={bboxData}
1314
+ narrationStore={storeRef.current}
1315
+ currentChunk={currentChunk}
1316
+ storyboardProvider={async ({ chunk, pageNumber, signal }) => {
1317
+ // Your director endpoint already has bbox server-side. Send just
1318
+ // the chunk + page number and get the storyboard back.
1319
+ const res = await fetch('/director', {
1320
+ method: 'POST',
1321
+ body: JSON.stringify({ chunk, pageNumber }),
1322
+ signal,
1323
+ });
1324
+ if (!res.ok) return null; // library skips this chunk gracefully
1325
+ return res.json(); // must match StoryboardSchema
1326
+ }}
1327
+ />
1328
+ ```
1329
+
1330
+ **Why use this instead of `llm`?**
1331
+ - You iterate on the system prompt without a library upgrade.
1332
+ - You choose the model (fine-tuned, OSS, hosted, local) without vendor lock.
1333
+ - You cache bbox server-side; the browser sends only the chunk + page number.
1334
+ - Works great with KV-cache / prefix caching on the director model.
1335
+
1336
+ **What the library still handles regardless of path:**
1337
+ - `StoryboardSchema` validation of the returned JSON
1338
+ - Range clamping of out-of-bounds numeric fields
1339
+ - `enforceOverlayPresence` auto-pulse if the storyboard is camera-only
1340
+ - Engine scheduling (per-step `setTimeout` at `at_ms`)
1341
+ - All overlay rendering (spotlight / underline / highlight / pulse / callout /
1342
+ ghost reference / box / label), plus viewport-space overlays and camera math
1343
+ - Debug events (`llm-request` / `llm-response` / `storyboard-execute`) so the
1344
+ DebugLog telemetry works identically to the built-in path
1345
+
1346
+ **Priority:** if both `storyboardProvider` and `llm` are set, the provider
1347
+ wins and `llm` is ignored.
1348
+
1349
+ **Return `null` to skip:** the provider may decide a given chunk shouldn't
1350
+ trigger visuals (e.g. filler words, acknowledgements). Returning `null`
1351
+ emits a `note` debug event and no storyboard fires.
1352
+
1302
1353
  ### The integration contract in one picture
1303
1354
 
1304
1355
  ```
package/dist/index.cjs CHANGED
@@ -13181,10 +13181,26 @@ var PAPER = "#faf6ec";
13181
13181
  var ACCENT = "#b04a1a";
13182
13182
  var ACCENT_SOFT = "rgba(176, 74, 26, 0.18)";
13183
13183
  var ACCENT_GLOW = "rgba(176, 74, 26, 0.35)";
13184
- var MARKER = "#e6b422";
13185
- var MARKER_SOFT = "rgba(230, 180, 34, 0.38)";
13186
13184
  var SERIF = "'Iowan Old Style', 'Palatino Linotype', Palatino, 'Book Antiqua', 'EB Garamond', 'Hoefler Text', Georgia, serif";
13187
13185
  var EASE_OUT_EXPO = [0.22, 1, 0.36, 1];
13186
+ var PILL_FONT_CAPS = "clamp(10.5px, 0.55vw + 8.5px, 14.5px)";
13187
+ var PILL_FONT_BODY = "clamp(12px, 0.6vw + 10px, 16.5px)";
13188
+ var PILL_FONT_DISPLAY = "clamp(14px, 0.75vw + 12px, 19px)";
13189
+ var PILL_MAX_W_CAPS = "clamp(180px, 26vw, 380px)";
13190
+ var PILL_MAX_W_BODY = "clamp(200px, 28vw, 440px)";
13191
+ function resolvePillOffset(viewportWidthPx) {
13192
+ return clamp(0.022 * viewportWidthPx + 12, 20, 44);
13193
+ }
13194
+ function resolveMaxPillW(viewportWidthPx) {
13195
+ return clamp(0.26 * viewportWidthPx, 180, 380);
13196
+ }
13197
+ function resolveMaxPillH(viewportWidthPx) {
13198
+ const font = clamp(55e-4 * viewportWidthPx + 8.5, 10.5, 14.5);
13199
+ return clamp(font * 2.6, 28, 42);
13200
+ }
13201
+ function clamp(v, lo, hi) {
13202
+ return Math.min(hi, Math.max(lo, v));
13203
+ }
13188
13204
 
13189
13205
  // src/components/TutorMode/SpotlightMask.tsx
13190
13206
  var import_jsx_runtime42 = require("react/jsx-runtime");
@@ -13470,17 +13486,17 @@ function AnimatedUnderline({ bbox, action }) {
13470
13486
  var import_react56 = require("react");
13471
13487
  var import_framer_motion4 = require("framer-motion");
13472
13488
  var import_jsx_runtime44 = require("react/jsx-runtime");
13489
+ var WASH = "rgba(230, 180, 34, 0.22)";
13473
13490
  function AnimatedHighlight({ bbox, action }) {
13474
13491
  const [x1, y1, x2, y2] = bbox;
13475
13492
  const h = Math.max(1, y2 - y1);
13476
13493
  const bleed = Math.min(4, h * 0.12);
13477
13494
  const yTop = y1 - bleed;
13478
13495
  const yBot = y2 + bleed;
13479
- const height = yBot - yTop;
13480
13496
  const duration = action.draw_duration_ms / 1e3;
13481
13497
  const filterId = (0, import_react56.useId)();
13482
- const fill = action.color && action.color !== "rgba(250, 204, 21, 0.35)" && action.color !== "rgba(250,204,21,0.35)" ? action.color : MARKER_SOFT;
13483
- const inner = action.color && action.color !== "rgba(250, 204, 21, 0.35)" && action.color !== "rgba(250,204,21,0.35)" ? action.color : MARKER;
13498
+ const isDefaultColour = !action.color || action.color === "rgba(250, 204, 21, 0.35)" || action.color === "rgba(250,204,21,0.35)";
13499
+ const fill = isDefaultColour ? WASH : action.color;
13484
13500
  const taper = Math.min(6, h * 0.2);
13485
13501
  const pathD = `
13486
13502
  M ${x1 - 2} ${yTop + taper}
@@ -13510,44 +13526,24 @@ function AnimatedHighlight({ bbox, action }) {
13510
13526
  "feTurbulence",
13511
13527
  {
13512
13528
  type: "fractalNoise",
13513
- baseFrequency: "1.6",
13529
+ baseFrequency: "1.8",
13514
13530
  numOctaves: "1",
13515
13531
  seed: 3,
13516
13532
  result: "noise"
13517
13533
  }
13518
13534
  ),
13519
- /* @__PURE__ */ (0, import_jsx_runtime44.jsx)("feDisplacementMap", { in: "SourceGraphic", in2: "noise", scale: 1.4 })
13535
+ /* @__PURE__ */ (0, import_jsx_runtime44.jsx)("feDisplacementMap", { in: "SourceGraphic", in2: "noise", scale: 1 })
13520
13536
  ] }) }),
13521
- /* @__PURE__ */ (0, import_jsx_runtime44.jsxs)(
13522
- import_framer_motion4.motion.g,
13537
+ /* @__PURE__ */ (0, import_jsx_runtime44.jsx)(
13538
+ import_framer_motion4.motion.path,
13523
13539
  {
13540
+ d: pathD,
13541
+ fill,
13524
13542
  initial: { clipPath: `inset(0 100% 0 0)` },
13525
13543
  animate: { clipPath: `inset(0 0% 0 0)` },
13526
13544
  exit: { opacity: 0 },
13527
- transition: { duration, ease: EASE_OUT_EXPO },
13528
- children: [
13529
- /* @__PURE__ */ (0, import_jsx_runtime44.jsx)(
13530
- "path",
13531
- {
13532
- d: pathD,
13533
- fill,
13534
- opacity: 0.85,
13535
- filter: `url(#${filterId})`
13536
- }
13537
- ),
13538
- /* @__PURE__ */ (0, import_jsx_runtime44.jsx)(
13539
- "rect",
13540
- {
13541
- x: x1 - 1,
13542
- y: y1 - bleed * 0.4,
13543
- width: x2 - x1 + 2,
13544
- height: height - bleed * 0.8,
13545
- fill: inner,
13546
- opacity: 0.5,
13547
- filter: `url(#${filterId})`
13548
- }
13549
- )
13550
- ]
13545
+ filter: `url(#${filterId})`,
13546
+ transition: { duration, ease: EASE_OUT_EXPO }
13551
13547
  }
13552
13548
  )
13553
13549
  ]
@@ -14117,9 +14113,11 @@ function GhostReference({
14117
14113
  {
14118
14114
  style: {
14119
14115
  position: "relative",
14120
- padding: "20px 22px 20px 26px",
14116
+ // Padding scales with viewport: compact on phones, breathing on
14117
+ // desktop. Extra 4px on the left preserves the footnote-rule gap.
14118
+ padding: "clamp(14px, 1.4vw + 10px, 26px) clamp(14px, 1.4vw + 10px, 26px) clamp(14px, 1.4vw + 10px, 26px) clamp(18px, 1.4vw + 14px, 30px)",
14121
14119
  display: "flex",
14122
- gap: 16,
14120
+ gap: "clamp(12px, 1.1vw + 8px, 20px)",
14123
14121
  alignItems: "flex-start"
14124
14122
  },
14125
14123
  children: [
@@ -14133,7 +14131,7 @@ function GhostReference({
14133
14131
  transition: { duration: 0.45, delay: 0.18, ease: [0.22, 1, 0.36, 1] },
14134
14132
  style: {
14135
14133
  flexShrink: 0,
14136
- width: 46,
14134
+ width: "clamp(40px, 4vw + 20px, 64px)",
14137
14135
  aspectRatio: `${page.width} / ${page.height}`,
14138
14136
  background: PAPER_DEEP,
14139
14137
  borderRadius: 2,
@@ -14198,7 +14196,7 @@ function GhostReference({
14198
14196
  flex: 1,
14199
14197
  minWidth: 0,
14200
14198
  fontFamily: SERIF2,
14201
- fontSize: 15.5,
14199
+ fontSize: PILL_FONT_DISPLAY,
14202
14200
  lineHeight: 1.55,
14203
14201
  color: INK2,
14204
14202
  fontFeatureSettings: "'liga' 1, 'kern' 1, 'onum' 1",
@@ -14220,7 +14218,7 @@ function GhostReference({
14220
14218
  left: -14,
14221
14219
  top: -2,
14222
14220
  color: ACCENT2,
14223
- fontSize: 22,
14221
+ fontSize: "clamp(18px, 1vw + 14px, 28px)",
14224
14222
  lineHeight: 1,
14225
14223
  fontWeight: 500
14226
14224
  // ornamental flourish anchoring the paragraph
@@ -14330,10 +14328,6 @@ var import_framer_motion12 = require("framer-motion");
14330
14328
  // src/components/TutorMode/StickyLabel.tsx
14331
14329
  var import_framer_motion11 = require("framer-motion");
14332
14330
  var import_jsx_runtime51 = require("react/jsx-runtime");
14333
- var INK3 = "#2a2420";
14334
- var PAPER3 = "#faf6ec";
14335
- var ACCENT3 = "#b04a1a";
14336
- var SERIF3 = "'Iowan Old Style', 'Palatino Linotype', Palatino, 'Book Antiqua', 'EB Garamond', 'Hoefler Text', Georgia, serif";
14337
14331
  var STEM = 18;
14338
14332
  function StickyLabel({ screenAnchor, action }) {
14339
14333
  const { x, y } = screenAnchor;
@@ -14372,7 +14366,7 @@ function StickyLabel({ screenAnchor, action }) {
14372
14366
  transition: { duration: 0.35, ease: [0.22, 1, 0.36, 1] },
14373
14367
  style: {
14374
14368
  position: "absolute",
14375
- background: ACCENT3,
14369
+ background: ACCENT,
14376
14370
  transformOrigin: layout.stemOrigin,
14377
14371
  ...layout.stem
14378
14372
  }
@@ -14391,8 +14385,8 @@ function StickyLabel({ screenAnchor, action }) {
14391
14385
  width: 6,
14392
14386
  height: 6,
14393
14387
  borderRadius: "50%",
14394
- background: ACCENT3,
14395
- boxShadow: `0 0 0 2px ${PAPER3}, 0 0 0 3px rgba(176, 74, 26, 0.25)`,
14388
+ background: ACCENT,
14389
+ boxShadow: `0 0 0 2px ${PAPER}, 0 0 0 3px rgba(176, 74, 26, 0.25)`,
14396
14390
  ...layout.dot
14397
14391
  }
14398
14392
  }
@@ -14407,25 +14401,25 @@ function StickyLabel({ screenAnchor, action }) {
14407
14401
  style: {
14408
14402
  position: "absolute",
14409
14403
  ...layout.bodyAnchor,
14410
- background: PAPER3,
14411
- color: INK3,
14404
+ background: PAPER,
14405
+ color: INK,
14412
14406
  border: "1px solid rgba(42, 36, 32, 0.10)",
14413
14407
  borderRadius: 3,
14414
14408
  padding: "6px 12px 6px 14px",
14415
- fontFamily: SERIF3,
14416
- fontSize: 12.5,
14409
+ fontFamily: SERIF,
14410
+ fontSize: PILL_FONT_BODY,
14417
14411
  lineHeight: 1.25,
14418
14412
  letterSpacing: 0.6,
14419
14413
  textTransform: "uppercase",
14420
14414
  fontWeight: 500,
14421
14415
  whiteSpace: "nowrap",
14422
- maxWidth: 240,
14416
+ maxWidth: PILL_MAX_W_BODY,
14423
14417
  overflow: "hidden",
14424
14418
  textOverflow: "ellipsis",
14425
14419
  // Warm two-layer shadow (matches GhostReference's palette).
14426
14420
  boxShadow: "0 1px 2px rgba(42, 36, 32, 0.12), 0 8px 18px -6px rgba(42, 36, 32, 0.22)",
14427
14421
  // Internal left accent rule — a 2px terracotta stripe.
14428
- backgroundImage: `linear-gradient(to right, ${ACCENT3} 0, ${ACCENT3} 2px, transparent 2px)`,
14422
+ backgroundImage: `linear-gradient(to right, ${ACCENT} 0, ${ACCENT} 2px, transparent 2px)`,
14429
14423
  backgroundRepeat: "no-repeat",
14430
14424
  backgroundSize: "2px 100%",
14431
14425
  backgroundPosition: "left top"
@@ -14667,9 +14661,9 @@ function computePillAnchor(fromBbox, toBbox, page, camera, viewport) {
14667
14661
  const tipScreenX = viewport.width / 2 + camera.x + (toX - pageCX) * camera.scale;
14668
14662
  const tipScreenY = viewport.height / 2 + camera.y + (toY - pageCY) * camera.scale;
14669
14663
  const isVertical = Math.abs(dy) >= Math.abs(dx);
14670
- const OFFSET = 32;
14671
- const MAX_PILL_W = 220;
14672
- const MAX_PILL_H = 30;
14664
+ const OFFSET = resolvePillOffset(viewport.width);
14665
+ const MAX_PILL_W = resolveMaxPillW(viewport.width);
14666
+ const MAX_PILL_H = resolveMaxPillH(viewport.width);
14673
14667
  const SAFE = 16;
14674
14668
  if (isVertical) {
14675
14669
  const canFitRight = tipScreenX + OFFSET + MAX_PILL_W < viewport.width - SAFE;
@@ -14713,13 +14707,13 @@ function CalloutLabelPill({
14713
14707
  borderRadius: 3,
14714
14708
  padding: spec.padding,
14715
14709
  fontFamily: SERIF,
14716
- fontSize: 11.5,
14710
+ fontSize: PILL_FONT_CAPS,
14717
14711
  lineHeight: 1.2,
14718
14712
  letterSpacing: 0.6,
14719
14713
  textTransform: "uppercase",
14720
14714
  fontWeight: 500,
14721
14715
  whiteSpace: "nowrap",
14722
- maxWidth: 220,
14716
+ maxWidth: PILL_MAX_W_CAPS,
14723
14717
  overflow: "hidden",
14724
14718
  textOverflow: "ellipsis",
14725
14719
  boxShadow: "0 1px 2px rgba(42, 36, 32, 0.12), 0 8px 18px -6px rgba(42, 36, 32, 0.22)",
@@ -15110,7 +15104,12 @@ var StoryboardStepSchema = import_zod.z.object({
15110
15104
  });
15111
15105
  var StoryboardSchema = import_zod.z.object({
15112
15106
  version: import_zod.z.literal(1),
15113
- reasoning: import_zod.z.string().max(500).default(""),
15107
+ // `reasoning` was required in 0.4.x as a model-generated explanation used
15108
+ // by DebugLog. It carries no visual effect and costs 50–150 output tokens
15109
+ // per call, so from 0.5.1 it's optional (default empty). Consumers who
15110
+ // still send it (from cached prompts or older directors) keep working —
15111
+ // the field is still accepted, just not required.
15112
+ reasoning: import_zod.z.string().max(500).optional().default(""),
15114
15113
  steps: import_zod.z.array(StoryboardStepSchema).min(1).max(4)
15115
15114
  });
15116
15115
  function storyboardJsonSchema(opts = {}) {
@@ -15225,7 +15224,10 @@ function storyboardJsonSchema(opts = {}) {
15225
15224
  return {
15226
15225
  type: "object",
15227
15226
  additionalProperties: false,
15228
- required: ["version", "reasoning", "steps"],
15227
+ // `reasoning` intentionally omitted from `required` the field is still
15228
+ // accepted when present but the model doesn't need to generate it,
15229
+ // which saves 50–150 output tokens per call. See zod schema above.
15230
+ required: ["version", "steps"],
15229
15231
  properties: {
15230
15232
  version: { type: "integer", enum: [1] },
15231
15233
  reasoning: { type: "string" },
@@ -15264,7 +15266,6 @@ Anchoring rules:
15264
15266
  Output ONLY this JSON, nothing else:
15265
15267
  {
15266
15268
  "version": 1,
15267
- "reasoning": "<which block(s) you picked, which intent you used, and why \u2014 name the block_id>",
15268
15269
  "steps": [ { "at_ms": <int>, "duration_ms": <int>, "action": <action> }, ... ]
15269
15270
  }
15270
15271
 
@@ -15301,7 +15302,6 @@ When narration fits one of these patterns, emit the corresponding storyboard sha
15301
15302
  Shape: spotlight the term + underline it + drop a label tag. No camera move if the block is already on-screen.
15302
15303
  {
15303
15304
  "version": 1,
15304
- "reasoning": "define recipe: spotlighting and underlining the term, labeling as 'definition'",
15305
15305
  "steps": [
15306
15306
  { "at_ms":0, "duration_ms":700, "action": { "type":"spotlight", "target_block":"p1_para0", "dim_opacity":0.6, "feather_px":40, "shape":"rounded" } },
15307
15307
  { "at_ms":200, "duration_ms":800, "action": { "type":"underline", "target_block":"p1_para0", "color":"#FBBF24", "style":"sketch", "draw_duration_ms":700 } },
@@ -15313,7 +15313,6 @@ Shape: spotlight the term + underline it + drop a label tag. No camera move if t
15313
15313
  Shape: gentle camera move + callout arrow from caption to figure + pulse the figure.
15314
15314
  {
15315
15315
  "version": 1,
15316
- "reasoning": "point_out recipe: drawing attention from caption p1_cap1 to figure p1_fig0",
15317
15316
  "steps": [
15318
15317
  { "at_ms":0, "duration_ms":600, "action": { "type":"camera", "target_block":"p1_fig0", "scale":1.3, "padding":80, "easing":"ease-out" } },
15319
15318
  { "at_ms":400, "duration_ms":900, "action": { "type":"callout", "from_block":"p1_cap1", "to_block":"p1_fig0", "label":"see here", "curve":"curved" } },
@@ -15325,7 +15324,6 @@ Shape: gentle camera move + callout arrow from caption to figure + pulse the fig
15325
15324
  Shape: box A + box B + callout between them with a relational label.
15326
15325
  {
15327
15326
  "version": 1,
15328
- "reasoning": "compare recipe: framing fibrous vs synovial joints",
15329
15327
  "steps": [
15330
15328
  { "at_ms":0, "duration_ms":600, "action": { "type":"box", "target_block":"p1_list5", "color":"#3B82F6", "style":"solid" } },
15331
15329
  { "at_ms":300, "duration_ms":600, "action": { "type":"box", "target_block":"p1_list12", "color":"#F472B6", "style":"solid" } },
@@ -15337,7 +15335,6 @@ Shape: box A + box B + callout between them with a relational label.
15337
15335
  Shape: highlight + pulse. Fast, punchy, no camera.
15338
15336
  {
15339
15337
  "version": 1,
15340
- "reasoning": "emphasize recipe: highlighting key keyword and pulsing for stress",
15341
15338
  "steps": [
15342
15339
  { "at_ms":0, "duration_ms":500, "action": { "type":"highlight", "target_block":"p1_list0", "color":"rgba(250,204,21,0.35)", "draw_duration_ms":450 } },
15343
15340
  { "at_ms":350, "duration_ms":800, "action": { "type":"pulse", "target_block":"p1_list0", "count":2, "intensity":"strong" } }
@@ -15606,32 +15603,32 @@ function clampNumericRanges(input) {
15606
15603
  }
15607
15604
  const type = typeof out.type === "string" ? out.type : void 0;
15608
15605
  if (type === "camera") {
15609
- if (typeof out.scale === "number") out.scale = clamp(out.scale, 0.5, 4);
15606
+ if (typeof out.scale === "number") out.scale = clamp2(out.scale, 0.5, 4);
15610
15607
  if (typeof out.padding === "number") {
15611
- out.padding = clamp(out.padding, 0, 400);
15608
+ out.padding = clamp2(out.padding, 0, 400);
15612
15609
  }
15613
15610
  }
15614
15611
  if (typeof out.dim_opacity === "number") {
15615
- out.dim_opacity = clamp(out.dim_opacity, 0, 1);
15612
+ out.dim_opacity = clamp2(out.dim_opacity, 0, 1);
15616
15613
  }
15617
15614
  if (typeof out.feather_px === "number") {
15618
- out.feather_px = clamp(out.feather_px, 0, 200);
15615
+ out.feather_px = clamp2(out.feather_px, 0, 200);
15619
15616
  }
15620
15617
  if (typeof out.draw_duration_ms === "number") {
15621
- out.draw_duration_ms = clamp(out.draw_duration_ms, 100, 3e3);
15618
+ out.draw_duration_ms = clamp2(out.draw_duration_ms, 100, 3e3);
15622
15619
  }
15623
15620
  if (typeof out.count === "number") {
15624
- out.count = Math.round(clamp(out.count, 1, 5));
15621
+ out.count = Math.round(clamp2(out.count, 1, 5));
15625
15622
  }
15626
15623
  if (typeof out.at_ms === "number") {
15627
- out.at_ms = clamp(out.at_ms, 0, 5e3);
15624
+ out.at_ms = clamp2(out.at_ms, 0, 5e3);
15628
15625
  }
15629
15626
  if (typeof out.duration_ms === "number" && type === void 0) {
15630
- out.duration_ms = clamp(out.duration_ms, 100, 5e3);
15627
+ out.duration_ms = clamp2(out.duration_ms, 100, 5e3);
15631
15628
  }
15632
15629
  return out;
15633
15630
  }
15634
- function clamp(v, lo, hi) {
15631
+ function clamp2(v, lo, hi) {
15635
15632
  return Math.min(hi, Math.max(lo, v));
15636
15633
  }
15637
15634
  function enforceOverlayPresence(sb) {
@@ -15644,9 +15641,10 @@ function enforceOverlayPresence(sb) {
15644
15641
  if (!cameraStep || cameraStep.action.type !== "camera") return sb;
15645
15642
  const target = cameraStep.action.target_block;
15646
15643
  if (!target) return sb;
15644
+ const prefix = sb.reasoning ? `${sb.reasoning} ` : "";
15647
15645
  return {
15648
15646
  ...sb,
15649
- reasoning: `${sb.reasoning} [auto-appended pulse: camera-only storyboards are forbidden]`,
15647
+ reasoning: `${prefix}[auto-appended pulse: camera-only storyboards are forbidden]`,
15650
15648
  steps: [
15651
15649
  ...sb.steps,
15652
15650
  {
@@ -16038,6 +16036,7 @@ function TutorModeContainer({
16038
16036
  backgroundColor = "#ffffff",
16039
16037
  loadingComponent,
16040
16038
  onPageChange,
16039
+ storyboardProvider,
16041
16040
  className
16042
16041
  }) {
16043
16042
  const containerRef = (0, import_react58.useRef)(null);
@@ -16116,7 +16115,7 @@ function TutorModeContainer({
16116
16115
  const debounceRef = (0, import_react58.useRef)(null);
16117
16116
  const lastChunkRef = (0, import_react58.useRef)(null);
16118
16117
  (0, import_react58.useEffect)(() => {
16119
- if (!llm) return;
16118
+ if (!storyboardProvider && !llm) return;
16120
16119
  if (!currentChunk || currentChunk === lastChunkRef.current) return;
16121
16120
  if (debounceRef.current) clearTimeout(debounceRef.current);
16122
16121
  debounceRef.current = setTimeout(async () => {
@@ -16137,6 +16136,75 @@ function TutorModeContainer({
16137
16136
  });
16138
16137
  abortRef.current?.abort();
16139
16138
  abortRef.current = new AbortController();
16139
+ if (storyboardProvider) {
16140
+ narrationStore.getState().setLlmStatus("in-flight");
16141
+ narrationStore.getState().appendDebugEvent({
16142
+ kind: "llm-request",
16143
+ summary: `provider (page ${pageNumber}, ${page2.blocks.length} blocks)`,
16144
+ payload: {
16145
+ via: "storyboardProvider",
16146
+ pageNumber,
16147
+ blockCount: page2.blocks.length
16148
+ }
16149
+ });
16150
+ try {
16151
+ const raw = await storyboardProvider({
16152
+ chunk,
16153
+ pageNumber,
16154
+ page: page2,
16155
+ history: narrationStore.getState().chunkHistory,
16156
+ signal: abortRef.current.signal
16157
+ });
16158
+ if (!raw) {
16159
+ narrationStore.getState().setLlmStatus("idle");
16160
+ narrationStore.getState().appendDebugEvent({
16161
+ kind: "note",
16162
+ summary: "provider returned null \u2014 no storyboard for this chunk"
16163
+ });
16164
+ return;
16165
+ }
16166
+ const parsed = StoryboardSchema.safeParse(raw);
16167
+ if (!parsed.success) {
16168
+ narrationStore.getState().setLlmStatus(
16169
+ "failed",
16170
+ parsed.error.message
16171
+ );
16172
+ narrationStore.getState().appendDebugEvent({
16173
+ kind: "llm-error",
16174
+ summary: `provider storyboard rejected by schema: ${parsed.error.issues[0]?.message ?? "unknown"}`,
16175
+ payload: { raw, error: parsed.error.message }
16176
+ });
16177
+ return;
16178
+ }
16179
+ const storyboard = parsed.data;
16180
+ narrationStore.getState().setLlmStatus("idle");
16181
+ narrationStore.getState().appendDebugEvent({
16182
+ kind: "llm-response",
16183
+ summary: summariseStoryboard(storyboard),
16184
+ payload: { via: "storyboardProvider", storyboard }
16185
+ });
16186
+ engineRef.current?.execute(storyboard);
16187
+ narrationStore.getState().appendDebugEvent({
16188
+ kind: "storyboard-execute",
16189
+ summary: `engine executing ${storyboard.steps.length} steps`,
16190
+ payload: storyboard.steps.map((s) => ({
16191
+ at_ms: s.at_ms,
16192
+ type: s.action.type,
16193
+ target: "target_block" in s.action ? s.action.target_block : void 0
16194
+ }))
16195
+ });
16196
+ } catch (e) {
16197
+ if (e.name === "AbortError") return;
16198
+ narrationStore.getState().setLlmStatus("failed", e.message);
16199
+ narrationStore.getState().appendDebugEvent({
16200
+ kind: "llm-error",
16201
+ summary: `provider threw: ${e.message.slice(0, 80)}`,
16202
+ payload: e
16203
+ });
16204
+ }
16205
+ return;
16206
+ }
16207
+ if (!llm) return;
16140
16208
  narrationStore.getState().setLlmStatus("in-flight");
16141
16209
  narrationStore.getState().appendDebugEvent({
16142
16210
  kind: "llm-request",
@@ -16158,7 +16226,7 @@ function TutorModeContainer({
16158
16226
  narrationStore.getState().setLlmStatus("idle");
16159
16227
  narrationStore.getState().appendDebugEvent({
16160
16228
  kind: "llm-response",
16161
- summary: `storyboard \u2713 ${result.storyboard.steps.length} steps \u2014 ${result.storyboard.reasoning.slice(0, 60)}`,
16229
+ summary: summariseStoryboard(result.storyboard),
16162
16230
  payload: { raw: result.raw, storyboard: result.storyboard }
16163
16231
  });
16164
16232
  engineRef.current?.execute(result.storyboard);
@@ -16201,7 +16269,16 @@ function TutorModeContainer({
16201
16269
  return () => {
16202
16270
  if (debounceRef.current) clearTimeout(debounceRef.current);
16203
16271
  };
16204
- }, [currentChunk, llm, index, pageNumber, narrationStore, embeddingProvider, llmTimeoutMs]);
16272
+ }, [
16273
+ currentChunk,
16274
+ llm,
16275
+ storyboardProvider,
16276
+ index,
16277
+ pageNumber,
16278
+ narrationStore,
16279
+ embeddingProvider,
16280
+ llmTimeoutMs
16281
+ ]);
16205
16282
  (0, import_react58.useEffect)(() => {
16206
16283
  if (!currentChunk) return;
16207
16284
  const t = setTimeout(() => {
@@ -16391,6 +16468,15 @@ function TutorLoadingState({
16391
16468
  }
16392
16469
  );
16393
16470
  }
16471
+ function summariseStoryboard(sb) {
16472
+ const stepCount = sb.steps.length;
16473
+ const trimmedReasoning = (sb.reasoning ?? "").trim();
16474
+ if (trimmedReasoning) {
16475
+ return `storyboard \u2713 ${stepCount} steps \u2014 ${trimmedReasoning.slice(0, 60)}`;
16476
+ }
16477
+ const kinds = sb.steps.map((s) => s.action.type).join(" \u2192 ");
16478
+ return `storyboard \u2713 ${stepCount} steps \u2014 ${kinds}`;
16479
+ }
16394
16480
 
16395
16481
  // src/director/transformers-embedding.ts
16396
16482
  var loaded = null;