Implement "Export To Final Cut Pro Timeline"
Nobody has claimed this yet.
- Dominant language
- Objective-C
- Stars
- 344
- Forks
- 54
- PR merge metrics
- No merged PRs in 30d
Description
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](https://squares.tv/timeline) if you use any of this!
### Swift source
```swift
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
{% for action in actions %}
{% for sentence in action.sentences %}
{{sentence.text}}
{% endfor %}
{% endfor %}
```
### Elixir source
```elixir
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
```
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
The issue provides the Swift handleExportToFCPX function, the Elixir Document.from and FinalDraft.Document.parse entry points, and a Stencil FCPXML template, but names no Beat files or tests. Start by locating Beat's existing export entry point and mapping its script model to the supplied structures. Done means exporting a valid .fcpxml timeline with actions and captions in Final Cut Pro.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- elixir, objective-c, swift
- Domain
- desktop
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 20/100