@hyperframes/studio 0.8.28 → 0.8.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.28",
3
+ "version": "0.8.30",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,11 +48,11 @@
48
48
  "gsap": "^3.13.0",
49
49
  "marked": "^14.1.4",
50
50
  "mediabunny": "^1.45.3",
51
- "@hyperframes/core": "0.8.28",
52
- "@hyperframes/player": "0.8.28",
53
- "@hyperframes/sdk": "0.8.28",
54
- "@hyperframes/studio-server": "0.8.28",
55
- "@hyperframes/parsers": "0.8.28"
51
+ "@hyperframes/player": "0.8.30",
52
+ "@hyperframes/core": "0.8.30",
53
+ "@hyperframes/sdk": "0.8.30",
54
+ "@hyperframes/studio-server": "0.8.30",
55
+ "@hyperframes/parsers": "0.8.30"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/react": "19",
@@ -69,7 +69,7 @@
69
69
  "vite": "^6.4.2",
70
70
  "vitest": "^3.2.4",
71
71
  "zustand": "^5.0.0",
72
- "@hyperframes/producer": "0.8.28"
72
+ "@hyperframes/producer": "0.8.30"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "react": "19",
@@ -2,6 +2,26 @@ import { describe, expect, it } from "vitest";
2
2
  import { patchMediaColorGradingInHtml } from "./colorGradingScopePatch";
3
3
 
4
4
  describe("patchMediaColorGradingInHtml", () => {
5
+ it.each(['"', "'"])("replaces and removes long grading values quoted with %s", (quote) => {
6
+ const otherQuote = quote === '"' ? "'" : '"';
7
+ for (const value of [
8
+ "",
9
+ "line1\nline2",
10
+ "a".repeat(100_000),
11
+ `data-color-grading=${otherQuote}a`.repeat(10_000),
12
+ ]) {
13
+ const html = `<img alt="kept"\tDATA-COLOR-GRADING=${quote}${value}${quote} />`;
14
+ expect(patchMediaColorGradingInHtml(html, "new")).toEqual({
15
+ html: '<img alt="kept" data-color-grading="new" />',
16
+ count: 1,
17
+ });
18
+ expect(patchMediaColorGradingInHtml(html, null)).toEqual({
19
+ html: '<img alt="kept" />',
20
+ count: 1,
21
+ });
22
+ }
23
+ });
24
+
5
25
  it("adds color grading to video and image tags only", () => {
6
26
  const { html, count } = patchMediaColorGradingInHtml(
7
27
  `<div><video id="v"></video><img id="i" /><audio id="a"></audio></div>`,
@@ -1,5 +1,5 @@
1
1
  const MEDIA_TAG_RE = /<\s*(video|img)\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
2
- const COLOR_GRADING_ATTR_RE = /\sdata-color-grading=(["'])([\s\S]*?)\1/i;
2
+ const COLOR_GRADING_ATTR_RE = /\sdata-color-grading=(?:"[^"]*"|'[^']*')/i;
3
3
  const IGNORED_HTML_RANGE_RE = /<!--[\s\S]*?-->|<(script|style)\b[\s\S]*?<\/\1\s*>/gi;
4
4
 
5
5
  interface TextRange {
@@ -175,7 +175,7 @@ describe("external file change coordinator", () => {
175
175
  expect(options.reloadSdkSession).toHaveBeenCalledOnce();
176
176
  });
177
177
 
178
- it("ignores stale drain completion after a newer generation", async () => {
178
+ it("serializes drains and processes stashed events", async () => {
179
179
  const drains: Array<(result: { status: "clean" }) => void> = [];
180
180
  const { options } = await mountCoordinator({
181
181
  drainPendingChanges: () => new Promise((resolve) => drains.push(resolve)),
@@ -184,11 +184,14 @@ describe("external file change coordinator", () => {
184
184
  handler?.({ path: "index.html", content: "first", version: "v2" });
185
185
  handler?.({ path: "index.html", content: "second", version: "v3" });
186
186
  });
187
+ expect(drains).toHaveLength(1);
187
188
  await act(async () => drains[0]?.({ status: "clean" }));
188
- expect(options.reloadPreview).not.toHaveBeenCalled();
189
- await act(async () => drains[1]?.({ status: "clean" }));
190
189
  expect(options.reloadPreview).toHaveBeenCalledOnce();
191
- expect(options.reloadSdkSession).toHaveBeenCalledOnce();
190
+ await act(async () => {});
191
+ expect(drains).toHaveLength(2);
192
+ await act(async () => drains[1]?.({ status: "clean" }));
193
+ expect(options.reloadPreview).toHaveBeenCalledTimes(2);
194
+ expect(options.reloadSdkSession).toHaveBeenCalledTimes(2);
192
195
  });
193
196
 
194
197
  it("restores a durable unresolved conflict after remount", async () => {
@@ -285,4 +288,38 @@ describe("external file change coordinator", () => {
285
288
  );
286
289
  expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
287
290
  });
291
+
292
+ it("completes a reload after a burst of rapid external writes", async () => {
293
+ const drains: Array<(result: { status: "clean" }) => void> = [];
294
+ const reloadPreview = vi.fn();
295
+ const onAcceptedPersistedFileChange = vi.fn();
296
+ await mountCoordinator({
297
+ drainPendingChanges: vi.fn(
298
+ () => new Promise<{ status: "clean" }>((resolve) => drains.push(resolve)),
299
+ ),
300
+ reloadPreview,
301
+ onAcceptedPersistedFileChange,
302
+ });
303
+
304
+ // Fire three events in rapid succession (simulates generator + check + snapshot)
305
+ act(() => {
306
+ handler?.({ path: "index.html", content: "write-1", version: "v1" });
307
+ handler?.({ path: "index.html", content: "write-2", version: "v2" });
308
+ handler?.({ path: "index.html", content: "write-3", version: "v3" });
309
+ });
310
+
311
+ // Only one drain runs — events 2 and 3 are stashed (last one wins)
312
+ expect(drains).toHaveLength(1);
313
+
314
+ // Complete the first drain — triggers reload, then stashed event starts a second drain
315
+ await act(async () => drains[0]?.({ status: "clean" }));
316
+ expect(reloadPreview).toHaveBeenCalledOnce();
317
+ await act(async () => {});
318
+ expect(drains).toHaveLength(2);
319
+
320
+ // Complete the second drain — processes the final write
321
+ await act(async () => drains[1]?.({ status: "clean" }));
322
+ expect(reloadPreview).toHaveBeenCalledTimes(2);
323
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledTimes(2);
324
+ });
288
325
  });
@@ -135,6 +135,8 @@ export function useExternalFileChangeCoordinator({
135
135
  const lastEventIdentityRef = useRef<string | null>(null);
136
136
  const blockedRef = useRef(blocked);
137
137
  const snapshotWriteTailRef = useRef<Promise<void>>(Promise.resolve());
138
+ const drainingRef = useRef(false);
139
+ const pendingPayloadRef = useRef<{ payload: unknown } | null>(null);
138
140
  blockedRef.current = blocked;
139
141
 
140
142
  useEffect(() => {
@@ -213,37 +215,11 @@ export function useExternalFileChangeCoordinator({
213
215
  await next;
214
216
  }, []);
215
217
 
216
- const processChange = useCallback(
218
+ const drainOnePending = useCallback(
217
219
  // fallow-ignore-next-line complexity
218
- async (payload: unknown, allowDuplicate = false) => {
220
+ async (payload: unknown) => {
219
221
  const path = readStudioFileChangePath(payload);
220
- if (!path || !projectId) return;
221
- const pendingTimelinePaths = pendingTimelineEditPathRef.current;
222
- // The old path-only suppression could drop a real agent/user write that
223
- // raced ahead of the timeline write receipt. Clear the legacy marker but
224
- // decide ownership only from the exact write token/content below.
225
- pendingTimelinePaths.delete(path);
226
-
227
- const content = readFileChangeContent(payload);
228
- const token = readFileChangeWriteToken(payload);
229
- logReload("file-change", { path, token: token ?? null, hasContent: content != null });
230
- const identity = eventIdentity(path, payload);
231
- if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) {
232
- logReload("suppressed", { path, why: "duplicate event" });
233
- return;
234
- }
235
- lastEventIdentityRef.current = identity;
236
-
237
- const ownWriteToken = consumeStudioWriteToken(token);
238
- const ownContentEcho = content != null && isSelfWriteEcho(path, content);
239
- if (ownWriteToken || ownContentEcho) {
240
- onAcceptedPersistedFileChange(path);
241
- logReload("suppressed", {
242
- path,
243
- why: ownWriteToken ? "own write token" : "own content echo",
244
- });
245
- return;
246
- }
222
+ if (!path) return;
247
223
 
248
224
  const generation = ++generationRef.current;
249
225
  const result = await drainPendingChanges();
@@ -253,7 +229,7 @@ export function useExternalFileChangeCoordinator({
253
229
  const previousBlocked = blockedRef.current;
254
230
  if (previousBlocked?.status === "failed" && deleteConflictSnapshot) {
255
231
  try {
256
- await deleteConflictSnapshot(projectId, path);
232
+ await deleteConflictSnapshot(projectId!, path);
257
233
  } catch (error) {
258
234
  if (mountedRef.current && generation === generationRef.current) {
259
235
  setBlocked({ ...previousBlocked, generation, error });
@@ -267,6 +243,7 @@ export function useExternalFileChangeCoordinator({
267
243
  reloadAcceptedGeneration(path);
268
244
  return;
269
245
  }
246
+ const content = readFileChangeContent(payload);
270
247
  if (result.status === "failed") {
271
248
  const candidate = getPendingCandidate?.();
272
249
  const studioContent = candidate?.path === path ? candidate.content : null;
@@ -275,7 +252,7 @@ export function useExternalFileChangeCoordinator({
275
252
  try {
276
253
  await persistSnapshotInOrder(() =>
277
254
  persistFailureSnapshot(
278
- projectId,
255
+ projectId!,
279
256
  path,
280
257
  studioContent,
281
258
  readFileChangeVersion(payload),
@@ -305,7 +282,7 @@ export function useExternalFileChangeCoordinator({
305
282
  return;
306
283
  }
307
284
  try {
308
- await persistSnapshotInOrder(() => persistConflictSnapshot(projectId, result.error));
285
+ await persistSnapshotInOrder(() => persistConflictSnapshot(projectId!, result.error));
309
286
  } catch (error) {
310
287
  if (!mountedRef.current || generation !== generationRef.current) return;
311
288
  setBlocked({
@@ -323,9 +300,8 @@ export function useExternalFileChangeCoordinator({
323
300
  setBlocked({ status: "conflict", generation, error: result.error, payload });
324
301
  },
325
302
  [
326
- projectId,
327
- pendingTimelineEditPathRef,
328
303
  drainPendingChanges,
304
+ projectId,
329
305
  deleteConflictSnapshot,
330
306
  getPendingCandidate,
331
307
  persistConflictSnapshot,
@@ -336,6 +312,55 @@ export function useExternalFileChangeCoordinator({
336
312
  ],
337
313
  );
338
314
 
315
+ const startDrainLoop = useCallback(async () => {
316
+ if (drainingRef.current) return;
317
+ drainingRef.current = true;
318
+ try {
319
+ while (mountedRef.current) {
320
+ const pending = pendingPayloadRef.current;
321
+ if (!pending) break;
322
+ pendingPayloadRef.current = null;
323
+ await drainOnePending(pending.payload);
324
+ }
325
+ } finally {
326
+ drainingRef.current = false;
327
+ }
328
+ }, [drainOnePending]);
329
+
330
+ const processChange = useCallback(
331
+ // fallow-ignore-next-line complexity
332
+ (payload: unknown) => {
333
+ const path = readStudioFileChangePath(payload);
334
+ if (!path || !projectId) return;
335
+ pendingTimelineEditPathRef.current.delete(path);
336
+
337
+ const content = readFileChangeContent(payload);
338
+ const token = readFileChangeWriteToken(payload);
339
+ logReload("file-change", { path, token: token ?? null, hasContent: content != null });
340
+ const identity = eventIdentity(path, payload);
341
+ if (identity != null && identity === lastEventIdentityRef.current) {
342
+ logReload("suppressed", { path, why: "duplicate event" });
343
+ return;
344
+ }
345
+ lastEventIdentityRef.current = identity;
346
+
347
+ const ownWriteToken = consumeStudioWriteToken(token);
348
+ const ownContentEcho = content != null && isSelfWriteEcho(path, content);
349
+ if (ownWriteToken || ownContentEcho) {
350
+ onAcceptedPersistedFileChange(path);
351
+ logReload("suppressed", {
352
+ path,
353
+ why: ownWriteToken ? "own write token" : "own content echo",
354
+ });
355
+ return;
356
+ }
357
+
358
+ pendingPayloadRef.current = { payload };
359
+ void startDrainLoop();
360
+ },
361
+ [projectId, pendingTimelineEditPathRef, startDrainLoop, onAcceptedPersistedFileChange],
362
+ );
363
+
339
364
  useEffect(() => {
340
365
  const handler = (payload?: unknown) => processChange(payload);
341
366
  const adapter = testHotAdapter();
@@ -357,7 +382,7 @@ export function useExternalFileChangeCoordinator({
357
382
  if (!current || current.status === "conflict" || current.recovered) return;
358
383
  resetSaveQueues?.();
359
384
  lastEventIdentityRef.current = null;
360
- await processChange(current.payload, true);
385
+ processChange(current.payload);
361
386
  }, [processChange, resetSaveQueues]);
362
387
 
363
388
  const useExternalFile = useCallback(
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mergeStyleIntoTag } from "./htmlEditor";
3
+
4
+ describe("mergeStyleIntoTag", () => {
5
+ it.each([
6
+ ['<div style="color: red; opacity: 1">', '<div style="color: red; opacity: 0.5">'],
7
+ ["<div style='color: red; opacity: 1'>", "<div style='color: red; opacity: 0.5'>"],
8
+ ['<div style="">', '<div style="opacity: 0.5">'],
9
+ ["<div style=''>", "<div style='opacity: 0.5'>"],
10
+ ['<div style="color:\nred">', '<div style="color: red; opacity: 0.5">'],
11
+ ["<div style='font-family: \"a\"'>", "<div style='font-family: \"a\"; opacity: 0.5'>"],
12
+ ["<div style=\"font-family: 'a'\">", "<div style=\"font-family: 'a'; opacity: 0.5\">"],
13
+ [
14
+ "<div style=\"color:red\" style='color:blue'>",
15
+ "<div style=\"color: red; opacity: 0.5\" style='color:blue'>",
16
+ ],
17
+ ['<div STYLE="color:red">', '<div STYLE="color:red" style="opacity: 0.5">'],
18
+ ['<div style = "color:red">', '<div style = "color:red" style="opacity: 0.5">'],
19
+ ['<div data-style="color:red">', '<div data-style="color: red; opacity: 0.5">'],
20
+ ["<img/>", '<img style="opacity: 0.5"/>'],
21
+ ['<div style="color:red>', '<div style="color:red style="opacity: 0.5">'],
22
+ ])("preserves quote and source behavior (case %#)", (tag, expected) => {
23
+ expect(mergeStyleIntoTag(tag, "opacity: 0.5")).toBe(expected);
24
+ expect(mergeStyleIntoTag(tag, " \n")).toBe(tag);
25
+ });
26
+
27
+ it.each(['"', "'"])("handles long values delimited by %s", (quote) => {
28
+ const value = "a".repeat(100_000);
29
+ const tag = `<div style=${quote}--label:${value}${quote}>`;
30
+ expect(mergeStyleIntoTag(tag, "opacity: 0.5")).toBe(
31
+ `<div style=${quote}--label: ${value}; opacity: 0.5${quote}>`,
32
+ );
33
+ const opener = `style=${quote}a`;
34
+ expect(mergeStyleIntoTag(`<div style=${quote}${opener.repeat(10_000)}>`, "opacity: 0.5")).toBe(
35
+ `<div style=${quote}opacity: 0.5${quote}a${opener.repeat(9_999)}>`,
36
+ );
37
+ const unterminated = `<div style=${quote}${value}>`;
38
+ expect(mergeStyleIntoTag(unterminated, "opacity: 0.5")).toBe(
39
+ `<div style=${quote}${value} style="opacity: 0.5">`,
40
+ );
41
+ });
42
+ });
@@ -29,13 +29,13 @@ export function mergeStyleIntoTag(tag: string, newStyles: string): string {
29
29
 
30
30
  const incoming = parseStyleString(newStyles);
31
31
 
32
- // Match style="..." or style='...' handle multi-line attrs via dotall-like trick
33
- const styleAttrRe = /style=(["'])([\s\S]*?)\1/;
32
+ // Match each quote-delimited form explicitly, including multi-line values.
33
+ const styleAttrRe = /style=(")([^"]*)"|style=(')([^']*)'/;
34
34
  const match = tag.match(styleAttrRe);
35
35
 
36
36
  if (match) {
37
- const quote = match[1];
38
- const existing = parseStyleString(match[2]);
37
+ const quote = match[1] ?? match[3];
38
+ const existing = parseStyleString(match[2] ?? match[4]);
39
39
  const merged = { ...existing, ...incoming };
40
40
  const serialized = Object.entries(merged)
41
41
  .map(([k, v]) => `${k}: ${v}`)
@@ -8,6 +8,22 @@ import {
8
8
  } from "./sourcePatcher";
9
9
 
10
10
  describe("applyPatchByTarget", () => {
11
+ it.each(['"', "'"])("patches long and multiline styles quoted with %s", (quote) => {
12
+ const otherQuote = quote === '"' ? "'" : '"';
13
+ const op: PatchOperation = { type: "inline-style", property: "opacity", value: "0.5" };
14
+ for (const value of [
15
+ "",
16
+ "line1\nline2",
17
+ "a".repeat(100_000),
18
+ `style=${otherQuote}a`.repeat(10_000),
19
+ ]) {
20
+ const html = `<img id="hero" class="hero" style=${quote}--label:${value}${quote} />`;
21
+ const expected = `<img id="hero" class="hero" style=${quote}--label: ${value}; opacity: 0.5${quote} />`;
22
+ expect(applyPatch(html, "hero", op)).toBe(expected);
23
+ expect(applyPatchByTarget(html, { selector: ".hero" }, op)).toBe(expected);
24
+ }
25
+ });
26
+
11
27
  it("updates a composition host by data-composition-id selector", () => {
12
28
  const html = `<div data-composition-id="intro" data-start="0" data-track-index="1"></div>`;
13
29
  const op: PatchOperation = { type: "attribute", property: "start", value: "2.5" };
@@ -66,6 +82,23 @@ describe("applyPatchByTarget", () => {
66
82
  expect(result).not.toContain("/ style");
67
83
  });
68
84
 
85
+ it.each(["", " ", "\t\r\n", "\u00a0\u2028", " ".repeat(100_000)])(
86
+ "preserves self-closing whitespace handling (case %#)",
87
+ (whitespace) => {
88
+ const prefix = '<img id="hero" class="hero"';
89
+ const op: PatchOperation = { type: "inline-style", property: "opacity", value: "0.5" };
90
+ for (const suffix of ["/", "/\n", "/\r", "/\r\n", "/\u2028", "", "x", "/ "]) {
91
+ const tag = prefix + whitespace + suffix;
92
+ const selfClosing = suffix === "/";
93
+ // The original operation removes whitespace before the slash only.
94
+ const expectedBase = selfClosing ? prefix + suffix.slice(1) : tag;
95
+ const expected = `${expectedBase} style="opacity: 0.5"${selfClosing ? " /" : ""}>`;
96
+ expect(applyPatch(tag + ">", "hero", op)).toBe(expected);
97
+ expect(applyPatchByTarget(tag + ">", { selector: ".hero" }, op)).toBe(expected);
98
+ }
99
+ },
100
+ );
101
+
69
102
  it("patches inline move styles by target", () => {
70
103
  const html = `<div id="card" style="position: absolute; left: 108px; top: 112px"></div>`;
71
104
 
@@ -184,10 +184,10 @@ function patchInlineStyleInTag(
184
184
  if (!tag) return html;
185
185
 
186
186
  // Check if there's an existing style attribute
187
- const styleMatch = /\bstyle=(["'])([\s\S]*?)\1/.exec(tag);
187
+ const styleMatch = /\bstyle=(")([^"]*)"|\bstyle=(')([^']*)'/.exec(tag);
188
188
  if (styleMatch) {
189
- const existingStyle = styleMatch[2];
190
- const quote = styleMatch[1];
189
+ const existingStyle = styleMatch[2] ?? styleMatch[4];
190
+ const quote = styleMatch[1] ?? styleMatch[3];
191
191
  // Parse existing properties
192
192
  const props = new Map<string, string>();
193
193
  for (const part of splitInlineStyleDeclarations(existingStyle)) {
@@ -212,8 +212,8 @@ function patchInlineStyleInTag(
212
212
  } else {
213
213
  // No existing style attribute
214
214
  if (value === null) return html; // nothing to remove
215
- const selfClosing = /\s*\/$/.test(tag);
216
- const base = selfClosing ? tag.replace(/\s*\/$/, "") : tag;
215
+ const selfClosing = tag.endsWith("/");
216
+ const base = selfClosing ? tag.slice(0, -1).trimEnd() : tag;
217
217
  const newTag = `${base} style="${prop}: ${escapeStyleAttributeValue(value, '"')}"${selfClosing ? " /" : ""}`;
218
218
  return html.replace(tag, newTag);
219
219
  }