Skip to content

Commit 1ba9d58

Browse files
committed
experimental: Support video upload
1 parent a449f66 commit 1ba9d58

File tree

9 files changed

+190
-20
lines changed

9 files changed

+190
-20
lines changed

apps/builder/app/builder/shared/assets/asset-utils.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ import type { Asset, FontAsset, ImageAsset } from "@webstudio-is/sdk";
22
import { nanoid } from "nanoid";
33
import type { UploadingFileData } from "~/shared/nano-states";
44

5+
const videoExtensionToMime = [
6+
[".mp4", "video/mp4"],
7+
[".webm", "video/webm"],
8+
[".mpg", "video/mpeg"],
9+
[".mpeg", "video/mpeg"],
10+
[".mov", "video/quicktime"],
11+
] as const;
12+
513
const extensionToMime = new Map([
614
[".gif", "image/gif"],
715
[".ico", "image/x-icon"],
@@ -10,8 +18,14 @@ const extensionToMime = new Map([
1018
[".png", "image/png"],
1119
[".svg", "image/svg+xml"],
1220
[".webp", "image/webp"],
21+
// Support video formats as images
22+
...videoExtensionToMime,
1323
] as const);
1424

25+
export const isVideoFormat = (format: string) => {
26+
return videoExtensionToMime.some(([extension]) => extension.includes(format));
27+
};
28+
1529
const extensions = [...extensionToMime.keys()];
1630

1731
export const imageMimeTypes = [...extensionToMime.values()];
@@ -102,6 +116,27 @@ export const uploadingFileDataToAsset = (
102116
);
103117
const format = mimeType.split("/")[1];
104118

119+
if (mimeType.startsWith("video/")) {
120+
// Use image type for now
121+
const asset: ImageAsset = {
122+
id: fileData.assetId,
123+
name: fileData.objectURL,
124+
format,
125+
type: "image",
126+
description: "",
127+
createdAt: "",
128+
projectId: "",
129+
size: 0,
130+
131+
meta: {
132+
width: Number.NaN,
133+
height: Number.NaN,
134+
},
135+
};
136+
137+
return asset;
138+
}
139+
105140
if (mimeType.startsWith("image/")) {
106141
const asset: ImageAsset = {
107142
id: fileData.assetId,

apps/builder/app/builder/shared/assets/use-assets.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,24 @@ const $assetContainers = computed(
145145

146146
export type UploadData = ActionData;
147147

148+
const getVideoDimensions = async (file: File) => {
149+
return new Promise<{ width: number; height: number }>((resolve, reject) => {
150+
const url = URL.createObjectURL(file);
151+
const vid = document.createElement("video");
152+
vid.preload = "metadata";
153+
vid.src = url;
154+
155+
vid.onloadedmetadata = () => {
156+
URL.revokeObjectURL(url);
157+
resolve({ width: vid.videoWidth, height: vid.videoHeight });
158+
};
159+
vid.onerror = () => {
160+
URL.revokeObjectURL(url);
161+
reject(new Error("Invalid video file"));
162+
};
163+
});
164+
};
165+
148166
const uploadAsset = async ({
149167
authToken,
150168
projectId,
@@ -198,8 +216,17 @@ const uploadAsset = async ({
198216
headers.set("Content-Type", "application/json");
199217
}
200218

219+
let width = undefined;
220+
let height = undefined;
221+
222+
if (mimeType.startsWith("video/") && fileOrUrl instanceof File) {
223+
const videoSize = await getVideoDimensions(fileOrUrl);
224+
width = videoSize.width;
225+
height = videoSize.height;
226+
}
227+
201228
const uploadResponse = await fetch(
202-
restAssetsUploadPath({ name: metaData.name }),
229+
restAssetsUploadPath({ name: metaData.name, width, height }),
203230
{
204231
method: "POST",
205232
body,

apps/builder/app/builder/shared/image-manager/image-thumbnail.tsx

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Filename } from "./filename";
77
import { Image } from "./image";
88
import brokenImage from "~/shared/images/broken-image-placeholder.svg";
99
import { theme } from "@webstudio-is/design-system";
10+
import { isVideoFormat } from "../assets/asset-utils";
1011

1112
const StyledWebstudioImage = styled(Image, {
1213
position: "absolute",
@@ -34,6 +35,32 @@ const StyledWebstudioImage = styled(Image, {
3435
},
3536
});
3637

38+
const StyledWebstudioVideo = styled("video", {
39+
position: "absolute",
40+
width: "100%",
41+
height: "100%",
42+
objectFit: "contain",
43+
44+
// This is shown only if an image was not loaded and broken
45+
// From the spec:
46+
// - The pseudo-elements generated by ::before and ::after are contained by the element's formatting box,
47+
// and thus don't apply to "replaced" elements such as <img>, or to <br> elements
48+
// Not in spec but supported by all browsers:
49+
// - broken image is not a "replaced" element so this style is applied
50+
"&::after": {
51+
content: "' '",
52+
position: "absolute",
53+
width: "100%",
54+
height: "100%",
55+
left: 0,
56+
top: 0,
57+
backgroundSize: "contain",
58+
backgroundRepeat: "no-repeat",
59+
backgroundPosition: "center",
60+
backgroundImage: `url(${brokenImage})`,
61+
},
62+
});
63+
3764
const ThumbnailContainer = styled(Box, {
3865
position: "relative",
3966
display: "flex",
@@ -120,18 +147,29 @@ export const ImageThumbnail = ({
120147
onChange?.(assetContainer);
121148
}}
122149
>
123-
<StyledWebstudioImage
124-
assetId={assetContainer.asset.id}
125-
name={assetContainer.asset.name}
126-
objectURL={
127-
assetContainer.status === "uploading"
128-
? assetContainer.objectURL
129-
: undefined
130-
}
131-
alt={description ?? name}
132-
// width={64} used for Image optimizations it should be approximately equal to the width of the picture on the screen in px
133-
width={64}
134-
/>
150+
{isVideoFormat(assetContainer.asset.format) ? (
151+
<StyledWebstudioVideo
152+
width={64}
153+
src={
154+
assetContainer.status === "uploading"
155+
? assetContainer.objectURL
156+
: `/cgi/image/${assetContainer.asset.name}?format=raw`
157+
}
158+
/>
159+
) : (
160+
<StyledWebstudioImage
161+
assetId={assetContainer.asset.id}
162+
name={assetContainer.asset.name}
163+
objectURL={
164+
assetContainer.status === "uploading"
165+
? assetContainer.objectURL
166+
: undefined
167+
}
168+
alt={description ?? name}
169+
// width={64} used for Image optimizations it should be approximately equal to the width of the picture on the screen in px
170+
width={64}
171+
/>
172+
)}
135173
</Thumbnail>
136174
<Box
137175
css={{

apps/builder/app/routes/rest.assets_.$name.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export const action = async (
2121

2222
const { request, params } = props;
2323

24+
// await new Promise((resolve) => setTimeout(resolve, 20000));
25+
2426
if (params.name === undefined) {
2527
throw new Error("Name is undefined");
2628
}
@@ -57,12 +59,31 @@ export const action = async (
5759
body = imageRequest.body;
5860
}
5961

62+
const url = new URL(request.url);
63+
const contentTypeArr = contentType?.split(";")[0]?.split("/") ?? [];
64+
65+
const format =
66+
contentTypeArr[0] === "video" ? contentTypeArr[1] : undefined;
67+
68+
const width = url.searchParams.has("width")
69+
? parseInt(url.searchParams.get("width")!, 10)
70+
: undefined;
71+
const height = url.searchParams.has("height")
72+
? parseInt(url.searchParams.get("height")!, 10)
73+
: undefined;
74+
75+
const assetInfoFallback =
76+
height !== undefined && width !== undefined && format !== undefined
77+
? { width, height, format }
78+
: undefined;
79+
6080
const context = await createContext(request);
6181
const asset = await uploadFile(
6282
params.name,
6383
body,
6484
createAssetClient(),
65-
context
85+
context,
86+
assetInfoFallback
6687
);
6788
return {
6889
uploadedAssets: [asset],

apps/builder/app/shared/router-utils/path-utils.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,27 @@ export const restAssetsPath = () => {
123123
return `/rest/assets`;
124124
};
125125

126-
export const restAssetsUploadPath = ({ name }: { name: string }) => {
126+
export const restAssetsUploadPath = ({
127+
name,
128+
width,
129+
height,
130+
}: {
131+
name: string;
132+
width?: number | undefined;
133+
height?: number | undefined;
134+
}) => {
135+
const urlSearchParams = new URLSearchParams();
136+
if (width !== undefined) {
137+
urlSearchParams.set("width", String(width));
138+
}
139+
if (height !== undefined) {
140+
urlSearchParams.set("height", String(height));
141+
}
142+
143+
if (urlSearchParams.size > 0) {
144+
return `/rest/assets/${name}?${urlSearchParams.toString()}`;
145+
}
146+
127147
return `/rest/assets/${name}`;
128148
};
129149

packages/asset-uploader/src/client.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ export type AssetClient = {
44
uploadFile: (
55
name: string,
66
type: string,
7-
data: AsyncIterable<Uint8Array>
7+
data: AsyncIterable<Uint8Array>,
8+
assetInfoFallback:
9+
| { width: number; height: number; format: string }
10+
| undefined
811
) => Promise<AssetData>;
912
};

packages/asset-uploader/src/clients/s3/s3.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ export const createS3Client = (options: S3ClientOptions): AssetClient => {
2626
uriEscapePath: false,
2727
});
2828

29-
const uploadFile: AssetClient["uploadFile"] = async (name, type, data) => {
29+
const uploadFile: AssetClient["uploadFile"] = async (
30+
name,
31+
type,
32+
data,
33+
assetInfoFallback
34+
) => {
3035
return uploadToS3({
3136
signer,
3237
name,
@@ -36,6 +41,7 @@ export const createS3Client = (options: S3ClientOptions): AssetClient => {
3641
endpoint: options.endpoint,
3742
bucket: options.bucket,
3843
acl: options.acl,
44+
assetInfoFallback,
3945
});
4046
};
4147

packages/asset-uploader/src/clients/s3/upload.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const uploadToS3 = async ({
1313
endpoint,
1414
bucket,
1515
acl,
16+
assetInfoFallback,
1617
}: {
1718
signer: SignatureV4;
1819
name: string;
@@ -22,6 +23,9 @@ export const uploadToS3 = async ({
2223
endpoint: string;
2324
bucket: string;
2425
acl?: string;
26+
assetInfoFallback:
27+
| { width: number; height: number; format: string }
28+
| undefined;
2529
}): Promise<AssetData> => {
2630
const limitSize = createSizeLimiter(maxSize, name);
2731

@@ -65,8 +69,20 @@ export const uploadToS3 = async ({
6569
throw Error(`Cannot upload file ${name}`);
6670
}
6771

72+
if (type.startsWith("video") && assetInfoFallback !== undefined) {
73+
return {
74+
size: data.byteLength,
75+
format: assetInfoFallback?.format,
76+
meta: {
77+
width: assetInfoFallback?.width ?? 0,
78+
height: assetInfoFallback?.height ?? 0,
79+
},
80+
};
81+
}
82+
6883
const assetData = await getAssetData({
69-
type: type.startsWith("image") ? "image" : "font",
84+
type:
85+
type.startsWith("image") || type.startsWith("video") ? "image" : "font",
7086
size: data.byteLength,
7187
data: new Uint8Array(data),
7288
name,

packages/asset-uploader/src/upload.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@ export const uploadFile = async (
9696
name: string,
9797
data: ReadableStream<Uint8Array>,
9898
client: AssetClient,
99-
context: AppContext
99+
context: AppContext,
100+
assetInfoFallback:
101+
| { width: number; height: number; format: string }
102+
| undefined
100103
): Promise<Asset> => {
101104
let file = await context.postgrest.client
102105
.from("File")
@@ -117,7 +120,8 @@ export const uploadFile = async (
117120
name,
118121
file.data.format,
119122
// global web streams types do not define ReadableStream as async iterable
120-
data as unknown as AsyncIterable<Uint8Array>
123+
data as unknown as AsyncIterable<Uint8Array>,
124+
assetInfoFallback
121125
);
122126
const { meta, format, size } = assetData;
123127
file = await context.postgrest.client

0 commit comments

Comments
 (0)