@hyperframes/studio 0.5.0-alpha.10 → 0.5.0-alpha.12
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/index-JhhmFie-.js +105 -0
- package/dist/assets/index-KioPDrX6.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +371 -149
- package/src/components/nle/NLELayout.tsx +43 -8
- package/src/components/sidebar/LeftSidebar.tsx +64 -36
- package/src/hooks/usePersistentEditHistory.test.ts +255 -0
- package/src/hooks/usePersistentEditHistory.ts +336 -0
- package/src/icons/SystemIcons.tsx +4 -0
- package/src/player/components/PlayerControls.tsx +3 -44
- package/src/player/components/Timeline.tsx +5 -2
- package/src/player/components/TimelineClip.tsx +2 -1
- package/src/player/components/timelineTheme.test.ts +19 -0
- package/src/player/components/timelineTheme.ts +8 -4
- package/src/player/hooks/useTimelinePlayer.test.ts +139 -0
- package/src/player/hooks/useTimelinePlayer.ts +201 -89
- package/src/player/lib/time.test.ts +11 -1
- package/src/player/lib/time.ts +6 -0
- package/src/player/store/playerStore.ts +1 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +38 -0
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/dist/assets/index-DKaNgV2Z.css +0 -1
- package/dist/assets/index-peNJzL-4.js +0 -105
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useRef, useCallback } from "react";
|
|
2
2
|
import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
|
-
import {
|
|
4
|
+
import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
5
5
|
import { useCaptionStore } from "../../captions/store";
|
|
6
6
|
|
|
7
7
|
interface PlaybackAdapter {
|
|
@@ -64,9 +64,12 @@ function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
|
|
67
|
-
|
|
67
|
+
const win = el.ownerDocument.defaultView ?? window;
|
|
68
|
+
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
|
|
69
|
+
const ImageElementCtor = win.HTMLImageElement ?? globalThis.HTMLImageElement;
|
|
70
|
+
if (el instanceof MediaElementCtor || el instanceof ImageElementCtor) return el;
|
|
68
71
|
const candidate = el.querySelector("video, audio, img");
|
|
69
|
-
return candidate instanceof
|
|
72
|
+
return candidate instanceof MediaElementCtor || candidate instanceof ImageElementCtor
|
|
70
73
|
? candidate
|
|
71
74
|
: null;
|
|
72
75
|
}
|
|
@@ -92,7 +95,9 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
|
|
|
92
95
|
const src = mediaEl.getAttribute("src");
|
|
93
96
|
if (src) entry.src = src;
|
|
94
97
|
|
|
95
|
-
|
|
98
|
+
const win = mediaEl.ownerDocument.defaultView ?? window;
|
|
99
|
+
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
|
|
100
|
+
if (typeof MediaElementCtor === "undefined" || !(mediaEl instanceof MediaElementCtor)) return;
|
|
96
101
|
|
|
97
102
|
const sourceDurationAttr =
|
|
98
103
|
el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
|
|
@@ -165,11 +170,24 @@ export function shouldIgnorePlaybackShortcutEvent(
|
|
|
165
170
|
);
|
|
166
171
|
}
|
|
167
172
|
|
|
173
|
+
function getTimelineElementDisplayLabel(input: {
|
|
174
|
+
id?: string | null;
|
|
175
|
+
label?: string | null;
|
|
176
|
+
tag?: string | null;
|
|
177
|
+
}): string {
|
|
178
|
+
const label = input.label?.trim();
|
|
179
|
+
if (label) return label;
|
|
180
|
+
const id = input.id?.trim();
|
|
181
|
+
if (id) return id;
|
|
182
|
+
const tag = input.tag?.trim().toLowerCase();
|
|
183
|
+
return tag ? `${tag} clip` : "Timeline clip";
|
|
184
|
+
}
|
|
185
|
+
|
|
168
186
|
/**
|
|
169
187
|
* Parse [data-start] elements from a Document into TimelineElement[].
|
|
170
188
|
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
|
|
171
189
|
*/
|
|
172
|
-
function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
|
|
190
|
+
export function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
|
|
173
191
|
const rootComp = doc.querySelector("[data-composition-id]");
|
|
174
192
|
const nodes = doc.querySelectorAll("[data-start]");
|
|
175
193
|
const els: TimelineElement[] = [];
|
|
@@ -200,17 +218,24 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
|
|
|
200
218
|
const selector = getTimelineElementSelector(el);
|
|
201
219
|
const sourceFile = getTimelineElementSourceFile(el);
|
|
202
220
|
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
|
|
203
|
-
const
|
|
221
|
+
const label = getTimelineElementDisplayLabel({
|
|
222
|
+
id: el.id || compId || null,
|
|
223
|
+
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
|
|
224
|
+
tag: tagLower,
|
|
225
|
+
});
|
|
226
|
+
const identity = buildTimelineElementIdentity({
|
|
227
|
+
preferredId: el.id || compId || null,
|
|
228
|
+
label,
|
|
229
|
+
fallbackIndex: els.length,
|
|
230
|
+
domId: el.id || undefined,
|
|
231
|
+
selector,
|
|
232
|
+
selectorIndex,
|
|
233
|
+
sourceFile,
|
|
234
|
+
});
|
|
204
235
|
const entry: TimelineElement = {
|
|
205
|
-
id,
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
fallbackIndex: els.length,
|
|
209
|
-
domId: el.id || undefined,
|
|
210
|
-
selector,
|
|
211
|
-
selectorIndex,
|
|
212
|
-
sourceFile,
|
|
213
|
-
}),
|
|
236
|
+
id: identity.id,
|
|
237
|
+
label,
|
|
238
|
+
key: identity.key,
|
|
214
239
|
tag: tagLower,
|
|
215
240
|
start,
|
|
216
241
|
duration: dur,
|
|
@@ -310,6 +335,39 @@ function buildTimelineElementKey(params: {
|
|
|
310
335
|
if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
|
|
311
336
|
return `${scope}:${params.id}:${params.fallbackIndex}`;
|
|
312
337
|
}
|
|
338
|
+
function buildTimelineElementIdentity(params: {
|
|
339
|
+
preferredId?: string | null;
|
|
340
|
+
label: string;
|
|
341
|
+
fallbackIndex: number;
|
|
342
|
+
domId?: string;
|
|
343
|
+
selector?: string;
|
|
344
|
+
selectorIndex?: number;
|
|
345
|
+
sourceFile?: string;
|
|
346
|
+
}): { id: string; key: string } {
|
|
347
|
+
const id =
|
|
348
|
+
params.preferredId?.trim() ||
|
|
349
|
+
buildTimelineElementKey({
|
|
350
|
+
id: params.label,
|
|
351
|
+
fallbackIndex: params.fallbackIndex,
|
|
352
|
+
domId: params.domId,
|
|
353
|
+
selector: params.selector,
|
|
354
|
+
selectorIndex: params.selectorIndex,
|
|
355
|
+
sourceFile: params.sourceFile,
|
|
356
|
+
});
|
|
357
|
+
const key = buildTimelineElementKey({
|
|
358
|
+
id,
|
|
359
|
+
fallbackIndex: params.fallbackIndex,
|
|
360
|
+
domId: params.domId,
|
|
361
|
+
selector: params.selector,
|
|
362
|
+
selectorIndex: params.selectorIndex,
|
|
363
|
+
sourceFile: params.sourceFile,
|
|
364
|
+
});
|
|
365
|
+
return { id, key };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function getTimelineElementIdentity(element: TimelineElement): string {
|
|
369
|
+
return element.key ?? element.id;
|
|
370
|
+
}
|
|
313
371
|
|
|
314
372
|
function getTimelineDomNodes(doc: Document): Element[] {
|
|
315
373
|
const rootComp = doc.querySelector("[data-composition-id]");
|
|
@@ -352,6 +410,103 @@ export function findTimelineDomNodeForClip(
|
|
|
352
410
|
return candidates[fallbackIndex] ?? null;
|
|
353
411
|
}
|
|
354
412
|
|
|
413
|
+
export function createTimelineElementFromManifestClip(params: {
|
|
414
|
+
clip: ClipManifestClip;
|
|
415
|
+
fallbackIndex: number;
|
|
416
|
+
doc?: Document | null;
|
|
417
|
+
hostEl?: Element | null;
|
|
418
|
+
}): TimelineElement {
|
|
419
|
+
const { clip, fallbackIndex, doc } = params;
|
|
420
|
+
let hostEl = params.hostEl ?? null;
|
|
421
|
+
const label = getTimelineElementDisplayLabel({
|
|
422
|
+
id: clip.id,
|
|
423
|
+
label: clip.label,
|
|
424
|
+
tag: clip.tagName || clip.kind,
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
let domId: string | undefined;
|
|
428
|
+
let selector: string | undefined;
|
|
429
|
+
let selectorIndex: number | undefined;
|
|
430
|
+
let sourceFile: string | undefined;
|
|
431
|
+
|
|
432
|
+
if (hostEl) {
|
|
433
|
+
domId = hostEl.id || undefined;
|
|
434
|
+
selector = getTimelineElementSelector(hostEl);
|
|
435
|
+
selectorIndex =
|
|
436
|
+
doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
|
|
437
|
+
sourceFile = getTimelineElementSourceFile(hostEl);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const identity = buildTimelineElementIdentity({
|
|
441
|
+
preferredId: clip.id,
|
|
442
|
+
label,
|
|
443
|
+
fallbackIndex,
|
|
444
|
+
domId,
|
|
445
|
+
selector,
|
|
446
|
+
selectorIndex,
|
|
447
|
+
sourceFile,
|
|
448
|
+
});
|
|
449
|
+
const entry: TimelineElement = {
|
|
450
|
+
id: identity.id,
|
|
451
|
+
label,
|
|
452
|
+
key: identity.key,
|
|
453
|
+
tag: clip.tagName || clip.kind,
|
|
454
|
+
start: clip.start,
|
|
455
|
+
duration: clip.duration,
|
|
456
|
+
track: clip.track,
|
|
457
|
+
domId,
|
|
458
|
+
selector,
|
|
459
|
+
selectorIndex,
|
|
460
|
+
sourceFile,
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
if (hostEl) {
|
|
464
|
+
applyMediaMetadataFromElement(entry, hostEl);
|
|
465
|
+
}
|
|
466
|
+
if (clip.assetUrl) entry.src = clip.assetUrl;
|
|
467
|
+
if (clip.kind === "composition" && clip.compositionId) {
|
|
468
|
+
let resolvedSrc = clip.compositionSrc;
|
|
469
|
+
if (!resolvedSrc) {
|
|
470
|
+
hostEl = doc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
|
|
471
|
+
resolvedSrc =
|
|
472
|
+
hostEl?.getAttribute("data-composition-src") ??
|
|
473
|
+
hostEl?.getAttribute("data-composition-file") ??
|
|
474
|
+
null;
|
|
475
|
+
}
|
|
476
|
+
if (resolvedSrc) {
|
|
477
|
+
entry.compositionSrc = resolvedSrc;
|
|
478
|
+
} else if (hostEl) {
|
|
479
|
+
const innerVideo = hostEl.querySelector("video[src]");
|
|
480
|
+
if (innerVideo) {
|
|
481
|
+
entry.src = innerVideo.getAttribute("src") || undefined;
|
|
482
|
+
entry.tag = "video";
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (hostEl) {
|
|
486
|
+
entry.domId = hostEl.id || undefined;
|
|
487
|
+
entry.selector = getTimelineElementSelector(hostEl);
|
|
488
|
+
entry.selectorIndex =
|
|
489
|
+
doc && entry.selector
|
|
490
|
+
? getTimelineElementSelectorIndex(doc, hostEl, entry.selector)
|
|
491
|
+
: undefined;
|
|
492
|
+
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
|
493
|
+
const nextIdentity = buildTimelineElementIdentity({
|
|
494
|
+
preferredId: clip.id,
|
|
495
|
+
label,
|
|
496
|
+
fallbackIndex,
|
|
497
|
+
domId: entry.domId,
|
|
498
|
+
selector: entry.selector,
|
|
499
|
+
selectorIndex: entry.selectorIndex,
|
|
500
|
+
sourceFile: entry.sourceFile,
|
|
501
|
+
});
|
|
502
|
+
entry.id = nextIdentity.id;
|
|
503
|
+
entry.key = nextIdentity.key;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return entry;
|
|
508
|
+
}
|
|
509
|
+
|
|
355
510
|
function findTimelineDomNode(doc: Document, id: string): Element | null {
|
|
356
511
|
return (
|
|
357
512
|
doc.getElementById(id) ??
|
|
@@ -380,6 +535,10 @@ export function buildStandaloneRootTimelineElement(params: {
|
|
|
380
535
|
|
|
381
536
|
return {
|
|
382
537
|
id: params.compositionId,
|
|
538
|
+
label: getTimelineElementDisplayLabel({
|
|
539
|
+
id: params.compositionId,
|
|
540
|
+
tag: params.tagName,
|
|
541
|
+
}),
|
|
383
542
|
key: buildTimelineElementKey({
|
|
384
543
|
id: params.compositionId,
|
|
385
544
|
fallbackIndex: 0,
|
|
@@ -500,8 +659,10 @@ export function mergeTimelineElementsPreservingDowngrades(
|
|
|
500
659
|
return nextElements;
|
|
501
660
|
}
|
|
502
661
|
|
|
503
|
-
const
|
|
504
|
-
const preserved = currentElements.filter(
|
|
662
|
+
const nextIdentities = new Set(nextElements.map(getTimelineElementIdentity));
|
|
663
|
+
const preserved = currentElements.filter(
|
|
664
|
+
(element) => !nextIdentities.has(getTimelineElementIdentity(element)),
|
|
665
|
+
);
|
|
505
666
|
if (preserved.length === 0) return nextElements;
|
|
506
667
|
return [...nextElements, ...preserved];
|
|
507
668
|
}
|
|
@@ -743,7 +904,7 @@ export function useTimelinePlayer() {
|
|
|
743
904
|
(deltaFrames: number) => {
|
|
744
905
|
const adapter = getAdapter();
|
|
745
906
|
const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
|
|
746
|
-
seek(currentTime
|
|
907
|
+
seek(stepFrameTime(currentTime, deltaFrames, STUDIO_PREVIEW_FPS));
|
|
747
908
|
},
|
|
748
909
|
[getAdapter, seek],
|
|
749
910
|
);
|
|
@@ -876,72 +1037,16 @@ export function useTimelinePlayer() {
|
|
|
876
1037
|
}
|
|
877
1038
|
const usedHostEls = new Set<Element>();
|
|
878
1039
|
const els: TimelineElement[] = filtered.map((clip, index) => {
|
|
879
|
-
|
|
1040
|
+
const hostEl = iframeDoc
|
|
880
1041
|
? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
|
|
881
1042
|
: null;
|
|
882
1043
|
if (hostEl) usedHostEls.add(hostEl);
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
id,
|
|
886
|
-
tag: clip.tagName || clip.kind,
|
|
887
|
-
start: clip.start,
|
|
888
|
-
duration: clip.duration,
|
|
889
|
-
track: clip.track,
|
|
890
|
-
};
|
|
891
|
-
if (hostEl) {
|
|
892
|
-
entry.domId = hostEl.id || undefined;
|
|
893
|
-
entry.selector = getTimelineElementSelector(hostEl);
|
|
894
|
-
entry.selectorIndex =
|
|
895
|
-
iframeDoc && entry.selector
|
|
896
|
-
? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
|
|
897
|
-
: undefined;
|
|
898
|
-
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
|
899
|
-
applyMediaMetadataFromElement(entry, hostEl);
|
|
900
|
-
}
|
|
901
|
-
if (clip.assetUrl) entry.src = clip.assetUrl;
|
|
902
|
-
if (clip.kind === "composition" && clip.compositionId) {
|
|
903
|
-
// The bundler renames data-composition-src to data-composition-file
|
|
904
|
-
// after inlining, so the clip manifest may not have compositionSrc.
|
|
905
|
-
// Fall back to reading data-composition-file from the DOM.
|
|
906
|
-
let resolvedSrc = clip.compositionSrc;
|
|
907
|
-
if (!resolvedSrc) {
|
|
908
|
-
hostEl =
|
|
909
|
-
iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
|
|
910
|
-
resolvedSrc =
|
|
911
|
-
hostEl?.getAttribute("data-composition-src") ??
|
|
912
|
-
hostEl?.getAttribute("data-composition-file") ??
|
|
913
|
-
null;
|
|
914
|
-
}
|
|
915
|
-
if (resolvedSrc) {
|
|
916
|
-
entry.compositionSrc = resolvedSrc;
|
|
917
|
-
} else if (hostEl) {
|
|
918
|
-
// Inline composition (no external file) — expose inner video for thumbnails
|
|
919
|
-
const innerVideo = hostEl.querySelector("video[src]");
|
|
920
|
-
if (innerVideo) {
|
|
921
|
-
entry.src = innerVideo.getAttribute("src") || undefined;
|
|
922
|
-
entry.tag = "video";
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
if (hostEl) {
|
|
926
|
-
const iframeDoc = iframeRef.current?.contentDocument;
|
|
927
|
-
entry.domId = hostEl.id || undefined;
|
|
928
|
-
entry.selector = getTimelineElementSelector(hostEl);
|
|
929
|
-
entry.selectorIndex =
|
|
930
|
-
iframeDoc && entry.selector
|
|
931
|
-
? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
|
|
932
|
-
: undefined;
|
|
933
|
-
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
entry.key = buildTimelineElementKey({
|
|
937
|
-
id,
|
|
1044
|
+
return createTimelineElementFromManifestClip({
|
|
1045
|
+
clip,
|
|
938
1046
|
fallbackIndex: index,
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
selectorIndex: entry.selectorIndex,
|
|
942
|
-
sourceFile: entry.sourceFile,
|
|
1047
|
+
doc: iframeDoc,
|
|
1048
|
+
hostEl,
|
|
943
1049
|
});
|
|
944
|
-
return entry;
|
|
945
1050
|
});
|
|
946
1051
|
const rawDuration = data.durationInFrames / 30;
|
|
947
1052
|
// Clamp non-finite or absurdly large durations — the runtime can emit
|
|
@@ -1055,17 +1160,24 @@ export function useTimelinePlayer() {
|
|
|
1055
1160
|
const selector = getTimelineElementSelector(el);
|
|
1056
1161
|
const sourceFile = getTimelineElementSourceFile(el);
|
|
1057
1162
|
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
|
|
1058
|
-
const
|
|
1163
|
+
const label = getTimelineElementDisplayLabel({
|
|
1164
|
+
id: el.id || compId || null,
|
|
1165
|
+
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
|
|
1166
|
+
tag: el.tagName,
|
|
1167
|
+
});
|
|
1168
|
+
const identity = buildTimelineElementIdentity({
|
|
1169
|
+
preferredId: el.id || compId || null,
|
|
1170
|
+
label,
|
|
1171
|
+
fallbackIndex: missing.length,
|
|
1172
|
+
domId: el.id || undefined,
|
|
1173
|
+
selector,
|
|
1174
|
+
selectorIndex,
|
|
1175
|
+
sourceFile,
|
|
1176
|
+
});
|
|
1059
1177
|
const entry: TimelineElement = {
|
|
1060
|
-
id,
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
fallbackIndex: missing.length,
|
|
1064
|
-
domId: el.id || undefined,
|
|
1065
|
-
selector,
|
|
1066
|
-
selectorIndex,
|
|
1067
|
-
sourceFile,
|
|
1068
|
-
}),
|
|
1178
|
+
id: identity.id,
|
|
1179
|
+
label,
|
|
1180
|
+
key: identity.key,
|
|
1069
1181
|
tag: el.tagName.toLowerCase(),
|
|
1070
1182
|
start,
|
|
1071
1183
|
duration: dur,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { formatFrameTime, frameToSeconds, secondsToFrame, formatTime } from "./time";
|
|
2
|
+
import { formatFrameTime, frameToSeconds, secondsToFrame, stepFrameTime, formatTime } from "./time";
|
|
3
3
|
|
|
4
4
|
describe("formatTime", () => {
|
|
5
5
|
it("formats zero seconds", () => {
|
|
@@ -72,4 +72,14 @@ describe("frame helpers", () => {
|
|
|
72
72
|
it("formats current and total frame display", () => {
|
|
73
73
|
expect(formatFrameTime(1, 5)).toBe("30f / 150f");
|
|
74
74
|
});
|
|
75
|
+
|
|
76
|
+
it("steps from a truncated runtime time by integer frame index", () => {
|
|
77
|
+
expect(stepFrameTime(0.0333333, 1)).toBe(2 / 30);
|
|
78
|
+
expect(stepFrameTime(0.0666666, 1)).toBe(3 / 30);
|
|
79
|
+
expect(stepFrameTime(0.0666666, -1)).toBe(1 / 30);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("clamps frame stepping at zero", () => {
|
|
83
|
+
expect(stepFrameTime(0, -1)).toBe(0);
|
|
84
|
+
});
|
|
75
85
|
});
|
package/src/player/lib/time.ts
CHANGED
|
@@ -19,6 +19,12 @@ export function frameToSeconds(frame: number, fps = STUDIO_PREVIEW_FPS): number
|
|
|
19
19
|
return frame / fps;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export function stepFrameTime(time: number, deltaFrames: number, fps = STUDIO_PREVIEW_FPS): number {
|
|
23
|
+
const currentFrame = secondsToFrame(time, fps);
|
|
24
|
+
const nextFrame = Math.max(0, currentFrame + deltaFrames);
|
|
25
|
+
return frameToSeconds(nextFrame, fps);
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
export function formatFrameTime(time: number, duration: number, fps = STUDIO_PREVIEW_FPS): string {
|
|
23
29
|
const currentFrame = secondsToFrame(time, fps);
|
|
24
30
|
const totalFrames = secondsToFrame(duration, fps);
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildEditHistoryEntry,
|
|
4
|
+
canApplyEditHistoryEntry,
|
|
5
|
+
createEmptyEditHistory,
|
|
6
|
+
hashEditHistoryContent,
|
|
7
|
+
pushEditHistoryEntry,
|
|
8
|
+
redoEditHistory,
|
|
9
|
+
undoEditHistory,
|
|
10
|
+
} from "./editHistory";
|
|
11
|
+
|
|
12
|
+
describe("edit history", () => {
|
|
13
|
+
it("pushes changed file snapshots onto undo and clears redo", () => {
|
|
14
|
+
const state = createEmptyEditHistory();
|
|
15
|
+
const entry = buildEditHistoryEntry({
|
|
16
|
+
projectId: "project-1",
|
|
17
|
+
label: "Move layer",
|
|
18
|
+
files: {
|
|
19
|
+
"index.html": {
|
|
20
|
+
before: '<div style="left: 0px"></div>',
|
|
21
|
+
after: '<div style="left: 20px"></div>',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
now: 100,
|
|
25
|
+
id: "entry-1",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const withUndo = pushEditHistoryEntry(state, entry);
|
|
29
|
+
const redoEntry = buildEditHistoryEntry({
|
|
30
|
+
projectId: "project-1",
|
|
31
|
+
label: "Redoable edit",
|
|
32
|
+
files: {
|
|
33
|
+
"index.html": {
|
|
34
|
+
before: '<div style="left: 20px"></div>',
|
|
35
|
+
after: '<div style="left: 40px"></div>',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
now: 200,
|
|
39
|
+
id: "redo-entry",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const next = pushEditHistoryEntry(
|
|
43
|
+
{
|
|
44
|
+
...withUndo,
|
|
45
|
+
redo: [redoEntry],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
...entry,
|
|
49
|
+
id: "entry-2",
|
|
50
|
+
label: "Resize layer",
|
|
51
|
+
createdAt: 300,
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(withUndo.undo).toHaveLength(1);
|
|
56
|
+
expect(withUndo.redo).toHaveLength(0);
|
|
57
|
+
expect(next.undo.map((item) => item.label)).toEqual(["Move layer", "Resize layer"]);
|
|
58
|
+
expect(next.redo).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("undo returns before contents and moves entry to redo", () => {
|
|
62
|
+
const entry = buildEditHistoryEntry({
|
|
63
|
+
projectId: "project-1",
|
|
64
|
+
label: "Move layer",
|
|
65
|
+
files: {
|
|
66
|
+
"index.html": { before: "before", after: "after" },
|
|
67
|
+
},
|
|
68
|
+
now: 100,
|
|
69
|
+
id: "entry-1",
|
|
70
|
+
});
|
|
71
|
+
const state = pushEditHistoryEntry(createEmptyEditHistory(), entry);
|
|
72
|
+
|
|
73
|
+
const result = undoEditHistory(state, { "index.html": hashEditHistoryContent("after") }, 200);
|
|
74
|
+
|
|
75
|
+
expect(result.ok).toBe(true);
|
|
76
|
+
expect(result.filesToWrite).toEqual({ "index.html": "before" });
|
|
77
|
+
expect(result.state.undo).toHaveLength(0);
|
|
78
|
+
expect(result.state.redo.map((item) => item.id)).toEqual(["entry-1"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("redo returns after contents and moves entry to undo", () => {
|
|
82
|
+
const entry = buildEditHistoryEntry({
|
|
83
|
+
projectId: "project-1",
|
|
84
|
+
label: "Move layer",
|
|
85
|
+
files: {
|
|
86
|
+
"index.html": { before: "before", after: "after" },
|
|
87
|
+
},
|
|
88
|
+
now: 100,
|
|
89
|
+
id: "entry-1",
|
|
90
|
+
});
|
|
91
|
+
const undone = undoEditHistory(
|
|
92
|
+
pushEditHistoryEntry(createEmptyEditHistory(), entry),
|
|
93
|
+
{ "index.html": hashEditHistoryContent("after") },
|
|
94
|
+
200,
|
|
95
|
+
).state;
|
|
96
|
+
|
|
97
|
+
const result = redoEditHistory(undone, { "index.html": hashEditHistoryContent("before") }, 300);
|
|
98
|
+
|
|
99
|
+
expect(result.ok).toBe(true);
|
|
100
|
+
expect(result.filesToWrite).toEqual({ "index.html": "after" });
|
|
101
|
+
expect(result.state.undo.map((item) => item.id)).toEqual(["entry-1"]);
|
|
102
|
+
expect(result.state.redo).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("blocks undo when current content hash does not match the recorded after hash", () => {
|
|
106
|
+
const entry = buildEditHistoryEntry({
|
|
107
|
+
projectId: "project-1",
|
|
108
|
+
label: "Move layer",
|
|
109
|
+
files: {
|
|
110
|
+
"index.html": { before: "before", after: "after" },
|
|
111
|
+
},
|
|
112
|
+
now: 100,
|
|
113
|
+
id: "entry-1",
|
|
114
|
+
});
|
|
115
|
+
const state = pushEditHistoryEntry(createEmptyEditHistory(), entry);
|
|
116
|
+
|
|
117
|
+
const result = undoEditHistory(
|
|
118
|
+
state,
|
|
119
|
+
{ "index.html": hashEditHistoryContent("external") },
|
|
120
|
+
200,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(result.ok).toBe(false);
|
|
124
|
+
expect(result.reason).toBe("content-mismatch");
|
|
125
|
+
expect(result.state).toBe(state);
|
|
126
|
+
expect(result.filesToWrite).toEqual({});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("can validate all files in a multi-file entry before applying", () => {
|
|
130
|
+
const entry = buildEditHistoryEntry({
|
|
131
|
+
projectId: "project-1",
|
|
132
|
+
label: "Update files",
|
|
133
|
+
files: {
|
|
134
|
+
"index.html": { before: "a", after: "b" },
|
|
135
|
+
"compositions/title.html": { before: "c", after: "d" },
|
|
136
|
+
},
|
|
137
|
+
now: 100,
|
|
138
|
+
id: "entry-1",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(
|
|
142
|
+
canApplyEditHistoryEntry(entry, "undo", {
|
|
143
|
+
"index.html": hashEditHistoryContent("b"),
|
|
144
|
+
"compositions/title.html": hashEditHistoryContent("d"),
|
|
145
|
+
}),
|
|
146
|
+
).toEqual({ ok: true });
|
|
147
|
+
expect(
|
|
148
|
+
canApplyEditHistoryEntry(entry, "undo", {
|
|
149
|
+
"index.html": hashEditHistoryContent("b"),
|
|
150
|
+
"compositions/title.html": hashEditHistoryContent("external"),
|
|
151
|
+
}),
|
|
152
|
+
).toEqual({ ok: false, reason: "content-mismatch", path: "compositions/title.html" });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("prunes oldest undo entries when the limit is exceeded", () => {
|
|
156
|
+
let state = createEmptyEditHistory({ maxEntries: 2 });
|
|
157
|
+
for (let index = 1; index <= 3; index += 1) {
|
|
158
|
+
state = pushEditHistoryEntry(
|
|
159
|
+
state,
|
|
160
|
+
buildEditHistoryEntry({
|
|
161
|
+
projectId: "project-1",
|
|
162
|
+
label: `Edit ${index}`,
|
|
163
|
+
files: {
|
|
164
|
+
"index.html": { before: `${index - 1}`, after: `${index}` },
|
|
165
|
+
},
|
|
166
|
+
now: index,
|
|
167
|
+
id: `entry-${index}`,
|
|
168
|
+
}),
|
|
169
|
+
{ maxEntries: 2 },
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
expect(state.undo.map((entry) => entry.id)).toEqual(["entry-2", "entry-3"]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("coalesces source editor edits for the same file inside the coalesce window", () => {
|
|
177
|
+
const first = buildEditHistoryEntry({
|
|
178
|
+
projectId: "project-1",
|
|
179
|
+
label: "Edit source",
|
|
180
|
+
kind: "source",
|
|
181
|
+
coalesceKey: "source:index.html",
|
|
182
|
+
files: {
|
|
183
|
+
"index.html": { before: "a", after: "b" },
|
|
184
|
+
},
|
|
185
|
+
now: 100,
|
|
186
|
+
id: "entry-1",
|
|
187
|
+
});
|
|
188
|
+
const second = buildEditHistoryEntry({
|
|
189
|
+
projectId: "project-1",
|
|
190
|
+
label: "Edit source",
|
|
191
|
+
kind: "source",
|
|
192
|
+
coalesceKey: "source:index.html",
|
|
193
|
+
files: {
|
|
194
|
+
"index.html": { before: "b", after: "c" },
|
|
195
|
+
},
|
|
196
|
+
now: 300,
|
|
197
|
+
id: "entry-2",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const state = pushEditHistoryEntry(
|
|
201
|
+
pushEditHistoryEntry(createEmptyEditHistory(), first),
|
|
202
|
+
second,
|
|
203
|
+
{ coalesceMs: 1000 },
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
expect(state.undo).toHaveLength(1);
|
|
207
|
+
expect(state.undo[0].id).toBe("entry-2");
|
|
208
|
+
expect(state.undo[0].files["index.html"].before).toBe("a");
|
|
209
|
+
expect(state.undo[0].files["index.html"].after).toBe("c");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("does not coalesce source editor edits outside the coalesce window", () => {
|
|
213
|
+
const first = buildEditHistoryEntry({
|
|
214
|
+
projectId: "project-1",
|
|
215
|
+
label: "Edit source",
|
|
216
|
+
kind: "source",
|
|
217
|
+
coalesceKey: "source:index.html",
|
|
218
|
+
files: {
|
|
219
|
+
"index.html": { before: "a", after: "b" },
|
|
220
|
+
},
|
|
221
|
+
now: 100,
|
|
222
|
+
id: "entry-1",
|
|
223
|
+
});
|
|
224
|
+
const second = buildEditHistoryEntry({
|
|
225
|
+
projectId: "project-1",
|
|
226
|
+
label: "Edit source",
|
|
227
|
+
kind: "source",
|
|
228
|
+
coalesceKey: "source:index.html",
|
|
229
|
+
files: {
|
|
230
|
+
"index.html": { before: "b", after: "c" },
|
|
231
|
+
},
|
|
232
|
+
now: 5000,
|
|
233
|
+
id: "entry-2",
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const state = pushEditHistoryEntry(
|
|
237
|
+
pushEditHistoryEntry(createEmptyEditHistory(), first),
|
|
238
|
+
second,
|
|
239
|
+
{ coalesceMs: 1000 },
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(state.undo.map((entry) => entry.id)).toEqual(["entry-1", "entry-2"]);
|
|
243
|
+
});
|
|
244
|
+
});
|