@hyperframes/studio 0.8.142 → 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 (32) hide show
  1. package/dist/assets/{hyperframes-player-sAsRuV73.js → hyperframes-player-BXSuksFh.js} +3 -3
  2. package/dist/assets/{index-D3Vij9t1.js → index-BgOVCqjo.js} +1 -1
  3. package/dist/assets/{index-D5q2_7Gw.js → index-FgyO_5Kv.js} +1 -1
  4. package/dist/assets/{index-B-wffzRH.js → index-J7jpL4pj.js} +150 -150
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +122 -160
  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/editor/mediaAudioEdits.test.ts +12 -0
  11. package/src/components/editor/mediaAudioEdits.ts +5 -5
  12. package/src/components/editor/propertyPanelFill.tsx +2 -1
  13. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -4
  14. package/src/components/editor/propertyPanelMediaSection.tsx +3 -4
  15. package/src/components/editor/useColorGradingController.ts +2 -1
  16. package/src/hooks/useDomEditCommits.ts +2 -1
  17. package/src/hooks/useFileManager.ts +2 -1
  18. package/src/hooks/useFileTree.ts +2 -1
  19. package/src/hooks/useRenderClipContent.ts +15 -3
  20. package/src/player/components/clipMenuNormalize.ts +2 -2
  21. package/src/player/components/clipPeakMap.ts +2 -2
  22. package/src/player/components/thumbnailUtils.test.ts +33 -0
  23. package/src/player/components/thumbnailUtils.ts +5 -0
  24. package/src/player/lib/mediaProbe.test.ts +12 -0
  25. package/src/player/lib/mediaProbe.ts +4 -2
  26. package/src/utils/sourcePatcher.test.ts +135 -0
  27. package/src/utils/sourcePatcher.ts +102 -186
  28. package/src/utils/studioHelpers.test.ts +23 -0
  29. package/src/utils/studioHelpers.ts +3 -2
  30. package/src/utils/studioSelectionSnapshot.test.ts +31 -0
  31. package/src/utils/timelineAssetDrop.test.ts +14 -0
  32. package/src/utils/timelineAssetDrop.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.142",
3
+ "version": "0.8.143",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/player": "0.8.142",
64
- "@hyperframes/studio-server": "0.8.142",
65
- "@hyperframes/core": "0.8.142",
66
- "@hyperframes/shader-transitions": "0.8.142",
67
- "@hyperframes/sdk": "0.8.142",
68
- "@hyperframes/parsers": "0.8.142"
63
+ "@hyperframes/core": "0.8.143",
64
+ "@hyperframes/player": "0.8.143",
65
+ "@hyperframes/sdk": "0.8.143",
66
+ "@hyperframes/studio-server": "0.8.143",
67
+ "@hyperframes/parsers": "0.8.143",
68
+ "@hyperframes/shader-transitions": "0.8.143"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -82,7 +82,7 @@
82
82
  "vite": "^6.4.2",
83
83
  "vitest": "^4.1.11",
84
84
  "zustand": "^5.0.0",
85
- "@hyperframes/producer": "0.8.142"
85
+ "@hyperframes/producer": "0.8.143"
86
86
  },
87
87
  "peerDependencies": {
88
88
  "react": "19",
@@ -1,6 +1,7 @@
1
1
  import { buildProjectApiPath } from "../utils/projectRouting";
2
2
  import { useState } from "react";
3
3
  import { IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../utils/mediaTypes";
4
+ import { encodeUrlPath } from "@hyperframes/parsers";
4
5
 
5
6
  function MediaErrorPanel({ name, filePath }: { name: string; filePath: string }) {
6
7
  return (
@@ -29,7 +30,7 @@ function MediaErrorPanel({ name, filePath }: { name: string; filePath: string })
29
30
  }
30
31
 
31
32
  export function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) {
32
- const serveUrl = buildProjectApiPath(projectId, `/preview/${filePath}`);
33
+ const serveUrl = buildProjectApiPath(projectId, `/preview/${encodeUrlPath(filePath)}`);
33
34
  const name = filePath.split("/").pop() ?? filePath;
34
35
  // Keyed by path so switching to another file clears a previous failure.
35
36
  const [failedPath, setFailedPath] = useState<string | null>(null);
@@ -239,6 +239,18 @@ describe("buildKeepSoundCutoutEdit", () => {
239
239
  expect(doc.getElementById("clip")?.getAttribute("src")).toBe("assets/cut.webm");
240
240
  });
241
241
 
242
+ it("preserves an apostrophe in the authored source of the linked audio", () => {
243
+ const authored = source.replace('src="assets/talk.mp4"', `src="assets/it's.mp4"`);
244
+ const edit = buildKeepSoundCutoutEdit({
245
+ video: liveVideo(),
246
+ videoId: "clip",
247
+ target: { id: "clip" },
248
+ cutoutSrc: "assets/cut.webm",
249
+ });
250
+ const doc = parse(applyEdit(authored, edit));
251
+ expect(doc.getElementById("clip-audio")?.getAttribute("src")).toBe("assets/it's.mp4");
252
+ });
253
+
242
254
  it("takes the first <source> child when the video has no src attribute", () => {
243
255
  const withSource = source.replace(
244
256
  /<video id="clip"([^>]*?) src="assets\/talk.mp4"([^>]*)><\/video>/,
@@ -14,7 +14,7 @@ import {
14
14
  findTagByTarget,
15
15
  applyPatchByTarget,
16
16
  readAttributeByTarget,
17
- unescapeHtmlAttribute,
17
+ readTagAttribute,
18
18
  type PatchOperation,
19
19
  type PatchTarget,
20
20
  } from "../../utils/sourcePatcher";
@@ -147,10 +147,10 @@ export function formatAttrs(attrs: Array<[string, string]>): string {
147
147
  export function readAuthoredSrc(source: string, target: PatchTarget): string {
148
148
  const tag = findTagByTarget(source, target);
149
149
  if (!tag) return "";
150
- const own = /\bsrc=(["'])([^"']*)\1/.exec(tag.tag);
151
- if (own?.[2]) return unescapeHtmlAttribute(own[2]);
152
- const firstSource = /^\s*<source\b[^>]*?\bsrc=(["'])([^"']*)\1/i.exec(source.slice(tag.end + 1));
153
- return firstSource?.[2] ? unescapeHtmlAttribute(firstSource[2]) : "";
150
+ const own = readTagAttribute(tag.tag, "src");
151
+ if (own) return own;
152
+ const firstSource = /^\s*<source\b[^>]*>/i.exec(source.slice(tag.end + 1));
153
+ return (firstSource && readTagAttribute(firstSource[0], "src")) || "";
154
154
  }
155
155
 
156
156
  export function insertBeforeTarget(source: string, target: PatchTarget, markup: string): string {
@@ -20,6 +20,7 @@ import {
20
20
  } from "./propertyPanelPrimitives";
21
21
  import { ColorField } from "./propertyPanelColor";
22
22
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
23
+ import { encodeUrlPath } from "@hyperframes/parsers";
23
24
 
24
25
  /* ------------------------------------------------------------------ */
25
26
  /* Asset path helpers */
@@ -181,7 +182,7 @@ export function ImageFillField({
181
182
  {selectedAsset && (
182
183
  <div className="overflow-hidden rounded-xl border border-neutral-800 bg-neutral-900/80">
183
184
  <img
184
- src={buildProjectApiPath(projectId, `/preview/${selectedAsset}`)}
185
+ src={buildProjectApiPath(projectId, `/preview/${encodeUrlPath(selectedAsset)}`)}
185
186
  alt={selectedAsset.split("/").pop() ?? selectedAsset}
186
187
  className="h-28 w-full object-contain bg-neutral-950/80"
187
188
  />
@@ -10,7 +10,6 @@ import {
10
10
  formatTimingValue,
11
11
  parseNumericValue,
12
12
  readClipInPoint,
13
- stripQueryAndHash,
14
13
  } from "./propertyPanelHelpers";
15
14
  import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
16
15
  import { FlatToggle } from "./propertyPanelFlatToggle";
@@ -37,6 +36,7 @@ import {
37
36
  import { parseGainInput, parseRateInput, parseSecondsInput } from "./audioInspectorInput";
38
37
  import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
39
38
  import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits";
39
+ import { authoredSrcPath } from "../../player/components/thumbnailUtils";
40
40
 
41
41
  // fallow-ignore-next-line complexity
42
42
  export function FlatMediaSection({
@@ -126,12 +126,11 @@ export function FlatMediaSection({
126
126
  const [createPlate, setCreatePlate] = useState(false);
127
127
  const [quality, setQuality] = useState<"fast" | "balanced" | "best">("balanced");
128
128
 
129
- const absoluteSrc =
130
- projectDir && srcAttr && !srcAttr.startsWith("http") ? `${projectDir}/${srcAttr}` : srcAttr;
131
129
  const projectSrc =
132
130
  srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr)
133
- ? stripQueryAndHash(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr)
131
+ ? authoredSrcPath(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr)
134
132
  : "";
133
+ const absoluteSrc = projectDir && projectSrc ? `${projectDir}/${projectSrc}` : srcAttr;
135
134
  const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc);
136
135
 
137
136
  useEffect(() => {
@@ -11,7 +11,6 @@ import {
11
11
  parseNumericValue,
12
12
  readClipInPoint,
13
13
  RESPONSIVE_GRID,
14
- stripQueryAndHash,
15
14
  } from "./propertyPanelHelpers";
16
15
  import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives";
17
16
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
@@ -26,6 +25,7 @@ import {
26
25
  import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
27
26
  import { useLinkedSpeedCommit, withLinkedPlaybackRate } from "./linkedSpeedEdits";
28
27
  import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits";
28
+ import { authoredSrcPath } from "../../player/components/thumbnailUtils";
29
29
 
30
30
  // fallow-ignore-next-line complexity
31
31
  export function MediaSection({
@@ -98,12 +98,11 @@ export function MediaSection({
98
98
  const [createPlate, setCreatePlate] = useState(false);
99
99
  const [quality, setQuality] = useState<"fast" | "balanced" | "best">("balanced");
100
100
 
101
- const absoluteSrc =
102
- projectDir && srcAttr && !srcAttr.startsWith("http") ? `${projectDir}/${srcAttr}` : srcAttr;
103
101
  const projectSrc =
104
102
  srcAttr && !/^(?:https?:|data:|blob:)/i.test(srcAttr)
105
- ? stripQueryAndHash(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr)
103
+ ? authoredSrcPath(srcAttr.startsWith("./") ? srcAttr.slice(2) : srcAttr)
106
104
  : "";
105
+ const absoluteSrc = projectDir && projectSrc ? `${projectDir}/${projectSrc}` : srcAttr;
107
106
  const canRemoveBackground = Boolean(onRemoveBackground && isVisualMedia && projectSrc);
108
107
  const panelTitle = isImage ? "Image" : isVideo ? "Video" : "Audio";
109
108
 
@@ -30,6 +30,7 @@ import {
30
30
  type ColorGradingPreviewOptions,
31
31
  } from "./useColorGradingPreviews";
32
32
  import { studioApiFetch } from "../../utils/studioApiFetch";
33
+ import { authoredSrcPath } from "../../player/components/thumbnailUtils";
33
34
 
34
35
  export type { ColorGradingPresetPreviews, ColorGradingPreviewOptions };
35
36
 
@@ -97,7 +98,7 @@ function resolveProjectAssetPath(
97
98
  const sourceDir = sourceFile.includes("/")
98
99
  ? sourceFile.slice(0, sourceFile.lastIndexOf("/"))
99
100
  : "";
100
- const parts = `${sourceDir}/${trimmed}`.split("/");
101
+ const parts = `${sourceDir}/${authoredSrcPath(trimmed)}`.split("/");
101
102
  const normalized: string[] = [];
102
103
  for (const part of parts) {
103
104
  if (!part || part === ".") continue;
@@ -23,6 +23,7 @@ import {
23
23
  } from "./useDomEditCommitsHelpers";
24
24
  import type { CutoverResult } from "../utils/sdkCutover";
25
25
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
26
+ import { encodeUrlPath } from "@hyperframes/parsers";
26
27
 
27
28
  export interface UseDomEditCommitsParams {
28
29
  activeCompPath: string | null;
@@ -112,7 +113,7 @@ export function useDomEditCommits({
112
113
  return {
113
114
  family: fontFamilyFromAssetPath(asset),
114
115
  path: asset,
115
- url: buildProjectApiPath(projectId, `/preview/${asset}`),
116
+ url: buildProjectApiPath(projectId, `/preview/${encodeUrlPath(asset)}`),
116
117
  };
117
118
  },
118
119
  [fileTree, projectId, importedFontAssetsRef],
@@ -9,6 +9,7 @@ import { useFileTree } from "./useFileTree";
9
9
  import { useEditorSave } from "./useEditorSave";
10
10
  import { useProjectFileWriter } from "./useProjectFileWriter";
11
11
  import { studioApiFetch } from "../utils/studioApiFetch";
12
+ import { encodeUrlPath } from "@hyperframes/parsers";
12
13
 
13
14
  // ── Types ──
14
15
 
@@ -386,7 +387,7 @@ export function useFileManager({
386
387
  .map((asset) => ({
387
388
  family: fontFamilyFromAssetPath(asset),
388
389
  path: asset,
389
- url: `/api/projects/${encodeURIComponent(pid)}/preview/${asset}`,
390
+ url: `/api/projects/${encodeURIComponent(pid)}/preview/${encodeUrlPath(asset)}`,
390
391
  }));
391
392
  importedFontAssetsRef.current = [
392
393
  ...imported,
@@ -4,6 +4,7 @@ import { FONT_EXT } from "../utils/mediaTypes";
4
4
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
5
5
  import { captureProjectProvenance } from "../components/feedback/projectProvenance";
6
6
  import { studioApiFetch } from "../utils/studioApiFetch";
7
+ import { encodeUrlPath } from "@hyperframes/parsers";
7
8
 
8
9
  interface UseFileTreeOptions {
9
10
  projectId: string | null;
@@ -119,7 +120,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
119
120
  .map((asset) => ({
120
121
  family: fontFamilyFromAssetPath(asset),
121
122
  path: asset,
122
- url: projectId ? buildProjectApiPath(projectId, `/preview/${asset}`) : "",
123
+ url: projectId ? buildProjectApiPath(projectId, `/preview/${encodeUrlPath(asset)}`) : "",
123
124
  })),
124
125
  [assets, projectId],
125
126
  );
@@ -17,7 +17,11 @@ import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipCo
17
17
  import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
18
18
  import { clipPeaksUrl, clipSourceWindow } from "../player/components/clipPeakMap";
19
19
  import { clipHasSound } from "../player/components/clipMenuNormalize";
20
- import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
20
+ import {
21
+ authoredSrcPath,
22
+ encodePreviewPath,
23
+ resolveMediaPreviewUrl,
24
+ } from "../player/components/thumbnailUtils";
21
25
  import { usePlayerStore } from "../player/store/playerStore";
22
26
  import { thumbnailRevisionOf } from "../player/store/thumbnailSlice";
23
27
  import { effectiveThumbnailMode } from "../player/lib/thumbnailPolicy";
@@ -67,7 +71,11 @@ function renderAudioClip(
67
71
  elements: readonly TimelineElement[],
68
72
  labelInset?: number,
69
73
  ): ReactNode {
70
- const audioUrl = resolveMediaPreviewUrl(el.src ?? "", pid, window.location.origin);
74
+ const audioUrl = resolveMediaPreviewUrl(
75
+ authoredSrcPath(el.src ?? ""),
76
+ pid,
77
+ window.location.origin,
78
+ );
71
79
  const srcRelative = resolvePreviewRelative(audioUrl, pid, window.location.origin);
72
80
  // Encode each path segment (spaces, parens, U+202F, unicode) so the URL matches
73
81
  // what the assets panel loads — a raw segment 404s. resolvePreviewRelative
@@ -213,7 +221,11 @@ export function useRenderClipContent({
213
221
  const htmlPreviewEligible = el.duration > 0 && effectiveTimelineDuration > 0;
214
222
 
215
223
  if ((el.tag === "video" || el.tag === "img") && el.src) {
216
- const mediaSrc = resolveMediaPreviewUrl(el.src, pid, window.location.origin);
224
+ const mediaSrc = resolveMediaPreviewUrl(
225
+ authoredSrcPath(el.src),
226
+ pid,
227
+ window.location.origin,
228
+ );
217
229
  // Still images can't be decoded by VideoThumbnail's <video> extractor
218
230
  // (the error event fires and the shimmer never resolves) — render the
219
231
  // image itself as the strip.
@@ -1,6 +1,6 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
2
  import { resolvePreviewRelative } from "../../utils/previewRelativePath";
3
- import { resolveMediaPreviewUrl } from "./thumbnailUtils";
3
+ import { authoredSrcPath, resolveMediaPreviewUrl } from "./thumbnailUtils";
4
4
  import type { TimelineElement } from "../store/timelineElement";
5
5
  import { parseAutomation, VOLUME_TARGET } from "@hyperframes/core/audio-automation";
6
6
  import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
@@ -79,7 +79,7 @@ export async function requestNormalizePlan(
79
79
  ): Promise<NormalizePlan> {
80
80
  const origin = window.location.origin;
81
81
  const relative = resolvePreviewRelative(
82
- resolveMediaPreviewUrl(el.src ?? "", projectId, origin),
82
+ resolveMediaPreviewUrl(authoredSrcPath(el.src ?? ""), projectId, origin),
83
83
  projectId,
84
84
  origin,
85
85
  );
@@ -3,7 +3,7 @@ import { studioApiFetch } from "../../utils/studioApiFetch";
3
3
  import { resolvePreviewRelative } from "../../utils/previewRelativePath";
4
4
  import type { TimelineElement } from "../store/timelineElement";
5
5
  import { clipSourcePeak, isPeakMap, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
6
- import { encodePreviewPath, resolveMediaPreviewUrl } from "./thumbnailUtils";
6
+ import { authoredSrcPath, encodePreviewPath, resolveMediaPreviewUrl } from "./thumbnailUtils";
7
7
 
8
8
  const peakMapRequests = new Map<string, Promise<PeakMap | null>>();
9
9
 
@@ -23,7 +23,7 @@ export function loadPeakMap(url: string): Promise<PeakMap | null> {
23
23
  export function clipPeaksUrl(src: string | undefined, projectId: string): string | undefined {
24
24
  const origin = window.location.origin;
25
25
  const relative = resolvePreviewRelative(
26
- resolveMediaPreviewUrl(src ?? "", projectId, origin),
26
+ resolveMediaPreviewUrl(authoredSrcPath(src ?? ""), projectId, origin),
27
27
  projectId,
28
28
  origin,
29
29
  );
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { buildTimelineAssetInsertHtml } from "../../utils/timelineAssetDrop";
2
3
  import {
4
+ authoredSrcPath,
3
5
  computeThumbnailStrip,
4
6
  encodePreviewPath,
5
7
  resolveMediaPreviewUrl,
@@ -187,3 +189,34 @@ describe("encodePreviewPath", () => {
187
189
  expect(encodePreviewPath("assets/music.mp3")).toBe("assets/music.mp3");
188
190
  });
189
191
  });
192
+
193
+ describe("authoredSrcPath", () => {
194
+ const droppedSrc = (assetPath: string) =>
195
+ /src="([^"]*)"/.exec(
196
+ buildTimelineAssetInsertHtml({
197
+ id: "clip",
198
+ hfId: "hf-clip",
199
+ assetPath,
200
+ kind: "video",
201
+ start: 0,
202
+ duration: 2,
203
+ track: 1,
204
+ zIndex: 1,
205
+ }),
206
+ )![1]!;
207
+
208
+ it.each([
209
+ ["assets/My clip.mp4", "/api/projects/p/preview/assets/My%20clip.mp4"],
210
+ ["assets/café.mp4", "/api/projects/p/preview/assets/caf%C3%A9.mp4"],
211
+ ["assets/50% off #1?.mp4", "/api/projects/p/preview/assets/50%25%20off%20%231%3F.mp4"],
212
+ ])("previews a dropped %j at its own file", (assetPath, url) => {
213
+ expect(resolveMediaPreviewUrl(authoredSrcPath(droppedSrc(assetPath)), "p")).toBe(url);
214
+ });
215
+
216
+ it("drops the query and fragment, and leaves URLs with a scheme alone", () => {
217
+ expect(authoredSrcPath("assets/a%20b.mp4?v=2#t=1")).toBe("assets/a b.mp4");
218
+ expect(authoredSrcPath("assets/100%.png")).toBe("assets/100%.png");
219
+ expect(authoredSrcPath("https://cdn.example/a%20b.mp4")).toBe("https://cdn.example/a%20b.mp4");
220
+ expect(authoredSrcPath("//cdn.example/a%20b.mp4")).toBe("//cdn.example/a%20b.mp4");
221
+ });
222
+ });
@@ -1,3 +1,4 @@
1
+ import { decodedUrlPath } from "@hyperframes/parsers";
1
2
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
3
  import { MAX_VISIBLE_THUMBNAIL_FRAMES } from "../lib/timelineViewportBudgets";
3
4
 
@@ -94,6 +95,10 @@ export function computeThumbnailStrip(
94
95
  return { frameW, frameCount };
95
96
  }
96
97
 
98
+ export function authoredSrcPath(src: string): string {
99
+ return /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(src) ? src : decodedUrlPath(src);
100
+ }
101
+
97
102
  /**
98
103
  * Percent-encode each segment of a composition-relative media path so filenames
99
104
  * containing spaces, parentheses, a U+202F narrow no-break space (the macOS
@@ -89,6 +89,18 @@ describe("media probe registry", () => {
89
89
  expect(getDurationFromMetadata).toHaveBeenCalledTimes(2);
90
90
  });
91
91
 
92
+ it("probes a dropped clip's URL-spelled src at its own file", async () => {
93
+ await probeMissingSourceDurations(
94
+ [{ id: "clip", tag: "video", src: "assets/My%20clip%20%231.mp4" }],
95
+ "project-a",
96
+ vi.fn(),
97
+ );
98
+
99
+ expect(requestedSources).toEqual([
100
+ `${window.location.origin}/api/projects/project-a/preview/assets/My%20clip%20%231.mp4`,
101
+ ]);
102
+ });
103
+
92
104
  it("probes same-origin rooted media through the active project preview", async () => {
93
105
  const apply = vi.fn();
94
106
  await probeMissingSourceDurations(
@@ -1,4 +1,4 @@
1
- import { resolveMediaPreviewUrl } from "../components/thumbnailUtils";
1
+ import { authoredSrcPath, resolveMediaPreviewUrl } from "../components/thumbnailUtils";
2
2
  import { TIMELINE_VIEWPORT_BUDGETS } from "./timelineViewportBudgets";
3
3
 
4
4
  export interface MediaProbeResult {
@@ -87,7 +87,9 @@ function getCachedProbe(url: string): MediaProbeResult | undefined {
87
87
  }
88
88
 
89
89
  function resolveProbeSource(src: string, projectId: string | null): string {
90
- return projectId ? resolveMediaPreviewUrl(src, projectId, window.location.origin) : src;
90
+ return projectId
91
+ ? resolveMediaPreviewUrl(authoredSrcPath(src), projectId, window.location.origin)
92
+ : src;
91
93
  }
92
94
 
93
95
  function evictMetadataOverflow(): void {
@@ -3,11 +3,43 @@ import {
3
3
  applyPatch,
4
4
  applyPatchByTarget,
5
5
  readAttributeByTarget,
6
+ readTagAttribute,
6
7
  readTagSnippetByTarget,
7
8
  type PatchOperation,
8
9
  } from "./sourcePatcher";
9
10
 
10
11
  describe("applyPatchByTarget", () => {
12
+ it("removes all duplicate boolean attributes so unmuting cannot reveal another", () => {
13
+ expect(
14
+ applyPatch(`<video id="clip" muted muted></video>`, "clip", {
15
+ type: "html-attribute",
16
+ property: "muted",
17
+ value: null,
18
+ }),
19
+ ).toBe(`<video id="clip"></video>`);
20
+ });
21
+
22
+ it("locates Unicode custom tags and respects the first duplicate attribute", () => {
23
+ const html = `<my-élement id="real" id="other" data-duration="1"></my-élement>`;
24
+ const op: PatchOperation = { type: "attribute", property: "duration", value: "2" };
25
+ expect(applyPatch(html, "real", op)).toContain('data-duration="2"');
26
+ expect(applyPatch(html, "other", op)).toBe(html);
27
+ });
28
+
29
+ it("reads and patches only the real source outside quoted attribute content", () => {
30
+ const html = `<video id="hero" title="example src='other.mp4'" data-src="lazy.mp4" src="assets/it's.mp4"></video>`;
31
+ expect(readTagAttribute(html, "src")).toBe("assets/it's.mp4");
32
+ const op: PatchOperation = { type: "html-attribute", property: "src", value: "new.mp4" };
33
+ const expected = `<video id="hero" title="example src='other.mp4'" data-src="lazy.mp4" src="new.mp4"></video>`;
34
+ expect(applyPatch(html, "hero", op)).toBe(expected);
35
+ expect(applyPatchByTarget(html, { id: "hero" }, op)).toBe(expected);
36
+ });
37
+
38
+ it("does not locate a target through a prefixed or quoted id", () => {
39
+ const html = `<video data-id="hero" title="id='hero'" src="other.mp4"></video><video id="hero" src="real.mp4"></video>`;
40
+ expect(readTagSnippetByTarget(html, { id: "hero" })).toContain('src="real.mp4"');
41
+ });
42
+
11
43
  it.each(['"', "'"])("patches long and multiline styles quoted with %s", (quote) => {
12
44
  const otherQuote = quote === '"' ? "'" : '"';
13
45
  const op: PatchOperation = { type: "inline-style", property: "opacity", value: "0.5" };
@@ -646,3 +678,106 @@ describe("T3 — hfId targeting (spec for R1)", () => {
646
678
  expect(result).toContain('<h2 class="b">B</h2>');
647
679
  });
648
680
  });
681
+
682
+ describe("file names with special characters", () => {
683
+ const src = (value: string): PatchOperation => ({
684
+ type: "html-attribute",
685
+ property: "src",
686
+ value,
687
+ });
688
+
689
+ it("replaces a double-quoted value that holds an apostrophe instead of adding a second one", () => {
690
+ expect(applyPatch(`<img id="a" src="assets/it's.png">`, "a", src("assets/b.png"))).toBe(
691
+ `<img id="a" src="assets/b.png">`,
692
+ );
693
+ expect(applyPatch(`<img id="a" src='assets/say "hi".png'>`, "a", src("b.png"))).toBe(
694
+ `<img id="a" src="b.png">`,
695
+ );
696
+ });
697
+
698
+ it("writes $ sequences in a value literally", () => {
699
+ expect(applyPatch(`<img id="a" src="old.png">`, "a", src("assets/$100 bill.png"))).toBe(
700
+ `<img id="a" src="assets/$100 bill.png">`,
701
+ );
702
+ expect(applyPatch(`<img id="a">`, "a", src("$&$1.png"))).toBe(
703
+ `<img id="a" src="$&amp;$1.png">`,
704
+ );
705
+ expect(
706
+ applyPatch(`<div id="a" data-src="old">`, "a", {
707
+ type: "attribute",
708
+ property: "src",
709
+ value: "$' and $`",
710
+ }),
711
+ ).toBe(`<div id="a" data-src="$' and $\`">`);
712
+ });
713
+
714
+ it("reads a data attribute whose value holds the other quote kind", () => {
715
+ expect(readAttributeByTarget(`<div id="a" data-title="it's here">`, { id: "a" }, "title")).toBe(
716
+ "it's here",
717
+ );
718
+ });
719
+ });
720
+
721
+ describe("readTagAttribute", () => {
722
+ it("reads both quote kinds and decodes entities without losing the other quote", () => {
723
+ expect(readTagAttribute(`<video src="assets/it's&amp;ours.mp4">`, "src")).toBe(
724
+ "assets/it's&ours.mp4",
725
+ );
726
+ expect(readTagAttribute(`<video src='assets/say "hi".mp4'>`, "src")).toBe(
727
+ 'assets/say "hi".mp4',
728
+ );
729
+ });
730
+
731
+ it("distinguishes an empty attribute from a missing attribute", () => {
732
+ expect(readTagAttribute('<video src="">', "src")).toBe("");
733
+ expect(readTagAttribute("<video>", "src")).toBeUndefined();
734
+ });
735
+ });
736
+
737
+ describe("whole attribute names", () => {
738
+ it.each(["data-src", "x-src", "xml:src", "srcset"])(
739
+ "reads and edits src without matching %s",
740
+ (other) => {
741
+ const tag = `<img id="a" ${other}="assets/other.png" src="assets/original.png">`;
742
+ expect(readTagAttribute(tag, "src")).toBe("assets/original.png");
743
+ for (const patch of [
744
+ (op: PatchOperation) => applyPatch(tag, "a", op),
745
+ (op: PatchOperation) => applyPatchByTarget(tag, { id: "a" }, op),
746
+ ]) {
747
+ expect(patch({ type: "html-attribute", property: "src", value: "assets/new.png" })).toBe(
748
+ `<img id="a" ${other}="assets/other.png" src="assets/new.png">`,
749
+ );
750
+ expect(patch({ type: "html-attribute", property: "src", value: null })).toBe(
751
+ `<img id="a" ${other}="assets/other.png">`,
752
+ );
753
+ }
754
+ expect(readTagAttribute(`<img ${other}="assets/other.png">`, "src")).toBeUndefined();
755
+ },
756
+ );
757
+
758
+ it("does not treat a longer data attribute as a boolean attribute to remove", () => {
759
+ const tag = '<div id="a" data-title-long="keep">';
760
+ expect(
761
+ applyPatchByTarget(tag, { id: "a" }, { type: "attribute", property: "title", value: null }),
762
+ ).toBe(tag);
763
+ });
764
+ });
765
+
766
+ it("removes a boolean attribute without truncating a longer attribute name", () => {
767
+ const tag = '<video id="a" muted-long="keep" muted>';
768
+ expect(
769
+ applyPatchByTarget(
770
+ tag,
771
+ { id: "a" },
772
+ { type: "html-attribute", property: "muted", value: null },
773
+ ),
774
+ ).toBe('<video id="a" muted-long="keep">');
775
+ });
776
+
777
+ it("decodes an authored apostrophe entity once", () => {
778
+ expect(readTagAttribute("<video src='assets/it&apos;s.mp4'>", "src")).toBe("assets/it's.mp4");
779
+ expect(readTagAttribute("<video src='assets/it&#39;s.mp4'>", "src")).toBe("assets/it's.mp4");
780
+ expect(readTagAttribute('<video src="assets/it&amp;apos;s.mp4">', "src")).toBe(
781
+ "assets/it&apos;s.mp4",
782
+ );
783
+ });