Repository: remotion-dev/remotion (2025-07-28T13:53:45Z)

Issue:
Videos not automatically rotated as expected during conversion, seen with iPhone videos and orientation metadata.
[From message posted on Discord by **hexcowboy**](https://discord.com/channels/809501355504959528/1394843888447131771/1394843888447131771)
----
After testing out with some iPhone videos, it doesn't seem that videos are being properly rotated according to the docs
```
Videos that have rotation metadata embedded will by default be rotated when re-encoded in order to produce a video with the correct orientation.
This means you should not try to detect rotation metadata and apply the correction yourself, because it will be done automatically.
```
You can test it out with the video files I attached, which are all rendered properly in the browser. When they are converted, the orientation is not correct.
```ts
const result = await convertMedia({
  src: file,
  container: "mp4",
  videoCodec: "h264",
  audioCodec: "aac",
});
```
Tested on Safari macOS and Chrome macOS.


Discussion:
Raw metadata of files attached:
`IMG_8541.MOV`: `"rotation": -90`
`IMG_8542.MOV`: `null` (no rotation)
`IMG_8543.MOV`: `"rotation": 90`
`IMG_8544.MOV`: `"rotation": -180`

What I think is happening is that the metadata rotation is being applied when it shouldn't be. I'm not really certain if this is because of how the WebCodecs API works, but with all these test cases it seems that the rotation from the metadata is applied, even though the output would be valid without applying a metadata rotation.
This is because Chrome now detects orientation as well and fixes it.  
Thanks for reporting, adjusting our logic to account for that!
@JonnyBurger thanks for looking into this! I just tested out the `main` branch with #5520 merged and it seems there are still some issues with the example videos I provided.

<img width="1110" height="1812" alt="Image" src="https://github.com/user-attachments/assets/220918da-95b4-4bd1-9dd2-29655c2e1e75" />

<img width="1028" height="1814" alt="Image" src="https://github.com/user-attachments/assets/df4e5d4c-b830-4fc9-a088-00dfe413ef52" />

<img width="3024" height="1814" alt="Image" src="https://github.com/user-attachments/assets/a151eb21-383e-417a-8c3b-cd88300cb70c" />

```ts
const result = await convertMedia({
  src: file,
  container: "mp4",
  videoCodec: "h264",
  audioCodec: "aac",
});
// Downloading the result shows the videos above
```

This is for video conversion on Chrome. Safari seems to be providing correct output now.

Apologies if this is already something you knew about!
You are right, I can still reproduce! 
@JonnyBurger  I may be doing something incorrect because https://www.remotion.dev/convert is working properly, but for some reason I'm still seeing issues with rotation on my own app. I've made a reproduction here with a screenshot of what I'm seeing.

https://github.com/hexcowboy/webcodecs-rotation-repro/tree/main

Am I doing something wrong?
Thanks, I figured it out, we indeed use custom options! Fixing for the next version 



Patch:
diff --git a/packages/convert/app/components/ConvertProgress.tsx b/packages/convert/app/components/ConvertProgress.tsx
index d67bfa93354..29b6fd3a16a 100644
--- a/packages/convert/app/components/ConvertProgress.tsx
+++ b/packages/convert/app/components/ConvertProgress.tsx
@@ -59,9 +59,10 @@ export const ConvertProgress: React.FC<{
 				<>
 					<VideoThumbnail
 						ref={convertProgressRef}
+						trackRotation={null}
 						initialReveal
 						smallThumbOnMobile={false}
-						rotation={0}
+						userRotation={0}
 						mirrorHorizontal={false}
 						mirrorVertical={false}
 					/>
diff --git a/packages/convert/app/components/Probe.tsx b/packages/convert/app/components/Probe.tsx
index d3d057ebf4a..9fb5a2e86da 100644
--- a/packages/convert/app/components/Probe.tsx
+++ b/packages/convert/app/components/Probe.tsx
@@ -94,8 +94,8 @@ export const Probe: React.FC<{
 		videoCodec,
 		durationInSeconds,
 		isHdr,
-		rotation,
 		done,
+		rotation,
 		error,
 		metadata,
 		location,
@@ -145,7 +145,8 @@ export const Probe: React.FC<{
 						<VideoThumbnail
 							ref={videoThumbnailRef}
 							smallThumbOnMobile
-							rotation={userRotation - (rotation ?? 0)}
+							userRotation={userRotation}
+							trackRotation={rotation}
 							mirrorHorizontal={mirrorHorizontal}
 							mirrorVertical={mirrorVertical}
 							initialReveal={false}
diff --git a/packages/convert/app/components/VideoThumbnail.tsx b/packages/convert/app/components/VideoThumbnail.tsx
index acd96e0d582..bc94f7e3bdd 100644
--- a/packages/convert/app/components/VideoThumbnail.tsx
+++ b/packages/convert/app/components/VideoThumbnail.tsx
@@ -7,7 +7,8 @@ export const THUMBNAIL_HEIGHT = Math.round((350 / 16) * 9);
 
 type Props = {
 	readonly smallThumbOnMobile: boolean;
-	readonly rotation: number;
+	readonly userRotation: number;
+	readonly trackRotation: number | null;
 	readonly mirrorHorizontal: boolean;
 	readonly mirrorVertical: boolean;
 	readonly initialReveal: boolean;
@@ -28,7 +29,8 @@ const VideoThumbnailRefForward: React.ForwardRefRenderFunction<
 > = (
 	{
 		smallThumbOnMobile,
-		rotation,
+		userRotation,
+		trackRotation,
 		mirrorHorizontal,
 		mirrorVertical,
 		initialReveal,
@@ -41,6 +43,7 @@ const VideoThumbnailRefForward: React.ForwardRefRenderFunction<
 	const [dimensions, setDimensions] = useState<{width: number}>({width: 350});
 	const [reveal, setReveal] = useState(initialReveal);
 	const [drawn, setDrawn] = useState(false);
+	const [needsRotation, setNeedsRotation] = useState(false);
 
 	const onChangeListeners = useRef<(() => void)[]>([]);
 
@@ -69,6 +72,7 @@ const VideoThumbnailRefForward: React.ForwardRefRenderFunction<
 			return new FastAverageColor().getColor(canvas).hex;
 		});
 		setDrawn(true);
+		setNeedsRotation(!('rotation' in unrotatedVideoFrame));
 	}, []);
 
 	useImperativeHandle(
@@ -103,6 +107,10 @@ const VideoThumbnailRefForward: React.ForwardRefRenderFunction<
 	const isNarrow = useIsNarrow();
 
 	const scale = isNarrow && smallThumbOnMobile ? 0.5 : 1;
+
+	const rotation =
+		userRotation - (needsRotation ? 0 - (trackRotation ?? 0) : 0);
+
 	const scaleTransform =
 		rotation % 90 === 0 && rotation % 180 !== 0
 			? THUMBNAIL_HEIGHT / dimensions.width
diff --git a/packages/docs/docs/media-parser/fields.mdx b/packages/docs/docs/media-parser/fields.mdx
index 7ddb81b77a2..61f02cc4515 100644
--- a/packages/docs/docs/media-parser/fields.mdx
+++ b/packages/docs/docs/media-parser/fields.mdx
@@ -158,7 +158,13 @@ Whether the video is in HDR (High dynamic range).
 
 _number_
 
-The rotation of the video in degrees (e.g. `-90` for a 90° counter-clockwise rotation).
+The rotation of the video in degrees (e.g. `90` for a 90° counter-clockwise rotation).  
+Always one of the following values: `0`, `90`, `180`, `270`.
+
+:::note
+Breaking change from v4.0.328: The rotation is now in _counter-clockwise_ degrees, where as before it was in _clockwise_ degrees.  
+This is because the WebCodecs spec now supports rotation and since `MediaParserVideoTrack` can be passed to `VideoDecoder.configure()`, this change was necessary to make sure the rotation is always in the correct direction.
+:::
 
 ### `images`
 
diff --git a/packages/media-parser/src/containers/iso-base-media/make-track.ts b/packages/media-parser/src/containers/iso-base-media/make-track.ts
index 9caddf808dd..789b6f270ca 100644
--- a/packages/media-parser/src/containers/iso-base-media/make-track.ts
+++ b/packages/media-parser/src/containers/iso-base-media/make-track.ts
@@ -28,6 +28,7 @@ import {
 	getVideoCodecString,
 	getVideoPrivateData,
 } from '../../get-video-codec';
+import {normalizeVideoRotation} from '../../normalize-video-rotation';
 import {WEBCODECS_TIMESCALE} from '../../webcodecs-timescale';
 import {mediaParserAdvancedColorToWebCodecsColor} from './color-to-webcodecs-colors';
 import {getActualDecoderParameters} from './get-actual-number-of-channels';
@@ -154,7 +155,7 @@ export const makeBaseMediaTrack = (
 		// Repeating those keys because they get picked up by VideoDecoder
 		displayAspectWidth,
 		displayAspectHeight,
-		rotation,
+		rotation: normalizeVideoRotation(0 - rotation),
 		codecData: privateData,
 		colorSpace: mediaParserAdvancedColorToWebCodecsColor(advancedColor),
 		advancedColor,
diff --git a/packages/media-parser/src/normalize-video-rotation.ts b/packages/media-parser/src/normalize-video-rotation.ts
new file mode 100644
index 00000000000..3583e4afa26
--- /dev/null
+++ b/packages/media-parser/src/normalize-video-rotation.ts
@@ -0,0 +1,3 @@
+export const normalizeVideoRotation = (rotation: number) => {
+	return ((rotation % 360) + 360) % 360;
+};
diff --git a/packages/webcodecs/src/on-frame.ts b/packages/webcodecs/src/on-frame.ts
index 6a60f51c9e3..aead9a7c5a5 100644
--- a/packages/webcodecs/src/on-frame.ts
+++ b/packages/webcodecs/src/on-frame.ts
@@ -6,7 +6,7 @@ import type {ConvertMediaVideoCodec} from './get-available-video-codecs';
 import type {ResizeOperation} from './resizing/mode';
 import {rotateAndResizeVideoFrame} from './rotate-and-resize-video-frame';
 
-export const onFrame = async ({
+export const processFrame = async ({
 	frame: unrotatedFrame,
 	onVideoFrame,
 	track,
diff --git a/packages/webcodecs/src/reencode-video-track.ts b/packages/webcodecs/src/reencode-video-track.ts
index 16ff9fd0de1..7f705fff615 100644
--- a/packages/webcodecs/src/reencode-video-track.ts
+++ b/packages/webcodecs/src/reencode-video-track.ts
@@ -10,7 +10,7 @@ import {createVideoDecoder} from './create-video-decoder';
 import type {MediaFn} from './create/media-fn';
 import type {ProgressTracker} from './create/progress-tracker';
 import {Log} from './log';
-import {onFrame} from './on-frame';
+import {processFrame} from './on-frame';
 import type {VideoOperation} from './on-video-track-handler';
 import {processingQueue} from './processing-queue';
 import {calculateNewDimensionsFromRotateAndScale} from './rotation';
@@ -52,7 +52,7 @@ export const reencodeVideoTrack = async ({
 		);
 	}
 
-	const rotation = (videoOperation.rotate ?? rotate) - track.rotation;
+	const rotation = (videoOperation.rotate ?? rotate) + track.rotation;
 
 	const {height: newHeight, width: newWidth} =
 		calculateNewDimensionsFromRotateAndScale({
@@ -154,7 +154,7 @@ export const reencodeVideoTrack = async ({
 		onOutput: async (frame) => {
 			await controller._internals._mediaParserController._internals.checkForAbortAndPause();
 
-			const processedFrame = await onFrame({
+			const processedFrame = await processFrame({
 				frame,
 				track,
 				onVideoFrame,
diff --git a/packages/webcodecs/src/rotate-and-resize-video-frame.ts b/packages/webcodecs/src/rotate-and-resize-video-frame.ts
index 682a1dac7fd..e2848409070 100644
--- a/packages/webcodecs/src/rotate-and-resize-video-frame.ts
+++ b/packages/webcodecs/src/rotate-and-resize-video-frame.ts
@@ -21,8 +21,12 @@ export const rotateAndResizeVideoFrame = ({
 }) => {
 	const normalized = normalizeVideoRotation(rotation);
 
+	// In Chrome, there is "rotation", but we cannot put frames with VideoEncoder if they have a rotation.
+	// We have to draw them to a canvas and make a new frame without video rotation.
+	const mustProcess = 'rotation' in frame && frame.rotation !== 0;
+
 	// No resize, no rotation
-	if (normalized === 0 && resizeOperation === null) {
+	if (normalized === 0 && resizeOperation === null && !mustProcess) {
 		return frame;
 	}
 
@@ -30,7 +34,7 @@ export const rotateAndResizeVideoFrame = ({
 		throw new Error('Only 90 degree rotations are supported');
 	}
 
-	const {height, width} = calculateNewDimensionsFromRotateAndScale({
+	const tentativeDimensions = calculateNewDimensionsFromRotateAndScale({
 		height: frame.displayHeight,
 		width: frame.displayWidth,
 		rotation,
@@ -41,12 +45,27 @@ export const rotateAndResizeVideoFrame = ({
 	// No rotation, and resize turned out to be same dimensions
 	if (
 		normalized === 0 &&
-		height === frame.displayHeight &&
-		width === frame.displayWidth
+		tentativeDimensions.height === frame.displayHeight &&
+		tentativeDimensions.width === frame.displayWidth &&
+		!mustProcess
 	) {
 		return frame;
 	}
 
+	// @ts-expect-error
+	const frameRotation: number = frame.rotation ?? 0;
+	const canvasRotationToApply = normalizeVideoRotation(
+		normalized - frameRotation,
+	);
+
+	const {width, height} = calculateNewDimensionsFromRotateAndScale({
+		height: frame.displayHeight,
+		width: frame.displayWidth,
+		rotation: canvasRotationToApply,
+		needsToBeMultipleOfTwo,
+		resizeOperation,
+	});
+
 	const canvas = new OffscreenCanvas(width, height);
 	const ctx = canvas.getContext('2d');
 	if (!ctx) {
@@ -56,12 +75,6 @@ export const rotateAndResizeVideoFrame = ({
 	canvas.width = width;
 	canvas.height = height;
 
-	// @ts-expect-error
-	const frameRotation: number = frame.rotation ?? 0;
-	const canvasRotationToApply = normalizeVideoRotation(
-		normalized + frameRotation,
-	);
-
 	if (canvasRotationToApply === 90) {
 		ctx.translate(width, 0);
 	} else if (canvasRotationToApply === 180) {
@@ -77,7 +90,7 @@ export const rotateAndResizeVideoFrame = ({
 	if (frame.displayHeight !== height || frame.displayWidth !== width) {
 		const dimensionsAfterRotate = calculateNewDimensionsFromRotate({
 			height: frame.displayHeight,
-			rotation,
+			rotation: canvasRotationToApply,
 			width: frame.displayWidth,
 		});
 		ctx.scale(


Test patch:
diff --git a/packages/media-parser/src/test/iso-base-media.test.ts b/packages/media-parser/src/test/iso-base-media.test.ts
index bec059e854d..71ed6881af6 100644
--- a/packages/media-parser/src/test/iso-base-media.test.ts
+++ b/packages/media-parser/src/test/iso-base-media.test.ts
@@ -52,7 +52,7 @@ if (process.platform !== 'win32') {
 		expect(result.tracks.find((t) => t.type === 'video')?.codec).toBe(
 			'hvc1.2.4.L150.b0',
 		);
-		expect(result.rotation).toBe(-90);
+		expect(result.rotation).toBe(90);
 		expect(result.unrotatedDimensions).toEqual({
 			height: 2160,
 			width: 3840,