@hypit/hypit 0.1.14 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +2 -2
  2. package/dist/public/browser-capture.d.ts +9 -3
  3. package/dist/public/caption.d.ts +2 -0
  4. package/dist/public/narrative.d.ts +2 -0
  5. package/dist/public/runtime-kit.d.ts +2 -2
  6. package/dist/public/speech.d.ts +2 -0
  7. package/examples/semantic-composition/packages/responsive-explainer/README.md +1 -1
  8. package/package.json +1 -1
  9. package/packages/browser-capture/README.md +9 -2
  10. package/packages/browser-capture/package.json +5 -0
  11. package/packages/browser-capture/src/browser.ts +22 -8
  12. package/packages/browser-capture/src/index.ts +8 -2
  13. package/packages/caption/README.md +1 -1
  14. package/packages/caption/src/display.ts +1 -0
  15. package/packages/caption-fine/README.md +19 -5
  16. package/packages/caption-fine/package.json +1 -0
  17. package/packages/caption-fine/src/joined-box.ts +114 -0
  18. package/packages/caption-fine/src/manifest.ts +1 -1
  19. package/packages/caption-fine/src/render.ts +99 -92
  20. package/packages/caption-fine/src/spacing.ts +8 -61
  21. package/packages/caption-fine-studio/src/index.ts +2 -4
  22. package/packages/cli/README.md +3 -2
  23. package/packages/cli/src/arguments.ts +2 -2
  24. package/packages/cli/src/command.ts +1 -1
  25. package/packages/cli/src/commands/environment.ts +9 -2
  26. package/packages/cli/src/machine-view.ts +1 -1
  27. package/packages/cli/src/output.ts +1 -0
  28. package/packages/credential-store-os/src/store.ts +5 -1
  29. package/packages/narrative/README.md +3 -1
  30. package/packages/narrative/src/schema.ts +1 -0
  31. package/packages/narrative/src/types.ts +2 -0
  32. package/packages/provider-whisperx-local/README.md +26 -10
  33. package/packages/provider-whisperx-local/src/activation.ts +10 -1
  34. package/packages/provider-whisperx-local/src/program.ts +8 -4
  35. package/packages/runtime-host-node/src/index.ts +1 -0
  36. package/packages/runtime-kit/README.md +6 -2
  37. package/packages/runtime-kit/src/index.ts +2 -2
  38. package/packages/runtime-local/README.md +6 -1
  39. package/packages/runtime-local/src/host.ts +2 -0
  40. package/packages/runtime-local/src/index.ts +1 -0
  41. package/packages/runtime-local/src/programs.ts +53 -17
  42. package/packages/script/README.md +81 -34
  43. package/packages/script/bin/migrate-0.2.mjs +90 -0
  44. package/packages/script/src/edit.ts +28 -60
  45. package/packages/script/src/lexical.ts +59 -130
  46. package/packages/script/src/manifest.ts +11 -11
  47. package/packages/script/src/narrative.ts +4 -2
  48. package/packages/script/src/parser.ts +140 -193
  49. package/packages/script/src/types.ts +2 -3
  50. package/packages/studio/src/ui/code.ts +1 -1
  51. package/packages/studio/src/ui/main.ts +2 -2
  52. package/packages/studio/src/ui/markers.ts +3 -3
  53. package/packages/studio/src/ui/selection.ts +1 -1
  54. package/packages/studio/src/ui/syntax.ts +42 -21
  55. package/packages/temporal-markup/README.md +1 -1
  56. package/packages/video-cli/README.md +9 -5
  57. package/packages/video-cli/src/capture.ts +17 -2
  58. package/packages/video-cli/src/creation.ts +4 -6
  59. package/packages/video-cli/src/media.ts +10 -3
  60. package/packages/whisperx/README.md +56 -3
  61. package/packages/whisperx/src/component.ts +2 -5
  62. package/packages/whisperx/src/evidence.ts +4 -3
  63. package/packages/whisperx/src/index.ts +1 -0
  64. package/packages/whisperx/src/manifest.ts +2 -3
  65. package/packages/whisperx/src/surface.ts +3 -6
  66. package/packages/whisperx/src/types.ts +9 -1
  67. package/packages/yt-dlp/README.md +9 -3
  68. package/packages/yt-dlp/package.json +2 -1
  69. package/packages/yt-dlp/src/download.ts +10 -24
  70. package/packages/yt-dlp/src/environment.ts +36 -0
  71. package/packages/yt-dlp/src/index.ts +1 -0
  72. package/services/whisperx/README.md +45 -12
  73. package/services/whisperx/src/hypit_whisperx_service/application.py +3 -0
  74. package/services/whisperx/src/hypit_whisperx_service/check.py +7 -0
  75. package/services/whisperx/src/hypit_whisperx_service/config.py +7 -0
  76. package/services/whisperx/src/hypit_whisperx_service/engine.py +21 -5
  77. package/services/whisperx/src/hypit_whisperx_service/models.py +131 -0
  78. package/services/whisperx/src/hypit_whisperx_service/prepare.py +21 -3
  79. package/services/whisperx/src/hypit_whisperx_service/resources.py +19 -3
  80. package/services/yt-dlp/README.md +14 -15
  81. package/services/yt-dlp/pyproject.toml +1 -1
  82. package/services/yt-dlp/uv.lock +513 -2
@@ -8,19 +8,22 @@ import type {
8
8
  VisualColorPaint,
9
9
  VisualElement,
10
10
  VisualKeyframe,
11
+ VisualProgramElement,
11
12
  VisualStyleDeclaration,
12
13
  VisualTextElement,
13
14
  VisualTextPaintLayer,
14
15
  VisualTrack,
15
16
  } from "@hypit/composition";
16
- import type { CaptionAlignmentUnit, CaptionDocument } from "@hypit/narrative";
17
+ import type { CaptionAlignmentUnit, CaptionDocument, CaptionDisplayWord } from "@hypit/narrative";
17
18
  import { assertProgramSpaceIdentity, programSpaceFrameCount } from "@hypit/program-space";
18
19
  import { assertSpatialRegionTimeline } from "@hypit/spatial";
19
20
  import type { CanvasSpace, SpatialFrame, SpatialRegionTimeline } from "@hypit/spatial";
20
21
 
21
22
  import { assertFineCaptionParameters, FINE_CAPTION_FAMILY } from "./style.js";
22
23
  import { assertFineCaptionSchedule } from "./schedule.js";
23
- import { joinSurfaces, uniformGap, wordGapBetween, wordGaps } from "./spacing.js";
24
+ import { uniformGap, wordGaps } from "./spacing.js";
25
+ import { browserProgram } from "@hypit/hyperframes";
26
+ import { joinedBoxSetup } from "./joined-box.js";
24
27
  import type {
25
28
  FineCaptionActiveUnderline,
26
29
  FineCaptionGlyphPaint,
@@ -621,20 +624,17 @@ function trackedPlacementAnimation(
621
624
  return { keyframes };
622
625
  }
623
626
 
624
- function structuralRowCount(
625
- atoms: readonly CaptionAlignmentUnit[],
626
- maxWordsPerLine: number,
627
- ): number {
628
- let rows = atoms.length === 0 ? 0 : 1;
627
+ function structuralRowStarts(atoms: readonly CaptionAlignmentUnit[], maxWordsPerLine?: number): ReadonlySet<number> {
628
+ const starts = new Set<number>();
629
629
  let wordsOnRow = 0;
630
- for (const atom of atoms) {
631
- if (wordsOnRow > 0 && wordsOnRow + atom.wordIds.length > maxWordsPerLine) {
632
- rows += 1;
630
+ for (const [index, atom] of atoms.entries()) {
631
+ if (index === 0 || (maxWordsPerLine !== undefined && wordsOnRow > 0 && wordsOnRow + atom.wordIds.length > maxWordsPerLine)) {
632
+ starts.add(index);
633
633
  wordsOnRow = 0;
634
634
  }
635
635
  wordsOnRow += atom.wordIds.length;
636
636
  }
637
- return rows;
637
+ return starts;
638
638
  }
639
639
 
640
640
  /**
@@ -665,7 +665,7 @@ function cueElements(
665
665
  atoms: readonly CaptionAlignmentUnit[],
666
666
  atomFrames: ReadonlyMap<string, { readonly start: number; readonly end: number }>,
667
667
  parameters: FineCaptionParameters,
668
- wordText: ReadonlyMap<string, string>,
668
+ wordText: ReadonlyMap<string, CaptionDisplayWord>,
669
669
  durationFrames: number,
670
670
  styleId: string,
671
671
  trackedPlacement?: {
@@ -673,7 +673,7 @@ function cueElements(
673
673
  readonly canvas: CanvasSpace;
674
674
  },
675
675
  ): VisualElement[] {
676
- type UnorderedVisualElement = Omit<VisualBoxElement, "order"> | Omit<VisualTextElement, "order">;
676
+ type UnorderedVisualElement = Omit<VisualBoxElement, "order"> | Omit<VisualTextElement, "order"> | Omit<VisualProgramElement, "order">;
677
677
  const elements: VisualElement[] = [];
678
678
  let order = 0;
679
679
  const push = (element: UnorderedVisualElement): void => {
@@ -694,21 +694,29 @@ function cueElements(
694
694
  const cueLoop = parameters.motion.loopTarget === "cue" ? loopAnimation(parameters, durationFrames) : undefined;
695
695
  const activationFrames = exclusiveActivationFrames(atoms, atomFrames);
696
696
  const gapPx = `${compactNumber(parameters.layout.wordGapPx)}px`;
697
- const atomSurfaces = atoms.map((atom) => atom.wordIds.map((wordId) => wordText.get(wordId) ?? ""));
698
- // A Cue whose boundaries all agree carries one `column-gap`, which is also what keeps a row that
699
- // wraps from opening on a margin. A Cue that mixes scripts spaces each element instead.
700
- const atomGaps = atomSurfaces.map((surfaces, index) => {
701
- const previous = atomSurfaces[index - 1];
702
- return previous === undefined ? false : wordGapBetween(previous.at(-1) ?? "", surfaces[0] ?? "");
703
- });
704
- const cueGap = uniformGap(atomGaps);
697
+ const atomWords = atoms.map(atom => atom.wordIds.map(wordId => {
698
+ const word = wordText.get(wordId);
699
+ if (!word) throw new Error(`Fine Caption Atom references unknown word ${wordId}`);
700
+ return word;
701
+ }));
702
+ const atomSurfaces = atomWords.map(words => words.map(word => word.text));
703
+ const atomGaps = atomWords.map((words, index) => index > 0 && words[0]!.separatorBefore === " ");
705
704
  // The Visual IR carries physical margins, so the leading edge follows the Cue's own direction.
706
705
  const marginStart = parameters.layout.direction === "rtl" ? "margin-right" : "margin-left";
707
706
  const spacedStyle = (spaced: boolean): readonly VisualStyleDeclaration[] =>
708
707
  spaced ? [{ name: marginStart, value: gapPx }] : [];
708
+ const atomFlow = (words: readonly CaptionDisplayWord[]): VisualStyleDeclaration[] => [
709
+ ...(uniformGap(wordGaps(words)) === true ? [{ name: "column-gap", value: gapPx }] : []),
710
+ { name: "display", value: "inline-flex" },
711
+ { name: "min-width", value: "0" },
712
+ { name: "overflow-wrap", value: "anywhere" },
713
+ { name: "white-space", value: "normal" },
714
+ { name: "word-break", value: parameters.layout.wrap === "grapheme" ? "break-all" : "normal" },
715
+ ];
709
716
  const fonts = parameters.typography.exactFonts;
717
+ const rowStarts = structuralRowStarts(atoms, parameters.layout.maxWordsPerLine);
710
718
  if (parameters.layout.maxLines !== undefined) {
711
- const rows = structuralRowCount(atoms, parameters.layout.maxWordsPerLine!);
719
+ const rows = rowStarts.size;
712
720
  if (rows > parameters.layout.maxLines) {
713
721
  throw new Error(`Fine Caption Style ${styleId} constructs ${rows} rows for a Cue whose maximum is ${parameters.layout.maxLines}`);
714
722
  }
@@ -766,10 +774,10 @@ function cueElements(
766
774
  { name: "border-radius", value: `${compactNumber(parameters.cueBox.radiusPx)}px` },
767
775
  { name: "border-style", value: "solid" },
768
776
  { name: "border-width", value: `${compactNumber(parameters.cueBox.borderWidthPx)}px` },
769
- ...(cueGap === true ? [{ name: "column-gap", value: gapPx }] as const : []),
770
777
  { name: "direction", value: parameters.layout.direction },
771
- { name: "display", value: "flex" },
772
- { name: "flex-wrap", value: "wrap" },
778
+ { name: "display", value: "block" },
779
+ { name: "font-size", value: "0px" },
780
+ { name: "line-height", value: "0" },
773
781
  { name: "justify-content", value: parameters.layout.textAlign === "left" ? "flex-start"
774
782
  : parameters.layout.textAlign === "right" ? "flex-end" : "center" },
775
783
  { name: "max-width", value: "100%" },
@@ -781,64 +789,73 @@ function cueElements(
781
789
  ],
782
790
  });
783
791
 
792
+ const pushSeparator = (id: string, parent: string): void => push({
793
+ id, parent, kind: "text", text: " ", fonts,
794
+ style: [
795
+ { name: "display", value: "inline" },
796
+ { name: "font-size", value: "0px" },
797
+ { name: "letter-spacing", value: gapPx },
798
+ { name: "line-height", value: "0" },
799
+ { name: "white-space", value: "normal" },
800
+ ],
801
+ });
802
+
784
803
  if (parameters.activeBox.mode === "trail" && parameters.activeBox.continuity === "joined") {
785
- for (const [prefixIndex, atom] of atoms.entries()) {
786
- const timing = atomFrames.get(atom.id);
787
- if (timing === undefined) throw new Error(`Fine Caption is missing timing for Atom ${atom.id}`);
788
- const next = atoms[prefixIndex + 1];
804
+ // Fine owns this drawing program. The renderer only executes its existing browser-program
805
+ // format; it learns nothing about captions, activation units or their layout rules.
806
+ push({
807
+ id: "joined-boxes", parent: "cue", kind: "program",
808
+ style: [
809
+ { name: "position", value: "absolute" }, { name: "inset", value: "0" },
810
+ { name: "padding", value: `${compactNumber(parameters.cueBox.paddingYPx)}px ${compactNumber(parameters.cueBox.paddingXPx)}px` },
811
+ ],
812
+ program: browserProgram({
813
+ html: "{{joined-layout}}" + atoms.map((_, index) => `{{joined-box-${index + 1}}}`).join(""),
814
+ setup: joinedBoxSetup,
815
+ css: ":scope { pointer-events: none; }",
816
+ data: {
817
+ wordCounts: atomWords.map(words => words.length),
818
+ background: parameters.activeBox.background, borderColor: parameters.activeBox.borderColor,
819
+ borderWidth: parameters.activeBox.borderWidthPx, radius: parameters.activeBox.radiusPx,
820
+ paddingX: parameters.activeBox.paddingXPx, paddingY: parameters.activeBox.paddingYPx,
821
+ },
822
+ }),
823
+ });
824
+ push({ id: "joined-layout", parent: "joined-boxes", kind: "box",
825
+ style: [{ name: "visibility", value: "hidden" }],
826
+ attributes: [{ name: "data-fine-box-layout", value: "" }],
827
+ });
828
+ // The complete stationary Cue supplies layout, including the inactive suffix. Its actual
829
+ // word fragments determine the background; decoration contributes no width to text flow.
830
+ for (const [index, words] of atomWords.entries()) {
831
+ if (index > 0 && rowStarts.has(index)) push({ id: `joined-break-${index}`, parent: "joined-layout", kind: "box",
832
+ style: [{ name: "display", value: "block" }, { name: "height", value: "0" }],
833
+ });
834
+ if (!rowStarts.has(index) && words[0]!.separatorBefore === " ") pushSeparator(`joined-gap-${index}`, "joined-layout");
835
+ const groupId = `joined-group-${index}`;
836
+ push({ id: groupId, parent: "joined-layout", kind: "box", style: [...atomFlow(words), { name: "max-width", value: "100%" }] });
837
+ const gaps = wordGaps(words);
838
+ for (const [wordIndex, word] of words.entries()) {
839
+ push({ id: `${groupId}-word-${wordIndex}`, parent: groupId, kind: "text", text: word.text, fonts,
840
+ style: [...transparentGlyphStyle(parameters),
841
+ ...(uniformGap(gaps) === undefined ? spacedStyle(gaps[wordIndex] ?? false) : [])],
842
+ attributes: [{ name: "data-fine-box-word", value: "" }],
843
+ });
844
+ }
845
+ }
846
+ for (const [index, atom] of atoms.entries()) {
847
+ const timing = atomFrames.get(atom.id)!;
848
+ const next = atoms[index + 1];
789
849
  const nextStart = next === undefined ? durationFrames : atomFrames.get(next.id)?.start;
790
850
  if (nextStart === undefined) throw new Error("Fine Caption is missing timing for the next Atom");
791
- // A no-break space inside an atom keeps its words on one row; the boundaries between atoms
792
- // take an ordinary space. Both are spaced only where the surfaces meeting there call for one.
793
- const prefixText = joinSurfaces(
794
- atoms.slice(0, prefixIndex + 1).map((prefixAtom, index) => {
795
- for (const wordId of prefixAtom.wordIds) {
796
- if (!wordText.has(wordId)) throw new Error(`Fine Caption Atom references unknown word ${wordId}`);
797
- }
798
- return joinSurfaces(atomSurfaces[index] ?? [], "\u00A0");
799
- }),
800
- " ",
801
- );
802
- const layerId = `joined-box-${prefixIndex + 1}`;
803
- push({
804
- id: layerId,
805
- parent: "cue",
806
- kind: "box",
807
- style: [
808
- { name: "box-sizing", value: "border-box" },
809
- { name: "direction", value: parameters.layout.direction },
810
- { name: "inset", value: "0" },
811
- { name: "padding", value: `${compactNumber(parameters.cueBox.paddingYPx)}px ${compactNumber(parameters.cueBox.paddingXPx)}px` },
812
- { name: "position", value: "absolute" },
813
- { name: "text-align", value: parameters.layout.textAlign },
814
- ],
851
+ push({ id: `joined-box-${index + 1}`, parent: "joined-boxes", kind: "box",
852
+ style: [{ name: "position", value: "absolute" }, { name: "inset", value: "0" }],
815
853
  animation: activeBoxAnimation(parameters, timing.start, nextStart, durationFrames, "current"),
816
854
  attributes: [{ name: "data-caption-active-box", value: "joined" }],
817
855
  });
818
- push({
819
- id: `${layerId}-text`,
820
- parent: layerId,
821
- kind: "text",
822
- text: prefixText,
823
- style: [
824
- ...transparentGlyphStyle(parameters).filter(({ name }) => name !== "white-space"),
825
- { name: "-webkit-box-decoration-break", value: "clone" },
826
- { name: "background", value: parameters.activeBox.background },
827
- { name: "border-color", value: parameters.activeBox.borderColor },
828
- { name: "border-radius", value: `${compactNumber(parameters.activeBox.radiusPx)}px` },
829
- { name: "border-style", value: "solid" },
830
- { name: "border-width", value: `${compactNumber(parameters.activeBox.borderWidthPx)}px` },
831
- { name: "box-decoration-break", value: "clone" },
832
- { name: "display", value: "inline" },
833
- { name: "padding", value: `${compactNumber(parameters.activeBox.paddingYPx)}px ${compactNumber(parameters.activeBox.paddingXPx)}px` },
834
- { name: "white-space", value: "normal" },
835
- ],
836
- fonts,
837
- });
838
856
  }
839
857
  }
840
858
 
841
- let wordsOnLine = 0;
842
859
  for (const [atomIndex, atom] of atoms.entries()) {
843
860
  const timing = atomFrames.get(atom.id);
844
861
  if (timing === undefined) throw new Error(`Fine Caption is missing timing for Atom ${atom.id}`);
@@ -848,22 +865,20 @@ function cueElements(
848
865
  const surfaces = atomSurfaces[atomIndex] ?? [];
849
866
  // The base glyphs and the activated copy stacked over them are laid out from this one list, so
850
867
  // the karaoke wipe keeps sitting on the letterforms it reveals.
851
- const gaps = wordGaps(surfaces);
868
+ const gaps = wordGaps(atomWords[atomIndex]!);
852
869
  const wordGap = uniformGap(gaps);
853
870
  const entryId = `${atomId}-entry`;
854
871
  const loopId = `${atomId}-loop`;
855
872
  const responseId = `${atomId}-response`;
856
- if (parameters.layout.maxWordsPerLine !== undefined
857
- && wordsOnLine > 0
858
- && wordsOnLine + atom.wordIds.length > parameters.layout.maxWordsPerLine) {
873
+ if (atomIndex > 0 && rowStarts.has(atomIndex)) {
859
874
  push({
860
875
  id: `line-break-${atomIndex}`,
861
876
  parent: "cue",
862
877
  kind: "box",
863
- style: [{ name: "flex-basis", value: "100%" }, { name: "height", value: "0" }],
878
+ style: [{ name: "display", value: "block" }, { name: "height", value: "0" }],
864
879
  });
865
- wordsOnLine = 0;
866
880
  }
881
+ if (!rowStarts.has(atomIndex) && atomGaps[atomIndex]) pushSeparator(`gap-${atomIndex}`, "cue");
867
882
  const entryAnimation = atomLifecycleAnimation(parameters, timing.start, timing.end, durationFrames);
868
883
  const atomLoop = parameters.motion.loopTarget === "active-atom"
869
884
  ? loopAnimation(parameters, durationFrames, activationTiming) : undefined;
@@ -880,8 +895,6 @@ function cueElements(
880
895
  style: [
881
896
  { name: "display", value: "inline-flex" },
882
897
  { name: "min-width", value: "0" },
883
- // A row that wraps opens at its own edge, so an atom that starts one carries no margin.
884
- ...(cueGap === undefined && wordsOnLine > 0 ? spacedStyle(atomGaps[atomIndex] ?? false) : []),
885
898
  { name: "transform-origin", value: "center center" },
886
899
  ],
887
900
  ...(entryAnimation === undefined ? {} : { animation: entryAnimation }),
@@ -913,13 +926,8 @@ function cueElements(
913
926
  parent: responseId,
914
927
  kind: "box",
915
928
  style: [
916
- ...(wordGap === true ? [{ name: "column-gap", value: gapPx }] as const : []),
917
- { name: "display", value: "inline-flex" },
918
- { name: "min-width", value: "0" },
919
- { name: "overflow-wrap", value: "anywhere" },
929
+ ...atomFlow(atomWords[atomIndex]!),
920
930
  { name: "position", value: "relative" },
921
- { name: "white-space", value: "normal" },
922
- { name: "word-break", value: parameters.layout.wrap === "grapheme" ? "break-all" : "normal" },
923
931
  ],
924
932
  attributes: [{ name: "data-caption-atom", value: atom.id }],
925
933
  });
@@ -996,7 +1004,7 @@ function cueElements(
996
1004
  }
997
1005
  };
998
1006
  for (const [wordIndex, wordId] of atom.wordIds.entries()) {
999
- const text = wordText.get(wordId);
1007
+ const text = wordText.get(wordId)?.text;
1000
1008
  if (text === undefined) throw new Error(`Fine Caption Atom references unknown word ${wordId}`);
1001
1009
  pushText({
1002
1010
  id: `${atomId}-base-${wordIndex + 1}`,
@@ -1037,7 +1045,7 @@ function cueElements(
1037
1045
  attributes: [{ name: kind === "glyph" ? "data-caption-karaoke" : "data-caption-active-underline", value: mode }],
1038
1046
  });
1039
1047
  for (const [wordIndex, wordId] of atom.wordIds.entries()) {
1040
- const text = wordText.get(wordId)!;
1048
+ const text = wordText.get(wordId)!.text;
1041
1049
  pushText({
1042
1050
  id: `${activeId}-${wordIndex + 1}`,
1043
1051
  parent: activeId,
@@ -1060,7 +1068,6 @@ function cueElements(
1060
1068
  if (parameters.activeUnderline.mode !== "off") {
1061
1069
  addActivatedTextLayer("underline", parameters.activeUnderline.mode, "underline");
1062
1070
  }
1063
- wordsOnLine += atom.wordIds.length;
1064
1071
  }
1065
1072
  return elements;
1066
1073
  }
@@ -1081,7 +1088,7 @@ export function renderFineCaption(
1081
1088
  throw new Error("Fine Caption inputs belong to different Timelines or Narratives");
1082
1089
  }
1083
1090
  const styles = new Map(program.styles.map((style) => [style.id, style]));
1084
- const wordText = new Map(document.words.map((word) => [word.id, word.text]));
1091
+ const wordText = new Map(document.words.map((word) => [word.id, word]));
1085
1092
  const atomById = new Map(document.units.map((atom) => [atom.id, atom]));
1086
1093
  for (const style of styles.values()) {
1087
1094
  if (style.rendering === null) continue;
@@ -1,69 +1,16 @@
1
- /**
2
- * Where a word gap belongs between two adjacent display surfaces.
3
- *
4
- * A Display Word is one lexical unit, and in Han, Hiragana and Katakana that unit is a single
5
- * character. A gap applied to every boundary therefore sets Chinese and Japanese as though every
6
- * character were a word, so each boundary decides its own gap from the two characters that meet
7
- * across it.
8
- *
9
- * The classes follow pangu.js: a CJK character beside a half-width letter, digit, bracket or
10
- * operator takes one space. The gap pangu inserts is an ordinary space, so a boundary either
11
- * carries the Style's `word-gap` or carries nothing.
12
- */
1
+ import type { CaptionDisplayWord } from "@hypit/narrative";
13
2
 
14
- /** The three scripts the Script tokenizer treats as one character per lexical unit. */
15
- const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
3
+ type Surface = Pick<CaptionDisplayWord, "text" | "separatorBefore">;
16
4
 
17
- /**
18
- * Full-width punctuation, including the bracket, quotation and dash pairs.
19
- *
20
- * These glyphs are drawn inside a full-width em box with their own side bearing, so a gap beside
21
- * one paints a second space over the one the font already carries. The ranges cover CJK
22
- * punctuation, the full-width ASCII forms, the curly quotes and the ellipsis, and stop short of
23
- * the full-width digits and letters at `0-9`, `A-Z` and `a-z`, which
24
- * are ordinary word characters.
25
- */
26
- const FULL_WIDTH_PUNCTUATION =
27
- /[、-〃〈-】〔-〟!-/:-@[-`{-・‘’“”—…]/u;
28
-
29
- /**
30
- * Half-width brackets and quotation marks take their space on the outside of the pair, which falls
31
- * out of reading the boundary characters: `displayWordSurfaces` attaches an opener to the surface
32
- * that follows it and a closer to the surface before it, so `中文(注)中文` meets `(` on the right
33
- * of one boundary and `)` on the left of the next, and both take a gap.
34
- *
35
- * A boundary with no CJK character on either side keeps the gap it has today. Latin spacing is not
36
- * what this decides.
37
- */
38
- export function wordGapBetween(previous: string, next: string): boolean {
39
- const left = [...previous].at(-1);
40
- const right = [...next].at(0);
41
- if (left === undefined || right === undefined) return false;
42
- if (FULL_WIDTH_PUNCTUATION.test(left) || FULL_WIDTH_PUNCTUATION.test(right)) return false;
43
- return !(CJK.test(left) && CJK.test(right));
44
- }
45
-
46
- /** Every boundary in order. The first surface opens the run, so its entry is always `false`. */
47
- export function wordGaps(surfaces: readonly string[]): readonly boolean[] {
48
- return surfaces.map((surface, index) => {
49
- const previous = surfaces[index - 1];
50
- return previous === undefined ? false : wordGapBetween(previous, surface);
51
- });
5
+ /** The first displayed word starts a line; all other gaps come from authored content. */
6
+ export function wordGaps(words: readonly Surface[]): readonly boolean[] {
7
+ return words.map((word, index) => index > 0 && word.separatorBefore === " ");
52
8
  }
53
9
 
54
- /**
55
- * A run whose boundaries all agree carries one `column-gap` on its container. A mixed run cannot,
56
- * because `column-gap` is uniform, and takes a margin per element instead.
57
- */
10
+ /** A uniform run can use column-gap; a mixed run requires individual separators. */
58
11
  export function uniformGap(gaps: readonly boolean[]): boolean | undefined {
59
12
  const boundaries = gaps.slice(1);
60
13
  if (boundaries.length === 0) return false;
61
- if (boundaries.every((gap) => gap)) return true;
62
- return boundaries.some((gap) => gap) ? undefined : false;
63
- }
64
-
65
- /** Join surfaces back into prose, spacing only the boundaries that carry a gap. */
66
- export function joinSurfaces(surfaces: readonly string[], separator: string): string {
67
- const gaps = wordGaps(surfaces);
68
- return surfaces.map((surface, index) => (gaps[index] ? `${separator}${surface}` : surface)).join("");
14
+ if (boundaries.every(gap => gap)) return true;
15
+ return boundaries.some(gap => gap) ? undefined : false;
69
16
  }
@@ -114,10 +114,8 @@ function cueText(document: CaptionDocument | undefined, unitIds: readonly string
114
114
  const selected = new Set(unitIds);
115
115
  return document.words
116
116
  .filter((word) => selected.has(word.unitId))
117
- .map((word) => word.text)
118
- .join(" ")
119
- .replace(/([\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}])\s+(?=[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}])/gu, "$1")
120
- .trim();
117
+ .map((word, index) => (index === 0 ? "" : word.separatorBefore) + word.text)
118
+ .join("");
121
119
  }
122
120
 
123
121
  export function projectCaptionContents(context: StudioTrackCompanionContext): readonly StudioEntityDraft[] {
@@ -153,13 +153,14 @@ paths. A deleted Source or directory remains queryable: only its existing ancest
153
153
  the missing path suffix is retained. This is local argument handling, with no saved alias inventory.
154
154
  CLI, Studio and creation tools call that same package; the CLI is not another environment owner.
155
155
 
156
- `doctor`, `programs up|status|down`, and `runtime up` accept repeated `--endpoint <instance>` values.
156
+ `doctor`, `programs prepare|up|status|down`, and `runtime up` accept repeated `--endpoint <instance>` values.
157
157
  The same Endpoint scope reaches package preparation and Program operations. Omission means the whole
158
158
  Profile. Build preflight instead uses the Endpoints resolved for that Build's concrete requests.
159
159
  An unrelated offered capability does not add another credential or Program requirement.
160
160
  Program rows show the configured Endpoint selector alongside an internal Program ID when they differ.
161
161
  The `programs` JSON `ok` field reports whether this command succeeded; `ready` reports service
162
- readiness. Successful stopping can therefore report `ok: true` with `ready: false`. A declined stop remains visible even
162
+ readiness for `up`/`status`, and preparation readiness for `prepare`. The latter does not start the
163
+ service. Successful stopping can therefore report `ok: true` with `ready: false`. A declined stop remains visible even
163
164
  while the service is still preparing and cannot yet answer its health probe.
164
165
 
165
166
  Upstream package installation reports an `install.log` path at preparation time. Exact package
@@ -349,8 +349,8 @@ function parseRuntimeCommand(tail: readonly string[]): RuntimeSelectionCommand |
349
349
 
350
350
  function parseProgramsCommand(tail: readonly string[]): ProgramsCommand {
351
351
  const [action, ...values] = tail;
352
- if (action !== "up" && action !== "down" && action !== "status") {
353
- throw new Error("programs takes up, down or status");
352
+ if (action !== "prepare" && action !== "up" && action !== "down" && action !== "status") {
353
+ throw new Error("programs takes prepare, up, down or status");
354
354
  }
355
355
  const [profile, rest] = optionalPositional(values);
356
356
  const options = commandOptions(`programs ${action}`, rest,
@@ -146,7 +146,7 @@ export type ProgramsCommand =
146
146
  | (CommandBase & RuntimeOption & {
147
147
  readonly command: "programs";
148
148
  readonly endpoints?: readonly string[];
149
- readonly action: "down" | "status";
149
+ readonly action: "prepare" | "down" | "status";
150
150
  readonly limit: number;
151
151
  });
152
152
 
@@ -175,7 +175,7 @@ export async function runEnvironmentCommand(input: {
175
175
  if (runtimeProfile === undefined) throw new Error("programs requires a Runtime Profile");
176
176
  const profile = resolve(runtimeProfile);
177
177
  const host = await runtimeHost(profile);
178
- if (args.action === "up") {
178
+ if (args.action === "up" || args.action === "prepare") {
179
179
  await host.prepare({ ...(args.endpoints === undefined ? {} : { endpoints: args.endpoints }), ...(reportPackageProgress === undefined ? {} : { onProgress: reportPackageProgress }) });
180
180
  }
181
181
  const controller = await runtimeController(profile);
@@ -185,6 +185,11 @@ export async function runEnvironmentCommand(input: {
185
185
  ...(args.maxWaitMs === undefined ? {} : { maxWaitMs: args.maxWaitMs }),
186
186
  ...(reportProgramProgress === undefined ? {} : { onProgress: reportProgramProgress }),
187
187
  })
188
+ : args.action === "prepare"
189
+ ? await controller.programs.prepare({
190
+ ...(args.endpoints === undefined ? {} : { endpoints: args.endpoints }),
191
+ ...(reportProgramProgress === undefined ? {} : { onProgress: reportProgramProgress }),
192
+ })
188
193
  : args.action === "down"
189
194
  ? await controller.programs.down(args.endpoints === undefined ? {} : { endpoints: args.endpoints })
190
195
  : await controller.programs.report(args.endpoints === undefined ? {} : { endpoints: args.endpoints });
@@ -199,7 +204,9 @@ export async function runEnvironmentCommand(input: {
199
204
  const urgent = relevant.filter(needsAttention);
200
205
  const shownPrograms = [...urgent, ...relevant.filter((item) => !needsAttention(item)).slice(0, Math.max(0, args.limit - urgent.length))];
201
206
  const omittedPrograms = relevant.length - shownPrograms.length;
202
- const title = args.action === "up"
207
+ const title = args.action === "prepare"
208
+ ? lifecycleOk ? "External program resources prepared" : "External program preparation needs attention"
209
+ : args.action === "up"
203
210
  ? lifecycleOk ? "External programs ready" : "External programs need attention"
204
211
  : args.action === "down"
205
212
  ? lifecycleOk ? "External programs stopped" : "External program stop needs attention"
@@ -20,7 +20,7 @@ export type OperationalMachineView =
20
20
  | { readonly format: "hypit.cli-runtime-selection@1"; readonly selected: boolean; readonly removed?: boolean; readonly profile?: string; readonly project?: string }
21
21
  | { readonly format: "hypit.cli-paths@1"; readonly project: string; readonly projectState: string; readonly profileSource: "argument" | "project" | "none"; readonly selectionFile: string; readonly profile?: string; readonly runtimeData?: string; readonly hostState: string; readonly machinePackages: string; readonly distribution?: string }
22
22
  | { readonly format: "hypit.cli-package@1"; readonly action: "install" | "status"; readonly package: string; readonly ready: boolean }
23
- | { readonly format: "hypit.cli-programs@1"; readonly action: "up" | "down" | "status"; readonly ok: boolean; readonly ready: boolean; readonly programCount: number; readonly readyCount: number; readonly programs: readonly ProgramStateView[]; readonly omittedPrograms?: number }
23
+ | { readonly format: "hypit.cli-programs@1"; readonly action: "prepare" | "up" | "down" | "status"; readonly ok: boolean; readonly ready: boolean; readonly programCount: number; readonly readyCount: number; readonly programs: readonly ProgramStateView[]; readonly omittedPrograms?: number }
24
24
  | { readonly format: "hypit.cli-runtime-up@1"; readonly ready: boolean; readonly worker: string; readonly preparedPackages: number; readonly programs: { readonly total: number; readonly ready: number; readonly items: readonly ProgramStateView[] } }
25
25
  | { readonly format: "hypit.cli-runtime-logs@1"; readonly lines: readonly string[]; readonly totalLines: number; readonly omittedLines: number; readonly path?: string }
26
26
  | { readonly format: "hypit.cli-runtime-down@1"; readonly worker: string }
@@ -878,6 +878,7 @@ function commandHelp(topic: string, colors: Palette): readonly string[] | undefi
878
878
  colors.accent(colors.strong("hypit programs")),
879
879
  colors.dim("Prepare and operate external programs declared by Endpoints in one Runtime Profile."),
880
880
  "",
881
+ " hypit programs prepare [--runtime <profile>] [--endpoint <instance>] # resources only; does not start services",
881
882
  " hypit programs up [--runtime <profile>] [--workspace <project>] [--max-wait-ms <ms>]",
882
883
  " hypit programs status [--runtime <profile>] [--workspace <project>]",
883
884
  " hypit programs down [--runtime <profile>] [--workspace <project>]",
@@ -141,7 +141,11 @@ function platformBackend(service: string) {
141
141
  return { read: macosReader(service), write: macosWriter(service), remove: macosDeleter(service) };
142
142
  }
143
143
  if (process.platform === "win32") return windowsBackend();
144
- throw new Error("OS CredentialStore supports macOS and Windows only");
144
+ // A Profile that selects this Store cannot be repaired by anything the user does here, and the
145
+ // other two Stores are the answer, so name them where the failure is read.
146
+ throw new Error("OS CredentialStore supports macOS and Windows only; select "
147
+ + "@hypit/credential-store-file (owner-private local file) or @hypit/credential-store-env "
148
+ + "(externally supplied value) in this Profile's credentials instead");
145
149
  }
146
150
 
147
151
  /** One logical writable store backed by the current user's OS credential locker. */
@@ -26,4 +26,6 @@ no Tokens. Authored order is independent of gaps, overlap or placement order in
26
26
  Caption uses this content query and the Narrative's display correspondence to select complete
27
27
  subtitle units. Timeline projection uses the same semantic references to locate events in physical
28
28
  time. Neither operation substitutes for the other. Token lookup returns Tokens, not reconstructed
29
- source prose: whitespace, comments and source-preserving edits remain the author language's concern.
29
+ source prose: comments and source-preserving edits remain the author language's concern. Caption
30
+ Display Words separately carry `separatorBefore` (`""` or `" "`) so consumers can reconstruct the
31
+ authored display spelling without guessing from Token boundaries or importing an author parser.
@@ -29,6 +29,7 @@ export const narrativeExcerptSchema: ValueSchema = object({
29
29
  tokenStart: { schema: integer }, tokenEndExclusive: { schema: integer },
30
30
  });
31
31
  const captionDisplayWord = object({
32
+ separatorBefore: { schema: { kind: "string", enum: ["", " "] } },
32
33
  id: { schema: string }, unitId: { schema: string }, segmentId: { schema: string },
33
34
  turnId: { schema: string }, role: { schema: string, optional: true }, text: { schema: string },
34
35
  attributes: { schema: { kind: "array", items: object({
@@ -50,6 +50,8 @@ export type NarrativeMomentRef = NarrativeMoment & {
50
50
 
51
51
  /** One author-visible word surface. Punctuation owned by the surface is preserved. */
52
52
  export type CaptionDisplayWord = {
53
+ /** Authored display separator from the preceding word; ignored at a displayed line start. */
54
+ readonly separatorBefore: "" | " ";
53
55
  readonly id: string;
54
56
  readonly unitId: string;
55
57
  readonly segmentId: string;
@@ -43,7 +43,8 @@ For multilingual transcription with quality as the priority and suitable hardwar
43
43
  "expectedModel": "large-v3",
44
44
  "expectedDevice": "cuda",
45
45
  "expectedCompute": "float16",
46
- "expectedBatchSize": 4
46
+ "expectedBatchSize": 4,
47
+ "alignmentLanguages": ["zh", "en"]
47
48
  }
48
49
  }
49
50
  ```
@@ -57,7 +58,11 @@ or hosted service for the actual language, material and time available rather th
57
58
  successively larger models as a routine sequence.
58
59
 
59
60
  `expectedModel` selects speech recognition. WhisperX separately loads the language-specific
60
- alignment model when that language is first requested. A larger ASR model can improve the words
61
+ alignment model from prepared local resources when that language is first requested. Set
62
+ `alignmentLanguages` to the language codes needed by this production before preparation. This is a
63
+ preparation demand, not a central language whitelist; already cached supported languages remain usable.
64
+ Omission prepares only ASR and sentence data, and does not imply every alignment model is installed.
65
+ A larger ASR model can improve the words
61
66
  fed into alignment, but does not select a larger aligner or guarantee better timing by itself.
62
67
  The health response identifies the loaded ASR configuration; it does not establish that every
63
68
  language's alignment weights are cached. See [WhisperX usage](https://github.com/m-bain/whisperX#usage-)
@@ -65,8 +70,9 @@ and [faster-whisper deployment](https://github.com/SYSTRAN/faster-whisper#usage)
65
70
 
66
71
  ## Preparation and downloads
67
72
 
68
- The Runtime installs and starts the packaged service in the machine Program Home when this Endpoint
69
- is selected. The environment is reused across projects and sessions. The current local package and
73
+ An explicit `programs up` or `runtime up` prepares and starts the selected packaged service in the
74
+ machine Program Home. Selecting an Endpoint alone does not install it. The environment is reused
75
+ across projects and sessions. The current local package and
70
76
  service are trusted code; this is not a community-plugin sandbox.
71
77
 
72
78
  `hypit paths` reports the machine `hostState`. The managed installation lives below
@@ -75,21 +81,31 @@ service are trusted code; this is not a community-plugin sandbox.
75
81
  the service host includes its port. The default service is `http://127.0.0.1:8765` and exposes
76
82
  its configuration through `/health`. Inspect an existing Profile's address and expected settings
77
83
  when locating that service. A custom `serviceCommand` supplies its own installation and start command.
78
- `hypit programs status` checks the Programs selected by the supplied Profile; `programs up` prepares
79
- and starts them. The managed installation does not require a global `whisperx` shell command.
84
+ `hypit programs prepare --endpoint <instance>` prepares the configured ASR model, requested alignment
85
+ models and sentence data without starting a process. It also works when the service is already online.
86
+ `programs up` prepares and starts the selected Programs; `programs status` reports process readiness.
87
+ Adding a language requires preparation, not a service restart, when its cache location is unchanged.
88
+ `modelCacheDirectory` optionally selects a root with `huggingface/` and `torch/` subdirectories;
89
+ relative paths resolve against the Runtime data root. Omission retains the upstream Hugging Face and
90
+ torch caches and their environment settings. Changing a running service's cache selection requires
91
+ an explicit restart when idle, just like changing its model or hardware. The managed installation
92
+ does not require a global `whisperx` shell command.
80
93
 
81
94
  Preparation and service processes inherit the environment of the command starting them. Set
82
95
  network and cache variables there before `programs up` or `runtime up`. A service already running
83
96
  retains its earlier environment. Inspect its reported log before deciding whether a selected
84
97
  Program needs restarting, and account for active work using it.
85
98
 
99
+ If NLTK refuses a proxied fetch during preparation, follow the service’s
100
+ [explicit proxy preparation](../../services/whisperx/README.md#preparing-sentence-data-through-a-proxy).
101
+
86
102
  Preparation commands write `install.log`; the running service writes `program.log`, with stderr in
87
103
  `program.err.log` on Windows. Inspect the stderr file for Python model-loading and download messages.
88
104
  `programs status` reports these files as `installationLogPath`, `logPath` and `errorLogPath` when they
89
- exist, even before installation finishes. Preparation notices name the Python environment and NLTK
90
- commands separately. The service logs the start and completion of ASR loading, transcription,
91
- language-alignment model loading and alignment, with elapsed times. Loading may include a download;
92
- transfer details come from the underlying client, not an estimated percentage from the Provider.
105
+ exist, even before installation finishes. Preparation notices name the Python environment and selected model/language resources separately. The service logs the start and completion of ASR loading, transcription,
106
+ language-alignment model loading and alignment, with elapsed times. Downloads happen only in
107
+ preparation; startup and inference only load local resources. A missing resource fails with a
108
+ preparation instruction. Transfer details come from the underlying client during preparation.
93
109
  The service health endpoint becomes available after ASR loading. A startup readiness wait expiring
94
110
  can leave that process still loading. Its PID is recorded when spawned; repeated `up` observes it,
95
111
  and `programs down` can stop it during loading. PID liveness and service readiness are separate facts.