@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.141",
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/core": "0.8.141",
64
- "@hyperframes/player": "0.8.141",
65
- "@hyperframes/parsers": "0.8.141",
66
- "@hyperframes/sdk": "0.8.141",
67
- "@hyperframes/shader-transitions": "0.8.141",
68
- "@hyperframes/studio-server": "0.8.141"
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.141"
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);
@@ -271,7 +271,7 @@ describe("TimelineToolbar Fit", () => {
271
271
  timelineFitPps: 10,
272
272
  timelinePps: 10,
273
273
  });
274
- return registerTimelineZoomViewport({ scroll, contentOrigin: 32 });
274
+ return registerTimelineZoomViewport({ scroll, contentOrigin: 32, publishScroll: () => {} });
275
275
  }
276
276
 
277
277
  it("moves the slider and its readout with a zoom while it is previewed", () => {
@@ -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
@@ -27,13 +27,13 @@ import { resolveInsertRow } from "./timelineCollision";
27
27
  import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
28
28
 
29
29
  describe("horizontal timeline window", () => {
30
- it("adds the shared half-viewport overscan on each side and clamps to duration", () => {
30
+ it("adds the shared quarter-viewport overscan on each side and clamps to duration", () => {
31
31
  expect(getTimelineRenderTimeRange({ scrollLeft: 300, clientWidth: 500 }, 100, 200, 20)).toEqual(
32
- { start: 0, end: 8.5 },
32
+ { start: 0, end: 7.25 },
33
33
  );
34
34
  expect(
35
35
  getTimelineRenderTimeRange({ scrollLeft: 1_900, clientWidth: 500 }, 100, 200, 20),
36
- ).toEqual({ start: 14.5, end: 20 });
36
+ ).toEqual({ start: 15.75, end: 20 });
37
37
  });
38
38
 
39
39
  it("generates globally aligned ticks directly inside the bounded window", () => {
@@ -49,6 +49,7 @@ afterEach(() => {
49
49
  });
50
50
 
51
51
  let unregisterViewport = () => {};
52
+ const publishScroll = vi.fn();
52
53
 
53
54
  /** A 1080px timeline viewport with 32px of track headers, holding one scaled row. */
54
55
  function viewport(scrollLeft = 0, scrollWidth = 20_000) {
@@ -60,7 +61,7 @@ function viewport(scrollLeft = 0, scrollWidth = 20_000) {
60
61
  });
61
62
  const row = scroll.appendChild(document.createElement("div"));
62
63
  row.setAttribute("data-timeline-zoom-scale", "");
63
- unregisterViewport = registerTimelineZoomViewport({ scroll, contentOrigin: 32 });
64
+ unregisterViewport = registerTimelineZoomViewport({ scroll, contentOrigin: 32, publishScroll });
64
65
  return { scroll, row };
65
66
  }
66
67
 
@@ -120,7 +121,7 @@ describe("requestTimelineZoom", () => {
120
121
  it("lays a zoom-out about the left edge out at once, before it shows unmounted time", () => {
121
122
  usePlayerStore.setState({ duration: 1000 });
122
123
  viewport();
123
- // Mounted to (1080 - 32 + 540) / 10 = 158.8 s; at 5 px/s the view reaches 209.6 s.
124
+ // Mounted to (1080 - 32 + 270) / 10 = 131.8 s; at 5 px/s the view reaches 209.6 s.
124
125
  requestTimelineZoom(50, { time: 0, x: 32 });
125
126
  vi.advanceTimersToNextFrame();
126
127
  expect(usePlayerStore.getState().timelinePps).toBe(5);
@@ -134,14 +135,14 @@ describe("requestTimelineZoom", () => {
134
135
  timelinePps: 100,
135
136
  });
136
137
  viewport(5000);
137
- // Mounted 44.28..65.88 s; at 60 px/s about 55.24 s the view shows 46.5..63.97 s.
138
- requestTimelineZoom(600, { time: 55.24, x: 556 });
138
+ // Mounted 46.98..63.18 s; at 70 px/s about 55.24 s the view shows 47.75..62.73 s.
139
+ requestTimelineZoom(700, { time: 55.24, x: 556 });
139
140
  vi.advanceTimersToNextFrame();
140
141
  expect(usePlayerStore.getState().timelinePps).toBe(100);
141
142
  });
142
143
 
143
144
  it("lays out a zoom-out before it shows past the window ruler ticks are drawn in", () => {
144
- // 50 s of clips in content 1996 s wide: ticks are drawn to 157 s, a view and a half in.
145
+ // 50 s of clips in content 1996 s wide: ticks are drawn to 131.8 s, a view and a quarter in.
145
146
  usePlayerStore.setState({ duration: 50 });
146
147
  viewport();
147
148
  requestTimelineZoom(60, { time: 0, x: 32 });
@@ -230,7 +231,7 @@ describe("requestTimelineZoom", () => {
230
231
  const { scroll } = viewport();
231
232
  requestTimelineZoom(150);
232
233
  unregisterViewport();
233
- unregisterViewport = registerTimelineZoomViewport({ scroll, contentOrigin: 32 });
234
+ unregisterViewport = registerTimelineZoomViewport({ scroll, contentOrigin: 32, publishScroll });
234
235
  await Promise.resolve();
235
236
  vi.advanceTimersByTime(200);
236
237
  expect(usePlayerStore.getState().timelinePps).toBe(15);
@@ -240,6 +241,7 @@ describe("requestTimelineZoom", () => {
240
241
  const unregisterOlder = registerTimelineZoomViewport({
241
242
  scroll: document.createElement("div"),
242
243
  contentOrigin: 32,
244
+ publishScroll,
243
245
  });
244
246
  viewport();
245
247
  unregisterOlder();
@@ -258,6 +260,25 @@ describe("requestTimelineZoom", () => {
258
260
  });
259
261
 
260
262
  describe("zoomTimelineToRange", () => {
263
+ it("tells the timeline where a pan at the laid-out scale scrolled to", () => {
264
+ usePlayerStore.setState({
265
+ duration: 1000,
266
+ zoomMode: "manual",
267
+ manualZoomPercent: 1000,
268
+ timelineFitPps: 10,
269
+ timelinePps: 100,
270
+ });
271
+ const { scroll } = viewport(0);
272
+ const published: number[] = [];
273
+ publishScroll.mockImplementation((el: HTMLDivElement) => published.push(el.scrollLeft));
274
+ void zoomTimelineToRange(60, 70);
275
+ for (let i = 0; i < 40; i++) vi.advanceTimersToNextFrame();
276
+ publishScroll.mockReset();
277
+ expect(usePlayerStore.getState().timelinePps).toBe(100);
278
+ expect(scroll.scrollLeft).toBeGreaterThan(5000);
279
+ expect(published.at(-1)).toBe(scroll.scrollLeft);
280
+ });
281
+
261
282
  it("fills the width with the range and puts its start at the left margin", () => {
262
283
  viewport();
263
284
  void zoomTimelineToRange(40, 90, { smooth: false });
@@ -21,6 +21,7 @@ export interface TimelineZoomAnchor {
21
21
  export interface TimelineZoomViewport {
22
22
  scroll: HTMLDivElement;
23
23
  contentOrigin: number;
24
+ publishScroll: (scroll: HTMLDivElement) => void;
24
25
  }
25
26
 
26
27
  /** How an eased zoom ended: it reached its range, or a person's zoom or the caller stopped it. */
@@ -209,7 +210,10 @@ function commitPreview() {
209
210
  );
210
211
  }
211
212
  preview = null;
212
- if (Math.abs(view.scroll.scrollLeft - left) >= 0.5) view.scroll.scrollLeft = left;
213
+ if (Math.abs(view.scroll.scrollLeft - left) >= 0.5) {
214
+ view.scroll.scrollLeft = left;
215
+ flushSync(() => view.publishScroll(view.scroll));
216
+ }
213
217
  clearScaled(view.scroll);
214
218
  emitPreview();
215
219
  }
@@ -1,20 +1,23 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import { act, useRef } from "react";
3
+ import { act, useLayoutEffect, useRef } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore";
7
7
  import { useTimelinePlayhead } from "./useTimelinePlayhead";
8
+ import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
9
+ import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
10
+ import { requestTimelineZoom, settleTimelineZoom } from "./timelineZoomInput";
8
11
 
9
12
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
10
13
 
11
14
  const ORIGIN = 32;
12
15
 
13
- function scrollBox(scrollLeft: number) {
16
+ function scrollBox(scrollLeft: number, clientWidth = 800) {
14
17
  const el = document.createElement("div");
15
18
  let left = scrollLeft;
16
19
  Object.defineProperties(el, {
17
- clientWidth: { value: 800 },
20
+ clientWidth: { value: clientWidth },
18
21
  scrollWidth: { value: 20_000 },
19
22
  scrollLeft: { get: () => left, set: (v: number) => (left = v) },
20
23
  });
@@ -27,9 +30,16 @@ interface HarnessProps {
27
30
  scroll: HTMLDivElement;
28
31
  dragging?: boolean;
29
32
  zoomMode?: ZoomMode;
33
+ syncScrollViewport?: (scroll: HTMLDivElement) => void;
30
34
  }
31
35
 
32
- function Harness({ pps: fixedPps, scroll, dragging = false, zoomMode = "manual" }: HarnessProps) {
36
+ function Harness({
37
+ pps: fixedPps,
38
+ scroll,
39
+ dragging = false,
40
+ zoomMode = "manual",
41
+ syncScrollViewport = () => {},
42
+ }: HarnessProps) {
33
43
  const storePps = usePlayerStore((s) => s.timelinePps);
34
44
  const pps = fixedPps ?? storePps;
35
45
  const scrollRef = useRef(scroll);
@@ -37,6 +47,7 @@ function Harness({ pps: fixedPps, scroll, dragging = false, zoomMode = "manual"
37
47
  useTimelinePlayhead({
38
48
  playheadRef: { current: document.createElement("div") },
39
49
  scrollRef,
50
+ syncScrollViewport,
40
51
  ppsRef: { current: pps },
41
52
  durationRef,
42
53
  isDragging: { current: dragging },
@@ -238,6 +249,81 @@ describe("useTimelinePlayhead follow while paused", () => {
238
249
  });
239
250
  });
240
251
 
252
+ describe("useTimelinePlayhead committed viewport", () => {
253
+ function mountViewport(scrollLeft: number) {
254
+ vi.stubGlobal(
255
+ "ResizeObserver",
256
+ class {
257
+ observe() {}
258
+ disconnect() {}
259
+ },
260
+ );
261
+ usePlayerStore.setState({
262
+ zoomMode: "manual",
263
+ manualZoomPercent: 1000,
264
+ timelineFitPps: 10,
265
+ timelinePps: 100,
266
+ });
267
+ const scroll = scrollBox(scrollLeft, 1080);
268
+ const container = document.createElement("div");
269
+ function Probe() {
270
+ const pps = usePlayerStore((s) => s.timelinePps);
271
+ const { viewport, setScrollRef, syncScrollViewport } = useTimelineScrollViewport(
272
+ useRef(scroll),
273
+ [],
274
+ );
275
+ useLayoutEffect(() => setScrollRef(scroll), [setScrollRef]);
276
+ const { renderTimeRange } = useTimelineClipRenderWindow({
277
+ tracks: [],
278
+ viewport,
279
+ pixelsPerSecond: pps,
280
+ contentOrigin: ORIGIN,
281
+ duration: 100,
282
+ });
283
+ return (
284
+ <>
285
+ <Harness scroll={scroll} syncScrollViewport={syncScrollViewport} />
286
+ {renderTimeRange.start <= 59 && renderTimeRange.end >= 59 && <span data-clip="59" />}
287
+ <output>{viewport.scrollLeft}</output>
288
+ </>
289
+ );
290
+ }
291
+ const root = createRoot(container);
292
+ roots.push(root);
293
+ act(() => root.render(<Probe />));
294
+ return { scroll, container };
295
+ }
296
+
297
+ afterEach(() => vi.unstubAllGlobals());
298
+
299
+ it("keeps the visible 59-second clip mounted when a pointer zoom commits", () => {
300
+ const { scroll, container } = mountViewport(5000);
301
+ expect(container.querySelector('[data-clip="59"]')).not.toBeNull();
302
+ act(() => {
303
+ requestTimelineZoom(1090, { time: 55.24, x: 556 });
304
+ settleTimelineZoom();
305
+ });
306
+ expect(scroll.scrollLeft).toBeCloseTo(5497.16);
307
+ expect(container.querySelector('[data-clip="59"]')).not.toBeNull();
308
+ expect(Number(container.querySelector("output")?.textContent)).toBeCloseTo(5497.16);
309
+ });
310
+
311
+ it("renders a pan at the laid-out scale before the browser paints", () => {
312
+ const { scroll, container } = mountViewport(0);
313
+ const actEnvironment = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
314
+ const wasActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
315
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = false;
316
+ try {
317
+ requestTimelineZoom(1000, { time: 60, x: ORIGIN });
318
+ settleTimelineZoom();
319
+ expect(scroll.scrollLeft).toBe(6000);
320
+ expect(container.querySelector("output")?.textContent).toBe("6000");
321
+ } finally {
322
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = wasActEnvironment;
323
+ }
324
+ });
325
+ });
326
+
241
327
  describe("useTimelinePlayhead wheel zoom", () => {
242
328
  beforeEach(() => {
243
329
  vi.useFakeTimers({