@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.
- package/dist/assets/{hyperframes-player-sAsRuV73.js → hyperframes-player-BXSuksFh.js} +3 -3
- package/dist/assets/{index-D3Vij9t1.js → index-BgOVCqjo.js} +1 -1
- package/dist/assets/{index-D5q2_7Gw.js → index-FgyO_5Kv.js} +1 -1
- package/dist/assets/{index-B-wffzRH.js → index-J7jpL4pj.js} +150 -150
- package/dist/index.html +1 -1
- package/dist/index.js +122 -160
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/MediaPreview.tsx +2 -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/lib/mediaProbe.test.ts +12 -0
- package/src/player/lib/mediaProbe.ts +4 -2
- 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 +2 -1
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/
|
|
64
|
-
"@hyperframes/
|
|
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);
|
|
@@ -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
|
|
@@ -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
|
|
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="$&$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&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's.mp4'>", "src")).toBe("assets/it's.mp4");
|
|
779
|
+
expect(readTagAttribute("<video src='assets/it's.mp4'>", "src")).toBe("assets/it's.mp4");
|
|
780
|
+
expect(readTagAttribute('<video src="assets/it&apos;s.mp4">', "src")).toBe(
|
|
781
|
+
"assets/it's.mp4",
|
|
782
|
+
);
|
|
783
|
+
});
|