pdfjs-reader-core 0.3.0 → 0.4.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/dist/index.cjs CHANGED
@@ -140,8 +140,8 @@ async function loadDocument(options) {
140
140
  signal.addEventListener("abort", abortHandler);
141
141
  }
142
142
  if (onProgress) {
143
- loadingTask.onProgress = ({ loaded, total }) => {
144
- onProgress({ loaded, total });
143
+ loadingTask.onProgress = ({ loaded: loaded2, total }) => {
144
+ onProgress({ loaded: loaded2, total });
145
145
  };
146
146
  }
147
147
  let document2;
@@ -271,9 +271,9 @@ function loadDocumentWithCallbacks(options) {
271
271
  };
272
272
  abortController.signal.addEventListener("abort", abortHandler);
273
273
  if (onProgress) {
274
- loadingTask.onProgress = ({ loaded, total }) => {
274
+ loadingTask.onProgress = ({ loaded: loaded2, total }) => {
275
275
  if (!abortController.signal.aborted) {
276
- onProgress({ loaded, total });
276
+ onProgress({ loaded: loaded2, total });
277
277
  }
278
278
  };
279
279
  }
@@ -627,13 +627,13 @@ function createViewerStore(initialOverrides = {}) {
627
627
  },
628
628
  zoomIn: () => {
629
629
  const { scale } = get();
630
- const currentIndex = ZOOM_LEVELS.findIndex((z) => z >= scale);
630
+ const currentIndex = ZOOM_LEVELS.findIndex((z2) => z2 >= scale);
631
631
  const nextIndex = Math.min(currentIndex + 1, ZOOM_LEVELS.length - 1);
632
632
  set({ scale: ZOOM_LEVELS[nextIndex] ?? MAX_SCALE });
633
633
  },
634
634
  zoomOut: () => {
635
635
  const { scale } = get();
636
- const currentIndex = ZOOM_LEVELS.findIndex((z) => z >= scale);
636
+ const currentIndex = ZOOM_LEVELS.findIndex((z2) => z2 >= scale);
637
637
  const prevIndex = Math.max(currentIndex - 1, 0);
638
638
  set({ scale: ZOOM_LEVELS[prevIndex] ?? MIN_SCALE });
639
639
  },
@@ -1863,6 +1863,34 @@ var init_page_turn_sound = __esm({
1863
1863
  }
1864
1864
  });
1865
1865
 
1866
+ // src/utils/camera-math.ts
1867
+ function fitPageScale(page, viewport) {
1868
+ const sx = viewport.width / page.width;
1869
+ const sy = viewport.height / page.height;
1870
+ return Math.min(sx, sy);
1871
+ }
1872
+ function computeCameraForBlock(bbox, page, viewport, opts = {}) {
1873
+ const targetScale = opts.targetScale ?? 1.5;
1874
+ const paddingPdf = opts.paddingPdf ?? 80;
1875
+ const [x1, y1, x2, y2] = bbox;
1876
+ const blockW = Math.max(1, x2 - x1 + paddingPdf * 2);
1877
+ const blockH = Math.max(1, y2 - y1 + paddingPdf * 2);
1878
+ const blockCX = (x1 + x2) / 2;
1879
+ const blockCY = (y1 + y2) / 2;
1880
+ const fitBlock = Math.min(viewport.width / blockW, viewport.height / blockH);
1881
+ const scale = fitBlock * targetScale;
1882
+ const pageCX = page.width / 2;
1883
+ const pageCY = page.height / 2;
1884
+ const x = (pageCX - blockCX) * scale;
1885
+ const y = (pageCY - blockCY) * scale;
1886
+ return { scale, x, y };
1887
+ }
1888
+ var init_camera_math = __esm({
1889
+ "src/utils/camera-math.ts"() {
1890
+ "use strict";
1891
+ }
1892
+ });
1893
+
1866
1894
  // src/utils/index.ts
1867
1895
  var init_utils = __esm({
1868
1896
  "src/utils/index.ts"() {
@@ -2281,6 +2309,78 @@ var init_student_store = __esm({
2281
2309
  }
2282
2310
  });
2283
2311
 
2312
+ // src/store/narration-store.ts
2313
+ function createNarrationStore(overrides = {}) {
2314
+ return (0, import_vanilla6.createStore)()((set) => ({
2315
+ ...initialState6,
2316
+ ...overrides,
2317
+ setCurrentChunk: (chunk) => set({ currentChunk: chunk }),
2318
+ setCurrentPage: (page) => set({ currentPage: page }),
2319
+ pushChunkHistory: (entry) => set((state) => ({
2320
+ chunkHistory: [
2321
+ ...state.chunkHistory.slice(-(MAX_HISTORY - 1)),
2322
+ entry
2323
+ ]
2324
+ })),
2325
+ setCamera: (camera) => set((state) => ({ camera: { ...state.camera, ...camera } })),
2326
+ addOverlay: (overlay) => set((state) => ({ activeOverlays: [...state.activeOverlays, overlay] })),
2327
+ removeOverlay: (id) => set((state) => ({
2328
+ activeOverlays: state.activeOverlays.filter((o) => o.id !== id)
2329
+ })),
2330
+ clearOverlays: (predicate) => set((state) => ({
2331
+ activeOverlays: predicate ? state.activeOverlays.filter((o) => !predicate(o)) : []
2332
+ })),
2333
+ setEngineStatus: (s) => set({ engineStatus: s }),
2334
+ setLlmStatus: (s, error = null) => set({ llmStatus: s, lastError: error }),
2335
+ setLastStoryboard: (sb) => set({ lastStoryboard: sb }),
2336
+ setPaused: (paused) => set({ isPaused: paused }),
2337
+ appendDebugEvent: (event) => set((state) => {
2338
+ debugEventCounter += 1;
2339
+ const next = {
2340
+ ...event,
2341
+ id: `dbg-${debugEventCounter}`,
2342
+ timestamp: Date.now()
2343
+ };
2344
+ return {
2345
+ debugEvents: [
2346
+ ...state.debugEvents.slice(-(MAX_DEBUG_EVENTS - 1)),
2347
+ next
2348
+ ]
2349
+ };
2350
+ }),
2351
+ clearDebugEvents: () => set({ debugEvents: [] }),
2352
+ reset: () => set(initialState6)
2353
+ }));
2354
+ }
2355
+ function makeOverlayId(action) {
2356
+ overlayIdCounter += 1;
2357
+ return `ov-${action.type}-${overlayIdCounter}-${Date.now()}`;
2358
+ }
2359
+ var import_vanilla6, MAX_HISTORY, initialState6, MAX_DEBUG_EVENTS, debugEventCounter, overlayIdCounter;
2360
+ var init_narration_store = __esm({
2361
+ "src/store/narration-store.ts"() {
2362
+ "use strict";
2363
+ import_vanilla6 = require("zustand/vanilla");
2364
+ MAX_HISTORY = 5;
2365
+ initialState6 = {
2366
+ currentChunk: null,
2367
+ currentPage: 1,
2368
+ chunkHistory: [],
2369
+ camera: { scale: 1, x: 0, y: 0, easing: "ease-in-out" },
2370
+ activeOverlays: [],
2371
+ engineStatus: "idle",
2372
+ llmStatus: "idle",
2373
+ lastStoryboard: null,
2374
+ lastError: null,
2375
+ isPaused: false,
2376
+ debugEvents: []
2377
+ };
2378
+ MAX_DEBUG_EVENTS = 50;
2379
+ debugEventCounter = 0;
2380
+ overlayIdCounter = 0;
2381
+ }
2382
+ });
2383
+
2284
2384
  // src/store/index.ts
2285
2385
  var init_store = __esm({
2286
2386
  "src/store/index.ts"() {
@@ -2290,13 +2390,14 @@ var init_store = __esm({
2290
2390
  init_search_store();
2291
2391
  init_agent_store();
2292
2392
  init_student_store();
2393
+ init_narration_store();
2293
2394
  }
2294
2395
  });
2295
2396
 
2296
2397
  // src/hooks/PDFViewerContext.tsx
2297
2398
  function PDFViewerProvider({
2298
2399
  children,
2299
- initialState: initialState6,
2400
+ initialState: initialState7,
2300
2401
  theme = "light",
2301
2402
  defaultSidebarPanel = "thumbnails",
2302
2403
  studentMode: _studentMode = false
@@ -2308,22 +2409,22 @@ function PDFViewerProvider({
2308
2409
  const studentStoreRef = (0, import_react2.useRef)(null);
2309
2410
  if (!viewerStoreRef.current) {
2310
2411
  viewerStoreRef.current = createViewerStore({
2311
- ...initialState6?.viewer,
2412
+ ...initialState7?.viewer,
2312
2413
  theme,
2313
2414
  sidebarPanel: defaultSidebarPanel
2314
2415
  });
2315
2416
  }
2316
2417
  if (!annotationStoreRef.current) {
2317
- annotationStoreRef.current = createAnnotationStore(initialState6?.annotation);
2418
+ annotationStoreRef.current = createAnnotationStore(initialState7?.annotation);
2318
2419
  }
2319
2420
  if (!searchStoreRef.current) {
2320
- searchStoreRef.current = createSearchStore(initialState6?.search);
2421
+ searchStoreRef.current = createSearchStore(initialState7?.search);
2321
2422
  }
2322
2423
  if (!agentStoreRef.current) {
2323
- agentStoreRef.current = createAgentStore(initialState6?.agent);
2424
+ agentStoreRef.current = createAgentStore(initialState7?.agent);
2324
2425
  }
2325
2426
  if (!studentStoreRef.current) {
2326
- studentStoreRef.current = createStudentStore(initialState6?.student);
2427
+ studentStoreRef.current = createStudentStore(initialState7?.student);
2327
2428
  }
2328
2429
  (0, import_react2.useEffect)(() => {
2329
2430
  return () => {
@@ -3647,8 +3748,8 @@ var init_PluginManager = __esm({
3647
3748
  /**
3648
3749
  * Get toolbar items by position
3649
3750
  */
3650
- getToolbarItemsByPosition(position) {
3651
- return this.getToolbarItems().filter((item) => item.position === position);
3751
+ getToolbarItemsByPosition(position2) {
3752
+ return this.getToolbarItems().filter((item) => item.position === position2);
3652
3753
  }
3653
3754
  /**
3654
3755
  * Get all sidebar panels from all plugins
@@ -4761,7 +4862,7 @@ var init_MobileToolbar = __esm({
4761
4862
  sidebarOpen,
4762
4863
  theme,
4763
4864
  onThemeChange,
4764
- position = "bottom",
4865
+ position: position2 = "bottom",
4765
4866
  className
4766
4867
  }) {
4767
4868
  const [showMoreMenu, setShowMoreMenu] = (0, import_react17.useState)(false);
@@ -4795,8 +4896,8 @@ var init_MobileToolbar = __esm({
4795
4896
  "bg-white dark:bg-gray-800",
4796
4897
  "border-gray-200 dark:border-gray-700",
4797
4898
  "px-2 py-1 safe-area-inset",
4798
- position === "top" && "top-0 border-b",
4799
- position === "bottom" && "bottom-0 border-t",
4899
+ position2 === "top" && "top-0 border-b",
4900
+ position2 === "bottom" && "bottom-0 border-t",
4800
4901
  className
4801
4902
  ),
4802
4903
  children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "flex items-center justify-between gap-1", children: [
@@ -4922,7 +5023,7 @@ var init_MobileToolbar = __esm({
4922
5023
  "bg-white dark:bg-gray-800",
4923
5024
  "rounded-lg shadow-lg",
4924
5025
  "border border-gray-200 dark:border-gray-700",
4925
- position === "bottom" ? "bottom-full mb-2" : "top-full mt-2"
5026
+ position2 === "bottom" ? "bottom-full mb-2" : "top-full mt-2"
4926
5027
  ),
4927
5028
  children: [
4928
5029
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "px-2 py-1 text-xs text-gray-500 dark:text-gray-400 font-medium", children: "Theme" }),
@@ -6748,7 +6849,7 @@ var init_AnnotationToolbar = __esm({
6748
6849
  onShapeTypeChange: onShapeTypeChangeProp,
6749
6850
  onColorChange: onColorChangeProp,
6750
6851
  onStrokeWidthChange: onStrokeWidthChangeProp,
6751
- position = "top",
6852
+ position: position2 = "top",
6752
6853
  className
6753
6854
  }) {
6754
6855
  const storeActiveTool = useAnnotationStore((s) => s.activeAnnotationTool);
@@ -6798,9 +6899,9 @@ var init_AnnotationToolbar = __esm({
6798
6899
  {
6799
6900
  className: cn(
6800
6901
  "annotation-toolbar flex items-center gap-1 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700",
6801
- position === "floating" && "fixed bottom-20 left-1/2 -translate-x-1/2 z-50",
6802
- position === "top" && "sticky top-0 z-40",
6803
- position === "bottom" && "sticky bottom-0 z-40",
6902
+ position2 === "floating" && "fixed bottom-20 left-1/2 -translate-x-1/2 z-50",
6903
+ position2 === "top" && "sticky top-0 z-40",
6904
+ position2 === "bottom" && "sticky bottom-0 z-40",
6804
6905
  !isActive && "opacity-90",
6805
6906
  className
6806
6907
  ),
@@ -8449,7 +8550,7 @@ var init_SelectionToolbar = __esm({
8449
8550
  activeColor = "yellow",
8450
8551
  className
8451
8552
  }) {
8452
- const [position, setPosition] = (0, import_react35.useState)({ top: 0, left: 0, visible: false });
8553
+ const [position2, setPosition] = (0, import_react35.useState)({ top: 0, left: 0, visible: false });
8453
8554
  const toolbarRef = (0, import_react35.useRef)(null);
8454
8555
  (0, import_react35.useEffect)(() => {
8455
8556
  if (selection && selection.text && selection.rects.length > 0) {
@@ -8488,7 +8589,7 @@ var init_SelectionToolbar = __esm({
8488
8589
  const handleCopy = (0, import_react35.useCallback)(() => {
8489
8590
  onCopy?.();
8490
8591
  }, [onCopy]);
8491
- if (!position.visible || !selection?.text) {
8592
+ if (!position2.visible || !selection?.text) {
8492
8593
  return null;
8493
8594
  }
8494
8595
  return /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)(
@@ -8506,8 +8607,8 @@ var init_SelectionToolbar = __esm({
8506
8607
  className
8507
8608
  ),
8508
8609
  style: {
8509
- top: position.top,
8510
- left: position.left,
8610
+ top: position2.top,
8611
+ left: position2.left,
8511
8612
  transform: "translateX(-50%)"
8512
8613
  },
8513
8614
  onMouseDown: (e) => {
@@ -8624,7 +8725,7 @@ var init_HighlightPopover = __esm({
8624
8725
  }) {
8625
8726
  const [isEditingComment, setIsEditingComment] = (0, import_react36.useState)(false);
8626
8727
  const [comment, setComment] = (0, import_react36.useState)(highlight?.comment ?? "");
8627
- const [position, setPosition] = (0, import_react36.useState)({ top: 0, left: 0, visible: false });
8728
+ const [position2, setPosition] = (0, import_react36.useState)({ top: 0, left: 0, visible: false });
8628
8729
  const popoverRef = (0, import_react36.useRef)(null);
8629
8730
  const textareaRef = (0, import_react36.useRef)(null);
8630
8731
  (0, import_react36.useEffect)(() => {
@@ -8672,11 +8773,11 @@ var init_HighlightPopover = __esm({
8672
8773
  onClose();
8673
8774
  }
8674
8775
  }
8675
- if (position.visible) {
8776
+ if (position2.visible) {
8676
8777
  document.addEventListener("mousedown", handleClickOutside);
8677
8778
  return () => document.removeEventListener("mousedown", handleClickOutside);
8678
8779
  }
8679
- }, [position.visible, onClose]);
8780
+ }, [position2.visible, onClose]);
8680
8781
  (0, import_react36.useEffect)(() => {
8681
8782
  function handleKeyDown(event) {
8682
8783
  if (event.key === "Escape") {
@@ -8688,11 +8789,11 @@ var init_HighlightPopover = __esm({
8688
8789
  }
8689
8790
  }
8690
8791
  }
8691
- if (position.visible) {
8792
+ if (position2.visible) {
8692
8793
  document.addEventListener("keydown", handleKeyDown);
8693
8794
  return () => document.removeEventListener("keydown", handleKeyDown);
8694
8795
  }
8695
- }, [position.visible, isEditingComment, highlight?.comment, onClose]);
8796
+ }, [position2.visible, isEditingComment, highlight?.comment, onClose]);
8696
8797
  const handleColorClick = (0, import_react36.useCallback)(
8697
8798
  (color) => {
8698
8799
  if (highlight) {
@@ -8719,7 +8820,7 @@ var init_HighlightPopover = __esm({
8719
8820
  onCopy?.(highlight.text);
8720
8821
  }
8721
8822
  }, [highlight, onCopy]);
8722
- if (!highlight || !position.visible) {
8823
+ if (!highlight || !position2.visible) {
8723
8824
  return null;
8724
8825
  }
8725
8826
  return /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)(
@@ -8736,8 +8837,8 @@ var init_HighlightPopover = __esm({
8736
8837
  className
8737
8838
  ),
8738
8839
  style: {
8739
- top: position.top,
8740
- left: position.left,
8840
+ top: position2.top,
8841
+ left: position2.left,
8741
8842
  transform: "translate(-50%, -100%)",
8742
8843
  width: 280
8743
8844
  },
@@ -9964,10 +10065,23 @@ var init_BookModeContainer = __esm({
9964
10065
  const scrollToPageRequest = useViewerStore((s) => s.scrollToPageRequest);
9965
10066
  const { viewerStore } = usePDFViewerStores();
9966
10067
  const [pages, setPages] = (0, import_react41.useState)([]);
9967
- const [pageDims, setPageDims] = (0, import_react41.useState)({ width: 612, height: 792 });
10068
+ const [rawPageDims, setRawPageDims] = (0, import_react41.useState)({ width: 612, height: 792 });
9968
10069
  const [isLoadingPages, setIsLoadingPages] = (0, import_react41.useState)(false);
10070
+ const containerRef = (0, import_react41.useRef)(null);
10071
+ const [containerSize, setContainerSize] = (0, import_react41.useState)({ width: 0, height: 0 });
9969
10072
  const flipBookRef = (0, import_react41.useRef)(null);
9970
10073
  const isSyncingRef = (0, import_react41.useRef)(false);
10074
+ (0, import_react41.useEffect)(() => {
10075
+ const el = containerRef.current;
10076
+ if (!el) return;
10077
+ const measure = () => {
10078
+ setContainerSize({ width: el.clientWidth, height: el.clientHeight });
10079
+ };
10080
+ measure();
10081
+ const ro = new ResizeObserver(measure);
10082
+ ro.observe(el);
10083
+ return () => ro.disconnect();
10084
+ }, []);
9971
10085
  (0, import_react41.useEffect)(() => {
9972
10086
  if (!document2) {
9973
10087
  setPages([]);
@@ -9983,12 +10097,12 @@ var init_BookModeContainer = __esm({
9983
10097
  }
9984
10098
  const results = await Promise.allSettled(pagePromises);
9985
10099
  if (!cancelled) {
9986
- const loaded = results.map((r) => r.status === "fulfilled" ? r.value : null);
9987
- setPages(loaded);
9988
- const firstPage = loaded[0];
10100
+ const loaded2 = results.map((r) => r.status === "fulfilled" ? r.value : null);
10101
+ setPages(loaded2);
10102
+ const firstPage = loaded2[0];
9989
10103
  if (firstPage) {
9990
- const vp = firstPage.getViewport({ scale, rotation });
9991
- setPageDims({ width: Math.floor(vp.width), height: Math.floor(vp.height) });
10104
+ const vp = firstPage.getViewport({ scale: 1, rotation });
10105
+ setRawPageDims({ width: vp.width, height: vp.height });
9992
10106
  }
9993
10107
  }
9994
10108
  } catch {
@@ -10000,13 +10114,27 @@ var init_BookModeContainer = __esm({
10000
10114
  return () => {
10001
10115
  cancelled = true;
10002
10116
  };
10003
- }, [document2, numPages, scale, rotation]);
10117
+ }, [document2, numPages, rotation]);
10004
10118
  (0, import_react41.useEffect)(() => {
10005
10119
  if (pages[0]) {
10006
- const vp = pages[0].getViewport({ scale, rotation });
10007
- setPageDims({ width: Math.floor(vp.width), height: Math.floor(vp.height) });
10008
- }
10009
- }, [pages, scale, rotation]);
10120
+ const vp = pages[0].getViewport({ scale: 1, rotation });
10121
+ setRawPageDims({ width: vp.width, height: vp.height });
10122
+ }
10123
+ }, [pages, rotation]);
10124
+ const padding = 8;
10125
+ const fitWidth = Math.max(containerSize.width - padding * 2, 200);
10126
+ const fitHeight = Math.max(containerSize.height - padding * 2, 300);
10127
+ const pageAspect = rawPageDims.width / rawPageDims.height;
10128
+ let displayWidth;
10129
+ let displayHeight;
10130
+ if (fitWidth / fitHeight > pageAspect) {
10131
+ displayHeight = fitHeight;
10132
+ displayWidth = Math.floor(fitHeight * pageAspect);
10133
+ } else {
10134
+ displayWidth = fitWidth;
10135
+ displayHeight = Math.floor(fitWidth / pageAspect);
10136
+ }
10137
+ const renderScale = displayWidth / rawPageDims.width;
10010
10138
  (0, import_react41.useEffect)(() => {
10011
10139
  const pageFlip = flipBookRef.current?.pageFlip();
10012
10140
  if (!pageFlip) return;
@@ -10040,18 +10168,15 @@ var init_BookModeContainer = __esm({
10040
10168
  sepia: "bg-amber-50"
10041
10169
  };
10042
10170
  const themeClass = theme === "dark" ? "dark" : theme === "sepia" ? "sepia" : "";
10043
- if (!document2) {
10044
- return /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("div", { className: cn("document-container", "flex-1", themeStyles[theme], className), children: /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(PDFLoadingScreen, { phase: isLoading ? "fetching" : "initializing" }) });
10045
- }
10046
- if (isLoadingPages || pages.length === 0) {
10047
- return /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("div", { className: cn("document-container", "flex-1", themeStyles[theme], className), children: /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(PDFLoadingScreen, { phase: "rendering" }) });
10048
- }
10171
+ const ready = !!document2 && !isLoadingPages && pages.length > 0;
10172
+ const hasContainer = containerSize.width > 0 && containerSize.height > 0;
10049
10173
  return /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(
10050
10174
  "div",
10051
10175
  {
10176
+ ref: containerRef,
10052
10177
  className: cn(
10053
10178
  "book-mode-container",
10054
- "flex-1 overflow-hidden",
10179
+ "flex-1 h-full w-full overflow-hidden",
10055
10180
  "flex items-center justify-center",
10056
10181
  themeStyles[theme],
10057
10182
  themeClass,
@@ -10059,24 +10184,30 @@ var init_BookModeContainer = __esm({
10059
10184
  ),
10060
10185
  style: { userSelect: "none", WebkitUserSelect: "none" },
10061
10186
  children: [
10062
- /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
10187
+ !ready && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
10188
+ PDFLoadingScreen,
10189
+ {
10190
+ phase: !document2 ? isLoading ? "fetching" : "initializing" : "rendering"
10191
+ }
10192
+ ),
10193
+ ready && hasContainer && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
10063
10194
  import_react_pageflip.default,
10064
10195
  {
10065
10196
  ref: flipBookRef,
10066
- width: pageDims.width,
10067
- height: pageDims.height,
10068
- size: "stretch",
10069
- minWidth: 300,
10070
- maxWidth: pageDims.width,
10071
- minHeight: 400,
10072
- maxHeight: pageDims.height,
10197
+ width: displayWidth,
10198
+ height: displayHeight,
10199
+ size: "fixed",
10200
+ minWidth: displayWidth,
10201
+ maxWidth: displayWidth,
10202
+ minHeight: displayHeight,
10203
+ maxHeight: displayHeight,
10073
10204
  drawShadow,
10074
10205
  maxShadowOpacity,
10075
10206
  flippingTime,
10076
10207
  usePortrait: true,
10077
10208
  startPage: currentPage - 1,
10078
10209
  showCover: false,
10079
- mobileScrollSupport: false,
10210
+ mobileScrollSupport: true,
10080
10211
  swipeDistance: 30,
10081
10212
  showPageCorners: true,
10082
10213
  useMouseEvents: true,
@@ -10085,7 +10216,7 @@ var init_BookModeContainer = __esm({
10085
10216
  className: "book-flipbook",
10086
10217
  style: {},
10087
10218
  startZIndex: 0,
10088
- autoSize: true,
10219
+ autoSize: false,
10089
10220
  renderOnlyPageLengthChange: false,
10090
10221
  disableFlipByClick: false,
10091
10222
  children: pages.map((page, index) => /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
@@ -10093,20 +10224,15 @@ var init_BookModeContainer = __esm({
10093
10224
  {
10094
10225
  pageNumber: index + 1,
10095
10226
  page,
10096
- scale,
10227
+ scale: renderScale,
10097
10228
  rotation,
10098
- width: pageDims.width,
10099
- height: pageDims.height
10229
+ width: displayWidth,
10230
+ height: displayHeight
10100
10231
  },
10101
10232
  index
10102
10233
  ))
10103
10234
  }
10104
- ),
10105
- /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)("div", { className: "book-page-indicator", children: [
10106
- currentPage,
10107
- " / ",
10108
- numPages
10109
- ] })
10235
+ )
10110
10236
  ]
10111
10237
  }
10112
10238
  );
@@ -10124,7 +10250,7 @@ var init_FloatingZoomControls = __esm({
10124
10250
  init_utils();
10125
10251
  import_jsx_runtime28 = require("react/jsx-runtime");
10126
10252
  FloatingZoomControls = (0, import_react42.memo)(function FloatingZoomControls2({
10127
- position = "bottom-right",
10253
+ position: position2 = "bottom-right",
10128
10254
  className,
10129
10255
  showFitToWidth = true,
10130
10256
  showFitToPage = false,
@@ -10165,7 +10291,7 @@ var init_FloatingZoomControls = __esm({
10165
10291
  "bg-white dark:bg-gray-800 rounded-lg shadow-lg",
10166
10292
  "border border-gray-200 dark:border-gray-700",
10167
10293
  "p-1",
10168
- positionClasses[position],
10294
+ positionClasses[position2],
10169
10295
  className
10170
10296
  ),
10171
10297
  children: [
@@ -10871,12 +10997,12 @@ var init_PDFViewerClient = __esm({
10871
10997
  src,
10872
10998
  workerSrc,
10873
10999
  signal: abortController.signal,
10874
- onProgress: ({ loaded, total }) => {
11000
+ onProgress: ({ loaded: loaded2, total }) => {
10875
11001
  if (!mountedRef.current || srcIdRef.current !== loadId || abortController.signal.aborted) {
10876
11002
  return;
10877
11003
  }
10878
11004
  const now = Date.now();
10879
- const percent = total > 0 ? Math.round(loaded / total * 100) : 0;
11005
+ const percent = total > 0 ? Math.round(loaded2 / total * 100) : 0;
10880
11006
  const timePassed = now - lastProgressUpdate >= PROGRESS_THROTTLE_MS;
10881
11007
  const percentChanged = Math.abs(percent - lastPercent) >= PROGRESS_MIN_CHANGE;
10882
11008
  const isComplete = percent >= 100;
@@ -10887,10 +11013,10 @@ var init_PDFViewerClient = __esm({
10887
11013
  loadingProgress: {
10888
11014
  phase: "fetching",
10889
11015
  percent,
10890
- bytesLoaded: loaded,
11016
+ bytesLoaded: loaded2,
10891
11017
  totalBytes: total
10892
11018
  },
10893
- streamingProgress: { loaded, total },
11019
+ streamingProgress: { loaded: loaded2, total },
10894
11020
  documentLoadingState: "loading"
10895
11021
  });
10896
11022
  }
@@ -11169,19 +11295,26 @@ var init_PDFViewer2 = __esm({
11169
11295
  // src/index.ts
11170
11296
  var index_exports = {};
11171
11297
  __export(index_exports, {
11298
+ AnimatedHighlight: () => AnimatedHighlight,
11299
+ AnimatedUnderline: () => AnimatedUnderline,
11172
11300
  AnnotationLayer: () => AnnotationLayer,
11173
11301
  AnnotationToolbar: () => AnnotationToolbar,
11174
11302
  AskAboutOverlay: () => AskAboutOverlay,
11175
11303
  AskAboutTrigger: () => AskAboutTrigger,
11176
11304
  BookModeContainer: () => BookModeContainer,
11177
11305
  BookmarksPanel: () => BookmarksPanel,
11306
+ BoxOverlay: () => BoxOverlay,
11307
+ CalloutArrow: () => CalloutArrow,
11308
+ CameraView: () => CameraView,
11178
11309
  CanvasLayer: () => CanvasLayer,
11310
+ CinemaLayer: () => CinemaLayer,
11179
11311
  ContinuousScrollContainer: () => ContinuousScrollContainer,
11180
11312
  DocumentContainer: () => DocumentContainer,
11181
11313
  DrawingCanvas: () => DrawingCanvas,
11182
11314
  DualPageContainer: () => DualPageContainer,
11183
11315
  FloatingZoomControls: () => FloatingZoomControls,
11184
11316
  FocusRegionLayer: () => FocusRegionLayer,
11317
+ GhostReference: () => GhostReference,
11185
11318
  HighlightLayer: () => HighlightLayer,
11186
11319
  HighlightPopover: () => HighlightPopover,
11187
11320
  HighlightsPanel: () => HighlightsPanel,
@@ -11198,32 +11331,46 @@ __export(index_exports, {
11198
11331
  PDFViewerContext: () => PDFViewerContext,
11199
11332
  PDFViewerProvider: () => PDFViewerProvider,
11200
11333
  PluginManager: () => PluginManager,
11334
+ PulseOverlay: () => PulseOverlay,
11201
11335
  QuickNoteButton: () => QuickNoteButton,
11202
11336
  QuickNotePopover: () => QuickNotePopover,
11337
+ SYSTEM_PROMPT: () => SYSTEM_PROMPT,
11203
11338
  SearchPanel: () => SearchPanel,
11204
11339
  SelectionToolbar: () => SelectionToolbar,
11205
11340
  ShapePreview: () => ShapePreview,
11206
11341
  ShapeRenderer: () => ShapeRenderer,
11207
11342
  Sidebar: () => Sidebar,
11343
+ SpotlightMask: () => SpotlightMask,
11344
+ StickyLabel: () => StickyLabel,
11208
11345
  StickyNote: () => StickyNote,
11346
+ StoryboardActionSchema: () => StoryboardActionSchema,
11347
+ StoryboardEngine: () => StoryboardEngine,
11348
+ StoryboardSchema: () => StoryboardSchema,
11349
+ SubtitleBar: () => SubtitleBar,
11209
11350
  TakeawaysPanel: () => TakeawaysPanel,
11210
11351
  TextLayer: () => TextLayer,
11211
11352
  ThumbnailPanel: () => ThumbnailPanel,
11212
11353
  Toolbar: () => Toolbar,
11354
+ TutorModeContainer: () => TutorModeContainer,
11213
11355
  VirtualizedDocumentContainer: () => VirtualizedDocumentContainer,
11214
11356
  applyRotation: () => applyRotation,
11357
+ buildBBoxIndex: () => buildBBoxIndex,
11358
+ buildUserPrompt: () => buildUserPrompt,
11215
11359
  clearHighlights: () => clearHighlights,
11216
11360
  clearStudentData: () => clearStudentData,
11217
11361
  cn: () => cn,
11362
+ cosineSimilarity: () => cosineSimilarity,
11218
11363
  countTextOnPage: () => countTextOnPage,
11219
11364
  createAgentAPI: () => createAgentAPI,
11220
11365
  createAgentStore: () => createAgentStore,
11221
11366
  createAnnotationStore: () => createAnnotationStore,
11367
+ createNarrationStore: () => createNarrationStore,
11222
11368
  createPDFViewer: () => createPDFViewer,
11223
11369
  createPluginManager: () => createPluginManager,
11224
11370
  createSearchStore: () => createSearchStore,
11225
11371
  createStudentStore: () => createStudentStore,
11226
11372
  createViewerStore: () => createViewerStore,
11373
+ directStoryboard: () => directStoryboard,
11227
11374
  doRectsIntersect: () => doRectsIntersect,
11228
11375
  downloadAnnotationsAsJSON: () => downloadAnnotationsAsJSON,
11229
11376
  downloadAnnotationsAsMarkdown: () => downloadAnnotationsAsMarkdown,
@@ -11238,6 +11385,7 @@ __export(index_exports, {
11238
11385
  generateDocumentId: () => generateDocumentId,
11239
11386
  getAllDocumentIds: () => getAllDocumentIds,
11240
11387
  getAllStudentDataDocumentIds: () => getAllStudentDataDocumentIds,
11388
+ getLocalMiniLM: () => getLocalMiniLM,
11241
11389
  getMetadata: () => getMetadata,
11242
11390
  getOutline: () => getOutline,
11243
11391
  getPage: () => getPage,
@@ -11255,6 +11403,8 @@ __export(index_exports, {
11255
11403
  loadDocumentWithCallbacks: () => loadDocumentWithCallbacks,
11256
11404
  loadHighlights: () => loadHighlights,
11257
11405
  loadStudentData: () => loadStudentData,
11406
+ makeOverlayId: () => makeOverlayId,
11407
+ matchChunkToBlock: () => matchChunkToBlock,
11258
11408
  mergeAdjacentRects: () => mergeAdjacentRects,
11259
11409
  pdfToPercent: () => pdfToPercent,
11260
11410
  pdfToViewport: () => pdfToViewport,
@@ -11267,6 +11417,9 @@ __export(index_exports, {
11267
11417
  saveHighlights: () => saveHighlights,
11268
11418
  saveStudentData: () => saveStudentData,
11269
11419
  scaleRect: () => scaleRect,
11420
+ storyboardFromMatch: () => storyboardFromMatch,
11421
+ storyboardJsonSchema: () => storyboardJsonSchema,
11422
+ truncate: () => truncate,
11270
11423
  useAgentContext: () => useAgentContext,
11271
11424
  useAgentStore: () => useAgentStore,
11272
11425
  useAnnotationStore: () => useAnnotationStore,
@@ -11966,7 +12119,7 @@ var import_jsx_runtime34 = require("react/jsx-runtime");
11966
12119
  var QuickNoteButton = (0, import_react48.memo)(function QuickNoteButton2({
11967
12120
  pageNumber,
11968
12121
  scale,
11969
- position = "top-right",
12122
+ position: position2 = "top-right",
11970
12123
  onClick,
11971
12124
  className,
11972
12125
  visible = true
@@ -11975,11 +12128,11 @@ var QuickNoteButton = (0, import_react48.memo)(function QuickNoteButton2({
11975
12128
  const handleClick = (0, import_react48.useCallback)(
11976
12129
  (e) => {
11977
12130
  e.stopPropagation();
11978
- const x = position === "top-right" ? 80 : 80;
11979
- const y = position === "top-right" ? 20 : 80;
12131
+ const x = position2 === "top-right" ? 80 : 80;
12132
+ const y = position2 === "top-right" ? 20 : 80;
11980
12133
  onClick(pageNumber, x / scale, y / scale);
11981
12134
  },
11982
- [pageNumber, onClick, position, scale]
12135
+ [pageNumber, onClick, position2, scale]
11983
12136
  );
11984
12137
  if (!visible) {
11985
12138
  return null;
@@ -12000,8 +12153,8 @@ var QuickNoteButton = (0, import_react48.memo)(function QuickNoteButton2({
12000
12153
  "transition-all duration-200",
12001
12154
  "focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2",
12002
12155
  isHovered && "scale-110",
12003
- position === "top-right" && "top-3 right-3",
12004
- position === "bottom-right" && "bottom-3 right-3",
12156
+ position2 === "top-right" && "top-3 right-3",
12157
+ position2 === "bottom-right" && "bottom-3 right-3",
12005
12158
  className
12006
12159
  ),
12007
12160
  title: "Add quick note",
@@ -12027,7 +12180,7 @@ init_utils();
12027
12180
  var import_jsx_runtime35 = require("react/jsx-runtime");
12028
12181
  var QuickNotePopover = (0, import_react49.memo)(function QuickNotePopover2({
12029
12182
  visible,
12030
- position,
12183
+ position: position2,
12031
12184
  initialContent = "",
12032
12185
  agentLastStatement,
12033
12186
  onSave,
@@ -12037,7 +12190,7 @@ var QuickNotePopover = (0, import_react49.memo)(function QuickNotePopover2({
12037
12190
  const [content, setContent] = (0, import_react49.useState)(initialContent);
12038
12191
  const textareaRef = (0, import_react49.useRef)(null);
12039
12192
  const popoverRef = (0, import_react49.useRef)(null);
12040
- const [adjustedPosition, setAdjustedPosition] = (0, import_react49.useState)(position);
12193
+ const [adjustedPosition, setAdjustedPosition] = (0, import_react49.useState)(position2);
12041
12194
  (0, import_react49.useEffect)(() => {
12042
12195
  if (visible && textareaRef.current) {
12043
12196
  textareaRef.current.focus();
@@ -12052,7 +12205,7 @@ var QuickNotePopover = (0, import_react49.memo)(function QuickNotePopover2({
12052
12205
  if (!visible || !popoverRef.current) return;
12053
12206
  const rect = popoverRef.current.getBoundingClientRect();
12054
12207
  const padding = 10;
12055
- let { x, y } = position;
12208
+ let { x, y } = position2;
12056
12209
  if (x + rect.width > window.innerWidth - padding) {
12057
12210
  x = window.innerWidth - rect.width - padding;
12058
12211
  }
@@ -12066,7 +12219,7 @@ var QuickNotePopover = (0, import_react49.memo)(function QuickNotePopover2({
12066
12219
  y = padding;
12067
12220
  }
12068
12221
  setAdjustedPosition({ x, y });
12069
- }, [position, visible]);
12222
+ }, [position2, visible]);
12070
12223
  const handleSave = (0, import_react49.useCallback)(() => {
12071
12224
  if (content.trim()) {
12072
12225
  onSave(content.trim());
@@ -12183,11 +12336,11 @@ var import_jsx_runtime36 = require("react/jsx-runtime");
12183
12336
  var AskAboutOverlay = (0, import_react50.memo)(function AskAboutOverlay2({
12184
12337
  visible,
12185
12338
  progress,
12186
- position,
12339
+ position: position2,
12187
12340
  size = 60,
12188
12341
  className
12189
12342
  }) {
12190
- if (!visible || !position) {
12343
+ if (!visible || !position2) {
12191
12344
  return null;
12192
12345
  }
12193
12346
  const strokeWidth = 4;
@@ -12203,8 +12356,8 @@ var AskAboutOverlay = (0, import_react50.memo)(function AskAboutOverlay2({
12203
12356
  className
12204
12357
  ),
12205
12358
  style: {
12206
- left: position.x - size / 2,
12207
- top: position.y - size / 2
12359
+ left: position2.x - size / 2,
12360
+ top: position2.y - size / 2
12208
12361
  },
12209
12362
  children: [
12210
12363
  /* @__PURE__ */ (0, import_jsx_runtime36.jsxs)(
@@ -12292,20 +12445,20 @@ var import_react51 = require("react");
12292
12445
  init_utils();
12293
12446
  var import_jsx_runtime37 = require("react/jsx-runtime");
12294
12447
  var AskAboutTrigger = (0, import_react51.memo)(function AskAboutTrigger2({
12295
- position,
12448
+ position: position2,
12296
12449
  onConfirm,
12297
12450
  onCancel,
12298
12451
  visible,
12299
12452
  autoHideDelay = 5e3,
12300
12453
  className
12301
12454
  }) {
12302
- const [adjustedPosition, setAdjustedPosition] = (0, import_react51.useState)(position);
12455
+ const [adjustedPosition, setAdjustedPosition] = (0, import_react51.useState)(position2);
12303
12456
  const triggerRef = (0, import_react51.useRef)(null);
12304
12457
  (0, import_react51.useEffect)(() => {
12305
12458
  if (!visible || !triggerRef.current) return;
12306
12459
  const rect = triggerRef.current.getBoundingClientRect();
12307
12460
  const padding = 10;
12308
- let { x, y } = position;
12461
+ let { x, y } = position2;
12309
12462
  if (x + rect.width / 2 > window.innerWidth - padding) {
12310
12463
  x = window.innerWidth - rect.width / 2 - padding;
12311
12464
  }
@@ -12313,10 +12466,10 @@ var AskAboutTrigger = (0, import_react51.memo)(function AskAboutTrigger2({
12313
12466
  x = rect.width / 2 + padding;
12314
12467
  }
12315
12468
  if (y + rect.height > window.innerHeight - padding) {
12316
- y = position.y - rect.height - 20;
12469
+ y = position2.y - rect.height - 20;
12317
12470
  }
12318
12471
  setAdjustedPosition({ x, y });
12319
- }, [position, visible]);
12472
+ }, [position2, visible]);
12320
12473
  (0, import_react51.useEffect)(() => {
12321
12474
  if (!visible || autoHideDelay === 0) return;
12322
12475
  const timer = setTimeout(onCancel, autoHideDelay);
@@ -12964,6 +13117,2167 @@ function withErrorBoundary({ component, ...props }) {
12964
13117
  // src/components/index.ts
12965
13118
  init_PDFLoadingScreen2();
12966
13119
 
13120
+ // src/components/TutorMode/TutorModeContainer.tsx
13121
+ var import_react56 = require("react");
13122
+ var import_zustand2 = require("zustand");
13123
+ init_PDFPage2();
13124
+ init_hooks();
13125
+
13126
+ // src/components/TutorMode/CameraView.tsx
13127
+ var import_framer_motion = require("framer-motion");
13128
+ var import_jsx_runtime41 = require("react/jsx-runtime");
13129
+ function CameraView({
13130
+ camera,
13131
+ children,
13132
+ durationMs = 700,
13133
+ className
13134
+ }) {
13135
+ return /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(
13136
+ import_framer_motion.motion.div,
13137
+ {
13138
+ className,
13139
+ style: {
13140
+ transformOrigin: "50% 50%",
13141
+ willChange: "transform",
13142
+ width: "100%",
13143
+ height: "100%",
13144
+ position: "relative"
13145
+ },
13146
+ animate: {
13147
+ scale: camera.scale,
13148
+ x: camera.x,
13149
+ y: camera.y
13150
+ },
13151
+ transition: {
13152
+ duration: durationMs / 1e3,
13153
+ ease: camera.easing === "linear" ? "linear" : camera.easing === "ease-in" ? [0.42, 0, 1, 1] : camera.easing === "ease-out" ? [0, 0, 0.58, 1] : [0.42, 0, 0.58, 1]
13154
+ },
13155
+ children
13156
+ }
13157
+ );
13158
+ }
13159
+
13160
+ // src/components/TutorMode/CinemaLayer.tsx
13161
+ var import_framer_motion10 = require("framer-motion");
13162
+
13163
+ // src/components/TutorMode/SpotlightMask.tsx
13164
+ var import_react55 = require("react");
13165
+ var import_framer_motion2 = require("framer-motion");
13166
+ var import_jsx_runtime42 = require("react/jsx-runtime");
13167
+ function SpotlightMask({
13168
+ page,
13169
+ bbox,
13170
+ action,
13171
+ durationMs = 400
13172
+ }) {
13173
+ const maskId = (0, import_react55.useId)();
13174
+ const filterId = `${maskId}-blur`;
13175
+ const [x1, y1, x2, y2] = bbox;
13176
+ const w = Math.max(0, x2 - x1);
13177
+ const h = Math.max(0, y2 - y1);
13178
+ const rx = action.shape === "rounded" ? 12 : action.shape === "ellipse" ? w / 2 : 0;
13179
+ const ry = action.shape === "rounded" ? 12 : action.shape === "ellipse" ? h / 2 : 0;
13180
+ const feather = action.feather_px;
13181
+ return /* @__PURE__ */ (0, import_jsx_runtime42.jsxs)(
13182
+ "svg",
13183
+ {
13184
+ viewBox: `0 0 ${page.width} ${page.height}`,
13185
+ width: page.width,
13186
+ height: page.height,
13187
+ preserveAspectRatio: "none",
13188
+ style: {
13189
+ position: "absolute",
13190
+ inset: 0,
13191
+ pointerEvents: "none",
13192
+ width: page.width,
13193
+ height: page.height
13194
+ },
13195
+ "data-role": "spotlight-mask",
13196
+ children: [
13197
+ /* @__PURE__ */ (0, import_jsx_runtime42.jsxs)("defs", { children: [
13198
+ /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("filter", { id: filterId, x: "-20%", y: "-20%", width: "140%", height: "140%", children: /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("feGaussianBlur", { in: "SourceGraphic", stdDeviation: feather / 4 }) }),
13199
+ /* @__PURE__ */ (0, import_jsx_runtime42.jsxs)("mask", { id: maskId, children: [
13200
+ /* @__PURE__ */ (0, import_jsx_runtime42.jsx)("rect", { x: 0, y: 0, width: page.width, height: page.height, fill: "white" }),
13201
+ action.shape === "ellipse" ? /* @__PURE__ */ (0, import_jsx_runtime42.jsx)(
13202
+ "ellipse",
13203
+ {
13204
+ cx: (x1 + x2) / 2,
13205
+ cy: (y1 + y2) / 2,
13206
+ rx: w / 2,
13207
+ ry: h / 2,
13208
+ fill: "black",
13209
+ filter: `url(#${filterId})`
13210
+ }
13211
+ ) : /* @__PURE__ */ (0, import_jsx_runtime42.jsx)(
13212
+ "rect",
13213
+ {
13214
+ x: x1,
13215
+ y: y1,
13216
+ width: w,
13217
+ height: h,
13218
+ rx,
13219
+ ry,
13220
+ fill: "black",
13221
+ filter: `url(#${filterId})`
13222
+ }
13223
+ )
13224
+ ] })
13225
+ ] }),
13226
+ /* @__PURE__ */ (0, import_jsx_runtime42.jsx)(
13227
+ import_framer_motion2.motion.rect,
13228
+ {
13229
+ x: 0,
13230
+ y: 0,
13231
+ width: page.width,
13232
+ height: page.height,
13233
+ fill: "black",
13234
+ mask: `url(#${maskId})`,
13235
+ initial: { fillOpacity: 0 },
13236
+ animate: { fillOpacity: action.dim_opacity },
13237
+ exit: { fillOpacity: 0 },
13238
+ transition: { duration: durationMs / 1e3, ease: "easeOut" }
13239
+ }
13240
+ )
13241
+ ]
13242
+ }
13243
+ );
13244
+ }
13245
+
13246
+ // src/components/TutorMode/AnimatedUnderline.tsx
13247
+ var import_framer_motion3 = require("framer-motion");
13248
+ var import_jsx_runtime43 = require("react/jsx-runtime");
13249
+ function pathForStyle(x1, x2, y, style) {
13250
+ if (style === "straight") return `M ${x1} ${y} L ${x2} ${y}`;
13251
+ if (style === "double")
13252
+ return `M ${x1} ${y - 3} L ${x2} ${y - 3} M ${x1} ${y + 3} L ${x2} ${y + 3}`;
13253
+ if (style === "wavy") {
13254
+ const steps = Math.max(8, Math.floor((x2 - x1) / 18));
13255
+ let d2 = `M ${x1} ${y}`;
13256
+ for (let i = 1; i <= steps; i++) {
13257
+ const px = x1 + (x2 - x1) * i / steps;
13258
+ const dy = i % 2 === 0 ? 4 : -4;
13259
+ d2 += ` Q ${px - (x2 - x1) / (2 * steps)} ${y + dy} ${px} ${y}`;
13260
+ }
13261
+ return d2;
13262
+ }
13263
+ const segs = 6;
13264
+ let d = `M ${x1} ${y}`;
13265
+ for (let i = 1; i <= segs; i++) {
13266
+ const px = x1 + (x2 - x1) * i / segs;
13267
+ const jitter = (Math.random() - 0.5) * 4;
13268
+ d += ` L ${px} ${y + jitter}`;
13269
+ }
13270
+ return d;
13271
+ }
13272
+ function AnimatedUnderline({ bbox, action }) {
13273
+ const [x1, , x2, y2] = bbox;
13274
+ const y = y2 + 6;
13275
+ const d = pathForStyle(x1, x2, y, action.style);
13276
+ const duration = action.draw_duration_ms / 1e3;
13277
+ return /* @__PURE__ */ (0, import_jsx_runtime43.jsx)(
13278
+ "svg",
13279
+ {
13280
+ style: {
13281
+ position: "absolute",
13282
+ inset: 0,
13283
+ pointerEvents: "none",
13284
+ overflow: "visible"
13285
+ },
13286
+ "data-role": "underline",
13287
+ children: /* @__PURE__ */ (0, import_jsx_runtime43.jsx)(
13288
+ import_framer_motion3.motion.path,
13289
+ {
13290
+ d,
13291
+ fill: "none",
13292
+ stroke: action.color,
13293
+ strokeWidth: 4,
13294
+ strokeLinecap: "round",
13295
+ initial: { pathLength: 0, opacity: 0 },
13296
+ animate: { pathLength: 1, opacity: 1 },
13297
+ exit: { opacity: 0 },
13298
+ transition: { duration, ease: "easeOut" }
13299
+ }
13300
+ )
13301
+ }
13302
+ );
13303
+ }
13304
+
13305
+ // src/components/TutorMode/AnimatedHighlight.tsx
13306
+ var import_framer_motion4 = require("framer-motion");
13307
+ var import_jsx_runtime44 = require("react/jsx-runtime");
13308
+ function AnimatedHighlight({ bbox, action }) {
13309
+ const [x1, y1, x2, y2] = bbox;
13310
+ const w = x2 - x1;
13311
+ const h = y2 - y1;
13312
+ return /* @__PURE__ */ (0, import_jsx_runtime44.jsx)(
13313
+ import_framer_motion4.motion.div,
13314
+ {
13315
+ style: {
13316
+ position: "absolute",
13317
+ left: x1,
13318
+ top: y1,
13319
+ height: h,
13320
+ background: action.color,
13321
+ borderRadius: 4,
13322
+ mixBlendMode: "multiply",
13323
+ transformOrigin: "0% 50%",
13324
+ pointerEvents: "none"
13325
+ },
13326
+ initial: { width: 0, opacity: 0.9 },
13327
+ animate: { width: w, opacity: 0.9 },
13328
+ exit: { opacity: 0 },
13329
+ transition: { duration: action.draw_duration_ms / 1e3, ease: "easeOut" },
13330
+ "data-role": "highlight"
13331
+ }
13332
+ );
13333
+ }
13334
+
13335
+ // src/components/TutorMode/PulseOverlay.tsx
13336
+ var import_framer_motion5 = require("framer-motion");
13337
+ var import_jsx_runtime45 = require("react/jsx-runtime");
13338
+ var INTENSITY = {
13339
+ subtle: { scale: 1.02, border: "2px solid rgba(59,130,246,0.6)" },
13340
+ normal: { scale: 1.05, border: "3px solid rgba(59,130,246,0.8)" },
13341
+ strong: { scale: 1.1, border: "4px solid rgba(59,130,246,1.0)" }
13342
+ };
13343
+ function PulseOverlay({ bbox, action }) {
13344
+ const [x1, y1, x2, y2] = bbox;
13345
+ const { scale, border } = INTENSITY[action.intensity];
13346
+ const repeat = action.count === 1 ? 0 : action.count - 1;
13347
+ return /* @__PURE__ */ (0, import_jsx_runtime45.jsx)(
13348
+ import_framer_motion5.motion.div,
13349
+ {
13350
+ style: {
13351
+ position: "absolute",
13352
+ left: x1,
13353
+ top: y1,
13354
+ width: x2 - x1,
13355
+ height: y2 - y1,
13356
+ border,
13357
+ borderRadius: 8,
13358
+ pointerEvents: "none",
13359
+ boxSizing: "border-box"
13360
+ },
13361
+ animate: { scale: [1, scale, 1] },
13362
+ transition: {
13363
+ duration: 1.2,
13364
+ times: [0, 0.5, 1],
13365
+ ease: "easeInOut",
13366
+ repeat,
13367
+ repeatType: "loop"
13368
+ },
13369
+ exit: { opacity: 0 },
13370
+ "data-role": "pulse"
13371
+ }
13372
+ );
13373
+ }
13374
+
13375
+ // src/components/TutorMode/CalloutArrow.tsx
13376
+ var import_framer_motion6 = require("framer-motion");
13377
+ var import_jsx_runtime46 = require("react/jsx-runtime");
13378
+ function centerOf(b) {
13379
+ return { x: (b[0] + b[2]) / 2, y: (b[1] + b[3]) / 2 };
13380
+ }
13381
+ function arrowPath(fromBbox, toBbox, curve) {
13382
+ const a = centerOf(fromBbox);
13383
+ const b = centerOf(toBbox);
13384
+ if (curve === "straight") return `M ${a.x} ${a.y} L ${b.x} ${b.y}`;
13385
+ if (curve === "zigzag") {
13386
+ const mx = (a.x + b.x) / 2;
13387
+ return `M ${a.x} ${a.y} L ${mx} ${a.y} L ${mx} ${b.y} L ${b.x} ${b.y}`;
13388
+ }
13389
+ const dx = b.x - a.x;
13390
+ const dy = b.y - a.y;
13391
+ const cx = (a.x + b.x) / 2 - dy * 0.25;
13392
+ const cy = (a.y + b.y) / 2 + dx * 0.25;
13393
+ return `M ${a.x} ${a.y} Q ${cx} ${cy} ${b.x} ${b.y}`;
13394
+ }
13395
+ function CalloutArrow({ fromBbox, toBbox, action }) {
13396
+ const d = arrowPath(fromBbox, toBbox, action.curve);
13397
+ const label = action.label;
13398
+ const target = centerOf(toBbox);
13399
+ return /* @__PURE__ */ (0, import_jsx_runtime46.jsxs)(
13400
+ "svg",
13401
+ {
13402
+ style: {
13403
+ position: "absolute",
13404
+ inset: 0,
13405
+ pointerEvents: "none",
13406
+ overflow: "visible"
13407
+ },
13408
+ "data-role": "callout",
13409
+ children: [
13410
+ /* @__PURE__ */ (0, import_jsx_runtime46.jsx)("defs", { children: /* @__PURE__ */ (0, import_jsx_runtime46.jsx)(
13411
+ "marker",
13412
+ {
13413
+ id: "arrowhead",
13414
+ viewBox: "0 0 10 10",
13415
+ refX: "8",
13416
+ refY: "5",
13417
+ markerWidth: "8",
13418
+ markerHeight: "8",
13419
+ orient: "auto",
13420
+ children: /* @__PURE__ */ (0, import_jsx_runtime46.jsx)("path", { d: "M 0 0 L 10 5 L 0 10 z", fill: "#3B82F6" })
13421
+ }
13422
+ ) }),
13423
+ /* @__PURE__ */ (0, import_jsx_runtime46.jsx)(
13424
+ import_framer_motion6.motion.path,
13425
+ {
13426
+ d,
13427
+ fill: "none",
13428
+ stroke: "#3B82F6",
13429
+ strokeWidth: 3,
13430
+ strokeLinecap: "round",
13431
+ markerEnd: "url(#arrowhead)",
13432
+ initial: { pathLength: 0, opacity: 0 },
13433
+ animate: { pathLength: 1, opacity: 1 },
13434
+ exit: { opacity: 0 },
13435
+ transition: { duration: 0.6, ease: "easeOut" }
13436
+ }
13437
+ ),
13438
+ label ? /* @__PURE__ */ (0, import_jsx_runtime46.jsxs)(
13439
+ import_framer_motion6.motion.g,
13440
+ {
13441
+ initial: { opacity: 0 },
13442
+ animate: { opacity: 1 },
13443
+ exit: { opacity: 0 },
13444
+ transition: { delay: 0.3, duration: 0.3 },
13445
+ children: [
13446
+ /* @__PURE__ */ (0, import_jsx_runtime46.jsx)(
13447
+ "rect",
13448
+ {
13449
+ x: target.x - 4,
13450
+ y: target.y - 28,
13451
+ width: label.length * 9 + 12,
13452
+ height: 22,
13453
+ rx: 4,
13454
+ fill: "#1F2937"
13455
+ }
13456
+ ),
13457
+ /* @__PURE__ */ (0, import_jsx_runtime46.jsx)(
13458
+ "text",
13459
+ {
13460
+ x: target.x + 2,
13461
+ y: target.y - 12,
13462
+ fill: "white",
13463
+ fontSize: 14,
13464
+ fontFamily: "system-ui, sans-serif",
13465
+ children: label
13466
+ }
13467
+ )
13468
+ ]
13469
+ }
13470
+ ) : null
13471
+ ]
13472
+ }
13473
+ );
13474
+ }
13475
+
13476
+ // src/components/TutorMode/GhostReference.tsx
13477
+ var import_framer_motion7 = require("framer-motion");
13478
+ var import_jsx_runtime47 = require("react/jsx-runtime");
13479
+ var POSITIONS = {
13480
+ "top-right": { top: 40, right: 40 },
13481
+ "top-left": { top: 40, left: 40 },
13482
+ "bottom-right": { bottom: 40, right: 40 },
13483
+ "bottom-left": { bottom: 40, left: 40 }
13484
+ };
13485
+ function GhostReference({
13486
+ page,
13487
+ sourceBbox,
13488
+ sourceBlockText,
13489
+ sourcePageNumber,
13490
+ action
13491
+ }) {
13492
+ const width = 360;
13493
+ const [x1, y1, x2, y2] = sourceBbox;
13494
+ return /* @__PURE__ */ (0, import_jsx_runtime47.jsxs)(
13495
+ import_framer_motion7.motion.div,
13496
+ {
13497
+ initial: { opacity: 0, y: 20, scale: 0.95 },
13498
+ animate: { opacity: 1, y: 0, scale: 1 },
13499
+ exit: { opacity: 0, y: 20, scale: 0.95 },
13500
+ transition: { duration: 0.4, ease: "easeOut" },
13501
+ style: {
13502
+ position: "absolute",
13503
+ width,
13504
+ background: "#111",
13505
+ color: "white",
13506
+ borderRadius: 12,
13507
+ padding: 12,
13508
+ boxShadow: "0 10px 40px rgba(0,0,0,0.5)",
13509
+ pointerEvents: "none",
13510
+ fontFamily: "system-ui, sans-serif",
13511
+ fontSize: 13,
13512
+ ...POSITIONS[action.position]
13513
+ },
13514
+ "data-role": "ghost-reference",
13515
+ children: [
13516
+ /* @__PURE__ */ (0, import_jsx_runtime47.jsxs)("div", { style: { opacity: 0.7, fontSize: 11, marginBottom: 6 }, children: [
13517
+ "Page ",
13518
+ sourcePageNumber,
13519
+ " \u2014 ",
13520
+ action.target_block
13521
+ ] }),
13522
+ /* @__PURE__ */ (0, import_jsx_runtime47.jsxs)(
13523
+ "svg",
13524
+ {
13525
+ width: width - 24,
13526
+ height: 160,
13527
+ viewBox: `0 0 ${page.width} ${page.height}`,
13528
+ style: { background: "#1F2937", borderRadius: 6, display: "block" },
13529
+ preserveAspectRatio: "xMidYMid meet",
13530
+ children: [
13531
+ /* @__PURE__ */ (0, import_jsx_runtime47.jsx)(
13532
+ "rect",
13533
+ {
13534
+ x: 0,
13535
+ y: 0,
13536
+ width: page.width,
13537
+ height: page.height,
13538
+ fill: "#1F2937"
13539
+ }
13540
+ ),
13541
+ /* @__PURE__ */ (0, import_jsx_runtime47.jsx)(
13542
+ "rect",
13543
+ {
13544
+ x: x1,
13545
+ y: y1,
13546
+ width: x2 - x1,
13547
+ height: y2 - y1,
13548
+ fill: "rgba(250,204,21,0.45)",
13549
+ stroke: "#FBBF24",
13550
+ strokeWidth: 8
13551
+ }
13552
+ )
13553
+ ]
13554
+ }
13555
+ ),
13556
+ /* @__PURE__ */ (0, import_jsx_runtime47.jsx)(
13557
+ "div",
13558
+ {
13559
+ style: {
13560
+ marginTop: 8,
13561
+ fontSize: 12,
13562
+ lineHeight: 1.4,
13563
+ opacity: 0.9
13564
+ },
13565
+ children: sourceBlockText ?? "(figure)"
13566
+ }
13567
+ )
13568
+ ]
13569
+ }
13570
+ );
13571
+ }
13572
+
13573
+ // src/components/TutorMode/BoxOverlay.tsx
13574
+ var import_framer_motion8 = require("framer-motion");
13575
+ var import_jsx_runtime48 = require("react/jsx-runtime");
13576
+ function BoxOverlay({ bbox, action }) {
13577
+ const [x1, y1, x2, y2] = bbox;
13578
+ return /* @__PURE__ */ (0, import_jsx_runtime48.jsx)(
13579
+ import_framer_motion8.motion.div,
13580
+ {
13581
+ initial: { opacity: 0, scale: 0.97 },
13582
+ animate: { opacity: 1, scale: 1 },
13583
+ exit: { opacity: 0 },
13584
+ transition: { duration: 0.35, ease: "easeOut" },
13585
+ style: {
13586
+ position: "absolute",
13587
+ left: x1,
13588
+ top: y1,
13589
+ width: x2 - x1,
13590
+ height: y2 - y1,
13591
+ border: `${action.style === "dashed" ? "3px dashed" : "3px solid"} ${action.color}`,
13592
+ borderRadius: 6,
13593
+ pointerEvents: "none",
13594
+ boxSizing: "border-box"
13595
+ },
13596
+ "data-role": "box"
13597
+ }
13598
+ );
13599
+ }
13600
+
13601
+ // src/components/TutorMode/StickyLabel.tsx
13602
+ var import_framer_motion9 = require("framer-motion");
13603
+ var import_jsx_runtime49 = require("react/jsx-runtime");
13604
+ function position(bbox, where) {
13605
+ const [x1, y1, x2, y2] = bbox;
13606
+ const cx = (x1 + x2) / 2;
13607
+ const cy = (y1 + y2) / 2;
13608
+ const PAD = 16;
13609
+ switch (where) {
13610
+ case "top":
13611
+ return { left: cx, top: y1 - PAD, transform: "translate(-50%, -100%)" };
13612
+ case "bottom":
13613
+ return { left: cx, top: y2 + PAD, transform: "translate(-50%, 0)" };
13614
+ case "left":
13615
+ return { left: x1 - PAD, top: cy, transform: "translate(-100%, -50%)" };
13616
+ case "right":
13617
+ return { left: x2 + PAD, top: cy, transform: "translate(0, -50%)" };
13618
+ default:
13619
+ return { left: cx, top: y1, transform: "translate(-50%, -100%)" };
13620
+ }
13621
+ }
13622
+ function StickyLabel({ bbox, action }) {
13623
+ return /* @__PURE__ */ (0, import_jsx_runtime49.jsx)(
13624
+ import_framer_motion9.motion.div,
13625
+ {
13626
+ initial: { opacity: 0, scale: 0.9 },
13627
+ animate: { opacity: 1, scale: 1 },
13628
+ exit: { opacity: 0 },
13629
+ transition: { duration: 0.35, ease: "easeOut" },
13630
+ style: {
13631
+ position: "absolute",
13632
+ padding: "6px 10px",
13633
+ background: "#FEF3C7",
13634
+ color: "#78350F",
13635
+ borderRadius: 6,
13636
+ boxShadow: "0 3px 10px rgba(0,0,0,0.2)",
13637
+ fontSize: 14,
13638
+ fontFamily: "system-ui, sans-serif",
13639
+ maxWidth: 280,
13640
+ pointerEvents: "none",
13641
+ ...position(bbox, action.position)
13642
+ },
13643
+ "data-role": "label",
13644
+ children: action.text
13645
+ }
13646
+ );
13647
+ }
13648
+
13649
+ // src/components/TutorMode/CinemaLayer.tsx
13650
+ var import_jsx_runtime50 = require("react/jsx-runtime");
13651
+ function blockBbox(index, block_id) {
13652
+ return index.blockById.get(block_id)?.block.bbox;
13653
+ }
13654
+ function CinemaLayer({
13655
+ page,
13656
+ index,
13657
+ overlays,
13658
+ scale
13659
+ }) {
13660
+ return /* @__PURE__ */ (0, import_jsx_runtime50.jsx)(
13661
+ "div",
13662
+ {
13663
+ "data-role": "cinema-layer",
13664
+ style: {
13665
+ position: "absolute",
13666
+ inset: 0,
13667
+ transformOrigin: "0 0",
13668
+ transform: `scale(${scale})`,
13669
+ width: page.page_dimensions.width,
13670
+ height: page.page_dimensions.height,
13671
+ pointerEvents: "none",
13672
+ // PDFPage renders internal layers at z-index 10/20/40/45/50
13673
+ // (canvas / text / highlight / focus / annotation). Without an
13674
+ // explicit z-index here, every tutor overlay stacks UNDER the
13675
+ // AnnotationLayer and becomes invisible. 100 puts us above all of
13676
+ // them while still letting the Exit button (z-index 60) remain
13677
+ // reachable because it sits OUTSIDE this stacking context.
13678
+ zIndex: 100
13679
+ },
13680
+ children: /* @__PURE__ */ (0, import_jsx_runtime50.jsx)(import_framer_motion10.AnimatePresence, { children: overlays.map((overlay) => {
13681
+ switch (overlay.kind) {
13682
+ case "spotlight": {
13683
+ const a = overlay.action;
13684
+ const b = blockBbox(index, a.target_block);
13685
+ if (!b) return null;
13686
+ return /* @__PURE__ */ (0, import_jsx_runtime50.jsx)(
13687
+ SpotlightMask,
13688
+ {
13689
+ page: page.page_dimensions,
13690
+ bbox: b,
13691
+ action: a
13692
+ },
13693
+ overlay.id
13694
+ );
13695
+ }
13696
+ case "underline": {
13697
+ const a = overlay.action;
13698
+ const b = blockBbox(index, a.target_block);
13699
+ if (!b) return null;
13700
+ return /* @__PURE__ */ (0, import_jsx_runtime50.jsx)(AnimatedUnderline, { bbox: b, action: a }, overlay.id);
13701
+ }
13702
+ case "highlight": {
13703
+ const a = overlay.action;
13704
+ const b = blockBbox(index, a.target_block);
13705
+ if (!b) return null;
13706
+ return /* @__PURE__ */ (0, import_jsx_runtime50.jsx)(AnimatedHighlight, { bbox: b, action: a }, overlay.id);
13707
+ }
13708
+ case "pulse": {
13709
+ const a = overlay.action;
13710
+ const b = blockBbox(index, a.target_block);
13711
+ if (!b) return null;
13712
+ return /* @__PURE__ */ (0, import_jsx_runtime50.jsx)(PulseOverlay, { bbox: b, action: a }, overlay.id);
13713
+ }
13714
+ case "callout": {
13715
+ const a = overlay.action;
13716
+ const from = blockBbox(index, a.from_block);
13717
+ const to = blockBbox(index, a.to_block);
13718
+ if (!from || !to) return null;
13719
+ return /* @__PURE__ */ (0, import_jsx_runtime50.jsx)(
13720
+ CalloutArrow,
13721
+ {
13722
+ fromBbox: from,
13723
+ toBbox: to,
13724
+ action: a
13725
+ },
13726
+ overlay.id
13727
+ );
13728
+ }
13729
+ case "ghost_reference": {
13730
+ const a = overlay.action;
13731
+ const hit = index.blockById.get(a.target_block);
13732
+ if (!hit) return null;
13733
+ const targetPage = index.byPage.get(a.target_page);
13734
+ if (!targetPage) return null;
13735
+ return /* @__PURE__ */ (0, import_jsx_runtime50.jsx)(
13736
+ GhostReference,
13737
+ {
13738
+ page: targetPage.page_dimensions,
13739
+ sourceBbox: hit.block.bbox,
13740
+ sourceBlockText: hit.block.text,
13741
+ sourcePageNumber: hit.pageNumber,
13742
+ action: a
13743
+ },
13744
+ overlay.id
13745
+ );
13746
+ }
13747
+ case "box": {
13748
+ const a = overlay.action;
13749
+ const b = blockBbox(index, a.target_block);
13750
+ if (!b) return null;
13751
+ return /* @__PURE__ */ (0, import_jsx_runtime50.jsx)(BoxOverlay, { bbox: b, action: a }, overlay.id);
13752
+ }
13753
+ case "label": {
13754
+ const a = overlay.action;
13755
+ const b = blockBbox(index, a.target_block);
13756
+ if (!b) return null;
13757
+ return /* @__PURE__ */ (0, import_jsx_runtime50.jsx)(StickyLabel, { bbox: b, action: a }, overlay.id);
13758
+ }
13759
+ case "clear":
13760
+ case "camera":
13761
+ return null;
13762
+ }
13763
+ }) })
13764
+ }
13765
+ );
13766
+ }
13767
+
13768
+ // src/components/TutorMode/SubtitleBar.tsx
13769
+ var import_framer_motion11 = require("framer-motion");
13770
+ var import_jsx_runtime51 = require("react/jsx-runtime");
13771
+ function SubtitleBar({ text }) {
13772
+ return /* @__PURE__ */ (0, import_jsx_runtime51.jsx)(import_framer_motion11.AnimatePresence, { children: text ? /* @__PURE__ */ (0, import_jsx_runtime51.jsx)(
13773
+ import_framer_motion11.motion.div,
13774
+ {
13775
+ initial: { opacity: 0, y: 20 },
13776
+ animate: { opacity: 1, y: 0 },
13777
+ exit: { opacity: 0, y: 20 },
13778
+ transition: { duration: 0.3 },
13779
+ style: {
13780
+ position: "absolute",
13781
+ left: "50%",
13782
+ bottom: 32,
13783
+ transform: "translateX(-50%)",
13784
+ background: "rgba(0,0,0,0.75)",
13785
+ color: "white",
13786
+ padding: "10px 18px",
13787
+ borderRadius: 8,
13788
+ maxWidth: "80%",
13789
+ fontSize: 16,
13790
+ lineHeight: 1.4,
13791
+ fontFamily: "system-ui, sans-serif",
13792
+ pointerEvents: "none",
13793
+ zIndex: 50,
13794
+ textAlign: "center"
13795
+ },
13796
+ "data-role": "subtitle-bar",
13797
+ children: text
13798
+ },
13799
+ text
13800
+ ) : null });
13801
+ }
13802
+
13803
+ // src/director/storyboard-engine.ts
13804
+ init_narration_store();
13805
+ init_camera_math();
13806
+ var DEFAULT_MIN_OVERLAY_MS = 3500;
13807
+ var StoryboardEngine = class {
13808
+ constructor(deps) {
13809
+ this.pendingTimers = /* @__PURE__ */ new Set();
13810
+ this.currentStoryboardId = 0;
13811
+ this.deps = deps;
13812
+ }
13813
+ /**
13814
+ * Execute a new storyboard. Cancels in-flight steps from the previous storyboard
13815
+ * and smoothly transitions the camera/overlays from the current state.
13816
+ */
13817
+ execute(storyboard) {
13818
+ this.cancelPending();
13819
+ this.currentStoryboardId += 1;
13820
+ const storyboardId = this.currentStoryboardId;
13821
+ const { narrationStore } = this.deps;
13822
+ narrationStore.getState().setEngineStatus("transitioning");
13823
+ narrationStore.getState().setLastStoryboard(storyboard);
13824
+ let steps = [...storyboard.steps].sort((a, b) => a.at_ms - b.at_ms);
13825
+ const hasCamera = steps.some((s) => s.action.type === "camera");
13826
+ if (!hasCamera) {
13827
+ const focus = steps.find(
13828
+ (s) => s.action.type !== "clear" && "target_block" in s.action && s.action.target_block
13829
+ );
13830
+ if (focus && focus.action.type !== "clear" && "target_block" in focus.action) {
13831
+ steps = [
13832
+ {
13833
+ at_ms: 0,
13834
+ duration_ms: 700,
13835
+ action: {
13836
+ type: "camera",
13837
+ target_block: focus.action.target_block,
13838
+ scale: 1,
13839
+ padding: 60,
13840
+ easing: "ease-out"
13841
+ }
13842
+ },
13843
+ ...steps
13844
+ ];
13845
+ }
13846
+ }
13847
+ for (const step of steps) {
13848
+ const timer = setTimeout(() => {
13849
+ if (storyboardId !== this.currentStoryboardId) return;
13850
+ this.runStep(step);
13851
+ }, step.at_ms);
13852
+ this.pendingTimers.add(timer);
13853
+ }
13854
+ const markExecuting = setTimeout(() => {
13855
+ if (storyboardId !== this.currentStoryboardId) return;
13856
+ narrationStore.getState().setEngineStatus("executing");
13857
+ }, 0);
13858
+ this.pendingTimers.add(markExecuting);
13859
+ const last = steps[steps.length - 1];
13860
+ if (last) {
13861
+ const totalMs = last.at_ms + last.duration_ms;
13862
+ const markIdle = setTimeout(() => {
13863
+ if (storyboardId !== this.currentStoryboardId) return;
13864
+ narrationStore.getState().setEngineStatus("idle");
13865
+ }, totalMs + 50);
13866
+ this.pendingTimers.add(markIdle);
13867
+ }
13868
+ }
13869
+ /** Abort all pending steps and set engine status to idle. */
13870
+ cancelPending() {
13871
+ for (const t of this.pendingTimers) clearTimeout(t);
13872
+ this.pendingTimers.clear();
13873
+ this.deps.narrationStore.getState().setEngineStatus("idle");
13874
+ }
13875
+ /** Reset visuals: clear overlays, fit camera back to page. */
13876
+ resetVisuals() {
13877
+ this.cancelPending();
13878
+ const { narrationStore, bboxIndex, getViewport } = this.deps;
13879
+ narrationStore.getState().clearOverlays();
13880
+ const viewport = getViewport();
13881
+ const currentPage = narrationStore.getState().currentPage;
13882
+ const pageDims = bboxIndex.byPage.get(currentPage);
13883
+ const fit = pageDims && viewport.width > 0 && viewport.height > 0 ? fitPageScale(pageDims.page_dimensions, viewport) * 0.95 : 1;
13884
+ narrationStore.getState().setCamera({ scale: fit, x: 0, y: 0, easing: "ease-in-out" });
13885
+ }
13886
+ /** Execute one step — dispatch to narrationStore. Returns true if applied. */
13887
+ runStep(step) {
13888
+ const action = step.action;
13889
+ const { narrationStore, bboxIndex } = this.deps;
13890
+ if ("target_block" in action && action.target_block) {
13891
+ if (!bboxIndex.blockById.has(action.target_block)) {
13892
+ narrationStore.getState().appendDebugEvent({
13893
+ kind: "llm-error",
13894
+ summary: `dropped ${action.type} step \u2192 unknown target_block "${action.target_block}"`,
13895
+ payload: { action, validIds: [...bboxIndex.blockById.keys()] }
13896
+ });
13897
+ return false;
13898
+ }
13899
+ }
13900
+ if ("from_block" in action && action.from_block) {
13901
+ if (!bboxIndex.blockById.has(action.from_block)) {
13902
+ narrationStore.getState().appendDebugEvent({
13903
+ kind: "llm-error",
13904
+ summary: `dropped ${action.type} step \u2192 unknown from_block "${action.from_block}"`,
13905
+ payload: { action }
13906
+ });
13907
+ return false;
13908
+ }
13909
+ }
13910
+ if ("to_block" in action && action.to_block) {
13911
+ if (!bboxIndex.blockById.has(action.to_block)) {
13912
+ narrationStore.getState().appendDebugEvent({
13913
+ kind: "llm-error",
13914
+ summary: `dropped ${action.type} step \u2192 unknown to_block "${action.to_block}"`,
13915
+ payload: { action }
13916
+ });
13917
+ return false;
13918
+ }
13919
+ }
13920
+ if (action.type === "camera") {
13921
+ this.applyCamera(action, step.duration_ms);
13922
+ return true;
13923
+ }
13924
+ if (action.type === "clear") {
13925
+ const targets = action.targets;
13926
+ if (targets === "all" || targets === "overlays") {
13927
+ narrationStore.getState().clearOverlays();
13928
+ } else if (targets === "spotlights") {
13929
+ narrationStore.getState().clearOverlays((o) => o.kind === "spotlight");
13930
+ } else if (Array.isArray(targets)) {
13931
+ const ids = new Set(targets);
13932
+ narrationStore.getState().clearOverlays((o) => ids.has(o.id));
13933
+ }
13934
+ return true;
13935
+ }
13936
+ const minMs = this.deps.minOverlayDurationMs ?? DEFAULT_MIN_OVERLAY_MS;
13937
+ const visibleMs = Math.max(step.duration_ms, minMs);
13938
+ const overlay = {
13939
+ id: makeOverlayId(action),
13940
+ kind: action.type,
13941
+ action,
13942
+ createdAt: Date.now(),
13943
+ expiresAt: Date.now() + visibleMs
13944
+ };
13945
+ narrationStore.getState().addOverlay(overlay);
13946
+ const timer = setTimeout(() => {
13947
+ narrationStore.getState().removeOverlay(overlay.id);
13948
+ }, visibleMs);
13949
+ this.pendingTimers.add(timer);
13950
+ return true;
13951
+ }
13952
+ applyCamera(action, durationMs) {
13953
+ const { narrationStore, bboxIndex, getViewport } = this.deps;
13954
+ const viewport = getViewport();
13955
+ let bbox = action.target_bbox;
13956
+ let pageDims = void 0;
13957
+ if (!bbox && action.target_block) {
13958
+ const hit = bboxIndex.blockById.get(action.target_block);
13959
+ if (!hit) return;
13960
+ bbox = hit.block.bbox;
13961
+ pageDims = bboxIndex.byPage.get(hit.pageNumber);
13962
+ } else if (bbox) {
13963
+ pageDims = bboxIndex.byPage.get(narrationStore.getState().currentPage);
13964
+ }
13965
+ if (!bbox || !pageDims) return;
13966
+ const fit = fitPageScale(pageDims.page_dimensions, viewport);
13967
+ const requested = Math.max(0.5, Math.min(3, action.scale ?? 1));
13968
+ const finalScale = fit * requested;
13969
+ const [x1, y1, x2, y2] = bbox;
13970
+ const blockCX = (x1 + x2) / 2;
13971
+ const blockCY = (y1 + y2) / 2;
13972
+ const pageCX = pageDims.page_dimensions.width / 2;
13973
+ const pageCY = pageDims.page_dimensions.height / 2;
13974
+ const x = (pageCX - blockCX) * finalScale;
13975
+ const y = (pageCY - blockCY) * finalScale;
13976
+ const camera = {
13977
+ scale: finalScale,
13978
+ x,
13979
+ y,
13980
+ easing: action.easing
13981
+ };
13982
+ narrationStore.getState().setCamera(camera);
13983
+ void durationMs;
13984
+ void computeCameraForBlock;
13985
+ }
13986
+ };
13987
+
13988
+ // src/director/storyboard-schema.ts
13989
+ var import_zod = require("zod");
13990
+ var BBoxCoordsSchema = import_zod.z.tuple([import_zod.z.number(), import_zod.z.number(), import_zod.z.number(), import_zod.z.number()]);
13991
+ var CameraSchema = import_zod.z.object({
13992
+ type: import_zod.z.literal("camera"),
13993
+ target_block: import_zod.z.string().optional(),
13994
+ target_bbox: BBoxCoordsSchema.optional(),
13995
+ scale: import_zod.z.number().min(0.5).max(4).default(1),
13996
+ padding: import_zod.z.number().min(0).max(400).default(80),
13997
+ easing: import_zod.z.enum(["linear", "ease-in", "ease-out", "ease-in-out"]).default("ease-in-out")
13998
+ }).refine((a) => !!a.target_block || !!a.target_bbox, {
13999
+ message: "camera requires target_block or target_bbox"
14000
+ });
14001
+ var SpotlightSchema = import_zod.z.object({
14002
+ type: import_zod.z.literal("spotlight"),
14003
+ target_block: import_zod.z.string(),
14004
+ dim_opacity: import_zod.z.number().min(0).max(1).default(0.65),
14005
+ feather_px: import_zod.z.number().min(0).max(200).default(40),
14006
+ shape: import_zod.z.enum(["rect", "rounded", "ellipse"]).default("rounded")
14007
+ });
14008
+ var UnderlineSchema = import_zod.z.object({
14009
+ type: import_zod.z.literal("underline"),
14010
+ target_block: import_zod.z.string(),
14011
+ color: import_zod.z.string().default("#FBBF24"),
14012
+ style: import_zod.z.enum(["straight", "sketch", "double", "wavy"]).default("sketch"),
14013
+ draw_duration_ms: import_zod.z.number().min(100).max(3e3).default(600)
14014
+ });
14015
+ var HighlightSchema = import_zod.z.object({
14016
+ type: import_zod.z.literal("highlight"),
14017
+ target_block: import_zod.z.string(),
14018
+ color: import_zod.z.string().default("rgba(250, 204, 21, 0.35)"),
14019
+ draw_duration_ms: import_zod.z.number().min(100).max(3e3).default(500)
14020
+ });
14021
+ var PulseSchema = import_zod.z.object({
14022
+ type: import_zod.z.literal("pulse"),
14023
+ target_block: import_zod.z.string(),
14024
+ count: import_zod.z.number().int().min(1).max(5).default(2),
14025
+ intensity: import_zod.z.enum(["subtle", "normal", "strong"]).default("normal")
14026
+ });
14027
+ var CalloutSchema = import_zod.z.object({
14028
+ type: import_zod.z.literal("callout"),
14029
+ from_block: import_zod.z.string(),
14030
+ to_block: import_zod.z.string(),
14031
+ label: import_zod.z.string().max(120).optional(),
14032
+ curve: import_zod.z.enum(["straight", "curved", "zigzag"]).default("curved")
14033
+ });
14034
+ var GhostReferenceSchema = import_zod.z.object({
14035
+ type: import_zod.z.literal("ghost_reference"),
14036
+ target_page: import_zod.z.number().int().min(1),
14037
+ target_block: import_zod.z.string(),
14038
+ position: import_zod.z.enum(["top-right", "top-left", "bottom-right", "bottom-left"]).default("top-right")
14039
+ });
14040
+ var BoxSchema = import_zod.z.object({
14041
+ type: import_zod.z.literal("box"),
14042
+ target_block: import_zod.z.string(),
14043
+ color: import_zod.z.string().default("#3B82F6"),
14044
+ style: import_zod.z.enum(["solid", "dashed"]).default("solid")
14045
+ });
14046
+ var LabelSchema = import_zod.z.object({
14047
+ type: import_zod.z.literal("label"),
14048
+ target_block: import_zod.z.string(),
14049
+ text: import_zod.z.string().min(1).max(120),
14050
+ position: import_zod.z.enum(["top", "bottom", "left", "right"]).default("top")
14051
+ });
14052
+ var ClearSchema = import_zod.z.object({
14053
+ type: import_zod.z.literal("clear"),
14054
+ targets: import_zod.z.union([import_zod.z.enum(["all", "spotlights", "overlays"]), import_zod.z.array(import_zod.z.string())]).default("overlays")
14055
+ });
14056
+ var StoryboardActionSchema = import_zod.z.union([
14057
+ CameraSchema,
14058
+ SpotlightSchema,
14059
+ UnderlineSchema,
14060
+ HighlightSchema,
14061
+ PulseSchema,
14062
+ CalloutSchema,
14063
+ GhostReferenceSchema,
14064
+ BoxSchema,
14065
+ LabelSchema,
14066
+ ClearSchema
14067
+ ]);
14068
+ var StoryboardStepSchema = import_zod.z.object({
14069
+ at_ms: import_zod.z.number().min(0).max(5e3).default(0),
14070
+ duration_ms: import_zod.z.number().min(100).max(5e3).default(800),
14071
+ action: StoryboardActionSchema
14072
+ });
14073
+ var StoryboardSchema = import_zod.z.object({
14074
+ version: import_zod.z.literal(1),
14075
+ reasoning: import_zod.z.string().max(500).default(""),
14076
+ steps: import_zod.z.array(StoryboardStepSchema).min(1).max(4)
14077
+ });
14078
+ function storyboardJsonSchema(opts = {}) {
14079
+ const { validBlockIds, validCrossPageBlockIds } = opts;
14080
+ const blockIdSchema = validBlockIds && validBlockIds.length > 0 ? { type: ["string", "null"], enum: [...validBlockIds, null] } : { type: ["string", "null"] };
14081
+ const crossPageBlockIdSchema = validCrossPageBlockIds && validCrossPageBlockIds.length > 0 ? {
14082
+ type: ["string", "null"],
14083
+ enum: [...validCrossPageBlockIds, ...validBlockIds ?? [], null]
14084
+ } : blockIdSchema;
14085
+ const actionSchema = {
14086
+ type: "object",
14087
+ additionalProperties: false,
14088
+ required: [
14089
+ "type",
14090
+ "target_block",
14091
+ "target_bbox",
14092
+ "scale",
14093
+ "padding",
14094
+ "easing",
14095
+ "dim_opacity",
14096
+ "feather_px",
14097
+ "shape",
14098
+ "color",
14099
+ "style",
14100
+ "draw_duration_ms",
14101
+ "count",
14102
+ "intensity",
14103
+ "from_block",
14104
+ "to_block",
14105
+ "label",
14106
+ "curve",
14107
+ "target_page",
14108
+ "position",
14109
+ "text",
14110
+ "targets"
14111
+ ],
14112
+ properties: {
14113
+ type: {
14114
+ type: "string",
14115
+ enum: [
14116
+ "camera",
14117
+ "spotlight",
14118
+ "underline",
14119
+ "highlight",
14120
+ "pulse",
14121
+ "callout",
14122
+ "ghost_reference",
14123
+ "box",
14124
+ "label",
14125
+ "clear"
14126
+ ]
14127
+ },
14128
+ target_block: blockIdSchema,
14129
+ target_bbox: {
14130
+ type: ["array", "null"],
14131
+ items: { type: "number" },
14132
+ minItems: 4,
14133
+ maxItems: 4
14134
+ },
14135
+ scale: { type: ["number", "null"] },
14136
+ padding: { type: ["number", "null"] },
14137
+ easing: {
14138
+ type: ["string", "null"],
14139
+ enum: ["linear", "ease-in", "ease-out", "ease-in-out", null]
14140
+ },
14141
+ dim_opacity: { type: ["number", "null"] },
14142
+ feather_px: { type: ["number", "null"] },
14143
+ shape: {
14144
+ type: ["string", "null"],
14145
+ enum: ["rect", "rounded", "ellipse", null]
14146
+ },
14147
+ color: { type: ["string", "null"] },
14148
+ style: {
14149
+ type: ["string", "null"],
14150
+ enum: ["straight", "sketch", "double", "wavy", "solid", "dashed", null]
14151
+ },
14152
+ draw_duration_ms: { type: ["number", "null"] },
14153
+ count: { type: ["integer", "null"] },
14154
+ intensity: {
14155
+ type: ["string", "null"],
14156
+ enum: ["subtle", "normal", "strong", null]
14157
+ },
14158
+ from_block: blockIdSchema,
14159
+ to_block: crossPageBlockIdSchema,
14160
+ label: { type: ["string", "null"] },
14161
+ curve: {
14162
+ type: ["string", "null"],
14163
+ enum: ["straight", "curved", "zigzag", null]
14164
+ },
14165
+ target_page: { type: ["integer", "null"] },
14166
+ position: {
14167
+ type: ["string", "null"],
14168
+ enum: [
14169
+ "top",
14170
+ "bottom",
14171
+ "left",
14172
+ "right",
14173
+ "top-right",
14174
+ "top-left",
14175
+ "bottom-right",
14176
+ "bottom-left",
14177
+ null
14178
+ ]
14179
+ },
14180
+ text: { type: ["string", "null"] },
14181
+ targets: {
14182
+ type: ["string", "null"],
14183
+ enum: ["all", "spotlights", "overlays", null]
14184
+ }
14185
+ }
14186
+ };
14187
+ return {
14188
+ type: "object",
14189
+ additionalProperties: false,
14190
+ required: ["version", "reasoning", "steps"],
14191
+ properties: {
14192
+ version: { type: "integer", enum: [1] },
14193
+ reasoning: { type: "string" },
14194
+ steps: {
14195
+ type: "array",
14196
+ minItems: 1,
14197
+ maxItems: 4,
14198
+ items: {
14199
+ type: "object",
14200
+ additionalProperties: false,
14201
+ required: ["at_ms", "duration_ms", "action"],
14202
+ properties: {
14203
+ at_ms: { type: "number" },
14204
+ duration_ms: { type: "number" },
14205
+ action: actionSchema
14206
+ }
14207
+ }
14208
+ }
14209
+ }
14210
+ };
14211
+ }
14212
+
14213
+ // src/director/prompts.ts
14214
+ var SYSTEM_PROMPT = `You are the cinematic director of an AI tutor's PDF visualization. The tutor speaks one "chunk" at a time; for each chunk you anchor the visuals onto the EXACT blocks on the page the tutor is talking about, so the reader sees the page react like a produced teaching video. Think of yourself as a motion designer layering effects on top of a document \u2014 zoom is only one tool in the kit, and often not the right one.
14215
+
14216
+ # Your primary task
14217
+ You are given a list of blocks for the current page under "Page blocks", each with a \`block_id\`, \`text\`, \`type\`, \`bbox\`, and \`default_action\`. Your #1 job is to decide WHICH block(s) the current chunk is referring to, and then pick the right visual action(s) to anchor there.
14218
+
14219
+ Anchoring rules:
14220
+ - EVERY action that references a block MUST set \`target_block\` (or \`from_block\`/\`to_block\` for callouts) to an EXISTING \`block_id\` from "Page blocks" (or "Cross-page figures index" for cross-page refs). Never invent an id. Never emit a step whose target can't be found in the provided lists.
14221
+ - Match the chunk to blocks by semantic overlap with the block's \`text\`: quoted phrases, named entities, keywords, figure references ("Fig 3.2", "the suture"), list enumerations.
14222
+ - If no block clearly matches, emit a single \`camera\` step that fits the page (no overlays) and explain in \`reasoning\`. Do NOT spray overlays onto random blocks.
14223
+ - If multiple blocks match, pick the most specific one, or use a \`callout\` from one to the other.
14224
+
14225
+ # Output shape
14226
+ Output ONLY this JSON, nothing else:
14227
+ {
14228
+ "version": 1,
14229
+ "reasoning": "<which block(s) you picked, which intent you used, and why \u2014 name the block_id>",
14230
+ "steps": [ { "at_ms": <int>, "duration_ms": <int>, "action": <action> }, ... ]
14231
+ }
14232
+
14233
+ # Action shapes \u2014 ALL fields shown are REQUIRED per action type
14234
+ - camera: { "type":"camera", "target_block":"<id>", "scale":1.1, "padding":80, "easing":"ease-out" }
14235
+ - spotlight: { "type":"spotlight", "target_block":"<id>", "dim_opacity":0.65, "feather_px":40, "shape":"rounded" }
14236
+ - underline: { "type":"underline", "target_block":"<id>", "color":"#FBBF24", "style":"sketch", "draw_duration_ms":600 }
14237
+ - highlight: { "type":"highlight", "target_block":"<id>", "color":"rgba(250,204,21,0.35)", "draw_duration_ms":500 }
14238
+ - pulse: { "type":"pulse", "target_block":"<id>", "count":2, "intensity":"normal" }
14239
+ - callout: { "type":"callout", "from_block":"<id>", "to_block":"<id>", "label":"<text>", "curve":"curved" }
14240
+ - ghost_reference: { "type":"ghost_reference", "target_page":<int>, "target_block":"<id>", "position":"top-right" }
14241
+ - box: { "type":"box", "target_block":"<id>", "color":"#3B82F6", "style":"solid" }
14242
+ - label: { "type":"label", "target_block":"<id>", "text":"<text>", "position":"top" }
14243
+ - clear: { "type":"clear", "targets":"overlays" }
14244
+
14245
+ # When to use each action (match the effect to the narration's intent, not just the block type)
14246
+ - camera \u2014 when focus SHIFTS to a new region. If the primary block is already on-screen and roughly centered, you may skip camera entirely and start with an overlay. When you do use camera, prefer scale 1.1\u20131.4 for gentle re-centering; reserve 1.5+ for dense figures you need to inspect closely.
14247
+ - spotlight \u2014 when narration ISOLATES one idea, term, or sentence. Great for definitions, principles, and "the key insight is\u2026" moments.
14248
+ - underline \u2014 when narration QUOTES a phrase or reads a line word-by-word. Use style "sketch" for handwritten feel, "straight" for formal, "wavy" for emphasis. Pairs well with spotlight.
14249
+ - highlight \u2014 when narration FLAGS a keyword inline without full focus. Cheap, fast, great for list items, definitions-in-context, callback references.
14250
+ - pulse \u2014 when narration says "notice this" / "see here" / "look at the diagram". Use with figures, icons, and anchor blocks that should catch the eye without blocking surrounding content.
14251
+ - callout \u2014 when narration CONNECTS two things on the page: a caption to its figure, a label to a region, one list item to another. Arrow implies directional meaning.
14252
+ - ghost_reference \u2014 when narration REFERS to a block on a DIFFERENT page ("as we saw on page 2\u2026"). Never use for same-page references.
14253
+ - box \u2014 when narration FRAMES a structural region: a table, a sidebar, a group of related items, a diagram subpart. Use dashed style for "in-progress" / "under discussion" regions.
14254
+ - label \u2014 when narration attaches a NAMED TAG to a block: "this is the definition", "this is an example", "step 3". Keep text \u226440 chars for readability.
14255
+ - clear \u2014 rarely needed; the engine auto-expires overlays. Use only when you explicitly want to wipe prior state before a new beat.
14256
+
14257
+ Respect each block's \`default_action\` as a soft hint, but override it freely when narration intent calls for a different effect. A paragraph labeled \`default_action: spotlight\` can absolutely take a highlight or underline if that's what the narration asks for.
14258
+
14259
+ # Intent Taxonomy \u2014 canonical "recipes" you should use as your default vocabulary
14260
+ When narration fits one of these patterns, emit the corresponding storyboard shape (fill in real block_ids from the page). You may also COMPOSE freely beyond these \u2014 they are a floor, not a ceiling.
14261
+
14262
+ ## define \u2014 the narration introduces or defines a term
14263
+ Shape: spotlight the term + underline it + drop a label tag. No camera move if the block is already on-screen.
14264
+ {
14265
+ "version": 1,
14266
+ "reasoning": "define recipe: spotlighting and underlining the term, labeling as 'definition'",
14267
+ "steps": [
14268
+ { "at_ms":0, "duration_ms":700, "action": { "type":"spotlight", "target_block":"p1_para0", "dim_opacity":0.6, "feather_px":40, "shape":"rounded" } },
14269
+ { "at_ms":200, "duration_ms":800, "action": { "type":"underline", "target_block":"p1_para0", "color":"#FBBF24", "style":"sketch", "draw_duration_ms":700 } },
14270
+ { "at_ms":900, "duration_ms":1200, "action": { "type":"label", "target_block":"p1_para0", "text":"definition", "position":"top" } }
14271
+ ]
14272
+ }
14273
+
14274
+ ## point_out \u2014 the narration directs the viewer's eye to a figure, diagram, or specific region
14275
+ Shape: gentle camera move + callout arrow from caption to figure + pulse the figure.
14276
+ {
14277
+ "version": 1,
14278
+ "reasoning": "point_out recipe: drawing attention from caption p1_cap1 to figure p1_fig0",
14279
+ "steps": [
14280
+ { "at_ms":0, "duration_ms":600, "action": { "type":"camera", "target_block":"p1_fig0", "scale":1.3, "padding":80, "easing":"ease-out" } },
14281
+ { "at_ms":400, "duration_ms":900, "action": { "type":"callout", "from_block":"p1_cap1", "to_block":"p1_fig0", "label":"see here", "curve":"curved" } },
14282
+ { "at_ms":900, "duration_ms":1200, "action": { "type":"pulse", "target_block":"p1_fig0", "count":2, "intensity":"normal" } }
14283
+ ]
14284
+ }
14285
+
14286
+ ## compare \u2014 the narration contrasts two things on the page
14287
+ Shape: box A + box B + callout between them with a relational label.
14288
+ {
14289
+ "version": 1,
14290
+ "reasoning": "compare recipe: framing fibrous vs synovial joints",
14291
+ "steps": [
14292
+ { "at_ms":0, "duration_ms":600, "action": { "type":"box", "target_block":"p1_list5", "color":"#3B82F6", "style":"solid" } },
14293
+ { "at_ms":300, "duration_ms":600, "action": { "type":"box", "target_block":"p1_list12", "color":"#F472B6", "style":"solid" } },
14294
+ { "at_ms":800, "duration_ms":1000, "action": { "type":"callout", "from_block":"p1_list5", "to_block":"p1_list12", "label":"vs", "curve":"curved" } }
14295
+ ]
14296
+ }
14297
+
14298
+ ## emphasize \u2014 the narration stresses a keyword, warning, or takeaway
14299
+ Shape: highlight + pulse. Fast, punchy, no camera.
14300
+ {
14301
+ "version": 1,
14302
+ "reasoning": "emphasize recipe: highlighting key keyword and pulsing for stress",
14303
+ "steps": [
14304
+ { "at_ms":0, "duration_ms":500, "action": { "type":"highlight", "target_block":"p1_list0", "color":"rgba(250,204,21,0.35)", "draw_duration_ms":450 } },
14305
+ { "at_ms":350, "duration_ms":800, "action": { "type":"pulse", "target_block":"p1_list0", "count":2, "intensity":"strong" } }
14306
+ ]
14307
+ }
14308
+
14309
+ # Choreography rules
14310
+ - HARD REQUIREMENT: every storyboard MUST contain at least ONE non-camera step. A lone camera step is NEVER a valid output \u2014 the viewer needs an overlay to know WHY the camera moved. If you cannot find a good overlay target, emit a \`highlight\` or \`pulse\` on your primary block as the second step.
14311
+ - Favor VARIETY that matches narration texture \u2014 a definition earns different visuals than a comparison. Don't send every chunk through the same zoom+box motion.
14312
+ - Include a camera step only when focus genuinely shifts to a new region. If the primary target is already on-screen and roughly centred, SKIP the camera entirely and start directly with an overlay.
14313
+ - Camera scale: default to **1.1** (gentle re-centre). Use **1.2\u20131.3** for normal reading distance. Use **1.4\u20131.6** ONLY for dense figures or small inline details. NEVER use a scale below 0.5 or above 4.0 \u2014 the engine rejects those. When in doubt, use 1.1.
14314
+ - Prefer overlays that OVERLAP the camera move. A camera that takes 700ms to finish while overlays fire at 200ms feels cinematic; overlays waiting until after the camera settles feel sluggish. Stagger \`at_ms\`: camera at 0, first overlay at 150\u2013300ms, second overlay at 600\u2013900ms.
14315
+ - 2\u20134 steps is typical; single-step overlays (no camera) are PREFERRED when the target is already visible.
14316
+ - When narration compares two things, USE the compare recipe (box + box + callout), not a single camera step. When narration says "key takeaway", USE emphasize (highlight + pulse).
14317
+ - Never target a block_id not present in the provided lists. If no block matches, emit a single camera step at scale 1.0 + a subtle pulse on the nearest heading; explain in \`reasoning\`.
14318
+ - Output ONLY valid JSON. No markdown, no code fences, no commentary, no trailing whitespace inside property values.
14319
+
14320
+ # Forbidden outputs \u2014 these will be rejected:
14321
+ - A storyboard with only a camera step.
14322
+ - A camera step with scale < 0.5 or > 4.0.
14323
+ - target_block values not listed in "Page blocks" or "Cross-page figures index".
14324
+ - Tab characters, newlines, or explanatory text inside JSON string values.`;
14325
+ function truncate(text, max = 200) {
14326
+ if (!text) return "";
14327
+ if (text.length <= max) return text;
14328
+ const slice = text.slice(0, max);
14329
+ const last = slice.lastIndexOf(" ");
14330
+ return (last > 40 ? slice.slice(0, last) : slice) + "\u2026";
14331
+ }
14332
+ function buildUserPrompt(input) {
14333
+ const {
14334
+ chunk,
14335
+ pageNumber,
14336
+ page,
14337
+ index,
14338
+ history,
14339
+ camera,
14340
+ activeOverlays,
14341
+ maxSteps = 4
14342
+ } = input;
14343
+ const pageBlocks = page.blocks.map((b) => ({
14344
+ block_id: b.block_id,
14345
+ type: b.type,
14346
+ text: truncate(b.text, 200),
14347
+ bbox: b.bbox,
14348
+ default_action: b.default_action
14349
+ }));
14350
+ const xPageFigures = index.crossPageFigures.filter((f) => f.page !== pageNumber).slice(0, 20).map((f) => ({
14351
+ block_id: f.block_id,
14352
+ page: f.page,
14353
+ type: f.type,
14354
+ text: truncate(f.text, 200)
14355
+ }));
14356
+ const recent = history.slice(-3).map((h) => h.text);
14357
+ const overlaySummary = activeOverlays.map((o) => ({ id: o.id, kind: o.kind }));
14358
+ const blockIdList = pageBlocks.map((b) => b.block_id);
14359
+ return [
14360
+ `Current page: ${pageNumber}`,
14361
+ `Page blocks (${pageBlocks.length}) \u2014 you MUST pick target_block from this list:`,
14362
+ JSON.stringify(pageBlocks),
14363
+ "",
14364
+ `Valid block_ids for this page: ${JSON.stringify(blockIdList)}`,
14365
+ "",
14366
+ `Cross-page figures index: ${JSON.stringify(xPageFigures)}`,
14367
+ "",
14368
+ `Current chunk (what the tutor just said): ${JSON.stringify(chunk)}`,
14369
+ `Recent chunks: ${JSON.stringify(recent)}`,
14370
+ `Current camera: ${JSON.stringify(camera)}`,
14371
+ `Active overlays: ${JSON.stringify(overlaySummary)}`,
14372
+ "",
14373
+ `Max steps: ${maxSteps}`,
14374
+ `Output JSON storyboard. Every target_block MUST be one of the ids above.`
14375
+ ].join("\n");
14376
+ }
14377
+
14378
+ // src/director/sse-parser.ts
14379
+ async function* parseSse(body) {
14380
+ const reader = body.getReader();
14381
+ const decoder = new TextDecoder();
14382
+ let buffer = "";
14383
+ try {
14384
+ while (true) {
14385
+ const { value, done } = await reader.read();
14386
+ if (done) break;
14387
+ buffer += decoder.decode(value, { stream: true });
14388
+ let idx;
14389
+ while ((idx = buffer.indexOf("\n")) !== -1) {
14390
+ const rawLine = buffer.slice(0, idx).trim();
14391
+ buffer = buffer.slice(idx + 1);
14392
+ if (!rawLine.startsWith("data:")) continue;
14393
+ const payload = rawLine.slice(5).trim();
14394
+ if (!payload || payload === "[DONE]") continue;
14395
+ try {
14396
+ yield JSON.parse(payload);
14397
+ } catch {
14398
+ }
14399
+ }
14400
+ }
14401
+ } finally {
14402
+ reader.releaseLock();
14403
+ }
14404
+ }
14405
+ function extractDelta(chunk) {
14406
+ if (!chunk || typeof chunk !== "object") return null;
14407
+ const choices = chunk.choices;
14408
+ if (!choices || !choices.length) return null;
14409
+ return choices[0].delta?.content ?? null;
14410
+ }
14411
+
14412
+ // src/director/llm-director.ts
14413
+ async function directStoryboard(config, input) {
14414
+ const {
14415
+ endpointUrl,
14416
+ model,
14417
+ authToken,
14418
+ extraBody,
14419
+ maxTokens = 1024,
14420
+ temperature = 0.3,
14421
+ useJsonSchema = true,
14422
+ stream = false
14423
+ } = config;
14424
+ const userContent = buildUserPrompt(input);
14425
+ const body = {
14426
+ model,
14427
+ stream,
14428
+ temperature,
14429
+ max_tokens: maxTokens,
14430
+ messages: [
14431
+ { role: "system", content: SYSTEM_PROMPT },
14432
+ { role: "user", content: userContent }
14433
+ ],
14434
+ ...extraBody ?? {}
14435
+ };
14436
+ if (useJsonSchema) {
14437
+ const validBlockIds = input.page.blocks.map((b) => b.block_id);
14438
+ const validCrossPageBlockIds = input.index.crossPageFigures.filter((f) => f.page !== input.pageNumber).map((f) => f.block_id);
14439
+ body.response_format = {
14440
+ type: "json_schema",
14441
+ json_schema: {
14442
+ name: "storyboard",
14443
+ strict: true,
14444
+ schema: storyboardJsonSchema({
14445
+ validBlockIds,
14446
+ validCrossPageBlockIds
14447
+ })
14448
+ }
14449
+ };
14450
+ }
14451
+ const headers = {
14452
+ "Content-Type": "application/json",
14453
+ Accept: stream ? "text/event-stream" : "application/json"
14454
+ };
14455
+ if (authToken) headers.Authorization = `Bearer ${authToken}`;
14456
+ const timeoutController = new AbortController();
14457
+ const timer = setTimeout(
14458
+ () => timeoutController.abort(),
14459
+ input.timeoutMs ?? 2500
14460
+ );
14461
+ const signal = mergeSignals(input.signal, timeoutController.signal);
14462
+ try {
14463
+ const response = await fetch(endpointUrl, {
14464
+ method: "POST",
14465
+ headers,
14466
+ body: JSON.stringify(body),
14467
+ signal
14468
+ });
14469
+ if (!response.ok || !response.body) {
14470
+ return {
14471
+ storyboard: null,
14472
+ raw: "",
14473
+ error: `HTTP ${response.status}`
14474
+ };
14475
+ }
14476
+ let raw = "";
14477
+ if (stream && response.body) {
14478
+ for await (const chunk of parseSse(response.body)) {
14479
+ const delta = extractDelta(chunk);
14480
+ if (delta) raw += delta;
14481
+ }
14482
+ } else {
14483
+ const json = await response.json();
14484
+ raw = json.choices?.[0]?.message?.content ?? "";
14485
+ }
14486
+ const stripped = collapseWhitespaceRuns(stripCodeFences(raw).trim());
14487
+ let parsed;
14488
+ try {
14489
+ parsed = JSON.parse(stripped);
14490
+ } catch (e) {
14491
+ return {
14492
+ storyboard: null,
14493
+ raw,
14494
+ error: `parse error: ${e.message}`
14495
+ };
14496
+ }
14497
+ const cleaned = clampNumericRanges(stripNullsDeep(parsed));
14498
+ const validation = StoryboardSchema.safeParse(cleaned);
14499
+ if (validation.success) {
14500
+ return {
14501
+ storyboard: enforceOverlayPresence(validation.data),
14502
+ raw
14503
+ };
14504
+ }
14505
+ const salvaged = salvageStoryboard(cleaned);
14506
+ if (salvaged) {
14507
+ return { storyboard: enforceOverlayPresence(salvaged), raw };
14508
+ }
14509
+ return {
14510
+ storyboard: null,
14511
+ raw,
14512
+ error: `validation failed: ${validation.error.message}`
14513
+ };
14514
+ } catch (e) {
14515
+ const name = e.name;
14516
+ const msg = name === "AbortError" ? "aborted" : e.message;
14517
+ return { storyboard: null, raw: "", error: msg };
14518
+ } finally {
14519
+ clearTimeout(timer);
14520
+ }
14521
+ }
14522
+ function stripCodeFences(s) {
14523
+ const m = s.match(/```(?:json)?\s*([\s\S]*?)```/);
14524
+ return m ? m[1] : s;
14525
+ }
14526
+ function collapseWhitespaceRuns(src) {
14527
+ let out = "";
14528
+ let inString = false;
14529
+ let escape = false;
14530
+ let run = 0;
14531
+ for (let i = 0; i < src.length; i++) {
14532
+ const c = src[i];
14533
+ if (inString) {
14534
+ out += c;
14535
+ if (escape) {
14536
+ escape = false;
14537
+ } else if (c === "\\") {
14538
+ escape = true;
14539
+ } else if (c === '"') {
14540
+ inString = false;
14541
+ }
14542
+ continue;
14543
+ }
14544
+ if (c === '"') {
14545
+ out += c;
14546
+ inString = true;
14547
+ run = 0;
14548
+ continue;
14549
+ }
14550
+ if (c === " " || c === " " || c === "\n" || c === "\r") {
14551
+ run++;
14552
+ if (run <= 1) out += " ";
14553
+ continue;
14554
+ }
14555
+ run = 0;
14556
+ out += c;
14557
+ }
14558
+ return out;
14559
+ }
14560
+ function clampNumericRanges(input) {
14561
+ if (input === null || input === void 0) return input;
14562
+ if (Array.isArray(input)) return input.map(clampNumericRanges);
14563
+ if (typeof input !== "object") return input;
14564
+ const obj = input;
14565
+ const out = {};
14566
+ for (const [k, v] of Object.entries(obj)) {
14567
+ out[k] = clampNumericRanges(v);
14568
+ }
14569
+ const type = typeof out.type === "string" ? out.type : void 0;
14570
+ if (type === "camera") {
14571
+ if (typeof out.scale === "number") out.scale = clamp(out.scale, 0.5, 4);
14572
+ if (typeof out.padding === "number") {
14573
+ out.padding = clamp(out.padding, 0, 400);
14574
+ }
14575
+ }
14576
+ if (typeof out.dim_opacity === "number") {
14577
+ out.dim_opacity = clamp(out.dim_opacity, 0, 1);
14578
+ }
14579
+ if (typeof out.feather_px === "number") {
14580
+ out.feather_px = clamp(out.feather_px, 0, 200);
14581
+ }
14582
+ if (typeof out.draw_duration_ms === "number") {
14583
+ out.draw_duration_ms = clamp(out.draw_duration_ms, 100, 3e3);
14584
+ }
14585
+ if (typeof out.count === "number") {
14586
+ out.count = Math.round(clamp(out.count, 1, 5));
14587
+ }
14588
+ if (typeof out.at_ms === "number") {
14589
+ out.at_ms = clamp(out.at_ms, 0, 5e3);
14590
+ }
14591
+ if (typeof out.duration_ms === "number" && type === void 0) {
14592
+ out.duration_ms = clamp(out.duration_ms, 100, 5e3);
14593
+ }
14594
+ return out;
14595
+ }
14596
+ function clamp(v, lo, hi) {
14597
+ return Math.min(hi, Math.max(lo, v));
14598
+ }
14599
+ function enforceOverlayPresence(sb) {
14600
+ if (sb.steps.length === 0) return sb;
14601
+ const hasOverlay = sb.steps.some(
14602
+ (s) => s.action.type !== "camera" && s.action.type !== "clear"
14603
+ );
14604
+ if (hasOverlay) return sb;
14605
+ const cameraStep = sb.steps.find((s) => s.action.type === "camera");
14606
+ if (!cameraStep || cameraStep.action.type !== "camera") return sb;
14607
+ const target = cameraStep.action.target_block;
14608
+ if (!target) return sb;
14609
+ return {
14610
+ ...sb,
14611
+ reasoning: `${sb.reasoning} [auto-appended pulse: camera-only storyboards are forbidden]`,
14612
+ steps: [
14613
+ ...sb.steps,
14614
+ {
14615
+ at_ms: Math.min(4800, (cameraStep.at_ms ?? 0) + 200),
14616
+ duration_ms: 900,
14617
+ action: {
14618
+ type: "pulse",
14619
+ target_block: target,
14620
+ count: 2,
14621
+ intensity: "normal"
14622
+ }
14623
+ }
14624
+ ]
14625
+ };
14626
+ }
14627
+ function stripNullsDeep(input) {
14628
+ if (input === null) return void 0;
14629
+ if (Array.isArray(input)) {
14630
+ return input.map(stripNullsDeep).filter((v) => v !== void 0);
14631
+ }
14632
+ if (input && typeof input === "object") {
14633
+ const out = {};
14634
+ for (const [k, v] of Object.entries(input)) {
14635
+ const cleaned = stripNullsDeep(v);
14636
+ if (cleaned !== void 0) out[k] = cleaned;
14637
+ }
14638
+ return out;
14639
+ }
14640
+ return input;
14641
+ }
14642
+ function salvageStoryboard(parsed) {
14643
+ if (!parsed || typeof parsed !== "object") return null;
14644
+ const obj = parsed;
14645
+ if (!Array.isArray(obj.steps)) return null;
14646
+ const goodSteps = [];
14647
+ for (const step of obj.steps) {
14648
+ const r = StoryboardStepSchema.safeParse(step);
14649
+ if (r.success) goodSteps.push(r.data);
14650
+ if (goodSteps.length >= 4) break;
14651
+ }
14652
+ if (goodSteps.length === 0) return null;
14653
+ return {
14654
+ version: 1,
14655
+ reasoning: typeof obj.reasoning === "string" ? obj.reasoning + " (salvaged)" : "salvaged",
14656
+ steps: goodSteps
14657
+ };
14658
+ }
14659
+ function mergeSignals(a, b) {
14660
+ if (!a) return b;
14661
+ if (!b) return a;
14662
+ const ctrl = new AbortController();
14663
+ const onAbort = () => ctrl.abort();
14664
+ a.addEventListener("abort", onAbort);
14665
+ b.addEventListener("abort", onAbort);
14666
+ return ctrl.signal;
14667
+ }
14668
+
14669
+ // src/director/embedding-fallback.ts
14670
+ function cosineSimilarity(a, b) {
14671
+ let dot = 0;
14672
+ let na = 0;
14673
+ let nb = 0;
14674
+ const n = Math.min(a.length, b.length);
14675
+ for (let i = 0; i < n; i++) {
14676
+ dot += a[i] * b[i];
14677
+ na += a[i] * a[i];
14678
+ nb += b[i] * b[i];
14679
+ }
14680
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
14681
+ return denom === 0 ? 0 : dot / denom;
14682
+ }
14683
+ async function matchChunkToBlock(chunk, page, provider) {
14684
+ const textBlocks = page.blocks.filter(
14685
+ (b) => typeof b.text === "string" && b.text.trim().length > 0
14686
+ );
14687
+ if (textBlocks.length === 0) return null;
14688
+ const inputs = [chunk, ...textBlocks.map((b) => b.text)];
14689
+ const embeds = await provider.embed(inputs);
14690
+ if (embeds.length < 2) return null;
14691
+ const chunkEmbed = embeds[0];
14692
+ let best = null;
14693
+ for (let i = 0; i < textBlocks.length; i++) {
14694
+ const score = cosineSimilarity(chunkEmbed, embeds[i + 1]);
14695
+ if (!best || score > best.score) best = { block: textBlocks[i], score };
14696
+ }
14697
+ return best;
14698
+ }
14699
+ function nearestFigureOnPage(caption, page) {
14700
+ if (!page) return null;
14701
+ const [cx1, cy1, cx2, cy2] = caption.bbox;
14702
+ const ccx = (cx1 + cx2) / 2;
14703
+ const ccy = (cy1 + cy2) / 2;
14704
+ let best = null;
14705
+ for (const b of page.blocks) {
14706
+ if (b.block_id === caption.block_id) continue;
14707
+ if (b.type !== "figure" && b.type !== "figure_region") continue;
14708
+ const [x1, y1, x2, y2] = b.bbox;
14709
+ const fx = (x1 + x2) / 2;
14710
+ const fy = (y1 + y2) / 2;
14711
+ const dist = Math.hypot(fx - ccx, fy - ccy);
14712
+ if (!best || dist < best.dist) best = { block: b, dist };
14713
+ }
14714
+ return best?.block ?? null;
14715
+ }
14716
+ function truncateLabel(text, max) {
14717
+ if (!text) return "";
14718
+ const clean = text.replace(/\s+/g, " ").trim();
14719
+ if (clean.length <= max) return clean;
14720
+ return clean.slice(0, max - 1) + "\u2026";
14721
+ }
14722
+ function storyboardFromMatch(match, page) {
14723
+ if (!match) {
14724
+ return {
14725
+ version: 1,
14726
+ reasoning: "fallback: no match \u2014 clearing overlays",
14727
+ steps: [
14728
+ {
14729
+ at_ms: 0,
14730
+ duration_ms: 800,
14731
+ action: { type: "clear", targets: "overlays" }
14732
+ }
14733
+ ]
14734
+ };
14735
+ }
14736
+ const { block } = match;
14737
+ const id = block.block_id;
14738
+ const reason = `fallback (block.type=${block.type}): matched ${id} (${match.score.toFixed(2)})`;
14739
+ switch (block.type) {
14740
+ case "heading": {
14741
+ return {
14742
+ version: 1,
14743
+ reasoning: reason,
14744
+ steps: [
14745
+ {
14746
+ at_ms: 0,
14747
+ duration_ms: 700,
14748
+ action: {
14749
+ type: "spotlight",
14750
+ target_block: id,
14751
+ dim_opacity: 0.6,
14752
+ feather_px: 40,
14753
+ shape: "rounded"
14754
+ }
14755
+ },
14756
+ {
14757
+ at_ms: 300,
14758
+ duration_ms: 1200,
14759
+ action: {
14760
+ type: "label",
14761
+ target_block: id,
14762
+ text: truncateLabel(block.text, 32) || "section",
14763
+ position: "top"
14764
+ }
14765
+ }
14766
+ ]
14767
+ };
14768
+ }
14769
+ case "paragraph": {
14770
+ return {
14771
+ version: 1,
14772
+ reasoning: reason,
14773
+ steps: [
14774
+ {
14775
+ at_ms: 0,
14776
+ duration_ms: 600,
14777
+ action: {
14778
+ type: "camera",
14779
+ target_block: id,
14780
+ scale: 1.1,
14781
+ padding: 80,
14782
+ easing: "ease-out"
14783
+ }
14784
+ },
14785
+ {
14786
+ at_ms: 300,
14787
+ duration_ms: 900,
14788
+ action: {
14789
+ type: "underline",
14790
+ target_block: id,
14791
+ color: "#FBBF24",
14792
+ style: "sketch",
14793
+ draw_duration_ms: 800
14794
+ }
14795
+ }
14796
+ ]
14797
+ };
14798
+ }
14799
+ case "list_item":
14800
+ case "mcq_option": {
14801
+ return {
14802
+ version: 1,
14803
+ reasoning: reason,
14804
+ steps: [
14805
+ {
14806
+ at_ms: 0,
14807
+ duration_ms: 500,
14808
+ action: {
14809
+ type: "highlight",
14810
+ target_block: id,
14811
+ color: "rgba(250, 204, 21, 0.35)",
14812
+ draw_duration_ms: 450
14813
+ }
14814
+ }
14815
+ ]
14816
+ };
14817
+ }
14818
+ case "caption": {
14819
+ const figure = nearestFigureOnPage(block, page);
14820
+ if (figure) {
14821
+ return {
14822
+ version: 1,
14823
+ reasoning: `${reason}; caption \u2192 figure ${figure.block_id}`,
14824
+ steps: [
14825
+ {
14826
+ at_ms: 0,
14827
+ duration_ms: 900,
14828
+ action: {
14829
+ type: "callout",
14830
+ from_block: id,
14831
+ to_block: figure.block_id,
14832
+ label: "see",
14833
+ curve: "curved"
14834
+ }
14835
+ },
14836
+ {
14837
+ at_ms: 600,
14838
+ duration_ms: 1e3,
14839
+ action: {
14840
+ type: "pulse",
14841
+ target_block: figure.block_id,
14842
+ count: 2,
14843
+ intensity: "normal"
14844
+ }
14845
+ }
14846
+ ]
14847
+ };
14848
+ }
14849
+ return {
14850
+ version: 1,
14851
+ reasoning: `${reason}; no figure on page, underlining caption`,
14852
+ steps: [
14853
+ {
14854
+ at_ms: 0,
14855
+ duration_ms: 800,
14856
+ action: {
14857
+ type: "underline",
14858
+ target_block: id,
14859
+ color: "#FBBF24",
14860
+ style: "sketch",
14861
+ draw_duration_ms: 700
14862
+ }
14863
+ }
14864
+ ]
14865
+ };
14866
+ }
14867
+ case "figure": {
14868
+ return {
14869
+ version: 1,
14870
+ reasoning: reason,
14871
+ steps: [
14872
+ {
14873
+ at_ms: 0,
14874
+ duration_ms: 900,
14875
+ action: {
14876
+ type: "pulse",
14877
+ target_block: id,
14878
+ count: 2,
14879
+ intensity: "strong"
14880
+ }
14881
+ },
14882
+ {
14883
+ at_ms: 400,
14884
+ duration_ms: 1200,
14885
+ action: {
14886
+ type: "box",
14887
+ target_block: id,
14888
+ color: "#3B82F6",
14889
+ style: "solid"
14890
+ }
14891
+ }
14892
+ ]
14893
+ };
14894
+ }
14895
+ case "figure_region": {
14896
+ return {
14897
+ version: 1,
14898
+ reasoning: reason,
14899
+ steps: [
14900
+ {
14901
+ at_ms: 0,
14902
+ duration_ms: 900,
14903
+ action: {
14904
+ type: "pulse",
14905
+ target_block: id,
14906
+ count: 2,
14907
+ intensity: "normal"
14908
+ }
14909
+ }
14910
+ ]
14911
+ };
14912
+ }
14913
+ case "table": {
14914
+ return {
14915
+ version: 1,
14916
+ reasoning: reason,
14917
+ steps: [
14918
+ {
14919
+ at_ms: 0,
14920
+ duration_ms: 700,
14921
+ action: {
14922
+ type: "camera",
14923
+ target_block: id,
14924
+ scale: 1.2,
14925
+ padding: 60,
14926
+ easing: "ease-out"
14927
+ }
14928
+ },
14929
+ {
14930
+ at_ms: 300,
14931
+ duration_ms: 1e3,
14932
+ action: {
14933
+ type: "box",
14934
+ target_block: id,
14935
+ color: "#3B82F6",
14936
+ style: "dashed"
14937
+ }
14938
+ }
14939
+ ]
14940
+ };
14941
+ }
14942
+ default: {
14943
+ return {
14944
+ version: 1,
14945
+ reasoning: `${reason}; unknown block.type, using highlight`,
14946
+ steps: [
14947
+ {
14948
+ at_ms: 0,
14949
+ duration_ms: 600,
14950
+ action: {
14951
+ type: "highlight",
14952
+ target_block: id,
14953
+ color: "rgba(250, 204, 21, 0.35)",
14954
+ draw_duration_ms: 500
14955
+ }
14956
+ }
14957
+ ]
14958
+ };
14959
+ }
14960
+ }
14961
+ }
14962
+
14963
+ // src/components/TutorMode/TutorModeContainer.tsx
14964
+ var import_jsx_runtime52 = require("react/jsx-runtime");
14965
+ function buildBBoxIndex(bboxData) {
14966
+ const byPage = /* @__PURE__ */ new Map();
14967
+ const blockById = /* @__PURE__ */ new Map();
14968
+ const crossPageFigures = [];
14969
+ for (const page of bboxData) {
14970
+ byPage.set(page.page_number, page);
14971
+ for (const block of page.blocks) {
14972
+ blockById.set(block.block_id, { block, pageNumber: page.page_number });
14973
+ if ((block.type === "figure" || block.type === "figure_region" || block.type === "caption") && typeof block.text === "string" && block.text.length > 0) {
14974
+ crossPageFigures.push({
14975
+ block_id: block.block_id,
14976
+ page: page.page_number,
14977
+ type: block.type,
14978
+ text: block.text
14979
+ });
14980
+ }
14981
+ }
14982
+ }
14983
+ return { byPage, blockById, crossPageFigures };
14984
+ }
14985
+ function TutorModeContainer({
14986
+ pageNumber,
14987
+ bboxData,
14988
+ narrationStore,
14989
+ scale,
14990
+ rotation = 0,
14991
+ currentChunk,
14992
+ llm,
14993
+ idleTimeoutMs = 5e3,
14994
+ llmTimeoutMs = 3e4,
14995
+ embeddingProvider,
14996
+ showSubtitles = false,
14997
+ showExitButton = true,
14998
+ onExitTutorMode,
14999
+ minOverlayDurationMs,
15000
+ className
15001
+ }) {
15002
+ const containerRef = (0, import_react56.useRef)(null);
15003
+ const index = (0, import_react56.useMemo)(() => buildBBoxIndex(bboxData), [bboxData]);
15004
+ const { document: document2 } = usePDFViewer();
15005
+ const [pageProxy, setPageProxy] = (0, import_react56.useState)(null);
15006
+ const [viewport, setViewport] = (0, import_react56.useState)({ width: 800, height: 1e3 });
15007
+ const camera = (0, import_zustand2.useStore)(narrationStore, (s) => s.camera);
15008
+ const activeOverlays = (0, import_zustand2.useStore)(narrationStore, (s) => s.activeOverlays);
15009
+ (0, import_react56.useEffect)(() => {
15010
+ if (!containerRef.current) return;
15011
+ const el = containerRef.current;
15012
+ const update = () => setViewport({ width: el.clientWidth, height: el.clientHeight });
15013
+ update();
15014
+ const ro = new ResizeObserver(update);
15015
+ ro.observe(el);
15016
+ return () => ro.disconnect();
15017
+ }, []);
15018
+ (0, import_react56.useEffect)(() => {
15019
+ if (!document2) {
15020
+ setPageProxy(null);
15021
+ return;
15022
+ }
15023
+ let cancelled = false;
15024
+ document2.getPage(pageNumber).then((p) => {
15025
+ if (!cancelled) setPageProxy(p);
15026
+ }).catch(() => {
15027
+ if (!cancelled) setPageProxy(null);
15028
+ });
15029
+ return () => {
15030
+ cancelled = true;
15031
+ };
15032
+ }, [document2, pageNumber]);
15033
+ (0, import_react56.useEffect)(() => {
15034
+ narrationStore.getState().setCurrentPage(pageNumber);
15035
+ }, [pageNumber, narrationStore]);
15036
+ (0, import_react56.useEffect)(() => {
15037
+ const page2 = index.byPage.get(pageNumber);
15038
+ if (!page2) return;
15039
+ if (viewport.width === 0 || viewport.height === 0) return;
15040
+ if (narrationStore.getState().activeOverlays.length > 0) return;
15041
+ const fit = Math.min(
15042
+ viewport.width / page2.page_dimensions.width,
15043
+ viewport.height / page2.page_dimensions.height
15044
+ ) * 0.95;
15045
+ narrationStore.getState().setCamera({ scale: fit, x: 0, y: 0 });
15046
+ }, [pageNumber, viewport, index, narrationStore]);
15047
+ const engineRef = (0, import_react56.useRef)(null);
15048
+ (0, import_react56.useEffect)(() => {
15049
+ engineRef.current = new StoryboardEngine({
15050
+ narrationStore,
15051
+ bboxIndex: index,
15052
+ getViewport: () => viewport,
15053
+ minOverlayDurationMs
15054
+ });
15055
+ return () => engineRef.current?.cancelPending();
15056
+ }, [narrationStore, index, viewport, minOverlayDurationMs]);
15057
+ const abortRef = (0, import_react56.useRef)(null);
15058
+ const debounceRef = (0, import_react56.useRef)(null);
15059
+ const lastChunkRef = (0, import_react56.useRef)(null);
15060
+ (0, import_react56.useEffect)(() => {
15061
+ if (!llm) return;
15062
+ if (!currentChunk || currentChunk === lastChunkRef.current) return;
15063
+ if (debounceRef.current) clearTimeout(debounceRef.current);
15064
+ debounceRef.current = setTimeout(async () => {
15065
+ const chunk = currentChunk;
15066
+ if (chunk === lastChunkRef.current) return;
15067
+ lastChunkRef.current = chunk;
15068
+ const page2 = index.byPage.get(pageNumber);
15069
+ if (!page2) return;
15070
+ narrationStore.getState().pushChunkHistory({
15071
+ text: chunk,
15072
+ pageNumber,
15073
+ timestamp: Date.now()
15074
+ });
15075
+ narrationStore.getState().appendDebugEvent({
15076
+ kind: "chunk",
15077
+ summary: `chunk \u2192 ${chunk.slice(0, 80)}${chunk.length > 80 ? "\u2026" : ""}`,
15078
+ payload: { chunk, pageNumber }
15079
+ });
15080
+ abortRef.current?.abort();
15081
+ abortRef.current = new AbortController();
15082
+ narrationStore.getState().setLlmStatus("in-flight");
15083
+ narrationStore.getState().appendDebugEvent({
15084
+ kind: "llm-request",
15085
+ summary: `LLM ${llm.model} (page ${pageNumber}, ${page2.blocks.length} blocks)`,
15086
+ payload: { model: llm.model, pageNumber, blockCount: page2.blocks.length }
15087
+ });
15088
+ const result = await directStoryboard(llm, {
15089
+ chunk,
15090
+ pageNumber,
15091
+ page: page2,
15092
+ index,
15093
+ history: narrationStore.getState().chunkHistory,
15094
+ camera: narrationStore.getState().camera,
15095
+ activeOverlays: narrationStore.getState().activeOverlays,
15096
+ signal: abortRef.current.signal,
15097
+ timeoutMs: llmTimeoutMs
15098
+ });
15099
+ if (result.storyboard) {
15100
+ narrationStore.getState().setLlmStatus("idle");
15101
+ narrationStore.getState().appendDebugEvent({
15102
+ kind: "llm-response",
15103
+ summary: `storyboard \u2713 ${result.storyboard.steps.length} steps \u2014 ${result.storyboard.reasoning.slice(0, 60)}`,
15104
+ payload: { raw: result.raw, storyboard: result.storyboard }
15105
+ });
15106
+ engineRef.current?.execute(result.storyboard);
15107
+ narrationStore.getState().appendDebugEvent({
15108
+ kind: "storyboard-execute",
15109
+ summary: `engine executing ${result.storyboard.steps.length} steps`,
15110
+ payload: result.storyboard.steps.map((s) => ({
15111
+ at_ms: s.at_ms,
15112
+ type: s.action.type,
15113
+ target: "target_block" in s.action ? s.action.target_block : "target" in s.action ? s.action.target : void 0
15114
+ }))
15115
+ });
15116
+ } else {
15117
+ narrationStore.getState().setLlmStatus("failed", result.error ?? "unknown");
15118
+ narrationStore.getState().appendDebugEvent({
15119
+ kind: "llm-error",
15120
+ summary: `LLM failed: ${(result.error ?? "unknown").slice(0, 80)}`,
15121
+ payload: { error: result.error, raw: result.raw }
15122
+ });
15123
+ if (embeddingProvider) {
15124
+ try {
15125
+ const match = await matchChunkToBlock(chunk, page2, embeddingProvider);
15126
+ const fallbackSb = storyboardFromMatch(match, page2);
15127
+ narrationStore.getState().appendDebugEvent({
15128
+ kind: "fallback-fired",
15129
+ summary: `embedding fallback \u2192 ${match?.block.block_id ?? "no match"}`,
15130
+ payload: { match, storyboard: fallbackSb }
15131
+ });
15132
+ engineRef.current?.execute(fallbackSb);
15133
+ } catch (e) {
15134
+ narrationStore.getState().appendDebugEvent({
15135
+ kind: "llm-error",
15136
+ summary: `fallback also failed: ${e.message}`,
15137
+ payload: e
15138
+ });
15139
+ }
15140
+ }
15141
+ }
15142
+ }, 200);
15143
+ return () => {
15144
+ if (debounceRef.current) clearTimeout(debounceRef.current);
15145
+ };
15146
+ }, [currentChunk, llm, index, pageNumber, narrationStore, embeddingProvider, llmTimeoutMs]);
15147
+ (0, import_react56.useEffect)(() => {
15148
+ if (!currentChunk) return;
15149
+ const t = setTimeout(() => {
15150
+ if (!engineRef.current) return;
15151
+ const hist = narrationStore.getState().chunkHistory;
15152
+ const latest = hist.length > 0 ? hist[hist.length - 1] : null;
15153
+ if (!latest) return;
15154
+ if (Date.now() - latest.timestamp < idleTimeoutMs) return;
15155
+ engineRef.current.resetVisuals();
15156
+ }, idleTimeoutMs + 100);
15157
+ return () => clearTimeout(t);
15158
+ }, [currentChunk, idleTimeoutMs, narrationStore]);
15159
+ const page = index.byPage.get(pageNumber);
15160
+ const dpiScale = page ? page.page_dimensions.dpi / 72 : 1;
15161
+ const rasterScale = dpiScale * (scale || 1);
15162
+ const baseW = page ? page.page_dimensions.width * (scale || 1) : 0;
15163
+ const baseH = page ? page.page_dimensions.height * (scale || 1) : 0;
15164
+ return /* @__PURE__ */ (0, import_jsx_runtime52.jsxs)(
15165
+ "div",
15166
+ {
15167
+ ref: containerRef,
15168
+ className,
15169
+ style: {
15170
+ position: "relative",
15171
+ width: "100%",
15172
+ height: "100%",
15173
+ overflow: "hidden",
15174
+ background: "#111"
15175
+ },
15176
+ "data-role": "tutor-mode-container",
15177
+ "data-page-loaded": page ? "true" : "false",
15178
+ children: [
15179
+ showExitButton ? /* @__PURE__ */ (0, import_jsx_runtime52.jsx)(
15180
+ "button",
15181
+ {
15182
+ onClick: () => {
15183
+ engineRef.current?.resetVisuals();
15184
+ onExitTutorMode?.();
15185
+ },
15186
+ style: {
15187
+ position: "absolute",
15188
+ top: 12,
15189
+ right: 12,
15190
+ zIndex: 60,
15191
+ minHeight: 40,
15192
+ minWidth: 40,
15193
+ padding: "8px 14px",
15194
+ border: "none",
15195
+ borderRadius: 8,
15196
+ background: "rgba(255,255,255,0.12)",
15197
+ color: "white",
15198
+ cursor: "pointer",
15199
+ fontFamily: "system-ui, sans-serif",
15200
+ fontSize: 14,
15201
+ touchAction: "manipulation"
15202
+ },
15203
+ "aria-label": "Reset view \u2014 clear overlays and fit the page",
15204
+ "data-role": "exit-tutor",
15205
+ children: "Reset view"
15206
+ }
15207
+ ) : null,
15208
+ page ? /* @__PURE__ */ (0, import_jsx_runtime52.jsx)(CameraView, { camera, children: /* @__PURE__ */ (0, import_jsx_runtime52.jsxs)(
15209
+ "div",
15210
+ {
15211
+ style: {
15212
+ position: "absolute",
15213
+ top: "50%",
15214
+ left: "50%",
15215
+ width: baseW,
15216
+ height: baseH,
15217
+ transform: "translate(-50%, -50%)"
15218
+ },
15219
+ children: [
15220
+ /* @__PURE__ */ (0, import_jsx_runtime52.jsx)(
15221
+ PDFPage,
15222
+ {
15223
+ pageNumber,
15224
+ page: pageProxy,
15225
+ scale: rasterScale,
15226
+ rotation,
15227
+ showTextLayer: false,
15228
+ showHighlightLayer: false,
15229
+ showAnnotationLayer: false
15230
+ }
15231
+ ),
15232
+ /* @__PURE__ */ (0, import_jsx_runtime52.jsx)(
15233
+ CinemaLayer,
15234
+ {
15235
+ page,
15236
+ index,
15237
+ overlays: activeOverlays,
15238
+ scale: scale || 1
15239
+ }
15240
+ )
15241
+ ]
15242
+ }
15243
+ ) }) : null,
15244
+ showSubtitles ? /* @__PURE__ */ (0, import_jsx_runtime52.jsx)(SubtitleBar, { text: currentChunk ?? null }) : null
15245
+ ]
15246
+ }
15247
+ );
15248
+ }
15249
+
15250
+ // src/director/transformers-embedding.ts
15251
+ var loaded = null;
15252
+ function getLocalMiniLM() {
15253
+ if (loaded) return loaded;
15254
+ loaded = (async () => {
15255
+ const mod = await import(
15256
+ /* webpackIgnore: true */
15257
+ "@xenova/transformers"
15258
+ );
15259
+ const { pipeline } = mod;
15260
+ const extractor = await pipeline(
15261
+ "feature-extraction",
15262
+ "Xenova/all-MiniLM-L6-v2"
15263
+ );
15264
+ return {
15265
+ async embed(texts) {
15266
+ const out = [];
15267
+ for (const t of texts) {
15268
+ const result = await extractor(t, {
15269
+ pooling: "mean",
15270
+ normalize: true
15271
+ });
15272
+ out.push(new Float32Array(result.data.slice()));
15273
+ }
15274
+ return out;
15275
+ }
15276
+ };
15277
+ })();
15278
+ return loaded;
15279
+ }
15280
+
12967
15281
  // src/index.ts
12968
15282
  init_hooks();
12969
15283
  init_store();
@@ -12975,19 +15289,26 @@ init_PluginManager();
12975
15289
  init_utils();
12976
15290
  // Annotate the CommonJS export names for ESM import in node:
12977
15291
  0 && (module.exports = {
15292
+ AnimatedHighlight,
15293
+ AnimatedUnderline,
12978
15294
  AnnotationLayer,
12979
15295
  AnnotationToolbar,
12980
15296
  AskAboutOverlay,
12981
15297
  AskAboutTrigger,
12982
15298
  BookModeContainer,
12983
15299
  BookmarksPanel,
15300
+ BoxOverlay,
15301
+ CalloutArrow,
15302
+ CameraView,
12984
15303
  CanvasLayer,
15304
+ CinemaLayer,
12985
15305
  ContinuousScrollContainer,
12986
15306
  DocumentContainer,
12987
15307
  DrawingCanvas,
12988
15308
  DualPageContainer,
12989
15309
  FloatingZoomControls,
12990
15310
  FocusRegionLayer,
15311
+ GhostReference,
12991
15312
  HighlightLayer,
12992
15313
  HighlightPopover,
12993
15314
  HighlightsPanel,
@@ -13004,32 +15325,46 @@ init_utils();
13004
15325
  PDFViewerContext,
13005
15326
  PDFViewerProvider,
13006
15327
  PluginManager,
15328
+ PulseOverlay,
13007
15329
  QuickNoteButton,
13008
15330
  QuickNotePopover,
15331
+ SYSTEM_PROMPT,
13009
15332
  SearchPanel,
13010
15333
  SelectionToolbar,
13011
15334
  ShapePreview,
13012
15335
  ShapeRenderer,
13013
15336
  Sidebar,
15337
+ SpotlightMask,
15338
+ StickyLabel,
13014
15339
  StickyNote,
15340
+ StoryboardActionSchema,
15341
+ StoryboardEngine,
15342
+ StoryboardSchema,
15343
+ SubtitleBar,
13015
15344
  TakeawaysPanel,
13016
15345
  TextLayer,
13017
15346
  ThumbnailPanel,
13018
15347
  Toolbar,
15348
+ TutorModeContainer,
13019
15349
  VirtualizedDocumentContainer,
13020
15350
  applyRotation,
15351
+ buildBBoxIndex,
15352
+ buildUserPrompt,
13021
15353
  clearHighlights,
13022
15354
  clearStudentData,
13023
15355
  cn,
15356
+ cosineSimilarity,
13024
15357
  countTextOnPage,
13025
15358
  createAgentAPI,
13026
15359
  createAgentStore,
13027
15360
  createAnnotationStore,
15361
+ createNarrationStore,
13028
15362
  createPDFViewer,
13029
15363
  createPluginManager,
13030
15364
  createSearchStore,
13031
15365
  createStudentStore,
13032
15366
  createViewerStore,
15367
+ directStoryboard,
13033
15368
  doRectsIntersect,
13034
15369
  downloadAnnotationsAsJSON,
13035
15370
  downloadAnnotationsAsMarkdown,
@@ -13044,6 +15379,7 @@ init_utils();
13044
15379
  generateDocumentId,
13045
15380
  getAllDocumentIds,
13046
15381
  getAllStudentDataDocumentIds,
15382
+ getLocalMiniLM,
13047
15383
  getMetadata,
13048
15384
  getOutline,
13049
15385
  getPage,
@@ -13061,6 +15397,8 @@ init_utils();
13061
15397
  loadDocumentWithCallbacks,
13062
15398
  loadHighlights,
13063
15399
  loadStudentData,
15400
+ makeOverlayId,
15401
+ matchChunkToBlock,
13064
15402
  mergeAdjacentRects,
13065
15403
  pdfToPercent,
13066
15404
  pdfToViewport,
@@ -13073,6 +15411,9 @@ init_utils();
13073
15411
  saveHighlights,
13074
15412
  saveStudentData,
13075
15413
  scaleRect,
15414
+ storyboardFromMatch,
15415
+ storyboardJsonSchema,
15416
+ truncate,
13076
15417
  useAgentContext,
13077
15418
  useAgentStore,
13078
15419
  useAnnotationStore,