119 lines
4.5 KiB
JavaScript
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),
|
|
};
|
|
}
|