Files
2026-08-28 01:01:12 +02:00

119 lines
4.5 KiB
JavaScript

import { H3_FPS } from './state.js';
import { localPreviewFrameIndices, MAX_PREVIEW_FRAMES, sampleLocalVideo } from './local_media.js';
import { processedVideoQuery, resolveInputMedia } from './preview_sources.js';
function frameLabel(count, source) {
return `${source} · ${count} frame${count === 1 ? '' : 's'}`;
}
function pickFrames(urls, maxFrames) {
const indices = localPreviewFrameIndices(urls.length, maxFrames);
return indices.map((frame) => ({ frame, url: urls[frame] }));
}
// Loader preview params are only meaningful when they override a default; sending the
// neutral values would just pollute the /view URL of a plain video loader.
function activePreviewParams(params) {
return Object.fromEntries(Object.entries(params || {}).filter(([name, value]) => {
if (value === null || value === undefined || value === '' || Number(value) === 0) return false;
return !(name === 'select_every_nth' && Number(value) === 1);
}));
}
/**
* Sampling seeks, so it needs a byte-range seekable URL. A loader's own preview URL is
* usually a chunked live transcode (no Range, no duration): the original file wins, and
* its trim is replayed from outputSettings instead of asking the loader to apply it.
*/
export function sampleUrlFor(media) {
const raw = String(media?.rawUrl || '');
if (raw) return raw;
const url = String(media?.url || '');
if (!url) return '';
const params = activePreviewParams(media?.previewParams);
if (!Object.keys(params).length) return url;
return `${url.split('?')[0]}?${processedVideoQuery(url, params)}`;
}
/**
* Maps a loader's trim settings onto the output stream the filmstrip must mirror.
* When the loader forces no rate, H3's own 24 fps is assumed for the offset math.
*/
export function outputMappingFor(media) {
const settings = media?.outputSettings;
if (!settings) {
return { fps: Number(media?.fps) > 0 ? Number(media.fps) : H3_FPS, startFrame: 0, stride: 1 };
}
return {
fps: Number(settings.forceRate) > 0 ? Number(settings.forceRate) : H3_FPS,
startFrame: Math.max(0, Math.round(Number(settings.skipFirstFrames) || 0)),
stride: Math.max(1, Math.round(Number(settings.selectEveryNth) || 1)),
};
}
/**
* Turns a resolved upstream media descriptor into a filmstrip build plan.
* Pure: no DOM, no network. `video` plans still need sampling.
*/
export function referencePlan(media, { maxFrames = MAX_PREVIEW_FRAMES } = {}) {
if (!media || media.status !== 'ready' || media.kind === 'none') {
return { kind: 'error', message: 'Connect a Load Video or an IMAGE batch to reference_media first.' };
}
if (media.kind === 'frames' || media.kind === 'image') {
const urls = (media.urls || []).filter(Boolean);
if (!urls.length) return { kind: 'error', message: 'The connected source exposes no frame preview yet.' };
return {
kind: 'frames',
frameCount: urls.length,
frames: pickFrames(urls, maxFrames),
label: frameLabel(urls.length, media.kind === 'image' ? 'reference image' : 'reference batch'),
};
}
if (media.kind === 'video') {
const url = sampleUrlFor(media);
if (!url) return { kind: 'error', message: 'The connected video loader has no readable file yet.' };
const mapping = outputMappingFor(media);
return {
kind: 'video',
url,
maxFrames,
frameCountHint: Math.max(0, Math.round(Number(media.frameCount) || 0)),
label: 'reference video',
...mapping,
};
}
return { kind: 'error', message: `Unsupported reference source (${media.kind}).` };
}
/**
* Resolves the media connected to `inputName` and samples reference frames for the
* filmstrip. Reads the upstream loader's own preview state, so no graph execution
* is required.
*/
export async function buildReferenceFrames(node, inputName, {
toUrl,
maxFrames = MAX_PREVIEW_FRAMES,
sampleVideo = sampleLocalVideo,
onProgress,
} = {}) {
const media = resolveInputMedia(node, inputName, toUrl);
const plan = referencePlan(media, { maxFrames });
if (plan.kind === 'error') throw new Error(plan.message);
if (plan.kind === 'frames') {
return { frameCount: plan.frameCount, frames: plan.frames, label: plan.label };
}
const sampled = await sampleVideo(plan.url, {
fps: plan.fps,
maxFrames: plan.maxFrames,
frameCountHint: plan.frameCountHint,
startFrame: plan.startFrame,
stride: plan.stride,
onProgress,
});
return {
frameCount: sampled.frameCount,
frames: sampled.frames,
label: frameLabel(sampled.frameCount, plan.label),
};
}