@hypit/hypit 0.1.2 → 0.1.4

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 (94) hide show
  1. package/README.md +1 -1
  2. package/dist/public/studio-adapter.d.ts +31 -2
  3. package/package.json +4 -1
  4. package/packages/audio-track-studio/src/index.ts +7 -4
  5. package/packages/build-result/README.md +58 -0
  6. package/packages/build-result/package.json +2 -1
  7. package/packages/build-result/src/decode.ts +10 -8
  8. package/packages/build-result/src/file-reference.ts +42 -0
  9. package/packages/build-result/src/index.ts +3 -0
  10. package/packages/build-result/src/replace-file-windows.ts +58 -0
  11. package/packages/build-result/src/replace-file.ts +11 -0
  12. package/packages/build-result/src/store.ts +44 -13
  13. package/packages/build-result/src/types.ts +38 -8
  14. package/packages/build-result/src/writer.ts +12 -1
  15. package/packages/build-result-fs/README.md +23 -1
  16. package/packages/build-result-s3/README.md +5 -0
  17. package/packages/build-result-s3/src/repository.ts +19 -5
  18. package/packages/caption-fine/README.md +17 -2
  19. package/packages/caption-fine/src/manifest.ts +1 -1
  20. package/packages/caption-fine/src/render.ts +48 -36
  21. package/packages/caption-fine-studio/package.json +8 -3
  22. package/packages/caption-fine-studio/src/index.ts +10 -1
  23. package/packages/cli/README.md +25 -0
  24. package/packages/cli/src/arguments.ts +16 -11
  25. package/packages/cli/src/build-planning.ts +6 -1
  26. package/packages/cli/src/command.ts +1 -1
  27. package/packages/cli/src/commands/environment.ts +42 -13
  28. package/packages/cli/src/machine-view.ts +12 -3
  29. package/packages/cli/src/main.ts +4 -0
  30. package/packages/cli/src/output.ts +28 -12
  31. package/packages/cli/src/result-export.ts +22 -13
  32. package/packages/cli/src/run-file.ts +19 -8
  33. package/packages/comment-sticker-studio/src/index.ts +2 -1
  34. package/packages/compiler-node/src/compiler.ts +1 -0
  35. package/packages/estimate/README.md +56 -3
  36. package/packages/estimate/src/program.ts +7 -8
  37. package/packages/fonts-open/README.md +5 -0
  38. package/packages/fonts-open/package.json +5 -1
  39. package/packages/fonts-open/src/studio.ts +21 -0
  40. package/packages/gpt-image-kits/README.md +17 -12
  41. package/packages/gpt-image-kits/kits/phone-ugc-v1.svs +8 -8
  42. package/packages/media-track-studio/src/index.ts +7 -2
  43. package/packages/provider-whisperx-local/README.md +57 -0
  44. package/packages/ranking/src/render.ts +11 -9
  45. package/packages/ranking-studio/README.md +11 -0
  46. package/packages/ranking-studio/package.json +8 -3
  47. package/packages/ranking-studio/src/index.ts +11 -4
  48. package/packages/runtime/src/catalog.ts +2 -0
  49. package/packages/runtime-host-node/src/index.ts +1 -0
  50. package/packages/runtime-local/README.md +19 -0
  51. package/packages/runtime-local/src/programs.ts +39 -24
  52. package/packages/runtime-local/src/runtime.ts +2 -0
  53. package/packages/script/README.md +22 -0
  54. package/packages/script/src/edit.ts +123 -73
  55. package/packages/script/src/lexical.ts +40 -6
  56. package/packages/script/src/parser.ts +36 -8
  57. package/packages/script/src/types.ts +4 -0
  58. package/packages/seedance-kits/README.md +3 -1
  59. package/packages/seedance-kits/kits/speaker-v1.svs +5 -5
  60. package/packages/speech-alignment/src/align.ts +45 -21
  61. package/packages/studio/INSPECTOR.md +104 -0
  62. package/packages/studio/README.md +65 -4
  63. package/packages/studio/src/build-library.ts +99 -84
  64. package/packages/studio/src/library-media.ts +19 -0
  65. package/packages/studio/src/parameter-values.ts +41 -1
  66. package/packages/studio/src/parameters.ts +20 -21
  67. package/packages/studio/src/server.ts +58 -30
  68. package/packages/studio/src/shared.ts +20 -6
  69. package/packages/studio/src/studio-registry.ts +22 -0
  70. package/packages/studio/src/style.css +135 -144
  71. package/packages/studio/src/temporal-edit.ts +131 -0
  72. package/packages/studio/src/ui/artifact-name.ts +80 -0
  73. package/packages/studio/src/ui/artifact-preview.ts +99 -0
  74. package/packages/studio/src/ui/icons.ts +5 -1
  75. package/packages/studio/src/ui/library.ts +373 -192
  76. package/packages/studio/src/ui/main.ts +153 -31
  77. package/packages/studio/src/ui/material-preview.ts +1 -1
  78. package/packages/studio/src/ui/overlay.ts +21 -20
  79. package/packages/studio/src/ui/sidebar-panel.ts +31 -0
  80. package/packages/studio/src/ui/stage.ts +124 -8
  81. package/packages/studio/src/ui/timeline.ts +16 -179
  82. package/packages/studio/start.ts +15 -3
  83. package/packages/studio-adapter/README.md +21 -0
  84. package/packages/studio-adapter/src/index.ts +26 -1
  85. package/packages/temporal-markup/EDITING.md +183 -0
  86. package/packages/temporal-markup/README.md +4 -0
  87. package/packages/typography-track-studio/package.json +8 -3
  88. package/packages/typography-track-studio/src/index.ts +5 -1
  89. package/packages/video-cli/README.md +12 -3
  90. package/packages/video-cli/src/creation.ts +11 -5
  91. package/packages/whisperx/README.md +7 -0
  92. package/packages/workspace/src/index.ts +2 -0
  93. package/packages/workspace-fs-node/README.md +4 -1
  94. package/packages/workspace-fs-node/src/workspace.ts +3 -1
@@ -1,4 +1,5 @@
1
- import type { StudioSnapshot } from "../shared.js";
1
+ import { createArtifactPreview } from "./artifact-preview.js";
2
+ import type { StudioArtifactView, StudioSnapshot } from "../shared.js";
2
3
  import { createOverlay } from "./overlay.js";
3
4
  import { icon, setIcon } from "./icons.js";
4
5
  import type { State, Store } from "./selection.js";
@@ -7,6 +8,8 @@ export type Stage = {
7
8
  readonly element: HTMLElement;
8
9
  /** Start or stop playback, however the request arrived. */
9
10
  toggle(): void;
11
+ openArtifact(artifact: StudioArtifactView): void;
12
+ renameArtifact(artifact: StudioArtifactView): void;
10
13
  };
11
14
 
12
15
  /**
@@ -17,19 +20,23 @@ export type Stage = {
17
20
  * drives frames, not seconds, and lands on exactly the frame the renderer would
18
21
  * photograph.
19
22
  */
20
- export function createStage(store: Store): Stage {
23
+ export function createStage(store: Store, selectedArtifact: (id: string | undefined) => void): Stage {
21
24
  const element = document.createElement("section");
22
25
  element.className = "stage";
23
26
  element.innerHTML = `
24
27
  <div class="stage-heading">
25
28
  <div class="stage-title">${icon("preview")}<strong>Preview</strong></div>
29
+ <button type="button" class="stage-return" hidden>Back to composition</button>
26
30
  </div>
27
31
  <div class="stage-viewport">
28
32
  <div class="stage-scaler">
29
33
  <iframe title="Preview" sandbox="allow-scripts allow-same-origin" allow="autoplay"></iframe>
30
34
  </div>
31
35
  </div>
36
+ <input class="stage-scrubber" type="range" min="0" max="0" value="0" step="0.01" aria-label="Media time" hidden>
32
37
  <div class="stage-bar">
38
+ <span class="stage-time" hidden></span>
39
+ <div class="stage-transport">
33
40
  <button type="button" data-previous aria-label="Previous frame" title="Previous frame">
34
41
  ${icon("previous")}
35
42
  </button>
@@ -39,6 +46,7 @@ export function createStage(store: Store): Stage {
39
46
  <button type="button" data-next aria-label="Next frame" title="Next frame">
40
47
  ${icon("next")}
41
48
  </button>
49
+ </div>
42
50
  <button type="button" data-mute aria-label="Mute" class="stage-mute">
43
51
  <span data-mute-icon>${icon("volume")}</span>
44
52
  </button>
@@ -57,17 +65,30 @@ export function createStage(store: Store): Stage {
57
65
  * of its enter and exit, and selecting a clip lands the playhead on exactly
58
66
  * the frame where that displacement is largest.
59
67
  */
60
- const measure = (clipId: string): { xPx: number; yPx: number; widthPx: number; heightPx: number } | undefined => {
68
+ const measure = (clipId: string): { xPx: number; yPx: number; widthPx: number; heightPx: number; stackOrder: number } | undefined => {
61
69
  const document_ = iframe.contentDocument;
62
70
  if (document_ === null) return undefined;
63
71
  const present = document_.querySelector(`[data-hypit-present-id="${CSS.escape(clipId)}"]`);
72
+ if (present === null) return undefined;
73
+ const current = store.current();
74
+ if (current === undefined) return undefined;
75
+ const frameRate = fps(current.snapshot);
76
+ const start = Number(present.getAttribute("data-start"));
77
+ const end = start + Number(present.getAttribute("data-duration"));
78
+ if (current.playhead.frame < Math.round(start * frameRate)
79
+ || current.playhead.frame >= Math.round(end * frameRate)) return undefined;
64
80
  // The present spans the whole canvas; the drawn box is its frame element.
65
81
  const drawn = present?.querySelector(`[data-hypit-element-id="${CSS.escape(`${clipId}:frame`)}"]`)
66
82
  ?? present?.querySelector("[data-hypit-element-id]");
67
83
  if (drawn === null || drawn === undefined) return undefined;
84
+ for (let ancestor: Element | null = drawn; ancestor !== null; ancestor = ancestor.parentElement) {
85
+ const style = document_.defaultView!.getComputedStyle(ancestor);
86
+ if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return undefined;
87
+ }
68
88
  const box = drawn.getBoundingClientRect();
69
89
  if (box.width === 0 && box.height === 0) return undefined;
70
- return { xPx: box.left, yPx: box.top, widthPx: box.width, heightPx: box.height };
90
+ return { xPx: box.left, yPx: box.top, widthPx: box.width, heightPx: box.height,
91
+ stackOrder: Number(document_.defaultView!.getComputedStyle(present).zIndex) || 0 };
71
92
  };
72
93
 
73
94
  // The overlay is a sibling of the iframe inside the scaler, so it inherits the
@@ -152,6 +173,7 @@ export function createStage(store: Store): Stage {
152
173
  };
153
174
 
154
175
  const toggle = (): void => {
176
+ if (artifactPreview.selected !== undefined) { artifactPreview.toggle(); return; }
155
177
  if (state === undefined) return;
156
178
  if (playing) { stop(); return; }
157
179
  playing = true;
@@ -172,21 +194,107 @@ export function createStage(store: Store): Stage {
172
194
  };
173
195
  play.addEventListener("click", toggle);
174
196
  previous.addEventListener("click", () => {
175
- if (state !== undefined) store.seek(state.playhead.frame - 1, "video");
197
+ if (artifactPreview.selected !== undefined) artifactPreview.seek((artifactPreview.media?.currentTime ?? 0) - 5);
198
+ else if (state !== undefined) store.seek(state.playhead.frame - 1, "video");
176
199
  });
177
200
  next.addEventListener("click", () => {
178
- if (state !== undefined) store.seek(state.playhead.frame + 1, "video");
201
+ if (artifactPreview.selected !== undefined) artifactPreview.seek((artifactPreview.media?.currentTime ?? 0) + 5);
202
+ else if (state !== undefined) store.seek(state.playhead.frame + 1, "video");
179
203
  });
180
204
 
181
205
  const applyMuted = (): void => {
182
206
  setIcon(muteIcon, muted ? "muted" : "volume");
183
207
  mute.setAttribute("aria-label", muted ? "Unmute" : "Mute");
208
+ if (artifactPreview.media !== undefined) artifactPreview.media.muted = muted;
184
209
  (iframe.contentWindow as SeekWindow | null)?.__hypitSetMuted?.(muted);
185
210
  };
186
211
  mute.addEventListener("click", () => { muted = !muted; applyMuted(); });
187
212
 
213
+ const title = element.querySelector<HTMLElement>(".stage-title strong")!;
214
+ const back = element.querySelector<HTMLButtonElement>(".stage-return")!;
215
+ const scrubber = element.querySelector<HTMLInputElement>(".stage-scrubber")!;
216
+ const time = element.querySelector<HTMLElement>(".stage-time")!;
217
+ const transport = element.querySelector<HTMLElement>(".stage-transport")!;
218
+ const clock = (seconds: number): string => {
219
+ const ticks = Math.max(0, Math.round(seconds * 100));
220
+ const whole = Math.floor(ticks / 100);
221
+ return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}.${String(ticks % 100).padStart(2, "0")}`;
222
+ };
223
+ const artifactPreview = createArtifactPreview(() => {
224
+ if (artifactPreview.selected === undefined) return;
225
+ const media = artifactPreview.media;
226
+ const timed = media !== undefined;
227
+ const duration = media !== undefined && Number.isFinite(media.duration) ? media.duration : 0;
228
+ transport.hidden = !timed;
229
+ mute.hidden = !timed;
230
+ scrubber.hidden = !timed;
231
+ time.hidden = !timed;
232
+ play.disabled = duration <= 0 || media?.error != null;
233
+ previous.disabled = next.disabled = play.disabled;
234
+ scrubber.disabled = duration <= 0;
235
+ scrubber.step = "0.01";
236
+ scrubber.setAttribute("aria-label", "Media time");
237
+ scrubber.max = String(duration);
238
+ scrubber.value = String(media?.currentTime ?? 0);
239
+ time.textContent = `${clock(media?.currentTime ?? 0)} / ${clock(duration)}`;
240
+ setIcon(playIcon, media !== undefined && !media.paused ? "pause" : "play");
241
+ play.setAttribute("aria-label", media !== undefined && !media.paused ? "Pause" : "Play");
242
+ });
243
+ viewport.append(artifactPreview.element);
244
+ const compositionTime = (): void => {
245
+ if (state === undefined || artifactPreview.selected !== undefined) return;
246
+ const rate = fps(state.snapshot);
247
+ scrubber.hidden = time.hidden = false;
248
+ scrubber.disabled = !ready;
249
+ scrubber.step = String(1 / rate);
250
+ scrubber.max = String(Math.max(0, state.snapshot.space.frameCount - 1) / rate);
251
+ scrubber.value = String(state.playhead.frame / rate);
252
+ scrubber.setAttribute("aria-label", "Composition time");
253
+ time.textContent = `${clock(state.playhead.frame / rate)} / ${clock(state.snapshot.space.frameCount / rate)}`;
254
+ };
255
+ scrubber.addEventListener("input", () => {
256
+ if (artifactPreview.selected !== undefined) artifactPreview.seek(Number(scrubber.value));
257
+ else if (state !== undefined) store.seek(Math.round(Number(scrubber.value) * fps(state.snapshot)), "video");
258
+ });
259
+
260
+ const returnToComposition = (): void => {
261
+ artifactPreview.close();
262
+ selectedArtifact(undefined);
263
+ scaler.hidden = false;
264
+ back.hidden = true;
265
+ compositionTime();
266
+ transport.hidden = mute.hidden = false;
267
+ play.disabled = previous.disabled = next.disabled = false;
268
+ previous.title = "Previous frame";
269
+ previous.setAttribute("aria-label", previous.title);
270
+ next.title = "Next frame";
271
+ next.setAttribute("aria-label", next.title);
272
+ title.textContent = "Preview";
273
+ title.removeAttribute("title");
274
+ setIcon(playIcon, "play");
275
+ play.setAttribute("aria-label", "Play");
276
+ fit();
277
+ if (state !== undefined && ready) seekPicture(state.playhead.frame);
278
+ };
279
+ back.addEventListener("click", returnToComposition);
280
+
281
+ const openArtifact = (artifact: StudioArtifactView): void => {
282
+ stop();
283
+ scaler.hidden = true;
284
+ back.hidden = false;
285
+ title.textContent = artifact.displayName ?? artifact.output;
286
+ title.title = artifact.output;
287
+ previous.title = "Back 5 seconds";
288
+ next.title = "Forward 5 seconds";
289
+ previous.setAttribute("aria-label", previous.title);
290
+ next.setAttribute("aria-label", next.title);
291
+ artifactPreview.open(artifact, muted);
292
+ selectedArtifact(artifact.id);
293
+ };
294
+
188
295
  iframe.addEventListener("load", () => {
189
296
  ready = true;
297
+ compositionTime();
190
298
  if (state !== undefined) seekPicture(state.playhead.frame);
191
299
  applyMuted();
192
300
  // The box could not be measured until now.
@@ -197,16 +305,19 @@ export function createStage(store: Store): Stage {
197
305
 
198
306
  store.subscribe((value) => {
199
307
  const first = state === undefined;
308
+ const moved = state !== undefined && (state.playhead.frame !== value.playhead.frame || state.selection !== value.selection);
200
309
  state = value;
310
+ if (moved && value.playhead.origin !== "play" && artifactPreview.selected !== undefined) returnToComposition();
201
311
  if (value.snapshot.revision !== mounted) {
202
312
  mounted = value.snapshot.revision;
203
- stop();
313
+ if (artifactPreview.selected === undefined) stop();
204
314
  ready = false;
205
315
  const preview = value.snapshot.preview;
206
316
  iframe.srcdoc = preview.srcdoc;
207
317
  fit();
208
318
  }
209
319
  if (first) fit();
320
+ compositionTime();
210
321
 
211
322
  // Playing and scrubbing are different requests: one lets material run on
212
323
  // its own clock, the other moves it. Asking a decoder for a fresh seek
@@ -227,5 +338,10 @@ export function createStage(store: Store): Stage {
227
338
  if (playing) overlay.refresh();
228
339
  });
229
340
 
230
- return { element, toggle };
341
+ return { element, toggle, openArtifact,
342
+ renameArtifact(artifact) {
343
+ const selected = artifactPreview.selected;
344
+ if (selected?.build === artifact.build && selected.output === artifact.output) title.textContent = artifact.displayName ?? artifact.output;
345
+ },
346
+ };
231
347
  }
@@ -8,6 +8,7 @@ import { mountMaterialPreview } from "./material-preview.js";
8
8
  import type { State, Store } from "./selection.js";
9
9
  import { createHandle } from "./resize.js";
10
10
  import { createZoom } from "./zoom.js";
11
+ import { chooseSemanticGesture, semanticGestureSpan } from "../temporal-edit.js";
11
12
  import { applyStudioMutation } from "./writeback.js";
12
13
 
13
14
  export type Timeline = {
@@ -17,7 +18,6 @@ export type Timeline = {
17
18
  readonly fit: () => void;
18
19
  };
19
20
 
20
- const opened = new Set<string>();
21
21
  const itemMetrics = {
22
22
  // Ordinary items fill their rows. Only the semantic lane owns compact word cells.
23
23
  insetYPx: 1,
@@ -221,107 +221,17 @@ export function createTimeline(store: Store): Timeline {
221
221
  readonly clip: StudioSnapshot["tracks"][number]["clips"][number];
222
222
  readonly handle: StudioEditHandle;
223
223
  readonly startFrame: number;
224
- readonly pointerAnchorIndex?: number;
225
224
  readonly pointerId: number;
226
225
  readonly node: HTMLElement;
227
226
  readonly originalLeft: string;
228
227
  readonly originalWidth: string;
229
228
  } | undefined;
230
229
 
231
- const nearestAnchorIndex = (
232
- frame: number,
233
- indices: readonly number[],
234
- preferredIndex?: number,
235
- ): number | undefined => {
236
- if (state === undefined) return undefined;
237
- const anchors = state.snapshot.semantic?.anchors ?? [];
238
- const preferredKind = preferredIndex === undefined ? undefined : anchors[preferredIndex]?.kind;
239
- return [...indices].sort((left, right) => {
240
- const leftAnchor = anchors[left]!;
241
- const rightAnchor = anchors[right]!;
242
- return Math.abs(leftAnchor.frame - frame) - Math.abs(rightAnchor.frame - frame)
243
- || Number(leftAnchor.kind !== preferredKind) - Number(rightAnchor.kind !== preferredKind)
244
- || Math.abs(left - (preferredIndex ?? left)) - Math.abs(right - (preferredIndex ?? right))
245
- || left - right;
246
- })[0];
247
- };
248
-
249
- const semanticTarget = (
250
- edit: NonNullable<typeof activeEdit>,
251
- nextFrame: number,
252
- ): { readonly kind: "selection"; readonly startAnchorId: string; readonly endAnchorId: string }
253
- | { readonly kind: "moment"; readonly anchorId: string }
254
- | undefined => {
255
- if (state === undefined || edit.handle.semantic === undefined) return undefined;
256
- const semantic = edit.handle.semantic;
257
- const anchors = state.snapshot.semantic?.anchors ?? [];
258
- if (semantic.kind === "moment") {
259
- const currentIndex = anchors.findIndex((anchor) => anchor.id === semantic.anchorId);
260
- const anchorIndex = nearestAnchorIndex(
261
- nextFrame,
262
- anchors.map((_, index) => index),
263
- currentIndex < 0 ? undefined : currentIndex,
264
- );
265
- return anchorIndex === undefined ? undefined : { kind: "moment", anchorId: anchors[anchorIndex]!.id };
266
- }
267
- const startIndex = anchors.findIndex((anchor) => anchor.id === semantic.startAnchorId);
268
- const endIndex = anchors.findIndex((anchor) => anchor.id === semantic.endAnchorId);
269
- if (startIndex < 0 || endIndex < 0 || startIndex >= endIndex) return undefined;
270
- let nextStart = startIndex;
271
- let nextEnd = endIndex;
272
- if (edit.handle.gesture === "trim-start") {
273
- const allowed = anchors.flatMap((anchor, index) =>
274
- index < endIndex && anchor.frame < anchors[endIndex]!.frame ? [index] : []);
275
- nextStart = nearestAnchorIndex(nextFrame, allowed, startIndex) ?? startIndex;
276
- } else if (edit.handle.gesture === "trim-end") {
277
- const allowed = anchors.flatMap((anchor, index) =>
278
- index > startIndex && anchor.frame > anchors[startIndex]!.frame ? [index] : []);
279
- nextEnd = nearestAnchorIndex(nextFrame, allowed, endIndex) ?? endIndex;
280
- } else if (edit.handle.gesture === "move") {
281
- const instantBoundary = edit.handle.temporal?.kind === "instant"
282
- && edit.handle.temporal.authority.kind === "semantic"
283
- && edit.handle.temporal.authority.source.kind === "selection"
284
- ? edit.handle.temporal.authority.boundary
285
- : undefined;
286
- if (instantBoundary === "start") {
287
- const allowed = anchors.flatMap((anchor, index) =>
288
- index < endIndex && anchor.frame < anchors[endIndex]!.frame ? [index] : []);
289
- nextStart = nearestAnchorIndex(nextFrame, allowed, startIndex) ?? startIndex;
290
- return { kind: "selection", startAnchorId: anchors[nextStart]!.id, endAnchorId: anchors[nextEnd]!.id };
291
- }
292
- if (instantBoundary === "end") {
293
- const allowed = anchors.flatMap((anchor, index) =>
294
- index > startIndex && anchor.frame > anchors[startIndex]!.frame ? [index] : []);
295
- nextEnd = nearestAnchorIndex(nextFrame, allowed, endIndex) ?? endIndex;
296
- return { kind: "selection", startAnchorId: anchors[nextStart]!.id, endAnchorId: anchors[nextEnd]!.id };
297
- }
298
- const pointerIndex = edit.pointerAnchorIndex ?? startIndex;
299
- const deltas = anchors.flatMap((_, index) => {
300
- const delta = index - pointerIndex;
301
- const movedStart = startIndex + delta;
302
- const movedEnd = endIndex + delta;
303
- return movedStart >= 0 && movedEnd < anchors.length
304
- && anchors[movedStart]!.frame < anchors[movedEnd]!.frame
305
- ? [delta]
306
- : [];
307
- });
308
- const targetPointer = nearestAnchorIndex(
309
- nextFrame,
310
- deltas.map((delta) => pointerIndex + delta),
311
- pointerIndex,
312
- );
313
- const delta = targetPointer === undefined ? 0 : targetPointer - pointerIndex;
314
- nextStart += delta;
315
- nextEnd += delta;
316
- } else {
317
- return undefined;
318
- }
319
- return {
320
- kind: "selection",
321
- startAnchorId: anchors[nextStart]!.id,
322
- endAnchorId: anchors[nextEnd]!.id,
323
- };
324
- };
230
+ const semanticTarget = (edit: NonNullable<typeof activeEdit>, nextFrame: number) =>
231
+ state === undefined ? undefined : chooseSemanticGesture({
232
+ anchors: state.snapshot.semantic?.anchors ?? [], handle: edit.handle,
233
+ pointerStart: edit.startFrame, pointerNow: nextFrame, frameCount: state.snapshot.space.frameCount,
234
+ });
325
235
 
326
236
  const absoluteWindowTarget = (
327
237
  edit: NonNullable<typeof activeEdit>,
@@ -354,40 +264,8 @@ export function createTimeline(store: Store): Timeline {
354
264
  ): { readonly startFrame: number; readonly endFrameExclusive: number } | undefined => {
355
265
  if (state === undefined) return undefined;
356
266
  const target = semanticTarget(edit, nextFrame);
357
- if (target?.kind === "selection" && edit.handle.semantic?.kind === "selection") {
358
- const current = state.snapshot.semantic?.selections.find((selection) => selection.id === edit.handle.semantic!.id);
359
- const nextStart = state.snapshot.semantic?.anchors.find((anchor) => anchor.id === target.startAnchorId)?.frame;
360
- const nextEnd = state.snapshot.semantic?.anchors.find((anchor) => anchor.id === target.endAnchorId)?.frame;
361
- if (current === undefined || nextStart === undefined || nextEnd === undefined) return undefined;
362
- const instantBoundary = edit.handle.temporal?.kind === "instant"
363
- && edit.handle.temporal.authority.kind === "semantic"
364
- && edit.handle.temporal.authority.source.kind === "selection"
365
- ? edit.handle.temporal.authority.boundary
366
- : undefined;
367
- if (instantBoundary === "start" || instantBoundary === "end") {
368
- const frame = instantBoundary === "start" ? nextStart : nextEnd;
369
- return { startFrame: frame, endFrameExclusive: frame + 1 };
370
- }
371
- return {
372
- startFrame: nextStart + edit.clip.startFrame - current.startFrame,
373
- endFrameExclusive: nextEnd + edit.clip.endFrameExclusive - current.endFrameExclusive,
374
- };
375
- }
376
- if (target?.kind === "moment" && edit.handle.semantic?.kind === "moment") {
377
- const current = state.snapshot.semantic?.moments.find((moment) => moment.id === edit.handle.semantic!.id);
378
- const next = state.snapshot.semantic?.anchors.find((anchor) => anchor.id === target.anchorId)?.frame;
379
- if (current === undefined || next === undefined) return undefined;
380
- const delta = next - current.frame;
381
- if (edit.handle.gesture === "trim-start") {
382
- return { startFrame: next, endFrameExclusive: edit.clip.endFrameExclusive };
383
- }
384
- if (edit.handle.gesture === "trim-end") {
385
- return { startFrame: edit.clip.startFrame, endFrameExclusive: next };
386
- }
387
- return edit.handle.moveEffect === "move-start"
388
- ? { startFrame: edit.clip.startFrame + delta, endFrameExclusive: edit.clip.endFrameExclusive }
389
- : { startFrame: edit.clip.startFrame + delta, endFrameExclusive: edit.clip.endFrameExclusive + delta };
390
- }
267
+ if (target !== undefined) return semanticGestureSpan(state.snapshot.semantic?.anchors ?? [], edit.handle, target);
268
+ if (edit.handle.semantic !== undefined) return undefined;
391
269
  return edit.handle.temporal === undefined ? undefined : absoluteWindowTarget(edit, nextFrame);
392
270
  };
393
271
  lanes.addEventListener("pointerdown", (event) => {
@@ -577,18 +455,15 @@ export function createTimeline(store: Store): Timeline {
577
455
  detail: string,
578
456
  height: number,
579
457
  attached = false,
580
- hasState = false,
581
458
  ): HTMLElement => {
582
459
  const label = document.createElement("div");
583
- label.className = `track-label track-${kind}${hasState ? " track-label-has-state" : ""}`;
460
+ label.className = `track-label track-${kind}`;
584
461
  if (attached) label.classList.add("track-label-attached");
585
462
  label.style.height = `${height}px`;
586
463
  label.title = `${name} · ${detail}`;
587
464
  label.innerHTML = `${attached
588
465
  ? '<span class="track-attachment-mark" aria-hidden="true"></span>'
589
- : '<span class="track-icon"></span>'}<span class="track-copy"><strong></strong></span>${hasState
590
- ? '<span class="track-state"></span>'
591
- : ""}`;
466
+ : '<span class="track-icon"></span>'}<span class="track-copy"><strong></strong></span>`;
592
467
  if (!attached) setIcon(label.querySelector(".track-icon")!, iconName);
593
468
  label.querySelector("strong")!.textContent = name;
594
469
  return label;
@@ -604,7 +479,7 @@ export function createTimeline(store: Store): Timeline {
604
479
  .sort((left, right) => (left.binding.lane.order ?? 0) - (right.binding.lane.order ?? 0));
605
480
 
606
481
  const buildSemanticLane = (snapshot: StudioSnapshot): void => {
607
- if (snapshot.semantic === undefined || snapshot.semantic.segments.length === 0) {
482
+ if (snapshot.semantic === undefined) {
608
483
  return;
609
484
  }
610
485
  const presentation = snapshot.semantic.presentation;
@@ -781,17 +656,6 @@ export function createTimeline(store: Store): Timeline {
781
656
  attached = false,
782
657
  ): void => {
783
658
  const tone = track.binding.tone;
784
- const freeFrom: number[] = [];
785
- const rowOf = new Map<string, number>();
786
- for (const clip of [...track.clips].sort((a, b) => b.stackOrder - a.stackOrder || a.startFrame - b.startFrame)) {
787
- let row = freeFrom.findIndex((free) => free <= clip.startFrame);
788
- if (row < 0) { row = freeFrom.length; freeFrom.push(0); }
789
- freeFrom[row] = clip.endFrameExclusive;
790
- rowOf.set(clip.id, row);
791
- }
792
- const depth = Math.max(1, freeFrom.length);
793
- const folded = depth > 1 && !opened.has(track.id);
794
- const shownRows = folded ? 1 : depth;
795
659
  const laneHeight = track.binding.lane.heightPx;
796
660
  const displayedItems = track.clips.length;
797
661
  const label = createTrackLabel(
@@ -799,44 +663,26 @@ export function createTimeline(store: Store): Timeline {
799
663
  tone,
800
664
  track.binding.icon,
801
665
  `${displayedItems} item${displayedItems === 1 ? "" : "s"}`,
802
- shownRows * laneHeight,
666
+ laneHeight,
803
667
  attached,
804
- depth > 1,
805
668
  );
806
669
  label.classList.add(`track-facet-${track.binding.facet}`);
807
- if (depth > 1) {
808
- const fold = document.createElement("button");
809
- fold.type = "button";
810
- fold.className = "track-fold";
811
- fold.textContent = folded ? `+${depth - 1}` : "−";
812
- fold.title = folded ? `Show ${depth} rows` : "Collapse rows";
813
- fold.setAttribute("aria-expanded", String(!folded));
814
- fold.addEventListener("click", (event) => {
815
- event.stopPropagation();
816
- if (opened.has(track.id)) opened.delete(track.id);
817
- else opened.add(track.id);
818
- build(snapshot);
819
- paint();
820
- });
821
- label.querySelector(".track-state")!.append(fold);
822
- }
823
670
  labels.append(label);
824
671
 
825
672
  const lane = document.createElement("div");
826
673
  lane.className = `lane track-tone-${tone} track-facet-${track.binding.facet}${attached ? " lane-attached" : ""}`;
827
- lane.style.height = `${shownRows * laneHeight}px`;
674
+ lane.style.height = `${laneHeight}px`;
828
675
  lane.style.setProperty("--lane-height", `${laneHeight}px`);
829
676
  const laneWidth = lanes.clientWidth;
830
677
  const materialMounts: {
831
678
  readonly target: HTMLElement;
832
679
  readonly preview: Extract<StudioSnapshot["tracks"][number]["clips"][number]["display"]["layers"][number], { readonly kind: "preview" }>["preview"];
833
680
  }[] = [];
834
- for (const clip of track.clips) {
681
+ // Stable order preserves Companion projection order when stack levels tie.
682
+ for (const clip of [...track.clips].sort((a, b) => a.stackOrder - b.stackOrder)) {
835
683
  const from = place(clip.startFrame, snapshot.space.frameCount, zoom.window());
836
684
  const to = place(clip.endFrameExclusive, snapshot.space.frameCount, zoom.window());
837
685
  if (to <= 0 || from >= 1) continue;
838
- const row = rowOf.get(clip.id) ?? 0;
839
- if (folded && row > 0) continue;
840
686
  const node = document.createElement("button");
841
687
  node.type = "button";
842
688
  node.className = `clip clip-tone-${tone} clip-facet-${track.binding.facet} clip-chrome-${clip.presentation.chrome}`;
@@ -847,7 +693,6 @@ export function createTimeline(store: Store): Timeline {
847
693
  node.style.width = `max(2px, calc(${Math.max(0, to - from) * 100}% - ${itemMetrics.gapPx}px))`;
848
694
  const visibleWidthPx = visibleItemWidth(from, to, laneWidth);
849
695
  node.classList.toggle("clip-preview-wide", visibleWidthPx >= 92);
850
- node.style.top = `${row * laneHeight}px`;
851
696
  node.title = `${clip.display.title} · ${clip.startFrame}-${clip.endFrameExclusive}f`;
852
697
  node.innerHTML = `<span class="clip-head"><span class="clip-name"></span><span class="clip-meta"></span></span><span class="clip-body"><span class="clip-layers" aria-hidden="true"></span><span class="clip-phases"></span></span><span class="clip-selection" aria-hidden="true"></span>`;
853
698
  const layers = node.querySelector<HTMLElement>(".clip-layers")!;
@@ -894,17 +739,10 @@ export function createTimeline(store: Store): Timeline {
894
739
  const pointerFrame = handle.coordinate === "semantic-anchor"
895
740
  ? rawFrameAt(event.clientX)
896
741
  : frameAt(event.clientX);
897
- const pointerAnchorIndex = handle.semantic !== undefined
898
- ? nearestAnchorIndex(
899
- pointerFrame,
900
- state?.snapshot.semantic?.anchors.map((_, index) => index) ?? [],
901
- )
902
- : undefined;
903
742
  activeEdit = {
904
743
  clip,
905
744
  handle,
906
745
  startFrame: pointerFrame,
907
- ...(pointerAnchorIndex === undefined ? {} : { pointerAnchorIndex }),
908
746
  pointerId: event.pointerId,
909
747
  node,
910
748
  originalLeft: node.style.left,
@@ -940,8 +778,7 @@ export function createTimeline(store: Store): Timeline {
940
778
  const attachedTo = track.binding.lane.attachedTo;
941
779
  if (attachedTo !== undefined) continue;
942
780
  buildTrack(snapshot, track, nextClipNodes);
943
- const slot = track.binding.lane.groupId;
944
- if (slot === undefined) continue;
781
+ const slot = track.binding.lane.groupId ?? track.binding.groupId;
945
782
  const attachments = attachedTracks(snapshot, slot, track.binding.groupId);
946
783
  for (const attachment of attachments) {
947
784
  buildTrack(snapshot, attachment, nextClipNodes, true);
@@ -11,9 +11,12 @@ Open a Run in the browser to inspect its composition, Sources and Results.
11
11
  hypit studio --run <build.svrun> [--runtime <hypit.runtime.json>]
12
12
  [--port <number>] [--workspace <directory>] [--package-root <directory>]
13
13
 
14
- Paths are relative to the invoking project. Without --runtime, Studio uses
15
- that project's selected Runtime Profile. The server prints its browser URL;
16
- press Ctrl+C to stop it.
14
+ Relative command-line paths start at the current directory; --workspace selects
15
+ the project, without rebasing those paths. Otherwise the nearest package.json
16
+ above the current directory defines the project (or the current directory if none).
17
+ Without --runtime, Studio uses that project's .hypit/runtime selection.
18
+ The server prints its project, Run, Runtime selection and browser URL.
19
+ Press Ctrl+C to stop it.
17
20
  `);
18
21
  }
19
22
 
@@ -76,6 +79,15 @@ export async function runStudio(argv: readonly string[], io: Pick<CliIo, "write"
76
79
  const runtimePath = runtimeArgument === undefined
77
80
  ? selectedRuntime?.profile
78
81
  : resolve(invokedFrom, runtimeArgument);
82
+ console.info([
83
+ ` Project ${workspaceRoot}`,
84
+ ` Run ${runPath}`,
85
+ ` Runtime Profile ${runtimePath ?? "not selected"}`,
86
+ ` Runtime selection ${runtimeArgument !== undefined
87
+ ? "command argument (this session only)" : selectedRuntime?.selectionFile ?? "none"}`,
88
+ ...(packageRoot === workspaceRoot ? [] : [` Package root ${packageRoot}`]),
89
+ "",
90
+ ].join("\n"));
79
91
  const port = Number(values.get("port") ?? "5179");
80
92
  if (!Number.isSafeInteger(port) || port <= 0) invalidArguments("--port must be a positive integer");
81
93
 
@@ -48,6 +48,22 @@ nominal output Type and authored input names of the component. A Companion does
48
48
  author attribute. Omitting `project` uses generic terminal entities; `poster` adds the declared
49
49
  Surface preview when one exists, without changing the component's rendered video.
50
50
 
51
+ Each lane occupies one timeline row. Entities overlap in ascending `stackOrder`,
52
+ with later projected entities above earlier ones when orders tie. Studio keeps
53
+ overlapping entities mounted and raises the selected entity within its lane;
54
+ selection does not change the rendered composition or write stacking back to Source.
55
+ Expose `presentId` or `renderIds` for visual entities so picture selection can
56
+ address the same entity shown in the timeline. Attached lanes represent distinct
57
+ Companion projections, not extra rows allocated to avoid temporal overlap.
58
+
59
+ An entity's timeline interval describes the operation being edited; its rendered
60
+ parts may remain visible afterward. Associate every relevant phase through
61
+ `renderIds` (for example, an entrance and the settled object). Studio picks the
62
+ currently visible part in rendered stacking order and selects the same entity,
63
+ without extending its editable interval. Give the parent the board or background
64
+ parts and its children their own parts when they should be independently selectable.
65
+ The component decides this granularity. A composite can remain one selectable entity.
66
+
51
67
  Merge the facet into the package's existing activation. In this example `authorContribution`
52
68
  exports its existing modules, deterministic component and Markup facets:
53
69
 
@@ -168,6 +184,11 @@ silently becoming one policy. Studio resolves the declarations against the
168
184
  current Source closure, publishes only real writable fields, renders all DOM
169
185
  and CSS itself, and commits changes through `parameter.adjust`.
170
186
 
187
+ Fields can declare `number` display scaling, supported suffixes, limits and step; `unit` alone is
188
+ only a label. Select options can carry separate scalar values, labels, descriptions and color/font
189
+ preview hints. Text may be multiline; colors may offer package-chosen swatches. See
190
+ [Inspector presentation and conversion](../studio/INSPECTOR.md) for examples and ownership.
191
+
171
192
  Structured controls keep a local draft and commit one complete canonical value.
172
193
  Their codec is the author language (`@hypit/svs` for Recipe values), not a
173
194
  Companion callback. Companions cannot inject DOM, CSS, parsing code or filesystem
@@ -122,6 +122,24 @@ export const studioParameterControls = [
122
122
  export type StudioParameterControl = typeof studioParameterControls[number];
123
123
  export type StudioParameterLanguage = "svml" | "svs" | "svrun";
124
124
 
125
+ /** Data-only choices. Packages supply meaning; Studio owns all option rendering. */
126
+ export type StudioParameterOption = string | {
127
+ readonly value: string | number | boolean;
128
+ readonly label: string;
129
+ readonly description?: string;
130
+ readonly preview?: { readonly kind: "color"; readonly color: string }
131
+ | { readonly kind: "font"; readonly family: string; readonly sample?: string };
132
+ };
133
+
134
+ /** Displayed number = authored number × scale. Suffixes are retained, never converted. */
135
+ export type StudioNumberPresentation = {
136
+ readonly scale?: number;
137
+ readonly suffixes?: readonly string[];
138
+ readonly minimum?: number;
139
+ readonly maximum?: number;
140
+ readonly step?: number;
141
+ };
142
+
125
143
  /** A real author endpoint. Its existence never implies Inspector visibility. */
126
144
  export type StudioSourceBinding = {
127
145
  readonly id: string;
@@ -170,8 +188,12 @@ export type StudioInspectorFieldDeclaration = {
170
188
  readonly summary?: string;
171
189
  /** Omit when the binding's public schema selects the finite Studio control. */
172
190
  readonly control?: StudioParameterControl;
173
- readonly options?: readonly string[];
191
+ readonly options?: readonly StudioParameterOption[];
174
192
  readonly unit?: string;
193
+ readonly number?: StudioNumberPresentation;
194
+ readonly multiline?: boolean;
195
+ /** Optional suggested colors, independent of the accepted color value. */
196
+ readonly swatches?: readonly string[];
175
197
  };
176
198
 
177
199
  /** Resolved Inspector DTO. Studio renders it and executes its exact source write. */
@@ -308,6 +330,7 @@ export type StudioEntityDisplay = {
308
330
  };
309
331
 
310
332
  export type StudioLaneDescription = {
333
+ /** Height of this single timeline lane; overlapping entities share it. */
311
334
  readonly heightPx: number;
312
335
  readonly groupId?: string;
313
336
  readonly attachedTo?: string;
@@ -482,10 +505,12 @@ export type StudioEntityDraft = {
482
505
  readonly display: StudioEntityDisplay;
483
506
  readonly startFrame: number;
484
507
  readonly endFrameExclusive: number;
508
+ /** Back-to-front timeline order. Ties preserve projection order. Selection raises only the timeline item. */
485
509
  readonly stackOrder: number;
486
510
  readonly elementRange?: Range;
487
511
  readonly markerId?: string;
488
512
  readonly presentId?: string;
513
+ /** Rendered parts belonging to this entity, including phases beyond its editable timeline interval. */
489
514
  readonly renderIds?: readonly string[];
490
515
  /** Resolved author references that differ per derived entity, such as one Cue's actual Style. */
491
516
  readonly parameterReferences?: Readonly<Record<string, string>>;