// 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 => `
`
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
}