diff --git a/docs/docs/video.md b/docs/docs/video.md index 43ea4d5995..8890c70215 100644 --- a/docs/docs/video.md +++ b/docs/docs/video.md @@ -38,7 +38,7 @@ interface VideoExampleProps { export const VideoExample = ({ localVideoFile }: VideoExampleProps) => { const paused = useSharedValue(false); const { width, height } = useWindowDimensions(); - const video = useVideo( + const { currentFrame } = useVideo( require(localVideoFile), { paused, @@ -52,7 +52,7 @@ export const VideoExample = ({ localVideoFile }: VideoExampleProps) => { { const seek = useSharedValue(null); // Set this value to true to pause the video const paused = useSharedValue(false); - // Contains the current playback time of the video - const currentTime = useSharedValue(0); const { width, height } = useWindowDimensions(); - const video = useVideo( + const {currentFrame, currentTime} = useVideo( require(localVideoFile), { seek, paused, - currentTime, looping: true, playbackSpeed: 1 } @@ -145,7 +144,7 @@ export const VideoExample = ({ localVideoFile }: VideoExampleProps) => { > { const paused = useSharedValue(false); const { width, height } = useWindowDimensions(); - const video = useVideoFromAsset( + const { currentFrame } = useVideoFromAsset( require("../../Tests/assets/BigBuckBunny.mp4"), { paused, @@ -28,7 +28,7 @@ export const Video = () => { { const paused = useSharedValue(false); const { width, height } = useWindowDimensions(); - const video = useVideoFromAsset( + const { currentFrame } = useVideoFromAsset( require("../../Tests/assets/BigBuckBunny.mp4"), { paused, @@ -28,7 +28,7 @@ export const Video = () => { CallDoubleMethod(_jniVideo.get(), mid); } + double RNSkAndroidVideo::framerate() { JNIEnv *env = facebook::jni::Environment::current(); jclass cls = env->GetObjectClass(_jniVideo.get()); @@ -89,4 +90,16 @@ void RNSkAndroidVideo::seek(double timestamp) { env->CallVoidMethod(_jniVideo.get(), mid, static_cast(timestamp)); } +float RNSkAndroidVideo::getRotationInDegrees() { + JNIEnv *env = facebook::jni::Environment::current(); + jclass cls = env->GetObjectClass(_jniVideo.get()); + jmethodID mid = env->GetMethodID(cls, "getRotationDegrees", "()I"); + if (!mid) { + RNSkLogger::logToConsole("getRotationDegrees method not found"); + return 0; + } + auto rotation = env->CallIntMethod(_jniVideo.get(), mid); + return static_cast(rotation); +} + } // namespace RNSkia diff --git a/package/android/cpp/rnskia-android/RNSkAndroidVideo.h b/package/android/cpp/rnskia-android/RNSkAndroidVideo.h index 3af9420b47..3d08728c6a 100644 --- a/package/android/cpp/rnskia-android/RNSkAndroidVideo.h +++ b/package/android/cpp/rnskia-android/RNSkAndroidVideo.h @@ -31,6 +31,7 @@ class RNSkAndroidVideo : public RNSkVideo { double duration() override; double framerate() override; void seek(double timestamp) override; + float getRotationInDegrees() 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 260e6a55f9..7646818f03 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 @@ -29,6 +29,7 @@ public class RNSkVideo { private Surface outputSurface; private double durationMs; private double frameRate; + private int rotationDegrees = 0; RNSkVideo(Context context, String localUri) { this.uri = Uri.parse(localUri); @@ -53,6 +54,9 @@ private void initializeReader() { if (format.containsKey(MediaFormat.KEY_FRAME_RATE)) { frameRate = format.getInteger(MediaFormat.KEY_FRAME_RATE); } + 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); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -87,11 +91,15 @@ public double getDuration() { } @DoNotStrip - public double getFrameRate() { return frameRate; } + @DoNotStrip + public int getRotationDegrees() { + return rotationDegrees; + } + @DoNotStrip public HardwareBuffer nextImage() { if (!decoderOutputAvailable()) { diff --git a/package/cpp/api/JsiVideo.h b/package/cpp/api/JsiVideo.h index 7fa9d6d809..8b6e9bac26 100644 --- a/package/cpp/api/JsiVideo.h +++ b/package/cpp/api/JsiVideo.h @@ -53,10 +53,17 @@ class JsiVideo : public JsiSkWrappingSharedPtrHostObject { return jsi::Value::undefined(); } + JSI_HOST_FUNCTION(getRotationInDegrees) { + auto context = getContext(); + auto rot = getObject()->getRotationInDegrees(); + return jsi::Value(static_cast(rot)); + } + 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, dispose)) JsiVideo(std::shared_ptr context, diff --git a/package/cpp/rnskia/RNSkVideo.h b/package/cpp/rnskia/RNSkVideo.h index 511b561219..fdb4e84285 100644 --- a/package/cpp/rnskia/RNSkVideo.h +++ b/package/cpp/rnskia/RNSkVideo.h @@ -18,6 +18,7 @@ class RNSkVideo { virtual double duration() = 0; virtual double framerate() = 0; virtual void seek(double timestamp) = 0; + virtual float getRotationInDegrees() = 0; }; } // namespace RNSkia diff --git a/package/ios/RNSkia-iOS/RNSkiOSVideo.h b/package/ios/RNSkia-iOS/RNSkiOSVideo.h index 25bd450955..23b02caadb 100644 --- a/package/ios/RNSkia-iOS/RNSkiOSVideo.h +++ b/package/ios/RNSkia-iOS/RNSkiOSVideo.h @@ -27,6 +27,7 @@ class RNSkiOSVideo : public RNSkVideo { double _framerate = 0; void setupReader(CMTimeRange timeRange); NSDictionary *getOutputSettings(); + CGAffineTransform _preferredTransform; public: RNSkiOSVideo(std::string url, RNSkPlatformContext *context); @@ -35,6 +36,7 @@ class RNSkiOSVideo : public RNSkVideo { double duration() override; double framerate() override; void seek(double timestamp) override; + float getRotationInDegrees() override; }; } // namespace RNSkia diff --git a/package/ios/RNSkia-iOS/RNSkiOSVideo.mm b/package/ios/RNSkia-iOS/RNSkiOSVideo.mm index 3f12eb406a..86c6c8616f 100644 --- a/package/ios/RNSkia-iOS/RNSkiOSVideo.mm +++ b/package/ios/RNSkia-iOS/RNSkiOSVideo.mm @@ -1,5 +1,3 @@ -#pragma once - #include #include @@ -46,6 +44,7 @@ AVAssetTrack *videoTrack = [[asset tracksWithMediaType:AVMediaTypeVideo] firstObject]; _framerate = videoTrack.nominalFrameRate; + _preferredTransform = videoTrack.preferredTransform; NSDictionary *outputSettings = getOutputSettings(); AVAssetReaderTrackOutput *trackOutput = @@ -99,6 +98,27 @@ }; } +float RNSkiOSVideo::getRotationInDegrees() { + CGFloat rotationAngle = 0.0; + auto transform = _preferredTransform; + // 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 + } else if (transform.a == 0 && transform.b == -1 && transform.c == 1 && + transform.d == 0) { + rotationAngle = -M_PI_2; // -90 degrees + } 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 + } + // Convert the rotation angle from radians to degrees + return rotationAngle * 180 / M_PI; +} + void RNSkiOSVideo::seek(double timeInMilliseconds) { if (_reader) { [_reader cancelReading]; diff --git a/package/src/external/reanimated/useVideo.ts b/package/src/external/reanimated/useVideo.ts index 05c923a225..b2971cb3a4 100644 --- a/package/src/external/reanimated/useVideo.ts +++ b/package/src/external/reanimated/useVideo.ts @@ -1,26 +1,15 @@ -import { - runOnUI, - useSharedValue, - type FrameInfo, - type SharedValue, -} from "react-native-reanimated"; -import { useCallback, useEffect, useMemo } from "react"; +import { type FrameInfo } from "react-native-reanimated"; +import { useEffect, useMemo } from "react"; import { Skia } from "../../skia/Skia"; -import type { SkImage } from "../../skia/types"; -import { Platform } from "../../Platform"; +import type { SkImage, Video } from "../../skia/types"; import Rea from "./ReanimatedProxy"; - -type Animated = SharedValue | T; - -export interface PlaybackOptions { - playbackSpeed: Animated; - looping: Animated; - paused: Animated; - seek: Animated; - currentTime: Animated; -} +import { + processVideoState, + type Animated, + type PlaybackOptions, +} from "./video"; const defaultOptions = { playbackSpeed: 1, @@ -33,12 +22,17 @@ const defaultOptions = { const useOption = (value: Animated) => { "worklet"; // TODO: only create defaultValue is needed (via makeMutable) - const defaultValue = useSharedValue( + const defaultValue = Rea.useSharedValue( Rea.isSharedValue(value) ? value.value : value ); return Rea.isSharedValue(value) ? value : defaultValue; }; +const disposeVideo = (video: Video | null) => { + "worklet"; + video?.dispose(); +}; + export const useVideo = ( source: string | null, userOptions?: Partial @@ -47,84 +41,42 @@ export const useVideo = ( const isPaused = useOption(userOptions?.paused ?? defaultOptions.paused); const looping = useOption(userOptions?.looping ?? defaultOptions.looping); const seek = useOption(userOptions?.seek ?? defaultOptions.seek); - const currentTime = useOption( - userOptions?.currentTime ?? defaultOptions.currentTime - ); const playbackSpeed = useOption( userOptions?.playbackSpeed ?? defaultOptions.playbackSpeed ); const currentFrame = Rea.useSharedValue(null); + const currentTime = Rea.useSharedValue(0); const lastTimestamp = Rea.useSharedValue(-1); - const startTimestamp = Rea.useSharedValue(-1); - - const framerate = useMemo(() => (video ? video.framerate() : -1), [video]); - const duration = useMemo(() => (video ? video.duration() : -1), [video]); - const frameDuration = useMemo( - () => (framerate > 0 ? 1000 / framerate : -1), - [framerate] + const duration = useMemo(() => video?.duration() ?? 0, [video]); + const framerate = useMemo(() => video?.framerate() ?? 0, [video]); + const rotationInDegrees = useMemo( + () => video?.getRotationInDegrees() ?? 0, + [video] ); - const disposeVideo = useCallback(() => { - "worklet"; - video?.dispose(); - }, [video]); - Rea.useFrameCallback((frameInfo: FrameInfo) => { - if (!video) { - return; - } - if (seek.value !== null) { - video.seek(seek.value); - seek.value = null; - lastTimestamp.value = -1; - startTimestamp.value = -1; - } - if (isPaused.value && lastTimestamp.value !== -1) { - return; - } - const { timestamp } = frameInfo; - - // Initialize start timestamp - if (startTimestamp.value === -1) { - startTimestamp.value = timestamp; - } - - // Calculate the current time in the video - const currentTimestamp = timestamp - startTimestamp.value; - currentTime.value = currentTimestamp; - - // Handle looping - if (currentTimestamp > duration && looping.value) { - video.seek(0); - startTimestamp.value = timestamp; - } - - // Update frame only if the elapsed time since last update is greater than the frame duration - const currentFrameDuration = Math.floor( - frameDuration / playbackSpeed.value + processVideoState( + video, + duration, + framerate, + frameInfo.timestamp, + { + paused: isPaused.value, + looping: looping.value, + playbackSpeed: playbackSpeed.value, + }, + currentTime, + currentFrame, + lastTimestamp, + seek ); - const delta = Math.floor(timestamp - lastTimestamp.value); - if (lastTimestamp.value === -1 || delta >= currentFrameDuration) { - const img = video.nextImage(); - if (img) { - if (currentFrame.value) { - currentFrame.value.dispose(); - } - if (Platform.OS === "android") { - currentFrame.value = img.makeNonTextureImage(); - } else { - currentFrame.value = img; - } - } - lastTimestamp.value = timestamp; - } }); useEffect(() => { return () => { // TODO: should video simply be a shared value instead? - runOnUI(disposeVideo)(); + Rea.runOnUI(disposeVideo)(video); }; - }, [disposeVideo, video]); + }, [video]); - return currentFrame; + return { currentFrame, currentTime, duration, framerate, rotationInDegrees }; }; diff --git a/package/src/external/reanimated/video.ts b/package/src/external/reanimated/video.ts new file mode 100644 index 0000000000..9720d8aa83 --- /dev/null +++ b/package/src/external/reanimated/video.ts @@ -0,0 +1,82 @@ +import type { SharedValue } from "react-native-reanimated"; + +import type { SkImage, Video } from "../../skia/types"; +import { Platform } from "../../Platform"; + +export type Animated = SharedValue | T; + +export interface PlaybackOptions { + playbackSpeed: Animated; + looping: Animated; + paused: Animated; + seek: Animated; +} + +type Materialized = { + [K in keyof T]: T[K] extends Animated ? U : T[K]; +}; + +export type MaterializedPlaybackOptions = Materialized< + Omit +>; + +export const setFrame = ( + video: Video, + currentFrame: SharedValue +) => { + "worklet"; + const img = video.nextImage(); + if (img) { + if (currentFrame.value) { + currentFrame.value.dispose(); + } + if (Platform.OS === "android") { + currentFrame.value = img.makeNonTextureImage(); + } else { + currentFrame.value = img; + } + } +}; + +export const processVideoState = ( + video: Video | null, + duration: number, + framerate: number, + currentTimestamp: number, + options: Materialized>, + currentTime: SharedValue, + currentFrame: SharedValue, + lastTimestamp: SharedValue, + seek: SharedValue +) => { + "worklet"; + if (!video) { + return; + } + if (options.paused) { + return; + } + const delta = currentTimestamp - lastTimestamp.value; + + const frameDuration = 1000 / framerate; + const currentFrameDuration = Math.floor( + frameDuration / options.playbackSpeed + ); + if (currentTime.value + delta >= duration && options.looping) { + seek.value = 0; + } + if (seek.value !== null) { + video.seek(seek.value); + currentTime.value = seek.value; + setFrame(video, currentFrame); + lastTimestamp.value = currentTimestamp; + seek.value = null; + return; + } + + if (delta >= currentFrameDuration) { + setFrame(video, currentFrame); + currentTime.value += delta; + lastTimestamp.value = currentTimestamp; + } +}; diff --git a/package/src/renderer/__tests__/Video.spec.tsx b/package/src/renderer/__tests__/Video.spec.tsx new file mode 100644 index 0000000000..eccab935e3 --- /dev/null +++ b/package/src/renderer/__tests__/Video.spec.tsx @@ -0,0 +1,165 @@ +import type { SharedValue } from "react-native-reanimated"; + +import type { SkImage, Video } from "../../skia/types"; +import { + processVideoState, + type MaterializedPlaybackOptions, +} from "../../external/reanimated/video"; + +const createValue = (value: T) => ({ value } as unknown as SharedValue); + +jest.mock("../../Platform", () => ({ + Platform: { + OS: "ios", + }, +})); + +// Test cases +describe("Video Player", () => { + let mockVideo: Video; + let options: MaterializedPlaybackOptions; + let currentTimestamp: number; + + const currentTime = createValue(0); + const currentFrame = createValue(null); + const lastTimestamp = createValue(0); + const seek = createValue(null); + const framerate = 30; + const duration = 5000; + beforeEach(() => { + mockVideo = { + __typename__: "Video", + dispose: jest.fn(), + framerate: jest.fn().mockReturnValue(framerate), + duration: jest.fn().mockReturnValue(duration), + seek: jest.fn(), + nextImage: jest.fn().mockReturnValue({} as SkImage), + getRotationInDegrees: jest.fn().mockReturnValue(0), + }; + options = { + playbackSpeed: 1, + looping: false, + paused: false, + }; + currentTimestamp = 0; + currentTime.value = 0; + currentFrame.value = null; + lastTimestamp.value = 0; + }); + + test("should not update state when paused", () => { + options.paused = true; + processVideoState( + mockVideo, + duration, + framerate, + currentTimestamp, + options, + currentTime, + currentFrame, + lastTimestamp, + seek + ); + expect(currentTime.value).toBe(0); + expect(currentFrame.value).toBeNull(); + expect(lastTimestamp.value).toBe(0); + }); + + test("should update state with next frame if not paused and delta exceeds frame duration", () => { + currentTimestamp = 100; + lastTimestamp.value = 0; + processVideoState( + mockVideo, + duration, + framerate, + currentTimestamp, + options, + currentTime, + currentFrame, + lastTimestamp, + seek + ); + expect(currentFrame.value).not.toBeNull(); + expect(currentTime.value).toBe(100); + expect(lastTimestamp.value).toBe(100); + }); + + test("should handle looping when current time exceeds video duration", () => { + currentTimestamp = 5100; + lastTimestamp.value = 0; + currentTime.value = 5000; + options.looping = true; + processVideoState( + mockVideo, + duration, + framerate, + currentTimestamp, + options, + currentTime, + currentFrame, + lastTimestamp, + seek + ); + expect(seek.value).toBe(null); + expect(currentTime.value).toBe(0); + }); + + test("should seek to specified time", () => { + seek.value = 2000; + processVideoState( + mockVideo, + duration, + framerate, + currentTimestamp, + options, + currentTime, + currentFrame, + lastTimestamp, + seek + ); + expect(mockVideo.seek).toHaveBeenCalledWith(2000); + expect(currentTime.value).toBe(2000); + expect(currentFrame.value).not.toBeNull(); + expect(lastTimestamp.value).toBe(currentTimestamp); + expect(seek.value).toBeNull(); + }); + + test("should not update frame if delta does not exceed frame duration", () => { + currentTimestamp = 10; + lastTimestamp.value = 0; + processVideoState( + mockVideo, + duration, + framerate, + currentTimestamp, + options, + currentTime, + currentFrame, + lastTimestamp, + seek + ); + expect(currentFrame.value).toBeNull(); + expect(currentTime.value).toBe(0); + expect(lastTimestamp.value).toBe(0); + }); + + test("should update frame based on playback speed", () => { + options.playbackSpeed = 2; // double speed + currentTimestamp = 100; + lastTimestamp.value = 0; + processVideoState( + mockVideo, + duration, + framerate, + currentTimestamp, + options, + currentTime, + currentFrame, + lastTimestamp, + seek + ); + expect(currentFrame.value).not.toBeNull(); + expect(currentTime.value).toBe(100); + expect(lastTimestamp.value).toBe(100); + }); +}); diff --git a/package/src/skia/types/Video/Video.ts b/package/src/skia/types/Video/Video.ts index c798dd6eb6..fbe8ab8527 100644 --- a/package/src/skia/types/Video/Video.ts +++ b/package/src/skia/types/Video/Video.ts @@ -6,4 +6,5 @@ export interface Video extends SkJSIInstance<"Video"> { framerate(): number; nextImage(): SkImage | null; seek(time: number): void; + getRotationInDegrees(): number; } diff --git a/package/src/skia/types/index.ts b/package/src/skia/types/index.ts index 6decae0553..161a3c7417 100644 --- a/package/src/skia/types/index.ts +++ b/package/src/skia/types/index.ts @@ -30,3 +30,4 @@ export * from "./Size"; export * from "./Paragraph"; export * from "./Matrix4"; export * from "./NativeBuffer"; +export * from "./Video";