@hyperframes/studio 0.8.58 → 0.8.59

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 (52) hide show
  1. package/dist/assets/{hyperframes-player-zGyOcwwh.js → hyperframes-player-BBub3ZdI.js} +3 -3
  2. package/dist/assets/{index-ZJaRCDYV.js → index-BxZ5Cw5w.js} +1 -1
  3. package/dist/assets/{index-BcE2OSVA.js → index-CI7WON-E.js} +1 -1
  4. package/dist/assets/{index-d8ferjRc.js → index-D1KNPijK.js} +190 -190
  5. package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
  6. package/dist/index.d.ts +548 -8
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +21596 -20725
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/components/TimelineToolbar.tsx +19 -0
  13. package/src/components/editor/audioInspectorInput.test.ts +55 -0
  14. package/src/components/editor/audioInspectorInput.ts +55 -0
  15. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
  16. package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
  17. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
  18. package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
  19. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
  20. package/src/components/editor/useColorGradingController.ts +2 -6
  21. package/src/components/nle/AudioMeterStrip.test.tsx +124 -0
  22. package/src/components/nle/AudioMeterStrip.tsx +213 -0
  23. package/src/components/nle/TimelinePane.tsx +29 -23
  24. package/src/contexts/TimelineEditContext.tsx +4 -0
  25. package/src/hooks/useTimelineAssetDropOps.ts +9 -1
  26. package/src/index.ts +16 -0
  27. package/src/player/components/Timeline.test.ts +22 -31
  28. package/src/player/components/Timeline.tsx +34 -7
  29. package/src/player/components/TimelineCanvas.tsx +10 -56
  30. package/src/player/components/TimelineClip.tsx +14 -1
  31. package/src/player/components/TimelineClipFades.test.tsx +171 -0
  32. package/src/player/components/TimelineClipFades.tsx +302 -0
  33. package/src/player/components/TimelineOverlays.tsx +164 -165
  34. package/src/player/components/TimelineParts.test.tsx +101 -0
  35. package/src/player/components/TimelineParts.tsx +74 -0
  36. package/src/player/components/TimelinePartsParity.test.tsx +58 -0
  37. package/src/player/components/TimelineProvider.tsx +25 -0
  38. package/src/player/components/TimelineRazorInteraction.tsx +1 -1
  39. package/src/player/components/TimelineRulerPart.tsx +22 -0
  40. package/src/player/components/timelineProviderStateBuilders.ts +20 -0
  41. package/src/player/components/useTimelineProviderState.tsx +19 -12
  42. package/src/player/lib/timelineElementHelpers.ts +20 -0
  43. package/src/player/store/timelineElement.ts +5 -0
  44. package/src/styles/theme.css +4 -0
  45. package/src/utils/audioMeterMath.test.ts +44 -0
  46. package/src/utils/audioMeterMath.ts +79 -0
  47. package/src/utils/audioMeterVisibility.ts +14 -0
  48. package/src/utils/studioHelpers.ts +21 -0
  49. package/src/utils/studioUiPreferences.test.ts +10 -0
  50. package/src/utils/studioUiPreferences.ts +5 -0
  51. package/src/utils/timelineAssetDrop.test.ts +32 -0
  52. package/src/utils/timelineAssetDrop.ts +6 -1
@@ -0,0 +1,22 @@
1
+ import { TimelineRuler as TimelineRulerImpl } from "./TimelineRuler";
2
+ import { useTimelineContext } from "./TimelineProvider";
3
+
4
+ export function TimelineRulerPart() {
5
+ const { state } = useTimelineContext();
6
+ const props = state.canvas;
7
+ return (
8
+ <TimelineRulerImpl
9
+ major={props.major}
10
+ minor={props.minor}
11
+ pps={props.pps}
12
+ trackContentWidth={props.trackContentWidth}
13
+ totalH={props.totalH}
14
+ effectiveDuration={props.effectiveDuration}
15
+ majorTickInterval={props.majorTickInterval}
16
+ theme={props.theme}
17
+ beatAnalysis={props.beatAnalysis}
18
+ contentOrigin={props.contentOrigin}
19
+ renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
20
+ />
21
+ );
22
+ }
@@ -5,6 +5,26 @@ import type {
5
5
  } from "./TimelineProvider";
6
6
  import type { ResizingClipState } from "./useTimelineClipDrag";
7
7
  import type { TimelineLaneBaseProps } from "./timelineLaneProps";
8
+ import type { DraggedClipState } from "./timelineClipDragTypes";
9
+ import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
10
+ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
11
+
12
+ export function resolveMultiDragPreview(
13
+ draggedClip: DraggedClipState | null,
14
+ selectedKeys: ReadonlySet<string>,
15
+ ): MultiDragPreviewInput | null {
16
+ // The dragged clip is a free ghost; selected companions follow the same
17
+ // clamped delta so the formation stays rigid at the lane boundary.
18
+ if (!draggedClip?.started) return null;
19
+ const draggedKey = getTimelineElementIdentity(draggedClip.element);
20
+ return {
21
+ dragStarted: true,
22
+ draggedKey,
23
+ draggedOriginStart: draggedClip.element.start,
24
+ draggedPreviewStart: draggedClip.previewStart,
25
+ selectedKeys,
26
+ };
27
+ }
8
28
 
9
29
  export function resolveResizingElementIds(
10
30
  resizingClip: ResizingClipState | null,
@@ -10,6 +10,7 @@ import { useTimelineClipDrag } from "./useTimelineClipDrag";
10
10
  import type { ClipContextMenuState, TimelineContextValue } from "./TimelineProvider";
11
11
  import {
12
12
  buildTimelineMeta,
13
+ resolveMultiDragPreview,
13
14
  resolveRenderClipContent,
14
15
  resolveResizingElementIds,
15
16
  shouldIgnoreTimelinePointerDown,
@@ -30,7 +31,7 @@ import {
30
31
  } from "./useTimelineTrackLayout";
31
32
  import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
32
33
  import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
33
- import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
34
+ import { TimelineRazorGuideOverlay, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
34
35
  import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
35
36
  import {
36
37
  getEffectiveTimelineDuration,
@@ -44,6 +45,8 @@ import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
44
45
  import { useTimelineActiveClips } from "./useTimelineActiveClips";
45
46
  import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
46
47
  import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
48
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
49
+ import { resolveSnapGuide } from "./timelineSnapping";
47
50
  export function useTimelineProviderState({
48
51
  onSeek,
49
52
  onDrillDown,
@@ -90,10 +93,10 @@ export function useTimelineProviderState({
90
93
  onSplitElement: onSplitElementOverride,
91
94
  });
92
95
  const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
96
+ const editContext = useTimelineEditContextOptional();
93
97
  const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
94
98
  useMusicBeatAnalysis();
95
- const rawElements = usePlayerStore((s) => s.elements);
96
- const timelineElements = rawElements;
99
+ const timelineElements = usePlayerStore((s) => s.elements);
97
100
  const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
98
101
  const duration = usePlayerStore((s) => s.duration);
99
102
  const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
@@ -108,7 +111,6 @@ export function useTimelineProviderState({
108
111
  );
109
112
  // The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
110
113
  const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
111
- const contentGutter = labelMode ? GUTTER : 0;
112
114
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
113
115
  const currentTime = usePlayerStore((s) => s.currentTime);
114
116
  const beatDragging = usePlayerStore((s) => s.beatDragging);
@@ -130,8 +132,8 @@ export function useTimelineProviderState({
130
132
  }, []);
131
133
  const lastScrollLeftRef = useRef(0);
132
134
  const effectiveDuration = useMemo(
133
- () => getEffectiveTimelineDuration(duration, rawElements),
134
- [duration, rawElements],
135
+ () => getEffectiveTimelineDuration(duration, timelineElements),
136
+ [duration, timelineElements],
135
137
  );
136
138
  const keyframeCache = usePlayerStore((s) => s.keyframeCache);
137
139
  useAutoExpandKeyframedClips(gsapAnimations);
@@ -406,7 +408,6 @@ export function useTimelineProviderState({
406
408
  handlePointerCancel,
407
409
  } = overlaysProps;
408
410
  const { rangeSelection, setRangeSelection } = overlays;
409
-
410
411
  const laneGapStrips = useTimelineGapHighlights({
411
412
  gapHighlight,
412
413
  tracks,
@@ -416,25 +417,24 @@ export function useTimelineProviderState({
416
417
  dragActive: draggedClip?.started === true || resizingClip != null,
417
418
  displayDuration,
418
419
  });
419
-
420
420
  const { major, minor, majorTickInterval } = useTimelineTicks(
421
421
  displayDuration,
422
422
  pps,
423
423
  timeDisplayMode,
424
424
  timelineFocus.rowVirtualizationActive ? renderTimeRange : undefined,
425
425
  );
426
-
427
426
  const getPreviewElement = useCallback(
428
427
  (element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
429
428
  [resizingClip],
430
429
  );
431
-
430
+ const draggedElement = draggedClip?.element ?? null;
431
+ const multiDragPreview = resolveMultiDragPreview(draggedClip, selectedElementIds);
432
432
  const canvasProps = {
433
433
  major,
434
434
  minor,
435
435
  pps,
436
436
  contentOrigin,
437
- contentGutter,
437
+ contentGutter: labelMode ? GUTTER : 0,
438
438
  trackContentWidth: displayContentWidth,
439
439
  totalH: displayLayout.totalH,
440
440
  effectiveDuration,
@@ -501,6 +501,13 @@ export function useTimelineProviderState({
501
501
  onResizeElement,
502
502
  onMoveElement,
503
503
  beatDragging,
504
+ draggedElement,
505
+ snapGuide: resolveSnapGuide(draggedClip, resizingClip),
506
+ multiDragPreview,
507
+ onToggleTrackHidden: editContext.onToggleTrackHidden,
508
+ onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe,
509
+ onRazorSplit: editContext.onRazorSplit,
510
+ onRazorSplitAll: editContext.onRazorSplitAll,
504
511
  };
505
512
  const timelineRenderClipContent = resolveRenderClipContent(
506
513
  timelineFocus.rowVirtualizationActive,
@@ -559,7 +566,7 @@ export function useTimelineProviderState({
559
566
  labelColumnWidth: LABEL_COL_W,
560
567
  razorGuide:
561
568
  activeTool === "razor" && razorGuideX !== null ? (
562
- <TimelineRazorGuide x={razorGuideX} />
569
+ <TimelineRazorGuideOverlay x={razorGuideX} />
563
570
  ) : null,
564
571
  });
565
572
  const contextValue: TimelineContextValue = {
@@ -12,6 +12,7 @@ import type { ClipManifestClip } from "./playbackTypes";
12
12
  import { isFinitePositive } from "./playbackAdapter";
13
13
  import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
14
14
  import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
15
+ import { readElementFades } from "@hyperframes/core/audio-fade";
15
16
 
16
17
  // ---------------------------------------------------------------------------
17
18
  // Layer-reveal lift transparency
@@ -173,8 +174,27 @@ function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element):
173
174
  }
174
175
  }
175
176
 
177
+ /** Sets or clears an optional field, so a re-parse after the attribute is removed drops it. */
178
+ function setOptional<K extends keyof TimelineElement>(
179
+ entry: TimelineElement,
180
+ key: K,
181
+ value: TimelineElement[K] | undefined,
182
+ ): void {
183
+ if (value === undefined) delete entry[key];
184
+ else entry[key] = value;
185
+ }
186
+
187
+ /** `data-has-audio` and the clip-edge fades: what the mixer hears and how it enters and leaves. */
188
+ function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): void {
189
+ setOptional(entry, "hasAudio", el.getAttribute("data-has-audio") === "true" ? true : undefined);
190
+ const fades = readElementFades(el);
191
+ setOptional(entry, "fadeIn", fades.fadeIn > 0 ? fades.fadeIn : undefined);
192
+ setOptional(entry, "fadeOut", fades.fadeOut > 0 ? fades.fadeOut : undefined);
193
+ }
194
+
176
195
  export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
177
196
  applyPlaybackMetadataFromElement(entry, el);
197
+ applyAudioMetadataFromElement(entry, el);
178
198
 
179
199
  const mediaEl = resolveMediaElement(el);
180
200
  if (!mediaEl) return;
@@ -54,6 +54,11 @@ export interface TimelineElement {
54
54
  playbackRate?: number;
55
55
  sourceDuration?: number;
56
56
  volume?: number;
57
+ /** `data-has-audio="true"` on a video: its sound is mixed, so it takes audio affordances (fades). */
58
+ hasAudio?: boolean;
59
+ /** Clip-edge fades from `data-fade-in` / `data-fade-out`, seconds; absent means none. */
60
+ fadeIn?: number;
61
+ fadeOut?: number;
57
62
  /** Verbatim `data-fx-chain` / `data-automation`; see automationLaneData. */
58
63
  fxChain?: string;
59
64
  automation?: string;
@@ -69,6 +69,10 @@
69
69
  --timeline-clip-border-hover: rgba(60, 230, 172, 0.75);
70
70
  --timeline-clip-border-active: rgba(60, 230, 172, 0.55);
71
71
  --timeline-handle: rgba(255, 255, 255, 0.55);
72
+ /* Audio clip fades: the shaded wedge over the faded gain, and the corner dots. */
73
+ --timeline-fade-shade: rgba(0, 0, 0, 0.38);
74
+ --timeline-fade-dot-active: rgba(255, 255, 255, 1);
75
+ --timeline-fade-dot-ring: rgba(0, 0, 0, 0.55);
72
76
  --timeline-resize-seam: rgba(255, 255, 255, 0.12);
73
77
  --timeline-resize-active: rgba(255, 255, 255, 0.24);
74
78
  --timeline-track-clip-fill: rgba(255, 255, 255, 0.055);
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ levelToFraction,
4
+ markFraction,
5
+ SILENT_CHANNEL,
6
+ stepChannel,
7
+ stepPair,
8
+ } from "./audioMeterMath";
9
+
10
+ describe("levelToFraction", () => {
11
+ it("puts each dB mark on its equally spaced stop", () => {
12
+ [1, 0.8, 0.6, 0.4, 0.2].forEach((f, i) =>
13
+ expect(markFraction([0, -3, -6, -12, -24][i]!)).toBeCloseTo(f, 6),
14
+ );
15
+ });
16
+ it("clamps silence and overs", () => {
17
+ expect(levelToFraction(0)).toBe(0);
18
+ expect(levelToFraction(4)).toBe(1);
19
+ });
20
+ it("is monotonic between marks", () => {
21
+ expect(levelToFraction(0.5)).toBeGreaterThan(markFraction(-12));
22
+ expect(levelToFraction(0.5)).toBeLessThan(markFraction(-3));
23
+ });
24
+ });
25
+
26
+ describe("stepChannel", () => {
27
+ it("attacks instantly, holds the peak, then falls", () => {
28
+ let ch = stepChannel(SILENT_CHANNEL, 1, 0, 16);
29
+ expect(ch.level).toBe(1);
30
+ ch = stepChannel(ch, 0, 500, 16);
31
+ expect(ch.peak).toBe(1);
32
+ expect(ch.level).toBeLessThan(1);
33
+ ch = stepChannel(ch, 0, 2000, 500);
34
+ expect(ch.peak).toBeLessThan(1);
35
+ });
36
+ });
37
+
38
+ describe("stepPair", () => {
39
+ it("returns the same pair while silent at rest, and moves on sound", () => {
40
+ const rest = stepPair(undefined, undefined, 0, 16);
41
+ expect(stepPair(rest, { l: 0, r: 0 }, 16, 16)).toBe(rest);
42
+ expect(stepPair(rest, { l: 1, r: 0 }, 32, 16)).not.toBe(rest);
43
+ });
44
+ });
@@ -0,0 +1,79 @@
1
+ import { useMemo } from "react";
2
+ import { audioDbToGain, audioGainToDb } from "@hyperframes/core/audio-gain";
3
+ import { usePlayerStore, type TimelineElement } from "../player";
4
+ import { isAudioTimelineElement } from "./timelineInspector";
5
+
6
+ /** dB stops of the meter scale, top to bottom; equal spacing between stops. */
7
+ export const METER_DB_MARKS = [0, -3, -6, -12, -24] as const;
8
+ const FLOOR_DB = -60;
9
+ const STOPS = [...METER_DB_MARKS, FLOOR_DB];
10
+
11
+ const FALL_PER_SECOND = 0.9;
12
+ const HOLD_MS = 1200;
13
+
14
+ /** Linear peak (0..1) to a 0..1 bar fraction along the piecewise-linear dB scale. */
15
+ export function levelToFraction(linear: number): number {
16
+ const db = linear > 0 ? audioGainToDb(linear) : FLOOR_DB;
17
+ if (db >= 0) return 1;
18
+ if (db <= FLOOR_DB) return 0;
19
+ const j = STOPS.findIndex((stop) => db >= stop);
20
+ const hi = STOPS[j - 1] as number;
21
+ const lo = STOPS[j] as number;
22
+ return (STOPS.length - 1 - j + (db - lo) / (hi - lo)) / (STOPS.length - 1);
23
+ }
24
+
25
+ /** Fraction of the bar height at which a dB mark sits, 0 at the bottom. */
26
+ export function markFraction(db: number): number {
27
+ return levelToFraction(audioDbToGain(db));
28
+ }
29
+
30
+ export interface MeterChannel {
31
+ level: number;
32
+ peak: number;
33
+ peakAt: number;
34
+ }
35
+
36
+ export const SILENT_CHANNEL: MeterChannel = { level: 0, peak: 0, peakAt: 0 };
37
+
38
+ /** Instant attack, steady fall; the peak holds for HOLD_MS, then falls with the bar. */
39
+ export function stepChannel(
40
+ prev: MeterChannel,
41
+ linear: number,
42
+ now: number,
43
+ dtMs: number,
44
+ ): MeterChannel {
45
+ const fall = (dtMs / 1000) * FALL_PER_SECOND;
46
+ const target = levelToFraction(linear);
47
+ const level = Math.max(target, prev.level - fall);
48
+ if (target >= prev.peak) return { level, peak: target, peakAt: now };
49
+ if (now - prev.peakAt < HOLD_MS) return { level, peak: prev.peak, peakAt: prev.peakAt };
50
+ return { level, peak: Math.max(target, prev.peak - fall), peakAt: prev.peakAt };
51
+ }
52
+
53
+ export type MeterPair = [MeterChannel, MeterChannel];
54
+
55
+ const atRest = (ch: MeterChannel): boolean => ch.level === 0 && ch.peak === 0;
56
+
57
+ /** Steps both channels; returns `prev` itself while silent and at rest, so callers can skip the paint. */
58
+ export function stepPair(
59
+ prev: MeterPair | undefined,
60
+ raw: { l: number; r: number } | undefined,
61
+ now: number,
62
+ dtMs: number,
63
+ ): MeterPair {
64
+ const [l, r] = prev ?? [SILENT_CHANNEL, SILENT_CHANNEL];
65
+ const silent = !raw?.l && !raw?.r;
66
+ if (prev && silent && atRest(l) && atRest(r)) return prev;
67
+ return [stepChannel(l, raw?.l ?? 0, now, dtMs), stepChannel(r, raw?.r ?? 0, now, dtMs)];
68
+ }
69
+
70
+ /** The meters appear only for a project with at least one audio clip or group. */
71
+ function hasProjectAudio(elements: readonly TimelineElement[]): boolean {
72
+ return elements.some((el) => isAudioTimelineElement(el) || el.audioGroup !== undefined);
73
+ }
74
+
75
+ /** Scans once per `elements` change, not on every store update (the playhead ticks each frame). */
76
+ export function useProjectHasAudio(): boolean {
77
+ const elements = usePlayerStore((s) => s.elements);
78
+ return useMemo(() => hasProjectAudio(elements), [elements]);
79
+ }
@@ -0,0 +1,14 @@
1
+ import { create } from "zustand";
2
+ import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences";
3
+
4
+ /** Whether the audio meter strip is shown; persisted, on unless the user hid it. */
5
+ export const useAudioMetersVisible = create<{
6
+ visible: boolean;
7
+ setVisible: (visible: boolean) => void;
8
+ }>((set) => ({
9
+ visible: readStudioUiPreferences().audioMetersVisible ?? true,
10
+ setVisible: (visible) => {
11
+ writeStudioUiPreferences({ audioMetersVisible: visible });
12
+ set({ visible });
13
+ },
14
+ }));
@@ -377,6 +377,27 @@ export async function resolveDroppedAssetDuration(
377
377
  return duration;
378
378
  }
379
379
 
380
+ export function mediaMetadataUrl(projectId: string, assetPath: string): string {
381
+ return `/api/projects/${encodeURIComponent(projectId)}/media/metadata?path=${encodeURIComponent(assetPath)}`;
382
+ }
383
+
384
+ /** Dropped video audio stream from the metadata endpoint. Failure answers false so the drop still lands muted. */
385
+ export async function resolveDroppedAssetHasAudio(
386
+ projectId: string,
387
+ assetPath: string,
388
+ kind: TimelineAssetKind,
389
+ ): Promise<boolean> {
390
+ if (kind !== "video") return false;
391
+ try {
392
+ const response = await fetch(mediaMetadataUrl(projectId, assetPath));
393
+ if (!response.ok) return false;
394
+ const data = (await response.json()) as { metadata?: { hasAudio?: boolean } } | null;
395
+ return data?.metadata?.hasAudio === true;
396
+ } catch {
397
+ return false;
398
+ }
399
+ }
400
+
380
401
  export async function resolveDroppedAssetDimensions(
381
402
  projectId: string,
382
403
  assetPath: string,
@@ -161,3 +161,13 @@ describe("timeline zoom pin persistence", () => {
161
161
  expect(prefs.timelineManualZoomPercent).toBeUndefined();
162
162
  });
163
163
  });
164
+
165
+ describe("audioMetersVisible preference", () => {
166
+ it("round-trips and ignores a non-boolean", () => {
167
+ const storage = createStorage();
168
+ writeStudioUiPreferences({ audioMetersVisible: false }, storage);
169
+ expect(readStudioUiPreferences(storage).audioMetersVisible).toBe(false);
170
+ storage.setItem("hf-studio-ui-preferences", JSON.stringify({ audioMetersVisible: "no" }));
171
+ expect(readStudioUiPreferences(storage).audioMetersVisible).toBeUndefined();
172
+ });
173
+ });
@@ -25,6 +25,8 @@ export interface StudioUiPreferences {
25
25
  snapToGrid?: boolean;
26
26
  /** Timeline magnet: snap clip drags/trims/drops to playhead, clip edges, and beats. */
27
27
  timelineSnapEnabled?: boolean;
28
+ /** Audio level meters at the timeline's right edge; shown unless hidden here. */
29
+ audioMetersVisible?: boolean;
28
30
  /** Keeps the main track gapless: deleting a clip closes the gap. Distinct
29
31
  * from `timelineSnapEnabled` ("Magnet", drag/trim snapping). */
30
32
  rippleEditEnabled?: boolean;
@@ -140,6 +142,9 @@ function readStorage(storage: Storage | null, key: string): StudioUiPreferences
140
142
  if (typeof parsed.timelineSnapEnabled === "boolean") {
141
143
  preferences.timelineSnapEnabled = parsed.timelineSnapEnabled;
142
144
  }
145
+ if (typeof parsed.audioMetersVisible === "boolean") {
146
+ preferences.audioMetersVisible = parsed.audioMetersVisible;
147
+ }
143
148
  if (typeof parsed.rippleEditEnabled === "boolean") {
144
149
  preferences.rippleEditEnabled = parsed.rippleEditEnabled;
145
150
  }
@@ -198,6 +198,38 @@ describe("insertTimelineAssetIntoSource", () => {
198
198
  });
199
199
  });
200
200
 
201
+ describe("buildTimelineAssetInsertHtml — video audio", () => {
202
+ const base = {
203
+ id: "clip_asset",
204
+ hfId: "hf-vid-1",
205
+ assetPath: "assets/clip.mp4",
206
+ kind: "video" as const,
207
+ start: 0,
208
+ duration: 8,
209
+ track: 1,
210
+ zIndex: 2,
211
+ };
212
+
213
+ it("inserts a video muted when nothing says it carries audio", () => {
214
+ const html = buildTimelineAssetInsertHtml(base);
215
+ expect(html).toContain(" muted ");
216
+ expect(html).not.toContain("data-has-audio");
217
+ });
218
+
219
+ it("inserts a video with an audio stream audible: data-has-audio and no muted", () => {
220
+ const html = buildTimelineAssetInsertHtml({ ...base, hasAudio: true });
221
+ expect(html).toContain('data-has-audio="true"');
222
+ expect(html).not.toContain("muted");
223
+ expect(html).toContain("playsinline");
224
+ });
225
+
226
+ it("keeps a video without an audio stream muted", () => {
227
+ const html = buildTimelineAssetInsertHtml({ ...base, hasAudio: false });
228
+ expect(html).toContain(" muted ");
229
+ expect(html).not.toContain("data-has-audio");
230
+ });
231
+ });
232
+
201
233
  describe("buildTimelineAssetInsertHtml markup quality", () => {
202
234
  const base = {
203
235
  id: "clip_1",
@@ -116,6 +116,8 @@ export function buildTimelineAssetInsertHtml(input: {
116
116
  track: number;
117
117
  zIndex: number;
118
118
  geometry?: { left: number; top: number; width: number; height: number };
119
+ /** Video only: true inserts `data-has-audio="true"` with no `muted`. Unknown or false stays muted. */
120
+ hasAudio?: boolean;
119
121
  }): string {
120
122
  const sharedAttrs = `id="${input.id}" data-hf-id="${input.hfId}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
121
123
  const geometry = input.geometry ?? { left: 0, top: 0, width: 640, height: 360 };
@@ -126,7 +128,10 @@ export function buildTimelineAssetInsertHtml(input: {
126
128
  }
127
129
 
128
130
  if (input.kind === "video") {
129
- return `<video ${sharedAttrs} muted playsinline style="${visualStyles}"></video>`;
131
+ // `muted` and `data-has-audio="true"` are mutually exclusive by the lint
132
+ // contract (video_has_audio_but_muted): an audible drop takes the latter.
133
+ const audio = input.hasAudio ? 'data-has-audio="true"' : "muted";
134
+ return `<video ${sharedAttrs} ${audio} playsinline style="${visualStyles}"></video>`;
130
135
  }
131
136
 
132
137
  return `<audio ${sharedAttrs} data-volume="1" style="z-index: ${input.zIndex}"></audio>`;