diff --git a/src/app/recorder.tsx b/src/app/recorder.tsx index 2c12856..8920d85 100644 --- a/src/app/recorder.tsx +++ b/src/app/recorder.tsx @@ -277,9 +277,20 @@ export default function RecorderScreen() { }, [device]); // Pinned 1080p output + 30fps so every recorded clip is format-uniform (fast-path merge). + // + // `resolutionBias` FIRST, deliberately. VisionCamera picks the capture format by scoring every + // format against a weighted list of constraints — weight is `count - index`, so earlier entries + // outrank later ones — and it auto-appends one `{ resolutionBias: output }` per output, in the + // order the outputs are given. `` puts its own preview output ahead of ours, and the + // preview asks for a format at least as large as the SCREEN. Every modern iPhone screen is taller + // than 1920px, so 1080p could never satisfy the preview and the vote elected 4K: clips shipped at + // 3840x2160 / ~23 Mbps while `useVideoOutput` below asked for 1080p / 5 Mbps. VisionCamera 5.2.0 + // rescored that case so 1080p wins on its own, but only by ~13% on the largest screens — naming + // the video output's bias explicitly, at the top of the list, turns a margin into a mandate. + // See `logNegotiatedResolution` in use-recorder.ts for the runtime check that this held. const constraints = useMemo( - () => [{ videoStabilizationMode: stabilization }, { fps: 30 }], - [stabilization], + () => [{ resolutionBias: videoOutput }, { videoStabilizationMode: stabilization }, { fps: 30 }], + [stabilization, videoOutput], ); const outputs = useMemo(() => [videoOutput], [videoOutput]); diff --git a/src/features/recorder/use-recorder.ts b/src/features/recorder/use-recorder.ts index 552741b..5f9f048 100644 --- a/src/features/recorder/use-recorder.ts +++ b/src/features/recorder/use-recorder.ts @@ -33,6 +33,35 @@ import { generateThumbnailFile, getDurationMs } from '@/utils/video'; import CallDetector from '../../../modules/expo-call-detector/src/CallDetectorModule'; import { useCallState } from './use-call-state'; +import { UPLOAD_MAX_LONG_EDGE } from '@/features/upload/upload-contract'; + +/** + * Report what the camera session ACTUALLY negotiated, and shout if it isn't what we asked for. + * + * `targetResolution` on the video output is a bias in a weighted vote across all outputs, not a + * setting — the preview output's preference outranked it and every clip came out 4K while the + * config said 1080p, silently, for weeks. The failure mode of a lost vote is indistinguishable + * from success unless something looks. This looks. + * + * Cheap (one property read on session start) and non-fatal by design: a device that can only + * offer something larger should still record, it just shouldn't do so unnoticed. + */ +function logNegotiatedResolution(output: { + currentResolution?: { width: number; height: number }; +}) { + const size = output.currentResolution; + if (!size) return; + const longEdge = Math.max(size.width, size.height); + if (longEdge > UPLOAD_MAX_LONG_EDGE) { + console.warn( + `[recorder] capture format negotiated to ${size.width}x${size.height}, above the ${UPLOAD_MAX_LONG_EDGE} ` + + `long-edge target — clips will be re-encoded before upload. Check the resolutionBias constraint order.`, + ); + return; + } + console.log(`[recorder] capture format: ${size.width}x${size.height}`); +} + // 'cinematic' is an iOS-only AVCaptureVideoStabilizationMode — CameraX has no equivalent, so // Android only cycles through the modes it can actually honor. The union type keeps 'cinematic' // on both platforms so persisted iOS prefs and shared UI maps still typecheck. @@ -433,7 +462,13 @@ export function useRecorder(initialDraftId?: string) { if (probe) { const decision = decideImport(probe); if (decision.action === 'normalize') { - const normalized = await compress(picked.uri, decision.options).catch(() => null); + const normalized = await compress(picked.uri, decision.options).catch((e: unknown) => { + // Falling back to the original bytes is the right call — a failed normalize should not + // block the import — but it must not be SILENT. This is how a 4K HDR master enters a + // draft looking exactly like a clip that was normalized successfully. + console.warn('[import] normalize failed; importing the original', decision.reasons, e); + return null; + }); if (normalized) { normalizedPath = normalized.outputPath; sourceUri = normalized.outputPath; @@ -549,7 +584,11 @@ export function useRecorder(initialDraftId?: string) { callActive, appActive, reportMicPriorityError, - onCameraReady: () => setCameraReady(true), + onCameraReady: () => { + // The session has started, so the output is attached and its negotiated format is readable. + logNegotiatedResolution(videoOutput); + setCameraReady(true); + }, // Wire to : fires whenever the session's connections are (re)formed — // cold open, enableAudio output rebuild, camera flip. Bumping the epoch re-arms the H.264 // pin for the NEW video connection (the codec is applied per-connection natively, so it diff --git a/src/features/upload/ensure-upload-contract.test.ts b/src/features/upload/ensure-upload-contract.test.ts new file mode 100644 index 0000000..38e8115 --- /dev/null +++ b/src/features/upload/ensure-upload-contract.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { compress, probeVideo, type VideoProbeResult } from 'react-native-video-trim'; + +import { ensureUploadContract } from './ensure-upload-contract'; +import { hasFaststart } from './faststart'; + +// `jest.mock` is hoisted above these imports by babel-plugin-jest-hoist, so the factories run +// first and the imports above resolve to the doubles below. The mock functions are created +// INSIDE the factories rather than captured from module scope: a `const` declared out here is +// still in its temporal dead zone when the hoisted factory runs. +// +// expo-file-system is a native module that `file-store` imports at load. Stubbing it keeps the +// REAL `toFileUri` in play, which is the behaviour under test. +jest.mock('expo-file-system', () => ({ + File: class {}, + Directory: class {}, + Paths: { document: '/doc', cache: '/cache' }, +})); +jest.mock('react-native-video-trim', () => ({ + probeVideo: jest.fn(), + compress: jest.fn(), +})); +// The scanner has its own tests against synthetic box layouts (faststart.test.ts); here we +// only care what the gate DOES with each of its three answers. +jest.mock('./faststart', () => ({ hasFaststart: jest.fn() })); + +const mockHasFaststart = hasFaststart as jest.MockedFunction; +const mockProbeVideo = probeVideo as jest.MockedFunction; +const mockCompress = compress as unknown as jest.MockedFunction< + (p: string, o: unknown) => Promise<{ outputPath: string }> +>; + +/** A clip that already satisfies the contract. */ +function compliant(overrides: Partial = {}): VideoProbeResult { + return { + hasVideo: true, + videoCodec: 'h264', + width: 1920, + height: 1080, + rotation: 0, + nominalFps: 30, + averageFps: 30, + bitrate: 5_000_000, + pixelFormat: 'yuv420p', + colorTransfer: 'bt709', + hasAudio: true, + audioCodec: 'aac', + audioSampleRate: 48000, + audioChannels: 2, + duration: 8000, + fileSize: 5_000_000, + ...overrides, + } as VideoProbeResult; +} + +/** + * The merged upload unit arrives as a bare filesystem path on Android (react-native-video-trim + * returns one). `probeVideo`/`compress` want a file:// URI, and a failed probe is swallowed into + * "upload the original" — so passing the bare path through made the gate fail open on every + * Android merged upload while still looking present in the code. + */ +describe('ensureUploadContract — path normalisation', () => { + beforeEach(() => { + mockProbeVideo.mockReset(); + mockCompress.mockReset(); + // These cases are about path handling, so keep faststart out of the picture. + mockHasFaststart.mockReset(); + mockHasFaststart.mockReturnValue(true); + }); + + it('probes a bare Android path as a file:// URI', async () => { + mockProbeVideo.mockResolvedValue(compliant()); + await ensureUploadContract('/data/user/0/app/cache/merged.mp4'); + expect(mockProbeVideo).toHaveBeenCalledWith('file:///data/user/0/app/cache/merged.mp4'); + }); + + it('re-encodes from the normalised URI, not the bare path', async () => { + mockProbeVideo.mockResolvedValue(compliant({ videoCodec: 'hevc' })); + mockCompress.mockResolvedValue({ outputPath: '/cache/out.mp4' }); + await ensureUploadContract('/data/merged.mp4'); + expect(mockCompress).toHaveBeenCalledWith('file:///data/merged.mp4', expect.anything()); + }); + + it('leaves an input that is already a URI untouched', async () => { + mockProbeVideo.mockResolvedValue(compliant()); + await ensureUploadContract('file:///doc/drafts/a/segments/s.mp4'); + expect(mockProbeVideo).toHaveBeenCalledWith('file:///doc/drafts/a/segments/s.mp4'); + }); + + it('returns a file:// URI on every path — passthrough, re-encode, and failure', async () => { + mockProbeVideo.mockResolvedValue(compliant()); + expect((await ensureUploadContract('/data/a.mp4')).path).toBe('file:///data/a.mp4'); + + mockProbeVideo.mockResolvedValue(compliant({ videoCodec: 'hevc' })); + mockCompress.mockResolvedValue({ outputPath: '/cache/out.mp4' }); + expect((await ensureUploadContract('/data/b.mp4')).path).toBe('file:///cache/out.mp4'); + + mockProbeVideo.mockRejectedValue(new Error('no such file')); + const failed = await ensureUploadContract('/data/c.mp4'); + expect(failed.path).toBe('file:///data/c.mp4'); + expect(failed.failure).toBeTruthy(); + }); + + it('a probe failure still fails open rather than dropping the upload', async () => { + mockProbeVideo.mockRejectedValue(new Error('boom')); + const r = await ensureUploadContract('/data/d.mp4'); + expect(r.changed).toBe(false); + expect(r.path).toBeTruthy(); + }); +}); + +/** + * `moov` placement is the one contract term a probe cannot see, so it is enforced here rather + * than in `decideUploadContract`. It only bites on files that skip the merge engine — a + * single-clip draft and every segment upload — which are raw AVCaptureMovieFileOutput files + * and therefore always index-at-the-tail. Before the recorder pinned H.264 they were re-encoded + * anyway for breaching codec/bitrate, and got faststart as a side effect; now they are otherwise + * compliant, so without this they would upload with the index still at the end. + */ +describe('ensureUploadContract — faststart', () => { + beforeEach(() => { + mockProbeVideo.mockReset(); + mockCompress.mockReset(); + mockHasFaststart.mockReset(); + mockProbeVideo.mockResolvedValue(compliant()); + }); + + it('remuxes a compliant clip whose moov is at the end', async () => { + mockHasFaststart.mockReturnValue(false); + mockCompress.mockResolvedValue({ outputPath: '/cache/remuxed.mp4' }); + + const r = await ensureUploadContract('file:///doc/segments/s.mp4'); + + expect(r.changed).toBe(true); + expect(r.path).toBe('file:///cache/remuxed.mp4'); + expect(r.reasons).toEqual(['moov atom at the end of the file']); + }); + + it('stream-copies the video rather than transcoding it', async () => { + mockHasFaststart.mockReturnValue(false); + mockCompress.mockResolvedValue({ outputPath: '/cache/remuxed.mp4' }); + + await ensureUploadContract('file:///doc/segments/s.mp4'); + + // copyVideo maps to `-c:v copy` in the fork, which also applies `+faststart` to the + // output. Re-encoding here would spend a quality generation to move four bytes. + expect(mockCompress).toHaveBeenCalledWith( + 'file:///doc/segments/s.mp4', + expect.objectContaining({ copyVideo: true, outputExt: 'mp4' }), + ); + const options = mockCompress.mock.calls[0][1] as Record; + expect(options.bitrate).toBeUndefined(); + expect(options.width).toBeUndefined(); + expect(options.height).toBeUndefined(); + }); + + it('leaves a merged clip that already has faststart completely alone', async () => { + mockHasFaststart.mockReturnValue(true); + + const r = await ensureUploadContract('file:///cache/merged.mp4'); + + expect(r.changed).toBe(false); + expect(r.reasons).toEqual([]); + expect(mockCompress).not.toHaveBeenCalled(); + }); + + it('does nothing when the scan cannot tell', async () => { + // A short read or an unrecognised container. Guessing would mean a needless re-encode on + // every upload, which is worse than the stall it would be avoiding. + mockHasFaststart.mockReturnValue(null); + + const r = await ensureUploadContract('file:///cache/odd.mp4'); + + expect(r.changed).toBe(false); + expect(mockCompress).not.toHaveBeenCalled(); + }); + + it('fails open loudly when the remux itself fails', async () => { + mockHasFaststart.mockReturnValue(false); + mockCompress.mockRejectedValue(new Error('ffmpeg exploded')); + + const r = await ensureUploadContract('file:///doc/segments/s.mp4'); + + expect(r.changed).toBe(false); + expect(r.path).toBe('file:///doc/segments/s.mp4'); + expect(r.failure).toBeTruthy(); + }); + + it('does not double-handle a clip that is already being re-encoded', async () => { + // A breaching file goes down the normalize path, and compress() writes faststart there + // too — so the scan must not add a second pass on top. + mockProbeVideo.mockResolvedValue(compliant({ videoCodec: 'hevc' })); + mockHasFaststart.mockReturnValue(false); + mockCompress.mockResolvedValue({ outputPath: '/cache/out.mp4' }); + + const r = await ensureUploadContract('file:///doc/segments/s.mp4'); + + expect(mockCompress).toHaveBeenCalledTimes(1); + expect(r.reasons).toEqual(['video codec hevc']); + }); +}); diff --git a/src/features/upload/ensure-upload-contract.ts b/src/features/upload/ensure-upload-contract.ts new file mode 100644 index 0000000..8114c32 --- /dev/null +++ b/src/features/upload/ensure-upload-contract.ts @@ -0,0 +1,173 @@ +import { File } from 'expo-file-system'; +import { compress, probeVideo } from 'react-native-video-trim'; + +import { toFileUri, uploadDest } from '@/utils/file-store'; + +import { hasFaststart } from './faststart'; +import { decideUploadContract } from './upload-contract'; + +/** Reported when a file is compliant in every respect except its `moov` placement. */ +const FASTSTART_REASON = 'moov atom at the end of the file'; + +/** + * What conditioning did to a file on its way to being uploaded. + * + * `path` is always usable — on any failure it falls back to the input, so a broken probe + * or a failed encode degrades to "upload the original", never to "upload nothing". + */ +export type ContractResult = { + /** + * The file to upload: the conditioned copy, or the input when nothing was needed. Always a + * `file://` URI — callers hand it straight to Expo `File`, and the merged unit's input is a + * bare path on Android. + */ + path: string; + /** True when `path` differs from the input. */ + changed: boolean; + /** Human-readable contract breaches that triggered the re-encode, for logging/UI. */ + reasons: string[]; + /** + * Set when the file could NOT be brought into the contract and the original is being + * uploaded instead. Never silently empty — the whole point of this gate is that a + * failure is visible. (`importClip` swallows exactly this case today, which is how a + * 4K master can still enter a draft.) + */ + failure?: string; +}; + +/** + * Bring a file into the upload contract before it is uploaded: probe it, and re-encode + * only if it breaches (see {@link decideUploadContract}). + * + * A compliant file is returned untouched — no copy, no re-encode, no quality generation + * spent. That is the intended steady state once the recorder emits 1080p/5 Mbps: this + * gate costs one probe and nothing else. It exists for the case where the recorder's + * format negotiation loses on some device we have not tested, or an import slips a 4K + * master through — outcomes we cannot prevent, only catch. + * + * Failure policy is deliberately "fail open, loudly": an upload that happens at reduced + * quality is better than an upload that does not happen, but it must be reported rather + * than absorbed. + */ +export async function ensureUploadContract(path: string): Promise { + // The merged unit arrives as a BARE filesystem path on Android (react-native-video-trim + // returns one, and `uploadMerged` documents it at the `new File(toFileUri(merged.path))` + // call one step later). `probeVideo`/`compress` want a file:// URI, so a bare path throws — + // and the catch below turns that into "upload the original". The gate would therefore fail + // open on EVERY Android merged upload: present in the code, never actually enforcing. + // `toFileUri` is a no-op on input that is already a URI, so the iOS/segment paths are + // unchanged (`absolutize` already yields a URI). + const uri = toFileUri(path); + + const probe = await probeVideo(uri).catch((e: unknown) => { + console.warn('[contract] probe failed; uploading the original', e); + return null; + }); + if (!probe) { + return { path: uri, changed: false, reasons: [], failure: 'could not probe the file' }; + } + + const decision = decideUploadContract(probe); + if (decision.action === 'passthrough') { + // Compliant on everything a probe can see. `moov` placement is the one part of the + // contract that is invisible to `probeVideo`, so it is checked separately, by reading + // the box headers (see faststart.ts). + // + // This is not a corner case: the two paths that skip the merge engine — a single-clip + // draft and every segment upload — hand us a raw AVCaptureMovieFileOutput file, and that + // API cannot write faststart at all. Those files used to be re-encoded here anyway, + // because they also breached the codec and bitrate rules, so their `moov` got moved to + // the front as a side effect. Now that the recorder pins H.264 and the 5 Mbps bitrate + // actually lands (mieweb/pulse#143), they arrive otherwise compliant and would sail + // through with the index still at the tail. + // + // Only an explicit `false` triggers work. `null` means the scan could not tell, and + // guessing there would cost a re-encode on every upload forever. + if (hasFaststart(uri) === false) { + // Stream-copies the video track and re-encodes only the audio, with `+faststart` on + // the output — roughly the cost of a file copy, not of a transcode. + const remuxed = await compress(uri, { copyVideo: true, outputExt: 'mp4' }).catch( + (e: unknown) => { + console.warn('[contract] faststart remux failed; uploading the original', e); + return null; + }, + ); + if (!remuxed) { + return { + path: uri, + changed: false, + reasons: [FASTSTART_REASON], + failure: `could not remux for faststart (${FASTSTART_REASON})`, + }; + } + return { + path: toFileUri(remuxed.outputPath), + changed: true, + reasons: [FASTSTART_REASON], + }; + } + return { path: uri, changed: false, reasons: [] }; + } + + const result = await compress(uri, { ...decision.options, outputExt: 'mp4' }).catch( + (e: unknown) => { + console.warn('[contract] re-encode failed; uploading the original', decision.reasons, e); + return null; + }, + ); + if (!result) { + return { + path: uri, + changed: false, + reasons: decision.reasons, + failure: `could not re-encode (${decision.reasons.join(', ')})`, + }; + } + + return { path: toFileUri(result.outputPath), changed: true, reasons: decision.reasons }; +} + +/** + * {@link ensureUploadContract} with a stable, reusable output location — the form the SEGMENT + * upload path needs. + * + * Segment uploads resume byte-wise (TUS `HEAD` for the offset, then `PATCH` from there), so a run + * that resumes must send exactly the bytes it began with. Re-running a re-encode would produce a + * second, subtly different encode and splice it into a half-finished transfer. Conditioning into a + * fixed per-clip path means a resumed run finds the file it already made and reuses it — correct + * first, and a saved re-encode second. + * + * The merged path solves the same problem differently: it persists the conditioned path in the + * draft row, which segments have no column for. + */ +export async function ensureUploadContractCached( + sourcePath: string, + draftId: string, + segmentId: string, +): Promise { + // The cache key is the source's BASENAME, not the segment id. `effFile` swaps to + // `{segmentId}.edited.{rev}.mp4` on a destructive edit while the id stays the same, so an + // id-keyed cache would return the conditioned copy of the pre-edit clip and upload the wrong + // bytes. The basename encodes the revision, so edited bytes miss the cache and re-condition. + const sourceName = sourcePath.split('/').pop() || `${segmentId}.mp4`; + const dest = uploadDest(draftId, sourceName); + if (dest.exists && (dest.size ?? 0) > 0) { + // Already conditioned on an earlier attempt — reuse verbatim. + return { path: dest.uri, changed: true, reasons: [] }; + } + + const result = await ensureUploadContract(sourcePath); + if (!result.changed) return result; + + try { + // compress() writes into the OS-purgeable cache dir; move it somewhere a resume can find it. + await new File(toFileUri(result.path)).move(dest); + return { ...result, path: dest.uri }; + } catch (e) { + // The conditioned bytes exist but could not be parked. Upload them from where they are + // rather than falling back to the oversized original; a resume may re-encode, which is + // worse than this but still better than uploading 4K. + console.warn('[contract] could not park the conditioned clip; using the cache copy', e); + return result; + } +} diff --git a/src/features/upload/faststart.test.ts b/src/features/upload/faststart.test.ts new file mode 100644 index 0000000..c76cae5 --- /dev/null +++ b/src/features/upload/faststart.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +import { type ByteReader, scanForFaststart } from './faststart'; + +// The module imports expo-file-system for `hasFaststart`, which jest cannot parse as shipped. +// These cases only exercise the pure scanner, so a stub is enough to let the import resolve. +jest.mock('expo-file-system', () => ({ File: class {} })); + +/** + * Build a fake MP4 as a list of top-level boxes and hand back a reader over it. Only the + * headers matter to the scanner, so box bodies are zero-filled: a real `mdat` is hundreds + * of megabytes and the whole point is that we never read it. + */ +function mp4(boxes: { type: string; size: number }[]): ByteReader { + const total = boxes.reduce((n, b) => n + b.size, 0); + const bytes = new Uint8Array(total); + let at = 0; + for (const box of boxes) { + bytes[at] = (box.size >>> 24) & 0xff; + bytes[at + 1] = (box.size >>> 16) & 0xff; + bytes[at + 2] = (box.size >>> 8) & 0xff; + bytes[at + 3] = box.size & 0xff; + for (let i = 0; i < 4; i++) bytes[at + 4 + i] = box.type.charCodeAt(i); + at += box.size; + } + return (offset, length) => { + if (offset >= bytes.length) return null; + return bytes.subarray(offset, Math.min(offset + length, bytes.length)); + }; +} + +describe('scanForFaststart', () => { + it('reports faststart when moov precedes mdat', () => { + expect( + scanForFaststart( + mp4([ + { type: 'ftyp', size: 32 }, + { type: 'moov', size: 4096 }, + { type: 'mdat', size: 1024 }, + ]), + ), + ).toBe(true); + }); + + it('reports moov-at-end for a raw recorder clip', () => { + // What AVCaptureMovieFileOutput writes: ftyp, then samples, then the index. + expect( + scanForFaststart( + mp4([ + { type: 'ftyp', size: 32 }, + { type: 'mdat', size: 8192 }, + { type: 'moov', size: 4096 }, + ]), + ), + ).toBe(false); + }); + + it('skips the filler boxes real writers emit before the payload', () => { + expect( + scanForFaststart( + mp4([ + { type: 'ftyp', size: 32 }, + { type: 'wide', size: 8 }, + { type: 'free', size: 64 }, + { type: 'moov', size: 4096 }, + ]), + ), + ).toBe(true); + }); + + it('follows a 64-bit largesize box', () => { + // size == 1 means the real size lives in the 8 bytes after the header. + const bytes = new Uint8Array(64); + const write = (at: number, type: string, size: number, large?: number) => { + const s = large ? 1 : size; + bytes[at] = (s >>> 24) & 0xff; + bytes[at + 1] = (s >>> 16) & 0xff; + bytes[at + 2] = (s >>> 8) & 0xff; + bytes[at + 3] = s & 0xff; + for (let i = 0; i < 4; i++) bytes[at + 4 + i] = type.charCodeAt(i); + if (large) { + // High word stays zero; low word carries the size. + bytes[at + 12] = (large >>> 24) & 0xff; + bytes[at + 13] = (large >>> 16) & 0xff; + bytes[at + 14] = (large >>> 8) & 0xff; + bytes[at + 15] = large & 0xff; + } + }; + write(0, 'ftyp', 0, 24); + write(24, 'moov', 16); + + const read: ByteReader = (offset, length) => + offset >= bytes.length + ? null + : bytes.subarray(offset, Math.min(offset + length, bytes.length)); + expect(scanForFaststart(read)).toBe(true); + }); + + it('gives up rather than guessing on a short read', () => { + expect(scanForFaststart(() => new Uint8Array(4))).toBeNull(); + expect(scanForFaststart(() => null)).toBeNull(); + }); + + it('gives up on a malformed size instead of looping forever', () => { + // A box claiming to be smaller than its own header would never advance the cursor. + expect(scanForFaststart(mp4([{ type: 'ftyp', size: 4 }]))).toBeNull(); + }); + + it('gives up when a box runs to the end of the file before moov', () => { + // size == 0 means "to EOF", so nothing follows and we never saw an index. + const bytes = new Uint8Array(16); + for (let i = 0; i < 4; i++) bytes[4 + i] = 'mdaX'.charCodeAt(i); + expect( + scanForFaststart((offset, length) => + offset >= bytes.length ? null : bytes.subarray(offset, offset + length), + ), + ).toBeNull(); + }); + + it('gives up on a file that is not an MP4 at all', () => { + expect(scanForFaststart(mp4([{ type: 'RIFF', size: 32 }]))).toBeNull(); + }); +}); diff --git a/src/features/upload/faststart.ts b/src/features/upload/faststart.ts new file mode 100644 index 0000000..d9b6c55 --- /dev/null +++ b/src/features/upload/faststart.ts @@ -0,0 +1,105 @@ +import { File } from 'expo-file-system'; + +/** + * Faststart detection (§ playback). + * + * An MP4 is a flat sequence of boxes, each `[4-byte big-endian size][4-byte ASCII type]`. + * "Faststart" just means the `moov` box (the index a player needs before it can render a + * single frame) sits ahead of `mdat` (the samples) rather than after it. With `moov` last, + * a browser has to fetch or seek to the tail of the file before playback can begin, which + * on a 100 MB upload over a phone network is the difference between "plays" and "spins". + * + * `probeVideo()` cannot see this — it reports codecs and geometry, not box order — which is + * why {@link decideUploadContract} deliberately says nothing about faststart. So we read the + * box headers ourselves. It costs two ranged reads of 16 bytes: the walk stops at whichever + * of `moov`/`mdat` comes first, and in a real file that is the second or third box. + * + * This matters because the two upload paths that skip the merge engine — a single-clip draft + * (`use-export.ts` returns the recorder's file verbatim) and every segment upload — hand us a + * raw `AVCaptureMovieFileOutput` file, and that API has no faststart option at all. Those + * files are always `moov`-at-end. Everything the merge/compress layer writes already has + * `+faststart` applied by the video-trim fork. + */ + +/** Reads `length` bytes at `offset`. Returns null (or a short read) at EOF or on error. */ +export type ByteReader = (offset: number, length: number) => Uint8Array | null; + +/** Boxes to walk before giving up. Real files reach `moov`/`mdat` within two or three. */ +const MAX_BOXES = 8; + +/** A 64-bit `largesize` needs 8 more bytes after the 8-byte header. */ +const HEADER_BYTES = 16; + +function readU32(b: Uint8Array, at: number): number { + return ((b[at] << 24) >>> 0) + (b[at + 1] << 16) + (b[at + 2] << 8) + b[at + 3]; +} + +function boxType(b: Uint8Array): string { + return String.fromCharCode(b[4], b[5], b[6], b[7]); +} + +/** + * Walk the top-level boxes and report whether `moov` precedes `mdat`. + * + * Returns `true` for faststart, `false` for `moov`-at-end, and **`null` for "cannot tell"** — + * a short read, a malformed size, a non-MP4 container, or a file that runs out of boxes. + * Callers must treat `null` as "leave it alone": guessing wrong here costs a needless + * re-encode on every upload, which is worse than the stall it would be trying to avoid. + * + * Pure, so the parsing is testable without a device or a real file. + */ +export function scanForFaststart(read: ByteReader): boolean | null { + let offset = 0; + + for (let i = 0; i < MAX_BOXES; i++) { + const head = read(offset, HEADER_BYTES); + if (!head || head.length < 8) return null; + + const type = boxType(head); + if (type === 'moov') return true; + if (type === 'mdat') return false; + + let size = readU32(head, 0); + if (size === 1) { + // 64-bit largesize. Split across two u32 reads because a single u64 does not fit a + // JS number; anything past 2^53 is not a file we could have written anyway. + if (head.length < 16) return null; + size = readU32(head, 8) * 2 ** 32 + readU32(head, 12); + } else if (size === 0) { + // "Extends to end of file", so there is no box after this one and we never saw `moov`. + return null; + } + + // A box cannot be smaller than its own header; a zero/negative step would spin forever. + if (!Number.isSafeInteger(size) || size < 8) return null; + offset += size; + } + + return null; +} + +/** + * {@link scanForFaststart} against a real file. Never throws: any failure reads as `null`, + * which the gate treats as "do nothing". + */ +export function hasFaststart(uri: string): boolean | null { + let handle: ReturnType | null = null; + try { + handle = new File(uri).open(); + const h = handle; + return scanForFaststart((offset, length) => { + h.offset = offset; + const bytes = h.readBytes(length); + return bytes && bytes.length > 0 ? bytes : null; + }); + } catch (e) { + console.warn('[contract] could not read box headers; assuming nothing about faststart', e); + return null; + } finally { + try { + handle?.close(); + } catch { + // Closing a handle we may never have opened is not worth reporting. + } + } +} diff --git a/src/features/upload/upload-contract.test.ts b/src/features/upload/upload-contract.test.ts new file mode 100644 index 0000000..3e8ee0f --- /dev/null +++ b/src/features/upload/upload-contract.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from '@jest/globals'; +import type { VideoProbeResult } from 'react-native-video-trim'; + +import { + decideUploadContract, + effectiveBitrate, + UPLOAD_MAX_LONG_EDGE, + UPLOAD_TARGET_BITRATE, + UPLOAD_TARGET_FPS, +} from './upload-contract'; + +/** A clip that already satisfies the contract; override one field to test one rule. */ +function probe(overrides: Partial = {}): VideoProbeResult { + return { + hasVideo: true, + videoCodec: 'h264', + width: 1920, + height: 1080, + rotation: 0, + nominalFps: 30, + averageFps: 30, + bitrate: 5_000_000, + pixelFormat: 'yuv420p', + colorTransfer: 'bt709', + hasAudio: true, + audioCodec: 'aac', + audioSampleRate: 48000, + audioChannels: 2, + duration: 8000, + fileSize: 5_000_000, + ...overrides, + }; +} + +/** + * The two PulseCam uploads that were actually sitting on the dev box, measured with + * ffprobe. These are the files that would not play on a phone — the regression test + * for this whole gate is that both of them get normalized. + */ +const REAL_UPLOADS: Record = { + '669b7a78 (343MB, 110s)': probe({ + videoCodec: 'hevc', + width: 3840, + height: 2160, + bitrate: 24_761_167, + duration: 110_588, + fileSize: 343_727_447, + }), + 'a9dd0919 (579MB, 166s)': probe({ + videoCodec: 'hevc', + width: 3840, + height: 2160, + bitrate: 27_831_557, + duration: 165_821, + fileSize: 579_118_404, + }), +}; + +describe('decideUploadContract', () => { + describe('the real 4K HEVC uploads that broke phone playback', () => { + for (const [name, p] of Object.entries(REAL_UPLOADS)) { + it(`normalizes ${name} on every count`, () => { + const decision = decideUploadContract(p); + expect(decision.action).toBe('normalize'); + if (decision.action !== 'normalize') return; + + expect(decision.reasons).toEqual( + expect.arrayContaining([ + expect.stringContaining('video codec hevc'), + expect.stringContaining('3840x2160'), + expect.stringContaining('Mbps'), + ]), + ); + expect(decision.options).toMatchObject({ + codec: 'h264', + bitrate: UPLOAD_TARGET_BITRATE, + frameRate: UPLOAD_TARGET_FPS, + width: UPLOAD_MAX_LONG_EDGE, + }); + // Landscape source: the long edge is pinned via width, height follows the aspect ratio. + expect(decision.options.height).toBeUndefined(); + }); + } + }); + + it('passes a compliant clip through untouched — the whole point of the recorder pin', () => { + expect(decideUploadContract(probe())).toEqual({ action: 'passthrough' }); + }); + + it('converts HEVC that is otherwise perfect (this is where it diverges from imports)', () => { + const decision = decideUploadContract(probe({ videoCodec: 'hevc' })); + expect(decision.action).toBe('normalize'); + if (decision.action !== 'normalize') return; + expect(decision.reasons).toEqual(['video codec hevc']); + // Nothing is wrong with the geometry, so no scaling is requested. + expect(decision.options.width).toBeUndefined(); + expect(decision.options.height).toBeUndefined(); + expect(decision.options.codec).toBe('h264'); + }); + + it('pins the long edge by HEIGHT for a rotated (portrait) 4K clip, not width', () => { + // 3840x2160 coded + 90deg rotation displays as 2160x3840 — portrait. + const decision = decideUploadContract( + probe({ videoCodec: 'hevc', width: 3840, height: 2160, rotation: 90, bitrate: 24_000_000 }), + ); + expect(decision.action).toBe('normalize'); + if (decision.action !== 'normalize') return; + expect(decision.options.height).toBe(UPLOAD_MAX_LONG_EDGE); + expect(decision.options.width).toBeUndefined(); + expect(decision.reasons).toEqual( + expect.arrayContaining([expect.stringContaining('2160x3840')]), + ); + }); + + it('accepts a clip sitting exactly on the long-edge cap, and rejects one pixel over', () => { + expect(decideUploadContract(probe({ width: 1920, height: 1080 })).action).toBe('passthrough'); + expect(decideUploadContract(probe({ width: 1921, height: 1080 })).action).toBe('normalize'); + }); + + it('leaves a slightly-over-target bitrate alone rather than burning a generation on 7%', () => { + expect(decideUploadContract(probe({ bitrate: 5_400_000 })).action).toBe('passthrough'); + expect(decideUploadContract(probe({ bitrate: 8_000_000 })).action).toBe('normalize'); + }); + + it('caps high frame rates', () => { + expect(decideUploadContract(probe({ nominalFps: 60, averageFps: 60 })).action).toBe( + 'normalize', + ); + // 29.97 NTSC must pass untouched. + expect(decideUploadContract(probe({ nominalFps: 29.97, averageFps: 29.97 })).action).toBe( + 'passthrough', + ); + }); + + it('normalizes 10-bit and HDR sources', () => { + expect(decideUploadContract(probe({ pixelFormat: 'yuv420p10le' })).action).toBe('normalize'); + expect(decideUploadContract(probe({ colorTransfer: 'arib-std-b67' })).action).toBe('normalize'); + expect(decideUploadContract(probe({ colorTransfer: 'smpte2084' })).action).toBe('normalize'); + // 8-bit format whose name merely contains "10". + expect(decideUploadContract(probe({ pixelFormat: 'yuv410p' })).action).toBe('passthrough'); + }); + + it('conforms audio only, copying the video, when just the audio codec is wrong', () => { + const decision = decideUploadContract(probe({ audioCodec: 'opus' })); + expect(decision.action).toBe('normalize'); + if (decision.action !== 'normalize') return; + expect(decision.options).toEqual({ copyVideo: true }); + expect(decision.reasons).toEqual(['audio codec opus']); + }); + + it('does a full re-encode (not a video copy) when audio AND video are both wrong', () => { + const decision = decideUploadContract(probe({ videoCodec: 'hevc', audioCodec: 'opus' })); + expect(decision.action).toBe('normalize'); + if (decision.action !== 'normalize') return; + expect(decision.options.copyVideo).toBeUndefined(); + expect(decision.options.codec).toBe('h264'); + expect(decision.reasons).toEqual(['video codec hevc', 'audio codec opus']); + }); + + it('ignores audio entirely on a silent clip', () => { + expect(decideUploadContract(probe({ hasAudio: false, audioCodec: '' })).action).toBe( + 'passthrough', + ); + }); + + it('passes through anything with no video stream', () => { + expect(decideUploadContract(probe({ hasVideo: false })).action).toBe('passthrough'); + }); +}); + +describe('effectiveBitrate', () => { + it('prefers the declared stream bitrate', () => { + expect(effectiveBitrate(probe({ bitrate: 4_000_000 }))).toBe(4_000_000); + }); + + it('derives from size and duration when the container declares nothing', () => { + // 10 MB over 10 s = 8 Mbps. + expect( + effectiveBitrate(probe({ bitrate: -1, fileSize: 10_000_000, duration: 10_000 })), + ).toBeCloseTo(8_000_000); + }); + + it('reports unknown rather than guessing when neither source is usable', () => { + expect(effectiveBitrate(probe({ bitrate: -1, fileSize: 0, duration: 0 }))).toBe(-1); + }); + + it('does not treat an undeclared bitrate as a reason to re-encode', () => { + expect(decideUploadContract(probe({ bitrate: -1, fileSize: 0, duration: 0 }))).toEqual({ + action: 'passthrough', + }); + }); + + it('catches a 4K master whose container declares no bitrate, via size/duration', () => { + const decision = decideUploadContract( + probe({ bitrate: -1, fileSize: 343_727_447, duration: 110_588, videoCodec: 'h264' }), + ); + expect(decision.action).toBe('normalize'); + if (decision.action !== 'normalize') return; + expect(decision.reasons).toEqual(expect.arrayContaining([expect.stringContaining('Mbps')])); + }); +}); diff --git a/src/features/upload/upload-contract.ts b/src/features/upload/upload-contract.ts new file mode 100644 index 0000000..1885187 --- /dev/null +++ b/src/features/upload/upload-contract.ts @@ -0,0 +1,186 @@ +import type { CompressOptions, VideoProbeResult } from 'react-native-video-trim'; + +import { displaySize, effectiveFps, HDR_TRANSFERS, is10Bit } from '@/utils/probe'; + +/** + * The upload contract (§ playback). + * + * Everything PulseCam uploads must satisfy: + * + * H.264 · long edge <= 1920 · <= 5 Mbps · AAC · faststart + * + * This is not a new target — it is what the recorder already asks for + * (`useVideoOutput` in use-recorder.ts) and what PulseClip's own exports already + * produce. The difference is that this module *enforces* it. + * + * Why enforcement is needed at all, measured on real recordings from two builds: + * + * resolution bitrate codec faststart container + * pre-07-21 3840x2160 28.80 Mbps hevc no qt + * 2026-07-29 1920x1080 8.24 Mbps hevc no qt + * + * The resolution breach is fixed — VisionCamera 5.2.0 rescored the format vote that the + * recorder's `targetResolution` participates in, so 1080p now wins it. Everything else + * still breaches: the bitrate lands 65% over a pin that has been set since 07-20, the + * codec is HEVC (fine on iOS, undecodable in Android Chrome), and the index is written + * at the end of the file. + * + * That is the case for a gate rather than a set of settings. `targetResolution` was + * silently ignored for weeks; `targetBitRate` and `fileType` still are. Each is a + * request to a subsystem that may or may not honour it, on hardware we have not tested. + * A probe-and-transcode gate does not ask. + * + * So: the recorder pin makes the common case FREE (a compliant file passes through + * untouched), and this gate makes every case CORRECT. Keep both. Neither replaces + * the other. + * + * How this differs from {@link decideImport}, which enforces a similar-looking policy + * at the Photos-import boundary: + * + * - **HEVC is not acceptable here.** Imports may keep it (iOS decodes it natively and + * the merge engine's fast path likes format-uniform clips), but an upload is watched + * in a browser, and Android Chrome will not decode HEVC at any size. Uploads convert. + * - **The bitrate ceiling is tighter** — an upload is streamed over a phone network, + * not read off local flash. + * + * Faststart is absent from the decision below because `moov` placement is not visible in + * a `probeVideo()` result — but it is still part of the contract, and it is still + * enforced. `ensureUploadContract` checks it separately by reading the file's box headers + * and remuxing (stream-copy, not transcode) when the index is at the tail. Keeping it out + * of this function is what lets the function stay pure and testable on a probe alone. + * + * That split matters more than it looks. The merge/compress paths emit `+faststart`, so + * for a long time the only files that missed it were also breaching the codec or bitrate + * rules, and the re-encode below moved their index as a side effect. Fixing the recorder + * (mieweb/pulse#143) removed the breach and would have removed the accident with it, + * leaving single-clip drafts and segment uploads — the two paths that skip the merge + * engine entirely — shipping `moov`-at-end with nothing client-side to catch it. + */ + +/** Long-edge cap. A 1080p long edge is 4x fewer pixels than 4K — the single biggest win. */ +export const UPLOAD_MAX_LONG_EDGE = 1920; +/** Re-encode target when a clip breaches the contract. Matches the recorder's own pin. */ +export const UPLOAD_TARGET_BITRATE = 5_000_000; +/** + * Re-encode trigger, deliberately above {@link UPLOAD_TARGET_BITRATE}. A clip that is + * already close to target is left alone: re-encoding 5.4 Mbps down to 5.0 costs a full + * transcode and a generation of quality to save ~7% of the bytes. Only a real breach + * (a 4K master at 23 Mbps) is worth the pass. + */ +export const UPLOAD_MAX_BITRATE = 6_500_000; +/** Frame-rate ceiling: passes 29.97/30 with margin, catches 60/120 (slo-mo, screen caps). */ +export const UPLOAD_MAX_FPS = 33; +/** Re-encode target frame rate. */ +export const UPLOAD_TARGET_FPS = 30; +/** The one codec that plays everywhere the browser lane cares about, Android Chrome included. */ +export const UPLOAD_VIDEO_CODEC = 'h264'; +/** The one audio codec that is MP4-muxable by stream copy across our paths. */ +export const UPLOAD_AUDIO_CODEC = 'aac'; + +export type UploadContractDecision = + | { action: 'passthrough' } + | { action: 'normalize'; options: Partial; reasons: string[] }; + +/** + * Effective video bitrate in bits per second. + * + * `probe.bitrate` is the *stream* bitrate and is `-1` when the container does not + * declare one — which is exactly the case for some camera-written MP4s, i.e. the files + * this gate exists to catch. Falling back to size/duration slightly overstates the + * video rate (it includes audio and container overhead), but it overstates in the safe + * direction: it can only push a borderline file towards being normalized, never away. + * + * Returns `-1` when neither source is usable, which the caller treats as "unknown" — + * an unknown bitrate is not by itself grounds to re-encode. + */ +export function effectiveBitrate(probe: VideoProbeResult): number { + if (probe.bitrate > 0) return probe.bitrate; + const seconds = probe.duration / 1000; + if (seconds > 0 && probe.fileSize > 0) return (probe.fileSize * 8) / seconds; + return -1; +} + +/** + * Decide how a file must be conditioned before it is uploaded: send the original bytes, + * conform only its audio, or re-encode it into the contract. + * + * Pure — feed it a `probeVideo()` result. The caller (`ensureUploadContract`) owns the + * file I/O; keeping the policy pure is what makes it testable without a device. + */ +export function decideUploadContract(probe: VideoProbeResult): UploadContractDecision { + // No video stream to constrain (audio-only artifacts ride other paths). Nothing to do. + if (!probe.hasVideo) return { action: 'passthrough' }; + + const reasons: string[] = []; + + if (probe.videoCodec !== UPLOAD_VIDEO_CODEC) { + // Includes HEVC, which is the common case: it is what the iPhone records by default, + // it is fine on iOS, and it is undecodable in Android Chrome. + reasons.push(`video codec ${probe.videoCodec || 'unknown'}`); + } + + const display = displaySize(probe); + const longEdge = Math.max(display.width, display.height); + const needsDownscale = longEdge > UPLOAD_MAX_LONG_EDGE; + if (needsDownscale) { + reasons.push(`${display.width}x${display.height} exceeds ${UPLOAD_MAX_LONG_EDGE}`); + } + + const bitrate = effectiveBitrate(probe); + if (bitrate > UPLOAD_MAX_BITRATE) { + reasons.push( + `${(bitrate / 1_000_000).toFixed(1)} Mbps exceeds ${UPLOAD_MAX_BITRATE / 1_000_000}`, + ); + } + + const fps = effectiveFps(probe); + if (fps > UPLOAD_MAX_FPS) { + reasons.push(`${Math.round(fps)} fps exceeds ${UPLOAD_MAX_FPS}`); + } + + // 10-bit / HDR: hardware H.264 encoders reject 10-bit input, and an HDR clip tone-maps + // unpredictably in a browser. Both force the SDR 8-bit re-encode. + if (is10Bit(probe.pixelFormat)) { + reasons.push(`10-bit pixel format ${probe.pixelFormat}`); + } + if (HDR_TRANSFERS.has(probe.colorTransfer)) { + reasons.push(`HDR transfer ${probe.colorTransfer}`); + } + + const audioHostile = probe.hasAudio && probe.audioCodec !== UPLOAD_AUDIO_CODEC; + + if (reasons.length === 0) { + if (audioHostile) { + // Video already satisfies the contract — stream-copy it and pay only for the audio. + return { + action: 'normalize', + options: { copyVideo: true }, + reasons: [`audio codec ${probe.audioCodec}`], + }; + } + return { action: 'passthrough' }; + } + + if (audioHostile) { + reasons.push(`audio codec ${probe.audioCodec}`); + } + + const options: Partial = { + codec: UPLOAD_VIDEO_CODEC, + bitrate: UPLOAD_TARGET_BITRATE, + frameRate: UPLOAD_TARGET_FPS, + }; + if (needsDownscale) { + // FFmpeg auto-rotates before filters run, so the cap is applied against DISPLAY + // orientation: pin the long edge, let the other follow the aspect ratio (-2). Pinning + // width unconditionally would upscale a portrait clip to 1920 wide — 4x the pixels + // the contract is trying to remove. + if (display.width >= display.height) { + options.width = UPLOAD_MAX_LONG_EDGE; + } else { + options.height = UPLOAD_MAX_LONG_EDGE; + } + } + + return { action: 'normalize', options, reasons }; +} diff --git a/src/features/upload/upload-manager.ts b/src/features/upload/upload-manager.ts index 281d12a..44f911e 100644 --- a/src/features/upload/upload-manager.ts +++ b/src/features/upload/upload-manager.ts @@ -20,6 +20,7 @@ import { getDraftToken } from '@/db/secure-token'; import { getDraftTranscriptRow } from '@/db/transcripts'; import { linesToVtt } from '@/features/transcription/vtt'; import { parseTranscriptLines } from '@/features/transcription/whisper'; +import { ensureUploadContract, ensureUploadContractCached } from './ensure-upload-contract'; import { absolutize, toFileUri } from '@/utils/file-store'; import { effFile } from '@/utils/segment-window'; import { generateThumbnailFile } from '@/utils/video'; @@ -578,6 +579,26 @@ class BackgroundUploadManager { private async uploadMerged(session: UploadSession, signal: AbortSignal): Promise { const { draftId, destination, segments, merged } = session; if (!merged) throw new Error('Export is not ready yet'); + + // Bring the video into the upload contract (H.264 / <=1920 long edge / <=5 Mbps / AAC) before + // anything reads its bytes. A compliant file costs one probe and is returned untouched; only a + // breach pays for a re-encode. This is the gate, NOT the export step: Share and Save-to-Photos + // read `state.outputPath` directly and must keep full capture quality — only what leaves the + // device for a browser to play is constrained. + const contract = await ensureUploadContract(merged.path); + if (contract.changed) { + // Persist the conditioned path so an after-kill resume re-uploads the SAME bytes. Without + // this, resume would re-encode from the original and a TUS PATCH could continue a transfer + // with bytes from a different encode. (Re-running the gate on an already-conditioned file is + // a no-op passthrough, so this is a cost saving as well as a correctness one.) + merged.path = contract.path; + await setUploadMerged(draftId, merged); + } + if (contract.failure) { + // Fail open, loudly: the upload proceeds with the original bytes, but it is not silent. + console.warn(`[contract] uploading unconditioned video for ${draftId}: ${contract.failure}`); + } + // merged.path is a bare filesystem path on Android (RNVT) — normalize to a file:// URI or the // File API rejects it outright ("URI is not absolute"). const file = new File(toFileUri(merged.path)); @@ -652,7 +673,13 @@ class BackgroundUploadManager { const result = await this.uploadOne( draftId, destination, - { artifactId: destination.artifactId, filename: `${draftId}.mp4`, kind: 'video', name: draftName, file }, + { + artifactId: destination.artifactId, + filename: `${draftId}.mp4`, + kind: 'video', + name: draftName, + file, + }, destination.resourceUrl, checksum, signal, @@ -739,7 +766,20 @@ class BackgroundUploadManager { for (const [index, segment] of segments.entries()) { reportClip(index + 1, index); - const file = new File(absolutize(effFile(segment))); + // Same contract as the merged unit. `uploadUnit` is chosen by the DESTINATION SERVER, not + // by us, so leaving this path ungated would silently disable the contract for any server + // that asks for segments — the protection would look present and not run. + const contract = await ensureUploadContractCached( + absolutize(effFile(segment)), + draftId, + segment.id, + ); + if (contract.failure) { + console.warn( + `[contract] uploading unconditioned clip ${segment.id} for ${draftId}: ${contract.failure}`, + ); + } + const file = new File(toFileUri(contract.path)); const checksum = await md5Checksum(file); const videoKey = `${segment.id}:video` as const; diff --git a/src/utils/file-store.ts b/src/utils/file-store.ts index c06297d..e51e775 100644 --- a/src/utils/file-store.ts +++ b/src/utils/file-store.ts @@ -5,14 +5,14 @@ import { Directory, File, Paths } from 'expo-file-system'; // between launches without invalidating references (§2.2). // drafts/{draftId}/segments/{segmentId}.mp4 — pristine original // drafts/{draftId}/segments/{segmentId}.edited.{rev}.mp4 — re-encoded editor output +// drafts/{draftId}/upload/{segmentId}.mp4 — upload-contract copy (see uploadDir) /** * Normalize a bare filesystem path to a `file://` URI. `merge()` / `getFrameAt` / the camera hand * back bare paths, but expo's `File`, expo-video, whisper, sharing, etc. all want a URI. A value * that already has a scheme is returned unchanged. */ -export const toFileUri = (path: string): string => - path.startsWith('/') ? `file://${path}` : path; +export const toFileUri = (path: string): string => (path.startsWith('/') ? `file://${path}` : path); export function segmentRelPath(draftId: string, segmentId: string): string { return `drafts/${draftId}/segments/${segmentId}.mp4`; @@ -55,6 +55,35 @@ function segmentsDir(draftId: string): Directory { return dir; } +/** + * The draft's conditioned-upload dir, creating it (and any missing parents) if needed. + * + * A STABLE location inside the draft's own directory, not a cache temp name. Segment uploads + * resume byte-wise (TUS HEAD + PATCH), so a resumed run must re-send the exact bytes it started + * with — re-encoding on resume would splice a second encode into a half-finished transfer. A + * fixed path means the conditioned file is found and reused instead. Living under + * `drafts/{draftId}/` means `deleteDraftDir` reclaims it with the draft, and it sits beside + * `segments/` rather than inside it so it can never be mistaken for a clip. + */ +function uploadDir(draftId: string): Directory { + const dir = new Directory(Paths.document, 'drafts', draftId, 'upload'); + dir.create({ intermediates: true, idempotent: true }); + return dir; +} + +/** + * The on-disk conditioned upload copy for a clip's SOURCE file, creating the upload dir if + * needed. Keyed by the source's basename, not the segment id: a destructive edit swaps the + * clip's effective file to `{segmentId}.edited.{rev}.mp4` while the id stays put, so an + * id-keyed cache would keep serving the conditioned copy of the pre-edit bytes. The basename + * carries the revision, so an edit naturally misses the cache and conditions the new bytes. + * (The superseded copy lingers until `deleteDraftDir` reclaims the draft — bounded, one file + * per destructive edit, and never re-uploaded because nothing references its name anymore.) + */ +export function uploadDest(draftId: string, sourceName: string): File { + return new File(uploadDir(draftId), sourceName); +} + /** The on-disk pristine segment file for a draft, creating the segments dir if needed. */ function segmentDest(draftId: string, segmentId: string): File { return new File(segmentsDir(draftId), `${segmentId}.mp4`); diff --git a/src/utils/import-normalization.ts b/src/utils/import-normalization.ts index ac75a42..8ccb866 100644 --- a/src/utils/import-normalization.ts +++ b/src/utils/import-normalization.ts @@ -1,5 +1,7 @@ import type { CompressOptions, VideoProbeResult } from 'react-native-video-trim'; +import { displaySize, effectiveFps, HDR_TRANSFERS, is10Bit } from './probe'; + /** * Import normalization policy (§ imports). * @@ -44,36 +46,11 @@ export const NORMALIZE_MAX_BITRATE = 8_000_000; * output, uploads) is standardized on H.264 for universal browser playback — HEVC imports * are re-encoded once at import time rather than leaking into merged artifacts. */ const NATIVE_VIDEO_CODECS = new Set(['h264']); -/** HDR transfer functions: HLG (iPhone camera default) and PQ (HDR10 / Dolby Vision 8.x). */ -const HDR_TRANSFERS = new Set(['arib-std-b67', 'smpte2084']); export type ImportDecision = | { action: 'passthrough' } | { action: 'normalize'; options: Partial; reasons: string[] }; -/** - * True for 10-bit pixel formats. FFmpeg names these with a `10`/`10le`/`10be` bit-depth - * suffix (yuv420p10le, p010le, ...) — matching the suffix rather than a bare `includes('10')` - * keeps 8-bit chroma-subsampling names like `yuv410p` from being misclassified. - */ -function is10Bit(pixelFormat: string): boolean { - return /10(le|be)?$/.test(pixelFormat); -} - -/** Effective fps for the decision: average when known (catches VFR), else nominal. */ -function effectiveFps(probe: VideoProbeResult): number { - return probe.averageFps > 0 ? probe.averageFps : probe.nominalFps; -} - -/** Display (post-rotation) dimensions: a 90/270 rotation swaps coded width/height. */ -function displaySize(probe: VideoProbeResult): { width: number; height: number } { - const swapped = probe.rotation % 180 !== 0; - return { - width: swapped ? probe.height : probe.width, - height: swapped ? probe.width : probe.height, - }; -} - /** * Decide how an imported clip enters the draft: byte-for-byte passthrough, an audio-only * conform (video stream-copied), or a full re-encode bounded to the recorder's signature. diff --git a/src/utils/probe.ts b/src/utils/probe.ts new file mode 100644 index 0000000..e140fb3 --- /dev/null +++ b/src/utils/probe.ts @@ -0,0 +1,37 @@ +import type { VideoProbeResult } from 'react-native-video-trim'; + +/** + * Shared readers for `probeVideo()` results. + * + * Both policy modules that interpret a probe — `import-normalization.ts` (what may enter a + * draft) and `features/upload/upload-contract.ts` (what may leave the device) — need the same + * low-level answers: real display geometry, effective frame rate, bit depth, HDR-ness. They + * live here so the upload contract does not have to reach into the import pipeline for them, + * and so the two policies cannot silently drift apart on how they read the same probe. + */ + +/** HDR transfer functions: HLG (iPhone camera default) and PQ (HDR10 / Dolby Vision 8.x). */ +export const HDR_TRANSFERS = new Set(['arib-std-b67', 'smpte2084']); + +/** + * True for 10-bit pixel formats. FFmpeg names these with a `10`/`10le`/`10be` bit-depth + * suffix (yuv420p10le, p010le, ...) — matching the suffix rather than a bare `includes('10')` + * keeps 8-bit chroma-subsampling names like `yuv410p` from being misclassified. + */ +export function is10Bit(pixelFormat: string): boolean { + return /10(le|be)?$/.test(pixelFormat); +} + +/** Effective fps for the decision: average when known (catches VFR), else nominal. */ +export function effectiveFps(probe: VideoProbeResult): number { + return probe.averageFps > 0 ? probe.averageFps : probe.nominalFps; +} + +/** Display (post-rotation) dimensions: a 90/270 rotation swaps coded width/height. */ +export function displaySize(probe: VideoProbeResult): { width: number; height: number } { + const swapped = probe.rotation % 180 !== 0; + return { + width: swapped ? probe.height : probe.width, + height: swapped ? probe.width : probe.height, + }; +}