@hyperframes/studio 0.6.0-alpha.2 → 0.6.0-alpha.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.
@@ -9,7 +9,9 @@ import {
9
9
  findElementForTimelineElement,
10
10
  getDomEditNonEditableReason,
11
11
  getDomEditTargetKey,
12
+ isLargeRasterDomEditSelection,
12
13
  isTextEditableSelection,
14
+ resolveVisualDomEditSelectionTarget,
13
15
  serializeDomEditTextFields,
14
16
  type DomEditSelection,
15
17
  resolveDomEditCapabilities,
@@ -23,6 +25,30 @@ function createDocument(markup: string): Document {
23
25
  return window.document;
24
26
  }
25
27
 
28
+ function setElementRect(
29
+ element: HTMLElement,
30
+ rect: Partial<Pick<DOMRect, "left" | "top" | "width" | "height">>,
31
+ ) {
32
+ const left = rect.left ?? 0;
33
+ const top = rect.top ?? 0;
34
+ const width = rect.width ?? 100;
35
+ const height = rect.height ?? 40;
36
+ Object.defineProperty(element, "getBoundingClientRect", {
37
+ configurable: true,
38
+ value: () => ({
39
+ x: left,
40
+ y: top,
41
+ left,
42
+ top,
43
+ width,
44
+ height,
45
+ right: left + width,
46
+ bottom: top + height,
47
+ toJSON: () => null,
48
+ }),
49
+ });
50
+ }
51
+
26
52
  describe("resolveDomEditCapabilities", () => {
27
53
  it("marks absolute px-positioned layers as movable and resizable", () => {
28
54
  expect(
@@ -199,6 +225,154 @@ describe("resolveDomEditCapabilities", () => {
199
225
  });
200
226
  });
201
227
 
228
+ describe("resolveVisualDomEditSelectionTarget", () => {
229
+ it("prefers the visible leaf under the pointer over an oversized container", () => {
230
+ const document = createDocument(`
231
+ <section id="container" class="hero-shell">
232
+ <span id="headline" class="headline">Launch faster</span>
233
+ </section>
234
+ `);
235
+ const container = document.getElementById("container") as HTMLElement;
236
+ const headline = document.getElementById("headline") as HTMLElement;
237
+ setElementRect(container, { width: 900, height: 520 });
238
+ setElementRect(headline, { left: 240, top: 160, width: 180, height: 36 });
239
+
240
+ expect(
241
+ resolveVisualDomEditSelectionTarget([container, headline], {
242
+ activeCompositionPath: "index.html",
243
+ }),
244
+ ).toBe(headline);
245
+ });
246
+
247
+ it("skips hidden and zero-size elements before picking a rendered candidate", () => {
248
+ const document = createDocument(`
249
+ <div id="hidden" style="display: none">Hidden</div>
250
+ <div id="empty">Empty</div>
251
+ <div id="visible">Visible</div>
252
+ `);
253
+ const hidden = document.getElementById("hidden") as HTMLElement;
254
+ const empty = document.getElementById("empty") as HTMLElement;
255
+ const visible = document.getElementById("visible") as HTMLElement;
256
+ setElementRect(hidden, { width: 120, height: 32 });
257
+ setElementRect(empty, { width: 0, height: 0 });
258
+ setElementRect(visible, { width: 120, height: 32 });
259
+
260
+ expect(
261
+ resolveVisualDomEditSelectionTarget([hidden, empty, visible], {
262
+ activeCompositionPath: "index.html",
263
+ }),
264
+ ).toBe(visible);
265
+ });
266
+
267
+ it("skips transparent elements that still report a box", () => {
268
+ const document = createDocument(`
269
+ <button id="transparent" style="opacity: 0">Transparent</button>
270
+ <button id="visible">Visible</button>
271
+ `);
272
+ const transparent = document.getElementById("transparent") as HTMLElement;
273
+ const visible = document.getElementById("visible") as HTMLElement;
274
+ setElementRect(transparent, { width: 120, height: 32 });
275
+ setElementRect(visible, { width: 120, height: 32 });
276
+
277
+ expect(
278
+ resolveVisualDomEditSelectionTarget([transparent, visible], {
279
+ activeCompositionPath: "index.html",
280
+ }),
281
+ ).toBe(visible);
282
+ });
283
+
284
+ it("falls back to the nearest stable editable ancestor when a visual child has no target", () => {
285
+ const document = createDocument(`
286
+ <section id="card">
287
+ <span>Unlabeled copy</span>
288
+ </section>
289
+ `);
290
+ const card = document.getElementById("card") as HTMLElement;
291
+ const span = card.querySelector("span") as HTMLElement;
292
+ setElementRect(card, { width: 400, height: 200 });
293
+ setElementRect(span, { left: 40, top: 40, width: 140, height: 28 });
294
+
295
+ expect(
296
+ resolveVisualDomEditSelectionTarget([span, card], {
297
+ activeCompositionPath: "index.html",
298
+ }),
299
+ ).toBe(card);
300
+ });
301
+
302
+ it("keeps explicit layer selection able to target containers", () => {
303
+ const document = createDocument(`
304
+ <section id="container" class="hero-shell">
305
+ <span id="headline" class="headline">Launch faster</span>
306
+ </section>
307
+ `);
308
+ const container = document.getElementById("container") as HTMLElement;
309
+ const headline = document.getElementById("headline") as HTMLElement;
310
+ setElementRect(container, { width: 900, height: 520 });
311
+ setElementRect(headline, { left: 240, top: 160, width: 180, height: 36 });
312
+
313
+ const visualTarget = resolveVisualDomEditSelectionTarget([container, headline], {
314
+ activeCompositionPath: "index.html",
315
+ });
316
+ const explicitSelection = resolveDomEditSelection(container, {
317
+ activeCompositionPath: "index.html",
318
+ isMasterView: false,
319
+ });
320
+
321
+ expect(visualTarget).toBe(headline);
322
+ expect(explicitSelection?.id).toBe("container");
323
+ });
324
+ });
325
+
326
+ describe("isLargeRasterDomEditSelection", () => {
327
+ it("flags large image and background targets for raster click fallback", () => {
328
+ expect(
329
+ isLargeRasterDomEditSelection(
330
+ {
331
+ tagName: "img",
332
+ boundingBox: { x: 0, y: 0, width: 1920, height: 1080 },
333
+ computedStyles: {},
334
+ },
335
+ { width: 1920, height: 1080 },
336
+ ),
337
+ ).toBe(true);
338
+
339
+ expect(
340
+ isLargeRasterDomEditSelection(
341
+ {
342
+ tagName: "div",
343
+ boundingBox: { x: 0, y: 0, width: 1280, height: 720 },
344
+ computedStyles: { "background-image": 'url("hero.png")' },
345
+ },
346
+ { width: 1920, height: 1080 },
347
+ ),
348
+ ).toBe(true);
349
+ });
350
+
351
+ it("does not flag small media or text selections", () => {
352
+ expect(
353
+ isLargeRasterDomEditSelection(
354
+ {
355
+ tagName: "img",
356
+ boundingBox: { x: 80, y: 80, width: 96, height: 96 },
357
+ computedStyles: {},
358
+ },
359
+ { width: 1920, height: 1080 },
360
+ ),
361
+ ).toBe(false);
362
+
363
+ expect(
364
+ isLargeRasterDomEditSelection(
365
+ {
366
+ tagName: "h1",
367
+ boundingBox: { x: 0, y: 0, width: 1600, height: 300 },
368
+ computedStyles: {},
369
+ },
370
+ { width: 1920, height: 1080 },
371
+ ),
372
+ ).toBe(false);
373
+ });
374
+ });
375
+
202
376
  describe("resolveDomEditSelection", () => {
203
377
  it("keeps composition host transforms disabled in master view", () => {
204
378
  expect(
@@ -643,6 +817,36 @@ describe("resolveDomEditSelection", () => {
643
817
  ).toBe(root);
644
818
  });
645
819
 
820
+ it("normalizes preview URLs when resolving master timeline composition clips", () => {
821
+ const document = createDocument(`
822
+ <div data-composition-id="main">
823
+ <div
824
+ id="slide-1"
825
+ data-composition-id="slide-core-conviction"
826
+ data-composition-src="compositions/slide-01-core-conviction.html"
827
+ >
828
+ <h1>Core Conviction</h1>
829
+ </div>
830
+ </div>
831
+ `);
832
+ const slide = document.getElementById("slide-1") as HTMLElement;
833
+
834
+ expect(
835
+ findElementForTimelineElement(
836
+ document,
837
+ {
838
+ id: "slide-1",
839
+ compositionSrc:
840
+ "http://127.0.0.1:5176/api/projects/apple-presentation-download/preview/compositions/slide-01-core-conviction.html",
841
+ },
842
+ {
843
+ activeCompositionPath: null,
844
+ isMasterView: true,
845
+ },
846
+ ),
847
+ ).toBe(slide);
848
+ });
849
+
646
850
  it("does not fall back to the root composition when an explicit timeline selector misses", () => {
647
851
  const document = createDocument(`
648
852
  <div data-composition-id="hook">
@@ -783,6 +987,50 @@ describe("patch builders and prompt builder", () => {
783
987
  expect(prompt).not.toContain("Source file: index.html");
784
988
  });
785
989
 
990
+ it("includes raster click context in copied agent prompts", () => {
991
+ const selection = {
992
+ element: {} as HTMLElement,
993
+ id: undefined,
994
+ selector: ".hero-bg",
995
+ selectorIndex: undefined,
996
+ sourceFile: "index.html",
997
+ compositionPath: "index.html",
998
+ compositionSrc: undefined,
999
+ isCompositionHost: false,
1000
+ label: "Hero Bg",
1001
+ tagName: "img",
1002
+ boundingBox: { x: 0, y: 0, width: 1920, height: 1080 },
1003
+ textContent: null,
1004
+ dataAttributes: {},
1005
+ inlineStyles: {},
1006
+ computedStyles: {},
1007
+ textFields: [],
1008
+ capabilities: {
1009
+ canSelect: true,
1010
+ canEditStyles: true,
1011
+ canMove: true,
1012
+ canResize: true,
1013
+ canApplyManualOffset: true,
1014
+ canApplyManualSize: true,
1015
+ canApplyManualRotation: true,
1016
+ },
1017
+ } satisfies DomEditSelection;
1018
+
1019
+ const prompt = buildElementAgentPrompt({
1020
+ selection,
1021
+ currentTime: 3,
1022
+ selectionContext:
1023
+ "The user clicked visible text that is baked into the selected image/background.",
1024
+ userInstruction: "Change the title copy.",
1025
+ });
1026
+
1027
+ expect(prompt).toContain("Selection context:");
1028
+ expect(prompt).toContain(
1029
+ "The user clicked visible text that is baked into the selected image/background.",
1030
+ );
1031
+ expect(prompt).toContain("Change the title copy.");
1032
+ });
1033
+
786
1034
  it("serializes child text fields back into HTML", () => {
787
1035
  expect(
788
1036
  serializeDomEditTextFields([
@@ -105,6 +105,11 @@ export interface DomEditContextOptions {
105
105
  preferClipAncestor?: boolean;
106
106
  }
107
107
 
108
+ export interface DomEditViewport {
109
+ width: number;
110
+ height: number;
111
+ }
112
+
108
113
  export interface TimelineElementDomTarget {
109
114
  id?: string;
110
115
  domId?: string;
@@ -291,6 +296,27 @@ function escapeCssString(value: string): string {
291
296
  .replace(/\f/g, "\\c ");
292
297
  }
293
298
 
299
+ function normalizeTimelineCompositionSource(value: string | undefined): string | undefined {
300
+ const trimmed = value?.trim();
301
+ if (!trimmed) return undefined;
302
+
303
+ let pathname = trimmed;
304
+ try {
305
+ pathname = new URL(trimmed, "http://studio.local").pathname;
306
+ } catch {
307
+ pathname = trimmed;
308
+ }
309
+
310
+ for (const marker of ["/preview/comp/", "/preview/"]) {
311
+ const markerIndex = pathname.indexOf(marker);
312
+ if (markerIndex < 0) continue;
313
+ const sourcePath = pathname.slice(markerIndex + marker.length).replace(/^\/+/, "");
314
+ return sourcePath || trimmed;
315
+ }
316
+
317
+ return trimmed;
318
+ }
319
+
294
320
  function querySelectorAllSafely(doc: Document, selector: string): Element[] {
295
321
  try {
296
322
  return Array.from(doc.querySelectorAll(selector));
@@ -372,6 +398,7 @@ function buildElementLabel(el: HTMLElement): string {
372
398
  const DOM_LAYER_IGNORED_TAGS = new Set([
373
399
  "base",
374
400
  "br",
401
+ "canvas",
375
402
  "link",
376
403
  "meta",
377
404
  "script",
@@ -416,6 +443,91 @@ function getDomLayerPatchTarget(
416
443
  };
417
444
  }
418
445
 
446
+ function getElementDepth(el: HTMLElement): number {
447
+ let depth = 0;
448
+ let current = el.parentElement;
449
+ while (current) {
450
+ depth += 1;
451
+ current = current.parentElement;
452
+ }
453
+ return depth;
454
+ }
455
+
456
+ function hasRenderedBox(el: HTMLElement): boolean {
457
+ const rect = el.getBoundingClientRect();
458
+ if (rect.width <= 1 || rect.height <= 1) return false;
459
+
460
+ const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
461
+ if (!computed) return true;
462
+ if (computed.display === "none" || computed.visibility === "hidden") return false;
463
+
464
+ const opacity = Number.parseFloat(computed.opacity);
465
+ if (Number.isFinite(opacity) && opacity <= 0.01) return false;
466
+
467
+ return true;
468
+ }
469
+
470
+ function getVisualElementScore(el: HTMLElement, pointerStackIndex: number): number {
471
+ const tagName = el.tagName.toLowerCase();
472
+ const rect = el.getBoundingClientRect();
473
+ const area = Math.max(1, rect.width * rect.height);
474
+ const smallerElementBonus = Math.max(0, 1_000_000 - Math.min(area, 1_000_000)) / 1_000;
475
+ const visualLeafBonus =
476
+ isEditableTextLeaf(el) || ["img", "video", "canvas", "svg"].includes(tagName) ? 2_000 : 0;
477
+
478
+ return getElementDepth(el) * 10_000 + visualLeafBonus + smallerElementBonus - pointerStackIndex;
479
+ }
480
+
481
+ export function resolveVisualDomEditSelectionTarget(
482
+ elementsFromPoint: Iterable<Element | null | undefined>,
483
+ options: Pick<DomEditContextOptions, "activeCompositionPath">,
484
+ ): HTMLElement | null {
485
+ let best: { element: HTMLElement; score: number } | null = null;
486
+ let pointerStackIndex = 0;
487
+
488
+ for (const entry of elementsFromPoint) {
489
+ if (!isHtmlElement(entry)) {
490
+ pointerStackIndex += 1;
491
+ continue;
492
+ }
493
+
494
+ if (hasRenderedBox(entry) && getDomLayerPatchTarget(entry, options.activeCompositionPath)) {
495
+ const score = getVisualElementScore(entry, pointerStackIndex);
496
+ if (!best || score > best.score) {
497
+ best = { element: entry, score };
498
+ }
499
+ }
500
+ pointerStackIndex += 1;
501
+ }
502
+
503
+ return best?.element ?? null;
504
+ }
505
+
506
+ function hasRasterBackground(selection: Pick<DomEditSelection, "computedStyles">): boolean {
507
+ const backgroundImage = selection.computedStyles["background-image"]?.trim();
508
+ return Boolean(backgroundImage && backgroundImage !== "none");
509
+ }
510
+
511
+ export function isLargeRasterDomEditSelection(
512
+ selection: Pick<DomEditSelection, "boundingBox" | "computedStyles" | "tagName">,
513
+ viewport?: DomEditViewport | null,
514
+ ): boolean {
515
+ const tagName = selection.tagName.toLowerCase();
516
+ const isRasterLike = tagName === "img" || hasRasterBackground(selection);
517
+ if (!isRasterLike) return false;
518
+
519
+ const { width, height } = selection.boundingBox;
520
+ if (width <= 1 || height <= 1) return false;
521
+ if (!viewport || viewport.width <= 1 || viewport.height <= 1) {
522
+ return width >= 960 && height >= 540;
523
+ }
524
+
525
+ const areaRatio = (width * height) / (viewport.width * viewport.height);
526
+ const widthRatio = width / viewport.width;
527
+ const heightRatio = height / viewport.height;
528
+ return areaRatio >= 0.4 || (widthRatio >= 0.7 && heightRatio >= 0.5);
529
+ }
530
+
419
531
  function getDirectLayerChildren(el: HTMLElement, options: DomEditContextOptions): HTMLElement[] {
420
532
  return Array.from(el.children).filter(
421
533
  (child): child is HTMLElement =>
@@ -848,9 +960,14 @@ export function findElementForTimelineElement(
848
960
  options: TimelineElementDomTargetOptions,
849
961
  ): HTMLElement | null {
850
962
  const elementId = typeof element.id === "string" ? element.id : "";
851
- const compositionSource = element.compositionSrc ?? options.compIdToSrc?.get(elementId);
963
+ const compositionSource =
964
+ normalizeTimelineCompositionSource(element.compositionSrc) ??
965
+ options.compIdToSrc?.get(elementId);
852
966
  const sourceFile =
853
- compositionSource ?? element.sourceFile ?? options.activeCompositionPath ?? "index.html";
967
+ compositionSource ??
968
+ normalizeTimelineCompositionSource(element.sourceFile) ??
969
+ options.activeCompositionPath ??
970
+ "index.html";
854
971
  const escapedElementId = escapeCssString(elementId);
855
972
  const escapedCompositionSource = compositionSource ? escapeCssString(compositionSource) : null;
856
973
  const selector =
@@ -927,12 +1044,14 @@ export function buildElementAgentPrompt({
927
1044
  selection,
928
1045
  currentTime,
929
1046
  tagSnippet,
1047
+ selectionContext,
930
1048
  userInstruction,
931
1049
  sourceFilePath,
932
1050
  }: {
933
1051
  selection: DomEditSelection;
934
1052
  currentTime: number;
935
1053
  tagSnippet?: string;
1054
+ selectionContext?: string;
936
1055
  userInstruction?: string;
937
1056
  sourceFilePath?: string;
938
1057
  }): string {
@@ -957,6 +1076,11 @@ export function buildElementAgentPrompt({
957
1076
  lines.push(`Text: ${selection.textContent}`);
958
1077
  }
959
1078
 
1079
+ const trimmedSelectionContext = selectionContext?.trim();
1080
+ if (trimmedSelectionContext) {
1081
+ lines.push("", "Selection context:", trimmedSelectionContext);
1082
+ }
1083
+
960
1084
  const textFieldsBlock = formatTextFields(selection.textFields);
961
1085
  if (textFieldsBlock) {
962
1086
  lines.push("", "Text fields:", textFieldsBlock);
@@ -16,10 +16,11 @@ describe("manual editing availability", () => {
16
16
  vi.resetModules();
17
17
  });
18
18
 
19
- it("enables inspector layers by default while motion and manual editing stay opt-in", async () => {
19
+ it("enables inspector selection by default while motion and manual dragging stay opt-in", async () => {
20
20
  const availability = await loadAvailabilityWithEnv({});
21
21
 
22
22
  expect(availability.STUDIO_PREVIEW_MANUAL_EDITING_ENABLED).toBe(false);
23
+ expect(availability.STUDIO_PREVIEW_SELECTION_ENABLED).toBe(true);
23
24
  expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(true);
24
25
  expect(availability.STUDIO_MOTION_PANEL_ENABLED).toBe(false);
25
26
  expect(availability.STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED).toBe(true);
@@ -51,6 +52,7 @@ describe("manual editing availability", () => {
51
52
  });
52
53
 
53
54
  expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(false);
55
+ expect(availability.STUDIO_PREVIEW_SELECTION_ENABLED).toBe(false);
54
56
  expect(availability.STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED).toBe(false);
55
57
  });
56
58
 
@@ -32,7 +32,7 @@ const env = import.meta.env as StudioFeatureFlagEnv;
32
32
  export const STUDIO_PREVIEW_MANUAL_EDITING_ENABLED = resolveStudioBooleanEnvFlag(
33
33
  env,
34
34
  [STUDIO_PREVIEW_MANUAL_DRAGGING_ENV, "VITE_STUDIO_PREVIEW_MANUAL_EDITING_ENABLED"],
35
- false,
35
+ true,
36
36
  );
37
37
 
38
38
  export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag(
@@ -44,7 +44,7 @@ export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag(
44
44
  export const STUDIO_MOTION_PANEL_ENABLED = resolveStudioBooleanEnvFlag(
45
45
  env,
46
46
  [STUDIO_MOTION_PANEL_ENV, "VITE_STUDIO_MOTION_PANEL_ENABLED"],
47
- false,
47
+ true,
48
48
  );
49
49
 
50
50
  export const STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED =
@@ -55,6 +55,8 @@ export const STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED =
55
55
  true,
56
56
  );
57
57
 
58
+ export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
59
+
58
60
  export const STUDIO_MANUAL_EDITING_ENABLED = STUDIO_PREVIEW_MANUAL_EDITING_ENABLED;
59
61
 
60
62
  export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
@@ -1053,7 +1053,11 @@ function wrapSeekReapplyFunction(
1053
1053
  return result;
1054
1054
  };
1055
1055
  markWrapped(wrappedSeek);
1056
- owner[key] = wrappedSeek;
1056
+ try {
1057
+ owner[key] = wrappedSeek;
1058
+ } catch {
1059
+ return false;
1060
+ }
1057
1061
  return true;
1058
1062
  }
1059
1063
 
@@ -1161,7 +1165,11 @@ function wrapPlayReapplyFunction(
1161
1165
  return result;
1162
1166
  };
1163
1167
  markWrapped(wrappedPlay);
1164
- owner[key] = wrappedPlay;
1168
+ try {
1169
+ owner[key] = wrappedPlay;
1170
+ } catch {
1171
+ return false;
1172
+ }
1165
1173
  return true;
1166
1174
  }
1167
1175
 
@@ -1181,7 +1189,11 @@ function wrapApplyAfterFunction(
1181
1189
  return result;
1182
1190
  };
1183
1191
  markWrapped(wrappedApplyAfter);
1184
- owner[key] = wrappedApplyAfter;
1192
+ try {
1193
+ owner[key] = wrappedApplyAfter;
1194
+ } catch {
1195
+ return false;
1196
+ }
1185
1197
  return true;
1186
1198
  }
1187
1199
 
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldDisableTimelineWhileCompositionLoading } from "./NLELayout";
3
+
4
+ describe("timeline loading disable state", () => {
5
+ it("disables the timeline while the composition loading overlay is visible", () => {
6
+ expect(shouldDisableTimelineWhileCompositionLoading(true)).toBe(true);
7
+ });
8
+
9
+ it("reenables the timeline after composition loading finishes", () => {
10
+ expect(shouldDisableTimelineWhileCompositionLoading(false)).toBe(false);
11
+ });
12
+ });