Skip to content

Commit

Permalink
fix(📹): Video support on Web (#2477)
Browse files Browse the repository at this point in the history
  • Loading branch information
wcandillon committed Jun 13, 2024
1 parent 524072c commit 81654ce
Show file tree
Hide file tree
Showing 11 changed files with 208 additions and 43 deletions.
4 changes: 3 additions & 1 deletion docs/docs/video.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ sidebar_label: Video
slug: /video
---

React Native Skia provides a way to load video frames as images, enabling rich multimedia experiences within your applications. A video frame can be used anywhere a Skia image is accepted: `Image`, `ImageShader`, and `Atlas`.
React Native Skia provides a way to load video frames as images, enabling rich multimedia experiences within your applications.
A video frame can be used anywhere a Skia image is accepted: `Image`, `ImageShader`, and `Atlas`.
Videos are also supported on Web.

### Requirements

Expand Down
41 changes: 10 additions & 31 deletions package/src/external/reanimated/useVideo.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
import {
type SharedValue,
type FrameInfo,
createWorkletRuntime,
runOnJS,
runOnRuntime,
} from "react-native-reanimated";
import { useEffect, useMemo, useState } from "react";
import type { SharedValue, FrameInfo } from "react-native-reanimated";
import { useEffect, useMemo } from "react";

import { Skia } from "../../skia/Skia";
import type { SkImage, Video } from "../../skia/types";
import { Platform } from "../../Platform";

import Rea from "./ReanimatedProxy";
import { useVideoLoading } from "./useVideoLoading";

type Animated<T> = SharedValue<T> | T;

Expand All @@ -28,7 +22,6 @@ const copyFrameOnAndroid = (currentFrame: SharedValue<SkImage | null>) => {
if (Platform.OS === "android") {
const tex = currentFrame.value;
if (tex) {
console.log("Copying frame on Android");
currentFrame.value = tex.makeNonTextureImage();
tex.dispose();
}
Expand Down Expand Up @@ -70,25 +63,6 @@ const disposeVideo = (video: Video | null) => {
video?.dispose();
};

const runtime = createWorkletRuntime("video-metadata-runtime");

type VideoSource = string | null;

const useVideoLoading = (source: VideoSource) => {
const [video, setVideo] = useState<Video | null>(null);
const cb = (src: string) => {
"worklet";
const vid = Skia.Video(src);
runOnJS(setVideo)(vid);
};
useEffect(() => {
if (source) {
runOnRuntime(runtime, cb)(source);
}
}, [source]);
return video;
};

export const useVideo = (
source: string | null,
userOptions?: Partial<PlaybackOptions>
Expand All @@ -102,7 +76,10 @@ export const useVideo = (
const currentTime = Rea.useSharedValue(0);
const lastTimestamp = Rea.useSharedValue(-1);
const duration = useMemo(() => video?.duration() ?? 0, [video]);
const framerate = useMemo(() => video?.framerate() ?? 0, [video]);
const framerate = useMemo(
() => (Platform.OS === "web" ? -1 : video?.framerate() ?? 0),
[video]
);
const size = useMemo(() => video?.size() ?? { width: 0, height: 0 }, [video]);
const rotation = useMemo(() => video?.rotation() ?? 0, [video]);
const frameDuration = 1000 / framerate;
Expand Down Expand Up @@ -155,7 +132,9 @@ export const useVideo = (
currentTime.value = seek.value;
lastTimestamp.value = currentTimestamp;
}
if (delta >= currentFrameDuration && !isOver) {
// On Web the framerate is uknown.
// This could be optimized by using requestVideoFrameCallback (Chrome only)
if ((delta >= currentFrameDuration && !isOver) || Platform.OS === "web") {
setFrame(video, currentFrame);
currentTime.value += delta;
lastTimestamp.value = currentTimestamp;
Expand Down
28 changes: 28 additions & 0 deletions package/src/external/reanimated/useVideoLoading.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useEffect, useState } from "react";
import {
createWorkletRuntime,
runOnJS,
runOnRuntime,
} from "react-native-reanimated";

import type { Video } from "../../skia/types";
import { Skia } from "../../skia";

const runtime = createWorkletRuntime("video-metadata-runtime");

type VideoSource = string | null;

export const useVideoLoading = (source: VideoSource) => {
const [video, setVideo] = useState<Video | null>(null);
const cb = (src: string) => {
"worklet";
const vid = Skia.Video(src) as Video;
runOnJS(setVideo)(vid);
};
useEffect(() => {
if (source) {
runOnRuntime(runtime, cb)(source);
}
}, [source]);
return video;
};
17 changes: 17 additions & 0 deletions package/src/external/reanimated/useVideoLoading.web.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useEffect, useState } from "react";

import type { Video } from "../../skia/types";
import { Skia } from "../../skia";

type VideoSource = string | null;

export const useVideoLoading = (source: VideoSource) => {
const [video, setVideo] = useState<Video | null>(null);
useEffect(() => {
if (source) {
const vid = Skia.Video(source) as Promise<Video>;
vid.then((v) => setVideo(v));
}
}, [source]);
return video;
};
5 changes: 3 additions & 2 deletions package/src/renderer/__tests__/e2e/Video.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { itRunsE2eOnly } from "../../../__tests__/setup";
import type { Video } from "../../../skia/types";
import { surface } from "../setup";

describe("Videos", () => {
itRunsE2eOnly("get video duration and framerate", async () => {
const result = await surface.eval((Skia, ctx) => {
const video = Skia.Video(ctx.localAssets[0]);
const video = Skia.Video(ctx.localAssets[0]) as Video;
return {
duration: video.duration(),
framerate: video.framerate(),
Expand Down Expand Up @@ -57,7 +58,7 @@ describe("Videos", () => {
// });
itRunsE2eOnly("seek non existing frame returns null", async () => {
const result = await surface.eval((Skia, ctx) => {
const video = Skia.Video(ctx.localAssets[0]);
const video = Skia.Video(ctx.localAssets[0]) as Video;
video.seek(100000);
const image = video.nextImage();
return image === null;
Expand Down
12 changes: 10 additions & 2 deletions package/src/skia/types/NativeBuffer/NativeBufferFactory.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import type { SkImage } from "../Image";

export abstract class CanvasKitWebGLBuffer {}

export type NativeBuffer<
T extends bigint | ArrayBuffer | CanvasImageSource | unknown = unknown
T extends
| bigint
| ArrayBuffer
| CanvasImageSource
| CanvasKitWebGLBuffer
| unknown = unknown
> = T;

export type NativeBufferAddr = NativeBuffer<bigint>;
Expand All @@ -20,7 +27,8 @@ export const isNativeBufferWeb = (
buffer instanceof OffscreenCanvas ||
buffer instanceof VideoFrame ||
buffer instanceof HTMLImageElement ||
buffer instanceof SVGImageElement;
buffer instanceof SVGImageElement ||
buffer instanceof CanvasKitWebGLBuffer;

export const isNativeBufferNode = (
buffer: NativeBuffer
Expand Down
2 changes: 1 addition & 1 deletion package/src/skia/types/Skia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,6 @@ export interface Skia {
TextBlob: TextBlobFactory;
Surface: SurfaceFactory;
ParagraphBuilder: ParagraphBuilderFactory;
Video: (url: string) => Video;
Video: (url: string) => Promise<Video> | Video;
NativeBuffer: NativeBufferFactory;
}
22 changes: 22 additions & 0 deletions package/src/skia/web/CanvasKitWebGLBufferImpl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Surface, TextureSource, Image } from "canvaskit-wasm";

import { CanvasKitWebGLBuffer } from "../types";

export class CanvasKitWebGLBufferImpl extends CanvasKitWebGLBuffer {
public image: Image | null = null;

constructor(public surface: Surface, private source: TextureSource) {
super();
}

toImage() {
if (this.image === null) {
this.image = this.surface.makeImageFromTextureSource(this.source);
}
if (this.image === null) {
throw new Error("Failed to create image from texture source");
}
this.surface.updateTextureFromSource(this.image, this.source);
return this.image;
}
}
19 changes: 16 additions & 3 deletions package/src/skia/web/JsiSkImageFactory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CanvasKit, Image } from "canvaskit-wasm";

import { isNativeBufferWeb } from "../types";
import { CanvasKitWebGLBuffer, isNativeBufferWeb } from "../types";
import type {
SkData,
ImageInfo,
Expand All @@ -13,6 +13,7 @@ import { Host, getEnum } from "./Host";
import { JsiSkImage } from "./JsiSkImage";
import { JsiSkData } from "./JsiSkData";
import type { JsiSkSurface } from "./JsiSkSurface";
import type { CanvasKitWebGLBufferImpl } from "./CanvasKitWebGLBufferImpl";

export class JsiSkImageFactory extends Host implements ImageFactory {
constructor(CanvasKit: CanvasKit) {
Expand All @@ -35,8 +36,20 @@ export class JsiSkImageFactory extends Host implements ImageFactory {
throw new Error("Invalid NativeBuffer");
}
if (!surface) {
// TODO: this is way to slow
const img = this.CanvasKit.MakeImageFromCanvasImageSource(buffer);
let img: Image;
if (
buffer instanceof HTMLImageElement ||
buffer instanceof HTMLVideoElement ||
buffer instanceof ImageBitmap
) {
img = this.CanvasKit.MakeLazyImageFromTextureSource(buffer);
} else if (buffer instanceof CanvasKitWebGLBuffer) {
img = (
buffer as CanvasKitWebGLBuffer as CanvasKitWebGLBufferImpl
).toImage();
} else {
img = this.CanvasKit.MakeImageFromCanvasImageSource(buffer);
}
return new JsiSkImage(this.CanvasKit, img);
} else if (!image) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
5 changes: 2 additions & 3 deletions package/src/skia/web/JsiSkia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { JsiSkFontMgrFactory } from "./JsiSkFontMgrFactory";
import { JsiSkAnimatedImageFactory } from "./JsiSkAnimatedImageFactory";
import { JsiSkParagraphBuilderFactory } from "./JsiSkParagraphBuilderFactory";
import { JsiSkNativeBufferFactory } from "./JsiSkNativeBufferFactory";
import { createVideo } from "./JsiVideo";

export const JsiSkApi = (CanvasKit: CanvasKit): Skia => ({
Point: (x: number, y: number) =>
Expand Down Expand Up @@ -127,7 +128,5 @@ export const JsiSkApi = (CanvasKit: CanvasKit): Skia => ({
FontMgr: new JsiSkFontMgrFactory(CanvasKit),
ParagraphBuilder: new JsiSkParagraphBuilderFactory(CanvasKit),
NativeBuffer: new JsiSkNativeBufferFactory(CanvasKit),
Video: (_localUri: string) => {
throw new Error("Not implemented on React Native Web");
},
Video: createVideo.bind(null, CanvasKit),
});
96 changes: 96 additions & 0 deletions package/src/skia/web/JsiVideo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { CanvasKit, Surface } from "canvaskit-wasm";

import type { CanvasKitWebGLBuffer, Video, ImageFactory } from "../types";

import { CanvasKitWebGLBufferImpl } from "./CanvasKitWebGLBufferImpl";
import { JsiSkImageFactory } from "./JsiSkImageFactory";

export const createVideo = async (
CanvasKit: CanvasKit,
url: string
): Promise<Video> => {
const video = document.createElement("video");
return new Promise((resolve, reject) => {
video.src = url;
video.style.display = "none";
video.crossOrigin = "anonymous";
video.volume = 0;
video.addEventListener("loadedmetadata", () => {
document.body.appendChild(video);
resolve(new JsiVideo(new JsiSkImageFactory(CanvasKit), video));
});
video.addEventListener("error", () => {
reject(new Error(`Failed to load video from URL: ${url}`));
});
});
};

export class JsiVideo implements Video {
__typename__ = "Video" as const;

private webglBuffer: CanvasKitWebGLBuffer | null = null;

constructor(
private ImageFactory: ImageFactory,
private videoElement: HTMLVideoElement
) {
document.body.appendChild(this.videoElement);
}

duration() {
return this.videoElement.duration * 1000;
}

framerate(): number {
throw new Error("Video.frame is not available on React Native Web");
}

setSurface(surface: Surface) {
// If we have the surface, we can use the WebGL buffer which is slightly faster
// This is because WebGL cannot be shared across contextes.
// This can be removed with WebGPU
this.webglBuffer = new CanvasKitWebGLBufferImpl(surface, this.videoElement);
}

nextImage() {
return this.ImageFactory.MakeImageFromNativeBuffer(
this.webglBuffer ? this.webglBuffer : this.videoElement
);
}

seek(time: number) {
if (isNaN(time)) {
throw new Error(`Invalid time: ${time}`);
}
this.videoElement.currentTime = time / 1000;
}

rotation() {
return 0 as const;
}

size() {
return {
width: this.videoElement.videoWidth,
height: this.videoElement.videoHeight,
};
}

pause() {
this.videoElement.pause();
}

play() {
this.videoElement.play();
}

setVolume(volume: number) {
this.videoElement.volume = volume;
}

dispose() {
if (this.videoElement.parentNode) {
this.videoElement.parentNode.removeChild(this.videoElement);
}
}
}

0 comments on commit 81654ce

Please sign in to comment.