Beat icon indicating copy to clipboard operation
Beat copied to clipboard

Implement "Export To Final Cut Pro Timeline"

Open michaelforrest opened this issue 2 years ago • 5 comments

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

michaelforrest avatar Jan 20 '22 09:01 michaelforrest

If I'm reading the code correctly, this doesn't create different elements for scenes or so, just the actions?

lmparppei avatar Jan 20 '22 12:01 lmparppei

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.

michaelforrest avatar Jan 20 '22 13:01 michaelforrest

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!)

michaelforrest avatar Jan 20 '22 13:01 michaelforrest

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
}

lmparppei avatar Jan 20 '22 13:01 lmparppei

Yeah exactly, you've already got the structured document models so you can go directly to the fcpxml template!

michaelforrest avatar Jan 20 '22 13:01 michaelforrest