@hyperframes/studio 0.8.106 → 0.8.108
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-dl6K6ABp.js → hyperframes-player-BakGew45.js} +1 -1
- package/dist/assets/{index-ooaZZhKf.js → index-C5dyUHwk.js} +1 -1
- package/dist/assets/{index-CMiYVGv5.js → index-C842tcVa.js} +203 -208
- package/dist/assets/{index-DKT5NF1Y.js → index-CPe7d_wO.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-ICTZJPQQ.js → chunk-AOWVEVEU.js} +3 -494
- package/dist/chunk-AOWVEVEU.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +377 -12
- package/dist/index.html +2 -2
- package/dist/index.js +13445 -11874
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +6 -0
- package/src/components/EditorShell.tsx +2 -0
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- package/src/components/editor/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- package/src/components/editor/DomEditOverlay.tsx +12 -18
- package/src/components/editor/PropertyPanel.tsx +1 -14
- package/src/components/editor/PropertyPanelFlat.tsx +3 -6
- package/src/components/editor/SnapToolbar.test.tsx +32 -1
- package/src/components/editor/SnapToolbar.tsx +3 -4
- package/src/components/editor/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- package/src/components/editor/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +28 -5
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +15 -1
- package/src/components/editor/mediaAudioEdits.test.ts +271 -0
- package/src/components/editor/mediaAudioEdits.ts +282 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +290 -87
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useDomEditNudge.test.tsx +134 -2
- package/src/components/editor/useDomEditNudge.ts +46 -23
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
- package/src/components/panels/SlideshowPanel.test.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +12 -7
- package/src/contexts/DomEditContext.test.tsx +62 -0
- package/src/contexts/DomEditContext.tsx +94 -77
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/canvasQuickSaves.test.tsx +72 -7
- package/src/hooks/domEditCommitTypes.ts +10 -0
- package/src/hooks/domEditPersistFailure.ts +12 -1
- package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
- package/src/hooks/domStyleCommit.ts +2 -8
- package/src/hooks/gsapDragCommit.ts +3 -8
- package/src/hooks/gsapDragPositionCommit.ts +6 -1
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
- package/src/hooks/gsapResizeIntercept.test.ts +3 -11
- package/src/hooks/gsapResizeIntercept.ts +6 -2
- package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
- package/src/hooks/inspectorSaveReload.test.tsx +107 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +17 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +3 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- package/src/hooks/useDomEditAttributeCommits.ts +71 -29
- package/src/hooks/useDomEditCommits.test.tsx +172 -92
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
- package/src/hooks/useDomEditPersist.ts +28 -13
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -3
- package/src/hooks/useDomEditTextCommits.ts +4 -8
- package/src/hooks/useDomGeometryCommits.ts +11 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +163 -3
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- package/src/hooks/useGsapAwareEditing.ts +6 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +9 -7
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/usePreviewPersistence.ts +6 -0
- package/src/hooks/useRazorSplit.ts +14 -1
- package/src/hooks/useRenderClipContent.test.ts +102 -0
- package/src/hooks/useRenderClipContent.ts +30 -23
- package/src/hooks/useTimelineEditing.ts +2 -2
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +8 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.tsx +22 -0
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineClip.tsx +2 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- package/src/player/components/clipMenuAudioItems.test.tsx +125 -0
- package/src/player/components/clipMenuAudioItems.tsx +108 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +142 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +235 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/lib/playbackShortcuts.ts +3 -7
- package/src/player/lib/timelineElementHelpers.test.ts +16 -0
- package/src/player/lib/timelineElementHelpers.ts +25 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- package/src/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -0
- package/src/utils/gsapSoftReload.ts +3 -2
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
- package/src/utils/gsapUndoRestore.test.ts +145 -1
- package/src/utils/gsapUndoRestore.ts +70 -21
- package/src/utils/previewRelativePath.ts +19 -0
- package/src/utils/sourcePatcher.ts +2 -2
- package/src/utils/studioFontHelpers.ts +2 -23
- package/src/utils/studioHelpers.test.ts +30 -0
- package/src/utils/studioHelpers.ts +18 -4
- package/src/utils/studioPendingEdits.test.ts +129 -0
- package/src/utils/studioPendingEdits.ts +61 -18
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/src/utils/typingTarget.test.ts +13 -0
- package/src/utils/typingTarget.ts +5 -2
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-ICTZJPQQ.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -12,6 +12,8 @@ import { isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
|
|
|
12
12
|
import { findAuthoredElement, parseSavedSource } from "./authoredSource";
|
|
13
13
|
import { STUDIO_EDIT_ATTRS } from "../components/editor/manualEditsSeekReapply";
|
|
14
14
|
import { markScenesStale } from "../player/sceneSwap";
|
|
15
|
+
import { STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR } from "../components/editor/manualEditsTypes";
|
|
16
|
+
import { studioGestureDraws } from "../components/editor/manualEditsDom";
|
|
15
17
|
|
|
16
18
|
type PreviewWindow = Window & {
|
|
17
19
|
__player?: { seek?: (t: number) => void };
|
|
@@ -140,23 +142,55 @@ function diffRestoreDocs(prevDoc: Document, nextDoc: Document): string[] | null
|
|
|
140
142
|
return prevRest === nextRest ? changedElementKeys : null;
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
/**
|
|
144
|
-
function syncElementAttributes(target: Element, source: Element): void {
|
|
145
|
+
/** Copies `source`'s attributes onto `target`; under a gesture mark it merges them (keepDrawn). */
|
|
146
|
+
function syncElementAttributes(target: Element, source: Element, base?: Element): void {
|
|
147
|
+
const draws = base && studioGestureDraws(target);
|
|
148
|
+
const merged = draws ? keepDrawn(target, source, base, draws) : source;
|
|
145
149
|
for (const name of [...target.getAttributeNames()]) {
|
|
146
|
-
if (!
|
|
150
|
+
if (!merged.hasAttribute(name)) target.removeAttribute(name);
|
|
147
151
|
}
|
|
148
|
-
for (const name of
|
|
149
|
-
target.setAttribute(name,
|
|
152
|
+
for (const name of merged.getAttributeNames()) {
|
|
153
|
+
target.setAttribute(name, merged.getAttribute(name) ?? "");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** `source`, plus what a gesture in progress draws on `live` (whatever its value) or changed since `base`. */
|
|
158
|
+
function keepDrawn(live: Element, source: Element, base: Element, draws: readonly string[]) {
|
|
159
|
+
const merged = source.cloneNode(false) as Element;
|
|
160
|
+
const keepsMark = draws.includes("translate");
|
|
161
|
+
for (const name of live.getAttributeNames().filter((n) => n !== "style")) {
|
|
162
|
+
const value = live.getAttribute(name)!;
|
|
163
|
+
const mark = keepsMark && name === STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR;
|
|
164
|
+
if (mark || value !== base.getAttribute(name)) merged.setAttribute(name, value);
|
|
165
|
+
}
|
|
166
|
+
keepDrawnStyle(live as HTMLElement, base as HTMLElement, merged as HTMLElement, draws);
|
|
167
|
+
return merged;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function keepDrawnStyle(
|
|
171
|
+
live: HTMLElement,
|
|
172
|
+
base: HTMLElement,
|
|
173
|
+
merged: HTMLElement,
|
|
174
|
+
draws: readonly string[],
|
|
175
|
+
) {
|
|
176
|
+
const value = (prop: string) => live.style.getPropertyValue(prop);
|
|
177
|
+
const drawn = (prop: string) =>
|
|
178
|
+
draws.includes(prop) || value(prop) !== base.style.getPropertyValue(prop);
|
|
179
|
+
const props = new Set([...Array.from(live.style), ...Array.from(base.style), ...draws]);
|
|
180
|
+
for (const prop of [...props].filter(drawn)) {
|
|
181
|
+
if (value(prop))
|
|
182
|
+
merged.style.setProperty(prop, value(prop), live.style.getPropertyPriority(prop));
|
|
183
|
+
else merged.style.removeProperty(prop);
|
|
150
184
|
}
|
|
151
185
|
}
|
|
152
186
|
|
|
153
187
|
// A gesture folded into the script leaves marks on the live element that every seek would re-impose.
|
|
154
|
-
function syncStaleEditMarks(doc: Document,
|
|
155
|
-
const restoredDoc = parseSavedSource
|
|
188
|
+
function syncStaleEditMarks(doc: Document, file: UndoRestoreFile): void {
|
|
189
|
+
const [restoredDoc, previousDoc] = [file.restored, file.previous].map(parseSavedSource);
|
|
156
190
|
for (const live of doc.querySelectorAll(STUDIO_EDIT_ATTRS.map((attr) => `[${attr}]`).join())) {
|
|
157
191
|
const source = findAuthoredElement(restoredDoc, live);
|
|
158
192
|
if (source && STUDIO_EDIT_ATTRS.some((a) => live.getAttribute(a) !== source.getAttribute(a))) {
|
|
159
|
-
syncElementAttributes(live, source);
|
|
193
|
+
syncElementAttributes(live, source, findAuthoredElement(previousDoc, live) ?? undefined);
|
|
160
194
|
}
|
|
161
195
|
}
|
|
162
196
|
}
|
|
@@ -172,7 +206,7 @@ function hasAmbiguousGsapScriptChange(
|
|
|
172
206
|
);
|
|
173
207
|
}
|
|
174
208
|
|
|
175
|
-
type RestoreTarget = { live: Element; restored: Element };
|
|
209
|
+
type RestoreTarget = { live: Element; restored: Element; previous?: Element };
|
|
176
210
|
type RestorePlan = { targets: RestoreTarget[]; scripted: boolean };
|
|
177
211
|
|
|
178
212
|
/** Whether either side has a GSAP script, or null when its scripts rule out an in-place restore. */
|
|
@@ -196,6 +230,7 @@ function scopeTargets(
|
|
|
196
230
|
scope: ParentNode,
|
|
197
231
|
keys: string[],
|
|
198
232
|
restoredByKey: Map<string, Element>,
|
|
233
|
+
previousByKey: Map<string, Element>,
|
|
199
234
|
): RestoreTarget[] | null {
|
|
200
235
|
const liveByKey = identityElementMap(scope);
|
|
201
236
|
if (!liveByKey) return null;
|
|
@@ -205,7 +240,7 @@ function scopeTargets(
|
|
|
205
240
|
const restored = restoredByKey.get(key);
|
|
206
241
|
// The preview rewrites a sub-composition's own root, so its attributes are not the file's.
|
|
207
242
|
if (!live || !restored || live.hasAttribute("data-hf-inner-root")) return null;
|
|
208
|
-
targets.push({ live, restored });
|
|
243
|
+
targets.push({ live, restored, previous: previousByKey.get(key) });
|
|
209
244
|
}
|
|
210
245
|
return targets;
|
|
211
246
|
}
|
|
@@ -222,9 +257,10 @@ function fileTargets(
|
|
|
222
257
|
if (scripted === null) return null;
|
|
223
258
|
const keys = diffRestoreDocs(prevDoc, nextDoc);
|
|
224
259
|
const restoredByKey = identityElementMap(nextDoc);
|
|
225
|
-
|
|
260
|
+
const previousByKey = identityElementMap(prevDoc);
|
|
261
|
+
if (!keys || !restoredByKey || !previousByKey) return null;
|
|
226
262
|
const found = liveScopes(doc, path, isActive).map((scope) =>
|
|
227
|
-
scopeTargets(scope, keys, restoredByKey),
|
|
263
|
+
scopeTargets(scope, keys, restoredByKey, previousByKey),
|
|
228
264
|
);
|
|
229
265
|
if (!found.length || found.includes(null)) return null;
|
|
230
266
|
return { targets: (found as RestoreTarget[][]).flat(), scripted };
|
|
@@ -273,11 +309,14 @@ export function showRestoreInPlace(
|
|
|
273
309
|
const win = iframe?.contentWindow as PreviewWindow | null;
|
|
274
310
|
const plan = doc && win ? planRestoreTargets(doc, activeCompPath ?? "index.html", files) : null;
|
|
275
311
|
if (!iframe || !win || !plan || plan.scripted) return null;
|
|
276
|
-
const before = plan.targets.map(
|
|
277
|
-
|
|
312
|
+
const before = plan.targets.map(
|
|
313
|
+
({ live, restored }) => [live, live.cloneNode(false) as Element, restored] as const,
|
|
314
|
+
);
|
|
315
|
+
for (const { live, restored, previous } of plan.targets)
|
|
316
|
+
syncElementAttributes(live, restored, previous);
|
|
278
317
|
const putBack = (time: number) => {
|
|
279
318
|
if (iframe.contentDocument !== doc) return false;
|
|
280
|
-
for (const [live, attributes] of before) syncElementAttributes(live, attributes);
|
|
319
|
+
for (const [live, attributes, shown] of before) syncElementAttributes(live, attributes, shown);
|
|
281
320
|
return finalizeInPlace(iframe, win, time);
|
|
282
321
|
};
|
|
283
322
|
if (finalizeInPlace(iframe, win, currentTime)) return putBack;
|
|
@@ -299,9 +338,10 @@ export function showRestoreInPlace(
|
|
|
299
338
|
* element — so a canvas-position revert lands on the live DOM the runtime's
|
|
300
339
|
* seek-reapply reads from, not just on disk.
|
|
301
340
|
* 2. The runtime refresh depends on what changed:
|
|
302
|
-
* - GSAP script text actually CHANGED between previous and restored
|
|
303
|
-
* restored script is re-run in
|
|
304
|
-
* `currentTime`, re-folds manual
|
|
341
|
+
* - GSAP script text actually CHANGED between previous and restored, or a
|
|
342
|
+
* synced element was parsed by GSAP → the restored script is re-run in
|
|
343
|
+
* place via applySoftReload (re-seeks to `currentTime`, re-folds manual
|
|
344
|
+
* edits, re-parses those elements); several scripts can't, so it reloads.
|
|
305
345
|
* - Script unchanged or absent (the overwhelmingly common undo: z-order,
|
|
306
346
|
* lane move, timing shift, style tweak) → NO script execution — the
|
|
307
347
|
* blink-free finalization only (seek + __hfForceTimelineRebind + manual
|
|
@@ -338,17 +378,26 @@ export function applyUndoRestoreToPreview(
|
|
|
338
378
|
}
|
|
339
379
|
// Sync each changed element's attributes onto the live DOM from the restored
|
|
340
380
|
// markup, so the runtime's seek-reapply reads the reverted values.
|
|
341
|
-
for (const target of plan.targets)
|
|
381
|
+
for (const target of plan.targets)
|
|
382
|
+
syncElementAttributes(target.live, target.restored, target.previous);
|
|
342
383
|
|
|
343
384
|
const active = files[activeDocPath];
|
|
344
385
|
const restoredScript = active ? extractGsapScriptText(active.restored) : null;
|
|
345
386
|
const previousScript = active ? extractGsapScriptText(active.previous) : null;
|
|
346
|
-
|
|
347
|
-
|
|
387
|
+
const reparse = plan.scripted
|
|
388
|
+
? plan.targets.map(({ live }) => live).filter((el) => "_gsap" in el)
|
|
389
|
+
: [];
|
|
390
|
+
if (reparse.length && !restoredScript) {
|
|
391
|
+
reloadPreview();
|
|
392
|
+
return "full";
|
|
393
|
+
}
|
|
394
|
+
if (restoredScript && (restoredScript !== previousScript || reparse.length)) {
|
|
395
|
+
syncStaleEditMarks(doc, active);
|
|
348
396
|
const result = applySoftReload(iframe, restoredScript, {
|
|
349
397
|
onAsyncFailure: reloadPreview,
|
|
350
398
|
currentTimeOverride: currentTime,
|
|
351
399
|
authoredHtml: active.restored,
|
|
400
|
+
reparse,
|
|
352
401
|
});
|
|
353
402
|
if (result === "cannot-soft-reload") {
|
|
354
403
|
reloadPreview();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { buildProjectApiPath } from "./projectRouting";
|
|
2
|
+
|
|
3
|
+
/** Resolve a media src to its project-relative preview path, or null. */
|
|
4
|
+
export function resolvePreviewRelative(
|
|
5
|
+
src: string | undefined,
|
|
6
|
+
pid: string,
|
|
7
|
+
origin: string,
|
|
8
|
+
): string | null {
|
|
9
|
+
if (!src) return null;
|
|
10
|
+
try {
|
|
11
|
+
const parsed = new URL(src, origin);
|
|
12
|
+
const base = new URL(buildProjectApiPath(pid, `/preview/`), origin).pathname;
|
|
13
|
+
return parsed.pathname.startsWith(base)
|
|
14
|
+
? decodeURIComponent(parsed.pathname.slice(base.length))
|
|
15
|
+
: null;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -12,7 +12,7 @@ function escapeStyleAttributeValue(value: string, quote: string): string {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/** Escape a string for safe use inside a double-quoted HTML attribute. */
|
|
15
|
-
function escapeHtmlAttribute(value: string): string {
|
|
15
|
+
export function escapeHtmlAttribute(value: string): string {
|
|
16
16
|
return value
|
|
17
17
|
.replace(/&/g, "&")
|
|
18
18
|
.replace(/"/g, """)
|
|
@@ -21,7 +21,7 @@ function escapeHtmlAttribute(value: string): string {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/** Reverse escapeHtmlAttribute so callers get the original value. */
|
|
24
|
-
function unescapeHtmlAttribute(value: string): string {
|
|
24
|
+
export function unescapeHtmlAttribute(value: string): string {
|
|
25
25
|
return value
|
|
26
26
|
.replace(/"/g, '"')
|
|
27
27
|
.replace(/</g, "<")
|
|
@@ -60,27 +60,6 @@ export function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsse
|
|
|
60
60
|
doc.head.appendChild(style);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
export function
|
|
64
|
-
|
|
65
|
-
asset: ImportedFontAsset,
|
|
66
|
-
sourceFile: string,
|
|
67
|
-
): string {
|
|
68
|
-
const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
|
|
69
|
-
if (html.includes(css)) return html;
|
|
70
|
-
|
|
71
|
-
const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
|
|
72
|
-
const styleMatch = styleRe.exec(html);
|
|
73
|
-
if (styleMatch) {
|
|
74
|
-
const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
|
|
75
|
-
return html.replace(
|
|
76
|
-
styleMatch[0],
|
|
77
|
-
() => `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`,
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
|
|
82
|
-
if (/<\/head>/i.test(html)) {
|
|
83
|
-
return html.replace(/<\/head>/i, () => ` ${styleTag}\n </head>`);
|
|
84
|
-
}
|
|
85
|
-
return `${styleTag}\n${html}`;
|
|
63
|
+
export function importedFontFaceCssFor(asset: ImportedFontAsset, sourceFile: string): string {
|
|
64
|
+
return importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
|
|
86
65
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
historyTooltipLabel,
|
|
6
6
|
findMatchingTimelineElementId,
|
|
7
7
|
findTimelineIdByAncestor,
|
|
8
|
+
resolveAssetHasAudio,
|
|
8
9
|
resolveDroppedAssetDimensions,
|
|
9
10
|
resolveElementTrack,
|
|
10
11
|
resolveTimelineIdForSelection,
|
|
@@ -235,3 +236,32 @@ describe("historyTooltipLabel", () => {
|
|
|
235
236
|
expect(historyTooltipLabel("redo", null)).toMatch(/^Redo \(.+\)$/);
|
|
236
237
|
});
|
|
237
238
|
});
|
|
239
|
+
|
|
240
|
+
describe("resolveAssetHasAudio", () => {
|
|
241
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
242
|
+
|
|
243
|
+
const stubMetadata = (response: Response | Error) =>
|
|
244
|
+
vi.stubGlobal(
|
|
245
|
+
"fetch",
|
|
246
|
+
vi.fn(async () => {
|
|
247
|
+
if (response instanceof Error) throw response;
|
|
248
|
+
return response;
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
it("answers the metadata endpoint's audio flag", async () => {
|
|
253
|
+
stubMetadata(new Response(JSON.stringify({ metadata: { hasAudio: true } })));
|
|
254
|
+
expect(await resolveAssetHasAudio("p1", "assets/talk.mp4")).toBe(true);
|
|
255
|
+
stubMetadata(new Response(JSON.stringify({ metadata: { hasAudio: false } })));
|
|
256
|
+
expect(await resolveAssetHasAudio("p1", "assets/cutout.webm")).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("answers null when the probe cannot tell", async () => {
|
|
260
|
+
stubMetadata(new Response("nope", { status: 500 }));
|
|
261
|
+
expect(await resolveAssetHasAudio("p1", "assets/talk.mp4")).toBeNull();
|
|
262
|
+
stubMetadata(new Error("offline"));
|
|
263
|
+
expect(await resolveAssetHasAudio("p1", "assets/talk.mp4")).toBeNull();
|
|
264
|
+
stubMetadata(new Response(JSON.stringify({ metadata: {} })));
|
|
265
|
+
expect(await resolveAssetHasAudio("p1", "assets/talk.mp4")).toBeNull();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -388,13 +388,27 @@ export async function resolveDroppedAssetHasAudio(
|
|
|
388
388
|
kind: TimelineAssetKind,
|
|
389
389
|
): Promise<boolean> {
|
|
390
390
|
if (kind !== "video") return false;
|
|
391
|
+
return (await resolveAssetHasAudio(projectId, assetPath)) === true;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
395
|
+
return typeof value === "object" && value !== null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** An asset's audio stream from the metadata endpoint: null when the probe can't tell. */
|
|
399
|
+
export async function resolveAssetHasAudio(
|
|
400
|
+
projectId: string,
|
|
401
|
+
assetPath: string,
|
|
402
|
+
): Promise<boolean | null> {
|
|
391
403
|
try {
|
|
392
404
|
const response = await fetch(mediaMetadataUrl(projectId, assetPath));
|
|
393
|
-
if (!response.ok) return
|
|
394
|
-
const data =
|
|
395
|
-
|
|
405
|
+
if (!response.ok) return null;
|
|
406
|
+
const data: unknown = await response.json();
|
|
407
|
+
const metadata = isPlainRecord(data) ? data.metadata : undefined;
|
|
408
|
+
const hasAudio = isPlainRecord(metadata) ? metadata.hasAudio : undefined;
|
|
409
|
+
return typeof hasAudio === "boolean" ? hasAudio : null;
|
|
396
410
|
} catch {
|
|
397
|
-
return
|
|
411
|
+
return null;
|
|
398
412
|
}
|
|
399
413
|
}
|
|
400
414
|
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
import { describe, expect, it, vi } from "vitest";
|
|
3
3
|
import {
|
|
4
4
|
addStudioPendingEditFlushListener,
|
|
5
|
+
beginStudioPendingEdit,
|
|
5
6
|
flushStudioPendingEdits,
|
|
7
|
+
hasStudioPendingEdits,
|
|
8
|
+
revertNewestStudioPendingEdit,
|
|
6
9
|
trackStudioPendingEdit,
|
|
10
|
+
trackedStudioEdit,
|
|
7
11
|
} from "./studioPendingEdits";
|
|
8
12
|
import { StudioFileConflictError } from "./studioSaveDiagnostics";
|
|
9
13
|
|
|
@@ -36,6 +40,22 @@ describe("studio pending edit flush", () => {
|
|
|
36
40
|
input.remove();
|
|
37
41
|
});
|
|
38
42
|
|
|
43
|
+
it.each([
|
|
44
|
+
["contenteditable", "plaintext-only"],
|
|
45
|
+
["role", "combobox"],
|
|
46
|
+
["role", "searchbox"],
|
|
47
|
+
["role", "switch"],
|
|
48
|
+
])("counts a focused [%s=%s] as a pending edit", (attribute, value) => {
|
|
49
|
+
const field = document.createElement("div");
|
|
50
|
+
field.setAttribute(attribute, value);
|
|
51
|
+
field.tabIndex = 0;
|
|
52
|
+
document.body.append(field);
|
|
53
|
+
field.focus();
|
|
54
|
+
|
|
55
|
+
expect(hasStudioPendingEdits()).toBe(true);
|
|
56
|
+
field.remove();
|
|
57
|
+
});
|
|
58
|
+
|
|
39
59
|
it("waits for a post-blur effect to register its pending edit listener", async () => {
|
|
40
60
|
const input = document.createElement("textarea");
|
|
41
61
|
document.body.append(input);
|
|
@@ -148,3 +168,112 @@ describe("studio pending edit flush", () => {
|
|
|
148
168
|
expect(steps).toEqual(["persisted", "flushed"]);
|
|
149
169
|
});
|
|
150
170
|
});
|
|
171
|
+
|
|
172
|
+
describe("a drain that meets a conflict", () => {
|
|
173
|
+
it("still waits for the edits that started while it ran before it reports the conflict", async () => {
|
|
174
|
+
const conflict = new StudioFileConflictError({
|
|
175
|
+
filePath: "index.html",
|
|
176
|
+
currentVersion: "v2",
|
|
177
|
+
currentContent: "external",
|
|
178
|
+
attemptedContent: "studio",
|
|
179
|
+
});
|
|
180
|
+
let finish!: () => void;
|
|
181
|
+
let laterSaved = false;
|
|
182
|
+
trackStudioPendingEdit(
|
|
183
|
+
Promise.resolve().then(() => {
|
|
184
|
+
trackStudioPendingEdit(
|
|
185
|
+
new Promise<void>((resolve) => (finish = resolve)).then(() => (laterSaved = true)),
|
|
186
|
+
);
|
|
187
|
+
throw conflict;
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
let drained = false;
|
|
191
|
+
const drain = flushStudioPendingEdits().then((result) => ((drained = true), result));
|
|
192
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
193
|
+
expect(drained).toBe(false);
|
|
194
|
+
finish();
|
|
195
|
+
await expect(drain).resolves.toEqual({ status: "conflict", error: conflict });
|
|
196
|
+
expect(laterSaved).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("trackedStudioEdit", () => {
|
|
201
|
+
it("counts each call as a pending edit until it settles, and reports a failure to the drain", async () => {
|
|
202
|
+
let fail!: () => void;
|
|
203
|
+
const failure = new Error("The save failed.");
|
|
204
|
+
const edit = trackedStudioEdit(
|
|
205
|
+
() => new Promise<void>((_, reject) => (fail = () => reject(failure))),
|
|
206
|
+
);
|
|
207
|
+
const saved = edit();
|
|
208
|
+
expect(hasStudioPendingEdits()).toBe(true);
|
|
209
|
+
const drain = flushStudioPendingEdits();
|
|
210
|
+
fail();
|
|
211
|
+
await expect(saved).rejects.toThrow("The save failed.");
|
|
212
|
+
await expect(drain).resolves.toEqual({ status: "failed", error: failure });
|
|
213
|
+
expect(hasStudioPendingEdits()).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("a pending edit undo can paint back", () => {
|
|
218
|
+
const shown: string[] = [];
|
|
219
|
+
const edit = (name: string) =>
|
|
220
|
+
beginStudioPendingEdit(() => {
|
|
221
|
+
shown.push(`${name} undone`);
|
|
222
|
+
return () => shown.push(`${name} again`);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("paints back only the newest edit, once, and shows it again on request", () => {
|
|
226
|
+
shown.length = 0;
|
|
227
|
+
const first = edit("first");
|
|
228
|
+
const second = edit("second");
|
|
229
|
+
const again = revertNewestStudioPendingEdit();
|
|
230
|
+
expect(shown).toEqual(["second undone"]);
|
|
231
|
+
expect(second.reverted()).toBe(true);
|
|
232
|
+
expect(first.reverted()).toBe(false);
|
|
233
|
+
expect(revertNewestStudioPendingEdit()).toBeNull();
|
|
234
|
+
again!();
|
|
235
|
+
expect(shown).toEqual(["second undone", "second again"]);
|
|
236
|
+
first.settle();
|
|
237
|
+
second.settle();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("paints nothing when the newest edit has no revert", async () => {
|
|
241
|
+
shown.length = 0;
|
|
242
|
+
const move = edit("move");
|
|
243
|
+
let saved!: () => void;
|
|
244
|
+
trackStudioPendingEdit(new Promise<void>((resolve) => (saved = resolve)));
|
|
245
|
+
expect(revertNewestStudioPendingEdit()).toBeNull();
|
|
246
|
+
expect(shown).toEqual([]);
|
|
247
|
+
saved();
|
|
248
|
+
move.settle();
|
|
249
|
+
await flushStudioPendingEdits();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("counts what the edit starts inside adopt as that edit, not a newer one", async () => {
|
|
253
|
+
shown.length = 0;
|
|
254
|
+
const move = edit("move");
|
|
255
|
+
let saved!: () => void;
|
|
256
|
+
const save = move.adopt(() =>
|
|
257
|
+
trackStudioPendingEdit(new Promise<void>((resolve) => (saved = resolve))),
|
|
258
|
+
);
|
|
259
|
+
move.settle(save);
|
|
260
|
+
revertNewestStudioPendingEdit();
|
|
261
|
+
expect(shown).toEqual(["move undone"]);
|
|
262
|
+
saved();
|
|
263
|
+
await expect(flushStudioPendingEdits()).resolves.toEqual({ status: "clean" });
|
|
264
|
+
expect(hasStudioPendingEdits()).toBe(false);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("a pending edit whose start throws", () => {
|
|
269
|
+
it("ends, so undo and export never wait on it", async () => {
|
|
270
|
+
const edit = beginStudioPendingEdit(null);
|
|
271
|
+
expect(() =>
|
|
272
|
+
edit.adopt(() => {
|
|
273
|
+
throw new Error("The commit threw.");
|
|
274
|
+
}),
|
|
275
|
+
).toThrow("The commit threw.");
|
|
276
|
+
await expect(flushStudioPendingEdits()).resolves.toEqual({ status: "clean" });
|
|
277
|
+
expect(hasStudioPendingEdits()).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { StudioFileConflictError, type StudioSaveDrainResult } from "./studioSaveDiagnostics";
|
|
2
|
+
import { isTypingTarget } from "./typingTarget";
|
|
2
3
|
|
|
3
4
|
const STUDIO_FLUSH_PENDING_EDITS_EVENT = "hf-studio-flush-pending-edits";
|
|
4
5
|
|
|
@@ -8,7 +9,10 @@ interface StudioFlushPendingEditsDetail {
|
|
|
8
9
|
|
|
9
10
|
export type StudioPendingEditsDrainResult = StudioSaveDrainResult;
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
export type StudioEditRevert = () => () => void;
|
|
13
|
+
|
|
14
|
+
const pendingEdits = new Map<Promise<unknown>, { revert: StudioEditRevert | null }>();
|
|
15
|
+
let adopting = false;
|
|
12
16
|
|
|
13
17
|
function waitForPostBlurEffects(): Promise<void> {
|
|
14
18
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
@@ -29,18 +33,15 @@ function inspectDrainFailures(results: PromiseSettledResult<unknown>[]): {
|
|
|
29
33
|
|
|
30
34
|
function focusedField(): HTMLElement | null {
|
|
31
35
|
const active = document.activeElement;
|
|
32
|
-
return active instanceof HTMLElement &&
|
|
33
|
-
active.matches('input, textarea, select, [contenteditable="true"], [role="textbox"]')
|
|
34
|
-
? active
|
|
35
|
-
: null;
|
|
36
|
+
return active instanceof HTMLElement && isTypingTarget(active) ? active : null;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
export function hasStudioPendingEdits(): boolean {
|
|
39
|
-
return
|
|
40
|
+
return pendingEdits.size > 0 || focusedField() !== null;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export function isStudioEditSaving(): boolean {
|
|
43
|
-
return
|
|
44
|
+
return pendingEdits.size > 0;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export function afterStudioPendingEdits(run: () => void): () => void {
|
|
@@ -48,7 +49,7 @@ export function afterStudioPendingEdits(run: () => void): () => void {
|
|
|
48
49
|
const check = () => {
|
|
49
50
|
if (!waiting) return;
|
|
50
51
|
if (isStudioEditSaving()) {
|
|
51
|
-
void Promise.allSettled([...
|
|
52
|
+
void Promise.allSettled([...pendingEdits.keys()]).then(check);
|
|
52
53
|
return;
|
|
53
54
|
}
|
|
54
55
|
waiting = false;
|
|
@@ -65,20 +66,61 @@ export function trackStudioPendingEdit(
|
|
|
65
66
|
): Promise<unknown> | undefined {
|
|
66
67
|
if (!result) return undefined;
|
|
67
68
|
const promise = Promise.resolve(result);
|
|
68
|
-
|
|
69
|
+
if (adopting) return promise;
|
|
70
|
+
pendingEdits.set(promise, { revert: null });
|
|
69
71
|
promise.then(
|
|
70
|
-
() =>
|
|
71
|
-
() =>
|
|
72
|
+
() => pendingEdits.delete(promise),
|
|
73
|
+
() => pendingEdits.delete(promise),
|
|
72
74
|
);
|
|
73
75
|
return promise;
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
export function trackedStudioEdit<Args extends unknown[], R>(
|
|
79
|
+
edit: (...args: Args) => R,
|
|
80
|
+
): (...args: Args) => R {
|
|
81
|
+
return (...args) => {
|
|
82
|
+
const result = edit(...args);
|
|
83
|
+
if (result instanceof Promise) trackStudioPendingEdit(result);
|
|
84
|
+
return result;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
|
|
89
|
+
let settle!: (saved?: Promise<unknown>) => void;
|
|
90
|
+
const promise = trackStudioPendingEdit(new Promise<unknown>((resolve) => (settle = resolve)))!;
|
|
91
|
+
const entry = pendingEdits.get(promise)!;
|
|
92
|
+
entry.revert = revert;
|
|
93
|
+
return {
|
|
94
|
+
settle,
|
|
95
|
+
reverted: () => entry.revert === null && revert !== null,
|
|
96
|
+
// Only what `start` registers synchronously is adopted; a registration after an await is a newer edit.
|
|
97
|
+
adopt<T>(start: () => T): T {
|
|
98
|
+
adopting = true;
|
|
99
|
+
try {
|
|
100
|
+
return start();
|
|
101
|
+
} catch (error) {
|
|
102
|
+
settle();
|
|
103
|
+
throw error;
|
|
104
|
+
} finally {
|
|
105
|
+
adopting = false;
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function revertNewestStudioPendingEdit(): (() => void) | null {
|
|
112
|
+
const newest = [...pendingEdits.values()].at(-1);
|
|
113
|
+
const revert = newest?.revert;
|
|
114
|
+
if (!newest || !revert) return null;
|
|
115
|
+
newest.revert = null;
|
|
116
|
+
return revert();
|
|
117
|
+
}
|
|
118
|
+
|
|
76
119
|
export async function flushStudioPendingEdits(): Promise<StudioPendingEditsDrainResult> {
|
|
77
120
|
const active = focusedField();
|
|
78
121
|
if (active) {
|
|
79
122
|
active.blur();
|
|
80
|
-
// ponytail:
|
|
81
|
-
// so React effects triggered by the blur can register their flush listener.
|
|
123
|
+
// ponytail: keep blur commits, then cross one task so effects the blur runs can add their listener.
|
|
82
124
|
await Promise.resolve();
|
|
83
125
|
await waitForPostBlurEffects();
|
|
84
126
|
}
|
|
@@ -86,15 +128,16 @@ export async function flushStudioPendingEdits(): Promise<StudioPendingEditsDrain
|
|
|
86
128
|
window.dispatchEvent(
|
|
87
129
|
new CustomEvent<StudioFlushPendingEditsDetail>(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }),
|
|
88
130
|
);
|
|
131
|
+
let conflict: StudioFileConflictError | undefined;
|
|
89
132
|
let firstFailure: PromiseRejectedResult | undefined;
|
|
90
|
-
while (detail.promises.length > 0 ||
|
|
91
|
-
const promises = [...detail.promises, ...
|
|
133
|
+
while (detail.promises.length > 0 || pendingEdits.size > 0) {
|
|
134
|
+
const promises = [...detail.promises, ...pendingEdits.keys()];
|
|
92
135
|
detail.promises = [];
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
if (batchFailures.conflict) return { status: "conflict", error: batchFailures.conflict };
|
|
136
|
+
const batchFailures = inspectDrainFailures(await Promise.allSettled(promises));
|
|
137
|
+
conflict ??= batchFailures.conflict;
|
|
96
138
|
firstFailure ??= batchFailures.firstFailure;
|
|
97
139
|
}
|
|
140
|
+
if (conflict) return { status: "conflict", error: conflict };
|
|
98
141
|
return firstFailure ? { status: "failed", error: firstFailure.reason } : { status: "clean" };
|
|
99
142
|
}
|
|
100
143
|
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
canHideSelections,
|
|
5
5
|
isAudioDomElement,
|
|
6
6
|
isAudioTimelineElement,
|
|
7
|
+
isMusicSourceElement,
|
|
7
8
|
isMusicTrack,
|
|
8
9
|
resolveBeatSourceTrack,
|
|
9
10
|
} from "./timelineInspector";
|
|
@@ -12,9 +13,15 @@ import type { TimelineElement } from "../player";
|
|
|
12
13
|
// Minimal element factory for tests
|
|
13
14
|
function el(
|
|
14
15
|
overrides: Partial<
|
|
15
|
-
Pick<
|
|
16
|
+
Pick<
|
|
17
|
+
TimelineElement,
|
|
18
|
+
"tag" | "src" | "id" | "domId" | "timelineRole" | "duration" | "hasAudio" | "muted"
|
|
19
|
+
>
|
|
16
20
|
>,
|
|
17
|
-
): Pick<
|
|
21
|
+
): Pick<
|
|
22
|
+
TimelineElement,
|
|
23
|
+
"tag" | "src" | "id" | "domId" | "timelineRole" | "duration" | "hasAudio" | "muted"
|
|
24
|
+
> {
|
|
18
25
|
return {
|
|
19
26
|
tag: "audio",
|
|
20
27
|
src: "assets/track.mp3",
|
|
@@ -153,3 +160,27 @@ describe("isAudioDomElement / canHideSelections", () => {
|
|
|
153
160
|
expect(canHideSelections([{ element: el("hf-audio-group") }])).toBe(false);
|
|
154
161
|
});
|
|
155
162
|
});
|
|
163
|
+
|
|
164
|
+
describe("beat source from a video with sound", () => {
|
|
165
|
+
const talk = { tag: "video", src: "assets/clip.mp4", hasAudio: true };
|
|
166
|
+
|
|
167
|
+
it("counts an audible video as a music source but never a muted or silent one", () => {
|
|
168
|
+
expect(isMusicSourceElement(el(talk))).toBe(true);
|
|
169
|
+
expect(isMusicSourceElement(el({ ...talk, muted: true }))).toBe(false);
|
|
170
|
+
expect(isMusicSourceElement(el({ ...talk, hasAudio: false }))).toBe(false);
|
|
171
|
+
expect(isAudioTimelineElement(el(talk))).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("takes a video tagged as music as the beat source", () => {
|
|
175
|
+
const video = el({ ...talk, timelineRole: "music" });
|
|
176
|
+
expect(isMusicTrack(video)).toBe(true);
|
|
177
|
+
expect(resolveBeatSourceTrack([video])).toEqual({ element: video, isFallback: false });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("falls back to the longest audible video when there is no audio", () => {
|
|
181
|
+
const short = el({ ...talk, id: "a", duration: 3 });
|
|
182
|
+
const long = el({ ...talk, id: "b", duration: 9 });
|
|
183
|
+
const muted = el({ ...talk, id: "c", duration: 20, muted: true });
|
|
184
|
+
expect(resolveBeatSourceTrack([short, long, muted])?.element).toBe(long);
|
|
185
|
+
});
|
|
186
|
+
});
|