fix resolution
Build and Push Docker Image / build (push) Failing after 18s

This commit is contained in:
Worph
2026-09-12 21:35:44 +02:00
parent 52715891e0
commit 1b194bf965
3 changed files with 245 additions and 4 deletions
+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);
}