@hyperframes/studio 0.8.141 → 0.8.143

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 (41) hide show
  1. package/dist/assets/{hyperframes-player-5Vx2AiUz.js → hyperframes-player-BXSuksFh.js} +3 -3
  2. package/dist/assets/{index-BAnh7cSQ.js → index-BgOVCqjo.js} +1 -1
  3. package/dist/assets/{index-P8fgj_Xa.js → index-FgyO_5Kv.js} +1 -1
  4. package/dist/assets/{index-C0thd4eM.js → index-J7jpL4pj.js} +157 -157
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +148 -175
  7. package/dist/index.js.map +1 -1
  8. package/package.json +8 -8
  9. package/src/components/MediaPreview.tsx +2 -1
  10. package/src/components/TimelineToolbar.test.tsx +1 -1
  11. package/src/components/editor/mediaAudioEdits.test.ts +12 -0
  12. package/src/components/editor/mediaAudioEdits.ts +5 -5
  13. package/src/components/editor/propertyPanelFill.tsx +2 -1
  14. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -4
  15. package/src/components/editor/propertyPanelMediaSection.tsx +3 -4
  16. package/src/components/editor/useColorGradingController.ts +2 -1
  17. package/src/hooks/useDomEditCommits.ts +2 -1
  18. package/src/hooks/useFileManager.ts +2 -1
  19. package/src/hooks/useFileTree.ts +2 -1
  20. package/src/hooks/useRenderClipContent.ts +15 -3
  21. package/src/player/components/clipMenuNormalize.ts +2 -2
  22. package/src/player/components/clipPeakMap.ts +2 -2
  23. package/src/player/components/thumbnailUtils.test.ts +33 -0
  24. package/src/player/components/thumbnailUtils.ts +5 -0
  25. package/src/player/components/timelineLayout.test.ts +3 -3
  26. package/src/player/components/timelineZoomInput.test.ts +27 -6
  27. package/src/player/components/timelineZoomInput.ts +5 -1
  28. package/src/player/components/useTimelinePlayhead.test.tsx +90 -4
  29. package/src/player/components/useTimelinePlayhead.ts +18 -3
  30. package/src/player/components/useTimelineProviderState.tsx +1 -0
  31. package/src/player/lib/mediaProbe.test.ts +12 -0
  32. package/src/player/lib/mediaProbe.ts +4 -2
  33. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  34. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  35. package/src/utils/sourcePatcher.test.ts +135 -0
  36. package/src/utils/sourcePatcher.ts +102 -186
  37. package/src/utils/studioHelpers.test.ts +23 -0
  38. package/src/utils/studioHelpers.ts +3 -2
  39. package/src/utils/studioSelectionSnapshot.test.ts +31 -0
  40. package/src/utils/timelineAssetDrop.test.ts +14 -0
  41. package/src/utils/timelineAssetDrop.ts +3 -16
@@ -1,3 +1,9 @@
1
+ import {
2
+ scanHtmlOpeningTags,
3
+ decodeAuthoredAttribute,
4
+ HTML_ATTRIBUTE_ENTITIES,
5
+ } from "@hyperframes/parsers";
6
+
1
7
  /**
2
8
  * Source Patcher — Maps visual property edits back to source HTML files.
3
9
  * Handles inline style updates, attribute changes, and text content.
@@ -7,26 +13,44 @@ export function escapeRegex(s: string): string {
7
13
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8
14
  }
9
15
 
16
+ function tagAttribute(tag: string, name: string) {
17
+ return scanHtmlOpeningTags(tag)[0]?.attributes.find((attr) => attr.name === name.toLowerCase());
18
+ }
19
+
20
+ type AttributeEdit = { kind: "remove" } | { kind: "boolean" } | { kind: "value"; value: string };
21
+
22
+ function patchTagAttribute(tag: string, name: string, edit: AttributeEdit): string {
23
+ if (edit.kind === "remove") {
24
+ const attributes =
25
+ scanHtmlOpeningTags(tag)[0]?.attributes.filter((attr) => attr.name === name.toLowerCase()) ??
26
+ [];
27
+ const parts: string[] = [];
28
+ let cursor = 0;
29
+ for (const attribute of attributes) {
30
+ let start = attribute.start;
31
+ while (start > cursor && /[\t\n\f\r ]/.test(tag[start - 1]!)) start--;
32
+ parts.push(tag.slice(cursor, start));
33
+ cursor = attribute.end;
34
+ }
35
+ parts.push(tag.slice(cursor));
36
+ return parts.join("");
37
+ }
38
+ const attr = tagAttribute(tag, name);
39
+ const replacement =
40
+ edit.kind === "boolean" ? name : `${name}="${escapeHtmlAttribute(edit.value)}"`;
41
+ if (attr) return tag.slice(0, attr.start) + replacement + tag.slice(attr.end);
42
+ return tag.endsWith("/")
43
+ ? `${tag.slice(0, -1).trimEnd()} ${replacement} /`
44
+ : `${tag} ${replacement}`;
45
+ }
46
+
10
47
  function escapeStyleAttributeValue(value: string, quote: string): string {
11
48
  return quote === '"' ? value.replace(/"/g, """) : value.replace(/'/g, "'");
12
49
  }
13
50
 
14
51
  /** Escape a string for safe use inside a double-quoted HTML attribute. */
15
52
  export function escapeHtmlAttribute(value: string): string {
16
- return value
17
- .replace(/&/g, "&")
18
- .replace(/"/g, """)
19
- .replace(/</g, "&lt;")
20
- .replace(/>/g, "&gt;");
21
- }
22
-
23
- /** Reverse escapeHtmlAttribute so callers get the original value. */
24
- export function unescapeHtmlAttribute(value: string): string {
25
- return value
26
- .replace(/&quot;/g, '"')
27
- .replace(/&lt;/g, "<")
28
- .replace(/&gt;/g, ">")
29
- .replace(/&amp;/g, "&");
53
+ return value.replace(/[&"<>]/g, (char) => HTML_ATTRIBUTE_ENTITIES[char]![0]!);
30
54
  }
31
55
 
32
56
  function splitInlineStyleDeclarations(style: string): string[] {
@@ -98,8 +122,6 @@ export interface PatchOperation {
98
122
  childIndex?: number;
99
123
  }
100
124
 
101
- // Runtime validation for hfId lives in findTagByTarget → execDataAttrPattern (CSS attr-value
102
- // escape). This type is documentation only; the server's MutationTarget mirrors this shape.
103
125
  export interface PatchTarget {
104
126
  id?: string | null;
105
127
  hfId?: string;
@@ -120,7 +142,7 @@ export function resolveSourceFile(
120
142
  // Strategy 1: Search by id attribute
121
143
  if (elementId) {
122
144
  for (const [path, content] of Object.entries(files)) {
123
- if (content.includes(`id="${elementId}"`) || content.includes(`id='${elementId}'`)) {
145
+ if (findTagByAttribute(content, "id", elementId)) {
124
146
  return path;
125
147
  }
126
148
  }
@@ -131,7 +153,7 @@ export function resolveSourceFile(
131
153
  if (compIdMatch) {
132
154
  const compId = compIdMatch[1];
133
155
  for (const [path, content] of Object.entries(files)) {
134
- if (content.includes(`data-composition-id="${compId}"`)) {
156
+ if (findTagByAttribute(content, "data-composition-id", compId!)) {
135
157
  return path;
136
158
  }
137
159
  }
@@ -142,11 +164,7 @@ export function resolveSourceFile(
142
164
  if (classMatch) {
143
165
  const cls = classMatch[1];
144
166
  for (const [path, content] of Object.entries(files)) {
145
- if (
146
- content.includes(`class="${cls}"`) ||
147
- content.includes(`class="${cls} `) ||
148
- content.includes(` ${cls}"`)
149
- ) {
167
+ if (findTagByClass(content, { selector: `.${cls}` })) {
150
168
  return path;
151
169
  }
152
170
  }
@@ -166,28 +184,17 @@ function patchInlineStyle(
166
184
  prop: string,
167
185
  value: string | null,
168
186
  ): string {
169
- // Find the element tag with this id
170
- const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
171
- const match = idPattern.exec(html);
172
- if (!match) return html;
173
-
174
- const tag = match[1];
175
- return patchInlineStyleInTag(html, tag, prop, value);
187
+ return patchInlineStyleByTarget(html, { id: elementId }, prop, value);
176
188
  }
177
189
 
178
- function patchInlineStyleInTag(
179
- html: string,
180
- tag: string,
181
- prop: string,
182
- value: string | null,
183
- ): string {
184
- if (!tag) return html;
190
+ function patchInlineStyleInTag(tag: string, prop: string, value: string | null): string {
191
+ if (!tag) return tag;
185
192
 
186
193
  // Check if there's an existing style attribute
187
- const styleMatch = /\bstyle=(")([^"]*)"|\bstyle=(')([^']*)'/.exec(tag);
188
- if (styleMatch) {
189
- const existingStyle = styleMatch[2] ?? styleMatch[4];
190
- const quote = styleMatch[1] ?? styleMatch[3];
194
+ const styleMatch = tagAttribute(tag, "style");
195
+ if (styleMatch?.kind === "value") {
196
+ const existingStyle = styleMatch.value;
197
+ const quote = styleMatch.quote || '"';
191
198
  // Parse existing properties
192
199
  const props = new Map<string, string>();
193
200
  for (const part of splitInlineStyleDeclarations(existingStyle)) {
@@ -207,15 +214,18 @@ function patchInlineStyleInTag(
207
214
  const newStyle = Array.from(props.entries())
208
215
  .map(([k, v]) => `${k}: ${escapeStyleAttributeValue(v, quote)}`)
209
216
  .join("; ");
210
- const newTag = tag.replace(styleMatch[0], `style=${quote}${newStyle}${quote}`);
211
- return html.replace(tag, newTag);
217
+ const newTag =
218
+ tag.slice(0, styleMatch.start) +
219
+ `style=${quote}${newStyle}${quote}` +
220
+ tag.slice(styleMatch.end);
221
+ return newTag;
212
222
  } else {
213
223
  // No existing style attribute
214
- if (value === null) return html; // nothing to remove
224
+ if (value === null) return tag; // nothing to remove
215
225
  const selfClosing = tag.endsWith("/");
216
226
  const base = selfClosing ? tag.slice(0, -1).trimEnd() : tag;
217
227
  const newTag = `${base} style="${prop}: ${escapeStyleAttributeValue(value, '"')}"${selfClosing ? " /" : ""}`;
218
- return html.replace(tag, newTag);
228
+ return newTag;
219
229
  }
220
230
  }
221
231
 
@@ -227,7 +237,7 @@ function patchInlineStyleByTarget(
227
237
  ): string {
228
238
  const match = findTagByTarget(html, target);
229
239
  if (!match) return html;
230
- const newTag = patchInlineStyleInTag(match.tag, match.tag, prop, value);
240
+ const newTag = patchInlineStyleInTag(match.tag, prop, value);
231
241
  return replaceTagAtMatch(html, match, newTag);
232
242
  }
233
243
 
@@ -241,45 +251,40 @@ function replaceTagAtMatch(html: string, match: TagMatch, newTag: string): strin
241
251
  return `${html.slice(0, match.start)}${newTag}${html.slice(match.end)}`;
242
252
  }
243
253
 
244
- function execDataAttrPattern(html: string, attr: string, value: string): TagMatch | null {
245
- const pattern = new RegExp(`(<[^>]*\\b${attr}=(["'])${escapeRegex(value)}\\2[^>]*)>`, "i");
246
- const match = pattern.exec(html);
247
- if (match?.index == null) return null;
248
- return { tag: match[1], start: match.index, end: match.index + match[1].length };
254
+ function findTagByAttribute(html: string, name: string, value: string): TagMatch | null {
255
+ const found = scanHtmlOpeningTags(html).find((tag) => {
256
+ const attr = tag.attributes.find((attr) => attr.name === name);
257
+ return tag.closed && attr?.kind === "value" && decodeAuthoredAttribute(attr.value) === value;
258
+ });
259
+ return found
260
+ ? { tag: html.slice(found.start, found.bodyEnd), start: found.start, end: found.bodyEnd }
261
+ : null;
249
262
  }
250
263
 
251
264
  function findTagByClass(html: string, target: PatchTarget): TagMatch | null {
252
265
  const classMatch = target.selector?.match(/^\.([a-zA-Z0-9_-]+)$/);
253
266
  if (!classMatch) return null;
254
- const cls = classMatch[1];
255
- const pattern = new RegExp(
256
- `(<[^>]*\\bclass=(["'])[^"']*\\b${escapeRegex(cls)}\\b[^"']*\\2[^>]*)>`,
257
- "gi",
258
- );
259
- const selectorIndex = target.selectorIndex ?? 0;
260
- let match: RegExpExecArray | null;
261
- let currentIndex = 0;
262
- while ((match = pattern.exec(html)) !== null) {
263
- if (currentIndex === selectorIndex && match.index != null) {
264
- return {
265
- tag: match[1],
266
- start: match.index,
267
- end: match.index + match[1].length,
268
- };
269
- }
270
- currentIndex += 1;
271
- }
272
- return null;
267
+ const found = scanHtmlOpeningTags(html).filter((tag) => {
268
+ const attr = tag.attributes.find((attr) => attr.name === "class");
269
+ return (
270
+ tag.closed &&
271
+ attr?.kind === "value" &&
272
+ decodeAuthoredAttribute(attr.value).split(/\s+/).includes(classMatch[1]!)
273
+ );
274
+ })[target.selectorIndex ?? 0];
275
+ return found
276
+ ? { tag: html.slice(found.start, found.bodyEnd), start: found.start, end: found.bodyEnd }
277
+ : null;
273
278
  }
274
279
 
275
280
  export function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
276
281
  if (target.hfId) {
277
- const result = execDataAttrPattern(html, "data-hf-id", target.hfId);
282
+ const result = findTagByAttribute(html, "data-hf-id", target.hfId);
278
283
  if (result) return result;
279
284
  }
280
285
 
281
286
  if (target.id) {
282
- const result = execDataAttrPattern(html, "id", target.id);
287
+ const result = findTagByAttribute(html, "id", target.id);
283
288
  if (result) return result;
284
289
  }
285
290
 
@@ -287,7 +292,7 @@ export function findTagByTarget(html: string, target: PatchTarget): TagMatch | n
287
292
 
288
293
  const compositionIdMatch = target.selector.match(/^\[data-composition-id="([^"]+)"\]$/);
289
294
  if (compositionIdMatch) {
290
- const result = execDataAttrPattern(html, "data-composition-id", compositionIdMatch[1]);
295
+ const result = findTagByAttribute(html, "data-composition-id", compositionIdMatch[1]);
291
296
  if (result) return result;
292
297
  }
293
298
 
@@ -302,9 +307,12 @@ export function readAttributeByTarget(
302
307
  const match = findTagByTarget(html, target);
303
308
  if (!match) return undefined;
304
309
 
305
- const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
306
- const valueMatch = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`).exec(match.tag);
307
- return valueMatch?.[2] != null ? unescapeHtmlAttribute(valueMatch[2]) : undefined;
310
+ return readTagAttribute(match.tag, attr.startsWith("data-") ? attr : `data-${attr}`);
311
+ }
312
+
313
+ export function readTagAttribute(tag: string, attr: string): string | undefined {
314
+ const value = tagAttribute(tag, attr);
315
+ return value?.kind === "value" ? decodeAuthoredAttribute(value.value) : undefined;
308
316
  }
309
317
 
310
318
  export function readTagSnippetByTarget(html: string, target: PatchTarget): string | undefined {
@@ -320,85 +328,25 @@ function patchAttributeByTarget(
320
328
  ): string {
321
329
  const match = findTagByTarget(html, target);
322
330
  if (!match) return html;
323
-
324
- const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
325
- const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
326
- const tag = match.tag;
327
-
328
- if (value === null) {
329
- // Remove the attribute if present
330
- const boolAttrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
331
- if (!boolAttrPattern.test(tag)) return html;
332
- const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
333
- const newTag = tag.replace(removePattern, "");
334
- return replaceTagAtMatch(html, match, newTag);
335
- }
336
-
337
- const escaped = escapeHtmlAttribute(value);
338
- if (attrPattern.test(tag)) {
339
- const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
340
- return replaceTagAtMatch(html, match, newTag);
341
- }
342
-
343
- const newTag = tag + ` ${fullAttr}="${escaped}"`;
344
- return replaceTagAtMatch(html, match, newTag);
331
+ const name = attr.startsWith("data-") ? attr : `data-${attr}`;
332
+ const edit: AttributeEdit = value === null ? { kind: "remove" } : { kind: "value", value };
333
+ return replaceTagAtMatch(html, match, patchTagAttribute(match.tag, name, edit));
345
334
  }
346
335
 
347
- /**
348
- * Apply an attribute change to an element in the HTML source.
349
- */
350
336
  function patchAttribute(
351
337
  html: string,
352
338
  elementId: string,
353
339
  attr: string,
354
340
  value: string | null,
355
341
  ): string {
356
- const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
357
- const match = idPattern.exec(html);
358
- if (!match) return html;
359
-
360
- const tag = match[1];
361
- const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
362
- const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
363
-
364
- if (value === null) {
365
- const boolAttrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
366
- if (!boolAttrPattern.test(tag)) return html;
367
- const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
368
- const newTag = tag.replace(removePattern, "");
369
- return html.replace(tag, newTag);
370
- }
371
-
372
- const escaped = escapeHtmlAttribute(value);
373
- if (attrPattern.test(tag)) {
374
- // Update existing attribute
375
- const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
376
- return html.replace(tag, newTag);
377
- } else {
378
- // Add new attribute
379
- const newTag = tag + ` ${fullAttr}="${escaped}"`;
380
- return html.replace(tag, newTag);
381
- }
342
+ return patchAttributeByTarget(html, { id: elementId }, attr, value);
382
343
  }
383
344
 
384
345
  /**
385
346
  * Apply a text content change to an element.
386
347
  */
387
348
  function patchTextContent(html: string, elementId: string, value: string): string {
388
- const openTagPattern = new RegExp(
389
- `(<([a-z0-9-]+)[^>]*\\bid=(["'])${escapeRegex(elementId)}\\3[^>]*>)`,
390
- "i",
391
- );
392
- const match = openTagPattern.exec(html);
393
- if (!match || match.index == null) return html;
394
-
395
- const openingTag = match[1];
396
- const tagName = match[2];
397
- const contentStart = match.index + openingTag.length;
398
- const closingIndex = findMatchingClosingTagIndex(html, tagName, contentStart);
399
- if (closingIndex < 0) return html;
400
-
401
- return `${html.slice(0, contentStart)}${value}${html.slice(closingIndex)}`;
349
+ return patchTextContentByTarget(html, { id: elementId }, value);
402
350
  }
403
351
 
404
352
  function findMatchingClosingTagIndex(html: string, tagName: string, contentStart: number): number {
@@ -438,47 +386,18 @@ export const HTML_BOOLEAN_ATTRIBUTES = new Set([
438
386
  "selected",
439
387
  ]);
440
388
 
441
- function patchHtmlAttributeInTag(
442
- html: string,
443
- tag: string,
444
- attr: string,
445
- value: string | null,
446
- ): string {
447
- if (!tag) return html;
448
-
449
- const isBoolean = HTML_BOOLEAN_ATTRIBUTES.has(attr);
450
-
451
- if (isBoolean) {
452
- const escapedAttr = escapeRegex(attr);
453
- const hasBoolAttr = new RegExp(`(?:^|\\s)${escapedAttr}(?:\\s|=|$)`).test(tag);
454
-
455
- if (value === null || value === "" || value === "false") {
456
- if (!hasBoolAttr) return html;
457
- const removePattern = new RegExp(`\\s+${escapedAttr}(?:=(["'])[^"']*\\1)?`);
458
- const newTag = tag.replace(removePattern, "");
459
- return html.replace(tag, newTag);
389
+ function patchHtmlAttributeInTag(tag: string, attr: string, value: string | null): string {
390
+ if (!tag) return tag;
391
+ let edit: AttributeEdit;
392
+ if (HTML_BOOLEAN_ATTRIBUTES.has(attr)) {
393
+ if (value === null || value === "" || value === "false") edit = { kind: "remove" };
394
+ else {
395
+ if (tagAttribute(tag, attr)) return tag;
396
+ edit = { kind: "boolean" };
460
397
  }
461
- if (hasBoolAttr) return html;
462
- const newTag = tag + ` ${attr}`;
463
- return html.replace(tag, newTag);
464
- }
465
-
466
- const attrPattern = new RegExp(`\\b${escapeRegex(attr)}=(["'])([^"']*)\\1`);
467
- if (value === null) {
468
- if (!attrPattern.test(tag)) return html;
469
- const removePattern = new RegExp(`\\s+${escapeRegex(attr)}=(["'])[^"']*\\1`);
470
- const newTag = tag.replace(removePattern, "");
471
- return html.replace(tag, newTag);
472
- }
473
-
474
- const escaped = escapeHtmlAttribute(value);
475
- if (attrPattern.test(tag)) {
476
- const newTag = tag.replace(attrPattern, `${attr}="${escaped}"`);
477
- return html.replace(tag, newTag);
478
- }
479
-
480
- const newTag = tag + ` ${attr}="${escaped}"`;
481
- return html.replace(tag, newTag);
398
+ } else edit = value === null ? { kind: "remove" } : { kind: "value", value };
399
+ const newTag = patchTagAttribute(tag, attr, edit);
400
+ return newTag;
482
401
  }
483
402
 
484
403
  function patchHtmlAttribute(
@@ -487,10 +406,7 @@ function patchHtmlAttribute(
487
406
  attr: string,
488
407
  value: string | null,
489
408
  ): string {
490
- const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
491
- const match = idPattern.exec(html);
492
- if (!match) return html;
493
- return patchHtmlAttributeInTag(html, match[1], attr, value);
409
+ return patchHtmlAttributeByTarget(html, { id: elementId }, attr, value);
494
410
  }
495
411
 
496
412
  function patchHtmlAttributeByTarget(
@@ -501,7 +417,7 @@ function patchHtmlAttributeByTarget(
501
417
  ): string {
502
418
  const match = findTagByTarget(html, target);
503
419
  if (!match) return html;
504
- const newTag = patchHtmlAttributeInTag(match.tag, match.tag, attr, value);
420
+ const newTag = patchHtmlAttributeInTag(match.tag, attr, value);
505
421
  return replaceTagAtMatch(html, match, newTag);
506
422
  }
507
423
 
@@ -6,6 +6,7 @@ import {
6
6
  findMatchingTimelineElementId,
7
7
  findTimelineIdByAncestor,
8
8
  resolveAssetHasAudio,
9
+ resolveDroppedAssetDuration,
9
10
  resolveDroppedAssetDimensions,
10
11
  resolveElementTrack,
11
12
  resolveTimelineIdForSelection,
@@ -210,6 +211,28 @@ describe("resolveDroppedAssetDimensions", () => {
210
211
  });
211
212
  });
212
213
 
214
+ describe("dropped asset probes", () => {
215
+ const NAME = "assets/50% off #1?'s take.mp4";
216
+ const URL = "/api/projects/demo/preview/assets/50%25%20off%20%231%3F%27s%20take.mp4";
217
+
218
+ it("probe a dropped file's length and size at its own URL", async () => {
219
+ vi.useFakeTimers();
220
+ const probes: HTMLVideoElement[] = [];
221
+ const createElement = document.createElement.bind(document);
222
+ vi.spyOn(document, "createElement").mockImplementation((tagName, options) => {
223
+ const el = createElement(tagName, options);
224
+ if (tagName === "video") probes.push(el as HTMLVideoElement);
225
+ return el;
226
+ });
227
+
228
+ const duration = resolveDroppedAssetDuration("demo", NAME, "video");
229
+ const size = resolveDroppedAssetDimensions("demo", NAME, "video");
230
+ expect(probes.map((probe) => probe.getAttribute("src"))).toEqual([URL, URL]);
231
+ await vi.advanceTimersByTimeAsync(3000);
232
+ await Promise.all([duration, size]);
233
+ });
234
+ });
235
+
213
236
  describe("resolveElementTrack", () => {
214
237
  it("rounds an authored track", () => {
215
238
  expect(resolveElementTrack({ authoredTrack: 2.4, track: 0 })).toBe(2);
@@ -5,6 +5,7 @@ import type { DomEditSelection } from "../components/editor/domEditing";
5
5
  import type { TimelineAssetKind } from "./timelineAssetDrop";
6
6
  import { roundToCenti } from "./rounding";
7
7
  import { studioApiFetch } from "./studioApiFetch";
8
+ import { encodeUrlPath } from "@hyperframes/parsers";
8
9
 
9
10
  export interface EditingFile {
10
11
  path: string;
@@ -352,7 +353,7 @@ export async function resolveDroppedAssetDuration(
352
353
 
353
354
  const media = document.createElement(kind === "video" ? "video" : "audio");
354
355
  media.preload = "metadata";
355
- media.src = buildProjectApiPath(projectId, `/preview/${assetPath}`);
356
+ media.src = buildProjectApiPath(projectId, `/preview/${encodeUrlPath(assetPath)}`);
356
357
 
357
358
  const duration = await new Promise<number>((resolve) => {
358
359
  const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3000);
@@ -424,7 +425,7 @@ export async function resolveDroppedAssetDimensions(
424
425
  kind: TimelineAssetKind,
425
426
  ): Promise<{ width: number; height: number } | null> {
426
427
  if (kind === "audio") return null;
427
- const src = buildProjectApiPath(projectId, `/preview/${assetPath}`);
428
+ const src = buildProjectApiPath(projectId, `/preview/${encodeUrlPath(assetPath)}`);
428
429
 
429
430
  if (kind === "image") {
430
431
  return new Promise((resolve) => {
@@ -44,4 +44,35 @@ describe("buildStudioSelectionSnapshot", () => {
44
44
  expect(JSON.stringify(snapshot)).not.toContain("HTMLElement");
45
45
  expect(snapshot).not.toHaveProperty("element");
46
46
  });
47
+
48
+ it("links a selection inside a sub-composition to its own thumbnail", () => {
49
+ const snapshot = buildStudioSelectionSnapshot({
50
+ projectId: "demo",
51
+ selection: {
52
+ element: { tagName: "H1" } as HTMLElement,
53
+ id: null,
54
+ hfId: "intro-title",
55
+ selector: ".title",
56
+ selectorIndex: 0,
57
+ label: "Intro title",
58
+ tagName: "h1",
59
+ sourceFile: "compositions/intro #1.html",
60
+ compositionPath: "compositions/intro #1.html",
61
+ isCompositionHost: false,
62
+ isInsideLockedComposition: false,
63
+ boundingBox: { x: 0, y: 0, width: 10, height: 10 },
64
+ textContent: "Intro",
65
+ dataAttributes: {},
66
+ inlineStyles: {},
67
+ computedStyles: {},
68
+ textFields: [],
69
+ capabilities: { canSelect: true, canEditStyles: true },
70
+ } as DomEditSelection,
71
+ currentTime: 0,
72
+ });
73
+
74
+ expect(snapshot.thumbnailUrl).toMatch(
75
+ /^\/api\/projects\/demo\/thumbnail\/compositions%2Fintro%20%231\.html\?/,
76
+ );
77
+ });
47
78
  });
@@ -292,3 +292,17 @@ describe("fitTimelineAssetGeometry", () => {
292
292
  });
293
293
  });
294
294
  });
295
+
296
+ it("writes a dropped file's name as a URL, so %, #, ? and apostrophes load", () => {
297
+ const html = buildTimelineAssetInsertHtml({
298
+ id: "clip",
299
+ hfId: "hf-clip",
300
+ assetPath: "../assets/50% off #1?'s take.mp4",
301
+ kind: "video",
302
+ start: 0,
303
+ duration: 2,
304
+ track: 1,
305
+ zIndex: 1,
306
+ });
307
+ expect(html).toContain('src="../assets/50%25%20off%20%231%3F%27s%20take.mp4"');
308
+ });
@@ -1,3 +1,5 @@
1
+ import { encodeUrlPath } from "@hyperframes/parsers";
2
+ export { buildTimelineAssetId } from "@hyperframes/core/timeline-asset-id";
1
3
  import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
2
4
  import { roundToCenti } from "./rounding";
3
5
  import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
@@ -16,21 +18,6 @@ export function getTimelineAssetKind(assetPath: string): TimelineAssetKind | nul
16
18
  return null;
17
19
  }
18
20
 
19
- export function buildTimelineAssetId(assetPath: string, existingIds: Iterable<string>): string {
20
- const baseName = assetPath.split("/").pop() ?? "asset";
21
- const normalized = baseName
22
- .replace(/\.[^.]+$/, "")
23
- .replace(/[^a-zA-Z0-9_-]+/g, "_")
24
- .replace(/^_+|_+$/g, "")
25
- .toLowerCase();
26
- const baseId = normalized || "asset";
27
- const ids = new Set(existingIds);
28
- if (!ids.has(baseId)) return baseId;
29
- let suffix = 2;
30
- while (ids.has(`${baseId}_${suffix}`)) suffix += 1;
31
- return `${baseId}_${suffix}`;
32
- }
33
-
34
21
  export function resolveTimelineAssetSrc(targetPath: string, assetPath: string): string {
35
22
  const targetDir = targetPath.includes("/")
36
23
  ? targetPath.slice(0, targetPath.lastIndexOf("/"))
@@ -119,7 +106,7 @@ export function buildTimelineAssetInsertHtml(input: {
119
106
  /** Video only: true inserts `data-has-audio="true"` with no `muted`. Unknown or false stays muted. */
120
107
  hasAudio?: boolean;
121
108
  }): string {
122
- const sharedAttrs = `id="${input.id}" data-hf-id="${input.hfId}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
109
+ const sharedAttrs = `id="${input.id}" data-hf-id="${input.hfId}" class="clip" src="${encodeUrlPath(input.assetPath)}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
123
110
  const geometry = input.geometry ?? { left: 0, top: 0, width: 640, height: 360 };
124
111
  const visualStyles = `position: absolute; left: ${geometry.left}px; top: ${geometry.top}px; width: ${geometry.width}px; height: ${geometry.height}px; object-fit: contain; z-index: ${input.zIndex}`;
125
112