Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions client/dive-common/apispec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ interface DatasetMeta extends DatasetMetaMutable {
id: Readonly<string>;
imageData: Readonly<FrameImage[]>;
videoUrl: Readonly<string | undefined>;
// Path to original video for native (non-transcoded) playback via frame extraction
nativeVideoPath?: Readonly<string>;
type: Readonly<DatasetType | 'multi'>;
fps: Readonly<number>; // this will become mutable in the future.
name: Readonly<string>;
Expand Down
14 changes: 12 additions & 2 deletions client/dive-common/components/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { provideAnnotator } from 'vue-media-annotator/provides';
import {
ImageAnnotator,
VideoAnnotator,
NativeVideoAnnotator,
LargeImageAnnotator,
LayerManager,
useMediaController,
Expand Down Expand Up @@ -63,6 +64,7 @@ export default defineComponent({
Sidebar,
LayerManager,
VideoAnnotator,
NativeVideoAnnotator,
ImageAnnotator,
LargeImageAnnotator,
ConfidenceFilter,
Expand Down Expand Up @@ -119,6 +121,7 @@ export default defineComponent({
const datasetName = ref('');
const saveInProgress = ref(false);
const videoUrl: Ref<Record<string, string>> = ref({});
const nativeVideoPath: Ref<Record<string, string>> = ref({});
const {
loadDetections, loadMetadata, saveMetadata, getTiles, getTileURL,
} = useApi();
Expand Down Expand Up @@ -597,6 +600,9 @@ export default defineComponent({
if (subCameraMeta.videoUrl) {
videoUrl.value[camera] = subCameraMeta.videoUrl;
}
if (subCameraMeta.nativeVideoPath) {
nativeVideoPath.value[camera] = subCameraMeta.nativeVideoPath;
}
cameraStore.addCamera(camera);
addSaveCamera(camera);
// eslint-disable-next-line no-await-in-loop
Expand Down Expand Up @@ -865,6 +871,7 @@ export default defineComponent({
selectedKey,
trackFilters,
videoUrl,
nativeVideoPath,
visibleModes,
frameRate: time.frameRate,
originalFps: time.originalFps,
Expand Down Expand Up @@ -1139,14 +1146,17 @@ export default defineComponent({
>
<component
:is="datasetType === 'image-sequence' ? 'image-annotator'
: datasetType === 'video' ? 'video-annotator' : 'large-image-annotator'"
v-if="(imageData[camera].length || videoUrl[camera]) && progress.loaded"
: datasetType === 'video'
? (nativeVideoPath[camera] ? 'native-video-annotator' : 'video-annotator')
: 'large-image-annotator'"
v-if="(imageData[camera].length || videoUrl[camera] || nativeVideoPath[camera]) && progress.loaded"
ref="subPlaybackComponent"
class="fill-height"
:class="{ 'selected-camera': selectedCamera === camera && camera !== 'singleCam' }"
v-bind="{
imageData: imageData[camera],
videoUrl: videoUrl[camera],
nativeVideoPath: nativeVideoPath[camera],
updateTime,
frameRate,
originalFps,
Expand Down
28 changes: 25 additions & 3 deletions client/platform/desktop/backend/native/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ async function loadMetadata(
const projectMetaData = await loadJsonMetadata(projectDirData.metaFileAbsPath);

let videoUrl = '';
let nativeVideoPath: string | undefined;
let imageData = [] as FrameImage[];
let multiCamMedia: MultiCamMedia | null = null;
const { subType } = projectMetaData;
Expand All @@ -277,8 +278,21 @@ async function loadMetadata(
imageData = defaultDisplay.imageData;
videoUrl = defaultDisplay.videoUrl;
} else if (projectMetaData.type === 'video') {
/* If the video has been transcoded, use that video */
if (projectMetaData.transcodedVideoFile) {
// Get the original video path for native playback
const originalVideoPath = npath.join(
projectMetaData.originalBasePath,
projectMetaData.originalVideoFile,
);

/* If using native playback (no transcoding), provide the native video path */
if (projectMetaData.useNativePlayback) {
// For native playback, we pass the file path directly (not a URL)
// The frontend will use the frame extraction API
nativeVideoPath = originalVideoPath;
// Still provide videoUrl as empty - frontend will use nativeVideoPath instead
videoUrl = '';
} else if (projectMetaData.transcodedVideoFile) {
/* If the video has been transcoded, use that video */
const video = npath.join(projectDirData.basePath, projectMetaData.transcodedVideoFile);
videoUrl = makeMediaUrl(video);
} else {
Expand Down Expand Up @@ -307,6 +321,7 @@ async function loadMetadata(
return {
...projectMetaData,
videoUrl,
nativeVideoPath,
imageData,
multiCamMedia,
subType,
Expand Down Expand Up @@ -1021,6 +1036,7 @@ async function beginMediaImport(path: string): Promise<DesktopMediaImportRespons
mediaConvertList,
trackFileAbsPath,
forceMediaTranscode: false,
useNativePlayback: false,
multiCamTrackFiles: null,
metaFileAbsPath,
};
Expand Down Expand Up @@ -1142,7 +1158,13 @@ async function finalizeMediaImport(
jsonMeta.fps = (
Math.max(1, Math.min(jsonMeta.fps, jsonMeta.originalFps))
);
if (args.forceMediaTranscode) {

// If using native playback, skip conversion entirely
if (args.useNativePlayback) {
jsonMeta.useNativePlayback = true;
// Clear any conversion list for videos when using native playback
mediaConvertList = [];
} else if (args.forceMediaTranscode) {
mediaConvertList.push(npath.join(jsonMeta.originalBasePath, jsonMeta.originalVideoFile));
}
}
Expand Down
257 changes: 257 additions & 0 deletions client/platform/desktop/backend/native/frameExtraction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
/**
* Frame extraction service for native video playback.
* Extracts frames on-demand using FFmpeg without requiring
* full video transcoding.
*/
import { spawn } from 'child_process';
import { getBinaryPath, spawnResult } from './utils';

const ffmpegPath = getBinaryPath('ffmpeg-ffprobe-static/ffmpeg');
const ffprobePath = getBinaryPath('ffmpeg-ffprobe-static/ffprobe');

// LRU cache for extracted frames
interface CachedFrame {
data: Buffer;
timestamp: number;
}

interface FrameCache {
frames: Map<number, CachedFrame>;
maxSize: number;
}

const frameCaches: Map<string, FrameCache> = new Map();
const MAX_CACHE_SIZE = 100; // Max frames per video
const CACHE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes

interface VideoInfo {
fps: number;
duration: number;
width: number;
height: number;
frameCount: number;
}

/**
* Get video information using ffprobe
*/
async function getVideoInfo(videoPath: string): Promise<VideoInfo> {
const args = [
'-v', 'quiet',
'-print_format', 'json',
'-show_format',
'-show_streams',
videoPath,
];

const result = await spawnResult(ffprobePath, args);
if (result.error || result.output === null) {
throw new Error(`Failed to probe video: ${result.error || 'Unknown error'}`);
}

const info = JSON.parse(result.output);
const videoStream = info.streams?.find((s: { codec_type: string }) => s.codec_type === 'video');

if (!videoStream) {
throw new Error('No video stream found');
}

// Parse frame rate (e.g., "30000/1001" or "30/1")
const fpsStr = videoStream.avg_frame_rate || videoStream.r_frame_rate || '30/1';
const [num, den] = fpsStr.split('/').map(Number);
const fps = den ? num / den : num;

const duration = parseFloat(info.format?.duration || videoStream.duration || '0');
const frameCount = Math.floor(duration * fps);

return {
fps,
duration,
width: videoStream.width || 0,
height: videoStream.height || 0,
frameCount,
};
}

/**
* Get or create a frame cache for a video
*/
function getFrameCache(videoPath: string): FrameCache {
let cache = frameCaches.get(videoPath);
if (!cache) {
cache = {
frames: new Map(),
maxSize: MAX_CACHE_SIZE,
};
frameCaches.set(videoPath, cache);
}
return cache;
}

/**
* Evict old entries from the cache
*/
function evictOldEntries(cache: FrameCache) {
const now = Date.now();
const entries = Array.from(cache.frames.entries());

// Remove expired entries
entries.forEach(([frame, cached]) => {
if (now - cached.timestamp > CACHE_EXPIRY_MS) {
cache.frames.delete(frame);
}
});

// If still over capacity, remove oldest entries
if (cache.frames.size > cache.maxSize) {
const sortedEntries = entries
.filter(([frame]) => cache.frames.has(frame))
.sort((a, b) => a[1].timestamp - b[1].timestamp);

const toRemove = sortedEntries.slice(0, cache.frames.size - cache.maxSize);
toRemove.forEach(([frame]) => cache.frames.delete(frame));
}
}

/**
* Extract a single frame from a video at the specified frame number
* Returns the frame as a JPEG buffer
*/
async function extractFrame(
videoPath: string,
frameNumber: number,
fps: number,
): Promise<Buffer> {
const cache = getFrameCache(videoPath);

// Check cache first
const cached = cache.frames.get(frameNumber);
if (cached) {
cached.timestamp = Date.now(); // Update access time
return cached.data;
}

// Calculate timestamp from frame number
const timestamp = frameNumber / fps;

// Extract frame using ffmpeg
// Use -ss before -i for fast seeking to nearest keyframe
// Then use accurate seeking with filter
const args = [
'-ss', timestamp.toFixed(6),
'-i', videoPath,
'-vframes', '1',
'-f', 'image2pipe',
'-vcodec', 'mjpeg',
'-q:v', '2', // High quality JPEG
'-',
];

return new Promise((resolve, reject) => {
const ffmpeg = spawn(ffmpegPath, args);
const chunks: Buffer[] = [];

ffmpeg.stdout.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});

ffmpeg.stderr.on('data', () => {
// Ignore stderr output (progress info)
});

ffmpeg.on('close', (code) => {
if (code === 0 && chunks.length > 0) {
const frameData = Buffer.concat(chunks);

// Cache the frame
cache.frames.set(frameNumber, {
data: frameData,
timestamp: Date.now(),
});
evictOldEntries(cache);

resolve(frameData);
} else {
reject(new Error(`FFmpeg exited with code ${code}`));
}
});

ffmpeg.on('error', (err) => {
reject(err);
});
});
}

/**
* Process frames in batches with limited concurrency
*/
async function processBatches(
batches: number[][],
videoPath: string,
fps: number,
): Promise<void> {
if (batches.length === 0) return;
const [batch, ...remaining] = batches;
await Promise.all(batch.map((frame) => extractFrame(videoPath, frame, fps).catch(() => null)));
await processBatches(remaining, videoPath, fps);
}
Comment on lines +188 to +197
Copy link
Collaborator

@BryonLewis BryonLewis Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd have to look into how this specific ffmpeg comand works (in extractFrame) but I'm guessing you probably wouldn't want to be spawning a new process for each frame because of the overhead. There should be an option to be able to a range of frames in a single process.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a follow-up to address this with an optional setting, but it depends on code in the other branch landing first (the multi-camera option)


/**
* Pre-extract multiple frames around a given frame (for smoother playback)
*/
async function prefetchFrames(
videoPath: string,
centerFrame: number,
fps: number,
range: number = 5,
): Promise<void> {
const cache = getFrameCache(videoPath);
const framesToFetch: number[] = [];

// Determine which frames to prefetch (not already cached)
for (let i = -range; i <= range; i += 1) {
const frame = centerFrame + i;
if (frame >= 0 && !cache.frames.has(frame)) {
framesToFetch.push(frame);
}
}

// Fetch in parallel (limited concurrency)
const concurrency = 3;
const batches: number[][] = [];
for (let i = 0; i < framesToFetch.length; i += concurrency) {
batches.push(framesToFetch.slice(i, i + concurrency));
}
await processBatches(batches, videoPath, fps);
}

/**
* Clear the frame cache for a specific video
*/
function clearCache(videoPath?: string) {
if (videoPath) {
frameCaches.delete(videoPath);
} else {
frameCaches.clear();
}
}

/**
* Get cache statistics
*/
function getCacheStats(videoPath: string): { cachedFrames: number; maxSize: number } {
const cache = frameCaches.get(videoPath);
return {
cachedFrames: cache?.frames.size || 0,
maxSize: MAX_CACHE_SIZE,
};
}

export {
extractFrame,
prefetchFrames,
getVideoInfo,
clearCache,
getCacheStats,
VideoInfo,
};
Loading
Loading