diff --git a/docs/docs/video.md b/docs/docs/video.md
index 8890c70215..8ee1bd447c 100644
--- a/docs/docs/video.md
+++ b/docs/docs/video.md
@@ -96,7 +96,53 @@ export const useVideoFromAsset = (
## Returned Values
-The `useVideo` hook returns `currentFrame` which contains the current video frame, as well as `currentTime`, and `rotationInDegrees`.
+The `useVideo` hook returns `currentFrame` which contains the current video frame, as well as `currentTime`, `rotation`, and `size`.
+
+## Rotated Video
+
+`rotation` can either be `0`, `90`, `180`, or `270`.
+We provide a `fitbox` function that can help rotating and scaling the video.
+
+```tsx twoslash
+import React from "react";
+import {
+ Canvas,
+ Image,
+ useVideo,
+ fitbox,
+ rect
+} from "@shopify/react-native-skia";
+import { Pressable, useWindowDimensions } from "react-native";
+import { useSharedValue } from "react-native-reanimated";
+
+interface VideoExampleProps {
+ localVideoFile: string;
+}
+
+// The URL needs to be a local path; we usually use expo-asset for that.
+export const VideoExample = ({ localVideoFile }: VideoExampleProps) => {
+ const paused = useSharedValue(false);
+ const { width, height } = useWindowDimensions();
+ const { currentFrame, rotation, size } = useVideo(require(localVideoFile));
+ const src = rect(0, 0, size.width, size.height);
+ const dst = rect(0, 0, width, height)
+ const transform = fitbox("cover", src, dst, rotation);
+ return (
+
+ );
+};
+```
+
## Playback Options
diff --git a/package/android/cpp/rnskia-android/RNSkAndroidVideo.cpp b/package/android/cpp/rnskia-android/RNSkAndroidVideo.cpp
index f561097771..c73bc4d79c 100644
--- a/package/android/cpp/rnskia-android/RNSkAndroidVideo.cpp
+++ b/package/android/cpp/rnskia-android/RNSkAndroidVideo.cpp
@@ -9,6 +9,7 @@
#pragma clang diagnostic ignored "-Wdocumentation"
#include "include/core/SkImage.h"
+#include "include/core/SkSize.h"
#pragma clang diagnostic pop
@@ -102,4 +103,29 @@ float RNSkAndroidVideo::getRotationInDegrees() {
return static_cast(rotation);
}
+SkISize RNSkAndroidVideo::getSize() {
+ JNIEnv *env = facebook::jni::Environment::current();
+ jclass cls = env->GetObjectClass(_jniVideo.get());
+ jmethodID mid =
+ env->GetMethodID(cls, "getSize", "()Landroid/graphics/Point;");
+ if (!mid) {
+ RNSkLogger::logToConsole("getSize method not found");
+ return SkISize::Make(0, 0);
+ }
+ jobject jPoint = env->CallObjectMethod(_jniVideo.get(), mid);
+ jclass pointCls = env->GetObjectClass(jPoint);
+
+ jfieldID xFid = env->GetFieldID(pointCls, "x", "I");
+ jfieldID yFid = env->GetFieldID(pointCls, "y", "I");
+ if (!xFid || !yFid) {
+ RNSkLogger::logToConsole("Point class fields not found");
+ return SkISize::Make(0, 0);
+ }
+
+ jint width = env->GetIntField(jPoint, xFid);
+ jint height = env->GetIntField(jPoint, yFid);
+
+ return SkISize::Make(width, height);
+}
+
} // namespace RNSkia
diff --git a/package/android/cpp/rnskia-android/RNSkAndroidVideo.h b/package/android/cpp/rnskia-android/RNSkAndroidVideo.h
index 3d08728c6a..0d18c47b74 100644
--- a/package/android/cpp/rnskia-android/RNSkAndroidVideo.h
+++ b/package/android/cpp/rnskia-android/RNSkAndroidVideo.h
@@ -32,6 +32,7 @@ class RNSkAndroidVideo : public RNSkVideo {
double framerate() override;
void seek(double timestamp) override;
float getRotationInDegrees() override;
+ SkISize getSize() override;
};
} // namespace RNSkia
diff --git a/package/android/src/main/java/com/shopify/reactnative/skia/RNSkVideo.java b/package/android/src/main/java/com/shopify/reactnative/skia/RNSkVideo.java
index 7646818f03..63233123f2 100644
--- a/package/android/src/main/java/com/shopify/reactnative/skia/RNSkVideo.java
+++ b/package/android/src/main/java/com/shopify/reactnative/skia/RNSkVideo.java
@@ -11,6 +11,7 @@
import android.net.Uri;
import android.os.Build;
import android.view.Surface;
+import android.graphics.Point;
import androidx.annotation.RequiresApi;
@@ -30,6 +31,8 @@ public class RNSkVideo {
private double durationMs;
private double frameRate;
private int rotationDegrees = 0;
+ private int width = 0;
+ private int height = 0;
RNSkVideo(Context context, String localUri) {
this.uri = Uri.parse(localUri);
@@ -57,8 +60,8 @@ private void initializeReader() {
if (format.containsKey(MediaFormat.KEY_ROTATION)) {
rotationDegrees = format.getInteger(MediaFormat.KEY_ROTATION);
}
- int width = format.getInteger(MediaFormat.KEY_WIDTH);
- int height = format.getInteger(MediaFormat.KEY_HEIGHT);
+ width = format.getInteger(MediaFormat.KEY_WIDTH);
+ height = format.getInteger(MediaFormat.KEY_HEIGHT);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
imageReader = ImageReader.newInstance(
width,
@@ -125,6 +128,11 @@ public void seek(long timestamp) {
}
}
+ @DoNotStrip
+ public Point getSize() {
+ return new Point(width, height);
+ }
+
private int selectVideoTrack(MediaExtractor extractor) {
int numTracks = extractor.getTrackCount();
for (int i = 0; i < numTracks; i++) {
diff --git a/package/cpp/api/JsiVideo.h b/package/cpp/api/JsiVideo.h
index 8b6e9bac26..da0dcad8c5 100644
--- a/package/cpp/api/JsiVideo.h
+++ b/package/cpp/api/JsiVideo.h
@@ -53,17 +53,27 @@ class JsiVideo : public JsiSkWrappingSharedPtrHostObject {
return jsi::Value::undefined();
}
- JSI_HOST_FUNCTION(getRotationInDegrees) {
+ JSI_HOST_FUNCTION(rotation) {
auto context = getContext();
auto rot = getObject()->getRotationInDegrees();
return jsi::Value(static_cast(rot));
}
+ JSI_HOST_FUNCTION(size) {
+ auto context = getContext();
+ auto size = getObject()->getSize();
+ auto result = jsi::Object(runtime);
+ result.setProperty(runtime, "width", static_cast(size.width()));
+ result.setProperty(runtime, "height", static_cast(size.height()));
+ return result;
+ }
+
JSI_EXPORT_FUNCTIONS(JSI_EXPORT_FUNC(JsiVideo, nextImage),
JSI_EXPORT_FUNC(JsiVideo, duration),
JSI_EXPORT_FUNC(JsiVideo, framerate),
JSI_EXPORT_FUNC(JsiVideo, seek),
- JSI_EXPORT_FUNC(JsiVideo, getRotationInDegrees),
+ JSI_EXPORT_FUNC(JsiVideo, rotation),
+ JSI_EXPORT_FUNC(JsiVideo, size),
JSI_EXPORT_FUNC(JsiVideo, dispose))
JsiVideo(std::shared_ptr context,
diff --git a/package/cpp/rnskia/RNSkVideo.h b/package/cpp/rnskia/RNSkVideo.h
index fdb4e84285..d02d76359e 100644
--- a/package/cpp/rnskia/RNSkVideo.h
+++ b/package/cpp/rnskia/RNSkVideo.h
@@ -19,6 +19,7 @@ class RNSkVideo {
virtual double framerate() = 0;
virtual void seek(double timestamp) = 0;
virtual float getRotationInDegrees() = 0;
+ virtual SkISize getSize() = 0;
};
} // namespace RNSkia
diff --git a/package/ios/RNSkia-iOS/RNSkiOSVideo.h b/package/ios/RNSkia-iOS/RNSkiOSVideo.h
index 23b02caadb..4344c63c86 100644
--- a/package/ios/RNSkia-iOS/RNSkiOSVideo.h
+++ b/package/ios/RNSkia-iOS/RNSkiOSVideo.h
@@ -9,6 +9,7 @@
#pragma clang diagnostic ignored "-Wdocumentation"
#include "include/core/SkImage.h"
+#include "include/core/SkSize.h"
#pragma clang diagnostic pop
@@ -25,6 +26,8 @@ class RNSkiOSVideo : public RNSkVideo {
RNSkPlatformContext *_context;
double _duration = 0;
double _framerate = 0;
+ float _videoWidth = 0;
+ float _videoHeight = 0;
void setupReader(CMTimeRange timeRange);
NSDictionary *getOutputSettings();
CGAffineTransform _preferredTransform;
@@ -37,6 +40,7 @@ class RNSkiOSVideo : public RNSkVideo {
double framerate() override;
void seek(double timestamp) override;
float getRotationInDegrees() override;
+ SkISize getSize() override;
};
} // namespace RNSkia
diff --git a/package/ios/RNSkia-iOS/RNSkiOSVideo.mm b/package/ios/RNSkia-iOS/RNSkiOSVideo.mm
index 86c6c8616f..b18f199566 100644
--- a/package/ios/RNSkia-iOS/RNSkiOSVideo.mm
+++ b/package/ios/RNSkia-iOS/RNSkiOSVideo.mm
@@ -45,7 +45,9 @@
[[asset tracksWithMediaType:AVMediaTypeVideo] firstObject];
_framerate = videoTrack.nominalFrameRate;
_preferredTransform = videoTrack.preferredTransform;
-
+ CGSize videoSize = videoTrack.naturalSize;
+ _videoWidth = videoSize.width;
+ _videoHeight = videoSize.height;
NSDictionary *outputSettings = getOutputSettings();
AVAssetReaderTrackOutput *trackOutput =
[[AVAssetReaderTrackOutput alloc] initWithTrack:videoTrack
@@ -104,19 +106,15 @@
// Determine the rotation angle in radians
if (transform.a == 0 && transform.b == 1 && transform.c == -1 &&
transform.d == 0) {
- rotationAngle = M_PI_2; // 90 degrees
+ rotationAngle = 90;
} else if (transform.a == 0 && transform.b == -1 && transform.c == 1 &&
transform.d == 0) {
- rotationAngle = -M_PI_2; // -90 degrees
+ rotationAngle = 270;
} else if (transform.a == -1 && transform.b == 0 && transform.c == 0 &&
transform.d == -1) {
- rotationAngle = M_PI; // 180 degrees
- } else if (transform.a == 1 && transform.b == 0 && transform.c == 0 &&
- transform.d == 1) {
- rotationAngle = 0.0; // 0 degrees
+ rotationAngle = 180;
}
- // Convert the rotation angle from radians to degrees
- return rotationAngle * 180 / M_PI;
+ return rotationAngle;
}
void RNSkiOSVideo::seek(double timeInMilliseconds) {
@@ -136,4 +134,8 @@
double RNSkiOSVideo::framerate() { return _framerate; }
+SkISize RNSkiOSVideo::getSize() {
+ return SkISize::Make(_videoWidth, _videoHeight);
+}
+
} // namespace RNSkia
diff --git a/package/src/__tests__/snapshots/drawings/rotated-image.png b/package/src/__tests__/snapshots/drawings/rotated-image.png
new file mode 100644
index 0000000000..07ad737909
Binary files /dev/null and b/package/src/__tests__/snapshots/drawings/rotated-image.png differ
diff --git a/package/src/__tests__/snapshots/drawings/rotated-scaled-image.png b/package/src/__tests__/snapshots/drawings/rotated-scaled-image.png
new file mode 100644
index 0000000000..589a32482b
Binary files /dev/null and b/package/src/__tests__/snapshots/drawings/rotated-scaled-image.png differ
diff --git a/package/src/__tests__/snapshots/drawings/scaled-image.png b/package/src/__tests__/snapshots/drawings/scaled-image.png
new file mode 100644
index 0000000000..9586774cd7
Binary files /dev/null and b/package/src/__tests__/snapshots/drawings/scaled-image.png differ
diff --git a/package/src/__tests__/snapshots/drawings/scaled-image2.png b/package/src/__tests__/snapshots/drawings/scaled-image2.png
new file mode 100644
index 0000000000..1a54ced57d
Binary files /dev/null and b/package/src/__tests__/snapshots/drawings/scaled-image2.png differ
diff --git a/package/src/dom/nodes/datatypes/Fitting.ts b/package/src/dom/nodes/datatypes/Fitting.ts
index 8d8ec86224..bf084d65c0 100644
--- a/package/src/dom/nodes/datatypes/Fitting.ts
+++ b/package/src/dom/nodes/datatypes/Fitting.ts
@@ -7,7 +7,10 @@ export interface Size {
height: number;
}
-export const size = (width = 0, height = 0) => ({ width, height });
+export const size = (width = 0, height = 0) => {
+ "worklet";
+ return { width, height };
+};
export const rect2rect = (
src: SkRect,
@@ -18,6 +21,7 @@ export const rect2rect = (
{ scaleX: number },
{ scaleY: number }
] => {
+ "worklet";
const scaleX = dst.width / src.width;
const scaleY = dst.height / src.height;
const translateX = dst.x - src.x * scaleX;
@@ -25,30 +29,11 @@ export const rect2rect = (
return [{ translateX }, { translateY }, { scaleX }, { scaleY }];
};
-export const fitRects = (
- fit: Fit,
- rect: SkRect,
- { x, y, width, height }: SkRect
-) => {
- const sizes = applyBoxFit(
- fit,
- { width: rect.width, height: rect.height },
- { width, height }
- );
- const src = inscribe(sizes.src, rect);
- const dst = inscribe(sizes.dst, {
- x,
- y,
- width,
- height,
- });
- return { src, dst };
-};
-
const inscribe = (
{ width, height }: Size,
rect: { x: number; y: number; width: number; height: number }
) => {
+ "worklet";
const halfWidthDelta = (rect.width - width) / 2.0;
const halfHeightDelta = (rect.height - height) / 2.0;
return {
@@ -60,6 +45,7 @@ const inscribe = (
};
const applyBoxFit = (fit: Fit, input: Size, output: Size) => {
+ "worklet";
let src = size(),
dst = size();
if (
@@ -122,3 +108,24 @@ const applyBoxFit = (fit: Fit, input: Size, output: Size) => {
}
return { src, dst };
};
+
+export const fitRects = (
+ fit: Fit,
+ rect: SkRect,
+ { x, y, width, height }: SkRect
+) => {
+ "worklet";
+ const sizes = applyBoxFit(
+ fit,
+ { width: rect.width, height: rect.height },
+ { width, height }
+ );
+ const src = inscribe(sizes.src, rect);
+ const dst = inscribe(sizes.dst, {
+ x,
+ y,
+ width,
+ height,
+ });
+ return { src, dst };
+};
diff --git a/package/src/external/reanimated/useVideo.ts b/package/src/external/reanimated/useVideo.ts
index b2971cb3a4..71aac12040 100644
--- a/package/src/external/reanimated/useVideo.ts
+++ b/package/src/external/reanimated/useVideo.ts
@@ -49,10 +49,8 @@ export const useVideo = (
const lastTimestamp = Rea.useSharedValue(-1);
const duration = useMemo(() => video?.duration() ?? 0, [video]);
const framerate = useMemo(() => video?.framerate() ?? 0, [video]);
- const rotationInDegrees = useMemo(
- () => video?.getRotationInDegrees() ?? 0,
- [video]
- );
+ const size = useMemo(() => video?.size() ?? { width: 0, height: 0 }, [video]);
+ const rotation = useMemo(() => video?.rotation() ?? 0, [video]);
Rea.useFrameCallback((frameInfo: FrameInfo) => {
processVideoState(
video,
@@ -78,5 +76,12 @@ export const useVideo = (
};
}, [video]);
- return { currentFrame, currentTime, duration, framerate, rotationInDegrees };
+ return {
+ currentFrame,
+ currentTime,
+ duration,
+ framerate,
+ rotation,
+ size,
+ };
};
diff --git a/package/src/renderer/__tests__/FitBox.spec.tsx b/package/src/renderer/__tests__/FitBox.spec.tsx
index 39a8b76369..569a8dfdb9 100644
--- a/package/src/renderer/__tests__/FitBox.spec.tsx
+++ b/package/src/renderer/__tests__/FitBox.spec.tsx
@@ -1,7 +1,10 @@
+import fs from "fs";
+import path from "path";
+
import React from "react";
import { processResult } from "../../__tests__/setup";
-import { Circle, FitBox, Rect } from "../components";
+import { Circle, FitBox, Rect, Image, Group, fitbox } from "../components";
import { drawOnNode, width, height, importSkia } from "./setup";
@@ -35,4 +38,89 @@ describe("FitBox", () => {
);
processResult(surface, "snapshots/drawings/lightblue-quarter-circle.png");
});
+ it("Should scale the image (1)", () => {
+ const { Skia, rect } = importSkia();
+ const image = Skia.Image.MakeImageFromEncoded(
+ Skia.Data.fromBytes(
+ fs.readFileSync(
+ path.resolve(__dirname, "../../skia/__tests__/assets/box.png")
+ )
+ )
+ )!;
+ const surface = drawOnNode(
+
+
+
+ );
+ processResult(surface, "snapshots/drawings/scaled-image.png");
+ });
+ it("Should scale the image (2)", () => {
+ const { Skia, rect } = importSkia();
+ const image = Skia.Image.MakeImageFromEncoded(
+ Skia.Data.fromBytes(
+ fs.readFileSync(
+ path.resolve(__dirname, "../../skia/__tests__/assets/box.png")
+ )
+ )
+ )!;
+ const screen = rect(256, 128, 256, 512);
+ const surface = drawOnNode(
+
+
+
+
+ );
+ processResult(surface, "snapshots/drawings/scaled-image2.png");
+ });
+ it("Should rotate and scale the image", () => {
+ const { Skia, rect } = importSkia();
+ const image = Skia.Image.MakeImageFromEncoded(
+ Skia.Data.fromBytes(
+ fs.readFileSync(
+ path.resolve(__dirname, "../../skia/__tests__/assets/box2.png")
+ )
+ )
+ )!;
+ const screen = rect(256, 128, 256, 512);
+ const imageRect = rect(0, 0, image.width(), image.height());
+ const transform = fitbox("contain", imageRect, screen, 270);
+ const surface = drawOnNode(
+
+
+
+
+ );
+ processResult(surface, "snapshots/drawings/rotated-scaled-image.png");
+ });
});
diff --git a/package/src/renderer/__tests__/Video.spec.tsx b/package/src/renderer/__tests__/Video.spec.tsx
index eccab935e3..ff2b086d1f 100644
--- a/package/src/renderer/__tests__/Video.spec.tsx
+++ b/package/src/renderer/__tests__/Video.spec.tsx
@@ -34,7 +34,8 @@ describe("Video Player", () => {
duration: jest.fn().mockReturnValue(duration),
seek: jest.fn(),
nextImage: jest.fn().mockReturnValue({} as SkImage),
- getRotationInDegrees: jest.fn().mockReturnValue(0),
+ rotation: jest.fn().mockReturnValue(0),
+ size: jest.fn().mockReturnValue({ width: 0, height: 0 }),
};
options = {
playbackSpeed: 1,
diff --git a/package/src/renderer/__tests__/e2e/Video.spec.tsx b/package/src/renderer/__tests__/e2e/Video.spec.tsx
index ea16167c86..f65764ba6c 100644
--- a/package/src/renderer/__tests__/e2e/Video.spec.tsx
+++ b/package/src/renderer/__tests__/e2e/Video.spec.tsx
@@ -8,9 +8,16 @@ describe("Videos", () => {
return {
duration: video.duration(),
framerate: video.framerate(),
+ width: video.size().width,
+ height: video.size().height,
};
});
- expect(result).toEqual({ duration: 5280, framerate: 25 });
+ expect(result).toEqual({
+ duration: 5280,
+ framerate: 25,
+ height: 720,
+ width: 1280,
+ });
});
// TODO: We need to reanable these tests once we can run them on the UI thread
// itRunsE2eOnly("get frame", async () => {
diff --git a/package/src/renderer/components/shapes/FitBox.tsx b/package/src/renderer/components/shapes/FitBox.tsx
index bcd78ea647..42a1ac39d2 100644
--- a/package/src/renderer/components/shapes/FitBox.tsx
+++ b/package/src/renderer/components/shapes/FitBox.tsx
@@ -3,7 +3,7 @@ import React, { useMemo } from "react";
import type { Fit } from "../../../dom/nodes";
import { fitRects, rect2rect } from "../../../dom/nodes";
-import type { SkRect } from "../../../skia/types";
+import type { SkRect, Transforms3d } from "../../../skia/types";
import { Group } from "../Group";
interface FitProps {
@@ -13,9 +13,43 @@ interface FitProps {
children: ReactNode | ReactNode[];
}
-export const fitbox = (fit: Fit, src: SkRect, dst: SkRect) => {
- const rects = fitRects(fit, src, dst);
- return rect2rect(rects.src, rects.dst);
+export const fitbox = (
+ fit: Fit,
+ src: SkRect,
+ dst: SkRect,
+ rotation: 0 | 90 | 180 | 270 = 0
+) => {
+ "worklet";
+ const rects = fitRects(
+ fit,
+ rotation === 90 || rotation === 270
+ ? { x: 0, y: 0, width: src.height, height: src.width }
+ : src,
+ dst
+ );
+ const result = rect2rect(rects.src, rects.dst);
+ if (rotation === 90) {
+ return [
+ ...result,
+ { translate: [src.height, 0] },
+ { rotate: Math.PI / 2 },
+ ] as Transforms3d;
+ }
+ if (rotation === 180) {
+ return [
+ ...result,
+ { translate: [src.width, src.height] },
+ { rotate: Math.PI },
+ ] as Transforms3d;
+ }
+ if (rotation === 270) {
+ return [
+ ...result,
+ { translate: [0, src.width] },
+ { rotate: -Math.PI / 2 },
+ ] as Transforms3d;
+ }
+ return result;
};
export const FitBox = ({ fit = "contain", src, dst, children }: FitProps) => {
diff --git a/package/src/skia/__tests__/assets/box.png b/package/src/skia/__tests__/assets/box.png
new file mode 100644
index 0000000000..9ea1d64c0c
Binary files /dev/null and b/package/src/skia/__tests__/assets/box.png differ
diff --git a/package/src/skia/__tests__/assets/box2.png b/package/src/skia/__tests__/assets/box2.png
new file mode 100644
index 0000000000..07d55fa35c
Binary files /dev/null and b/package/src/skia/__tests__/assets/box2.png differ
diff --git a/package/src/skia/core/Matrix.ts b/package/src/skia/core/Matrix.ts
index a8e0646829..453c671561 100644
--- a/package/src/skia/core/Matrix.ts
+++ b/package/src/skia/core/Matrix.ts
@@ -2,5 +2,7 @@ import { Skia } from "../Skia";
import type { Transforms3d } from "../types";
import { processTransform } from "../types";
-export const processTransform2d = (transforms: Transforms3d) =>
- processTransform(Skia.Matrix(), transforms);
+export const processTransform2d = (transforms: Transforms3d) => {
+ "worklet";
+ return processTransform(Skia.Matrix(), transforms);
+};
diff --git a/package/src/skia/types/Matrix.ts b/package/src/skia/types/Matrix.ts
index 37e75d9c28..353c3894ec 100644
--- a/package/src/skia/types/Matrix.ts
+++ b/package/src/skia/types/Matrix.ts
@@ -30,6 +30,7 @@ export const processTransform = (
m: T,
transforms: Transforms3d
) => {
+ "worklet";
const m3 = processTransform3d(transforms);
m.concat(m3);
return m;
diff --git a/package/src/skia/types/Video/Video.ts b/package/src/skia/types/Video/Video.ts
index fbe8ab8527..7175de8cd8 100644
--- a/package/src/skia/types/Video/Video.ts
+++ b/package/src/skia/types/Video/Video.ts
@@ -1,10 +1,13 @@
import type { SkImage } from "../Image";
import type { SkJSIInstance } from "../JsiInstance";
+export type VideoRotation = 0 | 90 | 180 | 270;
+
export interface Video extends SkJSIInstance<"Video"> {
duration(): number;
framerate(): number;
nextImage(): SkImage | null;
seek(time: number): void;
- getRotationInDegrees(): number;
+ rotation(): VideoRotation;
+ size(): { width: number; height: number };
}