Skip to content

Commit f6f21c5

Browse files
authored
fix: Fix saveToFileAsync(...) accidentally applying orientation twice (#4043)
1 parent 7870330 commit f6f21c5

2 files changed

Lines changed: 146 additions & 3 deletions

File tree

apps/simple-camera/__tests__/visioncamera.photo.harness.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import type {
99
CameraDevice,
1010
CameraDeviceFactory,
11+
CameraOrientation,
1112
FlashMode,
1213
MirrorMode,
1314
QualityPrioritization,
@@ -18,6 +19,96 @@ import { CommonResolutions, VisionCamera } from 'react-native-vision-camera'
1819
const sleep = (ms: number) =>
1920
new Promise<void>((resolve) => setTimeout(resolve, ms))
2021

22+
function readJpegExifOrientation(buffer: ArrayBuffer): number | undefined {
23+
const view = new DataView(buffer)
24+
if (view.byteLength < 4 || view.getUint16(0, false) !== 0xffd8) {
25+
return undefined
26+
}
27+
28+
let offset = 2
29+
while (offset + 4 <= view.byteLength) {
30+
if (view.getUint8(offset) !== 0xff) {
31+
return undefined
32+
}
33+
while (offset < view.byteLength && view.getUint8(offset) === 0xff) {
34+
offset++
35+
}
36+
37+
const marker = view.getUint8(offset)
38+
offset++
39+
if (marker === 0xda || marker === 0xd9) {
40+
return undefined
41+
}
42+
if (offset + 2 > view.byteLength) {
43+
return undefined
44+
}
45+
46+
const segmentLength = view.getUint16(offset, false)
47+
const segmentStart = offset + 2
48+
const segmentEnd = offset + segmentLength
49+
if (segmentEnd > view.byteLength || segmentLength < 2) {
50+
return undefined
51+
}
52+
53+
const isExifSegment =
54+
marker === 0xe1 &&
55+
segmentStart + 6 <= segmentEnd &&
56+
view.getUint8(segmentStart) === 0x45 &&
57+
view.getUint8(segmentStart + 1) === 0x78 &&
58+
view.getUint8(segmentStart + 2) === 0x69 &&
59+
view.getUint8(segmentStart + 3) === 0x66 &&
60+
view.getUint8(segmentStart + 4) === 0x00 &&
61+
view.getUint8(segmentStart + 5) === 0x00
62+
if (isExifSegment) {
63+
return readTiffOrientation(view, segmentStart + 6, segmentEnd)
64+
}
65+
66+
offset = segmentEnd
67+
}
68+
69+
return undefined
70+
}
71+
72+
function readTiffOrientation(
73+
view: DataView,
74+
tiffStart: number,
75+
tiffEnd: number,
76+
): number | undefined {
77+
if (tiffStart + 8 > tiffEnd) {
78+
return undefined
79+
}
80+
81+
const byteOrder = view.getUint16(tiffStart, false)
82+
const littleEndian = byteOrder === 0x4949
83+
if (!littleEndian && byteOrder !== 0x4d4d) {
84+
return undefined
85+
}
86+
if (view.getUint16(tiffStart + 2, littleEndian) !== 42) {
87+
return undefined
88+
}
89+
90+
const firstIfdOffset = view.getUint32(tiffStart + 4, littleEndian)
91+
const ifdStart = tiffStart + firstIfdOffset
92+
if (ifdStart + 2 > tiffEnd) {
93+
return undefined
94+
}
95+
96+
const entryCount = view.getUint16(ifdStart, littleEndian)
97+
for (let index = 0; index < entryCount; index++) {
98+
const entryStart = ifdStart + 2 + index * 12
99+
if (entryStart + 12 > tiffEnd) {
100+
return undefined
101+
}
102+
103+
const tag = view.getUint16(entryStart, littleEndian)
104+
if (tag === 0x0112) {
105+
return view.getUint16(entryStart + 8, littleEndian)
106+
}
107+
}
108+
109+
return undefined
110+
}
111+
21112
describe('VisionCamera - Photo', () => {
22113
let factory: CameraDeviceFactory
23114
let backDevice: CameraDevice
@@ -154,6 +245,59 @@ describe('VisionCamera - Photo', () => {
154245
await session.stop()
155246
})
156247

248+
it('preserves JPEG EXIF orientation when saving an in-memory Photo to a file', async () => {
249+
const session = await VisionCamera.createCameraSession(false)
250+
const photoOutput = VisionCamera.createPhotoOutput({
251+
targetResolution: CommonResolutions.HD_4_3,
252+
containerFormat: 'jpeg',
253+
quality: 0.9,
254+
qualityPrioritization: 'balanced',
255+
})
256+
await session.configure([
257+
{
258+
input: backDevice,
259+
outputs: [{ output: photoOutput, mirrorMode: 'off' }],
260+
constraints: [],
261+
},
262+
])
263+
await session.start()
264+
265+
try {
266+
const outputOrientations: CameraOrientation[] = [
267+
'up',
268+
'right',
269+
'down',
270+
'left',
271+
]
272+
for (const outputOrientation of outputOrientations) {
273+
photoOutput.outputOrientation = outputOrientation
274+
const photo = await photoOutput.capturePhoto(
275+
{ flashMode: 'off', enableShutterSound: false },
276+
{},
277+
)
278+
try {
279+
const inMemoryData = await photo.getFileDataAsync()
280+
const inMemoryOrientation = readJpegExifOrientation(inMemoryData)
281+
expect(inMemoryOrientation).toBeDefined()
282+
283+
const path = await photo.saveToTemporaryFileAsync()
284+
const response = await fetch(`file://${path}`)
285+
const savedData = await response.arrayBuffer()
286+
const savedOrientation = readJpegExifOrientation(savedData)
287+
288+
console.log(
289+
`outputOrientation=${outputOrientation} photo.orientation=${photo.orientation} in-memory EXIF=${inMemoryOrientation} saved EXIF=${savedOrientation}`,
290+
)
291+
expect(savedOrientation).toBe(inMemoryOrientation)
292+
} finally {
293+
photo.dispose()
294+
}
295+
}
296+
} finally {
297+
await session.stop()
298+
}
299+
})
300+
157301
// TODO: Re-enable once VisionCamera exposes a way to query supported photo
158302
// container formats upfront (see the TODO in CameraPhotoOutput.nitro.ts
159303
// near `TargetPhotoContainerFormat`). Without that API there is no

packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/instances/HybridPhoto.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,8 @@ class HybridPhoto(
188188
if (isMirrored) {
189189
exif.flipHorizontally()
190190
}
191-
if (orientation != CameraOrientation.UP) {
192-
exif.rotate(orientation.degrees)
193-
}
191+
// JPEG buffers already carry imageInfo.rotationDegrees in EXIF. Exif.rotate(...)
192+
// composes with the existing orientation tag, so rotating here would apply it twice.
194193
if (location != null) {
195194
exif.attachLocation(location)
196195
}

0 commit comments

Comments
 (0)