Skip to content

Commit 4138a56

Browse files
authored
experimental: Support video upload for animations (#5144)
## Description ### NEXT PR, TODO, Image Manager accept issue https://github.com/webstudio-is/webstudio/blob/ab997532c239fcd0329ec017e748729c437200c4/apps/builder/app/builder/shared/image-manager/image-manager.tsx#L102 ### Current PR - [x] Image component can show mp4 files as an image https://video-upload-mn4t9.wstd.work/ https://p-f218c6e2-e776-467d-a97f-00fbaaf98f5a-dot-video.staging.webstudio.is/ - [x] Animations with uploaded images For animation to work "Animation Group" should have at least 1 animation with ranges set. https://video-animation-lw2i6.wstd.work/ https://p-610b2f8d-c4fe-4138-aa40-1339a958fc2e-dot-video.staging.webstudio.is/ Not tested but should work with following video files (only mp4 was tested) ``` [".mp4", "video/mp4"], [".webm", "video/webm"], [".mpg", "video/mpeg"], [".mpeg", "video/mpeg"], [".mov", "video/quicktime"], ``` ### How it works Add Animation Group Add 1 animation and set ranges Add Video Animation Set Source property at Video Animation/Video to uploaded video ## Steps for reproduction 1. click button 2. expect xyz ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 0000) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
1 parent 87312f8 commit 4138a56

File tree

14 files changed

+355
-111
lines changed

14 files changed

+355
-111
lines changed

apps/builder/app/builder/features/settings-panel/props-section/props-section.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const renderProperty = (
7676
logic.handleChange({ prop, propName }, propValue);
7777

7878
if (
79-
component === "Image" &&
79+
(component === "Image" || component === "Video") &&
8080
propName === "src" &&
8181
propValue.type === "asset"
8282
) {

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

+35
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

+28-1
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-manager.tsx

+15
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,21 @@ export const ImageManager = ({ accept, onChange }: ImageManagerProps) => {
9999
selectedIndex,
100100
} = useLogic({ onChange, accept });
101101

102+
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/accept
103+
// https://github.com/webstudio-is/webstudio/blob/83503e39b0e1561ea93cfcff92aa35b54c15fefa/packages/sdk-components-react/src/video.ws.ts#L34
104+
//
105+
// To reproduce:
106+
// 1. Create Video Animation
107+
// 2. Open "Video" component properties
108+
// 3. Click "Choose source" button
109+
// 4. See ImageManager allows upload all image and video files
110+
// 5. See ImageManager do not filter video files based on accept parameter
111+
// 6. But see accept = ".mp4,.webm,.mpg,.mpeg,.mov" is right here
112+
console.info(
113+
"@todo accept for video tag should allow only video uploads and filter items",
114+
accept
115+
);
116+
102117
return (
103118
<AssetsShell
104119
searchProps={searchProps}

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

+44-12
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,23 @@ 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+
assetContainer.status === "uploading" ? (
152+
<StyledWebstudioVideo width={64} src={assetContainer.objectURL} />
153+
) : (
154+
<StyledWebstudioImage
155+
assetId={assetContainer.asset.id}
156+
name={assetContainer.asset.name}
157+
objectURL={
158+
assetContainer.status === "uploading"
159+
? assetContainer.objectURL
160+
: undefined
161+
}
162+
alt={description ?? name}
163+
// width={64} used for Image optimizations it should be approximately equal to the width of the picture on the screen in px
164+
width={64}
165+
/>
166+
)}
135167
</Thumbnail>
136168
<Box
137169
css={{
+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
2+
import env from "~/env/env.server";
3+
4+
// this route used as proxy for images to cloudflare endpoint
5+
// https://developers.cloudflare.com/fundamentals/get-started/reference/cdn-cgi-endpoint/
6+
7+
export const loader = async ({ request }: LoaderFunctionArgs) => {
8+
if (env.RESIZE_ORIGIN !== undefined) {
9+
const url = new URL(request.url);
10+
const imgUrl = new URL(env.RESIZE_ORIGIN + url.pathname);
11+
imgUrl.search = url.search;
12+
13+
const response = await fetch(imgUrl.href, {
14+
headers: {
15+
accept: request.headers.get("accept") ?? "",
16+
"accept-encoding": request.headers.get("accept-encoding") ?? "",
17+
},
18+
});
19+
20+
const responseWHeaders = new Response(response.body, response);
21+
22+
if (false === responseWHeaders.ok) {
23+
console.error(
24+
`Request to Image url ${imgUrl.href} responded with status = ${responseWHeaders.status}`
25+
);
26+
}
27+
28+
return responseWHeaders;
29+
}
30+
31+
return new Response("Not supported", {
32+
status: 200,
33+
});
34+
};

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

+22-1
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+
? Number.parseInt(url.searchParams.get("width")!, 10)
70+
: undefined;
71+
const height = url.searchParams.has("height")
72+
? Number.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

+21-1
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

+4-1
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

+7-1
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

+15
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,6 +69,17 @@ 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({
6984
type: type.startsWith("image") ? "image" : "font",
7085
size: data.byteLength,

0 commit comments

Comments
 (0)