@hyperframes/studio 0.8.90 → 0.8.92
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-DDjER_ii.js → hyperframes-player-DKJPDHUp.js} +1 -1
- package/dist/assets/{index-BKE3_N4v.js → index-C5_j5-3u.js} +178 -178
- package/dist/assets/{index-5SFeUXP8.js → index-CHZ4kFWm.js} +1 -1
- package/dist/assets/{index-CVsCxnlz.js → index-DpzpqJ_j.js} +1 -1
- package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
- package/dist/chunk-QPSOTK6M.js.map +1 -0
- package/dist/index.d.ts +18 -14
- package/dist/index.html +1 -1
- package/dist/index.js +9190 -8916
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +12 -0
- package/src/components/editor/MotionPathOverlay.tsx +5 -1
- package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
- package/src/components/editor/elementPositionOffset.test.ts +62 -0
- package/src/components/editor/elementPositionOffset.ts +64 -0
- package/src/components/editor/motionPathCommit.test.ts +25 -5
- package/src/components/editor/motionPathSelection.ts +10 -3
- package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
- package/src/components/editor/useLayerRevealOverride.ts +2 -1
- package/src/components/nle/NLEContext.tsx +1 -1
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
- package/src/hooks/domEditPersistFailure.ts +8 -6
- package/src/hooks/elementOffsetStager.ts +65 -0
- package/src/hooks/gestureTransaction.ts +4 -2
- package/src/hooks/gsapDragCommit.ts +1 -0
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
- package/src/hooks/gsapRuntimeBridge.ts +50 -14
- package/src/hooks/gsapShared.test.ts +1 -1
- package/src/hooks/gsapShared.ts +16 -12
- package/src/hooks/gsapTweenReach.ts +30 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -1
- package/src/hooks/useClipboard.source.test.ts +58 -0
- package/src/hooks/useClipboard.ts +12 -3
- package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
- package/src/hooks/useDomGeometryCommit.ts +42 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
- package/src/hooks/useDomGeometryCommits.ts +14 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
- package/src/hooks/useGsapAwareEditing.ts +44 -7
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
- package/src/hooks/useRazorSplit.test.ts +5 -5
- package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
- package/src/hooks/useTimelineAssetDropOps.ts +40 -18
- package/src/hooks/useTimelineEditing.test.tsx +384 -0
- package/src/hooks/useTimelineEditing.ts +12 -12
- package/src/hooks/useTimelineGroupEditing.ts +16 -7
- package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
- package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTypes.ts +1 -1
- package/src/player/components/timelineClipDragCommit.test.ts +32 -66
- package/src/player/components/timelineClipDragCommit.ts +19 -37
- package/src/player/components/timelineClipDragPreview.test.ts +283 -24
- package/src/player/components/timelineClipDragPreview.ts +51 -83
- package/src/player/components/timelineClipDragTypes.ts +3 -8
- package/src/player/components/timelineCollision.test.ts +100 -302
- package/src/player/components/timelineCollision.ts +73 -160
- package/src/player/components/timelineDragDrop.test.tsx +19 -5
- package/src/player/components/timelineDragDrop.ts +7 -9
- package/src/player/components/timelineDragLanding.ts +0 -49
- package/src/player/components/timelineEditing.ts +2 -1
- package/src/player/components/timelineGroupEditing.ts +7 -2
- package/src/player/components/timelineLayout.test.ts +0 -25
- package/src/player/components/timelineLayout.ts +0 -16
- package/src/player/components/timelineMultiDragPreview.test.ts +34 -23
- package/src/player/components/timelineMultiDragPreview.ts +26 -25
- package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
- package/src/player/components/timelineProviderStateBuilders.ts +14 -6
- package/src/player/components/timelineShellMeta.tsx +99 -0
- package/src/player/components/timelineZMirror.test.ts +2 -2
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
- package/src/player/components/useTimelineClipDrag.ts +3 -2
- package/src/player/components/useTimelineDragPreviewState.ts +34 -0
- package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
- package/src/player/components/useTimelinePlayhead.ts +56 -27
- package/src/player/components/useTimelineProviderState.tsx +29 -66
- package/src/player/components/useTimelineZoom.ts +9 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
- package/src/player/hooks/useTimelinePlayer.ts +7 -4
- package/src/player/lib/timelineDOM.test.ts +79 -0
- package/src/player/lib/timelineDOM.ts +16 -7
- package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
- package/src/player/lib/timelineIframeHelpers.ts +12 -3
- package/src/player/store/liveTime.ts +6 -0
- package/src/player/store/playerStore.ts +2 -0
- package/src/player/store/timelineElement.ts +28 -3
- package/src/utils/razorSplitTransaction.test.ts +1 -1
- package/src/utils/razorSplitTransaction.ts +3 -3
- package/src/utils/timelineDropTrackInsert.test.ts +60 -1
- package/src/utils/timelineDropTrackInsert.ts +36 -5
- package/dist/chunk-KU4F7QZ3.js.map +0 -1
|
@@ -6,6 +6,7 @@ import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
|
|
6
6
|
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
7
7
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
8
|
import type { TimelineElement } from "../player";
|
|
9
|
+
import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
|
|
9
10
|
|
|
10
11
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
12
|
|
|
@@ -140,6 +141,143 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
140
141
|
expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
|
|
141
142
|
});
|
|
142
143
|
|
|
144
|
+
// A clip shown on timeline row `row` and written on file track `track`.
|
|
145
|
+
const rowClip = (
|
|
146
|
+
id: string,
|
|
147
|
+
start: number,
|
|
148
|
+
duration: number,
|
|
149
|
+
shown: { tag?: string; row?: number; track?: number } = {},
|
|
150
|
+
): TimelineElement => ({
|
|
151
|
+
id,
|
|
152
|
+
key: id,
|
|
153
|
+
tag: shown.tag ?? "div",
|
|
154
|
+
start,
|
|
155
|
+
duration,
|
|
156
|
+
track: shown.row ?? 0,
|
|
157
|
+
authoredTrack: shown.track ?? shown.row ?? 0,
|
|
158
|
+
hfId: `hf-${id}`,
|
|
159
|
+
domId: id,
|
|
160
|
+
});
|
|
161
|
+
const rowSource = (clips: TimelineElement[]) =>
|
|
162
|
+
[
|
|
163
|
+
'<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080">',
|
|
164
|
+
...clips.map(
|
|
165
|
+
(c) =>
|
|
166
|
+
`<${c.tag} data-hf-id="hf-${c.id}" id="${c.id}" data-start="${c.start}" data-duration="${c.duration}" data-track-index="${c.authoredTrack}"></${c.tag}>`,
|
|
167
|
+
),
|
|
168
|
+
"</main>",
|
|
169
|
+
].join("\n");
|
|
170
|
+
const writtenClip = (written: string, tag: string) =>
|
|
171
|
+
written.match(new RegExp(`<${tag} id="[^"]*"[^>]*>`))?.[0] ?? "";
|
|
172
|
+
|
|
173
|
+
it("lands an asset dropped on an occupied row at that row's nearest free time", async () => {
|
|
174
|
+
const subtitle = rowClip("subtitle", 0, 6);
|
|
175
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
176
|
+
const getDrop = renderDropHook(rowSource([subtitle]), writeProjectFile, [subtitle]);
|
|
177
|
+
|
|
178
|
+
await act(async () => {
|
|
179
|
+
await getDrop()("clip.mp4", { start: 2, track: 0 }, 5);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const [, written] = writeProjectFile.mock.calls[0] as [string, string];
|
|
183
|
+
const clip = writtenClip(written, "video");
|
|
184
|
+
expect(clip).toContain('data-start="6"');
|
|
185
|
+
expect(clip).toContain('data-track-index="0"');
|
|
186
|
+
expect(written).toContain(
|
|
187
|
+
'id="subtitle" data-start="0" data-duration="6" data-track-index="0"',
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Drops two files (3 s images by default) onto `clips`; returns each written clip's start and track.
|
|
192
|
+
async function twoFileDrop(
|
|
193
|
+
clips: TimelineElement[],
|
|
194
|
+
placement: TimelineDropPlacement,
|
|
195
|
+
[names, tag] = [["one.png", "two.png"], "img"] as [string[], string],
|
|
196
|
+
) {
|
|
197
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
198
|
+
const uploadProjectFiles = vi.fn().mockResolvedValue(names);
|
|
199
|
+
const getDrop = renderDropHook(
|
|
200
|
+
rowSource(clips),
|
|
201
|
+
writeProjectFile,
|
|
202
|
+
clips,
|
|
203
|
+
undefined,
|
|
204
|
+
uploadProjectFiles,
|
|
205
|
+
);
|
|
206
|
+
const files = names.map((name) => new File([name], name));
|
|
207
|
+
await act(async () => {
|
|
208
|
+
await getDrop.file()(files, placement);
|
|
209
|
+
});
|
|
210
|
+
return writeProjectFile.mock.calls.map(([, written]) => {
|
|
211
|
+
const img = writtenClip(written as string, tag);
|
|
212
|
+
return [img.match(/data-start="([^"]*)"/)?.[1], img.match(/data-track-index="([^"]*)"/)?.[1]];
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
it("keeps a multi-file drop's clips off each other and off the row's clips", async () => {
|
|
217
|
+
const landed = await twoFileDrop([rowClip("a", 0, 4), rowClip("b", 8, 2)], {
|
|
218
|
+
start: 1,
|
|
219
|
+
track: 0,
|
|
220
|
+
});
|
|
221
|
+
// The first moves past a [0,4); the second is too long for [7,8) and goes after b.
|
|
222
|
+
expect(landed).toEqual([
|
|
223
|
+
["4", "0"],
|
|
224
|
+
["10", "0"],
|
|
225
|
+
]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("puts a multi-file drop that opens a track back to back on that new track", async () => {
|
|
229
|
+
const landed = await twoFileDrop([rowClip("a", 0, 10)], {
|
|
230
|
+
start: 2,
|
|
231
|
+
track: 0,
|
|
232
|
+
insertRow: 0,
|
|
233
|
+
trackOrder: [0],
|
|
234
|
+
});
|
|
235
|
+
// Clip a moved down a row, so it no longer blocks the second file.
|
|
236
|
+
expect(landed).toEqual([
|
|
237
|
+
["2", "0"],
|
|
238
|
+
["5", "0"],
|
|
239
|
+
]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("keeps a multi-file drop on the file track of the row it was dropped on", async () => {
|
|
243
|
+
const clips = [
|
|
244
|
+
rowClip("title", 0, 10, { track: 2 }),
|
|
245
|
+
rowClip("mid", 0, 1, { row: 1, track: 3 }),
|
|
246
|
+
rowClip("other", 0, 1, { row: 2, track: 5 }),
|
|
247
|
+
];
|
|
248
|
+
// Row 0 is file track 2; the second file must not read that 2 back as row 2 (track 5).
|
|
249
|
+
expect(await twoFileDrop(clips, { start: 3, track: 0 })).toEqual([
|
|
250
|
+
["10", "2"],
|
|
251
|
+
["13", "2"],
|
|
252
|
+
]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("reads a row's file track from every clip of the file, whether or not it names the file", async () => {
|
|
256
|
+
const unnamed = rowClip("unnamed", 0, 1, { row: 2, track: 0 });
|
|
257
|
+
const title = { ...rowClip("title", 0, 10), sourceFile: "index.html" };
|
|
258
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
259
|
+
const getDrop = renderDropHook(rowSource([unnamed, title]), writeProjectFile, [unnamed, title]);
|
|
260
|
+
await act(async () => {
|
|
261
|
+
await getDrop()("pic.png", { start: 2, track: 0 }, 3);
|
|
262
|
+
});
|
|
263
|
+
const img = writtenClip(writeProjectFile.mock.calls[0][1] as string, "img");
|
|
264
|
+
expect(img).toContain('data-track-index="0"');
|
|
265
|
+
expect(img).toContain('data-start="10"');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("keeps two audio files dropped on a visual row together, as a single one would go", async () => {
|
|
269
|
+
const clips = [
|
|
270
|
+
rowClip("title", 0, 10, { track: 1 }),
|
|
271
|
+
rowClip("music", 2, 3, { tag: "audio", row: 1, track: 0 }),
|
|
272
|
+
];
|
|
273
|
+
const landed = await twoFileDrop(clips, { start: 12, track: 0 }, [["a.mp3", "b.mp3"], "audio"]);
|
|
274
|
+
// Audio length probing times out in this harness, so each file gets the 5 s default.
|
|
275
|
+
expect(landed).toEqual([
|
|
276
|
+
["12", "0"],
|
|
277
|
+
["17", "0"],
|
|
278
|
+
]);
|
|
279
|
+
}, 30_000);
|
|
280
|
+
|
|
143
281
|
it("refuses an asset drop before reading or writing when editing is blocked", async () => {
|
|
144
282
|
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
145
283
|
const checkEditable = vi.fn(() => false);
|
|
@@ -30,14 +30,20 @@ import { extendRootDurationInSource } from "../utils/rootDuration";
|
|
|
30
30
|
import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
|
|
31
31
|
import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
|
|
32
32
|
|
|
33
|
-
/** The first uploaded file opens the new track (if asked);
|
|
33
|
+
/** The first uploaded file opens the new track (if asked); each next one aims right after the previous. */
|
|
34
34
|
function fileDropPlacement(
|
|
35
35
|
index: number,
|
|
36
36
|
next: { start: number; track: number },
|
|
37
37
|
dropped: TimelineDropPlacement | undefined,
|
|
38
|
-
|
|
38
|
+
previous: TimelineElement | undefined,
|
|
39
39
|
): TimelineDropPlacement {
|
|
40
|
-
|
|
40
|
+
if (index === 0) return { ...dropped, ...next };
|
|
41
|
+
return previous ? { start: previous.start + previous.duration, track: previous.track } : next;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface DropGesture {
|
|
45
|
+
placed: TimelineElement[];
|
|
46
|
+
onNewTrack: boolean;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
function timelineDropTarget(
|
|
@@ -90,7 +96,8 @@ export function useTimelineAssetDropOps({
|
|
|
90
96
|
assetPath: string,
|
|
91
97
|
placement: TimelineDropPlacement,
|
|
92
98
|
durationOverride?: number,
|
|
93
|
-
|
|
99
|
+
gesture: DropGesture = { placed: [], onNewTrack: false },
|
|
100
|
+
): Promise<TimelineElement | undefined> => {
|
|
94
101
|
if (isRecordingRef?.current) {
|
|
95
102
|
showToast("Cannot edit timeline while recording", "error");
|
|
96
103
|
return undefined;
|
|
@@ -121,28 +128,32 @@ export function useTimelineAssetDropOps({
|
|
|
121
128
|
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
|
122
129
|
|
|
123
130
|
const resolvedTargetPath = targetPath || "index.html";
|
|
124
|
-
const
|
|
125
|
-
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath
|
|
126
|
-
|
|
127
|
-
const newElementZIndex = Math.max(1,
|
|
131
|
+
const fileElements = timelineElements
|
|
132
|
+
.filter((te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath)
|
|
133
|
+
.map((te) => ({ ...te, sourceFile: resolvedTargetPath }));
|
|
134
|
+
const newElementZIndex = Math.max(1, fileElements.length + gesture.placed.length + 1);
|
|
135
|
+
const tag = kind === "image" ? "img" : kind;
|
|
128
136
|
|
|
129
137
|
let newId = "";
|
|
130
138
|
let track = 0;
|
|
139
|
+
let start = normalizedStart;
|
|
131
140
|
const insertAsset = (originalContent: string) => {
|
|
132
141
|
newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
|
133
142
|
const resolved = resolveDropTrack({
|
|
134
143
|
source: originalContent,
|
|
135
|
-
// insertRow
|
|
136
|
-
elements:
|
|
144
|
+
// Rows and insertRow count the rows the timeline shows; a track this drop opened has no others.
|
|
145
|
+
elements: gesture.onNewTrack ? [] : fileElements,
|
|
146
|
+
placed: gesture.placed,
|
|
137
147
|
placement,
|
|
138
148
|
dropped: {
|
|
139
149
|
id: newId,
|
|
140
|
-
tag
|
|
150
|
+
tag,
|
|
141
151
|
start: normalizedStart,
|
|
142
152
|
duration: normalizedDuration,
|
|
143
153
|
},
|
|
144
154
|
});
|
|
145
155
|
track = resolved.track;
|
|
156
|
+
start = resolved.start;
|
|
146
157
|
return extendRootDurationInSource(
|
|
147
158
|
insertTimelineAssetIntoSource(
|
|
148
159
|
resolved.source,
|
|
@@ -151,7 +162,7 @@ export function useTimelineAssetDropOps({
|
|
|
151
162
|
hfId: `hf-${generateId()}`,
|
|
152
163
|
assetPath: resolvedAssetSrc,
|
|
153
164
|
kind,
|
|
154
|
-
start
|
|
165
|
+
start,
|
|
155
166
|
duration: normalizedDuration,
|
|
156
167
|
track,
|
|
157
168
|
zIndex: newElementZIndex,
|
|
@@ -162,7 +173,7 @@ export function useTimelineAssetDropOps({
|
|
|
162
173
|
),
|
|
163
174
|
}),
|
|
164
175
|
),
|
|
165
|
-
|
|
176
|
+
start + normalizedDuration,
|
|
166
177
|
);
|
|
167
178
|
};
|
|
168
179
|
|
|
@@ -178,7 +189,15 @@ export function useTimelineAssetDropOps({
|
|
|
178
189
|
selectAndRevealTimelineElement(deriveTimelineStoreKeyForDomId(newId, targetPath));
|
|
179
190
|
forceReloadSdkSession?.();
|
|
180
191
|
reloadPreview();
|
|
181
|
-
|
|
192
|
+
const lane = placement.insertRow == null ? placement.track : track;
|
|
193
|
+
return {
|
|
194
|
+
id: newId,
|
|
195
|
+
tag,
|
|
196
|
+
start,
|
|
197
|
+
duration: normalizedDuration,
|
|
198
|
+
track: lane,
|
|
199
|
+
authoredTrack: track,
|
|
200
|
+
};
|
|
182
201
|
} catch (error) {
|
|
183
202
|
const message =
|
|
184
203
|
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
|
|
@@ -234,15 +253,18 @@ export function useTimelineAssetDropOps({
|
|
|
234
253
|
placement ?? { start: 0, track: 0 },
|
|
235
254
|
durations,
|
|
236
255
|
);
|
|
237
|
-
|
|
256
|
+
const gesture: DropGesture = { placed: [], onNewTrack: false };
|
|
238
257
|
for (const [index, assetPath] of uploaded.entries()) {
|
|
239
258
|
const next = placements[index] ?? placements[0];
|
|
240
|
-
const
|
|
259
|
+
const clip = await dropAssetAt(
|
|
241
260
|
assetPath,
|
|
242
|
-
fileDropPlacement(index, next, placement,
|
|
261
|
+
fileDropPlacement(index, next, placement, gesture.placed.at(-1)),
|
|
243
262
|
durations[index],
|
|
263
|
+
gesture,
|
|
244
264
|
);
|
|
245
|
-
if (
|
|
265
|
+
if (!clip) continue;
|
|
266
|
+
gesture.placed.push(clip);
|
|
267
|
+
if (index === 0 && placement?.insertRow != null) gesture.onNewTrack = true;
|
|
246
268
|
}
|
|
247
269
|
},
|
|
248
270
|
[
|
|
@@ -9,6 +9,18 @@ import { usePlayerStore, type TimelineElement } from "../player";
|
|
|
9
9
|
import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
|
|
10
10
|
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
|
11
11
|
import { useTimelineEditing } from "./useTimelineEditing";
|
|
12
|
+
import {
|
|
13
|
+
buildMissingCompositionElements,
|
|
14
|
+
createTimelineElementFromManifestClip,
|
|
15
|
+
parseTimelineFromDOM,
|
|
16
|
+
} from "../player/lib/timelineDOM";
|
|
17
|
+
import type { ClipManifestClip, IframeWindow } from "../player/lib/playbackTypes";
|
|
18
|
+
import { computeResizePreview } from "../player/components/timelineClipDragPreview";
|
|
19
|
+
import {
|
|
20
|
+
buildTimelineGroupResizeMembers,
|
|
21
|
+
resolveTimelineGroupResizeChanges,
|
|
22
|
+
} from "../player/components/timelineGroupEditing";
|
|
23
|
+
import { createRuntimeStartTimeResolver } from "@hyperframes/core/runtime/start-resolver";
|
|
12
24
|
|
|
13
25
|
vi.mock("../components/editor/manualEditingAvailability", async (importOriginal) => {
|
|
14
26
|
const actual =
|
|
@@ -1969,3 +1981,375 @@ describe("useTimelineEditing effect saves report what happened", () => {
|
|
|
1969
1981
|
unmount();
|
|
1970
1982
|
});
|
|
1971
1983
|
});
|
|
1984
|
+
|
|
1985
|
+
// main 0 > intro 2 > logo 3 > badge 1: rows are master time, files are local.
|
|
1986
|
+
const NESTED_PREVIEW = `
|
|
1987
|
+
<div data-composition-id="main" data-start="0" data-duration="20">
|
|
1988
|
+
<div id="intro" data-hf-id="hf-intro" data-composition-id="intro" data-start="2" data-duration="10">
|
|
1989
|
+
<div data-composition-id="intro" data-composition-file="compositions/intro.html">
|
|
1990
|
+
<video id="vo" data-start="7" data-duration="2" data-hf-media-start-basis="global"></video>
|
|
1991
|
+
<div id="logo" data-hf-id="hf-logo" data-composition-id="logo" data-start="3" data-duration="5">
|
|
1992
|
+
<div data-composition-id="logo" data-composition-file="compositions/logo.html">
|
|
1993
|
+
<div id="badge" data-hf-id="hf-badge" class="clip" data-start="1" data-duration="2"></div>
|
|
1994
|
+
<div id="star" data-hf-id="hf-star" data-composition-id="star" data-start="1.5" data-duration="2">
|
|
1995
|
+
<div data-composition-id="star" data-composition-file="compositions/star.html"></div>
|
|
1996
|
+
</div>
|
|
1997
|
+
</div>
|
|
1998
|
+
</div>
|
|
1999
|
+
</div>
|
|
2000
|
+
</div>
|
|
2001
|
+
</div>`;
|
|
2002
|
+
const NESTED_FILES: Record<string, string> = {
|
|
2003
|
+
"index.html": [
|
|
2004
|
+
`<div data-composition-id="main" data-duration="20">`,
|
|
2005
|
+
` <div id="intro" data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="2" data-duration="10"></div>`,
|
|
2006
|
+
`</div>`,
|
|
2007
|
+
].join("\n"),
|
|
2008
|
+
"compositions/intro.html": [
|
|
2009
|
+
`<div data-composition-id="intro" data-duration="10">`,
|
|
2010
|
+
` <div id="intro-bg" class="clip" data-start="0" data-duration="10"></div>`,
|
|
2011
|
+
` <video id="vo" data-start="7" data-duration="2" data-hf-media-start-basis="global"></video>`,
|
|
2012
|
+
` <div id="logo" data-composition-id="logo" data-composition-src="compositions/logo.html" data-start="3" data-duration="5"></div>`,
|
|
2013
|
+
`</div>`,
|
|
2014
|
+
].join("\n"),
|
|
2015
|
+
"compositions/logo.html": [
|
|
2016
|
+
`<div data-composition-id="logo" data-duration="5">`,
|
|
2017
|
+
` <div id="logo-bg" class="clip" data-start="0" data-duration="5"></div>`,
|
|
2018
|
+
` <div id="badge" class="clip" data-start="1" data-duration="2"></div>`,
|
|
2019
|
+
` <div id="star" data-composition-id="star" data-composition-src="compositions/star.html" data-start="1.5" data-duration="2"></div>`,
|
|
2020
|
+
`</div>`,
|
|
2021
|
+
].join("\n"),
|
|
2022
|
+
};
|
|
2023
|
+
|
|
2024
|
+
function setupNestedHarness() {
|
|
2025
|
+
const iframe = document.createElement("iframe");
|
|
2026
|
+
document.body.append(iframe);
|
|
2027
|
+
const doc = iframe.contentDocument!;
|
|
2028
|
+
doc.body.innerHTML = NESTED_PREVIEW;
|
|
2029
|
+
const row = (domId: string) => parseTimelineFromDOM(doc, 20).find((e) => e.domId === domId)!;
|
|
2030
|
+
// The app's path: the runtime manifest's root clips, then the missing-host pass.
|
|
2031
|
+
const manifestRow = (domId: string) => {
|
|
2032
|
+
const intro = { id: "intro", start: 2, duration: 10, track: 0, kind: "composition" };
|
|
2033
|
+
const clip = { ...intro, tagName: "div", compositionId: "intro" } as ClipManifestClip;
|
|
2034
|
+
const hostEl = doc.getElementById("intro");
|
|
2035
|
+
const roots = [createTimelineElementFromManifestClip({ clip, fallbackIndex: 0, doc, hostEl })];
|
|
2036
|
+
const win = iframe.contentWindow as IframeWindow;
|
|
2037
|
+
return buildMissingCompositionElements(doc, win, roots, 20).missing.find(
|
|
2038
|
+
(e) => e.domId === domId,
|
|
2039
|
+
)!;
|
|
2040
|
+
};
|
|
2041
|
+
const playsAt = (domId: string) => {
|
|
2042
|
+
const resolver = createRuntimeStartTimeResolver({
|
|
2043
|
+
includeAuthoredTimingAttrs: true,
|
|
2044
|
+
documentRef: doc,
|
|
2045
|
+
});
|
|
2046
|
+
const el = doc.getElementById(domId)!;
|
|
2047
|
+
return el.tagName === "VIDEO"
|
|
2048
|
+
? resolver.resolveMediaStartForElement(el)
|
|
2049
|
+
: resolver.resolveStartForElement(el);
|
|
2050
|
+
};
|
|
2051
|
+
const writeProjectFile = vi.fn<(path: string, content: string) => Promise<void>>(async () => {});
|
|
2052
|
+
const written = (path: string, domId: string, attr: string) => {
|
|
2053
|
+
const call = writeProjectFile.mock.calls.find(([p]) => p === path);
|
|
2054
|
+
if (!call) throw new Error(`nothing written to ${path}`);
|
|
2055
|
+
const el = new DOMParser().parseFromString(call[1], "text/html").getElementById(domId);
|
|
2056
|
+
return el?.getAttribute(attr);
|
|
2057
|
+
};
|
|
2058
|
+
const writtenRootDuration = (path: string) => {
|
|
2059
|
+
const call = writeProjectFile.mock.calls.find(([p]) => p === path)!;
|
|
2060
|
+
return new DOMParser()
|
|
2061
|
+
.parseFromString(call[1], "text/html")
|
|
2062
|
+
.querySelector("[data-composition-id]")
|
|
2063
|
+
?.getAttribute("data-duration");
|
|
2064
|
+
};
|
|
2065
|
+
const fetchMock = stubProjectFetch(NESTED_FILES);
|
|
2066
|
+
const scaleCalls = () =>
|
|
2067
|
+
fetchMock.mock.calls
|
|
2068
|
+
.filter((call) => requestUrl(call[0]).includes("/gsap-mutations/"))
|
|
2069
|
+
.map((call) => JSON.parse(String((call[1] as RequestInit).body)))
|
|
2070
|
+
.filter((body) => body.type === "scale-positions");
|
|
2071
|
+
usePlayerStore.getState().setDuration(20);
|
|
2072
|
+
const hook = renderTimelineEditingHook({
|
|
2073
|
+
timelineElements: parseTimelineFromDOM(doc, 20),
|
|
2074
|
+
iframe,
|
|
2075
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
2076
|
+
projectId: "p1",
|
|
2077
|
+
writeProjectFile,
|
|
2078
|
+
recordEdit: vi.fn(async () => {}),
|
|
2079
|
+
});
|
|
2080
|
+
return {
|
|
2081
|
+
doc,
|
|
2082
|
+
row,
|
|
2083
|
+
manifestRow,
|
|
2084
|
+
playsAt,
|
|
2085
|
+
written,
|
|
2086
|
+
writtenRootDuration,
|
|
2087
|
+
writeProjectFile,
|
|
2088
|
+
scaleCalls,
|
|
2089
|
+
...hook,
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
describe("useTimelineEditing: nested rows write composition-local starts", () => {
|
|
2094
|
+
it("round-trips a depth-2 drag: drawn and played at the drop point, written local", async () => {
|
|
2095
|
+
const h = setupNestedHarness();
|
|
2096
|
+
const logo = h.row("logo");
|
|
2097
|
+
expect(logo.start).toBe(5);
|
|
2098
|
+
|
|
2099
|
+
await act(async () => {
|
|
2100
|
+
await h.move(logo, { start: 6, track: logo.track });
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("4");
|
|
2104
|
+
expect(h.doc.getElementById("logo")?.getAttribute("data-start")).toBe("4");
|
|
2105
|
+
expect(h.row("logo").start).toBe(6);
|
|
2106
|
+
expect(h.playsAt("logo")).toBe(6);
|
|
2107
|
+
expect(h.writtenRootDuration("compositions/intro.html")).toBe("10");
|
|
2108
|
+
h.unmount();
|
|
2109
|
+
});
|
|
2110
|
+
|
|
2111
|
+
it("round-trips a depth-3 drag through two enclosing hosts", async () => {
|
|
2112
|
+
const h = setupNestedHarness();
|
|
2113
|
+
const badge = h.row("badge");
|
|
2114
|
+
expect(badge.start).toBe(6);
|
|
2115
|
+
|
|
2116
|
+
await act(async () => {
|
|
2117
|
+
await h.move(badge, { start: 7, track: badge.track });
|
|
2118
|
+
});
|
|
2119
|
+
|
|
2120
|
+
expect(h.written("compositions/logo.html", "badge", "data-start")).toBe("2");
|
|
2121
|
+
expect(h.row("badge").start).toBe(7);
|
|
2122
|
+
expect(h.playsAt("badge")).toBe(7);
|
|
2123
|
+
expect(h.writtenRootDuration("compositions/logo.html")).toBe("5");
|
|
2124
|
+
h.unmount();
|
|
2125
|
+
});
|
|
2126
|
+
|
|
2127
|
+
it("round-trips a legacy root-time video inside a sub-composition, writing master time", async () => {
|
|
2128
|
+
const h = setupNestedHarness();
|
|
2129
|
+
const vo = h.row("vo");
|
|
2130
|
+
expect(vo.start).toBe(7);
|
|
2131
|
+
|
|
2132
|
+
await act(async () => {
|
|
2133
|
+
await h.move(vo, { start: 8, track: vo.track });
|
|
2134
|
+
});
|
|
2135
|
+
|
|
2136
|
+
expect(h.written("compositions/intro.html", "vo", "data-start")).toBe("8");
|
|
2137
|
+
expect(h.row("vo").start).toBe(8);
|
|
2138
|
+
expect(h.playsAt("vo")).toBe(8);
|
|
2139
|
+
h.unmount();
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
it("writes a local start on a head trim and leaves a tail trim's start alone", async () => {
|
|
2143
|
+
const h = setupNestedHarness();
|
|
2144
|
+
await act(async () => {
|
|
2145
|
+
await h.resize(h.row("logo"), { start: 5.5, duration: 4.5, playbackStart: undefined });
|
|
2146
|
+
});
|
|
2147
|
+
expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("3.5");
|
|
2148
|
+
expect(h.written("compositions/intro.html", "logo", "data-duration")).toBe("4.5");
|
|
2149
|
+
expect(h.row("logo")).toMatchObject({ start: 5.5, duration: 4.5 });
|
|
2150
|
+
|
|
2151
|
+
h.writeProjectFile.mockClear();
|
|
2152
|
+
await act(async () => {
|
|
2153
|
+
await h.resize(h.row("logo"), { start: 5.5, duration: 3, playbackStart: undefined });
|
|
2154
|
+
});
|
|
2155
|
+
expect(h.doc.getElementById("logo")?.getAttribute("data-start")).toBe("3.5");
|
|
2156
|
+
expect(h.doc.getElementById("logo")?.getAttribute("data-duration")).toBe("3");
|
|
2157
|
+
h.unmount();
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2160
|
+
it("scales a head-trimmed nested row's tweens over its local window", async () => {
|
|
2161
|
+
const h = setupNestedHarness();
|
|
2162
|
+
await act(async () => {
|
|
2163
|
+
await h.resize(h.row("logo"), { start: 5.5, duration: 4.5, playbackStart: undefined });
|
|
2164
|
+
});
|
|
2165
|
+
expect(h.scaleCalls()).toEqual([
|
|
2166
|
+
expect.objectContaining({ oldStart: 3, oldDuration: 5, newStart: 3.5, newDuration: 4.5 }),
|
|
2167
|
+
]);
|
|
2168
|
+
h.unmount();
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
it("writes local starts and a local scale window for a nested group resize", async () => {
|
|
2172
|
+
const h = setupNestedHarness();
|
|
2173
|
+
await act(async () => {
|
|
2174
|
+
await h.groupResize([{ element: h.row("logo"), start: 5.5, duration: 4.5 }]);
|
|
2175
|
+
});
|
|
2176
|
+
expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("3.5");
|
|
2177
|
+
expect(h.scaleCalls()).toEqual([
|
|
2178
|
+
expect.objectContaining({ oldStart: 3, oldDuration: 5, newStart: 3.5, newDuration: 4.5 }),
|
|
2179
|
+
]);
|
|
2180
|
+
h.unmount();
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
it("scales a legacy root-time video's tweens on its host's clock, single and group", async () => {
|
|
2184
|
+
const window = { oldStart: 5, oldDuration: 2, newStart: 5.5, newDuration: 1.5 };
|
|
2185
|
+
for (const group of [false, true]) {
|
|
2186
|
+
const h = setupNestedHarness();
|
|
2187
|
+
const vo = h.row("vo");
|
|
2188
|
+
await act(async () => {
|
|
2189
|
+
if (group) await h.groupResize([{ element: vo, start: 7.5, duration: 1.5 }]);
|
|
2190
|
+
else await h.resize(vo, { start: 7.5, duration: 1.5, playbackStart: undefined });
|
|
2191
|
+
});
|
|
2192
|
+
expect(h.scaleCalls()).toEqual([expect.objectContaining(window)]);
|
|
2193
|
+
h.unmount();
|
|
2194
|
+
vi.unstubAllGlobals();
|
|
2195
|
+
}
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
it("stops a head trim past the host's start at the host's start, end held", async () => {
|
|
2199
|
+
const h = setupNestedHarness();
|
|
2200
|
+
const logo = h.manifestRow("logo");
|
|
2201
|
+
expect(logo).toMatchObject({ start: 5, duration: 5, parentCompositionStart: 2 });
|
|
2202
|
+
const pps = 100;
|
|
2203
|
+
const preview = computeResizePreview(
|
|
2204
|
+
{
|
|
2205
|
+
element: logo,
|
|
2206
|
+
edge: "start",
|
|
2207
|
+
originClientX: 0,
|
|
2208
|
+
previewStart: 5,
|
|
2209
|
+
previewDuration: 5,
|
|
2210
|
+
started: true,
|
|
2211
|
+
pointerId: 1,
|
|
2212
|
+
},
|
|
2213
|
+
-4 * pps,
|
|
2214
|
+
{ scroll: null, pps, buildSnapTargets: () => [] },
|
|
2215
|
+
);
|
|
2216
|
+
expect(preview).toMatchObject({ previewStart: 2, previewDuration: 8 });
|
|
2217
|
+
|
|
2218
|
+
await act(async () => {
|
|
2219
|
+
await h.resize(logo, {
|
|
2220
|
+
start: preview.previewStart,
|
|
2221
|
+
duration: preview.previewDuration,
|
|
2222
|
+
playbackStart: undefined,
|
|
2223
|
+
});
|
|
2224
|
+
});
|
|
2225
|
+
expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("0");
|
|
2226
|
+
expect(h.written("compositions/intro.html", "logo", "data-duration")).toBe("8");
|
|
2227
|
+
expect(h.row("logo")).toMatchObject({ start: 2, duration: 8 });
|
|
2228
|
+
expect(h.scaleCalls()).toEqual([
|
|
2229
|
+
expect.objectContaining({ oldStart: 3, oldDuration: 5, newStart: 0, newDuration: 8 }),
|
|
2230
|
+
]);
|
|
2231
|
+
h.unmount();
|
|
2232
|
+
});
|
|
2233
|
+
|
|
2234
|
+
it("stops a group head trim at the nested member's host start", async () => {
|
|
2235
|
+
const h = setupNestedHarness();
|
|
2236
|
+
const logo = h.manifestRow("logo");
|
|
2237
|
+
const outro: TimelineElement = {
|
|
2238
|
+
...logo,
|
|
2239
|
+
id: "outro",
|
|
2240
|
+
key: "outro",
|
|
2241
|
+
domId: undefined,
|
|
2242
|
+
start: 12,
|
|
2243
|
+
parentCompositionStart: 0,
|
|
2244
|
+
};
|
|
2245
|
+
const members = buildTimelineGroupResizeMembers(
|
|
2246
|
+
[logo, outro],
|
|
2247
|
+
new Set([logo.key ?? logo.id, "outro"]),
|
|
2248
|
+
logo.key ?? logo.id,
|
|
2249
|
+
"start",
|
|
2250
|
+
)!;
|
|
2251
|
+
const changes = resolveTimelineGroupResizeChanges(members, "start", -4);
|
|
2252
|
+
const logoChange = changes.find((c) => c.key === (logo.key ?? logo.id))!;
|
|
2253
|
+
expect(logoChange).toMatchObject({ start: 2, duration: 8 });
|
|
2254
|
+
|
|
2255
|
+
await act(async () => {
|
|
2256
|
+
await h.groupResize([
|
|
2257
|
+
{ element: logo, start: logoChange.start, duration: logoChange.duration },
|
|
2258
|
+
]);
|
|
2259
|
+
});
|
|
2260
|
+
expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("0");
|
|
2261
|
+
expect(h.written("compositions/intro.html", "logo", "data-duration")).toBe("8");
|
|
2262
|
+
expect(h.scaleCalls()).toEqual([
|
|
2263
|
+
expect.objectContaining({ oldStart: 3, oldDuration: 5, newStart: 0, newDuration: 8 }),
|
|
2264
|
+
]);
|
|
2265
|
+
h.unmount();
|
|
2266
|
+
});
|
|
2267
|
+
|
|
2268
|
+
it("round-trips a depth-3 host from the manifest path", async () => {
|
|
2269
|
+
const h = setupNestedHarness();
|
|
2270
|
+
const star = h.manifestRow("star");
|
|
2271
|
+
expect(star).toMatchObject({ start: 6.5, parentCompositionStart: 5 });
|
|
2272
|
+
|
|
2273
|
+
await act(async () => {
|
|
2274
|
+
await h.move(star, { start: 7.5, track: star.track });
|
|
2275
|
+
});
|
|
2276
|
+
|
|
2277
|
+
expect(h.written("compositions/logo.html", "star", "data-start")).toBe("2.5");
|
|
2278
|
+
expect(h.playsAt("star")).toBe(7.5);
|
|
2279
|
+
expect(h.manifestRow("star").start).toBe(7.5);
|
|
2280
|
+
h.unmount();
|
|
2281
|
+
});
|
|
2282
|
+
|
|
2283
|
+
it("clamps a nested row dropped before its host's start to the host's start", async () => {
|
|
2284
|
+
const h = setupNestedHarness();
|
|
2285
|
+
const logo = h.row("logo");
|
|
2286
|
+
await act(async () => {
|
|
2287
|
+
await h.move(logo, { start: 1, track: logo.track });
|
|
2288
|
+
});
|
|
2289
|
+
expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("0");
|
|
2290
|
+
expect(h.row("logo").start).toBe(2);
|
|
2291
|
+
h.unmount();
|
|
2292
|
+
});
|
|
2293
|
+
|
|
2294
|
+
it("writes local starts for nested rows in a group move", async () => {
|
|
2295
|
+
const h = setupNestedHarness();
|
|
2296
|
+
await act(async () => {
|
|
2297
|
+
await h.groupMove([{ element: h.row("logo"), start: 6 }]);
|
|
2298
|
+
});
|
|
2299
|
+
expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("4");
|
|
2300
|
+
expect(h.row("logo").start).toBe(6);
|
|
2301
|
+
h.unmount();
|
|
2302
|
+
});
|
|
2303
|
+
|
|
2304
|
+
it("leaves a top-level row's start as written", async () => {
|
|
2305
|
+
const h = setupNestedHarness();
|
|
2306
|
+
const intro = h.row("intro");
|
|
2307
|
+
expect(intro.parentCompositionStart).toBe(0);
|
|
2308
|
+
|
|
2309
|
+
await act(async () => {
|
|
2310
|
+
await h.move(intro, { start: 3, track: intro.track });
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2313
|
+
expect(h.written("index.html", "intro", "data-start")).toBe("3");
|
|
2314
|
+
expect(h.row("intro").start).toBe(3);
|
|
2315
|
+
h.unmount();
|
|
2316
|
+
});
|
|
2317
|
+
|
|
2318
|
+
it("hands the SDK a local start for a nested clip in the open file", async () => {
|
|
2319
|
+
const source = [
|
|
2320
|
+
`<div data-hf-id="hf-main" data-hf-root data-composition-id="main" data-duration="20">`,
|
|
2321
|
+
` <div id="intro" data-hf-id="hf-intro" data-composition-id="intro" data-start="2" data-duration="10">`,
|
|
2322
|
+
` <div id="clip" data-hf-id="hf-clip" data-start="1" data-duration="2"></div>`,
|
|
2323
|
+
` </div>`,
|
|
2324
|
+
`</div>`,
|
|
2325
|
+
].join("\n");
|
|
2326
|
+
const iframe = document.createElement("iframe");
|
|
2327
|
+
document.body.append(iframe);
|
|
2328
|
+
iframe.contentDocument!.body.innerHTML = source;
|
|
2329
|
+
const clip = parseTimelineFromDOM(iframe.contentDocument!, 20).find((e) => e.domId === "clip")!;
|
|
2330
|
+
expect(clip.start).toBe(3);
|
|
2331
|
+
const sdkSession = await openComposition(source);
|
|
2332
|
+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
2333
|
+
stubProjectFetch(source);
|
|
2334
|
+
usePlayerStore.getState().setDuration(20);
|
|
2335
|
+
const hook = renderTimelineEditingHook({
|
|
2336
|
+
timelineElements: [clip],
|
|
2337
|
+
iframe,
|
|
2338
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
2339
|
+
projectId: "p1",
|
|
2340
|
+
writeProjectFile,
|
|
2341
|
+
recordEdit: vi.fn(async () => {}),
|
|
2342
|
+
sdkSession,
|
|
2343
|
+
publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
|
|
2344
|
+
});
|
|
2345
|
+
|
|
2346
|
+
await act(async () => {
|
|
2347
|
+
await hook.move(clip, { start: 4, track: clip.track });
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2350
|
+
expect(writeProjectFile.mock.calls[0]?.[1]).toContain(
|
|
2351
|
+
'id="clip" data-hf-id="hf-clip" data-start="2"',
|
|
2352
|
+
);
|
|
2353
|
+
hook.unmount();
|
|
2354
|
+
});
|
|
2355
|
+
});
|