@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
package/dist/index.js CHANGED
@@ -2272,7 +2272,7 @@ var MAX_VISIBLE_THUMBNAIL_FRAMES = Math.ceil(3840 / (66 * (16 / 9)));
2272
2272
  var TIMELINE_VIEWPORT_BUDGETS = Object.freeze({
2273
2273
  directScrollSafetyPx: 8e6,
2274
2274
  rowOverscanPerSide: 2,
2275
- timeOverscanViewportRatio: 0.5,
2275
+ timeOverscanViewportRatio: 0.25,
2276
2276
  maxMountedRows: 64,
2277
2277
  maxMountedClipRoots: 512,
2278
2278
  maxMountedClipRootsPerRow: 128,
@@ -2486,7 +2486,10 @@ function commitPreview() {
2486
2486
  );
2487
2487
  }
2488
2488
  preview = null;
2489
- if (Math.abs(view.scroll.scrollLeft - left) >= 0.5) view.scroll.scrollLeft = left;
2489
+ if (Math.abs(view.scroll.scrollLeft - left) >= 0.5) {
2490
+ view.scroll.scrollLeft = left;
2491
+ flushSync(() => view.publishScroll(view.scroll));
2492
+ }
2490
2493
  clearScaled(view.scroll);
2491
2494
  emitPreview();
2492
2495
  }
@@ -3359,6 +3362,7 @@ function revealPlayheadScrollLeft(scroll, playheadX, contentOrigin, from = scrol
3359
3362
  function useTimelinePlayhead({
3360
3363
  playheadRef,
3361
3364
  scrollRef,
3365
+ syncScrollViewport,
3362
3366
  ppsRef,
3363
3367
  durationRef,
3364
3368
  isDragging,
@@ -3395,6 +3399,7 @@ function useTimelinePlayhead({
3395
3399
  const maxScrollLeft2 = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
3396
3400
  const left = anchor2.time * pps + contentOrigin - anchor2.x;
3397
3401
  scroll.scrollLeft = Math.max(0, Math.min(maxScrollLeft2, left));
3402
+ syncScrollViewport(scroll);
3398
3403
  return;
3399
3404
  }
3400
3405
  const zoomed = userZoomCount !== prevZoomCount;
@@ -3413,7 +3418,8 @@ function useTimelinePlayhead({
3413
3418
  const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
3414
3419
  const anchored = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft));
3415
3420
  scroll.scrollLeft = zoomed ? revealPlayheadScrollLeft(scroll, contentOrigin + time * pps, contentOrigin, anchored) : anchored;
3416
- }, [pps, userZoomCount, scrollRef, durationRef, contentOrigin]);
3421
+ syncScrollViewport(scroll);
3422
+ }, [pps, userZoomCount, scrollRef, durationRef, contentOrigin, syncScrollViewport]);
3417
3423
  const syncPlayheadPosition = useCallback4(
3418
3424
  (time) => {
3419
3425
  if (!playheadRef.current || durationRef.current <= 0) return;
@@ -3553,14 +3559,25 @@ function useTimelinePlayhead({
3553
3559
  scroll.addEventListener("wheel", handlePinchWheel, { passive: false, capture: true });
3554
3560
  scroll.addEventListener("pointerdown", settleTimelineZoom, { capture: true });
3555
3561
  scroll.addEventListener("scroll", redrawTimelineZoomPreview, { passive: true });
3556
- const unregisterZoomViewport = registerTimelineZoomViewport({ scroll, contentOrigin });
3562
+ const unregisterZoomViewport = registerTimelineZoomViewport({
3563
+ scroll,
3564
+ contentOrigin,
3565
+ publishScroll: syncScrollViewport
3566
+ });
3557
3567
  return () => {
3558
3568
  scroll.removeEventListener("wheel", handlePinchWheel, { capture: true });
3559
3569
  scroll.removeEventListener("pointerdown", settleTimelineZoom, { capture: true });
3560
3570
  scroll.removeEventListener("scroll", redrawTimelineZoomPreview);
3561
3571
  unregisterZoomViewport();
3562
3572
  };
3563
- }, [handlePinchWheel, scrollRef, timelineReady, elementsLength, contentOrigin]);
3573
+ }, [
3574
+ handlePinchWheel,
3575
+ scrollRef,
3576
+ timelineReady,
3577
+ elementsLength,
3578
+ contentOrigin,
3579
+ syncScrollViewport
3580
+ ]);
3564
3581
  return { seekFromX, autoScrollDuringDrag, dragScrollRaf };
3565
3582
  }
3566
3583
 
@@ -3579,6 +3596,10 @@ function useTimelineZoom() {
3579
3596
  // src/player/components/timelineDragDrop.ts
3580
3597
  import { useCallback as useCallback5, useEffect as useEffect6, useRef as useRef6, useState as useState4 } from "react";
3581
3598
 
3599
+ // src/utils/timelineAssetDrop.ts
3600
+ import { encodeUrlPath } from "@hyperframes/parsers";
3601
+ import { buildTimelineAssetId } from "@hyperframes/core/timeline-asset-id";
3602
+
3582
3603
  // src/utils/mediaTypes.ts
3583
3604
  var IMAGE_EXTS = ["jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "ico"];
3584
3605
  var VIDEO_EXTS = ["mp4", "webm", "mov", "m4v"];
@@ -3636,16 +3657,6 @@ function getTimelineAssetKind(assetPath) {
3636
3657
  if (AUDIO_EXT.test(assetPath)) return "audio";
3637
3658
  return null;
3638
3659
  }
3639
- function buildTimelineAssetId(assetPath, existingIds) {
3640
- const baseName = assetPath.split("/").pop() ?? "asset";
3641
- const normalized = baseName.replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
3642
- const baseId = normalized || "asset";
3643
- const ids = new Set(existingIds);
3644
- if (!ids.has(baseId)) return baseId;
3645
- let suffix = 2;
3646
- while (ids.has(`${baseId}_${suffix}`)) suffix += 1;
3647
- return `${baseId}_${suffix}`;
3648
- }
3649
3660
  function resolveTimelineAssetSrc(targetPath, assetPath) {
3650
3661
  const targetDir = targetPath.includes("/") ? targetPath.slice(0, targetPath.lastIndexOf("/")) : "";
3651
3662
  if (!targetDir) return assetPath;
@@ -3691,7 +3702,7 @@ function fitTimelineAssetGeometry(natural, comp) {
3691
3702
  };
3692
3703
  }
3693
3704
  function buildTimelineAssetInsertHtml(input) {
3694
- 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}"`;
3705
+ 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}"`;
3695
3706
  const geometry = input.geometry ?? { left: 0, top: 0, width: 640, height: 360 };
3696
3707
  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}`;
3697
3708
  if (input.kind === "image") {
@@ -14852,6 +14863,7 @@ function useTimelineProviderState({
14852
14863
  const { seekFromX, autoScrollDuringDrag, dragScrollRaf } = useTimelinePlayhead({
14853
14864
  playheadRef,
14854
14865
  scrollRef,
14866
+ syncScrollViewport,
14855
14867
  ppsRef,
14856
14868
  durationRef,
14857
14869
  isDragging,
@@ -17132,17 +17144,41 @@ function useOwnPreviewIframe() {
17132
17144
  import { isHtmlElement as isHtmlElement4 } from "@hyperframes/core/runtime/dom-realm";
17133
17145
 
17134
17146
  // src/utils/sourcePatcher.ts
17147
+ import {
17148
+ scanHtmlOpeningTags,
17149
+ decodeAuthoredAttribute,
17150
+ HTML_ATTRIBUTE_ENTITIES
17151
+ } from "@hyperframes/parsers";
17135
17152
  function escapeRegex(s) {
17136
17153
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17137
17154
  }
17155
+ function tagAttribute(tag, name) {
17156
+ return scanHtmlOpeningTags(tag)[0]?.attributes.find((attr) => attr.name === name.toLowerCase());
17157
+ }
17158
+ function patchTagAttribute(tag, name, edit) {
17159
+ if (edit.kind === "remove") {
17160
+ const attributes = scanHtmlOpeningTags(tag)[0]?.attributes.filter((attr2) => attr2.name === name.toLowerCase()) ?? [];
17161
+ const parts = [];
17162
+ let cursor = 0;
17163
+ for (const attribute of attributes) {
17164
+ let start = attribute.start;
17165
+ while (start > cursor && /[\t\n\f\r ]/.test(tag[start - 1])) start--;
17166
+ parts.push(tag.slice(cursor, start));
17167
+ cursor = attribute.end;
17168
+ }
17169
+ parts.push(tag.slice(cursor));
17170
+ return parts.join("");
17171
+ }
17172
+ const attr = tagAttribute(tag, name);
17173
+ const replacement = edit.kind === "boolean" ? name : `${name}="${escapeHtmlAttribute(edit.value)}"`;
17174
+ if (attr) return tag.slice(0, attr.start) + replacement + tag.slice(attr.end);
17175
+ return tag.endsWith("/") ? `${tag.slice(0, -1).trimEnd()} ${replacement} /` : `${tag} ${replacement}`;
17176
+ }
17138
17177
  function escapeStyleAttributeValue(value, quote) {
17139
17178
  return quote === '"' ? value.replace(/"/g, "&quot;") : value.replace(/'/g, "&#39;");
17140
17179
  }
17141
17180
  function escapeHtmlAttribute(value) {
17142
- return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
17143
- }
17144
- function unescapeHtmlAttribute(value) {
17145
- return value.replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
17181
+ return value.replace(/[&"<>]/g, (char) => HTML_ATTRIBUTE_ENTITIES[char][0]);
17146
17182
  }
17147
17183
  function splitInlineStyleDeclarations(style) {
17148
17184
  const declarations = [];
@@ -17195,7 +17231,7 @@ function resolveSourceFile(elementId2, selector, files) {
17195
17231
  if (!elementId2 && !selector) return null;
17196
17232
  if (elementId2) {
17197
17233
  for (const [path, content] of Object.entries(files)) {
17198
- if (content.includes(`id="${elementId2}"`) || content.includes(`id='${elementId2}'`)) {
17234
+ if (findTagByAttribute(content, "id", elementId2)) {
17199
17235
  return path;
17200
17236
  }
17201
17237
  }
@@ -17204,7 +17240,7 @@ function resolveSourceFile(elementId2, selector, files) {
17204
17240
  if (compIdMatch) {
17205
17241
  const compId = compIdMatch[1];
17206
17242
  for (const [path, content] of Object.entries(files)) {
17207
- if (content.includes(`data-composition-id="${compId}"`)) {
17243
+ if (findTagByAttribute(content, "data-composition-id", compId)) {
17208
17244
  return path;
17209
17245
  }
17210
17246
  }
@@ -17213,7 +17249,7 @@ function resolveSourceFile(elementId2, selector, files) {
17213
17249
  if (classMatch) {
17214
17250
  const cls = classMatch[1];
17215
17251
  for (const [path, content] of Object.entries(files)) {
17216
- if (content.includes(`class="${cls}"`) || content.includes(`class="${cls} `) || content.includes(` ${cls}"`)) {
17252
+ if (findTagByClass(content, { selector: `.${cls}` })) {
17217
17253
  return path;
17218
17254
  }
17219
17255
  }
@@ -17222,18 +17258,14 @@ function resolveSourceFile(elementId2, selector, files) {
17222
17258
  return null;
17223
17259
  }
17224
17260
  function patchInlineStyle(html2, elementId2, prop, value) {
17225
- const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId2)}\\2[^>]*)>`, "i");
17226
- const match = idPattern.exec(html2);
17227
- if (!match) return html2;
17228
- const tag = match[1];
17229
- return patchInlineStyleInTag(html2, tag, prop, value);
17230
- }
17231
- function patchInlineStyleInTag(html2, tag, prop, value) {
17232
- if (!tag) return html2;
17233
- const styleMatch = /\bstyle=(")([^"]*)"|\bstyle=(')([^']*)'/.exec(tag);
17234
- if (styleMatch) {
17235
- const existingStyle = styleMatch[2] ?? styleMatch[4];
17236
- const quote = styleMatch[1] ?? styleMatch[3];
17261
+ return patchInlineStyleByTarget(html2, { id: elementId2 }, prop, value);
17262
+ }
17263
+ function patchInlineStyleInTag(tag, prop, value) {
17264
+ if (!tag) return tag;
17265
+ const styleMatch = tagAttribute(tag, "style");
17266
+ if (styleMatch?.kind === "value") {
17267
+ const existingStyle = styleMatch.value;
17268
+ const quote = styleMatch.quote || '"';
17237
17269
  const props = /* @__PURE__ */ new Map();
17238
17270
  for (const part of splitInlineStyleDeclarations(existingStyle)) {
17239
17271
  const colon = part.indexOf(":");
@@ -17248,67 +17280,54 @@ function patchInlineStyleInTag(html2, tag, prop, value) {
17248
17280
  props.set(prop, value);
17249
17281
  }
17250
17282
  const newStyle = Array.from(props.entries()).map(([k, v]) => `${k}: ${escapeStyleAttributeValue(v, quote)}`).join("; ");
17251
- const newTag = tag.replace(styleMatch[0], `style=${quote}${newStyle}${quote}`);
17252
- return html2.replace(tag, newTag);
17283
+ const newTag = tag.slice(0, styleMatch.start) + `style=${quote}${newStyle}${quote}` + tag.slice(styleMatch.end);
17284
+ return newTag;
17253
17285
  } else {
17254
- if (value === null) return html2;
17286
+ if (value === null) return tag;
17255
17287
  const selfClosing = tag.endsWith("/");
17256
17288
  const base2 = selfClosing ? tag.slice(0, -1).trimEnd() : tag;
17257
17289
  const newTag = `${base2} style="${prop}: ${escapeStyleAttributeValue(value, '"')}"${selfClosing ? " /" : ""}`;
17258
- return html2.replace(tag, newTag);
17290
+ return newTag;
17259
17291
  }
17260
17292
  }
17261
17293
  function patchInlineStyleByTarget(html2, target, prop, value) {
17262
17294
  const match = findTagByTarget(html2, target);
17263
17295
  if (!match) return html2;
17264
- const newTag = patchInlineStyleInTag(match.tag, match.tag, prop, value);
17296
+ const newTag = patchInlineStyleInTag(match.tag, prop, value);
17265
17297
  return replaceTagAtMatch(html2, match, newTag);
17266
17298
  }
17267
17299
  function replaceTagAtMatch(html2, match, newTag) {
17268
17300
  return `${html2.slice(0, match.start)}${newTag}${html2.slice(match.end)}`;
17269
17301
  }
17270
- function execDataAttrPattern(html2, attr, value) {
17271
- const pattern = new RegExp(`(<[^>]*\\b${attr}=(["'])${escapeRegex(value)}\\2[^>]*)>`, "i");
17272
- const match = pattern.exec(html2);
17273
- if (match?.index == null) return null;
17274
- return { tag: match[1], start: match.index, end: match.index + match[1].length };
17302
+ function findTagByAttribute(html2, name, value) {
17303
+ const found = scanHtmlOpeningTags(html2).find((tag) => {
17304
+ const attr = tag.attributes.find((attr2) => attr2.name === name);
17305
+ return tag.closed && attr?.kind === "value" && decodeAuthoredAttribute(attr.value) === value;
17306
+ });
17307
+ return found ? { tag: html2.slice(found.start, found.bodyEnd), start: found.start, end: found.bodyEnd } : null;
17275
17308
  }
17276
17309
  function findTagByClass(html2, target) {
17277
17310
  const classMatch = target.selector?.match(/^\.([a-zA-Z0-9_-]+)$/);
17278
17311
  if (!classMatch) return null;
17279
- const cls = classMatch[1];
17280
- const pattern = new RegExp(
17281
- `(<[^>]*\\bclass=(["'])[^"']*\\b${escapeRegex(cls)}\\b[^"']*\\2[^>]*)>`,
17282
- "gi"
17283
- );
17284
- const selectorIndex = target.selectorIndex ?? 0;
17285
- let match;
17286
- let currentIndex = 0;
17287
- while ((match = pattern.exec(html2)) !== null) {
17288
- if (currentIndex === selectorIndex && match.index != null) {
17289
- return {
17290
- tag: match[1],
17291
- start: match.index,
17292
- end: match.index + match[1].length
17293
- };
17294
- }
17295
- currentIndex += 1;
17296
- }
17297
- return null;
17312
+ const found = scanHtmlOpeningTags(html2).filter((tag) => {
17313
+ const attr = tag.attributes.find((attr2) => attr2.name === "class");
17314
+ return tag.closed && attr?.kind === "value" && decodeAuthoredAttribute(attr.value).split(/\s+/).includes(classMatch[1]);
17315
+ })[target.selectorIndex ?? 0];
17316
+ return found ? { tag: html2.slice(found.start, found.bodyEnd), start: found.start, end: found.bodyEnd } : null;
17298
17317
  }
17299
17318
  function findTagByTarget(html2, target) {
17300
17319
  if (target.hfId) {
17301
- const result = execDataAttrPattern(html2, "data-hf-id", target.hfId);
17320
+ const result = findTagByAttribute(html2, "data-hf-id", target.hfId);
17302
17321
  if (result) return result;
17303
17322
  }
17304
17323
  if (target.id) {
17305
- const result = execDataAttrPattern(html2, "id", target.id);
17324
+ const result = findTagByAttribute(html2, "id", target.id);
17306
17325
  if (result) return result;
17307
17326
  }
17308
17327
  if (!target.selector) return null;
17309
17328
  const compositionIdMatch = target.selector.match(/^\[data-composition-id="([^"]+)"\]$/);
17310
17329
  if (compositionIdMatch) {
17311
- const result = execDataAttrPattern(html2, "data-composition-id", compositionIdMatch[1]);
17330
+ const result = findTagByAttribute(html2, "data-composition-id", compositionIdMatch[1]);
17312
17331
  if (result) return result;
17313
17332
  }
17314
17333
  return findTagByClass(html2, target);
@@ -17316,9 +17335,11 @@ function findTagByTarget(html2, target) {
17316
17335
  function readAttributeByTarget(html2, target, attr) {
17317
17336
  const match = findTagByTarget(html2, target);
17318
17337
  if (!match) return void 0;
17319
- const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
17320
- const valueMatch = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`).exec(match.tag);
17321
- return valueMatch?.[2] != null ? unescapeHtmlAttribute(valueMatch[2]) : void 0;
17338
+ return readTagAttribute(match.tag, attr.startsWith("data-") ? attr : `data-${attr}`);
17339
+ }
17340
+ function readTagAttribute(tag, attr) {
17341
+ const value = tagAttribute(tag, attr);
17342
+ return value?.kind === "value" ? decodeAuthoredAttribute(value.value) : void 0;
17322
17343
  }
17323
17344
  function readTagSnippetByTarget(html2, target) {
17324
17345
  const match = findTagByTarget(html2, target);
@@ -17327,60 +17348,15 @@ function readTagSnippetByTarget(html2, target) {
17327
17348
  function patchAttributeByTarget(html2, target, attr, value) {
17328
17349
  const match = findTagByTarget(html2, target);
17329
17350
  if (!match) return html2;
17330
- const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
17331
- const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
17332
- const tag = match.tag;
17333
- if (value === null) {
17334
- const boolAttrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
17335
- if (!boolAttrPattern.test(tag)) return html2;
17336
- const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
17337
- const newTag2 = tag.replace(removePattern, "");
17338
- return replaceTagAtMatch(html2, match, newTag2);
17339
- }
17340
- const escaped = escapeHtmlAttribute(value);
17341
- if (attrPattern.test(tag)) {
17342
- const newTag2 = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
17343
- return replaceTagAtMatch(html2, match, newTag2);
17344
- }
17345
- const newTag = tag + ` ${fullAttr}="${escaped}"`;
17346
- return replaceTagAtMatch(html2, match, newTag);
17351
+ const name = attr.startsWith("data-") ? attr : `data-${attr}`;
17352
+ const edit = value === null ? { kind: "remove" } : { kind: "value", value };
17353
+ return replaceTagAtMatch(html2, match, patchTagAttribute(match.tag, name, edit));
17347
17354
  }
17348
17355
  function patchAttribute(html2, elementId2, attr, value) {
17349
- const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId2)}\\2[^>]*)>`, "i");
17350
- const match = idPattern.exec(html2);
17351
- if (!match) return html2;
17352
- const tag = match[1];
17353
- const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
17354
- const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
17355
- if (value === null) {
17356
- const boolAttrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
17357
- if (!boolAttrPattern.test(tag)) return html2;
17358
- const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
17359
- const newTag = tag.replace(removePattern, "");
17360
- return html2.replace(tag, newTag);
17361
- }
17362
- const escaped = escapeHtmlAttribute(value);
17363
- if (attrPattern.test(tag)) {
17364
- const newTag = tag.replace(attrPattern, `${fullAttr}="${escaped}"`);
17365
- return html2.replace(tag, newTag);
17366
- } else {
17367
- const newTag = tag + ` ${fullAttr}="${escaped}"`;
17368
- return html2.replace(tag, newTag);
17369
- }
17356
+ return patchAttributeByTarget(html2, { id: elementId2 }, attr, value);
17370
17357
  }
17371
17358
  function patchTextContent(html2, elementId2, value) {
17372
- const openTagPattern = new RegExp(
17373
- `(<([a-z0-9-]+)[^>]*\\bid=(["'])${escapeRegex(elementId2)}\\3[^>]*>)`,
17374
- "i"
17375
- );
17376
- const match = openTagPattern.exec(html2);
17377
- if (!match || match.index == null) return html2;
17378
- const openingTag = match[1];
17379
- const tagName = match[2];
17380
- const contentStart = match.index + openingTag.length;
17381
- const closingIndex = findMatchingClosingTagIndex(html2, tagName, contentStart);
17382
- if (closingIndex < 0) return html2;
17383
- return `${html2.slice(0, contentStart)}${value}${html2.slice(closingIndex)}`;
17359
+ return patchTextContentByTarget(html2, { id: elementId2 }, value);
17384
17360
  }
17385
17361
  function findMatchingClosingTagIndex(html2, tagName, contentStart) {
17386
17362
  const tagPattern = new RegExp(`</?${escapeRegex(tagName)}\\b[^>]*>`, "gi");
@@ -17415,47 +17391,26 @@ var HTML_BOOLEAN_ATTRIBUTES = /* @__PURE__ */ new Set([
17415
17391
  "reversed",
17416
17392
  "selected"
17417
17393
  ]);
17418
- function patchHtmlAttributeInTag(html2, tag, attr, value) {
17419
- if (!tag) return html2;
17420
- const isBoolean = HTML_BOOLEAN_ATTRIBUTES.has(attr);
17421
- if (isBoolean) {
17422
- const escapedAttr = escapeRegex(attr);
17423
- const hasBoolAttr = new RegExp(`(?:^|\\s)${escapedAttr}(?:\\s|=|$)`).test(tag);
17424
- if (value === null || value === "" || value === "false") {
17425
- if (!hasBoolAttr) return html2;
17426
- const removePattern = new RegExp(`\\s+${escapedAttr}(?:=(["'])[^"']*\\1)?`);
17427
- const newTag3 = tag.replace(removePattern, "");
17428
- return html2.replace(tag, newTag3);
17429
- }
17430
- if (hasBoolAttr) return html2;
17431
- const newTag2 = tag + ` ${attr}`;
17432
- return html2.replace(tag, newTag2);
17433
- }
17434
- const attrPattern = new RegExp(`\\b${escapeRegex(attr)}=(["'])([^"']*)\\1`);
17435
- if (value === null) {
17436
- if (!attrPattern.test(tag)) return html2;
17437
- const removePattern = new RegExp(`\\s+${escapeRegex(attr)}=(["'])[^"']*\\1`);
17438
- const newTag2 = tag.replace(removePattern, "");
17439
- return html2.replace(tag, newTag2);
17440
- }
17441
- const escaped = escapeHtmlAttribute(value);
17442
- if (attrPattern.test(tag)) {
17443
- const newTag2 = tag.replace(attrPattern, `${attr}="${escaped}"`);
17444
- return html2.replace(tag, newTag2);
17445
- }
17446
- const newTag = tag + ` ${attr}="${escaped}"`;
17447
- return html2.replace(tag, newTag);
17394
+ function patchHtmlAttributeInTag(tag, attr, value) {
17395
+ if (!tag) return tag;
17396
+ let edit;
17397
+ if (HTML_BOOLEAN_ATTRIBUTES.has(attr)) {
17398
+ if (value === null || value === "" || value === "false") edit = { kind: "remove" };
17399
+ else {
17400
+ if (tagAttribute(tag, attr)) return tag;
17401
+ edit = { kind: "boolean" };
17402
+ }
17403
+ } else edit = value === null ? { kind: "remove" } : { kind: "value", value };
17404
+ const newTag = patchTagAttribute(tag, attr, edit);
17405
+ return newTag;
17448
17406
  }
17449
17407
  function patchHtmlAttribute(html2, elementId2, attr, value) {
17450
- const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId2)}\\2[^>]*)>`, "i");
17451
- const match = idPattern.exec(html2);
17452
- if (!match) return html2;
17453
- return patchHtmlAttributeInTag(html2, match[1], attr, value);
17408
+ return patchHtmlAttributeByTarget(html2, { id: elementId2 }, attr, value);
17454
17409
  }
17455
17410
  function patchHtmlAttributeByTarget(html2, target, attr, value) {
17456
17411
  const match = findTagByTarget(html2, target);
17457
17412
  if (!match) return html2;
17458
- const newTag = patchHtmlAttributeInTag(match.tag, match.tag, attr, value);
17413
+ const newTag = patchHtmlAttributeInTag(match.tag, attr, value);
17459
17414
  return replaceTagAtMatch(html2, match, newTag);
17460
17415
  }
17461
17416
  function patchTextContentByTarget(html2, target, value) {
@@ -21826,6 +21781,7 @@ import {
21826
21781
  } from "@hyperframes/core/audio-automation";
21827
21782
 
21828
21783
  // src/utils/studioHelpers.ts
21784
+ import { encodeUrlPath as encodeUrlPath2 } from "@hyperframes/parsers";
21829
21785
  function getTimelineElementLabel(element) {
21830
21786
  return element.label || element.id || element.tag;
21831
21787
  }
@@ -21980,7 +21936,7 @@ async function resolveDroppedAssetDuration(projectId, assetPath, kind) {
21980
21936
  if (kind === "image") return DEFAULT_TIMELINE_ASSET_DURATION.image;
21981
21937
  const media = document.createElement(kind === "video" ? "video" : "audio");
21982
21938
  media.preload = "metadata";
21983
- media.src = buildProjectApiPath(projectId, `/preview/${assetPath}`);
21939
+ media.src = buildProjectApiPath(projectId, `/preview/${encodeUrlPath2(assetPath)}`);
21984
21940
  const duration = await new Promise((resolve) => {
21985
21941
  const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3e3);
21986
21942
  const finalize = (value) => {
@@ -25296,6 +25252,7 @@ function isPeakMap(value) {
25296
25252
  }
25297
25253
 
25298
25254
  // src/player/components/thumbnailUtils.ts
25255
+ import { decodedUrlPath } from "@hyperframes/parsers";
25299
25256
  function quantizeThumbnailFrameCount(frameCount) {
25300
25257
  const safeCount = Math.max(1, Number.isFinite(frameCount) ? Math.ceil(frameCount) : 1);
25301
25258
  const cap = 2 ** Math.floor(Math.log2(MAX_VISIBLE_THUMBNAIL_FRAMES));
@@ -25346,6 +25303,9 @@ function computeThumbnailStrip(containerWidth, aspect, clipHeight, minFrameWidth
25346
25303
  const frameCount = measured ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
25347
25304
  return { frameW, frameCount };
25348
25305
  }
25306
+ function authoredSrcPath(src) {
25307
+ return /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(src) ? src : decodedUrlPath(src);
25308
+ }
25349
25309
  function encodePreviewPath(relativePath) {
25350
25310
  return relativePath.split("/").map(encodeURIComponent).join("/");
25351
25311
  }
@@ -25390,7 +25350,7 @@ function loadPeakMap(url) {
25390
25350
  function clipPeaksUrl(src, projectId) {
25391
25351
  const origin2 = window.location.origin;
25392
25352
  const relative = resolvePreviewRelative(
25393
- resolveMediaPreviewUrl(src ?? "", projectId, origin2),
25353
+ resolveMediaPreviewUrl(authoredSrcPath(src ?? ""), projectId, origin2),
25394
25354
  projectId,
25395
25355
  origin2
25396
25356
  );
@@ -25461,7 +25421,7 @@ function isNormalizePlan(value) {
25461
25421
  async function requestNormalizePlan(projectId, el, fetchImpl = studioApiFetch) {
25462
25422
  const origin2 = window.location.origin;
25463
25423
  const relative = resolvePreviewRelative(
25464
- resolveMediaPreviewUrl(el.src ?? "", projectId, origin2),
25424
+ resolveMediaPreviewUrl(authoredSrcPath(el.src ?? ""), projectId, origin2),
25465
25425
  projectId,
25466
25426
  origin2
25467
25427
  );
@@ -26975,10 +26935,10 @@ function formatAttrs(attrs) {
26975
26935
  function readAuthoredSrc(source, target) {
26976
26936
  const tag = findTagByTarget(source, target);
26977
26937
  if (!tag) return "";
26978
- const own = /\bsrc=(["'])([^"']*)\1/.exec(tag.tag);
26979
- if (own?.[2]) return unescapeHtmlAttribute(own[2]);
26980
- const firstSource = /^\s*<source\b[^>]*?\bsrc=(["'])([^"']*)\1/i.exec(source.slice(tag.end + 1));
26981
- return firstSource?.[2] ? unescapeHtmlAttribute(firstSource[2]) : "";
26938
+ const own = readTagAttribute(tag.tag, "src");
26939
+ if (own) return own;
26940
+ const firstSource = /^\s*<source\b[^>]*>/i.exec(source.slice(tag.end + 1));
26941
+ return firstSource && readTagAttribute(firstSource[0], "src") || "";
26982
26942
  }
26983
26943
  function insertBeforeTarget(source, target, markup) {
26984
26944
  const match = findTagByTarget(source, target);
@@ -32173,7 +32133,7 @@ function getCachedProbe(url) {
32173
32133
  return cached5?.result;
32174
32134
  }
32175
32135
  function resolveProbeSource(src, projectId) {
32176
- return projectId ? resolveMediaPreviewUrl(src, projectId, window.location.origin) : src;
32136
+ return projectId ? resolveMediaPreviewUrl(authoredSrcPath(src), projectId, window.location.origin) : src;
32177
32137
  }
32178
32138
  function evictMetadataOverflow() {
32179
32139
  const overflow = cache.size + failed.size - TIMELINE_VIEWPORT_BUDGETS.metadataRegistryEntries;
@@ -52871,7 +52831,11 @@ function trimFractions(el) {
52871
52831
  return { start, end };
52872
52832
  }
52873
52833
  function renderAudioClip(el, pid, sessionEpoch, labelColor, context, elements, labelInset) {
52874
- const audioUrl = resolveMediaPreviewUrl(el.src ?? "", pid, window.location.origin);
52834
+ const audioUrl = resolveMediaPreviewUrl(
52835
+ authoredSrcPath(el.src ?? ""),
52836
+ pid,
52837
+ window.location.origin
52838
+ );
52875
52839
  const srcRelative = resolvePreviewRelative(audioUrl, pid, window.location.origin);
52876
52840
  const encodedRelative = srcRelative ? encodePreviewPath(srcRelative) : null;
52877
52841
  const waveformUrl = encodedRelative ? buildProjectApiPath(pid, `/waveform/${encodedRelative}`) : void 0;
@@ -52971,7 +52935,11 @@ function useRenderClipContent({
52971
52935
  }
52972
52936
  const htmlPreviewEligible = el.duration > 0 && effectiveTimelineDuration > 0;
52973
52937
  if ((el.tag === "video" || el.tag === "img") && el.src) {
52974
- const mediaSrc = resolveMediaPreviewUrl(el.src, pid, window.location.origin);
52938
+ const mediaSrc = resolveMediaPreviewUrl(
52939
+ authoredSrcPath(el.src),
52940
+ pid,
52941
+ window.location.origin
52942
+ );
52975
52943
  if (el.tag === "img") {
52976
52944
  return createElement(ImageThumbnail, {
52977
52945
  imageSrc: mediaSrc,
@@ -54689,8 +54657,8 @@ function MediaSection({
54689
54657
  const [removeProgress, setRemoveProgress] = useState65(null);
54690
54658
  const [createPlate, setCreatePlate] = useState65(false);
54691
54659
  const [quality, setQuality] = useState65("balanced");
54692
- const absoluteSrc = projectDir && srcAttr && !srcAttr.startsWith("http") ? `${projectDir}/${srcAttr}` : srcAttr;
54693
- const projectSrc = srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr) ? stripQueryAndHash(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr) : "";
54660
+ const projectSrc = srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr) ? authoredSrcPath(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr) : "";
54661
+ const absoluteSrc = projectDir && projectSrc ? `${projectDir}/${projectSrc}` : srcAttr;
54694
54662
  const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc);
54695
54663
  const panelTitle = isImage ? "Image" : isVideo ? "Video" : "Audio";
54696
54664
  useEffect72(() => {
@@ -56013,7 +55981,7 @@ function resolveProjectAssetPath(sourceFile, src, projectId) {
56013
55981
  return stripPreviewAssetPath(trimmed, projectId);
56014
55982
  }
56015
55983
  const sourceDir = sourceFile.includes("/") ? sourceFile.slice(0, sourceFile.lastIndexOf("/")) : "";
56016
- const parts = `${sourceDir}/${trimmed}`.split("/");
55984
+ const parts = `${sourceDir}/${authoredSrcPath(trimmed)}`.split("/");
56017
55985
  const normalized = [];
56018
55986
  for (const part of parts) {
56019
55987
  if (!part || part === ".") continue;
@@ -58487,6 +58455,7 @@ function ReverseGradientIcon({ size = 16 }) {
58487
58455
  }
58488
58456
 
58489
58457
  // src/components/editor/propertyPanelFill.tsx
58458
+ import { encodeUrlPath as encodeUrlPath3 } from "@hyperframes/parsers";
58490
58459
  import { jsx as jsx137, jsxs as jsxs99 } from "react/jsx-runtime";
58491
58460
  function normalizeProjectPath(value) {
58492
58461
  const trimmed = value.trim();
@@ -58613,7 +58582,7 @@ function ImageFillField({
58613
58582
  selectedAsset && /* @__PURE__ */ jsx137("div", { className: "overflow-hidden rounded-xl border border-neutral-800 bg-neutral-900/80", children: /* @__PURE__ */ jsx137(
58614
58583
  "img",
58615
58584
  {
58616
- src: buildProjectApiPath(projectId, `/preview/${selectedAsset}`),
58585
+ src: buildProjectApiPath(projectId, `/preview/${encodeUrlPath3(selectedAsset)}`),
58617
58586
  alt: selectedAsset.split("/").pop() ?? selectedAsset,
58618
58587
  className: "h-28 w-full object-contain bg-neutral-950/80"
58619
58588
  }
@@ -67079,8 +67048,8 @@ function FlatMediaSection({
67079
67048
  const [removeProgress, setRemoveProgress] = useState94(null);
67080
67049
  const [createPlate, setCreatePlate] = useState94(false);
67081
67050
  const [quality, setQuality] = useState94("balanced");
67082
- const absoluteSrc = projectDir && srcAttr && !srcAttr.startsWith("http") ? `${projectDir}/${srcAttr}` : srcAttr;
67083
- const projectSrc = srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr) ? stripQueryAndHash(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr) : "";
67051
+ const projectSrc = srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr) ? authoredSrcPath(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr) : "";
67052
+ const absoluteSrc = projectDir && projectSrc ? `${projectDir}/${projectSrc}` : srcAttr;
67084
67053
  const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc);
67085
67054
  useEffect94(() => {
67086
67055
  setRemoveProgress(null);
@@ -75107,6 +75076,7 @@ import { useState as useState117, useCallback as useCallback104, useRef as useRe
75107
75076
 
75108
75077
  // src/hooks/useFileTree.ts
75109
75078
  import { useState as useState116, useCallback as useCallback101, useEffect as useEffect113, useMemo as useMemo71, useRef as useRef128 } from "react";
75079
+ import { encodeUrlPath as encodeUrlPath4 } from "@hyperframes/parsers";
75110
75080
  var EMPTY_FILE_LIST = [];
75111
75081
  function useFileTree({ projectId, projectIdRef }) {
75112
75082
  const [fetched, setFetched] = useState116(null);
@@ -75178,7 +75148,7 @@ function useFileTree({ projectId, projectIdRef }) {
75178
75148
  () => (projectId ? assets : []).filter((asset) => FONT_EXT.test(asset)).map((asset) => ({
75179
75149
  family: fontFamilyFromAssetPath(asset),
75180
75150
  path: asset,
75181
- url: projectId ? buildProjectApiPath(projectId, `/preview/${asset}`) : ""
75151
+ url: projectId ? buildProjectApiPath(projectId, `/preview/${encodeUrlPath4(asset)}`) : ""
75182
75152
  })),
75183
75153
  [assets, projectId]
75184
75154
  );
@@ -75443,6 +75413,7 @@ function useProjectFileWriter({ projectId }) {
75443
75413
  }
75444
75414
 
75445
75415
  // src/hooks/useFileManager.ts
75416
+ import { encodeUrlPath as encodeUrlPath5 } from "@hyperframes/parsers";
75446
75417
  function useFileManager({
75447
75418
  projectId,
75448
75419
  showToast,
@@ -75752,7 +75723,7 @@ function useFileManager({
75752
75723
  const imported = uploaded.filter((asset) => FONT_EXT.test(asset)).map((asset) => ({
75753
75724
  family: fontFamilyFromAssetPath(asset),
75754
75725
  path: asset,
75755
- url: `/api/projects/${encodeURIComponent(pid)}/preview/${asset}`
75726
+ url: `/api/projects/${encodeURIComponent(pid)}/preview/${encodeUrlPath5(asset)}`
75756
75727
  }));
75757
75728
  importedFontAssetsRef.current = [
75758
75729
  ...imported,
@@ -81788,6 +81759,7 @@ function useDomGeometryCommits({
81788
81759
  }
81789
81760
 
81790
81761
  // src/hooks/useDomEditCommits.ts
81762
+ import { encodeUrlPath as encodeUrlPath6 } from "@hyperframes/parsers";
81791
81763
  function useDomEditCommits({
81792
81764
  activeCompPath,
81793
81765
  previewIframeRef,
@@ -81826,7 +81798,7 @@ function useDomEditCommits({
81826
81798
  return {
81827
81799
  family: fontFamilyFromAssetPath(asset),
81828
81800
  path: asset,
81829
- url: buildProjectApiPath(projectId, `/preview/${asset}`)
81801
+ url: buildProjectApiPath(projectId, `/preview/${encodeUrlPath6(asset)}`)
81830
81802
  };
81831
81803
  },
81832
81804
  [fileTree, projectId, importedFontAssetsRef]
@@ -91341,6 +91313,7 @@ import { memo as memo50, useCallback as useCallback171 } from "react";
91341
91313
 
91342
91314
  // src/components/MediaPreview.tsx
91343
91315
  import { useState as useState136 } from "react";
91316
+ import { encodeUrlPath as encodeUrlPath7 } from "@hyperframes/parsers";
91344
91317
  import { jsx as jsx212, jsxs as jsxs166 } from "react/jsx-runtime";
91345
91318
  function MediaErrorPanel({ name, filePath }) {
91346
91319
  return /* @__PURE__ */ jsxs166("div", { className: "flex flex-col items-center justify-center h-full p-4 bg-neutral-950 gap-2", children: [
@@ -91368,7 +91341,7 @@ function MediaErrorPanel({ name, filePath }) {
91368
91341
  ] });
91369
91342
  }
91370
91343
  function MediaPreview({ projectId, filePath }) {
91371
- const serveUrl = buildProjectApiPath(projectId, `/preview/${filePath}`);
91344
+ const serveUrl = buildProjectApiPath(projectId, `/preview/${encodeUrlPath7(filePath)}`);
91372
91345
  const name = filePath.split("/").pop() ?? filePath;
91373
91346
  const [failedPath, setFailedPath] = useState136(null);
91374
91347
  const failed2 = failedPath === filePath;