gkozlenko/node-video-lib

View on GitHub
lib/mp4/builder-impl.js

Summary

Maintainability
D
1 day
Test Coverage
A
93%
'use strict';

const fs = require('fs');

const Utils = require('./utils');
const BufferUtils = require('../buffer-utils');
const SourceReader = require('../readers/source-reader');
const VideoTrack = require('../video-track');
const AudioTrack = require('../audio-track');
const VideoSample = require('../video-sample');

const AtomMoov = require('./atoms/atom-moov');
const AtomFtyp = require('./atoms/atom-ftyp');

const CodecParser = require('../codecs/parser');
const CodecH264 = require('../codecs/codec-h264');
const CodecH265 = require('../codecs/codec-h265');
const CodecAac = require('../codecs/codec-aac');

const MAX_OFFSET_32 = 0xefffffff;
const MAX_AUDIO_SAMPLES_PACK = 10;

class BuilderImpl {

    constructor(movie, source, fd) {
        this.movie = movie;
        this.reader = SourceReader.create(source);
        this.fd = fd;
    }

    build() {
        // Tracks and Samples
        let tracks = [this.movie.videoTrack(), this.movie.audioTrack()]
            .filter(track => track !== null && track.samples.length > 0);
        let samples = tracks.reduce((array, track) => array.concat(track.samples), [])
            .sort((a, b) => a.offset - b.offset);

        // Chunks
        let videoChunks = [];
        let audioChunks = [];

        // Build chunks
        let offset = 0;
        let audioOffset = 0;
        let audioSamples = [];
        for (let i = 0, l = samples.length; i < l; i++) {
            let sample = samples[i];
            if (sample instanceof VideoSample) {
                if (audioSamples.length > 0) {
                    audioChunks.push({
                        offset: audioOffset,
                        samples: audioSamples,
                    });
                    audioSamples = [];
                }
                videoChunks.push({
                    offset: offset,
                    samples: [sample],
                });
            } else {
                if (audioSamples.length === MAX_AUDIO_SAMPLES_PACK) {
                    audioChunks.push({
                        offset: audioOffset,
                        samples: audioSamples,
                    });
                    audioSamples = [];
                }
                if (audioSamples.length === 0) {
                    audioOffset = offset;
                }
                audioSamples.push(sample);
            }
            offset += sample.size;
        }
        if (audioSamples.length > 0) {
            audioChunks.push({
                offset: audioOffset,
                samples: audioSamples,
            });
        }

        // Calculate MDAT size
        let mdatSize = samples.reduce((size, sample) => size + sample.size, 0);

        // Header
        let ftypAtom = new AtomFtyp();
        ftypAtom.majorBrand = 'isom';
        ftypAtom.minorVersion = 0x200;
        ftypAtom.compatibleBrands = ['isom', 'iso2', 'mp41'];
        let codecBrand = null;

        // Generate movie structure
        let moovAtom = new AtomMoov();

        // Movie header
        let mvhdAtom = moovAtom.createAtom(Utils.ATOM_MVHD);
        mvhdAtom.duration = this.movie.duration;
        mvhdAtom.timescale = this.movie.timescale;
        mvhdAtom.nextTrackId = 1;

        // Offset atoms
        let offsetAtoms = {};

        // Tracks
        let trackId = 0;
        for (let track of tracks) {
            let codec;
            try {
                codec = CodecParser.parse(track.extraData);
            } catch (e) {
                // Skip track with unknown codec
                continue;
            }

            // Track header
            let trakAtom = moovAtom.createAtom(Utils.ATOM_TRAK);
            let tkhdAtom = trakAtom.createAtom(Utils.ATOM_TKHD);
            let mdiaAtom = trakAtom.createAtom(Utils.ATOM_MDIA);
            let mdhdAtom = mdiaAtom.createAtom(Utils.ATOM_MDHD);
            mdhdAtom.duration = track.duration;
            mdhdAtom.timescale = track.timescale;

            tkhdAtom.trackId = trackId;
            mvhdAtom.nextTrackId = ++trackId;

            let hdlrAtom = mdiaAtom.createAtom(Utils.ATOM_HDLR);
            let minfAtom = mdiaAtom.createAtom(Utils.ATOM_MINF);
            if (track instanceof VideoTrack) {
                tkhdAtom.width = track.width;
                tkhdAtom.height = track.height;
                hdlrAtom.handlerType = Utils.TRACK_TYPE_VIDEO;
                hdlrAtom.componentName = Utils.COMPONENT_NAME_VIDEO;
                minfAtom.createAtom(Utils.ATOM_VMHD);
            } else {
                hdlrAtom.handlerType = Utils.TRACK_TYPE_AUDIO;
                hdlrAtom.componentName = Utils.COMPONENT_NAME_AUDIO;
                minfAtom.createAtom(Utils.ATOM_SMHD);
            }

            // Edits
            let edtsAtom = trakAtom.createAtom(Utils.ATOM_EDTS);
            let elstAtom = edtsAtom.createAtom(Utils.ATOM_ELST);
            elstAtom.entries = [track.duration * this.movie.timescale / track.timescale, 0, 1 << 16];
            if (track.samples.length > 0 && track.samples[0].timestamp > 0) {
                // add an empty edit
                let duration = (track.samples[0].timestamp * this.movie.timescale / track.timescale) >>> 0;
                elstAtom.entries.unshift(duration, -1, 1 << 16);
            }

            // Samples table
            let stblAtom = minfAtom.createAtom(Utils.ATOM_STBL);

            // Samples
            let samples = track.samples.slice().sort((a, b) => a.offset - b.offset);

            // Sample sizes
            let stszAtom = stblAtom.createAtom(Utils.ATOM_STSZ);
            stszAtom.entries = samples.map((sample) => sample.size);

            // Sample durations
            let sttsAtom = stblAtom.createAtom(Utils.ATOM_STTS);
            sttsAtom.entries = samples.reduce(BuilderImpl.buildDurationsReducer, []).reduce(BuilderImpl.compressReducer, []);

            if (track instanceof VideoTrack) {
                // Sample keyframes
                let stssAtom = stblAtom.createAtom(Utils.ATOM_STSS);
                stssAtom.entries = samples.map((sample, i) => sample.keyframe ? i + 1 : null).filter(v => v !== null);

                // Sample composition offsets
                let cttsAtom = stblAtom.createAtom(Utils.ATOM_CTTS);
                cttsAtom.entries = samples.map((sample) => sample.compositionOffset).reduce(BuilderImpl.compressReducer, []);
            }

            // Chunks
            let chunks;
            if (track instanceof VideoTrack) {
                chunks = videoChunks;
            } else {
                chunks = audioChunks;
            }

            // Chunk offsets
            let offsAtom = null;
            if (mdatSize >= MAX_OFFSET_32) {
                offsAtom = stblAtom.createAtom(Utils.ATOM_CO64);
            } else {
                offsAtom = stblAtom.createAtom(Utils.ATOM_STCO);
            }
            offsAtom.entries = chunks.map((chunk) => chunk.offset);
            offsetAtoms[tkhdAtom.trackId] = offsAtom;

            // Sample to chunks
            let stscAtom = stblAtom.createAtom(Utils.ATOM_STSC);
            stscAtom.entries = chunks.reduce(BuilderImpl.buildSampleToChunksReducer, []);

            // Sample description
            let stsdAtom = stblAtom.createAtom(Utils.ATOM_STSD);
            let codecAtom = null;
            if (codec instanceof CodecH264) {
                codecAtom = stsdAtom.createAtom(Utils.ATOM_AVC1);
                codecBrand = 'avc1';
            } else if (codec instanceof CodecH265) {
                codecAtom = stsdAtom.createAtom(Utils.ATOM_HEV1);
                codecBrand = 'hvc1';
            } else if (codec instanceof CodecAac) {
                codecAtom = stsdAtom.createAtom(Utils.ATOM_MP4A);
                codecAtom.streamId = tkhdAtom.trackId;
            }

            if (codecAtom !== null) {
                codecAtom.extraData = track.extraData;
                if (track instanceof VideoTrack) {
                    codecAtom.width = track.width;
                    codecAtom.height = track.height;
                } else if (track instanceof AudioTrack) {
                    codecAtom.channels = track.channels;
                    codecAtom.sampleRate = track.sampleRate;
                    codecAtom.sampleSize = track.sampleSize;
                }
            }
        }

        // Add compatible brands
        if (codecBrand !== null) {
            ftypAtom.compatibleBrands.push(codecBrand);
        }

        // Calculate header size
        let headerSize = ftypAtom.bufferSize() + moovAtom.bufferSize() + 8;
        if (mdatSize >= MAX_OFFSET_32) {
            headerSize += 8;
        }

        // Adjust sample offsets
        for (let trackId in offsetAtoms) {
            let offsAtom = offsetAtoms[trackId];
            offsAtom.entries = offsAtom.entries.map((offset) => offset + headerSize);
        }

        // Truncate file
        fs.ftruncateSync(this.fd);

        // Generate movie header
        let header = Buffer.alloc(headerSize); // don't use allocUnsafe!
        ftypAtom.build(header, 0);
        moovAtom.build(header, ftypAtom.bufferSize());
        if (mdatSize >= MAX_OFFSET_32) {
            header.writeUInt32BE(1, ftypAtom.bufferSize() + moovAtom.bufferSize());
            BufferUtils.writeUInt64BE(header, mdatSize, ftypAtom.bufferSize() + moovAtom.bufferSize() + 4);
        } else {
            header.writeUInt32BE(mdatSize + 8, ftypAtom.bufferSize() + moovAtom.bufferSize());
        }
        header.write(Utils.ATOM_MDAT, headerSize - 4);

        // Write header
        fs.writeSync(this.fd, header, 0, header.length, 0);

        // Write chunks
        let pos = headerSize;
        for (let i = 0, l = samples.length; i < l; i++) {
            let sample = samples[i];
            let buffer = Buffer.allocUnsafe(sample.size);
            this.reader.read(buffer, sample.offset);
            fs.writeSync(this.fd, buffer, 0, buffer.length, pos);
            pos += buffer.length;
        }

        return true;
    }

    static compressReducer(array, value) {
        if (array.length === 0 || array[array.length - 1] !== value) {
            array.push(1, value);
        } else {
            array[array.length - 2]++;
        }
        return array;
    }

    static buildDurationsReducer(result, sample, index, samples) {
        if (index === 0) {
            if (samples.length > 1) {
                result.push(samples[1].timestamp - sample.timestamp);
            } else {
                result.push(0);
            }
        } else {
            result.push(sample.timestamp - (index > 0 ? samples[index - 1].timestamp : 0));
        }
        return result;
    }

    static buildSampleToChunksReducer(result, chunk, index, chunks) {
        if (result.length === 0) {
            result.push(1, chunk.samples.length, 1, 2);
        } else if (result[result.length - 3] !== chunk.samples.length) {
            result.push(chunk.samples.length, 1, result[result.length - 1] + 1);
        } else {
            result[result.length - 1]++;
        }
        if (index === chunks.length - 1) {
            result = result.slice(0, result.length - 1);
        }
        return result;
    }

}

module.exports = BuilderImpl;