@hyperframes/studio 0.6.27 → 0.6.29

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.
@@ -4,7 +4,6 @@ import {
4
4
  buildPromptCopyText,
5
5
  buildTimelineElementAgentPrompt,
6
6
  buildTimelineAgentPrompt,
7
- canOffsetTrimClipStart,
8
7
  getTimelineEditCapabilities,
9
8
  hasPatchableTimelineTarget,
10
9
  resolveBlockedTimelineEditIntent,
@@ -158,42 +157,6 @@ describe("resolveTimelineMove", () => {
158
157
  });
159
158
  });
160
159
 
161
- describe("canOffsetTrimClipStart", () => {
162
- it("allows front trim for clips that carry playback offset metadata", () => {
163
- expect(
164
- canOffsetTrimClipStart({
165
- tag: "div",
166
- playbackStartAttr: "media-start",
167
- }),
168
- ).toBe(true);
169
- });
170
-
171
- it("allows front trim for media clips with source duration metadata", () => {
172
- expect(
173
- canOffsetTrimClipStart({
174
- tag: "video",
175
- sourceDuration: 12,
176
- }),
177
- ).toBe(true);
178
- });
179
-
180
- it("allows front trim for plain audio clips even before media-start exists", () => {
181
- expect(
182
- canOffsetTrimClipStart({
183
- tag: "audio",
184
- }),
185
- ).toBe(true);
186
- });
187
-
188
- it("blocks front trim for generic motion clips", () => {
189
- expect(
190
- canOffsetTrimClipStart({
191
- tag: "section",
192
- }),
193
- ).toBe(false);
194
- });
195
- });
196
-
197
160
  describe("hasPatchableTimelineTarget", () => {
198
161
  it("returns true when the clip has a DOM id", () => {
199
162
  expect(hasPatchableTimelineTarget({ domId: "hero-card" })).toBe(true);
@@ -224,7 +187,7 @@ describe("getTimelineEditCapabilities", () => {
224
187
  });
225
188
  });
226
189
 
227
- it("allows moving generic motion clips while keeping trims blocked", () => {
190
+ it("allows full editing of generic motion clips with authored timing", () => {
228
191
  expect(
229
192
  getTimelineEditCapabilities({
230
193
  tag: "section",
@@ -233,8 +196,8 @@ describe("getTimelineEditCapabilities", () => {
233
196
  }),
234
197
  ).toEqual({
235
198
  canMove: true,
236
- canTrimStart: false,
237
- canTrimEnd: false,
199
+ canTrimStart: true,
200
+ canTrimEnd: true,
238
201
  });
239
202
  });
240
203
 
@@ -285,7 +248,7 @@ describe("getTimelineEditCapabilities", () => {
285
248
  });
286
249
  });
287
250
 
288
- it("allows move and end trim for patchable composition hosts", () => {
251
+ it("allows full editing for patchable composition hosts", () => {
289
252
  expect(
290
253
  getTimelineEditCapabilities({
291
254
  tag: "div",
@@ -295,7 +258,38 @@ describe("getTimelineEditCapabilities", () => {
295
258
  }),
296
259
  ).toEqual({
297
260
  canMove: true,
261
+ canTrimStart: true,
262
+ canTrimEnd: true,
263
+ });
264
+ });
265
+
266
+ it("locks all timeline edits for clips with data-timeline-locked", () => {
267
+ expect(
268
+ getTimelineEditCapabilities({
269
+ tag: "div",
270
+ duration: 8,
271
+ selector: '[data-composition-id="caption-highlight"]',
272
+ compositionSrc: "compositions/components/caption-highlight.html",
273
+ timelineLocked: true,
274
+ }),
275
+ ).toEqual({
276
+ canMove: false,
298
277
  canTrimStart: false,
278
+ canTrimEnd: false,
279
+ });
280
+ });
281
+
282
+ it("allows full editing of explicitly authored generic elements", () => {
283
+ expect(
284
+ getTimelineEditCapabilities({
285
+ tag: "div",
286
+ duration: 4,
287
+ selector: "#hero-card",
288
+ timingSource: "authored",
289
+ }),
290
+ ).toEqual({
291
+ canMove: true,
292
+ canTrimStart: true,
299
293
  canTrimEnd: true,
300
294
  });
301
295
  });
@@ -576,6 +570,40 @@ describe("resolveTimelineResize", () => {
576
570
  ),
577
571
  ).toEqual({ start: 0.8, duration: 3.2, playbackStart: 0 });
578
572
  });
573
+
574
+ it("trims generic element start without media offset", () => {
575
+ expect(
576
+ resolveTimelineResize(
577
+ {
578
+ start: 2,
579
+ duration: 4,
580
+ originClientX: 100,
581
+ pixelsPerSecond: 100,
582
+ minStart: 0,
583
+ maxEnd: 10,
584
+ },
585
+ "start",
586
+ 200,
587
+ ),
588
+ ).toEqual({ start: 3, duration: 3, playbackStart: undefined });
589
+ });
590
+
591
+ it("extends generic element start leftward to time zero", () => {
592
+ expect(
593
+ resolveTimelineResize(
594
+ {
595
+ start: 1,
596
+ duration: 3,
597
+ originClientX: 100,
598
+ pixelsPerSecond: 100,
599
+ minStart: 0,
600
+ maxEnd: 10,
601
+ },
602
+ "start",
603
+ -200,
604
+ ),
605
+ ).toEqual({ start: 0, duration: 4, playbackStart: undefined });
606
+ });
579
607
  });
580
608
 
581
609
  describe("buildPromptCopyText", () => {
@@ -201,18 +201,6 @@ export function hasPatchableTimelineTarget(input: { domId?: string; selector?: s
201
201
  return Boolean(input.domId || input.selector);
202
202
  }
203
203
 
204
- export function canOffsetTrimClipStart(input: {
205
- tag: string;
206
- playbackStart?: number;
207
- playbackStartAttr?: "media-start" | "playback-start";
208
- sourceDuration?: number;
209
- }): boolean {
210
- if (input.playbackStartAttr != null) return true;
211
- if (input.playbackStart != null) return true;
212
- const normalizedTag = input.tag.toLowerCase();
213
- return ["video", "audio"].includes(normalizedTag);
214
- }
215
-
216
204
  export function getTimelineEditCapabilities(input: {
217
205
  tag: string;
218
206
  duration: number;
@@ -223,8 +211,9 @@ export function getTimelineEditCapabilities(input: {
223
211
  playbackStartAttr?: "media-start" | "playback-start";
224
212
  sourceDuration?: number;
225
213
  timingSource?: "authored" | "implicit";
214
+ timelineLocked?: boolean;
226
215
  }): TimelineEditCapabilities {
227
- if (input.timingSource === "implicit") {
216
+ if (input.timingSource === "implicit" || input.timelineLocked) {
228
217
  return {
229
218
  canMove: false,
230
219
  canTrimStart: false,
@@ -237,8 +226,8 @@ export function getTimelineEditCapabilities(input: {
237
226
  const hasDeterministicWindow = isDeterministicTimelineWindow(input);
238
227
  return {
239
228
  canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
240
- canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow,
241
- canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input),
229
+ canTrimEnd: canPatch && hasFiniteDuration,
230
+ canTrimStart: canPatch && hasFiniteDuration,
242
231
  };
243
232
  }
244
233
 
@@ -7,7 +7,7 @@ import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
7
7
  // Re-export public API consumed by tests and external modules.
8
8
  // All of these were previously defined in this file; they now live in focused
9
9
  // sub-modules but are re-exported here so existing import sites don't change.
10
- export type { PlaybackAdapter, ClipManifestClip } from "../lib/playbackTypes";
10
+ export type { ClipManifestClip } from "../lib/playbackTypes";
11
11
  export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
12
12
  export {
13
13
  getTimelineElementSelector,
@@ -42,6 +42,7 @@ import {
42
42
  setPreviewPlaybackRate,
43
43
  shouldMutePreviewAudio,
44
44
  } from "../lib/timelineIframeHelpers";
45
+ import { probeMediaUrl, getCachedProbe } from "../lib/mediaProbe";
45
46
 
46
47
  // ---------------------------------------------------------------------------
47
48
  // Hook
@@ -106,6 +107,32 @@ export function useTimelinePlayer() {
106
107
  if (!state.timelineReady) {
107
108
  setTimelineReady(true);
108
109
  }
110
+
111
+ // Asynchronously enrich media elements missing sourceDuration via mediabunny.
112
+ // The probe reads file headers only — no full decode — so this is cheap.
113
+ const needsProbe = mergedElements.filter(
114
+ (el) =>
115
+ el.src &&
116
+ el.sourceDuration == null &&
117
+ ["video", "audio"].includes(el.tag.toLowerCase()) &&
118
+ !getCachedProbe(el.src),
119
+ );
120
+ if (needsProbe.length > 0) {
121
+ void Promise.allSettled(
122
+ needsProbe.map(async (el) => {
123
+ const result = await probeMediaUrl(el.src!);
124
+ if (!result) return;
125
+ const key = el.key ?? el.id;
126
+ usePlayerStore.setState((state) => {
127
+ const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
128
+ if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
129
+ const patched = state.elements.slice();
130
+ patched[idx] = { ...state.elements[idx], sourceDuration: result.duration };
131
+ return { elements: patched };
132
+ });
133
+ }),
134
+ );
135
+ }
109
136
  },
110
137
  [setElements, setTimelineReady, setDuration],
111
138
  );
@@ -0,0 +1,68 @@
1
+ import { Input, UrlSource, ALL_FORMATS } from "mediabunny";
2
+
3
+ export interface MediaProbeResult {
4
+ duration: number;
5
+ width?: number;
6
+ height?: number;
7
+ hasVideo: boolean;
8
+ hasAudio: boolean;
9
+ }
10
+
11
+ const cache = new Map<string, MediaProbeResult>();
12
+ const inflight = new Map<string, Promise<MediaProbeResult | null>>();
13
+
14
+ function normalizeUrl(url: string): string {
15
+ try {
16
+ return new URL(url, window.location.href).href;
17
+ } catch {
18
+ return url;
19
+ }
20
+ }
21
+
22
+ async function probeOne(url: string): Promise<MediaProbeResult | null> {
23
+ const input = new Input({
24
+ source: new UrlSource(url),
25
+ formats: ALL_FORMATS,
26
+ });
27
+ try {
28
+ const duration = await input.getDurationFromMetadata();
29
+ if (duration == null || !Number.isFinite(duration) || duration <= 0) return null;
30
+
31
+ const videoTrack = await input.getPrimaryVideoTrack();
32
+ const audioTracks = await input.getAudioTracks();
33
+
34
+ const result: MediaProbeResult = {
35
+ duration,
36
+ width: videoTrack?.displayWidth,
37
+ height: videoTrack?.displayHeight,
38
+ hasVideo: videoTrack != null,
39
+ hasAudio: audioTracks.length > 0,
40
+ };
41
+ return result;
42
+ } catch {
43
+ return null;
44
+ } finally {
45
+ input.dispose();
46
+ }
47
+ }
48
+
49
+ export function getCachedProbe(url: string): MediaProbeResult | undefined {
50
+ return cache.get(normalizeUrl(url));
51
+ }
52
+
53
+ export async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
54
+ const key = normalizeUrl(url);
55
+ const cached = cache.get(key);
56
+ if (cached) return cached;
57
+
58
+ let pending = inflight.get(key);
59
+ if (pending) return pending;
60
+
61
+ pending = probeOne(key).then((result) => {
62
+ inflight.delete(key);
63
+ if (result) cache.set(key, result);
64
+ return result;
65
+ });
66
+ inflight.set(key, pending);
67
+ return pending;
68
+ }
@@ -280,6 +280,10 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
280
280
  applyMediaMetadataFromElement(entry, el);
281
281
  }
282
282
 
283
+ if (el.hasAttribute("data-timeline-locked")) {
284
+ entry.timelineLocked = true;
285
+ }
286
+
283
287
  // Sub-compositions
284
288
  const compSrc =
285
289
  el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
@@ -26,6 +26,8 @@ export interface TimelineElement {
26
26
  compositionSrc?: string;
27
27
  /** Whether this row came from authored clip timing or Studio's full-duration layer fallback. */
28
28
  timingSource?: "authored" | "implicit";
29
+ /** Set by data-timeline-locked on the host element — disables move and trim in Studio. */
30
+ timelineLocked?: boolean;
29
31
  }
30
32
 
31
33
  export type ZoomMode = "fit" | "manual";