@hypit/hypit 0.2.6 → 0.2.7

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 (61) hide show
  1. package/README.md +1 -1
  2. package/dist/public/hyperframes.d.ts +19 -2
  3. package/package.json +2 -1
  4. package/packages/caption-fine/README.md +4 -4
  5. package/packages/credential-store-file/README.md +4 -0
  6. package/packages/credential-store-file/src/store.ts +2 -2
  7. package/packages/credential-store-platform/README.md +2 -0
  8. package/packages/hyperframes/README.md +7 -0
  9. package/packages/hyperframes/src/html-project.ts +99 -0
  10. package/packages/hyperframes/src/index.ts +2 -0
  11. package/packages/hyperframes/src/project.ts +28 -0
  12. package/packages/pixverse/README.md +35 -0
  13. package/packages/pixverse/package.json +21 -0
  14. package/packages/pixverse/src/activation.ts +11 -0
  15. package/packages/pixverse/src/index.ts +122 -0
  16. package/packages/pixverse/src/surface.ts +146 -0
  17. package/packages/provider-hyperframes-local/README.md +19 -7
  18. package/packages/provider-hyperframes-local/src/capture.ts +18 -8
  19. package/packages/provider-hyperframes-local/src/index.ts +1 -1
  20. package/packages/provider-hyperframes-local/src/output.ts +1 -1
  21. package/packages/provider-hyperframes-local/src/process-tree.ts +8 -2
  22. package/packages/provider-hyperframes-local/src/provider.ts +29 -2
  23. package/packages/provider-hyperframes-local/src/render.ts +45 -20
  24. package/packages/provider-hyperframes-local/src/sampling.ts +3 -2
  25. package/packages/provider-whisperx-local/README.md +8 -5
  26. package/packages/render-hyperframes/README.md +9 -0
  27. package/packages/render-hyperframes/src/index.ts +3 -0
  28. package/packages/render-hyperframes/src/manifest.ts +7 -2
  29. package/packages/render-hyperframes/src/product.ts +41 -2
  30. package/packages/runtime-local/src/process-control.ts +4 -1
  31. package/packages/runtime-local/src/programs.ts +15 -3
  32. package/packages/runtime-local/src/supervisor.ts +8 -1
  33. package/packages/script/README.md +11 -4
  34. package/packages/script/src/format.ts +11 -24
  35. package/packages/script/src/manifest.ts +2 -2
  36. package/packages/script/src/parser.ts +16 -21
  37. package/packages/script/src/surface.ts +5 -3
  38. package/packages/script/src/types.ts +1 -1
  39. package/packages/source/README.md +1 -1
  40. package/packages/source/src/header.ts +2 -1
  41. package/packages/studio/README.md +13 -0
  42. package/packages/studio/src/feedback-server.ts +7 -0
  43. package/packages/studio/src/mutation-origin.ts +20 -0
  44. package/packages/studio/src/parameters.ts +3 -7
  45. package/packages/studio/src/preview/render.ts +7 -1
  46. package/packages/studio/src/server.ts +39 -0
  47. package/packages/studio/src/session.ts +7 -3
  48. package/packages/studio/src/ui/syntax.ts +11 -10
  49. package/packages/temporal-markup/EDITING.md +1 -1
  50. package/packages/video-cli/README.md +47 -4
  51. package/packages/video-cli/package.json +3 -0
  52. package/packages/video-cli/src/cli.ts +9 -9
  53. package/packages/video-cli/src/creation.ts +3 -3
  54. package/packages/video-cli/src/frame-grid.ts +34 -0
  55. package/packages/video-cli/src/index.ts +4 -0
  56. package/packages/video-cli/src/media-frames.ts +25 -0
  57. package/packages/video-cli/src/media.ts +198 -46
  58. package/packages/video-cli/src/process.ts +13 -3
  59. package/packages/video-cli/src/secret-input.ts +14 -0
  60. package/packages/video-cli/src/snapshot.ts +186 -0
  61. package/packages/yt-dlp/src/download.ts +32 -18
@@ -3,38 +3,25 @@ import { canonicalStringify } from "@hypit/protocol";
3
3
  import { ScriptSyntaxError } from "./error.js";
4
4
  import { narrativeValue } from "./narrative.js";
5
5
  import { parseScript } from "./parser.js";
6
-
7
- const ROLE = /^[\p{L}\p{M}\p{N}_](?:[\p{L}\p{M}\p{N}_. -]{0,30}[\p{L}\p{M}\p{N}_.-])?$/u;
6
+ import type { ParsedSegment } from "./types.js";
8
7
 
9
8
  function compact(value: string): string {
10
9
  return value.replace(/\s+/gu, " ").trim();
11
10
  }
12
11
 
13
- function formatSegment(raw: string, id: string, selfClosing: boolean): string[] {
12
+ function formatSegment(source: string, segment: ParsedSegment): string[] {
13
+ const { id, selfClosing, contentRange, atoms } = segment;
14
14
  if (selfClosing) return [`<${id}/>`];
15
- const openEnd = raw.indexOf(">") + 1;
16
- const closeStart = raw.lastIndexOf(`</${id}>`);
17
- const body = raw.slice(openEnd, closeStart);
18
15
  const lines: string[] = [`<${id}>`];
19
16
  const turns: string[] = [];
20
- let chunkStart = 0;
21
- let cursor = 0;
22
- while (cursor < body.length) {
23
- if (body[cursor] !== "<" || body.startsWith("<!--", cursor)) {
24
- cursor += 1;
25
- continue;
26
- }
27
- const end = body.indexOf(">", cursor + 1);
28
- if (end < 0) break;
29
- const inside = body.slice(cursor + 1, end);
30
- if (!inside.includes("|") && ROLE.test(inside)) {
31
- const before = compact(body.slice(chunkStart, cursor));
32
- if (before) turns.push(before);
33
- chunkStart = cursor;
34
- }
35
- cursor = end + 1;
17
+ let chunkStart = contentRange.start;
18
+ for (const atom of atoms) {
19
+ if (atom.kind !== "role") continue;
20
+ const before = compact(source.slice(chunkStart, atom.range.start));
21
+ if (before) turns.push(before);
22
+ chunkStart = atom.range.start;
36
23
  }
37
- const tail = compact(body.slice(chunkStart));
24
+ const tail = compact(source.slice(chunkStart, contentRange.end));
38
25
  if (tail) turns.push(tail);
39
26
  lines.push(...turns.map((turn) => ` ${turn}`));
40
27
  lines.push(`</${id}>`);
@@ -64,7 +51,7 @@ export function formatScript(sourceName: string, source: string): string {
64
51
  } else if (output.length) {
65
52
  output.push("");
66
53
  }
67
- output.push(...formatSegment(source.slice(start, end), segment.id, segment.selfClosing));
54
+ output.push(...formatSegment(source, segment));
68
55
  cursor = end;
69
56
  }
70
57
  const tail = formatOutside(source.slice(cursor));
@@ -64,9 +64,9 @@ export const scriptMarkupSurfaces = [
64
64
  "A Script requires at least one Segment, and natural-language text is refused outside a Segment.",
65
65
  "A Segment is opened by its own lower-case name and closed by that exact name, or written self-closing as `<pause/>`; the name is the Segment id, must be unique within the Script, and `script` is reserved. Segments do not nest.",
66
66
  "A Role Cue such as `<HOST>` is a bare tag inside a Segment with no close; its turn runs until the next Cue or the end of the Segment, and a Cue may not follow unowned speech in the same Segment. Role state resets when the Segment closes.",
67
- "Dual Text is written `<display | speech>`: the left side reaches Caption and the right side reaches dialogue and speech. `<display|>` inherits speech from the displayed prose and forms the same complete alignment unit; its word times remain individual. `<|speech>` speaks without displaying. Both sides empty is invalid.",
67
+ "Dual Text is written `<display | speech>`: the left side reaches Caption and the right side reaches dialogue and speech. `<display|>` shares the displayed prose with speech and forms the same complete alignment unit; its word times remain individual. `<|speech>` speaks without displaying. The explicit or shared speech must contain a spoken word; markers and punctuation alone supply no correspondence.",
68
68
  "Inside Dual Text, semantic markers belong to the source of spoken text: the explicit right side, or the shared left side when speech is omitted. Display attributes remain visual metadata and never enter spoken text.",
69
- "A flat token attribute follows a complete display token as `{name}` or `{name=value}`; multiple attributes use one comma-separated block. Attributes do not nest, do not carry timing, and never split a Dual Alignment Unit.",
69
+ "A flat token attribute follows a complete display token as `{name}` or `{name=value}`; multiple attributes use one comma-separated block. Zero-width markers and ordinary-prose comments do not interrupt that attachment; prose whitespace does. Attributes do not nest, do not carry timing, and never split a Dual Alignment Unit.",
70
70
  "Selection and Moment markers are fully enclosed in `@{...}` with all sigils inside. They are zero-width, share one name namespace, and may not split a speech token. Surrounding prose spaces remain content; do not add spaces to delimit a name:",
71
71
  [
72
72
  "| Marker | Meaning |",
@@ -492,10 +492,10 @@ export function parseScript(
492
492
  if (!shared && raw[index] === "{") fail("SCRIPT_DUAL_SPEECH_ATTRIBUTE", "Display attributes belong to the display side; escape literal braces in speech.", absoluteStart + index);
493
493
  if (shared && raw[index] === "{") {
494
494
  const before = raw.slice(partStart, index);
495
- if (!before || /\s$/u.test(before)) {
495
+ addLiteral(before, absoluteStart + partStart);
496
+ if (!runText || /\s$/u.test(runText)) {
496
497
  fail("SCRIPT_ATTRIBUTE_TARGET", "A token attribute must immediately follow a display token.", absoluteStart + index);
497
498
  }
498
- addLiteral(before, absoluteStart + partStart);
499
499
  const block = parseAttributeBlock(raw.slice(index), absoluteStart + index);
500
500
  runAttributes.push({ position: runText.length, offset: absoluteStart + index,
501
501
  end: absoluteStart + index + block.length, attributes: block.attributes });
@@ -517,20 +517,20 @@ export function parseScript(
517
517
  addLiteral(raw.slice(partStart), absoluteStart + partStart);
518
518
  const display = caption ?? spokenParts.join("");
519
519
  const sharedMarks = finishLexicalRun();
520
- if (shared && tokens.length === startToken) {
521
- fail("SCRIPT_DUAL_EMPTY", "Dual Text with omitted speech must contain spoken text on its display side.", absoluteStart);
522
- }
523
- if (tokens.length > startToken) {
524
- addCaptionRegion(
525
- display,
526
- current!.id,
527
- startToken,
528
- tokens.length,
529
- cleanProjection(display) ? "alias" : "hidden",
530
- { start: sourceOffset + absoluteStart, end: sourceOffset + absoluteStart + raw.length },
531
- shared ? sharedMarks : marks,
532
- );
520
+ if (tokens.length === startToken) {
521
+ fail("SCRIPT_DUAL_EMPTY", shared
522
+ ? "Dual Text with omitted speech must contain spoken text on its display side."
523
+ : "Dual Text speech side must contain a spoken word, not only markers or punctuation.", absoluteStart);
533
524
  }
525
+ addCaptionRegion(
526
+ display,
527
+ current!.id,
528
+ startToken,
529
+ tokens.length,
530
+ cleanProjection(display) ? "alias" : "hidden",
531
+ { start: sourceOffset + absoluteStart, end: sourceOffset + absoluteStart + raw.length },
532
+ shared ? sharedMarks : marks,
533
+ );
534
534
  };
535
535
 
536
536
  const closeCurrent = (end: number, selfClosing: boolean, contentEnd = end): void => {
@@ -626,18 +626,13 @@ export function parseScript(
626
626
  const speech = inside.slice(pipe + 1);
627
627
  if (!speech.trim()) {
628
628
  consumeSpeechSide(inside.slice(0, pipe), offset + 1, undefined);
629
- finishLexicalRun();
630
629
  offset = end + 1;
631
630
  continue;
632
631
  }
633
632
  assertDualDisplayLiteral(inside.slice(0, pipe), offset + 1);
634
633
  const markedDisplay = parseMarkedDisplay(inside.slice(0, pipe), offset + 1);
635
634
  const display = markedDisplay.display;
636
- if (!speech.replace(/@\{[^{}]*\}/gu, "").trim()) {
637
- fail("SCRIPT_DUAL_EMPTY", "Dual Text speech side must not be empty.", offset);
638
- }
639
635
  consumeSpeechSide(speech, offset + pipe + 2, display, markedDisplay.marks);
640
- finishLexicalRun();
641
636
  offset = end + 1;
642
637
  continue;
643
638
  }
@@ -674,7 +669,7 @@ export function parseScript(
674
669
  for (const piece of literalPieces(raw, textStart)) addText(piece.value, piece.value, piece.start, piece.end, true, piece.positions);
675
670
  if (source[offset] === "{") {
676
671
  const block = parseAttributeBlock(source.slice(offset), offset);
677
- if (!raw || /\s$/u.test(raw)) {
672
+ if (!runText || /\s$/u.test(runText)) {
678
673
  fail("SCRIPT_ATTRIBUTE_TARGET", "A token attribute must immediately follow a display token.", offset);
679
674
  }
680
675
  runAttributes.push({ position: runText.length, offset, end: offset + block.length, attributes: block.attributes });
@@ -24,6 +24,10 @@ function findClose(input: ScriptSurfaceInput): { readonly start: number; readonl
24
24
  const close = `</${input.tag}>`;
25
25
  let cursor = input.contentStart;
26
26
  while (cursor < input.source.length) {
27
+ if (input.source[cursor] === "\\") {
28
+ cursor += 2;
29
+ continue;
30
+ }
27
31
  if (input.source.startsWith("<!--", cursor)) {
28
32
  const commentEnd = input.source.indexOf("-->", cursor + 4);
29
33
  if (commentEnd < 0) {
@@ -33,9 +37,7 @@ function findClose(input: ScriptSurfaceInput): { readonly start: number; readonl
33
37
  continue;
34
38
  }
35
39
  if (input.source.startsWith(close, cursor)) {
36
- let slashes = 0;
37
- for (let before = cursor - 1; before >= 0 && input.source[before] === "\\"; before -= 1) slashes += 1;
38
- if (slashes % 2 === 0) return { start: cursor, end: cursor + close.length };
40
+ return { start: cursor, end: cursor + close.length };
39
41
  }
40
42
  cursor += 1;
41
43
  }
@@ -35,7 +35,7 @@ export type ParsedSegment = NarrativeSegment & {
35
35
  readonly index: number;
36
36
  readonly atoms: readonly ParsedAtom[];
37
37
  readonly range: SourceRange;
38
- /** Exact body range between the Segment tags, used only for source-preserving marker edits. */
38
+ /** Exact body range between Segment tags for Script-owned source edits and formatting. */
39
39
  readonly contentRange: SourceRange;
40
40
  readonly selfClosing: boolean;
41
41
  };
@@ -9,5 +9,5 @@ It recognizes exactly one mandatory bounded Header:
9
9
  ```
10
10
 
11
11
  The Header selects an exact trusted Frontend. There is no suffix dispatch and no default parser.
12
- The package masks the Header while preserving character offsets, but does not recognize imports,
12
+ The package masks the Header while preserving UTF-16 source offsets and line breaks, but does not recognize imports,
13
13
  XML, Script, Recipes, Run syntax or domain Types. Those belong to the selected Frontend.
@@ -75,6 +75,7 @@ export function parseSourceHeader(sourceName: string, text: string): SourceHeade
75
75
 
76
76
  /** Preserve every original offset while making the Header ordinary whitespace to body Frontends. */
77
77
  export function maskSourceHeader(text: string, header: SourceHeader): string {
78
- const prefix = text.slice(0, header.end).replace(/[^\r\n]/gu, " ");
78
+ // Source ranges use UTF-16 offsets: replace each code unit, not each code point.
79
+ const prefix = text.slice(0, header.end).replace(/[^\r\n]/g, " ");
79
80
  return `${prefix}${text.slice(header.end)}`;
80
81
  }
@@ -1,5 +1,13 @@
1
1
  # Hypit Studio
2
2
 
3
+ For direct picture inspection, `hypit snapshot --studio <studio-url>` reads the current compiled
4
+ `HyperframesDocument` from `GET /__studio/document` and its existing `/__studio/material/<resource>`
5
+ resources. `GET /__studio/visual.html` exposes the same materialized picture without the interactive
6
+ Studio playback shim or audio. Both representations come from the same compilation as the displayed
7
+ preview. The snapshot invocation uses the selected Runtime Profile's frame Provider and creates no
8
+ Build. During a pending Source compilation the export route reports that state; a failed update
9
+ reports its error instead of returning the previous picture as current.
10
+
3
11
  The single official Web Studio for SVML. It opens an explicit Run Source,
4
12
  traces its Film or Render target back to the semantic and visual projections
5
13
  Studio can edit, runs deterministic Producers and explicitly permitted transient Needs, and composites the
@@ -159,6 +167,11 @@ the selected entity. A reference can resolve to a shared Frame or Recipe, so one
159
167
  may affect several consumers. Structured fields save together when editing ends and the value is complete; missing required values stay in the editor with a completion hint.
160
168
  Check save status; source conflicts reject stale edits rather than overwrite newer files.
161
169
 
170
+ Script source ranges come from its raw Surface and Companion. Marker edits use Script's parsed
171
+ anchors and preserve unrelated prose, whitespace and word attributes; the displayed timeline words
172
+ are not a replacement text source. Recipe parameter reads use the same Source Header preparation
173
+ as compilation, preserving UTF-16 offsets for the exact property being edited.
174
+
162
175
  The Timeline owns the editor's complete range; displayed objects do not extend it. Take placement
163
176
  and complete extent are reference information in Studio.
164
177
 
@@ -3,6 +3,7 @@ import { relative } from "node:path";
3
3
  import type { Plugin } from "vite";
4
4
  import { createFeedbackStore, FeedbackConflict } from "./feedback-store.js";
5
5
  import { readFeedbackMutation } from "./feedback.js";
6
+ import { allowsStudioMutation } from "./mutation-origin.js";
6
7
  import type { FeedbackDocument, FeedbackView } from "./feedback.js";
7
8
 
8
9
  /** Review storage is separate from compilation, Results and Agent delivery. */
@@ -23,6 +24,12 @@ export function studioFeedbackPlugin(workspaceRoot: string, runPath: string): Pl
23
24
  server.httpServer?.once("close", () => watcher.close());
24
25
  server.middlewares.use((request, response, next) => {
25
26
  if (new URL(request.url ?? "/", "http://studio.hypit.local").pathname !== "/__studio/feedback") return next();
27
+ if (request.method === "POST" && !allowsStudioMutation(request.headers)) {
28
+ response.statusCode = 403;
29
+ response.setHeader("content-type", "application/json; charset=utf-8");
30
+ response.end(JSON.stringify({ error: "Studio mutation must come from this local Studio session." }));
31
+ return;
32
+ }
26
33
  response.setHeader("content-type", "application/json; charset=utf-8");
27
34
  response.setHeader("cache-control", "no-store");
28
35
  void (async () => {
@@ -0,0 +1,20 @@
1
+ import type { IncomingHttpHeaders } from "node:http";
2
+
3
+ /** Studio writes belong to the local page serving this Studio session. */
4
+ export function allowsStudioMutation(headers: IncomingHttpHeaders): boolean {
5
+ const host = headers.host;
6
+ if (host === undefined) return false;
7
+ let target: URL;
8
+ try { target = new URL(`http://${host}`); } catch { return false; }
9
+ if (!["localhost", "127.0.0.1", "[::1]"].includes(target.hostname) || target.host !== host.toLowerCase()) return false;
10
+
11
+ const origin = headers.origin;
12
+ if (origin !== undefined) {
13
+ let source: URL;
14
+ try { source = new URL(origin); } catch { return false; }
15
+ if (source.origin !== origin || source.origin !== target.origin) return false;
16
+ }
17
+
18
+ const site = headers["sec-fetch-site"];
19
+ return site === undefined || site === "same-origin" || site === "none";
20
+ }
@@ -17,6 +17,7 @@ import type {
17
17
  } from "@hypit/studio-adapter";
18
18
  import { parseSvs } from "@hypit/svs";
19
19
  import { parseOpeningTag } from "@hypit/markup";
20
+ import { prepareAuthorSource } from "@hypit/elaborator";
20
21
  import { parameterControlForSchema, parameterRecordSchema } from "./parameter-values.js";
21
22
  import type { CanonicalValue } from "@hypit/protocol";
22
23
 
@@ -132,12 +133,6 @@ function sourceAbsolute(root: string, path: string, base?: string): string {
132
133
  return resolve(directory, path);
133
134
  }
134
135
 
135
- function svsText(source: string): string {
136
- const header = /^\s*<\?svml[\s\S]*?\?>/u.exec(source);
137
- if (header === null) return source;
138
- return `${header[0].replace(/[^\r\n]/gu, " ")}${source.slice(header[0].length)}`;
139
- }
140
-
141
136
  function recipeParameters(input: {
142
137
  readonly root: string;
143
138
  readonly files: readonly StudioSourceFile[];
@@ -176,7 +171,8 @@ function recipeParameters(input: {
176
171
  const source = sourceFor(input.root, imported.source, input.files, input.current.path);
177
172
  if (source === undefined || source.language !== "svs") return [];
178
173
  const recipePath = parts.join(".");
179
- const parsed = parseSvs(source.path, svsText(source.text));
174
+ const prepared = prepareAuthorSource({ id: source.path, name: source.path, text: source.text });
175
+ const parsed = parseSvs(source.path, prepared.text);
180
176
  const recipe = parsed.recipes.find((item) => item.value.path === recipePath);
181
177
  if (recipe === undefined) return [];
182
178
  return input.recipe.bindings.flatMap((declaration): readonly StudioSourceBinding[] => {
@@ -1,5 +1,6 @@
1
1
  import type { AudioTrack, Composition } from "@hypit/composition";
2
2
  import { compileHyperframesDocument, materializeHyperframesHtml } from "@hypit/hyperframes";
3
+ import type { HyperframesDocument } from "@hypit/hyperframes";
3
4
  import type { ProgramSpace } from "@hypit/program-space";
4
5
 
5
6
  import { injectRuntimeShim } from "./runtime-shim.js";
@@ -19,6 +20,11 @@ export type RenderInput = {
19
20
  * material all come from the projection selected by the Run.
20
21
  */
21
22
  export function renderPreview(input: RenderInput): string {
23
+ return renderStudioProgramme(input).preview;
24
+ }
25
+
26
+ /** The same compiled picture serves immediate frame capture and interactive playback. */
27
+ export function renderStudioProgramme(input: RenderInput): { readonly document: HyperframesDocument; readonly html: string; readonly preview: string } {
22
28
  const document = compileHyperframesDocument(input.composition, input.space);
23
29
  const html = materializeHyperframesHtml(document, (artifact) => {
24
30
  // The only Artifacts a preview can reference are files the author already
@@ -51,5 +57,5 @@ export function renderPreview(input: RenderInput): string {
51
57
  fadeInSamples: clip.fadeInSamples, fadeOutSamples: clip.fadeOutSamples,
52
58
  }))}"></audio>`;
53
59
  }).join("");
54
- return injectRuntimeShim(html, audio);
60
+ return { document, html, preview: injectRuntimeShim(html, audio) };
55
61
  }
@@ -15,6 +15,7 @@ import type { ServedFile } from "./compile.js";
15
15
  import type { StudioDomain } from "./domain.js";
16
16
  import type { StudioCompanionRegistry } from "./studio-registry.js";
17
17
  import { loadStudioRun } from "./run.js";
18
+ import { allowsStudioMutation } from "./mutation-origin.js";
18
19
  import { parameterAuthorValue, parameterOption, serializeParameterValue, serializeAttributeGroup, validateParameterValue } from "./parameter-values.js";
19
20
  import { readStudioSession } from "./session.js";
20
21
  import type { Range, StudioFailure, StudioLibraryRequest, StudioLibraryView, StudioMutation, StudioSnapshot } from "./shared.js";
@@ -80,6 +81,8 @@ class StudioMutationRejected extends Error {}
80
81
 
81
82
  export function studioPlugin(options: StudioPluginOptions): Plugin {
82
83
  let snapshot: StudioSnapshot | undefined;
84
+ let visualHtml: string | undefined;
85
+ let visualDocument: import("@hypit/hyperframes").HyperframesDocument | undefined;
83
86
  let failure: StudioFailure | undefined;
84
87
  let material: ReadonlyMap<string, ServedFile> = new Map();
85
88
  let revision = 0;
@@ -158,6 +161,8 @@ export function studioPlugin(options: StudioPluginOptions): Plugin {
158
161
  if (attempt !== requestedRevision) return;
159
162
  revision = attempt;
160
163
  snapshot = result.snapshot;
164
+ visualHtml = result.visualHtml;
165
+ visualDocument = result.document;
161
166
  material = result.material;
162
167
  failure = undefined;
163
168
  if (notify) server?.ws.send({ type: "custom", event: "studio:snapshot", data: snapshot });
@@ -473,6 +478,10 @@ export function studioPlugin(options: StudioPluginOptions): Plugin {
473
478
  value.middlewares.use((request, response, next) => {
474
479
  const url = new URL(request.url ?? "/", "http://studio.hypit.local");
475
480
  if (request.method === "PUT" && url.pathname === "/__studio/source") {
481
+ if (!allowsStudioMutation(request.headers)) {
482
+ json(response, 403, { error: "Cross-origin Studio mutations are prohibited." });
483
+ return;
484
+ }
476
485
  void (async () => {
477
486
  let acquired = false;
478
487
  try {
@@ -524,6 +533,10 @@ export function studioPlugin(options: StudioPluginOptions): Plugin {
524
533
  return;
525
534
  }
526
535
  if (request.method === "PUT" && url.pathname === "/__studio/artifact-name") {
536
+ if (!allowsStudioMutation(request.headers)) {
537
+ json(response, 403, { error: "Cross-origin Studio mutations are prohibited." });
538
+ return;
539
+ }
527
540
  void (async () => {
528
541
  try {
529
542
  const chunks: Buffer[] = [];
@@ -546,6 +559,10 @@ export function studioPlugin(options: StudioPluginOptions): Plugin {
546
559
  return;
547
560
  }
548
561
  if (request.method === "POST" && url.pathname === "/__studio/mutation") {
562
+ if (!allowsStudioMutation(request.headers)) {
563
+ json(response, 403, { error: "Cross-origin Studio mutations are prohibited." });
564
+ return;
565
+ }
549
566
  void (async () => {
550
567
  try {
551
568
  const chunks: Buffer[] = [];
@@ -580,6 +597,28 @@ export function studioPlugin(options: StudioPluginOptions): Plugin {
580
597
  next();
581
598
  return;
582
599
  }
600
+ if (url.pathname === "/__studio/visual.html" || url.pathname === "/__studio/document") {
601
+ void (async () => {
602
+ if (timer !== undefined || publishing > 0) {
603
+ json(response, 409, { error: "Studio is compiling a Source change; capture after the updated preview is ready." });
604
+ return;
605
+ }
606
+ if (snapshot === undefined && failure === undefined) await publish(++requestedRevision);
607
+ if (failure !== undefined || visualHtml === undefined) {
608
+ json(response, 500, failure ?? { error: "Studio has no compiled picture." });
609
+ return;
610
+ }
611
+ if (url.pathname === "/__studio/document") {
612
+ json(response, 200, visualDocument);
613
+ return;
614
+ }
615
+ response.statusCode = 200;
616
+ response.setHeader("content-type", "text/html; charset=utf-8");
617
+ response.setHeader("cache-control", "no-store");
618
+ response.end(request.method === "HEAD" ? undefined : visualHtml);
619
+ })();
620
+ return;
621
+ }
583
622
  if (url.pathname === "/__studio/session") {
584
623
  void (async () => {
585
624
  if (snapshot === undefined && failure === undefined) {
@@ -9,7 +9,7 @@ import type { ServedFile } from "./compile.js";
9
9
  import type { StudioDomain } from "./domain.js";
10
10
  import type { Observations } from "./observe.js";
11
11
  import { preview } from "./programme.js";
12
- import { renderPreview } from "./preview/render.js";
12
+ import { renderStudioProgramme } from "./preview/render.js";
13
13
  import type { RunPlan } from "./run.js";
14
14
  import type { StudioSnapshot } from "./shared.js";
15
15
  import type { StudioCompanionRegistry } from "./studio-registry.js";
@@ -47,6 +47,8 @@ function sourceFiles(run: RunPlan): readonly StudioSourceFile[] {
47
47
 
48
48
  export type StudioSession = {
49
49
  readonly snapshot: StudioSnapshot;
50
+ readonly document: import("@hypit/hyperframes").HyperframesDocument;
51
+ readonly visualHtml: string;
50
52
  readonly material: ReadonlyMap<string, ServedFile>;
51
53
  readonly observations: Observations;
52
54
  readonly projections: readonly StudioViewRequirement[];
@@ -77,7 +79,7 @@ export async function readStudioSession(input: {
77
79
  projections: inspection.projections,
78
80
  ...(input.transientExecution === undefined ? {} : { transientExecution: input.transientExecution }),
79
81
  });
80
- const rendered = renderPreview({
82
+ const rendered = renderStudioProgramme({
81
83
  composition: built.composition,
82
84
  space: built.space as never,
83
85
  served: new Set(built.served.keys()),
@@ -85,6 +87,8 @@ export async function readStudioSession(input: {
85
87
  const text = readFileSync(input.run.authorSource, "utf8");
86
88
  const files = sourceFiles(input.run);
87
89
  return {
90
+ document: rendered.document,
91
+ visualHtml: rendered.html,
88
92
  snapshot: snapshot(input.registry, built, {
89
93
  revision: input.revision,
90
94
  path: input.sourcePath ?? input.run.authorSource,
@@ -99,7 +103,7 @@ export async function readStudioSession(input: {
99
103
  },
100
104
  canvas: built.canvas,
101
105
  frameRate: built.frameRate,
102
- preview: { kind: "hyperframes", srcdoc: rendered },
106
+ preview: { kind: "hyperframes", srcdoc: rendered.preview },
103
107
  workspaceRoot: input.workspaceRoot,
104
108
  sourceFiles: files,
105
109
  surfaces: input.domain.surfaces,
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * A tokenizer for SVML's own surface syntax.
3
3
  *
4
- * SVML has a small, closed grammar tags, quoted strings, whole-value
5
- * references, and Script prose with markers — so it is tokenized directly rather
6
- * than through a general highlighter that has no grammar for it. The Script
4
+ * Recognize the common Markup shell and Script prose for highlighting, without
5
+ * validating package-owned Surfaces. The Script
7
6
  * markers are the point: `@{claim} … @{/claim}` is what a Media Item binds to, so it
8
7
  * has to read as a distinct thing from an ordinary attribute.
9
8
  *
@@ -105,8 +104,7 @@ export function tokenizeSvml(source: string): readonly Token[] {
105
104
  cursor = nameStart + name[0].length;
106
105
  cursor = tokenizeAttributes(source, cursor, push);
107
106
 
108
- // Script is the language's one Raw Surface: its body is prose with
109
- // markers, not markup, and must be scanned by different rules.
107
+ // Script's Raw Surface has its own prose grammar.
110
108
  if (!closing && localName(name[0]) === "script" && source[cursor - 2] !== "/") {
111
109
  cursor = tokenizeScriptBody(source, cursor, name[0], push);
112
110
  }
@@ -167,6 +165,7 @@ function tokenizeAttributes(source: string, from: number, push: Push): number {
167
165
  function tokenizeScriptBody(source: string, from: number, tag: string, push: Push): number {
168
166
  const close = `</${tag}>`;
169
167
  let cursor = from;
168
+ let inSegment = false;
170
169
  while (cursor < source.length) {
171
170
  if (source[cursor] === "\\") { cursor += 2; continue; }
172
171
  if (source.startsWith(close, cursor)) {
@@ -199,14 +198,16 @@ function tokenizeScriptBody(source: string, from: number, tag: string, push: Pus
199
198
  }
200
199
  const closing = source[cursor + 1] === "/";
201
200
  const nameStart = cursor + (closing ? 2 : 1);
202
- const name = NAME.exec(source.slice(nameStart));
203
- if (name === null) { cursor += 1; continue; }
204
201
  const end = source.indexOf(">", nameStart);
205
202
  const stop = end < 0 ? source.length : end + 1;
203
+ const selfClosing = end >= 0 && source[end - 1] === "/";
204
+ const nameEnd = end < 0 ? stop : end - (selfClosing ? 1 : 0);
206
205
  push(cursor, nameStart, "punct");
207
- // An upper-case tag inside Script is a Role Cue, not a Segment.
208
- push(nameStart, nameStart + name[0].length, /^[A-Z]/u.test(name[0]) ? "role" : "tag");
209
- push(nameStart + name[0].length, stop, "punct");
206
+ // Context distinguishes Segments from Role Cues, regardless of language or case.
207
+ push(nameStart, nameEnd, inSegment && !closing ? "role" : "tag");
208
+ push(nameEnd, stop, "punct");
209
+ if (closing) inSegment = false;
210
+ else if (!inSegment && !selfClosing) inSegment = true;
210
211
  cursor = stop;
211
212
  continue;
212
213
  }
@@ -164,7 +164,7 @@ write target. The `fixed` case means no temporal gesture writes that endpoint, n
164
164
  parameter editing is forbidden.
165
165
 
166
166
  - `@hypit/script` owns tokenization, anchor identities, legal marker sites, and structural writeback.
167
- It normalizes equivalent marker whitespace, retains punctuation and attributes, writes coincident
167
+ It relocates markers without rewriting unrelated prose whitespace, retains punctuation and attributes, writes coincident
168
168
  markers together, and preserves all unrelated relationships and caption information.
169
169
  - `@hypit/temporal-markup` owns the interpretation of each time form and declares its write targets.
170
170
  Direct semantic references expose semantic targets; quoted time expressions expose parameter
@@ -1,8 +1,8 @@
1
1
  # `@hypit/video-cli`
2
2
 
3
3
  Official video command application. It selects the Markup compiler Host and supplies one editable
4
- starter Runtime Profile. The CLI still imports no Provider or Store implementation; installed packages
5
- are activated only by explicit Source imports or Profile `use` entries.
4
+ starter Runtime Profile. Execution Endpoints are selected by Profile `use` entries; Source imports
5
+ activate author packages. Immediate tools own their temporary input/output resource storage.
6
6
 
7
7
  Every Frontend, Surface, deterministic Producer and Validator is activated from Source imports.
8
8
  Installing a new author package therefore does not require a video CLI or Core release. Source
@@ -67,9 +67,35 @@ to the chosen file. `measure` estimates a passage locally:
67
67
 
68
68
  ```bash
69
69
  hypit transcribe reference.mp4 --to notes/reference.transcript.json --language en
70
+ hypit transcribe assets/recorded-voice.wav --to notes/voice.transcript.json --language en
70
71
  hypit measure main.svml --segment hook --language en --pace normal --rounding round
71
72
  ```
72
73
 
74
+ `snapshot` follows the same immediate invocation model for picture inspection. Prefer it for
75
+ existing production states and motion sequences, keeping Studio for playback with sound and Builds
76
+ for encoded delivery:
77
+
78
+ ```bash
79
+ hypit snapshot --studio http://localhost:5191 --at-frame 240,255,269 --to evidence/states
80
+ hypit snapshot --studio http://localhost:5191 --start-frame 240 --end-frame-exclusive 270 \
81
+ --grid 4x3 --cell 480 --to evidence/motion
82
+ hypit snapshot ./picture/index.html --at-frame 240 --to evidence/detail
83
+ ```
84
+
85
+ `--studio` reads Studio's current compiled document and its declared resources; a path or HTML URL
86
+ reads materialized HTML with inline scripts/styles and directly addressed media/fonts. The Profile's
87
+ `@hypit/render-hyperframes@1#render-frames` Endpoint returns PNGs in selected-frame order.
88
+ `--runtime` and `--workspace` select the environment just as for `transcribe`. The call streams
89
+ resources through a temporary `FileResourceStore`, writes full-size PNGs and optional grid pages,
90
+ then releases that temporary storage. `--to` names a new directory. `--json` reports paths and original
91
+ frame positions. Grid labels remain outside the picture. No Build, Worker receipt or video encoding
92
+ is created. Provider-owned browser preparation is unchanged.
93
+
94
+ `media frames --every-frame` and `media tiles --every-frame` decode every source frame in a selected
95
+ half-open seconds interval once. Native timestamps, including variable frame rate, are read from
96
+ decoder PTS and time base. `tiles --ranges <json> --every-frame` decodes each listed interval and
97
+ paginates its images. `--transcript` adds word context. This native path uses no FPS resampling.
98
+
73
99
  For `transcribe`, set `--language` to an explicit lowercase two- or three-letter spoken language code,
74
100
  such as `en`, `zh` or `ko`. The selected service owns which languages it can align. Chinese speech uses `zh`, including
75
101
  Chinese speech containing English names. The request selects the recognition language and
@@ -106,6 +132,9 @@ prints what a Source may write:
106
132
  ```bash
107
133
  hypit media probe reference.mp4
108
134
  hypit media cut reference.mp4 --start 12 --end 19.5 --label-time --to notes/hook.mp4
135
+ hypit media cut assets/talk.mp4 --start 12 --end 19.5 --to assets/opening.mp4
136
+ hypit media cut assets/talk.mp4 --keep 12:15.5 --keep 16:19.5 --to assets/opening-edited.mp4
137
+ hypit media cut assets/narration.wav --keep 0.3:4.1 --keep 4.6:9.2 --to assets/narration-edited.wav
109
138
  hypit media frames reference.mp4 --at 12.4,13.1 --label-time --to notes/hook-frames
110
139
  hypit media tile reference.mp4 --start 12 --end 19.5 --to notes/hook-grid.jpg
111
140
  hypit media tile reference.mp4 --at 12.4,13.1,14.8 --columns 3 --to notes/exact-grid.jpg
@@ -119,7 +148,21 @@ hypit vocabulary @hypit/media-pipeline --tag StillVideo
119
148
  hypit vocabulary --visual text
120
149
  ```
121
150
 
122
- `cut` isolates the requested interval and can visibly overlay source time on the evidence copy.
151
+ `probe` accepts audio-only files as well as video. `cut` keeps one interval using `--start` and
152
+ `--end`, or joins explicitly retained, ordered, non-overlapping intervals using repeated
153
+ `--keep start:end` in source seconds. The latter is useful for removing gaps inside one recorded
154
+ performance; it does not decide where Script Segments belong. Video retains its available picture
155
+ and sound together (MP4 is a useful output container); audio-only outputs PCM WAV. A silent source
156
+ video stays silent. The
157
+ `--json` reports the source intervals, their nominal positions on the new local clock, and the
158
+ measured output duration; actual frame and sample boundaries can differ slightly from the nominal
159
+ positions. It writes a new file and refuses to
160
+ overwrite an existing one. `--label-time` retains its single-video-interval role: it visibly
161
+ overlays source time on an inspection copy, not on the clean production media.
162
+
163
+ `transcribe` also accepts audio or video. Its transcript refers to the *input file's* clock. A
164
+ cut or joined file has a new clock; use the final recorded performance and its Script in the
165
+ Build's semantic preparation rather than treating source transcript timestamps as final timing.
123
166
  `frames` writes one JPEG per requested time, selecting the first decoded frame at or after it.
124
167
  Visible frame labels use that frame's actual timestamp, as do the labels below each `tile` cell.
125
168
  Sampling is shared by `frames`, `tile` and `tiles`:
@@ -149,7 +192,7 @@ and its Endpoint remain separate. `boundaries` reports adjacent-frame
149
192
  change candidates and their measured scores; it does not suppress short changes or call them shots.
150
193
  `prepare-fetch` explicitly prepares the locked downloader environment; `fetch` requires it and
151
194
  turns a link into a file with the pinned yt-dlp; [the downloader package](../yt-dlp/README.md)
152
- owns its dependencies, download choices and file handling. Commands that create evidence write only
195
+ owns its dependencies, download choices and file handling. Commands that create files write only
153
196
  what `--to` names and refuse to overwrite. `vocabulary` reads the installed
154
197
  manifests: every package with its tags and models, or one package's Surfaces with their attributes,
155
198
  children and example, or the value shapes a drawing Producer must emit.
@@ -18,6 +18,7 @@
18
18
  "@hypit/driver-node": "workspace:*",
19
19
  "@hypit/estimate": "workspace:*",
20
20
  "@hypit/generation": "workspace:*",
21
+ "@hypit/hyperframes": "workspace:*",
21
22
  "@hypit/markup": "workspace:*",
22
23
  "@hypit/model-kit": "workspace:*",
23
24
  "@hypit/package-loader-node": "workspace:*",
@@ -28,6 +29,8 @@
28
29
  "@hypit/provider-monid": "workspace:*",
29
30
  "@hypit/provider-pollo": "workspace:*",
30
31
  "@hypit/provider-tokendance": "workspace:*",
32
+ "@hypit/render-hyperframes": "workspace:*",
33
+ "@hypit/resource-store-fs": "workspace:*",
31
34
  "@hypit/runtime": "workspace:*",
32
35
  "@hypit/runtime-kit": "workspace:*",
33
36
  "@hypit/runtime-host-node": "workspace:*",