@hyperframes/studio 0.8.81 → 0.8.82
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-C68UawR3.js → hyperframes-player-zGIbZhLJ.js} +1 -1
- package/dist/assets/{index-CMzPGv9P.js → index-BiLCTunu.js} +1 -1
- package/dist/assets/{index-hNVM9Pd2.js → index-DSazIt7R.js} +189 -189
- package/dist/assets/{index-CS5SImRT.js → index-DStmfnpZ.js} +1 -1
- package/dist/index.d.ts +172 -7
- package/dist/index.html +1 -1
- package/dist/index.js +2414 -2172
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
- package/src/components/TimelineHistoryButtons.tsx +26 -14
- package/src/components/TimelineToolbar.test.tsx +9 -1
- package/src/components/TimelineToolbar.tsx +13 -5
- package/src/components/editor/BlockParamsPanel.tsx +14 -10
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
- package/src/components/editor/manualEdits.test.ts +24 -0
- package/src/components/editor/manualEdits.ts +6 -2
- package/src/components/editor/manualEditsDom.ts +0 -65
- package/src/components/editor/manualEditsSeekReapply.ts +136 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +71 -32
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
- package/src/components/editor/propertyPanelHelpers.ts +15 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
- package/src/components/editor/useFxLevelling.ts +35 -10
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/studioColorGradingScope.ts +12 -36
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- package/src/hooks/canvasQuickSaves.test.tsx +195 -0
- package/src/hooks/gsapMutationClient.ts +116 -0
- package/src/hooks/resizeObserverTestUtils.ts +14 -0
- package/src/hooks/timelineAudioGroupCreate.ts +18 -40
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +66 -61
- package/src/hooks/timelineQuickSaves.test.ts +195 -0
- package/src/hooks/timelineTimingSync.test.ts +12 -0
- package/src/hooks/timelineTimingSync.ts +31 -123
- package/src/hooks/timelineTrackVisibility.ts +15 -29
- package/src/hooks/useAskAgentModal.test.tsx +78 -0
- package/src/hooks/useAskAgentModal.ts +3 -1
- package/src/hooks/useClipboard.source.test.ts +32 -0
- package/src/hooks/useClipboard.ts +21 -20
- package/src/hooks/useDomEditCommits.test.tsx +13 -9
- package/src/hooks/useDomEditCommits.ts +88 -97
- package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +36 -37
- package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
- package/src/hooks/useFileManager.ts +21 -12
- package/src/hooks/useGroupCommits.ts +23 -24
- package/src/hooks/useGsapAnimationOps.ts +26 -8
- package/src/hooks/useGsapScriptCommits.ts +1 -0
- package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
- package/src/hooks/useThumbnailStripSize.ts +31 -0
- package/src/hooks/useTimelineAssetDropOps.ts +38 -35
- package/src/hooks/useTimelineDeleteOps.ts +53 -56
- package/src/hooks/useTimelineEditing.ts +3 -1
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +8 -0
- package/src/player/components/CompositionThumbnail.test.ts +40 -6
- package/src/player/components/CompositionThumbnail.tsx +6 -21
- package/src/player/components/ImageThumbnail.test.tsx +27 -6
- package/src/player/components/ImageThumbnail.tsx +4 -18
- package/src/player/components/PlayerControls.test.tsx +52 -0
- package/src/player/components/PlayerControls.tsx +2 -1
- package/src/player/components/Timeline.test.ts +28 -3
- package/src/player/components/TimelineCanvas.tsx +5 -3
- package/src/player/components/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- package/src/player/components/timelineEditing.test.ts +55 -0
- package/src/player/components/timelineGroupEditing.ts +13 -6
- package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
- package/src/player/components/timelinePlayheadTransform.ts +17 -0
- package/src/player/components/useTimelinePlayhead.ts +26 -8
- package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
- package/src/player/hooks/timelinePlayerSync.ts +18 -8
- package/src/player/hooks/useShadowPreviewReload.ts +15 -4
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
- package/src/player/lib/playbackShortcuts.ts +18 -0
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +92 -0
- package/src/player/lib/timelineDOM.ts +0 -2
- package/src/player/lib/timelineElementHelpers.ts +47 -10
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/store/timelineElement.ts +2 -1
- package/src/timelineChromeExports.test.tsx +79 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/studioFileHistory.test.ts +14 -14
- package/src/utils/studioFileHistory.ts +31 -2
|
@@ -14,9 +14,9 @@ import { runtimeAudioId } from "../player/lib/timelineElementHelpers";
|
|
|
14
14
|
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
|
|
15
15
|
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
|
16
16
|
import {
|
|
17
|
-
applyPatchByTarget,
|
|
18
|
-
buildPatchTarget,
|
|
19
17
|
findTimelineElementInIframe,
|
|
18
|
+
operationChanges,
|
|
19
|
+
patchTimelineChangesInSource,
|
|
20
20
|
readFileContent,
|
|
21
21
|
type RecordEditInput,
|
|
22
22
|
} from "./timelineEditingHelpers";
|
|
@@ -197,52 +197,30 @@ export async function createAudioGroupAndAssignMembers({
|
|
|
197
197
|
property: HF_AUDIO_GROUP_ATTR,
|
|
198
198
|
value: groupId,
|
|
199
199
|
};
|
|
200
|
-
const
|
|
201
|
-
const
|
|
200
|
+
const groupPath = activeCompPath || "index.html";
|
|
201
|
+
const byPath = groupElementsByTargetPath(elements, activeCompPath);
|
|
202
|
+
const files: Record<string, (current: string) => string> = {};
|
|
203
|
+
for (const targetPath of new Set([...byPath.keys(), groupPath])) {
|
|
204
|
+
files[targetPath] = (current) => {
|
|
205
|
+
let patched = patchTimelineChangesInSource(
|
|
206
|
+
current,
|
|
207
|
+
targetPath,
|
|
208
|
+
operationChanges(byPath.get(targetPath) ?? [], groupOperation),
|
|
209
|
+
);
|
|
210
|
+
if (targetPath === groupPath) patched = insertGroupElement(patched, groupId, groupLabel);
|
|
211
|
+
if (patched !== current) pendingTimelineEditPathRef.current.add(targetPath);
|
|
212
|
+
return patched;
|
|
213
|
+
};
|
|
214
|
+
}
|
|
202
215
|
|
|
203
216
|
try {
|
|
204
|
-
for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
|
|
205
|
-
let patchedContent = await readFileContent(projectId, targetPath);
|
|
206
|
-
originalByPath.set(targetPath, patchedContent);
|
|
207
|
-
|
|
208
|
-
for (const element of fileElements) {
|
|
209
|
-
const patchTarget = buildPatchTarget(element);
|
|
210
|
-
if (!patchTarget) {
|
|
211
|
-
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
212
|
-
}
|
|
213
|
-
if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
|
|
214
|
-
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
215
|
-
}
|
|
216
|
-
patchedContent = applyPatchByTarget(patchedContent, patchTarget, groupOperation);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
files[targetPath] = patchedContent;
|
|
220
|
-
pendingTimelineEditPathRef.current.add(targetPath);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const groupPath = activeCompPath || "index.html";
|
|
224
|
-
let groupContent = files[groupPath];
|
|
225
|
-
if (groupContent === undefined) {
|
|
226
|
-
groupContent = await readFileContent(projectId, groupPath);
|
|
227
|
-
originalByPath.set(groupPath, groupContent);
|
|
228
|
-
}
|
|
229
|
-
const withGroupElement = insertGroupElement(groupContent, groupId, groupLabel);
|
|
230
|
-
if (withGroupElement !== groupContent) {
|
|
231
|
-
files[groupPath] = withGroupElement;
|
|
232
|
-
pendingTimelineEditPathRef.current.add(groupPath);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
217
|
const changedPaths = await saveProjectFilesWithHistory({
|
|
236
218
|
projectId,
|
|
237
219
|
label: groupLabel
|
|
238
220
|
? `Group ${elements.length} clips as ${groupLabel}`
|
|
239
221
|
: `Group ${elements.length} voice clips`,
|
|
240
222
|
files,
|
|
241
|
-
readFile:
|
|
242
|
-
const original = originalByPath.get(path);
|
|
243
|
-
if (original !== undefined) return original;
|
|
244
|
-
return readFileContent(projectId, path);
|
|
245
|
-
},
|
|
223
|
+
readFile: (path) => readFileContent(projectId, path),
|
|
246
224
|
writeFile: writeProjectFile,
|
|
247
225
|
recordEdit,
|
|
248
226
|
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type PersistTimelineBatchChange,
|
|
13
13
|
} from "./timelineEditingHelpers";
|
|
14
14
|
import type { TimelineElement } from "../player/store/playerStore";
|
|
15
|
+
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
|
|
15
16
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
16
17
|
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
17
18
|
import { timelineKeyframeSelectionKey } from "../player/components/timelineKeyframeIdentity";
|
|
@@ -244,6 +245,26 @@ describe("buildTimelineResizeTimingPatch", () => {
|
|
|
244
245
|
});
|
|
245
246
|
});
|
|
246
247
|
|
|
248
|
+
describe("buildTimelineResizeTimingPatch — source in-point read as playback reads it", () => {
|
|
249
|
+
it.each([
|
|
250
|
+
['data-playback-start="-1" data-media-start="2"', 4.5, 1.5],
|
|
251
|
+
['data-playback-start="2"', 4.5, 1.5],
|
|
252
|
+
['data-media-start="junk"', 5.5, 0.5],
|
|
253
|
+
['data-media-start="1.5s"', 5.5, 0.5],
|
|
254
|
+
])("%s, head to %s", (inPoint, start, expected) => {
|
|
255
|
+
const source = `<div id="root"><video id="a" class="clip" data-start="5" data-duration="3" ${inPoint}></video></div>`;
|
|
256
|
+
const element = el({ id: "a", tag: "video", domId: "a", start: 5, duration: 3 });
|
|
257
|
+
const host = document.createElement("div");
|
|
258
|
+
host.innerHTML = buildTimelineResizeTimingPatch(source, { id: "a" }, element, {
|
|
259
|
+
start,
|
|
260
|
+
duration: 8 - start,
|
|
261
|
+
playbackStart: undefined,
|
|
262
|
+
});
|
|
263
|
+
const video = host.querySelector("#a")!;
|
|
264
|
+
expect(readMediaOffsetSeconds((name) => video.getAttribute(name))).toBe(expected);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
247
268
|
describe("persistTimelineBatchEdit", () => {
|
|
248
269
|
const SOURCE = `<div id="root"><video id="a" class="clip" data-start="1" data-track-index="0"></video><video id="b" class="clip" data-start="2" data-track-index="1"></video></div>`;
|
|
249
270
|
|
|
@@ -15,7 +15,10 @@ import { getElementZIndex } from "../player/lib/layerOrdering";
|
|
|
15
15
|
import {
|
|
16
16
|
furthestClipEndFromSource,
|
|
17
17
|
getTimelineElementIdentity,
|
|
18
|
+
playbackStartAttributeForElement,
|
|
19
|
+
readPlaybackStartAttributes,
|
|
18
20
|
} from "../player/lib/timelineElementHelpers";
|
|
21
|
+
import { resolveTimelinePlaybackRate } from "../player/components/timelineGroupEditing";
|
|
19
22
|
import {
|
|
20
23
|
saveProjectFilesWithHistory,
|
|
21
24
|
writeProjectFilesWithHistoryInQueue,
|
|
@@ -199,13 +202,6 @@ export function removeIframeTimelineElements(
|
|
|
199
202
|
findTimelineElementInIframe(iframe, element, activeCompositionPath)?.remove();
|
|
200
203
|
}
|
|
201
204
|
|
|
202
|
-
export function playbackStartAttributeForElement(
|
|
203
|
-
element: Pick<TimelineElement, "kind" | "playbackStartAttr">,
|
|
204
|
-
): "data-media-start" | "data-playback-start" {
|
|
205
|
-
return element.playbackStartAttr === "playback-start" || element.kind === "composition"
|
|
206
|
-
? "data-playback-start"
|
|
207
|
-
: "data-media-start";
|
|
208
|
-
}
|
|
209
205
|
// fallow-ignore-next-line complexity
|
|
210
206
|
function resolveResizePlaybackStart(
|
|
211
207
|
original: string,
|
|
@@ -219,15 +215,19 @@ function resolveResizePlaybackStart(
|
|
|
219
215
|
}
|
|
220
216
|
const trimDelta = updates.start - element.start;
|
|
221
217
|
if (trimDelta === 0) return null;
|
|
222
|
-
const
|
|
223
|
-
readAttributeByTarget(original, target,
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
218
|
+
const source = readPlaybackStartAttributes((name) =>
|
|
219
|
+
readAttributeByTarget(original, target, name),
|
|
220
|
+
);
|
|
221
|
+
if (source.playbackStart == null) return null;
|
|
222
|
+
const attrName = playbackStartAttributeForElement({ kind: element.kind, ...source }).slice(
|
|
223
|
+
"data-".length,
|
|
224
|
+
);
|
|
228
225
|
return {
|
|
229
226
|
attrName,
|
|
230
|
-
value: Math.max(
|
|
227
|
+
value: Math.max(
|
|
228
|
+
0,
|
|
229
|
+
source.playbackStart + trimDelta * resolveTimelinePlaybackRate(element.playbackRate),
|
|
230
|
+
),
|
|
231
231
|
};
|
|
232
232
|
}
|
|
233
233
|
|
|
@@ -308,25 +308,26 @@ export interface PersistTimelineEditInput {
|
|
|
308
308
|
|
|
309
309
|
export async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
|
|
310
310
|
const targetPath = input.element.sourceFile || input.activeCompPath || "index.html";
|
|
311
|
-
const originalContent = await readFileContent(input.projectId, targetPath);
|
|
312
|
-
|
|
313
311
|
const patchTarget = buildPatchTarget(input.element);
|
|
314
312
|
if (!patchTarget) {
|
|
315
313
|
throw new Error(`Timeline element ${input.element.id} is missing a patchable target`);
|
|
316
314
|
}
|
|
317
315
|
|
|
318
|
-
const patchedContent = input.buildPatches(originalContent, patchTarget);
|
|
319
|
-
if (patchedContent === originalContent) {
|
|
320
|
-
throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
316
|
input.pendingTimelineEditPathRef.current.add(targetPath);
|
|
324
317
|
await saveProjectFilesWithHistory({
|
|
325
318
|
projectId: input.projectId,
|
|
326
319
|
label: input.label,
|
|
327
320
|
coalesceKey: input.coalesceKey,
|
|
328
|
-
files: {
|
|
329
|
-
|
|
321
|
+
files: {
|
|
322
|
+
[targetPath]: (current) => {
|
|
323
|
+
const patched = input.buildPatches(current, patchTarget);
|
|
324
|
+
if (patched === current) {
|
|
325
|
+
throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`);
|
|
326
|
+
}
|
|
327
|
+
return patched;
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
readFile: (path) => readFileContent(input.projectId, path),
|
|
330
331
|
writeFile: input.writeProjectFile,
|
|
331
332
|
recordEdit: input.recordEdit,
|
|
332
333
|
});
|
|
@@ -337,6 +338,36 @@ export interface PersistTimelineBatchChange {
|
|
|
337
338
|
buildPatches: (original: string, target: PatchTarget) => string;
|
|
338
339
|
}
|
|
339
340
|
|
|
341
|
+
/** One batch change per element, each applying the same patch operation. */
|
|
342
|
+
export function operationChanges(
|
|
343
|
+
elements: readonly TimelineElement[],
|
|
344
|
+
operation: PatchOperation,
|
|
345
|
+
): PersistTimelineBatchChange[] {
|
|
346
|
+
return elements.map((element) => ({
|
|
347
|
+
element,
|
|
348
|
+
buildPatches: (html, target) => applyPatchByTarget(html, target, operation),
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Patches each change into `source`, failing loudly on a target the file does not hold. */
|
|
353
|
+
export function patchTimelineChangesInSource(
|
|
354
|
+
source: string,
|
|
355
|
+
targetPath: string,
|
|
356
|
+
changes: readonly PersistTimelineBatchChange[],
|
|
357
|
+
): string {
|
|
358
|
+
let current = source;
|
|
359
|
+
for (const { element, buildPatches } of changes) {
|
|
360
|
+
const target = buildPatchTarget(element);
|
|
361
|
+
if (!target) throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
362
|
+
// Resolve first: a member already at its target values patches to the same string, a missing one must throw.
|
|
363
|
+
if (!findTagByTarget(current, target)) {
|
|
364
|
+
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
365
|
+
}
|
|
366
|
+
current = buildPatches(current, target);
|
|
367
|
+
}
|
|
368
|
+
return current;
|
|
369
|
+
}
|
|
370
|
+
|
|
340
371
|
export interface PersistTimelineBatchEditInput {
|
|
341
372
|
projectId: string;
|
|
342
373
|
activeCompPath: string | null;
|
|
@@ -353,50 +384,24 @@ export interface PersistTimelineBatchEditInput {
|
|
|
353
384
|
export async function persistTimelineBatchEdit(
|
|
354
385
|
input: PersistTimelineBatchEditInput,
|
|
355
386
|
): Promise<void> {
|
|
356
|
-
const
|
|
357
|
-
const patchedByPath = new Map<string, string>();
|
|
358
|
-
|
|
387
|
+
const changesByPath = new Map<string, PersistTimelineBatchChange[]>();
|
|
359
388
|
for (const change of input.changes) {
|
|
360
389
|
const targetPath = change.element.sourceFile || input.activeCompPath || "index.html";
|
|
361
|
-
|
|
362
|
-
originals.get(targetPath) ?? (await readFileContent(input.projectId, targetPath));
|
|
363
|
-
originals.set(targetPath, original);
|
|
364
|
-
|
|
365
|
-
const patchTarget = buildPatchTarget(change.element);
|
|
366
|
-
if (!patchTarget) {
|
|
367
|
-
throw new Error(`Timeline element ${change.element.id} is missing a patchable target`);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const current = patchedByPath.get(targetPath) ?? original;
|
|
371
|
-
// Resolve the target FIRST: byte-identical output below is only a legit
|
|
372
|
-
// no-op when the member actually resolved in the source. A mistargeted
|
|
373
|
-
// member (stale id/selector) must fail loudly like the single-edit path,
|
|
374
|
-
// not be silently dropped as "already at target".
|
|
375
|
-
if (!findTagByTarget(current, patchTarget)) {
|
|
376
|
-
throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`);
|
|
377
|
-
}
|
|
378
|
-
const patched = change.buildPatches(current, patchTarget);
|
|
379
|
-
// The target resolved, so a member whose attributes already hold the target
|
|
380
|
-
// values patches to the identical string — e.g. a track-insert renumber
|
|
381
|
-
// where one clip's lane is already correct. That is a legitimate no-op:
|
|
382
|
-
// skip it instead of aborting (and rolling back) the whole batch.
|
|
383
|
-
if (patched === current) continue;
|
|
384
|
-
patchedByPath.set(targetPath, patched);
|
|
390
|
+
changesByPath.set(targetPath, [...(changesByPath.get(targetPath) ?? []), change]);
|
|
385
391
|
}
|
|
392
|
+
const buildFile = (targetPath: string) => (original: string) => {
|
|
393
|
+
const next = patchTimelineChangesInSource(original, targetPath, changesByPath.get(targetPath)!);
|
|
394
|
+
if (next !== original) input.pendingTimelineEditPathRef.current.add(targetPath);
|
|
395
|
+
return next;
|
|
396
|
+
};
|
|
386
397
|
|
|
387
|
-
if (patchedByPath.size === 0) return;
|
|
388
|
-
|
|
389
|
-
const files = Object.fromEntries(patchedByPath);
|
|
390
|
-
for (const targetPath of Object.keys(files)) {
|
|
391
|
-
input.pendingTimelineEditPathRef.current.add(targetPath);
|
|
392
|
-
}
|
|
393
398
|
await saveProjectFilesWithHistory({
|
|
394
399
|
projectId: input.projectId,
|
|
395
400
|
label: input.label,
|
|
396
401
|
coalesceKey: input.coalesceKey,
|
|
397
402
|
coalesceMs: input.coalesceMs,
|
|
398
|
-
files,
|
|
399
|
-
readFile:
|
|
403
|
+
files: Object.fromEntries([...changesByPath.keys()].map((path) => [path, buildFile(path)])),
|
|
404
|
+
readFile: (path) => readFileContent(input.projectId, path),
|
|
400
405
|
writeFile: input.writeProjectFile,
|
|
401
406
|
recordEdit: input.recordEdit,
|
|
402
407
|
});
|
|
@@ -427,7 +432,7 @@ export function readTargetAttribute(
|
|
|
427
432
|
return readAttributeByTarget(html, patchTarget, attr) ?? null;
|
|
428
433
|
}
|
|
429
434
|
|
|
430
|
-
export {
|
|
435
|
+
export { formatTimelineAttributeNumber, formatTimelineMediaOffset };
|
|
431
436
|
|
|
432
437
|
export { patchDocumentRootDuration } from "./timelineEditingGsap";
|
|
433
438
|
|
|
@@ -490,8 +495,8 @@ export async function persistElementAttribute({
|
|
|
490
495
|
return await writeProjectFilesWithHistoryInQueue({
|
|
491
496
|
projectId,
|
|
492
497
|
label,
|
|
493
|
-
files: { [targetPath]: patched },
|
|
494
|
-
readFile: async (
|
|
498
|
+
files: { [targetPath]: () => patched },
|
|
499
|
+
readFile: async () => before,
|
|
495
500
|
writeFile: writeProjectFile,
|
|
496
501
|
recordEdit,
|
|
497
502
|
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
buildTimelineMoveTimingPatch,
|
|
5
|
+
buildTimelineResizeTimingPatch,
|
|
6
|
+
persistElementAttribute,
|
|
7
|
+
persistTimelineBatchEdit,
|
|
8
|
+
persistTimelineEdit,
|
|
9
|
+
} from "./timelineEditingHelpers";
|
|
10
|
+
import type { TimelineElement } from "../player/store/playerStore";
|
|
11
|
+
import { finishClipTimingFallback } from "./timelineTimingSync";
|
|
12
|
+
import { applyColorGradingScopeUpdate } from "../components/studioColorGradingScope";
|
|
13
|
+
|
|
14
|
+
const SOURCE =
|
|
15
|
+
'<div id="root"><video id="a" class="clip" data-start="1" data-duration="5" data-track-index="0"></video>' +
|
|
16
|
+
'<video id="b" class="clip" data-start="2" data-duration="5" data-track-index="1"></video>' +
|
|
17
|
+
'<script>gsap.to("#a", { x: 1 }, 1)</script></div>';
|
|
18
|
+
|
|
19
|
+
// Refuses a write whose base is stale, like the server's If-Match; one write can be held mid-flight.
|
|
20
|
+
// A GSAP shift lands 10 ms after it is posted and releases the held write.
|
|
21
|
+
function fakeProject() {
|
|
22
|
+
let file = SOURCE;
|
|
23
|
+
const refused: string[] = [];
|
|
24
|
+
let writes = 0;
|
|
25
|
+
let release = () => {};
|
|
26
|
+
const firstWrite = new Promise<void>((resolve) => (release = resolve));
|
|
27
|
+
vi.stubGlobal(
|
|
28
|
+
"fetch",
|
|
29
|
+
vi.fn(async (input: string) => {
|
|
30
|
+
if (input.includes("/gsap-mutation-capabilities")) {
|
|
31
|
+
return Response.json({ atomicOwnershipPairs: true });
|
|
32
|
+
}
|
|
33
|
+
if (input.includes("/gsap-mutations/")) {
|
|
34
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
35
|
+
const before = file;
|
|
36
|
+
file = file.replace("{ x: 1 }, 1)", "{ x: 1 }, 3)");
|
|
37
|
+
release();
|
|
38
|
+
return Response.json({ mutated: true, before, after: file, scriptText: null });
|
|
39
|
+
}
|
|
40
|
+
return Response.json({ content: file });
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
const writeProjectFile = async (path: string, content: string, expected?: string) => {
|
|
44
|
+
if (++writes === 1) await firstWrite;
|
|
45
|
+
if (expected !== undefined && expected !== file && content !== file) {
|
|
46
|
+
refused.push(path);
|
|
47
|
+
throw new Error(`409 conflict on ${path}`);
|
|
48
|
+
}
|
|
49
|
+
file = content;
|
|
50
|
+
};
|
|
51
|
+
return { writeProjectFile, refused, release: () => release(), read: () => file };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Project = ReturnType<typeof fakeProject>;
|
|
55
|
+
|
|
56
|
+
function clip(id: string): TimelineElement {
|
|
57
|
+
return { id, key: id, domId: id, tag: "video", label: id, start: 0, duration: 5, track: 0 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const shared = (project: Project) => ({
|
|
61
|
+
projectId: "p1",
|
|
62
|
+
activeCompPath: "index.html",
|
|
63
|
+
writeProjectFile: project.writeProjectFile,
|
|
64
|
+
recordEdit: async () => {},
|
|
65
|
+
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const move = (project: Project, id: string, start: number) =>
|
|
69
|
+
persistTimelineEdit({
|
|
70
|
+
...shared(project),
|
|
71
|
+
element: clip(id),
|
|
72
|
+
label: "Move clip",
|
|
73
|
+
buildPatches: (original, target) => buildTimelineMoveTimingPatch(original, target, start, 5),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const trim = (project: Project, id: string, duration: number) =>
|
|
77
|
+
persistTimelineBatchEdit({
|
|
78
|
+
...shared(project),
|
|
79
|
+
label: "Trim clip",
|
|
80
|
+
changes: [
|
|
81
|
+
{
|
|
82
|
+
element: clip(id),
|
|
83
|
+
buildPatches: (original, target) =>
|
|
84
|
+
buildTimelineResizeTimingPatch(original, target, clip(id), { start: 0, duration }),
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Starts the second save while the first one's write is in flight; both must land unrefused.
|
|
90
|
+
async function expectBothLand(
|
|
91
|
+
project: Project,
|
|
92
|
+
first: () => Promise<unknown>,
|
|
93
|
+
second: () => Promise<unknown>,
|
|
94
|
+
) {
|
|
95
|
+
const saves = [first()];
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
97
|
+
saves.push(second());
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
99
|
+
project.release();
|
|
100
|
+
const results = await Promise.allSettled(saves);
|
|
101
|
+
expect(project.refused).toEqual([]);
|
|
102
|
+
expect(results.map((r) => r.status)).toEqual(["fulfilled", "fulfilled"]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
vi.unstubAllGlobals();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("two quick timeline saves on one file", () => {
|
|
110
|
+
it("lands both moves", async () => {
|
|
111
|
+
const project = fakeProject();
|
|
112
|
+
await expectBothLand(
|
|
113
|
+
project,
|
|
114
|
+
() => move(project, "a", 3),
|
|
115
|
+
() => move(project, "b", 4),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(project.read()).toContain('id="a" class="clip" data-start="3"');
|
|
119
|
+
expect(project.read()).toContain('id="b" class="clip" data-start="4"');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("lands both trims", async () => {
|
|
123
|
+
const project = fakeProject();
|
|
124
|
+
await expectBothLand(
|
|
125
|
+
project,
|
|
126
|
+
() => trim(project, "a", 3),
|
|
127
|
+
() => trim(project, "b", 2),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(project.read()).toMatch(/id="a"[^>]*data-duration="3"/);
|
|
131
|
+
expect(project.read()).toMatch(/id="b"[^>]*data-duration="2"/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("lands a move started while a volume save is writing", async () => {
|
|
135
|
+
const project = fakeProject();
|
|
136
|
+
const volume = () =>
|
|
137
|
+
persistElementAttribute({
|
|
138
|
+
...shared(project),
|
|
139
|
+
targetPath: "index.html",
|
|
140
|
+
patchTarget: { id: "a" },
|
|
141
|
+
attr: "data-volume",
|
|
142
|
+
value: "0.5",
|
|
143
|
+
label: "Set volume",
|
|
144
|
+
patchLive: () => {},
|
|
145
|
+
onFileRead: () => {},
|
|
146
|
+
});
|
|
147
|
+
await expectBothLand(project, volume, () => move(project, "b", 4));
|
|
148
|
+
|
|
149
|
+
expect(project.read()).toContain('data-volume="0.5"');
|
|
150
|
+
expect(project.read()).toContain('id="b" class="clip" data-start="4"');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("keeps a move when a color grade starts while it is writing", async () => {
|
|
154
|
+
const project = fakeProject();
|
|
155
|
+
const grade = () =>
|
|
156
|
+
applyColorGradingScopeUpdate({
|
|
157
|
+
scope: "source-file",
|
|
158
|
+
value: "warm",
|
|
159
|
+
selectedSourceFile: "index.html",
|
|
160
|
+
fileTree: ["index.html"],
|
|
161
|
+
projectId: "p1",
|
|
162
|
+
waitForPendingDomEditSaves: async () => {},
|
|
163
|
+
readProjectFile: async () => project.read(),
|
|
164
|
+
writeProjectFile: project.writeProjectFile,
|
|
165
|
+
recordEdit: async () => {},
|
|
166
|
+
reloadPreview: () => {},
|
|
167
|
+
showToast: () => {},
|
|
168
|
+
});
|
|
169
|
+
await expectBothLand(project, () => move(project, "a", 3), grade);
|
|
170
|
+
|
|
171
|
+
expect(project.read()).toContain('id="a" class="clip" data-start="3"');
|
|
172
|
+
expect(project.read()).toContain('data-color-grading="warm"');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("lands a move started while the previous move's GSAP rewrite is in flight", async () => {
|
|
176
|
+
const project = fakeProject();
|
|
177
|
+
const shiftA = finishClipTimingFallback({
|
|
178
|
+
iframe: null,
|
|
179
|
+
reloadPreview: () => {},
|
|
180
|
+
projectId: "p1",
|
|
181
|
+
targetPath: "index.html",
|
|
182
|
+
domId: "a",
|
|
183
|
+
label: "Move clip",
|
|
184
|
+
recordEdit: async () => {},
|
|
185
|
+
writeProjectFile: project.writeProjectFile,
|
|
186
|
+
edit: { kind: "shift", delta: 2 },
|
|
187
|
+
});
|
|
188
|
+
const results = await Promise.allSettled([shiftA, move(project, "b", 4)]);
|
|
189
|
+
|
|
190
|
+
expect(project.refused).toEqual([]);
|
|
191
|
+
expect(results.map((r) => r.status)).toEqual(["fulfilled", "fulfilled"]);
|
|
192
|
+
expect(project.read()).toContain('gsap.to("#a", { x: 1 }, 3)');
|
|
193
|
+
expect(project.read()).toContain('id="b" class="clip" data-start="4"');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
shiftGsapPositions,
|
|
12
12
|
} from "./timelineTimingSync";
|
|
13
13
|
|
|
14
|
+
const writeProjectFile = async () => {};
|
|
15
|
+
|
|
14
16
|
afterEach(() => {
|
|
15
17
|
usePlayerStore.getState().reset();
|
|
16
18
|
vi.restoreAllMocks();
|
|
@@ -68,6 +70,7 @@ function clipFallbackInput(overrides: {
|
|
|
68
70
|
domId: "clip",
|
|
69
71
|
label: "Move timeline clip",
|
|
70
72
|
recordEdit: overrides.recordEdit as never,
|
|
73
|
+
writeProjectFile,
|
|
71
74
|
edit: { kind: "shift", delta: 1 } as const,
|
|
72
75
|
};
|
|
73
76
|
}
|
|
@@ -495,6 +498,7 @@ describe("nothing-to-rewrite timing edits rebind in place (no script re-executio
|
|
|
495
498
|
const element = { sourceFile: "index.html" } as TimelineElement;
|
|
496
499
|
|
|
497
500
|
await finishGroupTimingGsapFallback({
|
|
501
|
+
writeProjectFile,
|
|
498
502
|
projectId: "p1",
|
|
499
503
|
iframe,
|
|
500
504
|
reloadPreview,
|
|
@@ -522,6 +526,7 @@ describe("nothing-to-rewrite timing edits rebind in place (no script re-executio
|
|
|
522
526
|
const reloadPreview = vi.fn();
|
|
523
527
|
|
|
524
528
|
await finishGroupTimingGsapFallback({
|
|
529
|
+
writeProjectFile,
|
|
525
530
|
projectId: "p1",
|
|
526
531
|
iframe,
|
|
527
532
|
reloadPreview,
|
|
@@ -602,6 +607,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
|
|
|
602
607
|
let clipIndex = 0;
|
|
603
608
|
|
|
604
609
|
await finishGroupTimingGsapFallback({
|
|
610
|
+
writeProjectFile,
|
|
605
611
|
projectId: "p1",
|
|
606
612
|
iframe: buildLivePreviewIframe().iframe,
|
|
607
613
|
reloadPreview: vi.fn(),
|
|
@@ -632,6 +638,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
|
|
|
632
638
|
let clipIndex = 0;
|
|
633
639
|
|
|
634
640
|
await finishGroupTimingGsapFallback({
|
|
641
|
+
writeProjectFile,
|
|
635
642
|
projectId: "p1",
|
|
636
643
|
iframe: buildLivePreviewIframe().iframe,
|
|
637
644
|
reloadPreview: vi.fn(),
|
|
@@ -669,6 +676,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
|
|
|
669
676
|
|
|
670
677
|
const reloadPreview = vi.fn();
|
|
671
678
|
await finishGroupTimingGsapFallback({
|
|
679
|
+
writeProjectFile,
|
|
672
680
|
projectId: "p1",
|
|
673
681
|
iframe: buildLivePreviewIframe().iframe,
|
|
674
682
|
reloadPreview,
|
|
@@ -698,6 +706,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
|
|
|
698
706
|
let clipIndex = 0;
|
|
699
707
|
|
|
700
708
|
await finishGroupTimingGsapFallback({
|
|
709
|
+
writeProjectFile,
|
|
701
710
|
projectId: "p1",
|
|
702
711
|
iframe: buildLivePreviewIframe().iframe,
|
|
703
712
|
reloadPreview,
|
|
@@ -728,6 +737,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
|
|
|
728
737
|
const recordEdit = vi.fn(async () => {});
|
|
729
738
|
|
|
730
739
|
await finishGroupTimingGsapFallback({
|
|
740
|
+
writeProjectFile,
|
|
731
741
|
projectId: "p1",
|
|
732
742
|
iframe: buildLivePreviewIframe().iframe,
|
|
733
743
|
reloadPreview: vi.fn(),
|
|
@@ -762,6 +772,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
|
|
|
762
772
|
|
|
763
773
|
const reloadPreview = vi.fn();
|
|
764
774
|
await finishGroupTimingGsapFallback({
|
|
775
|
+
writeProjectFile,
|
|
765
776
|
projectId: "p1",
|
|
766
777
|
iframe: buildLivePreviewIframe().iframe,
|
|
767
778
|
reloadPreview,
|
|
@@ -797,6 +808,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
|
|
|
797
808
|
const server = installOwnedFileServer(contents, { failReadAt: 1 });
|
|
798
809
|
|
|
799
810
|
await finishGroupTimingGsapFallback({
|
|
811
|
+
writeProjectFile,
|
|
800
812
|
projectId: "p1",
|
|
801
813
|
iframe: buildLivePreviewIframe().iframe,
|
|
802
814
|
reloadPreview: vi.fn(),
|