This commit is contained in:
+108
-3
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user