@hyperframes/studio 0.6.7 → 0.6.8
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-BSe0Kibk.js +115 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/App.tsx +5 -10
- package/src/components/StudioLeftSidebar.tsx +16 -2
- package/src/components/StudioRightPanel.tsx +15 -2
- package/src/components/editor/MotionPanel.tsx +8 -8
- package/src/components/editor/SourceEditor.tsx +14 -0
- package/src/components/editor/manualEdits.ts +2 -0
- package/src/components/editor/manualEditsDom.ts +56 -0
- package/src/components/editor/studioMotion.ts +96 -0
- package/src/components/editor/studioMotionOps.test.ts +445 -0
- package/src/components/editor/studioMotionOps.ts +78 -4
- package/src/components/renders/RenderQueue.tsx +20 -6
- package/src/components/renders/renderSettings.ts +38 -0
- package/src/components/renders/useRenderQueue.ts +11 -1
- package/src/components/sidebar/CompositionsTab.tsx +43 -1
- package/src/components/sidebar/LeftSidebar.tsx +6 -0
- package/src/contexts/FileManagerContext.tsx +6 -0
- package/src/hooks/useDomEditCommits.ts +45 -33
- package/src/hooks/useDomEditSession.ts +26 -25
- package/src/hooks/useFileManager.ts +42 -0
- package/src/hooks/useManifestPersistence.ts +40 -218
- package/src/hooks/usePreviewInteraction.ts +7 -0
- package/src/player/components/Player.tsx +12 -3
- package/src/player/components/PlayerControls.tsx +29 -2
- package/src/player/components/useTimelineRangeSelection.ts +30 -3
- package/src/utils/sourcePatcher.test.ts +285 -0
- package/src/utils/sourcePatcher.ts +26 -6
- package/dist/assets/index-Yvtxngdi.js +0 -116
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Window } from "happy-dom";
|
|
3
|
+
import {
|
|
4
|
+
readStudioMotionFromElement,
|
|
5
|
+
writeStudioMotionToElement,
|
|
6
|
+
clearStudioMotionFromElement,
|
|
7
|
+
} from "./studioMotionOps";
|
|
8
|
+
import {
|
|
9
|
+
STUDIO_MOTION_ATTR,
|
|
10
|
+
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
|
|
11
|
+
STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
|
|
12
|
+
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
|
|
13
|
+
} from "./studioMotionTypes";
|
|
14
|
+
import { buildMotionPatches, buildClearMotionPatches } from "./manualEditsDom";
|
|
15
|
+
import { applyPatchByTarget, readAttributeByTarget } from "../../utils/sourcePatcher";
|
|
16
|
+
|
|
17
|
+
function createElement(markup: string): HTMLElement {
|
|
18
|
+
const window = new Window();
|
|
19
|
+
window.document.body.innerHTML = markup;
|
|
20
|
+
return window.document.body.firstElementChild as HTMLElement;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── readStudioMotionFromElement semantics ──
|
|
24
|
+
|
|
25
|
+
describe("readStudioMotionFromElement", () => {
|
|
26
|
+
it("returns null for element with no attribute", () => {
|
|
27
|
+
const el = createElement(`<div id="test"></div>`);
|
|
28
|
+
expect(readStudioMotionFromElement(el)).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns null for legacy marker value 'true'", () => {
|
|
32
|
+
const el = createElement(`<div id="test"></div>`);
|
|
33
|
+
el.setAttribute(STUDIO_MOTION_ATTR, "true");
|
|
34
|
+
expect(readStudioMotionFromElement(el)).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns null for malformed JSON", () => {
|
|
38
|
+
const el = createElement(`<div id="test"></div>`);
|
|
39
|
+
el.setAttribute(STUDIO_MOTION_ATTR, "{not valid json");
|
|
40
|
+
expect(readStudioMotionFromElement(el)).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns null for non-object JSON", () => {
|
|
44
|
+
const el = createElement(`<div id="test"></div>`);
|
|
45
|
+
el.setAttribute(STUDIO_MOTION_ATTR, '"just a string"');
|
|
46
|
+
expect(readStudioMotionFromElement(el)).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns null when start < 0", () => {
|
|
50
|
+
const el = createElement(`<div id="test"></div>`);
|
|
51
|
+
el.setAttribute(
|
|
52
|
+
STUDIO_MOTION_ATTR,
|
|
53
|
+
JSON.stringify({
|
|
54
|
+
start: -0.5,
|
|
55
|
+
duration: 1,
|
|
56
|
+
ease: "none",
|
|
57
|
+
from: { opacity: 0 },
|
|
58
|
+
to: { opacity: 1 },
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
expect(readStudioMotionFromElement(el)).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns null when duration <= 0", () => {
|
|
65
|
+
const el = createElement(`<div id="test"></div>`);
|
|
66
|
+
el.setAttribute(
|
|
67
|
+
STUDIO_MOTION_ATTR,
|
|
68
|
+
JSON.stringify({
|
|
69
|
+
start: 0,
|
|
70
|
+
duration: 0,
|
|
71
|
+
ease: "none",
|
|
72
|
+
from: { opacity: 0 },
|
|
73
|
+
to: { opacity: 1 },
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
expect(readStudioMotionFromElement(el)).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns null when duration is negative", () => {
|
|
80
|
+
const el = createElement(`<div id="test"></div>`);
|
|
81
|
+
el.setAttribute(
|
|
82
|
+
STUDIO_MOTION_ATTR,
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
start: 0,
|
|
85
|
+
duration: -1,
|
|
86
|
+
ease: "none",
|
|
87
|
+
from: { opacity: 0 },
|
|
88
|
+
to: { opacity: 1 },
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
expect(readStudioMotionFromElement(el)).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns null when from is missing", () => {
|
|
95
|
+
const el = createElement(`<div id="test"></div>`);
|
|
96
|
+
el.setAttribute(
|
|
97
|
+
STUDIO_MOTION_ATTR,
|
|
98
|
+
JSON.stringify({
|
|
99
|
+
start: 0,
|
|
100
|
+
duration: 1,
|
|
101
|
+
ease: "none",
|
|
102
|
+
to: { opacity: 1 },
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
expect(readStudioMotionFromElement(el)).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns null when to is missing", () => {
|
|
109
|
+
const el = createElement(`<div id="test"></div>`);
|
|
110
|
+
el.setAttribute(
|
|
111
|
+
STUDIO_MOTION_ATTR,
|
|
112
|
+
JSON.stringify({
|
|
113
|
+
start: 0,
|
|
114
|
+
duration: 1,
|
|
115
|
+
ease: "none",
|
|
116
|
+
from: { opacity: 0 },
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
expect(readStudioMotionFromElement(el)).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns null when from/to have no recognized motion properties", () => {
|
|
123
|
+
const el = createElement(`<div id="test"></div>`);
|
|
124
|
+
el.setAttribute(
|
|
125
|
+
STUDIO_MOTION_ATTR,
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
start: 0,
|
|
128
|
+
duration: 1,
|
|
129
|
+
ease: "none",
|
|
130
|
+
from: { color: "red" },
|
|
131
|
+
to: { color: "blue" },
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
expect(readStudioMotionFromElement(el)).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("returns parsed motion for valid JSON", () => {
|
|
138
|
+
const el = createElement(`<div id="test"></div>`);
|
|
139
|
+
const motion = {
|
|
140
|
+
start: 0.5,
|
|
141
|
+
duration: 1,
|
|
142
|
+
ease: "power3.out",
|
|
143
|
+
from: { opacity: 0, y: 40 },
|
|
144
|
+
to: { opacity: 1, y: 0 },
|
|
145
|
+
};
|
|
146
|
+
el.setAttribute(STUDIO_MOTION_ATTR, JSON.stringify(motion));
|
|
147
|
+
|
|
148
|
+
const result = readStudioMotionFromElement(el);
|
|
149
|
+
expect(result).not.toBeNull();
|
|
150
|
+
expect(result).toEqual({
|
|
151
|
+
start: 0.5,
|
|
152
|
+
duration: 1,
|
|
153
|
+
ease: "power3.out",
|
|
154
|
+
customEase: undefined,
|
|
155
|
+
from: { opacity: 0, y: 40 },
|
|
156
|
+
to: { opacity: 1, y: 0 },
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns parsed motion with customEase", () => {
|
|
161
|
+
const el = createElement(`<div id="test"></div>`);
|
|
162
|
+
const motion = {
|
|
163
|
+
start: 0,
|
|
164
|
+
duration: 0.6,
|
|
165
|
+
ease: "studio-custom",
|
|
166
|
+
customEase: { id: "studio-custom", data: "M0,0 C0.2,0.9 0.28,1 1,1" },
|
|
167
|
+
from: { scale: 0.88, autoAlpha: 0 },
|
|
168
|
+
to: { scale: 1, autoAlpha: 1 },
|
|
169
|
+
};
|
|
170
|
+
el.setAttribute(STUDIO_MOTION_ATTR, JSON.stringify(motion));
|
|
171
|
+
|
|
172
|
+
const result = readStudioMotionFromElement(el);
|
|
173
|
+
expect(result).not.toBeNull();
|
|
174
|
+
expect(result!.customEase).toEqual({ id: "studio-custom", data: "M0,0 C0.2,0.9 0.28,1 1,1" });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("defaults ease to 'none' when ease is empty string", () => {
|
|
178
|
+
const el = createElement(`<div id="test"></div>`);
|
|
179
|
+
el.setAttribute(
|
|
180
|
+
STUDIO_MOTION_ATTR,
|
|
181
|
+
JSON.stringify({
|
|
182
|
+
start: 0,
|
|
183
|
+
duration: 1,
|
|
184
|
+
ease: "",
|
|
185
|
+
from: { y: 40 },
|
|
186
|
+
to: { y: 0 },
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const result = readStudioMotionFromElement(el);
|
|
191
|
+
expect(result).not.toBeNull();
|
|
192
|
+
expect(result!.ease).toBe("none");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("accepts start = 0 as valid", () => {
|
|
196
|
+
const el = createElement(`<div id="test"></div>`);
|
|
197
|
+
el.setAttribute(
|
|
198
|
+
STUDIO_MOTION_ATTR,
|
|
199
|
+
JSON.stringify({
|
|
200
|
+
start: 0,
|
|
201
|
+
duration: 0.5,
|
|
202
|
+
ease: "none",
|
|
203
|
+
from: { opacity: 0 },
|
|
204
|
+
to: { opacity: 1 },
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const result = readStudioMotionFromElement(el);
|
|
209
|
+
expect(result).not.toBeNull();
|
|
210
|
+
expect(result!.start).toBe(0);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── writeStudioMotionToElement / readStudioMotionFromElement round-trip ──
|
|
215
|
+
|
|
216
|
+
describe("write → read round-trip via DOM", () => {
|
|
217
|
+
it("round-trips motion through write and read", () => {
|
|
218
|
+
const el = createElement(`<div id="hero" style="transform: rotate(5deg); opacity: 0.8"></div>`);
|
|
219
|
+
const motion = {
|
|
220
|
+
start: 0.5,
|
|
221
|
+
duration: 1,
|
|
222
|
+
ease: "power3.out",
|
|
223
|
+
from: { opacity: 0, y: 40 },
|
|
224
|
+
to: { opacity: 1, y: 0 },
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
writeStudioMotionToElement(el, motion);
|
|
228
|
+
const result = readStudioMotionFromElement(el);
|
|
229
|
+
|
|
230
|
+
expect(result).not.toBeNull();
|
|
231
|
+
expect(result!.start).toBe(0.5);
|
|
232
|
+
expect(result!.duration).toBe(1);
|
|
233
|
+
expect(result!.ease).toBe("power3.out");
|
|
234
|
+
expect(result!.from).toEqual({ opacity: 0, y: 40 });
|
|
235
|
+
expect(result!.to).toEqual({ opacity: 1, y: 0 });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("captures original styles on first write", () => {
|
|
239
|
+
const el = createElement(
|
|
240
|
+
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: hidden"></div>`,
|
|
241
|
+
);
|
|
242
|
+
const motion = {
|
|
243
|
+
start: 0,
|
|
244
|
+
duration: 0.6,
|
|
245
|
+
ease: "none",
|
|
246
|
+
from: { autoAlpha: 0 },
|
|
247
|
+
to: { autoAlpha: 1 },
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
writeStudioMotionToElement(el, motion);
|
|
251
|
+
|
|
252
|
+
expect(el.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)).toBe("rotate(5deg)");
|
|
253
|
+
expect(el.getAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR)).toBe("0.8");
|
|
254
|
+
expect(el.getAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR)).toBe("hidden");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("does not overwrite original styles on subsequent writes", () => {
|
|
258
|
+
const el = createElement(
|
|
259
|
+
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible"></div>`,
|
|
260
|
+
);
|
|
261
|
+
const first = { start: 0, duration: 0.6, ease: "none", from: { y: 40 }, to: { y: 0 } };
|
|
262
|
+
const second = { start: 0.2, duration: 1, ease: "power2.out", from: { y: 60 }, to: { y: 0 } };
|
|
263
|
+
|
|
264
|
+
writeStudioMotionToElement(el, first);
|
|
265
|
+
// Simulate GSAP modifying styles
|
|
266
|
+
el.style.transform = "matrix(1, 0, 0, 1, 0, 20)";
|
|
267
|
+
el.style.opacity = "0.3";
|
|
268
|
+
|
|
269
|
+
writeStudioMotionToElement(el, second);
|
|
270
|
+
|
|
271
|
+
// Original capture should be preserved from the first write
|
|
272
|
+
expect(el.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)).toBe("rotate(5deg)");
|
|
273
|
+
expect(el.getAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR)).toBe("0.8");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ── clearStudioMotionFromElement ──
|
|
278
|
+
|
|
279
|
+
describe("clearStudioMotionFromElement", () => {
|
|
280
|
+
it("removes all four motion-related attributes", () => {
|
|
281
|
+
const el = createElement(
|
|
282
|
+
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible"></div>`,
|
|
283
|
+
);
|
|
284
|
+
const motion = {
|
|
285
|
+
start: 0,
|
|
286
|
+
duration: 0.6,
|
|
287
|
+
ease: "none",
|
|
288
|
+
from: { autoAlpha: 0 },
|
|
289
|
+
to: { autoAlpha: 1 },
|
|
290
|
+
};
|
|
291
|
+
writeStudioMotionToElement(el, motion);
|
|
292
|
+
|
|
293
|
+
expect(el.hasAttribute(STUDIO_MOTION_ATTR)).toBe(true);
|
|
294
|
+
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)).toBe(true);
|
|
295
|
+
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR)).toBe(true);
|
|
296
|
+
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR)).toBe(true);
|
|
297
|
+
|
|
298
|
+
clearStudioMotionFromElement(el);
|
|
299
|
+
|
|
300
|
+
expect(el.hasAttribute(STUDIO_MOTION_ATTR)).toBe(false);
|
|
301
|
+
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)).toBe(false);
|
|
302
|
+
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR)).toBe(false);
|
|
303
|
+
expect(el.hasAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR)).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("restores original inline styles after clearing", () => {
|
|
307
|
+
const el = createElement(
|
|
308
|
+
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: hidden"></div>`,
|
|
309
|
+
);
|
|
310
|
+
writeStudioMotionToElement(el, {
|
|
311
|
+
start: 0,
|
|
312
|
+
duration: 0.6,
|
|
313
|
+
ease: "none",
|
|
314
|
+
from: { autoAlpha: 0, y: 32 },
|
|
315
|
+
to: { autoAlpha: 1, y: 0 },
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Simulate GSAP overwriting styles
|
|
319
|
+
el.style.transform = "matrix(1, 0, 0, 1, 0, 16)";
|
|
320
|
+
el.style.opacity = "0.5";
|
|
321
|
+
el.style.visibility = "visible";
|
|
322
|
+
|
|
323
|
+
clearStudioMotionFromElement(el);
|
|
324
|
+
|
|
325
|
+
expect(el.style.transform).toBe("rotate(5deg)");
|
|
326
|
+
expect(el.style.opacity).toBe("0.8");
|
|
327
|
+
expect(el.style.visibility).toBe("hidden");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("is a no-op when element has no motion attribute", () => {
|
|
331
|
+
const el = createElement(`<div id="hero" style="opacity: 1"></div>`);
|
|
332
|
+
|
|
333
|
+
clearStudioMotionFromElement(el);
|
|
334
|
+
|
|
335
|
+
expect(el.style.opacity).toBe("1");
|
|
336
|
+
expect(el.hasAttribute(STUDIO_MOTION_ATTR)).toBe(false);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ── buildMotionPatches / buildClearMotionPatches ──
|
|
341
|
+
|
|
342
|
+
describe("buildMotionPatches", () => {
|
|
343
|
+
it("produces patches for all motion-related attributes present on the element", () => {
|
|
344
|
+
const el = createElement(
|
|
345
|
+
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible"></div>`,
|
|
346
|
+
);
|
|
347
|
+
const motion = {
|
|
348
|
+
start: 0.5,
|
|
349
|
+
duration: 1,
|
|
350
|
+
ease: "power3.out",
|
|
351
|
+
from: { opacity: 0, y: 40 },
|
|
352
|
+
to: { opacity: 1, y: 0 },
|
|
353
|
+
};
|
|
354
|
+
writeStudioMotionToElement(el, motion);
|
|
355
|
+
|
|
356
|
+
const patches = buildMotionPatches(el);
|
|
357
|
+
|
|
358
|
+
// Should have at least the motion attribute patch
|
|
359
|
+
const motionPatch = patches.find((p) => p.property === STUDIO_MOTION_ATTR);
|
|
360
|
+
expect(motionPatch).toBeDefined();
|
|
361
|
+
expect(motionPatch!.type).toBe("attribute");
|
|
362
|
+
expect(JSON.parse(motionPatch!.value!)).toMatchObject(motion);
|
|
363
|
+
|
|
364
|
+
// Should include original style capture patches
|
|
365
|
+
expect(patches.find((p) => p.property === STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)).toBeDefined();
|
|
366
|
+
expect(patches.find((p) => p.property === STUDIO_MOTION_ORIGINAL_OPACITY_ATTR)).toBeDefined();
|
|
367
|
+
expect(
|
|
368
|
+
patches.find((p) => p.property === STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR),
|
|
369
|
+
).toBeDefined();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("returns empty when element has no motion attribute", () => {
|
|
373
|
+
const el = createElement(`<div id="hero"></div>`);
|
|
374
|
+
expect(buildMotionPatches(el)).toEqual([]);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("buildClearMotionPatches round-trip", () => {
|
|
379
|
+
it("applying clear patches removes all four motion attributes from HTML", () => {
|
|
380
|
+
const el = createElement(
|
|
381
|
+
`<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible"></div>`,
|
|
382
|
+
);
|
|
383
|
+
writeStudioMotionToElement(el, {
|
|
384
|
+
start: 0,
|
|
385
|
+
duration: 0.6,
|
|
386
|
+
ease: "power2.out",
|
|
387
|
+
from: { autoAlpha: 0, y: 32 },
|
|
388
|
+
to: { autoAlpha: 1, y: 0 },
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// First, apply the motion patches to an HTML string
|
|
392
|
+
const motionPatches = buildMotionPatches(el);
|
|
393
|
+
let html = `<div id="hero" style="transform: rotate(5deg); opacity: 0.8; visibility: visible"></div>`;
|
|
394
|
+
for (const patch of motionPatches) {
|
|
395
|
+
html = applyPatchByTarget(html, { id: "hero" }, patch);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Verify all four attributes are present
|
|
399
|
+
expect(readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ATTR)).toBeDefined();
|
|
400
|
+
expect(
|
|
401
|
+
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR),
|
|
402
|
+
).toBeDefined();
|
|
403
|
+
expect(
|
|
404
|
+
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_OPACITY_ATTR),
|
|
405
|
+
).toBeDefined();
|
|
406
|
+
expect(
|
|
407
|
+
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR),
|
|
408
|
+
).toBeDefined();
|
|
409
|
+
|
|
410
|
+
// Now apply clear patches
|
|
411
|
+
const clearPatches = buildClearMotionPatches(el);
|
|
412
|
+
for (const patch of clearPatches) {
|
|
413
|
+
html = applyPatchByTarget(html, { id: "hero" }, patch);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// All four should be gone
|
|
417
|
+
expect(readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ATTR)).toBeUndefined();
|
|
418
|
+
expect(
|
|
419
|
+
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR),
|
|
420
|
+
).toBeUndefined();
|
|
421
|
+
expect(
|
|
422
|
+
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_OPACITY_ATTR),
|
|
423
|
+
).toBeUndefined();
|
|
424
|
+
expect(
|
|
425
|
+
readAttributeByTarget(html, { id: "hero" }, STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR),
|
|
426
|
+
).toBeUndefined();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("clear patches produce exactly four null-value attribute operations", () => {
|
|
430
|
+
const el = createElement(`<div id="hero"></div>`);
|
|
431
|
+
const clearPatches = buildClearMotionPatches(el);
|
|
432
|
+
|
|
433
|
+
expect(clearPatches).toHaveLength(4);
|
|
434
|
+
for (const patch of clearPatches) {
|
|
435
|
+
expect(patch.type).toBe("attribute");
|
|
436
|
+
expect(patch.value).toBeNull();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const properties = clearPatches.map((p) => p.property);
|
|
440
|
+
expect(properties).toContain(STUDIO_MOTION_ATTR);
|
|
441
|
+
expect(properties).toContain(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR);
|
|
442
|
+
expect(properties).toContain(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR);
|
|
443
|
+
expect(properties).toContain(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
@@ -5,11 +5,16 @@ import {
|
|
|
5
5
|
DEFAULT_CUSTOM_EASE_POINTS,
|
|
6
6
|
GSAP_EASE_CONTROL_POINTS,
|
|
7
7
|
CUSTOM_EASE_DATA_PATTERN,
|
|
8
|
+
STUDIO_MOTION_ATTR,
|
|
9
|
+
STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
|
|
10
|
+
STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
|
|
11
|
+
STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
|
|
8
12
|
type StudioCustomEaseControlPoints,
|
|
9
13
|
type StudioGsapCustomEase,
|
|
10
14
|
type StudioGsapMotion,
|
|
11
15
|
type StudioGsapMotionPreset,
|
|
12
16
|
type StudioGsapPresetMotionOptions,
|
|
17
|
+
type StudioGsapMotionValues,
|
|
13
18
|
type StudioMotionManifest,
|
|
14
19
|
type StudioMotionTarget,
|
|
15
20
|
} from "./studioMotionTypes";
|
|
@@ -124,12 +129,10 @@ export function buildStudioGsapPresetMotion(
|
|
|
124
129
|
|
|
125
130
|
// ── Manifest parse/serialize ──
|
|
126
131
|
|
|
127
|
-
function parseMotionValues(
|
|
128
|
-
value: unknown,
|
|
129
|
-
): import("./studioMotionTypes").StudioGsapMotionValues | null {
|
|
132
|
+
export function parseMotionValues(value: unknown): StudioGsapMotionValues | null {
|
|
130
133
|
if (!value || typeof value !== "object") return null;
|
|
131
134
|
const record = value as Record<string, unknown>;
|
|
132
|
-
const parsed:
|
|
135
|
+
const parsed: StudioGsapMotionValues = {};
|
|
133
136
|
for (const key of ["x", "y", "scale", "rotation", "opacity", "autoAlpha"] as const) {
|
|
134
137
|
const next = finiteNumber(record[key]);
|
|
135
138
|
if (next != null) parsed[key] = next;
|
|
@@ -297,3 +300,74 @@ export function getStudioMotionForSelection(
|
|
|
297
300
|
): StudioGsapMotion | null {
|
|
298
301
|
return manifest.motions.find((motion) => sameSelectionTarget(motion, selection)) ?? null;
|
|
299
302
|
}
|
|
303
|
+
|
|
304
|
+
// ── HTML-attribute–backed motion storage ──
|
|
305
|
+
|
|
306
|
+
/** The JSON stored in the attribute omits kind/target/updatedAt — those are derived from context. */
|
|
307
|
+
interface StudioMotionAttrPayload {
|
|
308
|
+
start: number;
|
|
309
|
+
duration: number;
|
|
310
|
+
ease: string;
|
|
311
|
+
customEase?: StudioGsapCustomEase;
|
|
312
|
+
from: StudioGsapMotionValues;
|
|
313
|
+
to: StudioGsapMotionValues;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function readStudioMotionFromElement(
|
|
317
|
+
element: HTMLElement,
|
|
318
|
+
): Omit<StudioGsapMotion, "kind" | "target" | "updatedAt"> | null {
|
|
319
|
+
const json = element.getAttribute(STUDIO_MOTION_ATTR);
|
|
320
|
+
if (!json || json === "true") return null;
|
|
321
|
+
try {
|
|
322
|
+
const parsed = JSON.parse(json) as unknown;
|
|
323
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
324
|
+
const record = parsed as Record<string, unknown>;
|
|
325
|
+
const start = finiteNumber(record.start);
|
|
326
|
+
const duration = finiteNumber(record.duration);
|
|
327
|
+
if (start == null || duration == null || start < 0 || duration <= 0) return null;
|
|
328
|
+
const ease =
|
|
329
|
+
typeof record.ease === "string" && record.ease.trim() ? record.ease.trim() : "none";
|
|
330
|
+
const from = parseMotionValues(record.from);
|
|
331
|
+
const to = parseMotionValues(record.to);
|
|
332
|
+
if (!from || !to) return null;
|
|
333
|
+
return { start, duration, ease, customEase: parseCustomEase(record.customEase), from, to };
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function writeStudioMotionToElement(
|
|
340
|
+
element: HTMLElement,
|
|
341
|
+
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
|
|
342
|
+
): void {
|
|
343
|
+
// Capture original styles before first write (only if not already captured)
|
|
344
|
+
if (!element.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR)) {
|
|
345
|
+
element.setAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, element.style.transform);
|
|
346
|
+
element.setAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, element.style.opacity);
|
|
347
|
+
element.setAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, element.style.visibility);
|
|
348
|
+
}
|
|
349
|
+
const payload: StudioMotionAttrPayload = {
|
|
350
|
+
start: motion.start,
|
|
351
|
+
duration: motion.duration,
|
|
352
|
+
ease: motion.ease,
|
|
353
|
+
from: motion.from,
|
|
354
|
+
to: motion.to,
|
|
355
|
+
};
|
|
356
|
+
if (motion.customEase) payload.customEase = motion.customEase;
|
|
357
|
+
element.setAttribute(STUDIO_MOTION_ATTR, JSON.stringify(payload));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function clearStudioMotionFromElement(
|
|
361
|
+
element: HTMLElement,
|
|
362
|
+
gsap?: { set?: (target: HTMLElement, vars: Record<string, unknown>) => void },
|
|
363
|
+
): void {
|
|
364
|
+
if (!element.hasAttribute(STUDIO_MOTION_ATTR)) return;
|
|
365
|
+
gsap?.set?.(element, { clearProps: "transform,opacity,visibility" });
|
|
366
|
+
element.style.transform = element.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR) ?? "";
|
|
367
|
+
element.style.opacity = element.getAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR) ?? "";
|
|
368
|
+
element.style.visibility = element.getAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR) ?? "";
|
|
369
|
+
element.removeAttribute(STUDIO_MOTION_ATTR);
|
|
370
|
+
element.removeAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR);
|
|
371
|
+
element.removeAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR);
|
|
372
|
+
element.removeAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR);
|
|
373
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo, useState, useRef, useEffect } from "react";
|
|
2
2
|
import { RenderQueueItem } from "./RenderQueueItem";
|
|
3
3
|
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
|
|
4
|
+
import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
|
|
4
5
|
|
|
5
6
|
export interface CompositionDimensions {
|
|
6
7
|
width: number;
|
|
@@ -198,10 +199,11 @@ function FormatExportButton({
|
|
|
198
199
|
isRendering: boolean;
|
|
199
200
|
compositionDimensions?: CompositionDimensions | null;
|
|
200
201
|
}) {
|
|
201
|
-
const
|
|
202
|
-
const [
|
|
202
|
+
const persisted = getPersistedRenderSettings();
|
|
203
|
+
const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format);
|
|
204
|
+
const [quality, setQuality] = useState<"draft" | "standard" | "high">(persisted.quality);
|
|
203
205
|
const [resolution, setResolution] = useState<ResolutionPreset | "auto">("auto");
|
|
204
|
-
const [fps, setFps] = useState<24 | 30 | 60>(
|
|
206
|
+
const [fps, setFps] = useState<24 | 30 | 60>(persisted.fps);
|
|
205
207
|
|
|
206
208
|
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
|
|
207
209
|
const showQuality = format !== "mov";
|
|
@@ -228,7 +230,11 @@ function FormatExportButton({
|
|
|
228
230
|
{showQuality && (
|
|
229
231
|
<select
|
|
230
232
|
value={quality}
|
|
231
|
-
onChange={(e) =>
|
|
233
|
+
onChange={(e) => {
|
|
234
|
+
const v = e.target.value as "draft" | "standard" | "high";
|
|
235
|
+
setQuality(v);
|
|
236
|
+
persistRenderSettings(format, v, fps);
|
|
237
|
+
}}
|
|
232
238
|
disabled={isRendering}
|
|
233
239
|
title={QUALITY_OPTIONS.find((q) => q.value === quality)?.title}
|
|
234
240
|
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
|
|
@@ -242,7 +248,11 @@ function FormatExportButton({
|
|
|
242
248
|
)}
|
|
243
249
|
<select
|
|
244
250
|
value={fps}
|
|
245
|
-
onChange={(e) =>
|
|
251
|
+
onChange={(e) => {
|
|
252
|
+
const v = Number(e.target.value) as 24 | 30 | 60;
|
|
253
|
+
setFps(v);
|
|
254
|
+
persistRenderSettings(format, quality, v);
|
|
255
|
+
}}
|
|
246
256
|
disabled={isRendering}
|
|
247
257
|
title="Frames per second"
|
|
248
258
|
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
|
|
@@ -253,7 +263,11 @@ function FormatExportButton({
|
|
|
253
263
|
</select>
|
|
254
264
|
<select
|
|
255
265
|
value={format}
|
|
256
|
-
onChange={(e) =>
|
|
266
|
+
onChange={(e) => {
|
|
267
|
+
const v = e.target.value as "mp4" | "webm" | "mov";
|
|
268
|
+
setFormat(v);
|
|
269
|
+
persistRenderSettings(v, quality, fps);
|
|
270
|
+
}}
|
|
257
271
|
disabled={isRendering}
|
|
258
272
|
className="h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
|
|
259
273
|
>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const RENDER_SETTINGS_KEY = "hf-studio-render-settings";
|
|
2
|
+
|
|
3
|
+
export interface PersistedRenderSettings {
|
|
4
|
+
format: "mp4" | "webm" | "mov";
|
|
5
|
+
quality: "draft" | "standard" | "high";
|
|
6
|
+
fps: 24 | 30 | 60;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getPersistedRenderSettings(): PersistedRenderSettings {
|
|
10
|
+
try {
|
|
11
|
+
const raw = localStorage.getItem(RENDER_SETTINGS_KEY);
|
|
12
|
+
if (raw) {
|
|
13
|
+
const parsed = JSON.parse(raw);
|
|
14
|
+
return {
|
|
15
|
+
format: ["mp4", "webm", "mov"].includes(parsed.format) ? parsed.format : "mp4",
|
|
16
|
+
quality: ["draft", "standard", "high"].includes(parsed.quality)
|
|
17
|
+
? parsed.quality
|
|
18
|
+
: "standard",
|
|
19
|
+
fps: [24, 30, 60].includes(parsed.fps) ? parsed.fps : 30,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
/* ignore */
|
|
24
|
+
}
|
|
25
|
+
return { format: "mp4", quality: "standard", fps: 30 };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function persistRenderSettings(
|
|
29
|
+
format: PersistedRenderSettings["format"],
|
|
30
|
+
quality: PersistedRenderSettings["quality"],
|
|
31
|
+
fps: PersistedRenderSettings["fps"],
|
|
32
|
+
): void {
|
|
33
|
+
try {
|
|
34
|
+
localStorage.setItem(RENDER_SETTINGS_KEY, JSON.stringify({ format, quality, fps }));
|
|
35
|
+
} catch {
|
|
36
|
+
/* ignore */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -29,6 +29,8 @@ export interface StartRenderOptions {
|
|
|
29
29
|
format?: "mp4" | "webm" | "mov";
|
|
30
30
|
/** `"auto"` (default) renders at the composition's authored dimensions. */
|
|
31
31
|
resolution?: ResolutionPreset | "auto";
|
|
32
|
+
/** Render a specific composition file instead of index.html. */
|
|
33
|
+
composition?: string;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export function useRenderQueue(projectId: string | null) {
|
|
@@ -86,17 +88,25 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
86
88
|
const quality = opts.quality ?? "standard";
|
|
87
89
|
const format = opts.format ?? "mp4";
|
|
88
90
|
const resolution = opts.resolution;
|
|
91
|
+
const composition = opts.composition;
|
|
89
92
|
|
|
90
93
|
const startTime = Date.now();
|
|
91
94
|
// "auto" / undefined means "render at the composition's authored size".
|
|
92
95
|
// Omit the field entirely — sending "auto" would trip the route's
|
|
93
96
|
// enum validation set.
|
|
94
|
-
const body: {
|
|
97
|
+
const body: {
|
|
98
|
+
fps: number;
|
|
99
|
+
quality: string;
|
|
100
|
+
format: string;
|
|
101
|
+
resolution?: string;
|
|
102
|
+
composition?: string;
|
|
103
|
+
} = {
|
|
95
104
|
fps,
|
|
96
105
|
quality,
|
|
97
106
|
format,
|
|
98
107
|
};
|
|
99
108
|
if (resolution && resolution !== "auto") body.resolution = resolution;
|
|
109
|
+
if (composition) body.composition = composition;
|
|
100
110
|
let res: Response;
|
|
101
111
|
try {
|
|
102
112
|
res = await fetch(`/api/projects/${projectId}/render`, {
|