@hyperframes/studio 0.6.0-alpha.12 → 0.6.0-alpha.13

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.
@@ -547,18 +547,65 @@ function MetricField({
547
547
  value,
548
548
  disabled,
549
549
  liveCommit,
550
+ scrub,
550
551
  onCommit,
551
552
  }: {
552
553
  label: string;
553
554
  value: string;
554
555
  disabled?: boolean;
555
556
  liveCommit?: boolean;
557
+ scrub?: boolean;
556
558
  onCommit: (nextValue: string) => void;
557
559
  }) {
560
+ const scrubRef = useRef<{
561
+ startX: number;
562
+ startValue: number;
563
+ pointerId: number;
564
+ } | null>(null);
565
+
566
+ const handleScrubPointerDown = useCallback(
567
+ (e: React.PointerEvent<HTMLSpanElement>) => {
568
+ if (disabled || !scrub) return;
569
+ const parsed = parseFloat(value);
570
+ if (!Number.isFinite(parsed)) return;
571
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
572
+ scrubRef.current = { startX: e.clientX, startValue: parsed, pointerId: e.pointerId };
573
+ },
574
+ [disabled, scrub, value],
575
+ );
576
+
577
+ const handleScrubPointerMove = useCallback(
578
+ (e: React.PointerEvent<HTMLSpanElement>) => {
579
+ const state = scrubRef.current;
580
+ if (!state) return;
581
+ const delta = e.clientX - state.startX;
582
+ const next = Math.round(state.startValue + delta);
583
+ onCommit(String(next));
584
+ },
585
+ [onCommit],
586
+ );
587
+
588
+ const handleScrubPointerUp = useCallback(() => {
589
+ scrubRef.current = null;
590
+ }, []);
591
+
592
+ const scrubProps =
593
+ scrub && !disabled
594
+ ? ({
595
+ className:
596
+ "flex-shrink-0 text-[11px] font-medium text-neutral-500 cursor-ew-resize select-none",
597
+ onPointerDown: handleScrubPointerDown,
598
+ onPointerMove: handleScrubPointerMove,
599
+ onPointerUp: handleScrubPointerUp,
600
+ } as const)
601
+ : ({
602
+ className: "flex-shrink-0 text-[11px] font-medium text-neutral-500",
603
+ } as const);
604
+
558
605
  return (
559
606
  <div className={FIELD}>
560
607
  <div className="flex min-w-0 items-center gap-3">
561
- <span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">{label}</span>
608
+ <span {...scrubProps}>{label}</span>
562
609
  <CommitField
563
610
  value={value}
564
611
  disabled={disabled}
@@ -769,8 +816,8 @@ function fontSourceRank(source: FontSource): number {
769
816
  if (source === "Current") return 0;
770
817
  if (source === "Document") return 1;
771
818
  if (source === "Imported") return 2;
772
- if (source === "Local") return 3;
773
- if (source === "Google") return 4;
819
+ if (source === "Google") return 3;
820
+ if (source === "Local") return 4;
774
821
  return 5;
775
822
  }
776
823
 
@@ -841,16 +888,44 @@ function loadImportedFontStylesheet(asset: ImportedFontAsset): void {
841
888
  document.head.appendChild(style);
842
889
  }
843
890
 
891
+ const ALL_WEIGHTS = ["100", "200", "300", "400", "500", "600", "700", "800", "900"];
892
+ const WEIGHT_LABELS: Record<string, string> = {
893
+ "100": "100 · Thin",
894
+ "200": "200 · Extra Light",
895
+ "300": "300 · Light",
896
+ "400": "400 · Regular",
897
+ "500": "500 · Medium",
898
+ "600": "600 · Semi Bold",
899
+ "700": "700 · Bold",
900
+ "800": "800 · Extra Bold",
901
+ "900": "900 · Black",
902
+ };
903
+
904
+ function detectAvailableWeights(fontFamily: string): string[] {
905
+ const fonts = document.fonts;
906
+ if (!fonts) return ALL_WEIGHTS;
907
+ const family = fontFamily.split(",")[0]?.trim().replace(/['"]/g, "");
908
+ if (!family) return ALL_WEIGHTS;
909
+ const available: string[] = [];
910
+ for (const w of ALL_WEIGHTS) {
911
+ if (fonts.check(`${w} 16px "${family}"`)) available.push(w);
912
+ }
913
+ return available.length > 0 ? available : ALL_WEIGHTS;
914
+ }
915
+
844
916
  function FontWeightField({
845
917
  value,
846
918
  disabled,
919
+ fontFamily,
847
920
  onCommit,
848
921
  }: {
849
922
  value: string;
850
923
  disabled?: boolean;
924
+ fontFamily?: string;
851
925
  onCommit: (nextValue: string) => void;
852
926
  }) {
853
- const options = ["300", "400", "500", "600", "700", "800"];
927
+ const options = fontFamily ? detectAvailableWeights(fontFamily) : ALL_WEIGHTS;
928
+ const displayOptions = value && !options.includes(value) ? [value, ...options] : options;
854
929
  return (
855
930
  <div className={FIELD}>
856
931
  <div className="flex min-w-0 items-center gap-3">
@@ -861,9 +936,9 @@ function FontWeightField({
861
936
  onChange={(e) => onCommit(e.target.value)}
862
937
  className="min-w-0 w-full appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
863
938
  >
864
- {options.map((option) => (
939
+ {displayOptions.map((option) => (
865
940
  <option key={option} value={option}>
866
- {option}
941
+ {WEIGHT_LABELS[option] ?? option}
867
942
  </option>
868
943
  ))}
869
944
  </select>
@@ -1022,13 +1097,20 @@ function FontFamilyField({
1022
1097
 
1023
1098
  const options = useMemo(() => {
1024
1099
  const documentFonts = collectDocumentFontFamilies();
1100
+ const googleSet = new Set(googleFonts.map((f) => f.toLowerCase()));
1101
+ const taggedLocalFonts = localFonts.map(
1102
+ (family): FontOption => ({
1103
+ family,
1104
+ source: googleSet.has(family.toLowerCase()) ? "Google" : "Local",
1105
+ }),
1106
+ );
1025
1107
  return sortFontOptions(
1026
1108
  uniqueFontOptions([
1027
1109
  { family: currentFamily, source: "Current" },
1028
1110
  ...documentFonts.map((family): FontOption => ({ family, source: "Document" })),
1029
1111
  ...projectFontAssets,
1030
- ...localFonts.map((family): FontOption => ({ family, source: "Local" })),
1031
1112
  ...googleFonts.map((family): FontOption => ({ family, source: "Google" })),
1113
+ ...taggedLocalFonts,
1032
1114
  ...DEFAULT_FONT_FAMILIES.map((family): FontOption => ({ family, source: "System" })),
1033
1115
  ]),
1034
1116
  );
@@ -1036,7 +1118,21 @@ function FontFamilyField({
1036
1118
 
1037
1119
  const filteredOptions = useMemo(() => {
1038
1120
  const matches = options.filter((option) => fontMatchesQuery(option.family, query));
1039
- return matches.slice(0, query.trim() ? 120 : 160);
1121
+ if (query.trim()) return matches.slice(0, 200);
1122
+ const bySource = new Map<string, FontOption[]>();
1123
+ for (const m of matches) {
1124
+ const list = bySource.get(m.source) ?? [];
1125
+ list.push(m);
1126
+ bySource.set(m.source, list);
1127
+ }
1128
+ const result: FontOption[] = [];
1129
+ for (const s of ["Current", "Document", "Imported"]) {
1130
+ result.push(...(bySource.get(s) ?? []));
1131
+ }
1132
+ result.push(...(bySource.get("Google") ?? []).slice(0, 100));
1133
+ result.push(...(bySource.get("Local") ?? []).slice(0, 80));
1134
+ result.push(...(bySource.get("System") ?? []));
1135
+ return result;
1040
1136
  }, [options, query]);
1041
1137
 
1042
1138
  const importLocalFont = async (family: string): Promise<ImportedFontAsset | null> => {
@@ -1226,21 +1322,36 @@ function AdvancedTextControls({
1226
1322
  return (
1227
1323
  <div className="space-y-4">
1228
1324
  <div className={RESPONSIVE_GRID}>
1229
- <MetricField
1325
+ <SelectField
1230
1326
  label="Line"
1231
1327
  value={getTextStyleValue(field, inheritedStyles, "line-height", "normal")}
1232
1328
  disabled={disabled}
1233
- liveCommit
1234
- onCommit={(next) =>
1329
+ options={["normal", "1", "1.1", "1.2", "1.25", "1.3", "1.4", "1.5", "1.6", "1.75", "2"]}
1330
+ onChange={(next) =>
1235
1331
  onCommit("line-height", normalizeTextMetricValue("line-height", next))
1236
1332
  }
1237
1333
  />
1238
- <MetricField
1334
+ <SelectField
1239
1335
  label="Track"
1240
1336
  value={getTextStyleValue(field, inheritedStyles, "letter-spacing", "0px")}
1241
1337
  disabled={disabled}
1242
- liveCommit
1243
- onCommit={(next) =>
1338
+ options={[
1339
+ "0px",
1340
+ "-0.05em",
1341
+ "-0.04em",
1342
+ "-0.03em",
1343
+ "-0.02em",
1344
+ "-0.01em",
1345
+ "0em",
1346
+ "0.01em",
1347
+ "0.02em",
1348
+ "0.03em",
1349
+ "0.05em",
1350
+ "0.1em",
1351
+ "0.15em",
1352
+ "0.2em",
1353
+ ]}
1354
+ onChange={(next) =>
1244
1355
  onCommit("letter-spacing", normalizeTextMetricValue("letter-spacing", next))
1245
1356
  }
1246
1357
  />
@@ -1266,7 +1377,7 @@ function AdvancedTextControls({
1266
1377
  value={getTextStyleValue(field, inheritedStyles, "font-style", "normal")}
1267
1378
  disabled={disabled}
1268
1379
  onChange={(next) => onCommit("font-style", next)}
1269
- options={["normal", "italic", "oblique"]}
1380
+ options={["normal", "italic"]}
1270
1381
  />
1271
1382
  </div>
1272
1383
  );
@@ -2245,6 +2356,16 @@ export const PropertyPanel = memo(function PropertyPanel({
2245
2356
  parsePxMetricValue(styles["border-width"] ?? "") ??
2246
2357
  parsePxMetricValue(styles["border-top-width"] ?? "") ??
2247
2358
  0;
2359
+ const hasVisualBackground =
2360
+ (styles.background != null && styles.background !== "none" && styles.background !== "") ||
2361
+ (styles["background-color"] != null &&
2362
+ styles["background-color"] !== "transparent" &&
2363
+ styles["background-color"] !== "rgba(0, 0, 0, 0)" &&
2364
+ styles["background-color"] !== "") ||
2365
+ (styles["background-image"] != null &&
2366
+ styles["background-image"] !== "none" &&
2367
+ styles["background-image"] !== "") ||
2368
+ borderWidthValue > 0;
2248
2369
  const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none";
2249
2370
  const borderColorValue =
2250
2371
  styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)";
@@ -2402,6 +2523,9 @@ export const PropertyPanel = memo(function PropertyPanel({
2402
2523
  styles["font-weight"] ||
2403
2524
  "400"
2404
2525
  }
2526
+ fontFamily={
2527
+ activeField.computedStyles["font-family"] || styles["font-family"]
2528
+ }
2405
2529
  disabled={false}
2406
2530
  onCommit={(next) =>
2407
2531
  onSetTextFieldStyle(activeField.key, "font-weight", next)
@@ -2530,6 +2654,7 @@ export const PropertyPanel = memo(function PropertyPanel({
2530
2654
  />
2531
2655
  <FontWeightField
2532
2656
  value={activeField.computedStyles["font-weight"] || "400"}
2657
+ fontFamily={activeField.computedStyles["font-family"]}
2533
2658
  disabled={false}
2534
2659
  onCommit={(next) =>
2535
2660
  onSetTextFieldStyle(activeField.key, "font-weight", next)
@@ -2570,24 +2695,28 @@ export const PropertyPanel = memo(function PropertyPanel({
2570
2695
  label="X"
2571
2696
  value={formatPxMetricValue(manualOffset.x)}
2572
2697
  disabled={manualOffsetEditingDisabled}
2698
+ scrub
2573
2699
  onCommit={(next) => commitManualOffset("x", next)}
2574
2700
  />
2575
2701
  <MetricField
2576
2702
  label="Y"
2577
2703
  value={formatPxMetricValue(manualOffset.y)}
2578
2704
  disabled={manualOffsetEditingDisabled}
2705
+ scrub
2579
2706
  onCommit={(next) => commitManualOffset("y", next)}
2580
2707
  />
2581
2708
  <MetricField
2582
2709
  label="W"
2583
2710
  value={formatPxMetricValue(resolvedWidth)}
2584
2711
  disabled={manualSizeEditingDisabled}
2712
+ scrub
2585
2713
  onCommit={(next) => commitManualSize("width", next)}
2586
2714
  />
2587
2715
  <MetricField
2588
2716
  label="H"
2589
2717
  value={formatPxMetricValue(resolvedHeight)}
2590
2718
  disabled={manualSizeEditingDisabled}
2719
+ scrub
2591
2720
  onCommit={(next) => commitManualSize("height", next)}
2592
2721
  />
2593
2722
  </div>
@@ -2640,18 +2769,20 @@ export const PropertyPanel = memo(function PropertyPanel({
2640
2769
 
2641
2770
  {showEditableSections && (
2642
2771
  <>
2643
- <Section title="Radius" icon={<Settings size={15} />}>
2644
- <SliderControl
2645
- value={radiusValue}
2646
- min={0}
2647
- max={Math.max(240, Math.ceil(radiusValue))}
2648
- step={1}
2649
- disabled={styleEditingDisabled}
2650
- displayValue={`${formatNumericValue(radiusValue)}px`}
2651
- formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
2652
- onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)}
2653
- />
2654
- </Section>
2772
+ {hasVisualBackground && (
2773
+ <Section title="Radius" icon={<Settings size={15} />}>
2774
+ <SliderControl
2775
+ value={radiusValue}
2776
+ min={0}
2777
+ max={Math.max(240, Math.ceil(radiusValue))}
2778
+ step={1}
2779
+ disabled={styleEditingDisabled}
2780
+ displayValue={`${formatNumericValue(radiusValue)}px`}
2781
+ formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
2782
+ onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)}
2783
+ />
2784
+ </Section>
2785
+ )}
2655
2786
 
2656
2787
  <Section title="Stroke" icon={<Square size={15} />}>
2657
2788
  <div className="space-y-4">
@@ -453,16 +453,49 @@ function getElementDepth(el: HTMLElement): number {
453
453
  return depth;
454
454
  }
455
455
 
456
+ const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
457
+
458
+ function isElementComputedVisible(el: HTMLElement): boolean {
459
+ const win = el.ownerDocument.defaultView;
460
+ if (!win) return true;
461
+ let current: HTMLElement | null = el;
462
+ while (current) {
463
+ const computed = win.getComputedStyle(current);
464
+ if (computed.display === "none" || computed.visibility === "hidden") return false;
465
+ const opacity = Number.parseFloat(computed.opacity);
466
+ if (Number.isFinite(opacity) && opacity <= 0.01) return false;
467
+ current = current.parentElement;
468
+ }
469
+ return true;
470
+ }
471
+
472
+ function isEmptyVisualContainer(el: HTMLElement): boolean {
473
+ const tag = el.tagName.toLowerCase();
474
+ if (VISUAL_LEAF_TAGS.has(tag)) return false;
475
+
476
+ const children = el.children;
477
+ if (children.length === 0) {
478
+ const text = (el.textContent ?? "").trim();
479
+ return text.length === 0;
480
+ }
481
+
482
+ for (let i = 0; i < children.length; i += 1) {
483
+ const child = children[i];
484
+ if (!isHtmlElement(child)) continue;
485
+ if (VISUAL_LEAF_TAGS.has(child.tagName.toLowerCase())) return false;
486
+ if (isElementComputedVisible(child)) return false;
487
+ }
488
+
489
+ return true;
490
+ }
491
+
456
492
  function hasRenderedBox(el: HTMLElement): boolean {
457
493
  const rect = el.getBoundingClientRect();
458
494
  if (rect.width <= 1 || rect.height <= 1) return false;
459
495
 
460
- const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
461
- if (!computed) return true;
462
- if (computed.display === "none" || computed.visibility === "hidden") return false;
496
+ if (!isElementComputedVisible(el)) return false;
463
497
 
464
- const opacity = Number.parseFloat(computed.opacity);
465
- if (Number.isFinite(opacity) && opacity <= 0.01) return false;
498
+ if (isEmptyVisualContainer(el)) return false;
466
499
 
467
500
  return true;
468
501
  }
@@ -16,10 +16,10 @@ describe("manual editing availability", () => {
16
16
  vi.resetModules();
17
17
  });
18
18
 
19
- it("enables inspector selection by default while motion and manual dragging stay opt-in", async () => {
19
+ it("enables inspector selection and manual dragging by default while motion stays opt-in", async () => {
20
20
  const availability = await loadAvailabilityWithEnv({});
21
21
 
22
- expect(availability.STUDIO_PREVIEW_MANUAL_EDITING_ENABLED).toBe(false);
22
+ expect(availability.STUDIO_PREVIEW_MANUAL_EDITING_ENABLED).toBe(true);
23
23
  expect(availability.STUDIO_PREVIEW_SELECTION_ENABLED).toBe(true);
24
24
  expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(true);
25
25
  expect(availability.STUDIO_MOTION_PANEL_ENABLED).toBe(false);
@@ -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(
@@ -52,9 +52,6 @@ interface NLELayoutProps {
52
52
  ) => Promise<void> | void;
53
53
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
54
54
  onSelectTimelineElement?: (element: TimelineElement | null) => void;
55
- onInspectTimelineElement?: (element: TimelineElement) => void;
56
- inspectedTimelineElementId?: string | null;
57
- timelineLayerChildCounts?: ReadonlyMap<string, number>;
58
55
  /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
59
56
  onCompIdToSrcChange?: (map: Map<string, string>) => void;
60
57
  /** Whether the timeline panel is visible (default: true) */
@@ -91,9 +88,6 @@ export const NLELayout = memo(function NLELayout({
91
88
  onResizeElement,
92
89
  onBlockedEditAttempt,
93
90
  onSelectTimelineElement,
94
- onInspectTimelineElement,
95
- inspectedTimelineElementId,
96
- timelineLayerChildCounts,
97
91
  onCompIdToSrcChange,
98
92
  timelineVisible,
99
93
  onToggleTimeline,
@@ -460,10 +454,6 @@ export const NLELayout = memo(function NLELayout({
460
454
  onResizeElement={onResizeElement}
461
455
  onBlockedEditAttempt={onBlockedEditAttempt}
462
456
  onSelectElement={onSelectTimelineElement}
463
- onInspectElement={onInspectTimelineElement}
464
- inspectedElementId={inspectedTimelineElementId}
465
- layerChildCounts={timelineLayerChildCounts}
466
- disabled={timelineDisabled}
467
457
  />
468
458
  </div>
469
459
  {timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
@@ -36,6 +36,8 @@ function getShaderTransitionLoading(event: Event): boolean | null {
36
36
  return state.loading === true && state.ready !== true;
37
37
  }
38
38
 
39
+ const COMPOSITION_LOADING_OVERLAY_DELAY_MS = 400;
40
+
39
41
  export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
40
42
  return compositionLoading;
41
43
  }
@@ -124,6 +126,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
124
126
  const [assetOverlayFading, setAssetOverlayFading] = useState(false);
125
127
  const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
126
128
  const [compositionLoading, setCompositionLoading] = useState(true);
129
+ const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
130
+
131
+ // eslint-disable-next-line no-restricted-syntax
132
+ useEffect(() => {
133
+ if (!compositionLoading) {
134
+ setCompositionOverlayDeferred(true);
135
+ return;
136
+ }
137
+ const timer = setTimeout(
138
+ () => setCompositionOverlayDeferred(false),
139
+ COMPOSITION_LOADING_OVERLAY_DELAY_MS,
140
+ );
141
+ return () => clearTimeout(timer);
142
+ }, [compositionLoading]);
127
143
 
128
144
  useMountEffect(() => {
129
145
  const container = containerRef.current;
@@ -281,7 +297,9 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
281
297
  }, [assetsLoading]);
282
298
 
283
299
  const showCompositionOverlay =
284
- !suppressLoadingOverlay && shouldShowCompositionLoadingOverlay(compositionLoading);
300
+ !suppressLoadingOverlay &&
301
+ !compositionOverlayDeferred &&
302
+ shouldShowCompositionLoadingOverlay(compositionLoading);
285
303
  const showAssetOverlay =
286
304
  assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay;
287
305
 
@@ -12,8 +12,6 @@ import {
12
12
  shouldHandleTimelineDeleteKey,
13
13
  shouldAutoScrollTimeline,
14
14
  } from "./Timeline";
15
- import { TIMELINE_CLIP_CONTROL_Z_INDEX } from "./TimelineClip";
16
- import { COMPOSITION_THUMBNAIL_LABEL_Z_INDEX } from "./CompositionThumbnail";
17
15
  import { formatTime } from "../lib/time";
18
16
 
19
17
  describe("generateTicks", () => {
@@ -166,12 +164,6 @@ describe("shouldAutoScrollTimeline", () => {
166
164
  });
167
165
  });
168
166
 
169
- describe("timeline clip controls", () => {
170
- it("renders layer controls above composition thumbnail chrome", () => {
171
- expect(TIMELINE_CLIP_CONTROL_Z_INDEX).toBeGreaterThan(COMPOSITION_THUMBNAIL_LABEL_Z_INDEX);
172
- });
173
- });
174
-
175
167
  describe("getTimelineScrollLeftForZoomTransition", () => {
176
168
  it("resets horizontal scroll when switching from manual zoom back to fit", () => {
177
169
  expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0);