fix resolution

This commit is contained in:
Worph
2026-09-12 21:35:44 +02:00
parent 52715891e0
commit 43eaaf4e26
3 changed files with 245 additions and 4 deletions
+1 -1
View File
@@ -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
+108 -3
View File
@@ -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<string, unknown>,
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<string, string>,
existing: Record<string, string> = {}
): Record<string, string> {
for (let i = 0; metadata[`stream/${i}`] != null; i++) {
let stream: Record<string, unknown>;
try {
stream = JSON.parse(metadata[`stream/${i}`]);
} catch {
continue;
}
if (stream?.type !== 'video') continue;
applyDisplayRotation(stream);
const promoted: Record<string, string> = {};
// 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<void>
@@ -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);
}
+136
View File
@@ -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<string, unknown>) => 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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({});
});
});