Beat
Beat copied to clipboard
Implement "Export To Final Cut Pro Timeline"
As per this discussion: https://twitter.com/beatScreenplay/status/1483928471961317376
I created a free tool to convert .fdx
files to .fcpxml
files for Final Cut Pro. Here's a post and a video explaining how it works:
https://squares.tv/posts/free-tool-edit-videos-fast-in-final-cut
As reference, I'm happy to share the Swift source from my personal desktop implementation and the Elixir implementation used on the squares.tv.
Please credit me with a link to this url if you use any of this!
Swift source
struct Sentence {
let text: String
let offset: String
let index: Int
}
struct FCPXAction {
let text: String
let index: Int
let caption: String
let sentences: [Sentence]
let duration: Int
}
func handleExportToFCPX(sender: NSObject){
self.title = self.fileURL?.deletingPathExtension().lastPathComponent ?? "Untitled"
let sentenceDuration = 4 // seconds
let fcpActions = self.actions.enumerated().map{ item -> FCPXAction in
let (offset, element) = item
// FIXME: crudely split sentences by .,! characters (breaks if you put a url like squares.tv) - should require punctuation + space
let sentences = element.caption.components(separatedBy: CharacterSet(charactersIn: ".;!"))
.enumerated()
.map{(index, text) in Sentence( text: text.xmlEscaped, offset: "\(index * sentenceDuration)s", index: index)}
return FCPXAction(
text: element.text.xmlEscaped,
index: offset,
caption: element.caption.xmlEscaped,
sentences: sentences,
duration: sentences.count * sentenceDuration
)
}
let context:[String: Any] = [
"name": "\(self.title) Action List",
"date": "2018-12-05 12:38:10 +0000", // FIXME
"eventUUID": UUID().uuidString,
"uuid": UUID().uuidString,
"duration": fcpActions.reduce(0, {acc, action in acc + action.sentences.count}) * sentenceDuration,
"actions": fcpActions,
"sentenceDuration": "\((sentenceDuration - 1) * 240000)/240000s"
]
let rendered = try? render(name: "markers-template.fcpxml", context: context); // uses Stencil but doesn't have to
let panel = NSSavePanel()
panel.nameFieldStringValue = self.title
panel.begin { result in
if result == .OK {
if let url = panel.url{
try! rendered?.write(to: url.appendingPathExtension("fcpxml"), atomically: true, encoding: .utf8)
}
}
}
}
Stencil template:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE fcpxml>
<fcpxml version="1.8">
<resources>
<format id="r1" name="FFVideoFormat1080p60" frameDuration="100/6000s" width="1920" height="1080" colorSpace="1-1-1 (Rec. 709)"/>
</resources>
<library>
<event name="Media" uid="{{eventUUID}}">
<project name="{{ name }}" uid="{{ uuid }}" modDate="{{ date}}">
<sequence duration="{{ duration }}s" format="r1" tcStart="0s" tcFormat="NDF" audioLayout="stereo" audioRate="48k">
<spine>
{% for action in actions %}
<gap name="Gap" duration="{{action.duration}}s" start="0s">
{% for sentence in action.sentences %}
<caption name="{{ sentence.text }}" lane="1" offset="{{sentence.offset}}" duration="{{sentenceDuration}}"
start="0s"
role="iTT?captionFormat=ITT.en">
<text placement="bottom">
<text-style ref="ts{{action.index}}{{sentence.index}}">{{sentence.text}}</text-style>
</text>
<text-style-def id="ts{{action.index}}{{sentence.index}}">
<text-style font=".SF NS Text" fontSize="13" fontFace="Regular" fontColor="1 1 1 1" backgroundColor="0 0 0 1"/>
</text-style-def>
</caption>
{% endfor %}
<marker start="0s" duration="1/48000s" value="{{action.index}}. {{ action.text }}" completed="0"/>
</gap>
{% endfor %}
</spine>
</sequence>
</project>
</event>
<smart-collection name="Projects" match="all">
<match-clip rule="is" type="project"/>
</smart-collection>
<smart-collection name="All Video" match="any">
<match-media rule="is" type="videoOnly"/>
<match-media rule="is" type="videoWithAudio"/>
</smart-collection>
<smart-collection name="Audio Only" match="all">
<match-media rule="is" type="audioOnly"/>
</smart-collection>
<smart-collection name="Stills" match="all">
<match-media rule="is" type="stills"/>
</smart-collection>
<smart-collection name="Favorites" match="all">
<match-ratings value="favorites"/>
</smart-collection>
</library>
</fcpxml>
Elixir source
defmodule Squares.Tools.ScriptToTimeline.FinalCutProX do
defmodule Action, do:
defstruct text: "", index: 0, sentences: [], duration: 0
defmodule Sentence, do:
defstruct text: "", offset: "", index: 0
defmodule Document do
defstruct name: "", date: "", eventUUID: "", uuid: "", duration: 0, actions: [], sentenceDuration: 4
@behaviour Access
defdelegate get(doc, key, default), to: Map
defdelegate fetch(doc, key), to: Map
defdelegate get_and_update(doc, key, func), to: Map
defdelegate pop(doc, key), to: Map
alias Squares.Tools.ScriptToTimeline.FinalDraft
def from(%FinalDraft.Document{}=script, sentenceDuration) do
actions =
script.segments
|> Enum.with_index()
|> Enum.map(fn({%FinalDraft.Segment{}=segment, segment_index})->
sentences =
segment.dialogue
|> Enum.with_index()
|> Enum.flat_map(fn({%FinalDraft.Dialogue{}=dialogue, dialogue_index})->
dialogue.dialogue
|> String.split(~r/[.;!]\s/) # FIXME (should require punctuation + space)
|> Enum.with_index()
|> Enum.map(fn({sentence,sentence_index})->
%Sentence{
text: sentence,
offset: "#{sentence_index * sentenceDuration}s",
index: sentence_index + (dialogue_index * 100)
}
end)
end)
%Action{
text: segment.action |> Enum.join(" "),
index: segment_index,
sentences: sentences,
duration: length(sentences) * sentenceDuration
}
end)
{:ok,
%Document{
name: script.title,
date: "2018-12-05 12:38:10 +0000", # FIXME
eventUUID: Ecto.UUID.generate(),
uuid: Ecto.UUID.generate(),
duration: Enum.reduce(script.segments, 0, fn(segment, total) ->
total + length(segment.dialogue) * sentenceDuration
end),
actions: actions,
sentenceDuration: sentenceDuration
}
}
end
end
end
defmodule Squares.Tools.ScriptToTimeline.FinalDraft do
defmodule Dialogue, do:
defstruct dialogue: "", speaker: "", parenthetical: ""
defmodule Segment, do:
defstruct action: [], dialogue: []
defmodule Document do
alias Squares.Tools.ScriptToTimeline.FinalDraft.Document
defstruct title: "", segments: [], parsed: %{}, source: ""
def parse(%Plug.Upload{}=upload) do
source = File.read!(upload.path)
doc = XmlToMap.naive_map(source)
paragraphs = doc["FinalDraft"]["#content"]["Content"]["Paragraph"]
{:ok, %Document{
title: upload.filename,
segments: extract_segments(paragraphs),
parsed: doc,
source: source
}}
end
defp extract_segments(paragraphs) do
paragraphs
|> Enum.reduce([ %Segment{} ], fn(paragraph, acc)->
segment = List.last(acc)
text = flatten_text(paragraph["#content"]["Text"])
# determine operation
{operation, segment} = case paragraph["-Type"] do
"Action" ->
if length(segment.dialogue) == 0 do # reuse if there has been no dialogue yet
{:modify, Map.put(segment, :action, segment.action ++ [text] )}
else
{:add, %Segment{action: [text]}}
end
"Character" -> # start new dialogue
{:modify, Map.put(segment, :dialogue, segment.dialogue ++[%Dialogue{speaker: text}])}
"Dialogue" ->
{:modify, Map.put(segment, :dialogue,
segment.dialogue
|> List.replace_at(length(segment.dialogue) - 1,
Map.put(List.last(segment.dialogue), :dialogue, text)
)
)}
# "Parenthetical" ->
# {:modify, Map.put(segment, :parenthetical, text)}
_ -> {:no_change, segment}
end
# return appropriately
case operation do
:add ->
acc ++ [segment]
:modify ->
List.replace_at(acc, length(acc) - 1, segment)
:no_change ->
acc
end
end)
end
defp flatten_text(elements) when is_nil(elements), do: ""
defp flatten_text(elements) when is_binary(elements), do: elements
defp flatten_text(elements) do
elements
|> Enum.map(
&(if is_binary(&1), do: &1, else: &1["#content"])
)
|> Enum.join(" ")
end
end
end
If I'm reading the code correctly, this doesn't create different elements for scenes or so, just the actions?
Ah yes this doesn't do anything with scenes - you'd need to add another layer of accumulation. I have some code that DOES extract scenes - let me grab that now.
Wait yeah no this doesn't really need to do anything with the scenes to create the timeline! I suppose you could use scene info to add tags?
(just checked my other code and it's for reading fdx files and turning them into a shooting checklist which is a different application to the timeline generation!)
Alright! For porting this to Beat plugin, I'll just skip the FDX step and use internal data. I think it would be nice to create some sort of scene markers.
Just as a reference for anyone else reading this, this is how you go through actions and lines in a plugin:
for (const line of Beat.lines()) {
if (line.type == Beat.type.action) // Do something
}
Scenes:
for (const scene of Beat.scenes()) {
// Do something
}
Yeah exactly, you've already got the structured document models so you can go directly to the fcpxml
template!