lmparppei / lmparppei/Beat

Implement "Export To Final Cut Pro Timeline"

Open
#101 5 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement
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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.