From 1b194bf965cfec1adbf3ddc49951bbb5489f80bf Mon Sep 17 00:00:00 2001 From: Worph Date: Sat, 12 Sep 2026 21:35:44 +0200 Subject: [PATCH] fix resolution --- README.md | 2 +- src/plugin.ts | 111 +++++++++++++++++++++++++- test/promote-resolution.test.ts | 136 ++++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 test/promote-resolution.test.ts diff --git a/README.md b/README.md index a48466d..5e62b5f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A MetaMesh plugin that extracts video, audio, and subtitle stream metadata using This plugin analyzes video files using FFprobe (from FFmpeg) to extract detailed stream information including: -- **Video streams**: codec, resolution, bitrate, frame rate, rotation +- **Video streams**: codec, resolution (as displayed), bitrate, frame rate - **Audio streams**: codec, language, sample rate, channels - **Subtitle streams**: codec, language, title - **Format info**: duration, container format diff --git a/src/plugin.ts b/src/plugin.ts index 0729b16..b4dbc5e 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -9,6 +9,7 @@ * - fileinfo/formatName = "matroska,webm" * - stream/0 = '{"type":"video","codec":"h264","width":1920,...}' (JSON string) * - stream/1 = '{"type":"audio","codec":"aac","language":"eng",...}' (JSON string) + * - resolutionW = "1920" / resolutionH = "1080" (primary video stream, promoted) * * Each stream is stored as a JSON string at the stream level. */ @@ -41,6 +42,10 @@ export const manifest: PluginManifest = { schema: { 'fileinfo/duration': { label: 'Duration', type: 'string', readonly: true }, 'fileinfo/formatName': { label: 'Format Name', type: 'string', readonly: true }, + // Pixel dimensions of the primary video stream, promoted to the top level + // (METADATA_KEYS §3) so readers don't have to walk stream/*. + 'resolutionW': { label: 'Width', type: 'string', readonly: true }, + 'resolutionH': { label: 'Height', type: 'string', readonly: true }, // Streams are stored as stream/0, stream/1, etc. - each is a JSON string 'stream/*': { label: 'Stream', @@ -72,6 +77,10 @@ interface FFprobeData { forced?: number; default?: number; }; + // fluent-ffmpeg flattens the Display Matrix [SIDE_DATA] block into the + // stream: a Number for positive degrees, a string for negative ones + // (its numeric regex has no sign) - e.g. 90, "-90", "-180". + rotation?: number | string; tags?: { language?: string; title?: string; @@ -115,6 +124,84 @@ function getFFprobeInput(filePath: string): string { return filePath; } +/** + * True when a display rotation (degrees, any sign, Number or string) turns the + * frame a quarter turn - i.e. the displayed width is the coded height. + */ +export function isQuarterTurn(rotation: unknown): boolean { + const n = Number(rotation); + return Number.isFinite(n) && Math.abs(Math.round(n)) % 180 === 90; +} + +/** + * Swap `width`/`height` in place when `rotation` is a quarter turn, and drop + * `rotation`. ffprobe reports the *coded* frame: a portrait phone clip is coded + * 1920x1080 with a display matrix that stands it upright. Records store what a + * player displays (METADATA_KEYS §3/§7), so the rotation is consumed here and + * never stored. + */ +export function applyDisplayRotation( + stream: Record, + rotation: unknown = stream.rotation +): void { + if (isQuarterTurn(rotation)) { + const { width, height } = stream; + if (height !== undefined) stream.width = height; else delete stream.width; + if (width !== undefined) stream.height = width; else delete stream.height; + } + delete stream.rotation; +} + +/** + * Promote the primary video stream's pixel dimensions to the top-level + * `resolutionW` / `resolutionH` keys (METADATA_KEYS §3). + * + * §7 (`stream/{n}`) stays the per-stream truth for multi-stream files; these + * two keys exist so no reader has to walk `stream/*` to answer the single most + * common question about a file. On locally-ingested files the values are + * authoritative — they are the *display* dimensions of the FIRST video stream, + * i.e. exactly what `stream/{n}` already carries. (A legacy stored stream that + * still carries `rotation` is normalized the same way before promotion.) + * + * Operates on the already-built `stream/{n}` map rather than on the raw + * ffprobe payload, so the fresh-probe path, the midhash cache path and the + * backfill of an already-processed record all share one implementation and + * cannot drift. + * + * Returns the keys to merge (empty when the record has no usable video + * stream); never overwrites values already present on the record. + */ +export function promoteResolution( + metadata: Record, + existing: Record = {} +): Record { + for (let i = 0; metadata[`stream/${i}`] != null; i++) { + let stream: Record; + try { + stream = JSON.parse(metadata[`stream/${i}`]); + } catch { + continue; + } + if (stream?.type !== 'video') continue; + applyDisplayRotation(stream); + + const promoted: Record = {}; + // Width and height are promoted independently: a reader must treat a + // missing `resolutionW` as unknown, not as "no resolution at all". + for (const [key, raw] of [ + ['resolutionW', stream.width], + ['resolutionH', stream.height], + ] as const) { + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) continue; + if (existing[key]) continue; + promoted[key] = String(Math.round(n)); + } + return promoted; + } + return {}; +} + export async function process( request: ProcessRequest, sendCallback: (payload: CallbackPayload) => Promise @@ -139,6 +226,13 @@ export async function process( // Check if already processed if (existingMeta?.['fileinfo/duration']) { + // Records ingested before resolutionW/resolutionH existed already + // carry the stream/* they are promoted from, so backfill them here + // rather than re-probing: it is a pure re-read of stored keys. + const backfill = promoteResolution(existingMeta, existingMeta); + if (Object.keys(backfill).length > 0) { + await metaCore.mergeMetadata(cid, backfill); + } await sendCallback({ taskId: request.taskId, status: 'skipped', @@ -245,9 +339,9 @@ export async function process( if (stream.avg_frame_rate && stream.avg_frame_rate !== '0/0') { streamData.frameRate = String(stream.avg_frame_rate); } - if (stream.tags?.rotate) { - streamData.rotation = String(stream.tags.rotate); - } + // Store display dimensions: side-data rotation (ffprobe >= 5), or + // the pre-5 `rotate` tag. + applyDisplayRotation(streamData, stream.rotation ?? stream.tags?.rotate); // Audio-specific fields if (stream.sample_rate) { @@ -277,6 +371,9 @@ export async function process( globalStreamIndex++; } + // Promote the primary video stream's dimensions to the top level + Object.assign(metadata, promoteResolution(metadata)); + // Write to meta-core await metaCore.mergeMetadata(cid, metadata); @@ -355,11 +452,19 @@ async function applyCachedMetadata( } } } + // Cache entries written before display dimensions still carry + // coded width/height + `rotation`. + applyDisplayRotation(streamData); // Store as JSON string metadata[`stream/${globalStreamIndex}`] = JSON.stringify(streamData); globalStreamIndex++; } } + // Same promotion as the fresh-probe path - a cache hit must produce the + // exact same record as a probe, or a file's keys would depend on whether + // its midhash happened to be cached. + Object.assign(metadata, promoteResolution(metadata)); + await metaCore.mergeMetadata(cid, metadata); } diff --git a/test/promote-resolution.test.ts b/test/promote-resolution.test.ts new file mode 100644 index 0000000..651970e --- /dev/null +++ b/test/promote-resolution.test.ts @@ -0,0 +1,136 @@ +/** + * Unit tests for the resolutionW/resolutionH promotion (METADATA_KEYS §3). + * + * Pure function over an already-built stream/* map - no FFprobe, no fixtures, + * so these run everywhere (unlike integration.test.ts). + */ + +import { describe, it, expect } from 'vitest'; +import { promoteResolution, applyDisplayRotation, isQuarterTurn } from '../src/plugin.js'; + +const stream = (o: Record) => JSON.stringify(o); + +// Values as fluent-ffmpeg hands them over: positive degrees parse to a Number, +// negative ones stay strings. +describe('isQuarterTurn', () => { + it.each([90, '-90', 270, '-270', '90.0'])('%s is a quarter turn', (r) => { + expect(isQuarterTurn(r)).toBe(true); + }); + it.each([0, '-180', 180, undefined, '', 'N/A'])('%s is not', (r) => { + expect(isQuarterTurn(r)).toBe(false); + }); +}); + +describe('applyDisplayRotation', () => { + it('swaps coded dimensions to display dimensions on a quarter turn', () => { + const s: Record = { type: 'video', width: 1920, height: 1080 }; + applyDisplayRotation(s, '-90'); + expect(s).toEqual({ type: 'video', width: 1080, height: 1920 }); + }); + + it('leaves a half turn unswapped and drops the rotation field', () => { + const s: Record = { width: 1920, height: 1080, rotation: -180 }; + applyDisplayRotation(s); + expect(s).toEqual({ width: 1920, height: 1080 }); + }); + + it('moves a lone axis to the other side', () => { + const s: Record = { height: 1080, rotation: 90 }; + applyDisplayRotation(s); + expect(s).toEqual({ width: 1080 }); + }); +}); + +describe('promoteResolution', () => { + it('promotes the primary video stream dimensions', () => { + expect( + promoteResolution({ + 'stream/0': stream({ type: 'video', codec: 'h264', width: 1920, height: 1080 }), + 'stream/1': stream({ type: 'audio', codec: 'eac3' }), + }) + ).toEqual({ resolutionW: '1920', resolutionH: '1080' }); + }); + + it('uses the FIRST video stream, not a later one', () => { + expect( + promoteResolution({ + 'stream/0': stream({ type: 'audio', codec: 'aac' }), + 'stream/1': stream({ type: 'video', width: 1280, height: 720 }), + 'stream/2': stream({ type: 'video', width: 640, height: 480 }), + }) + ).toEqual({ resolutionW: '1280', resolutionH: '720' }); + }); + + it('emits string values, never numbers (int-string format)', () => { + const out = promoteResolution({ + 'stream/0': stream({ type: 'video', width: 1920, height: 1080 }), + }); + expect(typeof out.resolutionW).toBe('string'); + expect(typeof out.resolutionH).toBe('string'); + }); + + it('writes each axis independently when one is missing', () => { + expect( + promoteResolution({ 'stream/0': stream({ type: 'video', height: 1080 }) }) + ).toEqual({ resolutionH: '1080' }); + }); + + it('returns nothing when there is no video stream', () => { + expect( + promoteResolution({ + 'stream/0': stream({ type: 'audio', codec: 'flac' }), + 'stream/1': stream({ type: 'subtitle', codec: 'subrip' }), + }) + ).toEqual({}); + }); + + it('returns nothing when the video stream has no usable dimensions', () => { + expect( + promoteResolution({ 'stream/0': stream({ type: 'video', width: 0, height: 0 }) }) + ).toEqual({}); + }); + + it('never overwrites values already on the record', () => { + expect( + promoteResolution( + { 'stream/0': stream({ type: 'video', width: 1920, height: 1080 }) }, + { resolutionW: '1440', resolutionH: '1080' } + ) + ).toEqual({}); + }); + + it('backfills only the axis that is missing', () => { + expect( + promoteResolution( + { 'stream/0': stream({ type: 'video', width: 1920, height: 1080 }) }, + { resolutionH: '1080' } + ) + ).toEqual({ resolutionW: '1920' }); + }); + + it('survives a malformed stream entry', () => { + expect( + promoteResolution({ + 'stream/0': 'not json', + 'stream/1': stream({ type: 'video', width: 3840, height: 2160 }), + }) + ).toEqual({ resolutionW: '3840', resolutionH: '2160' }); + }); + + it('promotes display dimensions from a legacy stream that still carries rotation', () => { + expect( + promoteResolution({ + 'stream/0': stream({ type: 'video', width: 1920, height: 1080, rotation: '90' }), + }) + ).toEqual({ resolutionW: '1080', resolutionH: '1920' }); + }); + + it('stops at the first gap in the stream index', () => { + expect( + promoteResolution({ + 'stream/0': stream({ type: 'audio' }), + 'stream/2': stream({ type: 'video', width: 1920, height: 1080 }), + }) + ).toEqual({}); + }); +});