Skip to content

Implement "Export To Final Cut Pro Timeline" #101

@michaelforrest

Description

@michaelforrest

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions