@hyperframes/studio 0.8.49 → 0.8.50
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.
- package/dist/assets/{hyperframes-player-CqON2Rzo.js → hyperframes-player-CxxDxHTW.js} +1 -1
- package/dist/assets/{index-mIgSsKqs.js → index-DUAIWxkI.js} +1 -1
- package/dist/assets/{index-BGovDksE.js → index-Dt3qBI-9.js} +1 -1
- package/dist/assets/{index-Bgmx2h7W.js → index-n2ml4tUn.js} +218 -217
- package/dist/assets/index-pO6p0H27.css +1 -0
- package/dist/index.d.ts +12 -4
- package/dist/index.html +2 -2
- package/dist/index.js +5899 -5754
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +10 -8
- package/src/components/EditorShell.selectionSync.test.tsx +4 -0
- package/src/components/EditorShell.tsx +24 -0
- package/src/components/StudioOverlays.tsx +1 -5
- package/src/components/editor/PreviewGuides.test.tsx +88 -0
- package/src/components/editor/PreviewGuides.tsx +125 -0
- package/src/components/editor/SnapToolbar.test.tsx +21 -0
- package/src/components/editor/SnapToolbar.tsx +25 -1
- package/src/components/editor/domEditingAgentPrompt.ts +5 -0
- package/src/components/editor/previewGuidesStore.ts +26 -0
- package/src/components/nle/NLEPreview.test.ts +13 -0
- package/src/components/nle/NLEPreview.tsx +9 -4
- package/src/components/nle/PreviewPane.test.tsx +52 -0
- package/src/components/nle/PreviewPane.tsx +2 -0
- package/src/components/nle/TimelinePane.tsx +14 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
- package/src/hooks/appHotkeysDispatch.ts +11 -0
- package/src/hooks/timelineAudioGroupCreate.ts +2 -2
- package/src/hooks/timelineTrackVisibility.ts +8 -11
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +3 -0
- package/src/hooks/useAskAgentModal.ts +2 -0
- package/src/hooks/useClipboard.test.ts +234 -0
- package/src/hooks/useClipboard.ts +264 -70
- package/src/hooks/useGroupCommits.test.ts +0 -1
- package/src/hooks/useRazorSplit.history.test.tsx +0 -1
- package/src/hooks/useRazorSplit.test.ts +0 -2
- package/src/hooks/useRazorSplit.test.tsx +0 -1
- package/src/hooks/useStudioContextValue.ts +5 -19
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/player/components/ClipContextMenu.tsx +87 -7
- package/src/player/components/Timeline.tsx +14 -14
- package/src/player/components/TimelineCanvas.tsx +30 -0
- package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
- package/src/player/components/TimelineGestureOverlay.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +22 -1
- package/src/player/components/TimelineOverlays.tsx +12 -0
- package/src/player/components/TimelineTypes.ts +4 -0
- package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
- package/src/player/components/timelineClipChildren.test.ts +2 -2
- package/src/player/components/timelineClipChildren.tsx +2 -1
- package/src/player/components/timelineClipDragCommit.ts +2 -2
- package/src/player/components/timelineDragDrop.test.tsx +31 -0
- package/src/player/components/timelineDragDrop.ts +30 -23
- package/src/player/components/timelineEditCapabilities.ts +1 -2
- package/src/player/components/timelineEditing.test.ts +0 -16
- package/src/player/components/timelineGroupEditing.test.ts +1 -3
- package/src/player/components/timelineGroupEditing.ts +0 -1
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
- package/src/player/components/useTimelineClipContextMenu.ts +32 -0
- package/src/player/components/useTimelineTrackDerivations.ts +1 -1
- package/src/player/hooks/timelineDomBinding.test.ts +0 -3
- package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
- package/src/player/hooks/timelineSyncHydration.ts +37 -18
- package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
- package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
- package/src/player/hooks/useTimelineRowElements.ts +17 -0
- package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
- package/src/player/lib/describeClips.test.ts +95 -0
- package/src/player/lib/describeClips.ts +103 -0
- package/src/player/lib/timelineDOM.test.ts +0 -80
- package/src/player/lib/timelineDOM.ts +1 -61
- package/src/player/lib/timelineElementHelpers.ts +2 -53
- package/src/player/store/playerStore.ts +5 -0
- package/src/player/store/timelineElement.ts +1 -3
- package/src/shims/canvasBrowserStub.js +50 -0
- package/src/utils/clipboardPayload.test.ts +42 -2
- package/src/utils/clipboardPayload.ts +57 -32
- package/src/utils/previewSafeMargins.test.ts +34 -0
- package/src/utils/previewSafeMargins.ts +10 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/sdkCanvasComposition.test.ts +77 -0
- package/src/utils/studioUiPreferences.test.ts +43 -0
- package/src/utils/studioUiPreferences.ts +27 -6
- package/src/utils/timelineElementSplit.test.ts +1 -2
- package/src/utils/timelineElementSplit.ts +0 -1
- package/src/webmcp/tools/lookTools.test.ts +40 -0
- package/src/webmcp/tools/lookTools.ts +32 -1
- package/dist/assets/index-B9LfWNyM.css +0 -1
- package/src/components/StudioGlobalDragOverlay.tsx +0 -24
- package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
|
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
|
|
|
3
3
|
import {
|
|
4
4
|
createTimelineElementFromManifestClip,
|
|
5
5
|
parseTimelineFromDOM,
|
|
6
|
-
createImplicitTimelineLayersFromDOM,
|
|
7
6
|
mergeTimelineElementsPreservingDowngrades,
|
|
8
7
|
} from "./timelineDOM";
|
|
9
8
|
import { isTimelineIgnoredElement } from "./timelineElementHelpers";
|
|
@@ -302,19 +301,6 @@ describe("createTimelineElementFromManifestClip — source-scoped selector ident
|
|
|
302
301
|
// implicit-layer fallback happily gave it a track. Draggable and trimmable, and
|
|
303
302
|
// writing timing onto a bus means nothing.
|
|
304
303
|
describe("<hf-audio-group> is not a timeline layer", () => {
|
|
305
|
-
it("gets no implicit row of its own", () => {
|
|
306
|
-
const doc = makeDoc(`
|
|
307
|
-
<div data-composition-id="root">
|
|
308
|
-
<audio id="voice-1" data-start="0" data-duration="6" data-audio-group="voiceover"></audio>
|
|
309
|
-
<hf-audio-group id="voiceover" data-label="Voiceover"></hf-audio-group>
|
|
310
|
-
</div>
|
|
311
|
-
`);
|
|
312
|
-
|
|
313
|
-
const implicit = createImplicitTimelineLayersFromDOM(doc, 12, []);
|
|
314
|
-
|
|
315
|
-
expect(implicit.map((el) => el.domId)).not.toContain("voiceover");
|
|
316
|
-
});
|
|
317
|
-
|
|
318
304
|
it("is excluded by the shared ignore predicate", () => {
|
|
319
305
|
const doc = makeDoc(`<hf-audio-group id="vo"></hf-audio-group><div id="panel"></div>`);
|
|
320
306
|
expect(isTimelineIgnoredElement(doc.getElementById("vo") as Element)).toBe(true);
|
|
@@ -322,72 +308,6 @@ describe("<hf-audio-group> is not a timeline layer", () => {
|
|
|
322
308
|
});
|
|
323
309
|
});
|
|
324
310
|
|
|
325
|
-
describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
|
|
326
|
-
it("uses the runtime root paint scope for implicit siblings of manifest clips", () => {
|
|
327
|
-
const doc = makeDoc(`
|
|
328
|
-
<div data-composition-id="root">
|
|
329
|
-
<div id="timed" data-start="0" data-duration="5"></div>
|
|
330
|
-
<div id="implicit"></div>
|
|
331
|
-
</div>
|
|
332
|
-
`);
|
|
333
|
-
const timedHost = doc.getElementById("timed");
|
|
334
|
-
const timed = createTimelineElementFromManifestClip({
|
|
335
|
-
clip: {
|
|
336
|
-
id: "timed",
|
|
337
|
-
label: "Timed",
|
|
338
|
-
start: 0,
|
|
339
|
-
duration: 5,
|
|
340
|
-
track: 0,
|
|
341
|
-
stackingContextId: "css:root",
|
|
342
|
-
kind: "element",
|
|
343
|
-
tagName: "div",
|
|
344
|
-
compositionId: null,
|
|
345
|
-
parentCompositionId: null,
|
|
346
|
-
compositionSrc: null,
|
|
347
|
-
assetUrl: null,
|
|
348
|
-
},
|
|
349
|
-
fallbackIndex: 0,
|
|
350
|
-
doc,
|
|
351
|
-
hostEl: timedHost,
|
|
352
|
-
});
|
|
353
|
-
const implicit = createImplicitTimelineLayersFromDOM(doc, 5, [timed])[0];
|
|
354
|
-
|
|
355
|
-
expect(implicit?.stackingContextId).toBe("css:root");
|
|
356
|
-
expect(implicit?.stackingContextId).toBe(timed.stackingContextId);
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
it("harvests hfId from an implicit layer child that has data-hf-id", () => {
|
|
360
|
-
const doc = makeDoc(`
|
|
361
|
-
<div data-composition-id="root">
|
|
362
|
-
<div id="layer" class="clip" data-hf-id="hf-xyz789"></div>
|
|
363
|
-
</div>
|
|
364
|
-
`);
|
|
365
|
-
|
|
366
|
-
const layers = createImplicitTimelineLayersFromDOM(doc, 10);
|
|
367
|
-
const layer = layers.find((el) => el.domId === "layer");
|
|
368
|
-
|
|
369
|
-
expect(layer).toBeDefined();
|
|
370
|
-
expect(layer?.hfId).toBe("hf-xyz789");
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
it("ignores runtime-owned color grading canvases as implicit layers", () => {
|
|
374
|
-
const doc = makeDoc(`
|
|
375
|
-
<div data-composition-id="root" data-duration="5">
|
|
376
|
-
<img id="photo" class="clip" data-start="0" data-duration="5" />
|
|
377
|
-
<canvas
|
|
378
|
-
class="__hf_color_grading_canvas__"
|
|
379
|
-
data-hf-color-grading-canvas="true"
|
|
380
|
-
data-hyperframes-ignore
|
|
381
|
-
></canvas>
|
|
382
|
-
</div>
|
|
383
|
-
`);
|
|
384
|
-
|
|
385
|
-
const layers = createImplicitTimelineLayersFromDOM(doc, 5);
|
|
386
|
-
|
|
387
|
-
expect(layers).toEqual([]);
|
|
388
|
-
});
|
|
389
|
-
});
|
|
390
|
-
|
|
391
311
|
describe("mergeTimelineElementsPreservingDowngrades — genuine removal vs transient downgrade", () => {
|
|
392
312
|
it("drops a removed TOP-LEVEL element (undo of a split) instead of ghosting it", () => {
|
|
393
313
|
const current = [el("a"), el("a-split")]; // post-split store: original + clone
|
|
@@ -17,8 +17,6 @@ import {
|
|
|
17
17
|
resolveMediaElement,
|
|
18
18
|
applyMediaMetadataFromElement,
|
|
19
19
|
getTimelineElementDisplayLabel,
|
|
20
|
-
getImplicitTimelineLayerLabel,
|
|
21
|
-
isImplicitTimelineLayerCandidate,
|
|
22
20
|
getTimelineElementSelector,
|
|
23
21
|
getTimelineElementSourceFile,
|
|
24
22
|
getTimelineElementSelectorIndex,
|
|
@@ -203,63 +201,6 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
203
201
|
return entry;
|
|
204
202
|
}
|
|
205
203
|
|
|
206
|
-
export function createImplicitTimelineLayersFromDOM(
|
|
207
|
-
doc: Document,
|
|
208
|
-
rootDuration: number,
|
|
209
|
-
existingElements: readonly TimelineElement[] = [],
|
|
210
|
-
): TimelineElement[] {
|
|
211
|
-
if (!Number.isFinite(rootDuration) || rootDuration <= 0) return [];
|
|
212
|
-
const rootComp = doc.querySelector("[data-composition-id]");
|
|
213
|
-
if (!rootComp) return [];
|
|
214
|
-
|
|
215
|
-
const existingKeys = new Set(existingElements.map(getTimelineElementIdentity));
|
|
216
|
-
const maxTrack = existingElements.reduce(
|
|
217
|
-
(max, element) => Math.max(max, Number.isFinite(element.track) ? element.track : 0),
|
|
218
|
-
-1,
|
|
219
|
-
);
|
|
220
|
-
const layers: TimelineElement[] = [];
|
|
221
|
-
|
|
222
|
-
for (const child of Array.from(rootComp.children)) {
|
|
223
|
-
if (!isImplicitTimelineLayerCandidate(rootComp, child)) continue;
|
|
224
|
-
|
|
225
|
-
const selector = getTimelineElementSelector(child);
|
|
226
|
-
if (!selector) continue;
|
|
227
|
-
const selectorIndex = getTimelineElementSelectorIndex(doc, child, selector);
|
|
228
|
-
const sourceFile = getTimelineElementSourceFile(child);
|
|
229
|
-
const label = getImplicitTimelineLayerLabel(child);
|
|
230
|
-
const identity = buildTimelineElementIdentity({
|
|
231
|
-
preferredId: child.id || null,
|
|
232
|
-
label,
|
|
233
|
-
fallbackIndex: existingElements.length + layers.length,
|
|
234
|
-
domId: child.id || undefined,
|
|
235
|
-
selector,
|
|
236
|
-
selectorIndex,
|
|
237
|
-
sourceFile,
|
|
238
|
-
});
|
|
239
|
-
if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue;
|
|
240
|
-
|
|
241
|
-
layers.push({
|
|
242
|
-
domId: child.id || undefined,
|
|
243
|
-
hfId: child.getAttribute("data-hf-id") || undefined,
|
|
244
|
-
zIndex: readTimelineElementZIndex(child),
|
|
245
|
-
duration: rootDuration,
|
|
246
|
-
id: identity.id,
|
|
247
|
-
key: identity.key,
|
|
248
|
-
label,
|
|
249
|
-
selector,
|
|
250
|
-
selectorIndex,
|
|
251
|
-
sourceFile,
|
|
252
|
-
stackingContextId: resolveCssStackingContextId(child),
|
|
253
|
-
start: 0,
|
|
254
|
-
tag: child.tagName.toLowerCase(),
|
|
255
|
-
timingSource: "implicit",
|
|
256
|
-
track: maxTrack + 1 + layers.length,
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
return layers;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
204
|
/**
|
|
264
205
|
* Parse [data-start] elements from a Document into TimelineElement[].
|
|
265
206
|
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
|
|
@@ -330,7 +271,6 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
330
271
|
selectorIndex,
|
|
331
272
|
sourceFile,
|
|
332
273
|
stackingContextId: resolveCssStackingContextId(el),
|
|
333
|
-
timingSource: "authored",
|
|
334
274
|
zIndex: readTimelineElementZIndex(el),
|
|
335
275
|
};
|
|
336
276
|
|
|
@@ -399,7 +339,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
399
339
|
els.push(entry);
|
|
400
340
|
});
|
|
401
341
|
|
|
402
|
-
return
|
|
342
|
+
return els;
|
|
403
343
|
}
|
|
404
344
|
|
|
405
345
|
// ---------------------------------------------------------------------------
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Low-level helpers for building and identifying TimelineElement objects.
|
|
3
3
|
*
|
|
4
4
|
* Covers: duration reading, media-element metadata extraction, selector/key/
|
|
5
|
-
* identity builders, DOM node lookup
|
|
5
|
+
* identity builders, and DOM node lookup. These are
|
|
6
6
|
* intentionally dependency-free (no store, no hooks) so they can be used in
|
|
7
7
|
* both the React hook and test environments.
|
|
8
8
|
*/
|
|
@@ -82,13 +82,7 @@ function normalizePlaybackRate(raw: number): number {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
export function isTimelineIgnoredElement(el: Element): boolean {
|
|
85
|
-
// An `<hf-audio-group>` is a mixer bus,
|
|
86
|
-
// label, fader, mute and FX chain, has no timing of its own, and is drawn as
|
|
87
|
-
// a GROUP ROW by the group derivation. Left in, the implicit-layer fallback
|
|
88
|
-
// also gave it an ordinary full-duration track — so a grouped composition
|
|
89
|
-
// showed "Voiceover • 0.0s – 12.0s" as a phantom clip directly above the real
|
|
90
|
-
// group header. Harmless-looking, but that row is draggable and trimmable,
|
|
91
|
-
// and writing timing onto the bus is meaningless.
|
|
85
|
+
// An `<hf-audio-group>` is a mixer bus with no timing of its own, drawn as a group row, never a clip.
|
|
92
86
|
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return true;
|
|
93
87
|
return Boolean(
|
|
94
88
|
el.closest(
|
|
@@ -223,37 +217,6 @@ export function getTimelineElementDisplayLabel(input: {
|
|
|
223
217
|
return tag ? `${tag} clip` : "Timeline clip";
|
|
224
218
|
}
|
|
225
219
|
|
|
226
|
-
const IMPLICIT_TIMELINE_LAYER_SKIP_TAGS = new Set([
|
|
227
|
-
"base",
|
|
228
|
-
"link",
|
|
229
|
-
"meta",
|
|
230
|
-
"noscript",
|
|
231
|
-
"script",
|
|
232
|
-
"style",
|
|
233
|
-
"template",
|
|
234
|
-
]);
|
|
235
|
-
|
|
236
|
-
function humanizeTimelineIdentifier(value: string): string {
|
|
237
|
-
return value
|
|
238
|
-
.trim()
|
|
239
|
-
.replace(/[_-]+/g, " ")
|
|
240
|
-
.replace(/\s+/g, " ")
|
|
241
|
-
.replace(/\b\w/g, (match) => match.toUpperCase());
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
export function getImplicitTimelineLayerLabel(el: HTMLElement): string {
|
|
245
|
-
const explicitLabel =
|
|
246
|
-
el.getAttribute("data-timeline-label") ??
|
|
247
|
-
el.getAttribute("data-label") ??
|
|
248
|
-
el.getAttribute("aria-label");
|
|
249
|
-
if (explicitLabel?.trim()) return explicitLabel.trim();
|
|
250
|
-
if (el.id.trim()) return humanizeTimelineIdentifier(el.id);
|
|
251
|
-
const classes = el.className.split(/\s+/).filter(Boolean);
|
|
252
|
-
const className = classes.find((value) => value !== "clip") ?? classes[0];
|
|
253
|
-
if (className) return humanizeTimelineIdentifier(className);
|
|
254
|
-
return getTimelineElementDisplayLabel({ tag: el.tagName });
|
|
255
|
-
}
|
|
256
|
-
|
|
257
220
|
// ---------------------------------------------------------------------------
|
|
258
221
|
// Selector / identity / key builders
|
|
259
222
|
// ---------------------------------------------------------------------------
|
|
@@ -475,17 +438,3 @@ export function createTimelineDomNodeResolver(doc: Document) {
|
|
|
475
438
|
return node;
|
|
476
439
|
};
|
|
477
440
|
}
|
|
478
|
-
|
|
479
|
-
// ---------------------------------------------------------------------------
|
|
480
|
-
// Implicit layer detection
|
|
481
|
-
// ---------------------------------------------------------------------------
|
|
482
|
-
|
|
483
|
-
export function isImplicitTimelineLayerCandidate(root: Element, el: Element): el is HTMLElement {
|
|
484
|
-
if (!isHtmlElement(el)) return false;
|
|
485
|
-
if (isTimelineIgnoredElement(el)) return false;
|
|
486
|
-
if (el.parentElement !== root) return false;
|
|
487
|
-
const tagName = el.tagName.toLowerCase();
|
|
488
|
-
if (IMPLICIT_TIMELINE_LAYER_SKIP_TAGS.has(tagName)) return false;
|
|
489
|
-
if (el.hasAttribute("data-start") || el.hasAttribute("data-track-index")) return false;
|
|
490
|
-
return Boolean(getTimelineElementSelector(el));
|
|
491
|
-
}
|
|
@@ -200,6 +200,8 @@ interface PlayerState extends PlayerStoreSlices {
|
|
|
200
200
|
clipManifest: ClipManifestClip[] | null;
|
|
201
201
|
setClipManifest: (clips: ClipManifestClip[] | null) => void;
|
|
202
202
|
clipParentMap: Map<string, string>;
|
|
203
|
+
topLevelIds: ReadonlySet<string> | null;
|
|
204
|
+
setTopLevelIds: (ids: ReadonlySet<string> | null) => void;
|
|
203
205
|
setClipParentMap: (map: Map<string, string>) => void;
|
|
204
206
|
/**
|
|
205
207
|
* Sub-composition DOM descendants (groups + their children) that have no
|
|
@@ -288,6 +290,7 @@ export function createTimelineResetState() {
|
|
|
288
290
|
beatPersist: null,
|
|
289
291
|
clipManifest: null,
|
|
290
292
|
clipParentMap: new Map<string, string>(),
|
|
293
|
+
topLevelIds: null,
|
|
291
294
|
domClipChildren: [],
|
|
292
295
|
subCompositionHostState: new Map<string, SubCompositionHostState>(),
|
|
293
296
|
};
|
|
@@ -434,6 +437,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
434
437
|
setClipManifest: (clips) => set({ clipManifest: clips }),
|
|
435
438
|
clipParentMap: new Map(),
|
|
436
439
|
setClipParentMap: (map) => set({ clipParentMap: map }),
|
|
440
|
+
topLevelIds: null,
|
|
441
|
+
setTopLevelIds: (ids) => set({ topLevelIds: ids }),
|
|
437
442
|
domClipChildren: [],
|
|
438
443
|
setDomClipChildren: (children) => set({ domClipChildren: children }),
|
|
439
444
|
subCompositionHostState: new Map(),
|
|
@@ -59,8 +59,6 @@ export interface TimelineElement {
|
|
|
59
59
|
automation?: string;
|
|
60
60
|
/** Path from data-composition-src — identifies sub-composition elements */
|
|
61
61
|
compositionSrc?: string;
|
|
62
|
-
/** Whether this row came from authored clip timing or Studio's full-duration layer fallback. */
|
|
63
|
-
timingSource?: "authored" | "implicit";
|
|
64
62
|
/** Set by data-timeline-locked on the host element — disables move and trim in Studio. */
|
|
65
63
|
timelineLocked?: boolean;
|
|
66
64
|
/** Set by data-hidden on the host element — hides the clip in preview and render. */
|
|
@@ -79,7 +77,7 @@ export interface TimelineElement {
|
|
|
79
77
|
audioGroupFxChain?: string;
|
|
80
78
|
audioGroupAutomation?: string;
|
|
81
79
|
/**
|
|
82
|
-
* Set by
|
|
80
|
+
* Set by useTimelineRowElements on an inline-expanded sub-composition
|
|
83
81
|
* child: the absolute master-timeline start of the sub-comp host the child
|
|
84
82
|
* lives in. Presence marks the element as expanded; edits subtract it to get
|
|
85
83
|
* the child's local (sourceFile-relative) time. Works at any nesting depth.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser stub for the Node-only `canvas` package.
|
|
3
|
+
*
|
|
4
|
+
* linkedom's `HTMLCanvasElement` constructor calls `createCanvas(300, 150)`
|
|
5
|
+
* (300x150 being the HTML canvas default size). linkedom guards that import in
|
|
6
|
+
* a try/catch: the try requires the native `canvas` package, and the catch
|
|
7
|
+
* falls back to linkedom's own bundled canvas shim.
|
|
8
|
+
*
|
|
9
|
+
* That guard only works with a *runtime* require. Vite resolves the specifier
|
|
10
|
+
* statically, so the bundled form is a plain assignment that cannot throw, the
|
|
11
|
+
* catch never runs, and `createCanvas` lands `undefined`. Parsing any `<canvas>`
|
|
12
|
+
* tag then throws inside `openComposition`, Studio loses its SDK session for the
|
|
13
|
+
* whole composition, and every edit silently falls back to the server path — the
|
|
14
|
+
* resolver shadow does not run either, so it was invisible until
|
|
15
|
+
* `studio:sdk_session_unavailable` shipped in v0.8.47.
|
|
16
|
+
*
|
|
17
|
+
* Production telemetry over the 72h after the flip: 1065 of 1544 session-open
|
|
18
|
+
* failures were this crash, across a large share of 197 users. Canvas is common
|
|
19
|
+
* in compositions (Three.js, shaders, particle effects, charts).
|
|
20
|
+
*
|
|
21
|
+
* Aliasing `canvas` to this file makes the browser build resolve to a
|
|
22
|
+
* measurement-only canvas, which is all linkedom's static parse needs — nothing
|
|
23
|
+
* in the SDK rasterizes through it. It mirrors linkedom's own
|
|
24
|
+
* `commonjs/canvas-shim.cjs`, reimplemented here because linkedom's `exports`
|
|
25
|
+
* map does not expose that file.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
class BrowserCanvasStub {
|
|
29
|
+
constructor(width, height) {
|
|
30
|
+
this.width = width;
|
|
31
|
+
this.height = height;
|
|
32
|
+
}
|
|
33
|
+
/** linkedom only ever needs the element to exist; nothing draws through it. */
|
|
34
|
+
getContext() {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
toDataURL() {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createCanvas(width, height) {
|
|
43
|
+
return new BrowserCanvasStub(width, height);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// linkedom consumes this as a DEFAULT import — `import Canvas from
|
|
47
|
+
// '../../commonjs/canvas.cjs'` then `const {createCanvas} = Canvas` — so the
|
|
48
|
+
// default export is the load-bearing one; the named export above is interop
|
|
49
|
+
// belt-and-braces.
|
|
50
|
+
export default { createCanvas };
|
|
@@ -33,10 +33,41 @@ describe("deduplicateIds", () => {
|
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
describe("serializeClipboardPayload / deserializeClipboardPayload", () => {
|
|
36
|
-
it("round-trips a timeline
|
|
36
|
+
it("round-trips a single-clip timeline payload", () => {
|
|
37
37
|
const payload: ClipboardPayload = {
|
|
38
38
|
kind: "timeline-clip",
|
|
39
|
-
|
|
39
|
+
clips: [
|
|
40
|
+
{
|
|
41
|
+
html: '<img id="photo" src="a.png" data-start="1" data-duration="3" />',
|
|
42
|
+
start: 1,
|
|
43
|
+
duration: 3,
|
|
44
|
+
track: 0,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
sourceFile: "index.html",
|
|
48
|
+
};
|
|
49
|
+
const json = serializeClipboardPayload(payload);
|
|
50
|
+
const parsed = deserializeClipboardPayload(json);
|
|
51
|
+
expect(parsed).toEqual(payload);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("round-trips a group timeline-clip payload", () => {
|
|
55
|
+
const payload: ClipboardPayload = {
|
|
56
|
+
kind: "timeline-clip",
|
|
57
|
+
clips: [
|
|
58
|
+
{
|
|
59
|
+
html: '<img id="a" data-start="1" data-duration="0.5" />',
|
|
60
|
+
start: 1,
|
|
61
|
+
duration: 0.5,
|
|
62
|
+
track: 0,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
html: '<img id="b" data-start="2" data-duration="0.5" />',
|
|
66
|
+
start: 2,
|
|
67
|
+
duration: 0.5,
|
|
68
|
+
track: 1,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
40
71
|
sourceFile: "index.html",
|
|
41
72
|
};
|
|
42
73
|
const json = serializeClipboardPayload(payload);
|
|
@@ -59,4 +90,13 @@ describe("serializeClipboardPayload / deserializeClipboardPayload", () => {
|
|
|
59
90
|
expect(deserializeClipboardPayload("not json")).toBeNull();
|
|
60
91
|
expect(deserializeClipboardPayload('{"kind":"unknown"}')).toBeNull();
|
|
61
92
|
});
|
|
93
|
+
|
|
94
|
+
it("returns null for a timeline-clip payload with no clips", () => {
|
|
95
|
+
const json = serializeClipboardPayload({
|
|
96
|
+
kind: "timeline-clip",
|
|
97
|
+
clips: [],
|
|
98
|
+
sourceFile: "index.html",
|
|
99
|
+
});
|
|
100
|
+
expect(deserializeClipboardPayload(json)).toBeNull();
|
|
101
|
+
});
|
|
62
102
|
});
|
|
@@ -2,35 +2,35 @@ import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
|
|
|
2
2
|
|
|
3
3
|
const CLIPBOARD_MARKER = "hyperframes-clipboard:v1";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
/** One clip's copied markup plus the original placement needed to preserve
|
|
6
|
+
* relative offsets and tracks across a group paste. */
|
|
7
|
+
export interface TimelineClipboardClip {
|
|
7
8
|
html: string;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
start: number;
|
|
10
|
+
duration: number;
|
|
11
|
+
track: number;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
export type ClipboardPayload =
|
|
15
|
+
| { kind: "timeline-clip"; clips: TimelineClipboardClip[]; sourceFile: string }
|
|
16
|
+
| {
|
|
17
|
+
kind: "dom-element";
|
|
18
|
+
html: string;
|
|
19
|
+
sourceFile: string;
|
|
20
|
+
originSelector?: string;
|
|
21
|
+
originSelectorIndex?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type SerializedPayload = { _marker: string } & ClipboardPayload;
|
|
21
25
|
|
|
22
26
|
export function serializeClipboardPayload(payload: ClipboardPayload): string {
|
|
23
|
-
const data: SerializedPayload = {
|
|
24
|
-
_marker: CLIPBOARD_MARKER,
|
|
25
|
-
kind: payload.kind,
|
|
26
|
-
html: payload.html,
|
|
27
|
-
sourceFile: payload.sourceFile,
|
|
28
|
-
originSelector: payload.originSelector,
|
|
29
|
-
originSelectorIndex: payload.originSelectorIndex,
|
|
30
|
-
};
|
|
27
|
+
const data: SerializedPayload = { _marker: CLIPBOARD_MARKER, ...payload };
|
|
31
28
|
return JSON.stringify(data);
|
|
32
29
|
}
|
|
33
30
|
|
|
31
|
+
// Each branch validates one wire shape at the trust boundary; splitting further
|
|
32
|
+
// would fragment one parse into partial validators with no independent reuse.
|
|
33
|
+
// fallow-ignore-next-line complexity
|
|
34
34
|
export function deserializeClipboardPayload(json: string): ClipboardPayload | null {
|
|
35
35
|
let parsed: unknown;
|
|
36
36
|
try {
|
|
@@ -41,16 +41,35 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu
|
|
|
41
41
|
if (!parsed || typeof parsed !== "object") return null;
|
|
42
42
|
const obj = parsed as Record<string, unknown>;
|
|
43
43
|
if (obj._marker !== CLIPBOARD_MARKER) return null;
|
|
44
|
-
if (
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
44
|
+
if (typeof obj.sourceFile !== "string") return null;
|
|
45
|
+
if (obj.kind === "timeline-clip") {
|
|
46
|
+
if (!Array.isArray(obj.clips)) return null;
|
|
47
|
+
const clips = obj.clips.filter((c): c is TimelineClipboardClip => {
|
|
48
|
+
const clip = c as Partial<TimelineClipboardClip> | null;
|
|
49
|
+
return (
|
|
50
|
+
!!clip &&
|
|
51
|
+
typeof clip === "object" &&
|
|
52
|
+
typeof clip.html === "string" &&
|
|
53
|
+
typeof clip.start === "number" &&
|
|
54
|
+
typeof clip.duration === "number" &&
|
|
55
|
+
typeof clip.track === "number"
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
if (clips.length === 0) return null;
|
|
59
|
+
return { kind: "timeline-clip", clips, sourceFile: obj.sourceFile };
|
|
60
|
+
}
|
|
61
|
+
if (obj.kind === "dom-element") {
|
|
62
|
+
if (typeof obj.html !== "string") return null;
|
|
63
|
+
return {
|
|
64
|
+
kind: "dom-element",
|
|
65
|
+
html: obj.html,
|
|
66
|
+
sourceFile: obj.sourceFile,
|
|
67
|
+
originSelector: typeof obj.originSelector === "string" ? obj.originSelector : undefined,
|
|
68
|
+
originSelectorIndex:
|
|
69
|
+
typeof obj.originSelectorIndex === "number" ? obj.originSelectorIndex : undefined,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
54
73
|
}
|
|
55
74
|
|
|
56
75
|
/**
|
|
@@ -59,6 +78,7 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu
|
|
|
59
78
|
* the composition root if the selector doesn't match — so paste never silently
|
|
60
79
|
* drops the content.
|
|
61
80
|
*/
|
|
81
|
+
// fallow-ignore-next-line complexity
|
|
62
82
|
export function insertAsSibling(
|
|
63
83
|
source: string,
|
|
64
84
|
newHtml: string,
|
|
@@ -110,6 +130,7 @@ export function insertAsSibling(
|
|
|
110
130
|
return source + newHtml;
|
|
111
131
|
}
|
|
112
132
|
|
|
133
|
+
// fallow-ignore-next-line complexity
|
|
113
134
|
function findClosingTagPosition(html: string, openTagStart: number): number {
|
|
114
135
|
// Find the end of the opening tag
|
|
115
136
|
const openTagEnd = html.indexOf(">", openTagStart);
|
|
@@ -156,9 +177,13 @@ function findClosingTagPosition(html: string, openTagStart: number): number {
|
|
|
156
177
|
return -1;
|
|
157
178
|
}
|
|
158
179
|
|
|
180
|
+
/** An `id="..."` attribute, not `data-id="..."` or similar — only matches
|
|
181
|
+
* when preceded by whitespace, the way every generated attribute is. */
|
|
182
|
+
export const ID_ATTR_RE = /(?<=\s)id="([^"]+)"/;
|
|
183
|
+
|
|
159
184
|
export function deduplicateIds(html: string, existingIds: string[]): string {
|
|
160
185
|
const existingSet = new Set(existingIds);
|
|
161
|
-
return html.replace(
|
|
186
|
+
return html.replace(new RegExp(ID_ATTR_RE.source, "g"), (full, id: string) => {
|
|
162
187
|
if (!existingSet.has(id)) return full;
|
|
163
188
|
let counter = 2;
|
|
164
189
|
while (existingSet.has(`${id}-${counter}`)) counter++;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
ACTION_SAFE_PERCENT,
|
|
6
|
+
SAFE_BOX_PERCENTS,
|
|
7
|
+
TITLE_SAFE_PERCENT,
|
|
8
|
+
safeBoxInsetPercent,
|
|
9
|
+
} from "./previewSafeMargins";
|
|
10
|
+
|
|
11
|
+
const SKILL_PATH = join(import.meta.dirname, "../../../../skills/hyperframes-studio/SKILL.md");
|
|
12
|
+
|
|
13
|
+
describe("safe boxes", () => {
|
|
14
|
+
it("are the 90% and 80% boxes, inset 5% and 10% from every edge", () => {
|
|
15
|
+
expect(SAFE_BOX_PERCENTS).toEqual([90, 80]);
|
|
16
|
+
expect(SAFE_BOX_PERCENTS.map(safeBoxInsetPercent)).toEqual([5, 10]);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Skipped until the hyperframes-studio skill exists on this branch.
|
|
21
|
+
describe.runIf(existsSync(SKILL_PATH))("hyperframes-studio skill matches the constants", () => {
|
|
22
|
+
it("has a safe-margins line naming both percentages", () => {
|
|
23
|
+
const skill = readFileSync(SKILL_PATH, "utf8");
|
|
24
|
+
const line = skill
|
|
25
|
+
.split("\n")
|
|
26
|
+
.find(
|
|
27
|
+
(l) =>
|
|
28
|
+
/\bsafe margins?\b/i.test(l) &&
|
|
29
|
+
l.includes(`${ACTION_SAFE_PERCENT}%`) &&
|
|
30
|
+
l.includes(`${TITLE_SAFE_PERCENT}%`),
|
|
31
|
+
);
|
|
32
|
+
expect(line).toBeDefined();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Adobe Premiere default: action-safe and title-safe margins are 10% and 20% total (5% and 10% per edge).
|
|
2
|
+
export const ACTION_SAFE_PERCENT = 90;
|
|
3
|
+
export const TITLE_SAFE_PERCENT = 80;
|
|
4
|
+
|
|
5
|
+
export const SAFE_BOX_PERCENTS = [ACTION_SAFE_PERCENT, TITLE_SAFE_PERCENT] as const;
|
|
6
|
+
|
|
7
|
+
/** Inset from each frame edge, as a percent of the frame. */
|
|
8
|
+
export function safeBoxInsetPercent(boxPercent: number): number {
|
|
9
|
+
return (100 - boxPercent) / 2;
|
|
10
|
+
}
|