// https://developer.apple.com/library/content/documentation/AppleApplications/Reference/FinalCutPro_XML const path = require('path') const Tone = require('tone') const { msecsToFrames } = require('./common') const { boardFileImageSize, boardFilenameForExport } = require('../models/board') const util = require('../utils') // fcp templating const clipItem = data => ` ${data.masterClipId} ${data.name} ${data.description} TRUE ${data.duration} ${data.timebase} ${data.ntsc} ${data.start} ${data.end} 107892 107916 ${data.fileFilename} ${data.filePathUrl} ${data.label2} ` const stereoTrack = data => ` ${data.audioClips.filter(c => c.numberOfChannels == 1).map(c => audioClip(Object.assign(c, { currentExplodedTrackIndex: 0 }))).join('\n')} ${data.audioClips.filter(c => c.numberOfChannels > 1).map(c => audioClip(Object.assign(c, { currentExplodedTrackIndex: 0 }))).join('\n')} TRUE FALSE 1 ${data.audioClips.filter(c => c.numberOfChannels > 1).map(c => audioClip(Object.assign(c, { currentExplodedTrackIndex: 1 }))).join('\n')} TRUE FALSE 2 ` const audioClipFile = data => data.numberOfChannels == 1 || data.currentExplodedTrackIndex === 0 ? ` ${data.filename} ${data.pathurl} ${data.timebase} ${data.ntsc} ${data.duration} ${data.timebase} ${data.ntsc} 00;00;00;00 0 DF ` : `` const audioClip = data => ` ${`masterclip-${data.audioIndexPos}`} ${data.name} TRUE ${data.duration} ${data.timebase} ${data.ntsc} ${data.start} ${data.end} ${data.in} ${data.out} ${data.pproTicksIn} ${data.pproTicksOut} ${audioClipFile(data)} audio ${data.currentExplodedTrackIndex + 1} ${ data.numberOfChannels > 1 ? ` clipindex-${data.audioIndexPos} clipindex-${data.audioIndexPos + 1} ` : '' } Caribbean ` const generateFinalCutProXml = data => ` ${data.uuid} ${data.timebase} ${data.ntsc} ` const sanitizeNameString = function (nameString) { return nameString.replace(/&/g, '+') .replace(//g, '-') .replace(/"/g, '') .replace(/'/g, '') .replace(/\//g, ''); }; // via https://forums.adobe.com/message/8161911#8161911 // 1 s = 254016000000 ticks // "This value is not guaranteed to remain the same from version to version." const pproTicksForFrames = (fps, frames) => frames / fps * 254016000000 const generateFinalCutProData = async (boardData, { projectFileAbsolutePath, outputPath }) => { let [width, height] = boardFileImageSize(boardData) let dirname = path.dirname(projectFileAbsolutePath) let extname = path.extname(projectFileAbsolutePath) let basenameWithoutExt = path.basename(projectFileAbsolutePath, extname) // fps is always rounded up let timebase = Math.ceil(boardData.fps) // ntsc is set true if fps is a decimal, false if fps is an integer let ntsc = boardData.fps % 1 > 0 ? 'TRUE' : 'FALSE' let clipItems = [] let currFrame = 0 let index = 0 let currAudioIndex = 0 let timelinePosInMsecs = 0 let stereoTracks = [ { endInMsecs: 0, audioClips: [] }] for (let board of boardData.boards) { let fileFilename = util.dashed(boardFilenameForExport(board, index, basenameWithoutExt)), filePathUrl = `./${encodeURI(fileFilename)}` //`file://${outputPath}/${fileFilename}` let duration = (util.isUndefined(board.duration) || board.duration == 0) ? boardData.defaultBoardTiming : board.duration let lastFrame = Math.round(msecsToFrames(boardData.fps, duration)), endFrame = currFrame + lastFrame let clipItem = { start: currFrame, end: endFrame, id: `clipitem-${index + 1}`, masterClipId: `masterclip-${index + 1}`, // set name if dialogue or action, otherwise filename name: board.dialogue ? sanitizeNameString(board.dialogue) : board.action ? sanitizeNameString(board.action) : fileFilename, description: board.notes ? sanitizeNameString(board.notes) : '', duration: 1294705, // ??? timebase, ntsc, fileId: `file-${index + 1}`, fileFilename, filePathUrl, fileWidth: width, fileHeight: height, label2: 'Lavender' } clipItems.push(clipItem) if (board.audio && board.audio.filename && board.audio.filename.length) { let filepath = path.join(dirname, 'images', board.audio.filename) let buffer try { buffer = await new Tone.Buffer().load(filepath) } catch (err) { console.error(err) throw new Error(`could not load audio file ${board.audio.filename}`) } // buffer.length // length in samples, e.g. 44788 // buffer.duration // duration in seconds, e.g. 0.933... // buffer._buffer.sampleRate // sample rate // buffer.numberOfChannels // number of channels (0 if not loaded) // read the values let audioDurationInMsecs = Math.round(buffer.duration * 1000) let bitDepth = -1 let sampleRate = buffer._buffer.sampleRate let numberOfChannels = buffer.numberOfChannels let audioEndFrame = currFrame + Math.round(msecsToFrames(boardData.fps, audioDurationInMsecs)) let audioClip = { name: board.audio.filename, duration: msecsToFrames(boardData.fps, audioDurationInMsecs), timebase: timebase, ntsc: ntsc, start: currFrame, end: audioEndFrame, in: 0, out: Math.round(msecsToFrames(boardData.fps, audioDurationInMsecs)), pproTicksIn: pproTicksForFrames(boardData.fps, 0), pproTicksOut: pproTicksForFrames(boardData.fps, Math.round(msecsToFrames(boardData.fps, audioDurationInMsecs))), filename: board.audio.filename, pathurl: `./${board.audio.filename}`, // `file://localhost${path.join(dirname, board.audio.filename)}`, bitDepth: bitDepth, sampleRate: sampleRate, numberOfChannels: numberOfChannels, audioIndexPos: currAudioIndex + boardData.boards.length + 1 } let nextAvailableTrackIndex = stereoTracks.length for (let i = 0; i < stereoTracks.length; i++) { let time = stereoTracks[i].endInMsecs if (timelinePosInMsecs >= time) { nextAvailableTrackIndex = i break } } stereoTracks[nextAvailableTrackIndex] = stereoTracks[nextAvailableTrackIndex] || {} stereoTracks[nextAvailableTrackIndex].audioClips = stereoTracks[nextAvailableTrackIndex].audioClips || [] stereoTracks[nextAvailableTrackIndex].endInMsecs = timelinePosInMsecs + audioDurationInMsecs stereoTracks[nextAvailableTrackIndex].audioClips.push(audioClip) currAudioIndex = currAudioIndex + 2 } timelinePosInMsecs += duration currFrame = endFrame index++ } return { sequenceId: 'sequence-1', uuid: util.uuid4(), width: width, height: height, clipItems: clipItems, stereoTracks: stereoTracks, timebase, ntsc } } module.exports = { generateFinalCutProData, generateFinalCutProXml }