@hyperframes/studio 0.4.17 → 0.4.18
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/index-D0VntLIQ.js +115 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/App.tsx +359 -48
- package/src/components/nle/NLELayout.tsx +15 -0
- package/src/components/sidebar/AssetsTab.tsx +7 -0
- package/src/player/components/Timeline.test.ts +73 -0
- package/src/player/components/Timeline.tsx +183 -12
- package/src/utils/timelineAssetDrop.test.ts +80 -0
- package/src/utils/timelineAssetDrop.ts +87 -0
- package/dist/assets/index-JZr8f8y8.js +0 -115
package/dist/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<title>HyperFrames Studio</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-D0VntLIQ.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-kT65pCwW.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.18",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"codemirror": "^6.0.1",
|
|
34
34
|
"motion": "^12.38.0",
|
|
35
|
-
"@hyperframes/core": "0.4.
|
|
36
|
-
"@hyperframes/player": "0.4.
|
|
35
|
+
"@hyperframes/core": "0.4.18",
|
|
36
|
+
"@hyperframes/player": "0.4.18"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/react": "^19.0.0",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"vite": "^6.4.2",
|
|
48
48
|
"vitest": "^3.2.4",
|
|
49
49
|
"zustand": "^5.0.0",
|
|
50
|
-
"@hyperframes/producer": "0.4.
|
|
50
|
+
"@hyperframes/producer": "0.4.18"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"react": "^18.0.0 || ^19.0.0",
|
package/src/App.tsx
CHANGED
|
@@ -13,6 +13,15 @@ import { LintModal } from "./components/LintModal";
|
|
|
13
13
|
import type { LintFinding } from "./components/LintModal";
|
|
14
14
|
import { MediaPreview } from "./components/MediaPreview";
|
|
15
15
|
import { isMediaFile } from "./utils/mediaTypes";
|
|
16
|
+
import {
|
|
17
|
+
buildTimelineAssetId,
|
|
18
|
+
buildTimelineAssetInsertHtml,
|
|
19
|
+
buildTimelineFileDropPlacements,
|
|
20
|
+
getTimelineAssetKind,
|
|
21
|
+
insertTimelineAssetIntoSource,
|
|
22
|
+
resolveTimelineAssetSrc,
|
|
23
|
+
type TimelineAssetKind,
|
|
24
|
+
} from "./utils/timelineAssetDrop";
|
|
16
25
|
import { CaptionOverlay } from "./captions/components/CaptionOverlay";
|
|
17
26
|
import { CaptionPropertyPanel } from "./captions/components/CaptionPropertyPanel";
|
|
18
27
|
import { CaptionTimeline } from "./captions/components/CaptionTimeline";
|
|
@@ -45,6 +54,56 @@ interface AppToast {
|
|
|
45
54
|
tone: "error" | "info";
|
|
46
55
|
}
|
|
47
56
|
|
|
57
|
+
const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
|
|
58
|
+
image: 3,
|
|
59
|
+
video: 5,
|
|
60
|
+
audio: 5,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function collectHtmlIds(source: string): string[] {
|
|
64
|
+
return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? "");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function resolveDroppedAssetDuration(
|
|
68
|
+
projectId: string,
|
|
69
|
+
assetPath: string,
|
|
70
|
+
kind: TimelineAssetKind,
|
|
71
|
+
): Promise<number> {
|
|
72
|
+
if (kind === "image") return DEFAULT_TIMELINE_ASSET_DURATION.image;
|
|
73
|
+
|
|
74
|
+
const media = document.createElement(kind === "video" ? "video" : "audio");
|
|
75
|
+
media.preload = "metadata";
|
|
76
|
+
media.src = `/api/projects/${projectId}/preview/${assetPath}`;
|
|
77
|
+
|
|
78
|
+
const duration = await new Promise<number>((resolve) => {
|
|
79
|
+
const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3000);
|
|
80
|
+
const finalize = (value: number) => {
|
|
81
|
+
window.clearTimeout(timeout);
|
|
82
|
+
resolve(value);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
media.addEventListener(
|
|
86
|
+
"loadedmetadata",
|
|
87
|
+
() => {
|
|
88
|
+
const raw = Number(media.duration);
|
|
89
|
+
finalize(
|
|
90
|
+
Number.isFinite(raw) && raw > 0
|
|
91
|
+
? Math.round(raw * 100) / 100
|
|
92
|
+
: DEFAULT_TIMELINE_ASSET_DURATION[kind],
|
|
93
|
+
);
|
|
94
|
+
},
|
|
95
|
+
{ once: true },
|
|
96
|
+
);
|
|
97
|
+
media.addEventListener("error", () => finalize(DEFAULT_TIMELINE_ASSET_DURATION[kind]), {
|
|
98
|
+
once: true,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
media.src = "";
|
|
103
|
+
media.load();
|
|
104
|
+
return duration;
|
|
105
|
+
}
|
|
106
|
+
|
|
48
107
|
// ── Main App ──
|
|
49
108
|
|
|
50
109
|
export function StudioApp() {
|
|
@@ -717,7 +776,135 @@ export function StudioApp() {
|
|
|
717
776
|
[activeCompPath],
|
|
718
777
|
);
|
|
719
778
|
|
|
720
|
-
|
|
779
|
+
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
|
|
780
|
+
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
|
781
|
+
setAppToast({ message, tone });
|
|
782
|
+
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
|
|
783
|
+
}, []);
|
|
784
|
+
|
|
785
|
+
const handleTimelineElementDelete = useCallback(
|
|
786
|
+
async (element: TimelineElement) => {
|
|
787
|
+
const pid = projectIdRef.current;
|
|
788
|
+
if (!pid) throw new Error("No active project");
|
|
789
|
+
|
|
790
|
+
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
791
|
+
try {
|
|
792
|
+
const response = await fetch(
|
|
793
|
+
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
794
|
+
);
|
|
795
|
+
if (!response.ok) {
|
|
796
|
+
throw new Error(`Failed to read ${targetPath}`);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const data = (await response.json()) as { content?: string };
|
|
800
|
+
const originalContent = data.content;
|
|
801
|
+
if (typeof originalContent !== "string") {
|
|
802
|
+
throw new Error(`Missing file contents for ${targetPath}`);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const patchTarget = element.domId
|
|
806
|
+
? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex }
|
|
807
|
+
: element.selector
|
|
808
|
+
? { selector: element.selector, selectorIndex: element.selectorIndex }
|
|
809
|
+
: null;
|
|
810
|
+
if (!patchTarget) {
|
|
811
|
+
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const resolvedTargetPath = targetPath || "index.html";
|
|
815
|
+
const remainingElements = timelineElements.filter(
|
|
816
|
+
(timelineElement) =>
|
|
817
|
+
(timelineElement.key ?? timelineElement.id) !== (element.key ?? element.id) &&
|
|
818
|
+
(timelineElement.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
|
819
|
+
);
|
|
820
|
+
const trackZIndices = buildTrackZIndexMap(
|
|
821
|
+
remainingElements.map((timelineElement) => timelineElement.track),
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
const removeResponse = await fetch(
|
|
825
|
+
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
|
826
|
+
{
|
|
827
|
+
method: "POST",
|
|
828
|
+
headers: { "Content-Type": "application/json" },
|
|
829
|
+
body: JSON.stringify({ target: patchTarget }),
|
|
830
|
+
},
|
|
831
|
+
);
|
|
832
|
+
if (!removeResponse.ok) {
|
|
833
|
+
throw new Error(`Failed to delete ${element.id} from ${targetPath}`);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const removeData = (await removeResponse.json()) as {
|
|
837
|
+
changed?: boolean;
|
|
838
|
+
content?: string;
|
|
839
|
+
};
|
|
840
|
+
let patchedContent =
|
|
841
|
+
typeof removeData.content === "string" ? removeData.content : originalContent;
|
|
842
|
+
for (const timelineElement of remainingElements) {
|
|
843
|
+
const elementTarget = timelineElement.domId
|
|
844
|
+
? {
|
|
845
|
+
id: timelineElement.domId,
|
|
846
|
+
selector: timelineElement.selector,
|
|
847
|
+
selectorIndex: timelineElement.selectorIndex,
|
|
848
|
+
}
|
|
849
|
+
: timelineElement.selector
|
|
850
|
+
? {
|
|
851
|
+
selector: timelineElement.selector,
|
|
852
|
+
selectorIndex: timelineElement.selectorIndex,
|
|
853
|
+
}
|
|
854
|
+
: null;
|
|
855
|
+
if (!elementTarget) continue;
|
|
856
|
+
const nextZIndex = trackZIndices.get(timelineElement.track);
|
|
857
|
+
if (nextZIndex == null) continue;
|
|
858
|
+
patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
|
|
859
|
+
type: "inline-style",
|
|
860
|
+
property: "z-index",
|
|
861
|
+
value: String(nextZIndex),
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const saveResponse = await fetch(
|
|
866
|
+
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
867
|
+
{
|
|
868
|
+
method: "PUT",
|
|
869
|
+
headers: { "Content-Type": "text/plain" },
|
|
870
|
+
body: patchedContent,
|
|
871
|
+
},
|
|
872
|
+
);
|
|
873
|
+
if (!saveResponse.ok) {
|
|
874
|
+
throw new Error(`Failed to save ${targetPath}`);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (editingPathRef.current === targetPath) {
|
|
878
|
+
setEditingFile({ path: targetPath, content: patchedContent });
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
usePlayerStore
|
|
882
|
+
.getState()
|
|
883
|
+
.setElements(
|
|
884
|
+
timelineElements.filter(
|
|
885
|
+
(timelineElement) =>
|
|
886
|
+
(timelineElement.key ?? timelineElement.id) !== (element.key ?? element.id),
|
|
887
|
+
),
|
|
888
|
+
);
|
|
889
|
+
usePlayerStore.getState().setSelectedElementId(null);
|
|
890
|
+
setRefreshKey((k) => k + 1);
|
|
891
|
+
} catch (error) {
|
|
892
|
+
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
|
|
893
|
+
showToast(message);
|
|
894
|
+
}
|
|
895
|
+
},
|
|
896
|
+
[activeCompPath, showToast, timelineElements],
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
const handleBlockedTimelineEdit = useCallback(
|
|
900
|
+
(_element: TimelineElement) => {
|
|
901
|
+
const now = Date.now();
|
|
902
|
+
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
|
|
903
|
+
lastBlockedTimelineToastAtRef.current = now;
|
|
904
|
+
showToast("This clip can’t be moved or resized from the timeline yet.", "info");
|
|
905
|
+
},
|
|
906
|
+
[showToast],
|
|
907
|
+
);
|
|
721
908
|
|
|
722
909
|
const refreshFileTree = useCallback(async () => {
|
|
723
910
|
const pid = projectIdRef.current;
|
|
@@ -727,6 +914,171 @@ export function StudioApp() {
|
|
|
727
914
|
if (data.files) setFileTree(data.files);
|
|
728
915
|
}, []);
|
|
729
916
|
|
|
917
|
+
const uploadProjectFiles = useCallback(
|
|
918
|
+
async (files: Iterable<File>, dir?: string): Promise<string[]> => {
|
|
919
|
+
const pid = projectIdRef.current;
|
|
920
|
+
const fileList = Array.from(files);
|
|
921
|
+
if (!pid || fileList.length === 0) return [];
|
|
922
|
+
|
|
923
|
+
const formData = new FormData();
|
|
924
|
+
for (const file of fileList) {
|
|
925
|
+
formData.append("file", file);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
|
|
929
|
+
try {
|
|
930
|
+
const res = await fetch(`/api/projects/${pid}/upload${qs}`, {
|
|
931
|
+
method: "POST",
|
|
932
|
+
body: formData,
|
|
933
|
+
});
|
|
934
|
+
if (res.ok) {
|
|
935
|
+
const data = await res.json();
|
|
936
|
+
if (data.skipped?.length) {
|
|
937
|
+
showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
|
|
938
|
+
}
|
|
939
|
+
if (data.invalid?.length) {
|
|
940
|
+
const names = data.invalid.map((entry: { name: string }) => entry.name).join(", ");
|
|
941
|
+
showToast(`Unsupported media skipped: ${names}`);
|
|
942
|
+
}
|
|
943
|
+
await refreshFileTree();
|
|
944
|
+
setRefreshKey((k) => k + 1);
|
|
945
|
+
return Array.isArray(data.files) ? data.files : [];
|
|
946
|
+
} else if (res.status === 413) {
|
|
947
|
+
showToast("Upload rejected: payload too large");
|
|
948
|
+
} else {
|
|
949
|
+
showToast(`Upload failed (${res.status})`);
|
|
950
|
+
}
|
|
951
|
+
} catch {
|
|
952
|
+
showToast("Upload failed: network error");
|
|
953
|
+
}
|
|
954
|
+
return [];
|
|
955
|
+
},
|
|
956
|
+
[refreshFileTree, showToast],
|
|
957
|
+
);
|
|
958
|
+
|
|
959
|
+
const handleTimelineAssetDrop = useCallback(
|
|
960
|
+
async (assetPath: string, placement: Pick<TimelineElement, "start" | "track">) => {
|
|
961
|
+
const pid = projectIdRef.current;
|
|
962
|
+
if (!pid) throw new Error("No active project");
|
|
963
|
+
|
|
964
|
+
const kind = getTimelineAssetKind(assetPath);
|
|
965
|
+
if (!kind) {
|
|
966
|
+
showToast("Only image, video, and audio assets can be dropped onto the timeline.");
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const targetPath = activeCompPath || "index.html";
|
|
971
|
+
try {
|
|
972
|
+
const response = await fetch(
|
|
973
|
+
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
974
|
+
);
|
|
975
|
+
if (!response.ok) {
|
|
976
|
+
throw new Error(`Failed to read ${targetPath}`);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const data = (await response.json()) as { content?: string };
|
|
980
|
+
const originalContent = data.content;
|
|
981
|
+
if (typeof originalContent !== "string") {
|
|
982
|
+
throw new Error(`Missing file contents for ${targetPath}`);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
|
|
986
|
+
const normalizedDuration = Number(
|
|
987
|
+
formatTimelineAttributeNumber(await resolveDroppedAssetDuration(pid, assetPath, kind)),
|
|
988
|
+
);
|
|
989
|
+
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
|
990
|
+
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
|
991
|
+
|
|
992
|
+
const resolvedTargetPath = targetPath || "index.html";
|
|
993
|
+
const relevantElements = timelineElements.filter(
|
|
994
|
+
(timelineElement) =>
|
|
995
|
+
(timelineElement.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
|
996
|
+
);
|
|
997
|
+
const trackZIndices = buildTrackZIndexMap([
|
|
998
|
+
...relevantElements.map((timelineElement) => timelineElement.track),
|
|
999
|
+
placement.track,
|
|
1000
|
+
]);
|
|
1001
|
+
|
|
1002
|
+
let patchedContent = originalContent;
|
|
1003
|
+
for (const timelineElement of relevantElements) {
|
|
1004
|
+
const elementTarget = timelineElement.domId
|
|
1005
|
+
? {
|
|
1006
|
+
id: timelineElement.domId,
|
|
1007
|
+
selector: timelineElement.selector,
|
|
1008
|
+
selectorIndex: timelineElement.selectorIndex,
|
|
1009
|
+
}
|
|
1010
|
+
: timelineElement.selector
|
|
1011
|
+
? {
|
|
1012
|
+
selector: timelineElement.selector,
|
|
1013
|
+
selectorIndex: timelineElement.selectorIndex,
|
|
1014
|
+
}
|
|
1015
|
+
: null;
|
|
1016
|
+
if (!elementTarget) continue;
|
|
1017
|
+
const nextZIndex = trackZIndices.get(timelineElement.track);
|
|
1018
|
+
if (nextZIndex == null) continue;
|
|
1019
|
+
patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
|
|
1020
|
+
type: "inline-style",
|
|
1021
|
+
property: "z-index",
|
|
1022
|
+
value: String(nextZIndex),
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
patchedContent = insertTimelineAssetIntoSource(
|
|
1027
|
+
patchedContent,
|
|
1028
|
+
buildTimelineAssetInsertHtml({
|
|
1029
|
+
id: newId,
|
|
1030
|
+
assetPath: resolvedAssetSrc,
|
|
1031
|
+
kind,
|
|
1032
|
+
start: normalizedStart,
|
|
1033
|
+
duration: normalizedDuration,
|
|
1034
|
+
track: placement.track,
|
|
1035
|
+
zIndex: trackZIndices.get(placement.track) ?? 1,
|
|
1036
|
+
}),
|
|
1037
|
+
);
|
|
1038
|
+
|
|
1039
|
+
const saveResponse = await fetch(
|
|
1040
|
+
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
1041
|
+
{
|
|
1042
|
+
method: "PUT",
|
|
1043
|
+
headers: { "Content-Type": "text/plain" },
|
|
1044
|
+
body: patchedContent,
|
|
1045
|
+
},
|
|
1046
|
+
);
|
|
1047
|
+
if (!saveResponse.ok) {
|
|
1048
|
+
throw new Error(`Failed to save ${targetPath}`);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (editingPathRef.current === targetPath) {
|
|
1052
|
+
setEditingFile({ path: targetPath, content: patchedContent });
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
setRefreshKey((k) => k + 1);
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
const message =
|
|
1058
|
+
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
|
|
1059
|
+
showToast(message);
|
|
1060
|
+
}
|
|
1061
|
+
},
|
|
1062
|
+
[activeCompPath, showToast, timelineElements],
|
|
1063
|
+
);
|
|
1064
|
+
|
|
1065
|
+
const handleTimelineFileDrop = useCallback(
|
|
1066
|
+
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
|
1067
|
+
const uploaded = await uploadProjectFiles(files);
|
|
1068
|
+
if (uploaded.length === 0) return;
|
|
1069
|
+
const placements = buildTimelineFileDropPlacements(
|
|
1070
|
+
placement ?? { start: 0, track: 0 },
|
|
1071
|
+
uploaded.length,
|
|
1072
|
+
);
|
|
1073
|
+
for (const [index, assetPath] of uploaded.entries()) {
|
|
1074
|
+
await handleTimelineAssetDrop(assetPath, placements[index] ?? placements[0]);
|
|
1075
|
+
}
|
|
1076
|
+
},
|
|
1077
|
+
[handleTimelineAssetDrop, uploadProjectFiles],
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1080
|
+
// ── File Management Handlers ──
|
|
1081
|
+
|
|
730
1082
|
const handleCreateFile = useCallback(
|
|
731
1083
|
async (path: string) => {
|
|
732
1084
|
const pid = projectIdRef.current;
|
|
@@ -840,55 +1192,11 @@ export function StudioApp() {
|
|
|
840
1192
|
|
|
841
1193
|
const handleMoveFile = handleRenameFile;
|
|
842
1194
|
|
|
843
|
-
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
|
|
844
|
-
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
|
845
|
-
setAppToast({ message, tone });
|
|
846
|
-
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
|
|
847
|
-
}, []);
|
|
848
|
-
|
|
849
|
-
const handleBlockedTimelineEdit = useCallback(
|
|
850
|
-
(_element: TimelineElement) => {
|
|
851
|
-
const now = Date.now();
|
|
852
|
-
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
|
|
853
|
-
lastBlockedTimelineToastAtRef.current = now;
|
|
854
|
-
showToast("This clip can’t be moved or resized from the timeline yet.", "info");
|
|
855
|
-
},
|
|
856
|
-
[showToast],
|
|
857
|
-
);
|
|
858
|
-
|
|
859
1195
|
const handleImportFiles = useCallback(
|
|
860
|
-
async (files: FileList, dir?: string) => {
|
|
861
|
-
|
|
862
|
-
if (!pid || files.length === 0) return;
|
|
863
|
-
|
|
864
|
-
const formData = new FormData();
|
|
865
|
-
for (const file of Array.from(files)) {
|
|
866
|
-
formData.append("file", file);
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
|
|
870
|
-
try {
|
|
871
|
-
const res = await fetch(`/api/projects/${pid}/upload${qs}`, {
|
|
872
|
-
method: "POST",
|
|
873
|
-
body: formData,
|
|
874
|
-
});
|
|
875
|
-
if (res.ok) {
|
|
876
|
-
const data = await res.json();
|
|
877
|
-
if (data.skipped?.length) {
|
|
878
|
-
showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
|
|
879
|
-
}
|
|
880
|
-
await refreshFileTree();
|
|
881
|
-
setRefreshKey((k) => k + 1);
|
|
882
|
-
} else if (res.status === 413) {
|
|
883
|
-
showToast("Upload rejected: payload too large");
|
|
884
|
-
} else {
|
|
885
|
-
showToast(`Upload failed (${res.status})`);
|
|
886
|
-
}
|
|
887
|
-
} catch {
|
|
888
|
-
showToast("Upload failed: network error");
|
|
889
|
-
}
|
|
1196
|
+
async (files: FileList | File[], dir?: string) => {
|
|
1197
|
+
void uploadProjectFiles(Array.from(files), dir);
|
|
890
1198
|
},
|
|
891
|
-
[
|
|
1199
|
+
[uploadProjectFiles],
|
|
892
1200
|
);
|
|
893
1201
|
|
|
894
1202
|
const handleLint = useCallback(async () => {
|
|
@@ -1151,6 +1459,9 @@ export function StudioApp() {
|
|
|
1151
1459
|
activeCompositionPath={activeCompPath}
|
|
1152
1460
|
timelineToolbar={timelineToolbar}
|
|
1153
1461
|
renderClipContent={renderClipContent}
|
|
1462
|
+
onDeleteElement={handleTimelineElementDelete}
|
|
1463
|
+
onAssetDrop={handleTimelineAssetDrop}
|
|
1464
|
+
onFileDrop={handleTimelineFileDrop}
|
|
1154
1465
|
onMoveElement={handleTimelineElementMove}
|
|
1155
1466
|
onResizeElement={handleTimelineElementResize}
|
|
1156
1467
|
onBlockedEditAttempt={handleBlockedTimelineEdit}
|
|
@@ -28,6 +28,15 @@ interface NLELayoutProps {
|
|
|
28
28
|
element: TimelineElement,
|
|
29
29
|
style: { clip: string; label: string },
|
|
30
30
|
) => ReactNode;
|
|
31
|
+
onFileDrop?: (
|
|
32
|
+
files: File[],
|
|
33
|
+
placement?: Pick<TimelineElement, "start" | "track">,
|
|
34
|
+
) => Promise<void> | void;
|
|
35
|
+
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
|
36
|
+
onAssetDrop?: (
|
|
37
|
+
assetPath: string,
|
|
38
|
+
placement: Pick<TimelineElement, "start" | "track">,
|
|
39
|
+
) => Promise<void> | void;
|
|
31
40
|
/** Persist timeline move actions back into source HTML */
|
|
32
41
|
onMoveElement?: (
|
|
33
42
|
element: TimelineElement,
|
|
@@ -61,6 +70,9 @@ export const NLELayout = memo(function NLELayout({
|
|
|
61
70
|
onIframeRef,
|
|
62
71
|
onCompositionChange,
|
|
63
72
|
renderClipContent,
|
|
73
|
+
onFileDrop,
|
|
74
|
+
onDeleteElement,
|
|
75
|
+
onAssetDrop,
|
|
64
76
|
onMoveElement,
|
|
65
77
|
onResizeElement,
|
|
66
78
|
onBlockedEditAttempt,
|
|
@@ -393,6 +405,9 @@ export const NLELayout = memo(function NLELayout({
|
|
|
393
405
|
onSeek={seek}
|
|
394
406
|
onDrillDown={handleDrillDown}
|
|
395
407
|
renderClipContent={renderClipContent}
|
|
408
|
+
onFileDrop={onFileDrop}
|
|
409
|
+
onDeleteElement={onDeleteElement}
|
|
410
|
+
onAssetDrop={onAssetDrop}
|
|
396
411
|
onMoveElement={onMoveElement}
|
|
397
412
|
onResizeElement={onResizeElement}
|
|
398
413
|
onBlockedEditAttempt={onBlockedEditAttempt}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo, useState, useCallback, useRef } from "react";
|
|
2
2
|
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
|
3
3
|
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
|
|
4
|
+
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
4
5
|
|
|
5
6
|
interface AssetsTabProps {
|
|
6
7
|
projectId: string;
|
|
@@ -106,7 +107,13 @@ function AssetCard({
|
|
|
106
107
|
return (
|
|
107
108
|
<>
|
|
108
109
|
<div
|
|
110
|
+
draggable
|
|
109
111
|
onClick={() => onCopy(asset)}
|
|
112
|
+
onDragStart={(e) => {
|
|
113
|
+
e.dataTransfer.effectAllowed = "copy";
|
|
114
|
+
e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
|
|
115
|
+
e.dataTransfer.setData("text/plain", asset);
|
|
116
|
+
}}
|
|
110
117
|
onContextMenu={(e) => {
|
|
111
118
|
e.preventDefault();
|
|
112
119
|
setContextMenu({ x: e.clientX, y: e.clientY });
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
generateTicks,
|
|
4
|
+
getDefaultDroppedTrack,
|
|
4
5
|
getTimelineCanvasHeight,
|
|
6
|
+
resolveTimelineAssetDrop,
|
|
5
7
|
getTimelinePlayheadLeft,
|
|
6
8
|
getTimelineScrollLeftForZoomTransition,
|
|
9
|
+
shouldHandleTimelineDeleteKey,
|
|
7
10
|
shouldAutoScrollTimeline,
|
|
8
11
|
} from "./Timeline";
|
|
9
12
|
import { formatTime } from "../lib/time";
|
|
@@ -162,3 +165,73 @@ describe("getTimelineCanvasHeight", () => {
|
|
|
162
165
|
expect(getTimelineCanvasHeight(0)).toBeGreaterThan(24);
|
|
163
166
|
});
|
|
164
167
|
});
|
|
168
|
+
|
|
169
|
+
describe("shouldHandleTimelineDeleteKey", () => {
|
|
170
|
+
it("handles Delete and Backspace when focus is not in an editor", () => {
|
|
171
|
+
expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);
|
|
172
|
+
expect(shouldHandleTimelineDeleteKey({ key: "Backspace" })).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("ignores modifier shortcuts", () => {
|
|
176
|
+
expect(shouldHandleTimelineDeleteKey({ key: "Delete", metaKey: true })).toBe(false);
|
|
177
|
+
expect(shouldHandleTimelineDeleteKey({ key: "Backspace", ctrlKey: true })).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("ignores input and editable targets", () => {
|
|
181
|
+
const input = { tagName: "INPUT", isContentEditable: false };
|
|
182
|
+
const editable = { tagName: "DIV", isContentEditable: true };
|
|
183
|
+
|
|
184
|
+
expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: input })).toBe(false);
|
|
185
|
+
expect(shouldHandleTimelineDeleteKey({ key: "Delete", target: editable })).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("getDefaultDroppedTrack", () => {
|
|
190
|
+
it("defaults to track 0 when there are no rows yet", () => {
|
|
191
|
+
expect(getDefaultDroppedTrack([])).toBe(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("creates a new bottom track when dropped below existing rows", () => {
|
|
195
|
+
expect(getDefaultDroppedTrack([0, 1, 5], 10)).toBe(6);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("resolveTimelineAssetDrop", () => {
|
|
200
|
+
it("maps drop coordinates to a start time and visible track", () => {
|
|
201
|
+
expect(
|
|
202
|
+
resolveTimelineAssetDrop(
|
|
203
|
+
{
|
|
204
|
+
rectLeft: 100,
|
|
205
|
+
rectTop: 200,
|
|
206
|
+
scrollLeft: 0,
|
|
207
|
+
scrollTop: 0,
|
|
208
|
+
pixelsPerSecond: 100,
|
|
209
|
+
duration: 10,
|
|
210
|
+
trackHeight: 72,
|
|
211
|
+
trackOrder: [0, 3, 7],
|
|
212
|
+
},
|
|
213
|
+
432,
|
|
214
|
+
310,
|
|
215
|
+
),
|
|
216
|
+
).toEqual({ start: 3, track: 3 });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("can create a new bottom track when dropped below the last visible row", () => {
|
|
220
|
+
expect(
|
|
221
|
+
resolveTimelineAssetDrop(
|
|
222
|
+
{
|
|
223
|
+
rectLeft: 100,
|
|
224
|
+
rectTop: 200,
|
|
225
|
+
scrollLeft: 0,
|
|
226
|
+
scrollTop: 0,
|
|
227
|
+
pixelsPerSecond: 100,
|
|
228
|
+
duration: 10,
|
|
229
|
+
trackHeight: 72,
|
|
230
|
+
trackOrder: [0, 3, 7],
|
|
231
|
+
},
|
|
232
|
+
250,
|
|
233
|
+
600,
|
|
234
|
+
),
|
|
235
|
+
).toEqual({ start: 1.18, track: 8 });
|
|
236
|
+
});
|
|
237
|
+
});
|