@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.
Files changed (30) hide show
  1. package/dist/assets/index-BSe0Kibk.js +115 -0
  2. package/dist/index.html +1 -1
  3. package/package.json +4 -4
  4. package/src/App.tsx +5 -10
  5. package/src/components/StudioLeftSidebar.tsx +16 -2
  6. package/src/components/StudioRightPanel.tsx +15 -2
  7. package/src/components/editor/MotionPanel.tsx +8 -8
  8. package/src/components/editor/SourceEditor.tsx +14 -0
  9. package/src/components/editor/manualEdits.ts +2 -0
  10. package/src/components/editor/manualEditsDom.ts +56 -0
  11. package/src/components/editor/studioMotion.ts +96 -0
  12. package/src/components/editor/studioMotionOps.test.ts +445 -0
  13. package/src/components/editor/studioMotionOps.ts +78 -4
  14. package/src/components/renders/RenderQueue.tsx +20 -6
  15. package/src/components/renders/renderSettings.ts +38 -0
  16. package/src/components/renders/useRenderQueue.ts +11 -1
  17. package/src/components/sidebar/CompositionsTab.tsx +43 -1
  18. package/src/components/sidebar/LeftSidebar.tsx +6 -0
  19. package/src/contexts/FileManagerContext.tsx +6 -0
  20. package/src/hooks/useDomEditCommits.ts +45 -33
  21. package/src/hooks/useDomEditSession.ts +26 -25
  22. package/src/hooks/useFileManager.ts +42 -0
  23. package/src/hooks/useManifestPersistence.ts +40 -218
  24. package/src/hooks/usePreviewInteraction.ts +7 -0
  25. package/src/player/components/Player.tsx +12 -3
  26. package/src/player/components/PlayerControls.tsx +29 -2
  27. package/src/player/components/useTimelineRangeSelection.ts +30 -3
  28. package/src/utils/sourcePatcher.test.ts +285 -0
  29. package/src/utils/sourcePatcher.ts +26 -6
  30. 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: import("./studioMotionTypes").StudioGsapMotionValues = {};
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 [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
202
- const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
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>(30);
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) => setQuality(e.target.value as "draft" | "standard" | "high")}
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) => setFps(Number(e.target.value) as 24 | 30 | 60)}
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) => setFormat(e.target.value as "mp4" | "webm" | "mov")}
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: { fps: number; quality: string; format: string; resolution?: string } = {
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`, {