@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.
- package/dist/assets/{hyperframes-player-5Vx2AiUz.js → hyperframes-player-BXSuksFh.js} +3 -3
- package/dist/assets/{index-BAnh7cSQ.js → index-BgOVCqjo.js} +1 -1
- package/dist/assets/{index-P8fgj_Xa.js → index-FgyO_5Kv.js} +1 -1
- package/dist/assets/{index-C0thd4eM.js → index-J7jpL4pj.js} +157 -157
- package/dist/index.html +1 -1
- package/dist/index.js +148 -175
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/MediaPreview.tsx +2 -1
- package/src/components/TimelineToolbar.test.tsx +1 -1
- package/src/components/editor/mediaAudioEdits.test.ts +12 -0
- package/src/components/editor/mediaAudioEdits.ts +5 -5
- package/src/components/editor/propertyPanelFill.tsx +2 -1
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -4
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -4
- package/src/components/editor/useColorGradingController.ts +2 -1
- package/src/hooks/useDomEditCommits.ts +2 -1
- package/src/hooks/useFileManager.ts +2 -1
- package/src/hooks/useFileTree.ts +2 -1
- package/src/hooks/useRenderClipContent.ts +15 -3
- package/src/player/components/clipMenuNormalize.ts +2 -2
- package/src/player/components/clipPeakMap.ts +2 -2
- package/src/player/components/thumbnailUtils.test.ts +33 -0
- package/src/player/components/thumbnailUtils.ts +5 -0
- package/src/player/components/timelineLayout.test.ts +3 -3
- package/src/player/components/timelineZoomInput.test.ts +27 -6
- package/src/player/components/timelineZoomInput.ts +5 -1
- package/src/player/components/useTimelinePlayhead.test.tsx +90 -4
- package/src/player/components/useTimelinePlayhead.ts +18 -3
- package/src/player/components/useTimelineProviderState.tsx +1 -0
- package/src/player/lib/mediaProbe.test.ts +12 -0
- package/src/player/lib/mediaProbe.ts +4 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/utils/sourcePatcher.test.ts +135 -0
- package/src/utils/sourcePatcher.ts +102 -186
- package/src/utils/studioHelpers.test.ts +23 -0
- package/src/utils/studioHelpers.ts +3 -2
- package/src/utils/studioSelectionSnapshot.test.ts +31 -0
- package/src/utils/timelineAssetDrop.test.ts +14 -0
- 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.
|
|
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.
|
|
64
|
-
"@hyperframes/player": "0.8.
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
151
|
-
if (own
|
|
152
|
-
const firstSource = /^\s*<source\b[^>]
|
|
153
|
-
return firstSource
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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,
|
package/src/hooks/useFileTree.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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(
|
|
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
|
|
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:
|
|
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:
|
|
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 +
|
|
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
|
|
138
|
-
requestTimelineZoom(
|
|
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
|
|
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)
|
|
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:
|
|
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({
|
|
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({
|