Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(📹): Video support on Web #2477

Merged
merged 13 commits into from
Jun 13, 2024
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);
}
}
}
Loading