VulpesOS/gaia/apps/video/js/metadata.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

419 lines
15 KiB
JavaScript
Executable file

/* global playerShowing,showThrobber,hideThrobber,updateDialog,videodb,
addVideo,getVideoRotation,THUMBNAIL_WIDTH,THUMBNAIL_HEIGHT,MediaDB */
/* exported addToMetadataQueue,stopParsingMetadata */
// This file is part of the Gaia Video app.
//
// It includes functions to obtain metadata for video files. The most
// important parts of this metadata are obtained by loading the video into
// an offscreen <video> element.
//
// The phones we want to run on have only a single hardware h.264 video
// decoder, and gecko is not yet smart enough to share the hardware among
// all the <video> elements that want to use it. So all apps (camera,
// gallery and video) that have permission to use the video hardware must be
// careful to only use it while they are in the foreground and to relinquish
// it when they go to the background.
//
// The video app wants to use <video> elements for metadata parsing and for
// playing videos for the user. It can't do both of these things at the same
// time. The MediaDB metadata parsing architecture assumes that it can,
// however. So the Video app does not pass a metadata parser function to
// MediaDB. MediaDB notifies the app about new video files but does not
// include any metadata with those notifications. Instead, we use the
// queueing and metadata parsing functions in this file to handle metadata
// parsing in an interruptible way.
//
// When we have a new video file that needs metadata, we pass it to
// addToMetadataQueue(). When we need to stop parsing metadata (because we
// want to play a video or because we went to the background) we call
// stopParsingMetadata(). And when we're able to parse metadata again we
// call startParsingMetadata(). When a queued video is processed and its
// metadata is ready, it is saved to MediaDB, and then addVideo() is invoked
// to display the video title and thumbnail.
//
'use strict';
var metadataQueue = [];
var processingQueue = false;
var stopParsingMetadataCallback = null;
// noMoreWorkCallback is fired when the queue is processed or empty.
var noMoreWorkCallback = null;
// This function queues a fileinfo object with no metadata. When the app is
// able it will obtain metadata for the video and pass the updated fileinfo
// to addVideo() so that it becomes visible to the user.
function addToMetadataQueue(fileinfo) {
metadataQueue.push(fileinfo);
startParsingMetadata();
}
// Start or resume metadata parsing, if conditions are right
function startParsingMetadata() {
// If we're already working, return right away
if (processingQueue) {
return;
}
// If there is no work queued, fire noMoreWorkCallback event and return right
// away.
if (metadataQueue.length === 0) {
if (noMoreWorkCallback) {
noMoreWorkCallback();
noMoreWorkCallback = null;
}
return;
}
// Don't parse metadata if we are not the foreground app. When we're
// in the background we need to allow the foreground app to use the
// video hardware. Also, if the video player is showing then we
// can't parse metadata because we're already using the video hardware.
if (document.hidden || playerShowing) {
return;
}
// If our MediaDB object is not ready, it means that we can't read
// video files so can't process metadata yet. We just return in this
// case. The onready event handler in db.js will call this function
// when the db becomes ready.
if (videodb.state !== MediaDB.READY) {
return;
}
// Start processing the queue
processingQueue = true;
showThrobber();
processFirstQueuedItem();
}
// Stop parsing metadata. If a callback is specified, call it when stopped.
function stopParsingMetadata(callback) {
// If we're not processing metadata, just call the callback right away
if (!processingQueue) {
if (callback) {
callback();
}
return;
}
// Otherwise, request a stop.
// This variable acts as a flag to prevent processFirstQueuedItem from
// creating any more metadatas.
stopParsingMetadataCallback = callback || true;
}
// If we've been asked to stop processing, then run the callback, and stop.
// Otherwise, if there is nothing in the queue, do nothing. Otherwise, take
// the first item off the queue, obtain metadata for it, save the metadata
// to the database, and then call addVideo() to display the newly processed
// video to the user. Finally, use setTimeout() to schedule a new call to
// this same function.
//
// XXX: are there any race conditions here?
// Are we guaranteed to always stop and to always call the callback?
//
function processFirstQueuedItem() {
// If the stop flag is set, call the callback and stop
if (stopParsingMetadataCallback) {
var callback = stopParsingMetadataCallback;
stopParsingMetadataCallback = null;
processingQueue = false;
hideThrobber();
if (callback !== true) {
callback(); // Okay, we've stopped.
}
return;
}
// If there is no work queued, update dialog in case there are no playable
// videos; then return right away
if (metadataQueue.length === 0) {
processingQueue = false;
hideThrobber();
updateDialog();
// Check explicitly for noMoreWorkCallBack as function, see bug 972651
if (typeof(noMoreWorkCallback) === 'function') {
noMoreWorkCallback();
}
return;
}
// Otherwise, take a fileinfo object from the front of the queue and
// process it with a <video> tag to obtain size, duration and a poster
// image. This happens asynchronously. When it is done, we call
// processFirstQueuedItem() again to re-check the stop flag and process
// the next item on the queue.
var fileinfo = metadataQueue.shift();
// XXX
// The code below is brittle because it does not have exception handlers.
// If anything goes wrong with this getFile() call or any of the nexted
// functions that get called asynchronously, then we may never advance to
// the next item in the queue and the metadata parsing process will just
// freeze up forever. The fix, I suppose is to add try/catch for each
// async step (or to convert to promises). See Bug 1229651 for a specific
// example of what happens when getFile() throws an exception.
videodb.getFile(fileinfo.name, function(file) {
getMetadata(file, function(metadata) {
// Associate the metadata with this fileinfo object
fileinfo.metadata = metadata;
// Save it to the database
videodb.updateMetadata(fileinfo.name, metadata, function() {
// Create and insert a thumbnail for the video
if (metadata.isVideo) {
videodb.getFileInfo(fileinfo.name, function(dbfileinfo) {
addVideo(dbfileinfo);
});
}
});
// And process the next video in the queue
setTimeout(processFirstQueuedItem);
});
}, function(err) {
console.error('getFile error: ', fileinfo.name, err);
processFirstQueuedItem();
});
}
// Given a video File object, asynchronously pass an object of metadata to
// the specified callback.
function getMetadata(videofile, callback) {
// This is the video element that will get the metadata for us.
// Because of an apparent bug in gecko, it needs to be here rather than
// something that is shared globally.
var offscreenVideo = document.createElement('video');
var metadata = {};
// If its a video type we don't know how to play, ignore it.
if (!offscreenVideo.canPlayType(videofile.type)) {
metadata.isVideo = false;
callback(metadata);
return;
}
// Create a blob: url for the file. It will be revoked in unload().
var url = URL.createObjectURL(videofile);
// Load the video into an offscreen <video> element.
offscreenVideo.preload = 'metadata';
offscreenVideo.src = url;
//
// There seems to have been a gecko regression, and error events are no
// longer being fired when we try to play some zero-length or truncated
// video files. To workaround this, we use a timeout to assume the
// video is unplayable if we nave not gotten any events within a reasonable
// amount of time.
//
// See bug 1198169
//
const METADATA_TIMEOUT = 3000;
var timeoutId = setTimeout(function() {
console.error('No loadedmetadata or error events seen yet.',
'Assuming video file is corrupt:', videofile.name);
offscreenVideo.onerror();
}, METADATA_TIMEOUT);
offscreenVideo.onerror = function(e) {
// Something went wrong. Maybe the file was corrupt?
console.error('Can\'t play video', videofile.name, e);
metadata.isVideo = false;
clearTimeout(timeoutId);
unload();
callback(metadata);
};
offscreenVideo.onloadedmetadata = function() {
// We got metadata, so we don't need the timeout
clearTimeout(timeoutId);
// If videoWidth is 0 then this is likely an audio file (ogg / mp4)
// with an ambiguous filename extension that makes it look like a video.
// This test only works correctly if we're using a new offscreen video
// element each time. If I try to make the element a global, then it
// retains the size of the last video when a non-video is loaded.
if (!offscreenVideo.videoWidth) {
metadata.isVideo = false;
unload();
callback(metadata);
return;
}
// Otherwise it is a video!
metadata.isVideo = true;
// read title from metadata or fallback to filename.
metadata.title = readFromMetadata('title') ||
fileNameToVideoName(videofile.name);
// The video element tells us the video duration and size.
metadata.duration = offscreenVideo.duration;
metadata.width = offscreenVideo.videoWidth;
metadata.height = offscreenVideo.videoHeight;
// If this is a .3gp video file, look for its rotation matrix and
// then create the thumbnail. Otherwise set rotation to 0 and
// create the thumbnail immediately. getVideoRotation is defined
// in shared/js/media/get_video_rotation.js
if (/.3gp$/.test(videofile.name)) {
getVideoRotation(videofile, function(rotation) {
if (typeof rotation === 'number') {
metadata.rotation = rotation;
}
else if (typeof rotation === 'string') {
console.warn('Video rotation:', rotation);
}
createThumbnail();
});
} else {
metadata.rotation = 0;
createThumbnail();
}
};
// The text case of key in metadata is not always lower or upper cases. That
// depends on the creation tools. This function helps to read keys in lower
// cases and returns the value of corresponding key.
function readFromMetadata(lowerCaseKey) {
var tags = offscreenVideo.mozGetMetadata();
for (var key in tags) {
// to lower case and match it.
if (key.toLowerCase() === lowerCaseKey) {
return tags[key];
}
}
// no matched key, return undefined.
return;
}
function createThumbnail() {
// Videos often begin with a black screen, so skip ahead 5 seconds
// or 1/10th of the video, whichever is shorter in the hope that we'll
// get a more interesting thumbnail that way.
// Because of bug 874692, corrupt video files may not be seekable,
// and may not fire an error event, so if we aren't able to seek
// after a certain amount of time, we'll abort and assume that the
// video is invalid.
var t = Math.min(5, offscreenVideo.duration / 10);
offscreenVideo.fastSeek(t);
var failed = false; // Did seeking fail?
var timeout = setTimeout(fail, 10000); // Fail after 10 seconds
offscreenVideo.onerror = fail; // Or if we get an error event
function fail() { // Seeking failure case
console.warn('Seek failed while creating thumbnail for', videofile.name,
'. Ignoring corrupt file.');
failed = true;
clearTimeout(timeout);
offscreenVideo.onerror = null;
metadata.isVideo = false;
unload();
callback(metadata);
}
offscreenVideo.onseeked = function() { // Seeking success case
// Avoid race condition: if we already failed, stop now
if (failed) {
return;
}
clearTimeout(timeout);
captureFrame(offscreenVideo, metadata, function(poster) {
metadata.poster = poster;
unload();
callback(metadata); // We've got all the metadata we need now.
});
};
}
// Free the resources being used by the offscreen video element
function unload() {
URL.revokeObjectURL(offscreenVideo.src);
offscreenVideo.removeAttribute('src');
offscreenVideo.load();
}
// Derive the video title from its filename.
function fileNameToVideoName(filename) {
filename = filename.split('/').pop()
.replace(/\.(webm|ogv|ogg|mp4|3gp)$/, '')
.replace(/[_\.]/g, ' ');
return filename.charAt(0).toUpperCase() + filename.slice(1);
}
}
function captureFrame(player, metadata, callback) {
try {
// Create a new canvas, and set its size
var canvas = document.createElement('canvas');
canvas.width = THUMBNAIL_WIDTH;
canvas.height = THUMBNAIL_HEIGHT;
// Now create the context for the canvas after the size is set.
// The flag is a hint that we're going to be calling toBlob() on
// this canvas and that it is more efficient to use a software canvas
// than it is to do this on the GPU.
var ctx = canvas.getContext('2d', { willReadFrequently: true });
var vw = player.videoWidth, vh = player.videoHeight;
var tw, th;
// If a rotation is specified, rotate the canvas context
switch (metadata.rotation) {
case 90:
ctx.translate(THUMBNAIL_WIDTH, 0);
ctx.rotate(Math.PI / 2);
tw = THUMBNAIL_HEIGHT;
th = THUMBNAIL_WIDTH;
break;
case 180:
ctx.translate(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT);
ctx.rotate(Math.PI);
tw = THUMBNAIL_WIDTH;
th = THUMBNAIL_HEIGHT;
break;
case 270:
ctx.translate(0, THUMBNAIL_HEIGHT);
ctx.rotate(-Math.PI / 2);
tw = THUMBNAIL_HEIGHT;
th = THUMBNAIL_WIDTH;
break;
default:
tw = THUMBNAIL_WIDTH;
th = THUMBNAIL_HEIGHT;
break;
}
// Need to find the minimum ratio between heights and widths,
// so the image (especailly the square thumbnails) would fit
// in the container with right ratio and no extra stretch.
// x and y are right/left and top/bottom margins and where we
// start drawing the image. Since we scale the image, x and y
// will be scaled too. Below gives us x and y actual pixels
// without scaling.
var scale = Math.min(tw / vw, th / vh),
w = scale * vw, h = scale * vh,
x = (tw - w) / 2 / scale, y = (th - h) / 2 / scale;
// Scale the image
ctx.scale(scale, scale);
// Draw the current video frame into the image
ctx.drawImage(player, x, y);
// Convert it to an image file and pass to the callback.
canvas.toBlob(done, 'image/jpeg');
}
catch (e) {
console.error('Exception in captureFrame:', e, e.stack);
done(null);
}
function done(blob) {
canvas.width = 0; // Free canvas memory
ctx = canvas = null; // Prevent leaks, just to be safe
callback(blob); // Return the frame thumbnail to the caller
}
}