@hyperframes/studio 0.8.78 → 0.8.80
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-CgnoxDoD.js +464 -0
- package/dist/assets/{index-CX_4MYbk.js → index-CsG24Jub.js} +190 -187
- package/dist/assets/{index-C_n__Uh3.js → index-DZGVvqDT.js} +1 -1
- package/dist/assets/{index-DdpuHaGN.js → index-J9HXe7mU.js} +1 -1
- package/dist/{chunk-5VANA2TG.js → chunk-I7XNAIOF.js} +2 -1
- package/dist/{domEditingLayers-IGPLTI5E.js → domEditingLayers-MHFDKAQ7.js} +2 -2
- package/dist/index.d.ts +40 -16
- package/dist/index.html +1 -1
- package/dist/index.js +1918 -1408
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -1
- package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +4 -0
- package/src/components/editor/useFxAudition.ts +3 -3
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -2
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/TimelineEditContext.tsx +2 -0
- package/src/hooks/liveLanes.ts +147 -0
- package/src/hooks/timelineAudioGroupVolume.ts +97 -53
- package/src/hooks/timelineEditPermission.ts +55 -13
- package/src/hooks/timelineEditingHelpers.test.ts +16 -0
- package/src/hooks/timelineEditingHelpers.ts +69 -43
- package/src/hooks/timelineElementFxAttribute.ts +92 -40
- package/src/hooks/useAppHotkeys.ts +1 -1
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
- package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
- package/src/hooks/useBlockHandlers.test.tsx +65 -0
- package/src/hooks/useBlockHandlers.ts +7 -4
- package/src/hooks/useClipboard.source.test.ts +340 -0
- package/src/hooks/useClipboard.ts +141 -62
- package/src/hooks/useDomEditAttributeCommits.ts +3 -17
- package/src/hooks/useDomEditSession.ts +1 -0
- package/src/hooks/useEditHistoryActions.ts +4 -3
- package/src/hooks/useElementPicker.test.ts +172 -0
- package/src/hooks/useElementPicker.ts +141 -93
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
- package/src/hooks/useFrameCapture.test.tsx +67 -0
- package/src/hooks/useFrameCapture.ts +18 -9
- package/src/hooks/usePersistentEditHistory.test.ts +2 -2
- package/src/hooks/usePersistentEditHistory.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +59 -0
- package/src/hooks/useTimelineEditing.ts +59 -47
- package/src/hooks/useTimelineGroupEditing.ts +2 -1
- package/src/hooks/useToast.ts +2 -1
- package/src/index.ts +1 -0
- package/src/player/components/ImageThumbnail.tsx +3 -12
- package/src/player/components/Player.test.ts +28 -0
- package/src/player/components/Player.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
- package/src/player/components/TimelineAutomationLane.tsx +18 -13
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
- package/src/player/components/TimelineClipFades.test.tsx +24 -3
- package/src/player/components/TimelineClipFades.tsx +5 -2
- package/src/player/components/TimelineFxButton.tsx +1 -1
- package/src/player/components/TimelineGroupHeader.tsx +1 -1
- package/src/player/components/TimelineGroupRow.test.tsx +0 -1
- package/src/player/components/TimelineGroupRow.tsx +17 -11
- package/src/player/components/TimelineTrackHeader.tsx +21 -6
- package/src/player/components/VideoThumbnail.test.tsx +13 -0
- package/src/player/components/VideoThumbnail.tsx +1 -1
- package/src/player/components/groupAutomationElement.ts +9 -1
- package/src/player/components/timelineCallbacks.ts +5 -2
- package/src/player/components/timelineClipDragPreview.test.ts +41 -0
- package/src/player/components/timelineClipDragPreview.ts +15 -24
- package/src/player/components/timelineEditing.test.ts +99 -1
- package/src/player/components/timelineEditing.ts +5 -8
- package/src/player/components/timelineGroupEditing.test.ts +27 -0
- package/src/player/components/timelineGroupEditing.ts +18 -15
- package/src/player/components/timelineStackingSync.ts +5 -5
- package/src/player/components/timelineZones.ts +2 -3
- package/src/player/components/useAutomationEdgeStretch.ts +4 -4
- package/src/player/components/useAutomationLaneGestures.ts +1 -1
- package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
- package/src/player/components/useAutomationLanes.ts +58 -74
- package/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +75 -0
- package/src/player/hooks/useTimelinePlayer.play.test.ts +36 -0
- package/src/player/hooks/useTimelinePlayer.ts +25 -28
- package/src/player/hooks/useTimelineSyncCallbacks.ts +12 -4
- package/src/player/lib/automationStoreSync.ts +30 -3
- package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
- package/src/player/lib/thumbnailImageDecoder.ts +73 -0
- package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
- package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
- package/src/player/lib/timelineDOM.test.ts +28 -8
- package/src/player/lib/timelineElementHelpers.ts +3 -4
- package/src/player/lib/timelineGroupInfo.ts +23 -23
- package/src/utils/authoredSource.ts +92 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +3 -7
- package/src/utils/studioFileHistory.ts +36 -30
- package/dist/assets/hyperframes-player-BdnQGwG5.js +0 -464
- package/src/player/components/automationGestureKeys.test.ts +0 -49
- package/src/player/components/automationGestureKeys.ts +0 -49
- /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
- /package/dist/{domEditingLayers-IGPLTI5E.js.map → domEditingLayers-MHFDKAQ7.js.map} +0 -0
|
@@ -5,6 +5,7 @@ import { decodeVideoThumbnail, videoThumbnailTimestamps } from "./thumbnailVideo
|
|
|
5
5
|
|
|
6
6
|
const dispose = vi.fn();
|
|
7
7
|
const canvasesAtTimestamps = vi.fn();
|
|
8
|
+
const getKeyPacket = vi.fn(async (_time: number) => null as { timestamp: number } | null);
|
|
8
9
|
const input = {
|
|
9
10
|
getPrimaryVideoTrack: vi.fn(),
|
|
10
11
|
dispose,
|
|
@@ -22,10 +23,25 @@ vi.mock("mediabunny", () => ({
|
|
|
22
23
|
CanvasSink: class {
|
|
23
24
|
canvasesAtTimestamps = canvasesAtTimestamps;
|
|
24
25
|
},
|
|
26
|
+
EncodedPacketSink: class {
|
|
27
|
+
getKeyPacket = getKeyPacket;
|
|
28
|
+
},
|
|
25
29
|
}));
|
|
26
30
|
|
|
31
|
+
function recordDecodes(decoded: number[][]): void {
|
|
32
|
+
canvasesAtTimestamps.mockImplementation(async function* (timestamps: AsyncIterable<number>) {
|
|
33
|
+
const run: number[] = [];
|
|
34
|
+
decoded.push(run);
|
|
35
|
+
for await (const time of timestamps) {
|
|
36
|
+
run.push(time);
|
|
37
|
+
yield { canvas: document.createElement("canvas") };
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
27
42
|
beforeEach(() => {
|
|
28
43
|
vi.clearAllMocks();
|
|
44
|
+
getKeyPacket.mockImplementation(async () => null);
|
|
29
45
|
vi.spyOn(URL, "createObjectURL").mockReturnValueOnce("blob:one").mockReturnValueOnce("blob:two");
|
|
30
46
|
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
|
31
47
|
HTMLCanvasElement.prototype.toBlob = function toBlob(callback) {
|
|
@@ -52,17 +68,14 @@ describe("videoThumbnailTimestamps", () => {
|
|
|
52
68
|
|
|
53
69
|
describe("decodeVideoThumbnail", () => {
|
|
54
70
|
it("extracts sparse frames, returns object URLs, and disposes once", async () => {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
expect(timestamps).toEqual([2, 8]);
|
|
58
|
-
yield { canvas, timestamp: 2, duration: 1 };
|
|
59
|
-
yield { canvas, timestamp: 8, duration: 1 };
|
|
60
|
-
});
|
|
71
|
+
const decoded: number[][] = [];
|
|
72
|
+
recordDecodes(decoded);
|
|
61
73
|
const result = await decodeVideoThumbnail(
|
|
62
74
|
{ source: "/clip.mp4", sourceStart: 2, sourceRangeDuration: 6, frameCount: 2 },
|
|
63
75
|
new AbortController().signal,
|
|
64
76
|
);
|
|
65
77
|
|
|
78
|
+
expect(decoded).toEqual([[2, 8]]);
|
|
66
79
|
expect(result.value).toEqual({
|
|
67
80
|
kind: "filmstrip",
|
|
68
81
|
urls: ["blob:one", "blob:two"],
|
|
@@ -74,6 +87,89 @@ describe("decodeVideoThumbnail", () => {
|
|
|
74
87
|
expect(dispose).toHaveBeenCalledTimes(1);
|
|
75
88
|
});
|
|
76
89
|
|
|
90
|
+
it("decodes each strip frame at its keyframe unless that keyframe is before the clip's range", async () => {
|
|
91
|
+
getKeyPacket.mockImplementation(async (time) => ({ timestamp: Math.floor(time / 4) * 4 }));
|
|
92
|
+
const decoded: number[][] = [];
|
|
93
|
+
recordDecodes(decoded);
|
|
94
|
+
await decodeVideoThumbnail(
|
|
95
|
+
{ source: "/clip.mp4", sourceStart: 2, sourceRangeDuration: 8, frameCount: 3 },
|
|
96
|
+
new AbortController().signal,
|
|
97
|
+
);
|
|
98
|
+
expect(decoded).toEqual([[2, 4, 8]]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("keeps a slot's own time when its keyframe is more than half a slot earlier", async () => {
|
|
102
|
+
getKeyPacket.mockImplementation(async () => ({ timestamp: 0 }));
|
|
103
|
+
const decoded: number[][] = [];
|
|
104
|
+
recordDecodes(decoded);
|
|
105
|
+
await decodeVideoThumbnail(
|
|
106
|
+
{ source: "/clip.mp4", sourceStart: 0, sourceRangeDuration: 10, frameCount: 3 },
|
|
107
|
+
new AbortController().signal,
|
|
108
|
+
);
|
|
109
|
+
await decodeVideoThumbnail(
|
|
110
|
+
{ source: "/clip.mp4", sourceStart: 0, sourceRangeDuration: 10, frameCount: 1 },
|
|
111
|
+
new AbortController().signal,
|
|
112
|
+
);
|
|
113
|
+
expect(decoded).toEqual([[0, 5, 10], [5]]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("looks up each keyframe just before decoding it", async () => {
|
|
117
|
+
const events: string[] = [];
|
|
118
|
+
getKeyPacket.mockImplementation(async (time) => {
|
|
119
|
+
events.push(`key ${time}`);
|
|
120
|
+
return { timestamp: time };
|
|
121
|
+
});
|
|
122
|
+
canvasesAtTimestamps.mockImplementation(async function* (timestamps: AsyncIterable<number>) {
|
|
123
|
+
for await (const time of timestamps) {
|
|
124
|
+
events.push(`frame ${time}`);
|
|
125
|
+
yield { canvas: document.createElement("canvas") };
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
await decodeVideoThumbnail(
|
|
129
|
+
{ source: "/clip.mp4", sourceStart: 0, sourceRangeDuration: 10, frameCount: 3 },
|
|
130
|
+
new AbortController().signal,
|
|
131
|
+
);
|
|
132
|
+
expect(events).toEqual(["key 0", "frame 0", "key 5", "frame 5", "key 10", "frame 10"]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("ends the decode times without throwing when cancelled during a keyframe lookup", async () => {
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
getKeyPacket.mockImplementation(async () => (controller.abort(), null));
|
|
138
|
+
const decoded: number[] = [];
|
|
139
|
+
let timesEnded = false;
|
|
140
|
+
canvasesAtTimestamps.mockImplementation(async function* (timestamps: AsyncIterable<number>) {
|
|
141
|
+
for await (const time of timestamps) {
|
|
142
|
+
decoded.push(time);
|
|
143
|
+
yield { canvas: document.createElement("canvas") };
|
|
144
|
+
}
|
|
145
|
+
timesEnded = true;
|
|
146
|
+
});
|
|
147
|
+
await expect(
|
|
148
|
+
decodeVideoThumbnail(
|
|
149
|
+
{ source: "/clip.mp4", sourceStart: 0, sourceRangeDuration: 10, frameCount: 3 },
|
|
150
|
+
controller.signal,
|
|
151
|
+
),
|
|
152
|
+
).rejects.toThrow("Aborted");
|
|
153
|
+
expect(getKeyPacket).toHaveBeenCalledTimes(1);
|
|
154
|
+
expect(decoded).toEqual([]);
|
|
155
|
+
expect(timesEnded).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("spreads a trimmed strip across its range when the file reports no duration", async () => {
|
|
159
|
+
input.getPrimaryVideoTrack.mockResolvedValue({
|
|
160
|
+
getDisplayWidth: vi.fn(async () => 1080),
|
|
161
|
+
getDisplayHeight: vi.fn(async () => 1920),
|
|
162
|
+
getDurationFromMetadata: vi.fn(async () => null),
|
|
163
|
+
});
|
|
164
|
+
const decoded: number[][] = [];
|
|
165
|
+
recordDecodes(decoded);
|
|
166
|
+
await decodeVideoThumbnail(
|
|
167
|
+
{ source: "/clip.mp4", sourceStart: 5, sourceRangeDuration: 5, frameCount: 3 },
|
|
168
|
+
new AbortController().signal,
|
|
169
|
+
);
|
|
170
|
+
expect(decoded).toEqual([[5, 7.5, 10]]);
|
|
171
|
+
});
|
|
172
|
+
|
|
77
173
|
it("releases input and degrades when the source has no video track", async () => {
|
|
78
174
|
input.getPrimaryVideoTrack.mockResolvedValue(null);
|
|
79
175
|
await expect(
|
|
@@ -44,7 +44,7 @@ interface DecodedResources {
|
|
|
44
44
|
|
|
45
45
|
interface ThumbnailCanvasSink {
|
|
46
46
|
canvasesAtTimestamps(
|
|
47
|
-
timestamps: number
|
|
47
|
+
timestamps: AsyncIterable<number>,
|
|
48
48
|
): AsyncIterable<{ canvas: HTMLCanvasElement | OffscreenCanvas } | null>;
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -77,7 +77,7 @@ function targetDimensions(
|
|
|
77
77
|
|
|
78
78
|
async function decodeFrames(
|
|
79
79
|
sink: ThumbnailCanvasSink,
|
|
80
|
-
timestamps: number
|
|
80
|
+
timestamps: AsyncIterable<number>,
|
|
81
81
|
signal: AbortSignal,
|
|
82
82
|
resources: DecodedResources,
|
|
83
83
|
): Promise<void> {
|
|
@@ -89,6 +89,7 @@ async function decodeFrames(
|
|
|
89
89
|
throwIfAborted(signal);
|
|
90
90
|
resources.urls.push(URL.createObjectURL(blob));
|
|
91
91
|
}
|
|
92
|
+
throwIfAborted(signal);
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
function loadedResult(
|
|
@@ -138,8 +139,12 @@ export async function decodeVideoThumbnail(
|
|
|
138
139
|
}
|
|
139
140
|
const metadataDuration = await track.getDurationFromMetadata({ skipLiveWait: true });
|
|
140
141
|
throwIfAborted(signal);
|
|
141
|
-
const
|
|
142
|
-
const
|
|
142
|
+
const requestedStart = Math.max(0, request.sourceStart ?? 0);
|
|
143
|
+
const sourceDuration = Math.max(
|
|
144
|
+
0,
|
|
145
|
+
metadataDuration ?? requestedStart + (request.sourceRangeDuration ?? 0),
|
|
146
|
+
);
|
|
147
|
+
const sourceStart = Math.min(requestedStart, sourceDuration);
|
|
143
148
|
const requestedDuration =
|
|
144
149
|
request.sourceRangeDuration ?? Math.max(0, sourceDuration - sourceStart);
|
|
145
150
|
const duration = Math.min(
|
|
@@ -151,6 +156,16 @@ export async function decodeVideoThumbnail(
|
|
|
151
156
|
duration,
|
|
152
157
|
Math.min(request.frameCount, budgets.richPreviewFrameCount),
|
|
153
158
|
);
|
|
159
|
+
const keys = new mediabunny.EncodedPacketSink(track);
|
|
160
|
+
const maxKeyframeLead = duration / Math.max(2, timestamps.length - 1) / 2;
|
|
161
|
+
async function* decodeTimesAtNearbyKeyframes() {
|
|
162
|
+
for (const time of timestamps) {
|
|
163
|
+
const key = await keys.getKeyPacket(time, { metadataOnly: true });
|
|
164
|
+
if (signal.aborted) return;
|
|
165
|
+
const near = key && key.timestamp >= sourceStart && time - key.timestamp <= maxKeyframeLead;
|
|
166
|
+
yield near ? key.timestamp : time;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
154
169
|
const aspect = displayWidth / displayHeight;
|
|
155
170
|
const target = targetDimensions(aspect, budgets);
|
|
156
171
|
const sink = new mediabunny.CanvasSink(track, {
|
|
@@ -159,7 +174,7 @@ export async function decodeVideoThumbnail(
|
|
|
159
174
|
fit: request.fit ?? "cover",
|
|
160
175
|
poolSize: 1,
|
|
161
176
|
});
|
|
162
|
-
await decodeFrames(sink,
|
|
177
|
+
await decodeFrames(sink, decodeTimesAtNearbyKeyframes(), signal, resources);
|
|
163
178
|
return loadedResult(resources, aspect, target.width, target.height);
|
|
164
179
|
} catch (error) {
|
|
165
180
|
releaseDecodedResources(resources);
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
} from "./timelineDOM";
|
|
9
9
|
import { isTimelineIgnoredElement } from "./timelineElementHelpers";
|
|
10
10
|
import { computeResizePreview } from "../components/timelineClipDragPreview";
|
|
11
|
-
import { invalidateGroupInfoCache } from "./timelineGroupInfo";
|
|
12
11
|
import type { TimelineElement } from "../store/playerStore";
|
|
13
12
|
|
|
14
13
|
function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
|
|
@@ -21,6 +20,32 @@ function makeDoc(html: string): Document {
|
|
|
21
20
|
return d;
|
|
22
21
|
}
|
|
23
22
|
|
|
23
|
+
describe("parseTimelineFromDOM — media in-point", () => {
|
|
24
|
+
it("reads a negative in-point as 0, as the runtime does, so a head trim keeps the clip", () => {
|
|
25
|
+
const doc = makeDoc(`
|
|
26
|
+
<div data-composition-id="root">
|
|
27
|
+
<video id="v" class="clip" data-start="2" data-duration="1" data-media-start="-1"></video>
|
|
28
|
+
</div>
|
|
29
|
+
`);
|
|
30
|
+
const element = parseTimelineFromDOM(doc, 10).find((e) => e.domId === "v")!;
|
|
31
|
+
expect(element.playbackStart).toBe(0);
|
|
32
|
+
|
|
33
|
+
const preview = computeResizePreview(
|
|
34
|
+
{
|
|
35
|
+
element,
|
|
36
|
+
edge: "start",
|
|
37
|
+
originClientX: 0,
|
|
38
|
+
previewStart: 2,
|
|
39
|
+
previewDuration: 1,
|
|
40
|
+
started: true,
|
|
41
|
+
},
|
|
42
|
+
10,
|
|
43
|
+
{ scroll: null, pps: 100, buildSnapTargets: () => [] },
|
|
44
|
+
);
|
|
45
|
+
expect(preview.previewDuration).toBe(0.9);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
24
49
|
describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
|
|
25
50
|
it("bridges a real GSAP transition marker onto both named clips", () => {
|
|
26
51
|
document.body.innerHTML = `
|
|
@@ -166,7 +191,7 @@ describe("group info cache", () => {
|
|
|
166
191
|
// changes either. Without an explicit drop, a muted group could never be
|
|
167
192
|
// unmuted: the header kept reading the cached `hidden: false` and re-wrote
|
|
168
193
|
// `data-hidden` forever.
|
|
169
|
-
it("re-reads group state
|
|
194
|
+
it("re-reads group state written earlier in the same task", () => {
|
|
170
195
|
const doc = makeDoc(`
|
|
171
196
|
<div data-composition-id="root">
|
|
172
197
|
<audio id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>
|
|
@@ -175,15 +200,10 @@ describe("group info cache", () => {
|
|
|
175
200
|
`);
|
|
176
201
|
|
|
177
202
|
expect(parseMember(doc).audioGroupHidden).toBe(false);
|
|
178
|
-
|
|
203
|
+
// No await: the observer has not delivered either write when the next read runs.
|
|
179
204
|
doc.getElementById("voiceover")?.setAttribute("data-hidden", "");
|
|
180
|
-
expect(parseMember(doc).audioGroupHidden).toBe(false); // still the cached scan
|
|
181
|
-
|
|
182
|
-
invalidateGroupInfoCache(doc);
|
|
183
205
|
expect(parseMember(doc).audioGroupHidden).toBe(true);
|
|
184
|
-
|
|
185
206
|
doc.getElementById("voiceover")?.removeAttribute("data-hidden");
|
|
186
|
-
invalidateGroupInfoCache(doc);
|
|
187
207
|
expect(parseMember(doc).audioGroupHidden).toBe(false);
|
|
188
208
|
});
|
|
189
209
|
|
|
@@ -13,6 +13,7 @@ import { isFinitePositive } from "./playbackAdapter";
|
|
|
13
13
|
import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
|
|
14
14
|
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
15
15
|
import { readElementFades } from "@hyperframes/core/audio-fade";
|
|
16
|
+
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
|
|
16
17
|
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
18
19
|
// Layer-reveal lift transparency
|
|
@@ -171,10 +172,8 @@ function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element):
|
|
|
171
172
|
const playbackStartValue = el.getAttribute("data-playback-start");
|
|
172
173
|
const legacyMediaStartValue = el.getAttribute("data-media-start");
|
|
173
174
|
const mediaStartValue = playbackStartValue ?? legacyMediaStartValue;
|
|
174
|
-
if (mediaStartValue != null)
|
|
175
|
-
|
|
176
|
-
if (Number.isFinite(playbackStart)) entry.playbackStart = playbackStart;
|
|
177
|
-
}
|
|
175
|
+
if (mediaStartValue != null)
|
|
176
|
+
entry.playbackStart = readMediaOffsetSeconds((n) => el.getAttribute(n));
|
|
178
177
|
if (playbackStartValue != null) entry.playbackStartAttr = "playback-start";
|
|
179
178
|
else if (legacyMediaStartValue != null) entry.playbackStartAttr = "media-start";
|
|
180
179
|
|
|
@@ -42,7 +42,24 @@ const groupInfoCache = new WeakMap<
|
|
|
42
42
|
|
|
43
43
|
/** Bumped by every observed mutation to group state in a document. */
|
|
44
44
|
const groupRevisions = new WeakMap<Document, number>();
|
|
45
|
-
const groupObservers = new
|
|
45
|
+
const groupObservers = new WeakMap<Document, MutationObserver>();
|
|
46
|
+
|
|
47
|
+
function countGroupChanges(doc: Document, records: readonly MutationRecord[]): void {
|
|
48
|
+
// Only a group element added or removed counts: childList fires for every node a playing
|
|
49
|
+
// preview inserts. Attribute records always count; the observer filter already narrowed them.
|
|
50
|
+
const relevant = records.some(
|
|
51
|
+
(record) =>
|
|
52
|
+
record.type !== "childList" ||
|
|
53
|
+
[...record.addedNodes, ...record.removedNodes].some(
|
|
54
|
+
(node) =>
|
|
55
|
+
node instanceof Element &&
|
|
56
|
+
(node.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ||
|
|
57
|
+
node.hasAttribute(HF_AUDIO_GROUP_ATTR) ||
|
|
58
|
+
node.querySelector?.(`${HF_AUDIO_GROUP_TAG},[${HF_AUDIO_GROUP_ATTR}]`) != null),
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
if (relevant) groupRevisions.set(doc, (groupRevisions.get(doc) ?? 0) + 1);
|
|
62
|
+
}
|
|
46
63
|
|
|
47
64
|
/**
|
|
48
65
|
* Watch a document for any change to group state, so the cache expires itself.
|
|
@@ -55,28 +72,8 @@ const groupObservers = new WeakSet<Document>();
|
|
|
55
72
|
*/
|
|
56
73
|
function observeGroupState(doc: Document): void {
|
|
57
74
|
if (groupObservers.has(doc) || typeof MutationObserver === "undefined" || !doc.body) return;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// `childList` fires for EVERY node added or removed anywhere in the live
|
|
61
|
-
// preview, which on a composition that churns nodes during playback
|
|
62
|
-
// (SplitText, a typewriter, anything runtime-inserted) would expire this
|
|
63
|
-
// cache permanently and put it back to one whole-tree scan per parse. Only
|
|
64
|
-
// a group ELEMENT appearing or leaving actually changes the answer, so
|
|
65
|
-
// childList records are filtered rather than trusted; attribute records
|
|
66
|
-
// always count, because the filter below already narrowed them.
|
|
67
|
-
const relevant = records.some(
|
|
68
|
-
(record) =>
|
|
69
|
-
record.type !== "childList" ||
|
|
70
|
-
[...record.addedNodes, ...record.removedNodes].some(
|
|
71
|
-
(node) =>
|
|
72
|
-
node instanceof Element &&
|
|
73
|
-
(node.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ||
|
|
74
|
-
node.hasAttribute(HF_AUDIO_GROUP_ATTR) ||
|
|
75
|
-
node.querySelector?.(`${HF_AUDIO_GROUP_TAG},[${HF_AUDIO_GROUP_ATTR}]`) != null),
|
|
76
|
-
),
|
|
77
|
-
);
|
|
78
|
-
if (relevant) groupRevisions.set(doc, (groupRevisions.get(doc) ?? 0) + 1);
|
|
79
|
-
});
|
|
75
|
+
const observer = new MutationObserver((records) => countGroupChanges(doc, records));
|
|
76
|
+
groupObservers.set(doc, observer);
|
|
80
77
|
observer.observe(doc.body, {
|
|
81
78
|
subtree: true,
|
|
82
79
|
childList: true,
|
|
@@ -112,6 +109,9 @@ export function invalidateGroupInfoCache(doc: Document | null | undefined): void
|
|
|
112
109
|
export function groupInfoFor(doc: Document | null | undefined, groupId: string): GroupInfo {
|
|
113
110
|
if (!doc) return { label: groupId, volume: 1, hidden: false };
|
|
114
111
|
observeGroupState(doc);
|
|
112
|
+
// A write earlier in this task has not reached the observer's callback yet.
|
|
113
|
+
const unseen = groupObservers.get(doc)?.takeRecords();
|
|
114
|
+
if (unseen?.length) countGroupChanges(doc, unseen);
|
|
115
115
|
const revision = groupRevisions.get(doc) ?? 0;
|
|
116
116
|
const cached = groupInfoCache.get(doc);
|
|
117
117
|
let info = cached && cached.revision === revision ? cached.entries : undefined;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ensureHfIds, isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
|
|
2
|
+
|
|
3
|
+
// Stamped as the preview stamps the files it serves, so a live element's hf-id finds its source.
|
|
4
|
+
export function parseSavedSource(html: string): Document {
|
|
5
|
+
return new DOMParser().parseFromString(ensureHfIds(html), "text/html");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const rootsByDoc = new WeakMap<Document, ParentNode[]>();
|
|
9
|
+
|
|
10
|
+
// A plain template is a runtime clone source, so ids inside it repeat across clones.
|
|
11
|
+
function searchRoots(root: ParentNode): ParentNode[] {
|
|
12
|
+
const templates = Array.from(root.querySelectorAll("template")).filter(isCompositionTemplate);
|
|
13
|
+
return [root, ...templates.flatMap((template) => searchRoots(template.content))];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function findByAttribute(doc: Document, name: string, value: string): Element | null {
|
|
17
|
+
let roots = rootsByDoc.get(doc);
|
|
18
|
+
if (!roots) rootsByDoc.set(doc, (roots = searchRoots(doc)));
|
|
19
|
+
const selector = `[${name}="${value.replace(/["\\]/g, "\\$&")}"]`;
|
|
20
|
+
for (const root of roots) {
|
|
21
|
+
const match = root.querySelector(selector);
|
|
22
|
+
if (match) return match;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function findAuthoredElement(doc: Document, live: Element): Element | null {
|
|
28
|
+
const hfId = live.getAttribute("data-hf-id");
|
|
29
|
+
if (hfId) return findByAttribute(doc, "data-hf-id", hfId);
|
|
30
|
+
return live.id ? findByAttribute(doc, "id", live.id) : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function findAuthoredElementById(doc: Document, live: Element): Element | null {
|
|
34
|
+
return live.id ? findByAttribute(doc, "id", live.id) : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const PATH_ATTRS = ["src", "href"];
|
|
38
|
+
const CSS_URL = /\burl\(\s*(["']?)([^)"'\s](?:[^)"']*[^)"'\s])?)\1\s*\)/g;
|
|
39
|
+
|
|
40
|
+
function isRelative(path: string): boolean {
|
|
41
|
+
return !!path && !/^(?:[a-z][a-z\d+.-]*:|\/|#)/i.test(path);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toProjectPath(sourceFile: string, path: string): string {
|
|
45
|
+
const parts = sourceFile.split("/").slice(0, -1);
|
|
46
|
+
for (const part of path.split("/")) {
|
|
47
|
+
if (part === "..") parts.pop();
|
|
48
|
+
else if (part !== "." && part !== "") parts.push(part);
|
|
49
|
+
}
|
|
50
|
+
return parts.join("/");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function livePathsOf(live: Element): Set<string> {
|
|
54
|
+
const paths = new Set<string>();
|
|
55
|
+
for (const el of [live, ...Array.from(live.querySelectorAll("*"))]) {
|
|
56
|
+
for (const attr of PATH_ATTRS) paths.add(el.getAttribute(attr) ?? "");
|
|
57
|
+
for (const match of (el.getAttribute("style") ?? "").matchAll(CSS_URL)) paths.add(match[2]);
|
|
58
|
+
}
|
|
59
|
+
return paths;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The preview inlines a sub-composition with its relative asset paths rebased to the project
|
|
63
|
+
// root (rewriteAssetPath in @hyperframes/parsers, which is not browser-safe); copy does the same.
|
|
64
|
+
export function authoredMarkup(authored: Element, live: Element, sourceFile: string): string {
|
|
65
|
+
if (!sourceFile.includes("/")) return authored.outerHTML;
|
|
66
|
+
const livePaths = livePathsOf(live);
|
|
67
|
+
const rebase = (path: string): string => {
|
|
68
|
+
if (!isRelative(path)) return path;
|
|
69
|
+
const cut = path.search(/[?#]/);
|
|
70
|
+
const suffix = cut < 0 ? "" : path.slice(cut);
|
|
71
|
+
const rebased = toProjectPath(sourceFile, cut < 0 ? path : path.slice(0, cut)) + suffix;
|
|
72
|
+
return path.startsWith("../") || livePaths.has(rebased) ? rebased : path;
|
|
73
|
+
};
|
|
74
|
+
const copy = authored.cloneNode(true) as Element;
|
|
75
|
+
for (const el of [copy, ...Array.from(copy.querySelectorAll("*"))]) {
|
|
76
|
+
for (const attr of PATH_ATTRS) {
|
|
77
|
+
const value = el.getAttribute(attr);
|
|
78
|
+
if (value) el.setAttribute(attr, rebase(value.trim()));
|
|
79
|
+
}
|
|
80
|
+
const style = el.getAttribute("style");
|
|
81
|
+
if (style) {
|
|
82
|
+
el.setAttribute(
|
|
83
|
+
"style",
|
|
84
|
+
style.replace(
|
|
85
|
+
CSS_URL,
|
|
86
|
+
(_, quote: string, url: string) => `url(${quote}${rebase(url)}${quote})`,
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return copy.outerHTML;
|
|
92
|
+
}
|
|
@@ -488,6 +488,35 @@ describe("applySoftReload authored-opacity restore", () => {
|
|
|
488
488
|
expect(el.style.getPropertyPriority("opacity")).toBe("");
|
|
489
489
|
});
|
|
490
490
|
|
|
491
|
+
it("keeps the stamp for an element whose hf-id the written file lacks, even if it shares an id", () => {
|
|
492
|
+
const el = document.createElement("img");
|
|
493
|
+
el.id = "title-card";
|
|
494
|
+
el.setAttribute("data-hf-id", "hf-from-another-file");
|
|
495
|
+
el.setAttribute("data-hf-authored-opacity", "0.5");
|
|
496
|
+
el.style.opacity = "0";
|
|
497
|
+
|
|
498
|
+
const opacity = restoreOpacity(
|
|
499
|
+
el,
|
|
500
|
+
'<html><body><img id="title-card" style="opacity: 0.9"></body></html>',
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
expect(opacity).toBe("0.5");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("does not take a runtime clone's opacity from its plain template", () => {
|
|
507
|
+
const el = document.createElement("li");
|
|
508
|
+
el.id = "item";
|
|
509
|
+
el.setAttribute("data-hf-authored-opacity", "0.5");
|
|
510
|
+
el.style.opacity = "0";
|
|
511
|
+
|
|
512
|
+
const opacity = restoreOpacity(
|
|
513
|
+
el,
|
|
514
|
+
'<html><body><template><li id="item" style="opacity: 0.3"></li></template></body></html>',
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
expect(opacity).toBe("0.5");
|
|
518
|
+
});
|
|
519
|
+
|
|
491
520
|
it("falls back to the parse-time stamp when no after-write HTML is given", () => {
|
|
492
521
|
const el = document.createElement("img");
|
|
493
522
|
el.setAttribute("data-hf-authored-opacity", "0.75");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
|
|
2
|
+
import { findAuthoredElement, parseSavedSource } from "./authoredSource";
|
|
2
3
|
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
|
|
3
4
|
|
|
4
5
|
type IframeWindow = Window & {
|
|
@@ -301,17 +302,12 @@ export function applySoftReload(
|
|
|
301
302
|
const findAuthoredSource = (el: HTMLElement): Element | null => {
|
|
302
303
|
if (authoredDoc === undefined) {
|
|
303
304
|
try {
|
|
304
|
-
authoredDoc = authoredHtml
|
|
305
|
-
? new DOMParser().parseFromString(authoredHtml, "text/html")
|
|
306
|
-
: null;
|
|
305
|
+
authoredDoc = authoredHtml ? parseSavedSource(authoredHtml) : null;
|
|
307
306
|
} catch {
|
|
308
307
|
authoredDoc = null;
|
|
309
308
|
}
|
|
310
309
|
}
|
|
311
|
-
|
|
312
|
-
const hfId = el.getAttribute("data-hf-id");
|
|
313
|
-
if (hfId) return authoredDoc.querySelector(`[data-hf-id="${hfId}"]`);
|
|
314
|
-
return el.id ? authoredDoc.getElementById(el.id) : null;
|
|
310
|
+
return authoredDoc ? findAuthoredElement(authoredDoc, el) : null;
|
|
315
311
|
};
|
|
316
312
|
const readAuthoredOpacity = (el: HTMLElement): string | null => {
|
|
317
313
|
const source = findAuthoredSource(el);
|
|
@@ -60,7 +60,15 @@ export async function readProjectFileContent(pid: string, path: string): Promise
|
|
|
60
60
|
return data.content;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
export async function saveProjectFilesWithHistory(
|
|
63
|
+
export async function saveProjectFilesWithHistory(
|
|
64
|
+
input: SaveProjectFilesWithHistoryInput,
|
|
65
|
+
): Promise<string[]> {
|
|
66
|
+
return serializeStudioFileMutations(input.writeFile, Object.keys(input.files), () =>
|
|
67
|
+
writeProjectFilesWithHistoryInQueue(input),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function writeProjectFilesWithHistoryInQueue({
|
|
64
72
|
label,
|
|
65
73
|
coalesceKey,
|
|
66
74
|
coalesceMs,
|
|
@@ -70,39 +78,37 @@ export async function saveProjectFilesWithHistory({
|
|
|
70
78
|
recordEdit,
|
|
71
79
|
diskContent,
|
|
72
80
|
}: SaveProjectFilesWithHistoryInput): Promise<string[]> {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
snapshots[path] = { before, after };
|
|
79
|
-
}
|
|
81
|
+
const snapshots: Record<string, { before: string; after: string }> = {};
|
|
82
|
+
for (const [path, after] of Object.entries(files)) {
|
|
83
|
+
const before = await readFile(path);
|
|
84
|
+
if (before !== after) {
|
|
85
|
+
snapshots[path] = { before, after };
|
|
80
86
|
}
|
|
87
|
+
}
|
|
81
88
|
|
|
82
|
-
|
|
83
|
-
|
|
89
|
+
const changedPaths = Object.keys(snapshots);
|
|
90
|
+
if (changedPaths.length === 0) return [];
|
|
84
91
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
const writtenPaths: string[] = [];
|
|
93
|
+
try {
|
|
94
|
+
for (const path of changedPaths) {
|
|
95
|
+
await writeFile(path, snapshots[path].after, diskContent?.[path] ?? snapshots[path].before);
|
|
96
|
+
writtenPaths.push(path);
|
|
97
|
+
}
|
|
91
98
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
} catch (rollbackError) {
|
|
99
|
-
throw new AggregateError(
|
|
100
|
-
[error, rollbackError],
|
|
101
|
-
"Failed to save project files and rollback did not complete",
|
|
102
|
-
);
|
|
99
|
+
await recordEdit({ label, coalesceKey, coalesceMs, files: snapshots });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
try {
|
|
102
|
+
for (const path of writtenPaths.reverse()) {
|
|
103
|
+
await writeFile(path, snapshots[path].before, snapshots[path].after);
|
|
103
104
|
}
|
|
104
|
-
|
|
105
|
+
} catch (rollbackError) {
|
|
106
|
+
throw new AggregateError(
|
|
107
|
+
[error, rollbackError],
|
|
108
|
+
"Failed to save project files and rollback did not complete",
|
|
109
|
+
);
|
|
105
110
|
}
|
|
106
|
-
|
|
107
|
-
}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
return changedPaths;
|
|
108
114
|
}
|