Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement "Export To Final Cut Pro Timeline" #101

Open
michaelforrest opened this issue Jan 20, 2022 · 5 comments
Open

Implement "Export To Final Cut Pro Timeline" #101

michaelforrest opened this issue Jan 20, 2022 · 5 comments
Labels
enhancement New feature or request

Comments

@michaelforrest
Copy link

michaelforrest commented Jan 20, 2022

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
@lmparppei
Copy link
Owner

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

@michaelforrest
Copy link
Author

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
Copy link
Author

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

@lmparppei
Copy link
Owner

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
}

@michaelforrest
Copy link
Author

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

@lmparppei lmparppei added the enhancement New feature or request label Jan 20, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants