Hammerspoon / hammerspoon

Staggeringly powerful macOS desktop automation with Lua
http://www.hammerspoon.org
MIT License
12.08k stars 582 forks source link

Question - Pasteboards for Final Cut Pro? #1016

Closed latenitefilms closed 7 years ago

latenitefilms commented 8 years ago

Hi Everyone,

I'm considering trying to build a Clipboard Manager for Final Cut Pro, similar to this.

When I copy something in Final Cut Pro, I get this:

> hs.inspect(hs.pasteboard.allContentTypes())
{ { "com.apple.flexo.proFFPasteboardUTI" } }

Does the hs.pasteboard module have the functionality to do what I want to do? How do I work out what the 'name' is of the Final Cut Pro pasteboard?

Thanks in advance!

latenitefilms commented 8 years ago

@asmagill or @cmsj - Any idea if this is remotely possible or not?

cmsj commented 8 years ago

@latenitefilms what do you get if you print out the pasteboard's contents? It may just be XML/plist data, but we currently have nothing that can deal with that UTI specifically, nor push something back with that UTI.

asmagill commented 8 years ago

Adding the ability to return an arbitrary pasteboard UTI as a binary blob of data (i.e. a string with 8bit bytes in it) would be possible. Adding the ability to post such a blob back to the pasteboard should also be doable...

However, Lua would just see it as a string of bytes... to understand it/manipulate it would be entirely up to your code.

latenitefilms commented 8 years ago

Thanks for your replies @cmsj & @asmagill !

I don't really care about what the contents of the pasteboard is - I basically just want to "save" it and be able to "restore" it - essentially creating a clipboard history specifically for Final Cut Pro.

Does hs.pasteboard currently have the ability to return an arbitrary pasteboard UTI as a binary blob of data - or is this something that would need to be developed?

I can see "com.apple.flexo.proFFPasteboardUTI" in hs.pasteboard.allContentTypes(), I just have no idea how to get access to this data as hs.pasteboard.getContent() just returns an error.

Any suggestions?

asmagill commented 8 years ago

At present, no, but adding it should be fairly simple... I'll add it to my list, but it will probably be sometime next week before I get to it.

latenitefilms commented 8 years ago

Legend, thanks so much!!

latenitefilms commented 8 years ago

@asmagill - Absolutely no rush or pressure what-so-ever, but seeing as you're online currently, I was just wondering if you've had a chance look into this yet?

asmagill commented 8 years ago

@latenitefilms, just an FYI, I may have a pull up later today/tomorrow for you to test... It should allow grabbing the item as raw data as well as a property list, if the item conforms to the NSCoding protocol. The property list idea intrigues me as it means that maybe you could modify it before putting it back on the pasteboard, but it needs a little more testing before I put it up for testing... are you able to build your own Hammerspoon application with an added pull request, or should I put it up as a stand-alone module like I've done for hs._asm.axuielement when I'm ready for you to test it?

your previous comment appeared as I was typing this in :-)

latenitefilms commented 8 years ago

Haha... awesome!

I've never tried building Hammerspoon yet, so if it's easy for you to upload a pre-built version that would be great - however, I'm sure I can work it out if it's a pain for you! I've been meaning to get my head around Xcode anyway.

Thanks so much for all your help!!

asmagill commented 8 years ago

Check out https://github.com/asmagill/hammerspoon_asm/tree/master/pasteboard for the code and a precompiled module you can install -- it will "replace" the core hs.pasteboard module when you restart Hammerspoon (not really, it just appears earlier in the search path so it's loaded instead of the version at /Applications/Hammerspoon.app/Contents/Resources/extensions/hs/pasteboard)

If you're serious about compiling your own version of Hammerspoon sometime, it's not really that hard... just clone the repository with git and then type make in the Hammerspoon directory and it should build the application in the build/ subdirectory... it will differ in some minor ways (no Sparkle/auto-update support, a couple of predefined variables indicating compile date/time, and maybe some additional logging enabled, IIRC) There are a few more steps if you want to apply a self-signed certificate so that you don't have to re-enable accessibility every time you build a new version or if you want to build a local version of the documentation (in case you're tweaking/adding to that as well). I used to (maybe still?) have my build script up in one of my git repositories but I think it's outdated... I should update it sometime and provide a link to the site that describes how to create a local signing certificate.

latenitefilms commented 8 years ago

@asmagill - AMAZING! Thank you so much! I've only just started playing, but it seems to work as advertised, which is awesome. Again, thank you!

However, it seems Final Cut Pro uses Binary Plist's as the Clipboard content.

I know I've asked this before, but is there any easy way of translating a binary plist to a human-readable plist without having to write a file and then use command line tools to translate it?

What I'd love to do is be able to "read" the contents of Final Cut Pro's binary plist in the clipboard, so that I can create something similar to this, and use some kind of metadata within the binary plist (i.e. the clip name) to "label" what the clipboard contents actually is.

I found this online - but not exactly sure if it's useful or not?

latenitefilms commented 8 years ago

If you're interested, here's what Final Cut Pro saves to the clipboard: pasteboard.txt

asmagill commented 8 years ago

Note that there is an updated version available at the link I provided... it concerns the writePListForUTI function which, as you'll see below, is broken in other ways, so... download it if you wish -- it won't change what currently works and what doesn't...

Here's how I've examined the data in the file you provided:

First, put the data back onto the clipboard... I suspect you won't have to do this, since you're getting it straight from FCP:

f = io.open("/Users/XXXXX/Downloads/pasteboard.txt", "r")
a = f:read("a")
f:close()
hs.pasteboard.writeDataForUTI("com.apple.flexo.proFFPasteboardUTI", a)

Now, get it as a property list:

b = hs.pasteboard.readPListForUTI("com.apple.flexo.proFFPasteboardUTI")
-- if you cut and paste this as a block, inspect won't output anything unless we specify it as the last
-- item with a "return"... if typing this in line by line, you can leave out the "return" if you like.
return hs.inspect(b)

Which results in a lot of stuff, but the key of interest is ffpasteboardobject... since it begins with "bplist", I suspect it is also a property list, so:

-- we specify a pasteboard name so that we leave the original on the general pasteboard alone for now
hs.pasteboard.writeDataForUTI("ffpasteboardobject", "ffpasteboardobject", b.ffpasteboardobject)

-- interestingly enough, the item now on the new pasteboard has a different UTI; maybe it's
-- embedded in the data itself or something else is going on that I'll debug and worry about
-- later
return hs.inspect(hs.pasteboard.contentTypes("ffpasteboardobject"))

You can see the output yourself, it's actually not that interesting other than it determines what we have to request next:

c = hs.pasteboard.readPListForUTI("ffpasteboardobject", hs.pasteboard.contentTypes("ffpasteboardobject")[1])
return hs.inspect(c)

Looking at this it looks like an archived NSObject... this would allow a macOS application to serialize the data so that it could be stored, sent over the network, etc. and be reconstituted back into the same object at a later time or on another machine... I'm not familiar with the format, so you're going to have to experiment and see if you can modify/use any of the data in the c["$objects"] sub-table.

And trying to write it back unchanged gives an error:

hs.pasteboard.writePListForUTI("holding", hs.pasteboard.contentTypes("ffpasteboardobject")[1], c)

Results in

[string "return hs.pasteboard.writePListForUTI("holdin..."]:1: Could not write property list with invalid format to the pasteboard.
stack traceback:
    [C]: in function 'hs.pasteboard.writePListForUTI'
    [string "return hs.pasteboard.writePListForUTI("holdin..."]:1: in main chunk
    [C]: in function 'xpcall'
    ...app/Contents/Resources/extensions/hs/_coresetup/init.lua:321: in function <...app/Contents/Resources/extensions/hs/_coresetup/init.lua:301>

So, at least for now, while the PList functions allow us to examine the data more closely, apparently it isn't quite correct (or maybe complete? some of the fields looked like they maybe should have had more detail, but I'm not sure... I'll have to hand craft some archived objects and play with it more)... since it uses the LuaSkin internal conversion code that underlies, well..., practically everything in Hammerspoon, it's going to take some time to figure out what needs to be adjusted without breaking anything else... I may need to duplicate the NSObject parser completely with an eye towards archived objects this time...

In any case, when we're done with the user created pasteboards, we really should release them... technically we don't have to (nothing will blow up if we don't), but it wastes memory and theoretically allows for the possibility of leaking data to another application (though it would have to know what pasteboard names we created), so...

hs.pasteboard.delete("holding")
hs.pasteboard.delete("ffpasteboardobject")

So, for now, you can get the raw data and write the raw data back, but converting it to a PList for modification and then writing it back doesn't work at present... that's going to take some digging and testing, so I don't have an estimate at the moment.

asmagill commented 8 years ago

I'll take a look at CFBinaryPList.c and see if it suggests anything... I have been trying to leverage what is already provided in the OSX Property list internals, but as the above shows, that has its own issues with our current implementation of an NSObject parser... maybe using an externally crafted one will turn out to be easier to work with.

latenitefilms commented 8 years ago

Thanks so much for all your help @asmagill!

Your pasteboard code seems to work perfectly!

Below is how I'm currently doing things. Unfortunately, I'm still using command lines tools to manipulate the plist data, which I'm sure is slower than it could be - but it works.

I'm also using SLAXML to handle the XML data within Hammerspoon, which seems to work pretty well so far.

--------------------------------------------------------------------------------
-- WATCH THE FINAL CUT PRO CLIPBOARD FOR CHANGES:
--------------------------------------------------------------------------------
function clipboardWatcher()

    --------------------------------------------------------------------------------
    -- Get Clipboard History from Settings:
    --------------------------------------------------------------------------------
    clipboardHistory = settings.get("fcpxHacks.clipboardHistory") or {}

    --------------------------------------------------------------------------------
    -- Watch for Clipboard Changes:
    --------------------------------------------------------------------------------
    clipboardTimer = hs.timer.new(0.5, function()
        clipboardCurrentChange = pasteboard.changeCount()
        if (clipboardCurrentChange > clipboardLastChange) then

            local clipboardContent = pasteboard.allContentTypes()
            if clipboardContent[1][1] == finalCutProClipboardUTI then

                print("[FCPX Hacks] Something has been added to FCPX's Clipboard.")

                --------------------------------------------------------------------------------
                -- Set Up Variables:
                --------------------------------------------------------------------------------
                local executeOutput             = nil
                local executeStatus             = nil
                local executeType               = nil
                local executeRC                 = nil
                local addToClipboardHistory     = true

                --------------------------------------------------------------------------------
                -- Save Clipboard Data:
                --------------------------------------------------------------------------------
                local currentClipboardData      = pasteboard.readDataForUTI(finalCutProClipboardUTI)

                --------------------------------------------------------------------------------
                -- Define Temporary Files:
                --------------------------------------------------------------------------------
                local temporaryFileName         = os.tmpname()
                local temporaryFileNameTwo      = os.tmpname()

                --------------------------------------------------------------------------------
                -- Write Clipboard Data to Temporary File:
                --------------------------------------------------------------------------------
                local temporaryFile = io.open(temporaryFileName, "w")
                temporaryFile:write(currentClipboardData)
                temporaryFile:close()

                --------------------------------------------------------------------------------
                -- Convert binary plist to XML then return in JSON:
                --------------------------------------------------------------------------------
                local executeOutput, executeStatus, executeType, executeRC = hs.execute([[
                    plutil -convert xml1 ]] .. temporaryFileName .. [[ -o - |
                    sed 's/data>/string>/g' |
                    plutil -convert json - -o -
                ]])
                if not executeStatus then
                    print("[FCPX Hacks] ERROR: Failed to convert binary plist to XML.")
                    addToClipboardHistory = false
                end

                --------------------------------------------------------------------------------
                -- Get data from 'ffpasteboardobject':
                --------------------------------------------------------------------------------
                local file = io.open(temporaryFileName, "w")
                file:write(json.decode(executeOutput)["ffpasteboardobject"])
                file:close()

                --------------------------------------------------------------------------------
                -- Convert base64 data to human readable:
                --------------------------------------------------------------------------------
                executeCommand = "openssl base64 -in " .. tostring(temporaryFileName) .. " -out " .. tostring(temporaryFileNameTwo) .. " -d"
                executeOutput, executeStatus, executeType, executeRC = hs.execute(executeCommand)
                if not executeStatus then
                    print("[FCPX Hacks] ERROR: Failed to convert base64 data to human readable.")
                    addToClipboardHistory = false
                end

                --------------------------------------------------------------------------------
                -- Convert from binary plist to human readable:
                --------------------------------------------------------------------------------
                executeOutput, executeStatus, executeType, executeRC = hs.execute("plutil -convert xml1 " .. tostring(temporaryFileNameTwo))
                if not executeStatus then
                    print("[FCPX Hacks] ERROR: Failed to convert from binary plist to human readable.")
                    addToClipboardHistory = false
                end

                --------------------------------------------------------------------------------
                -- Bring XML data into Hammerspoon:
                --------------------------------------------------------------------------------
                executeOutput, executeStatus, executeType, executeRC = hs.execute("cat " .. tostring(temporaryFileNameTwo))
                if not executeStatus then
                    print("[FCPX Hacks] ERROR: Failed to cat the plist.")
                    addToClipboardHistory = false
                end

                --------------------------------------------------------------------------------
                -- XML fun times!
                --------------------------------------------------------------------------------
                local xml = slaxdom:dom(tostring(executeOutput))
                local currentClipboardLabel = nil

                    --------------------------------------------------------------------------------
                    -- Clip copied form Primary Storyline:
                    --------------------------------------------------------------------------------
                    if xml['root']['kids'][2]['kids'][8]['kids'][24]['kids'][1]['value'] == "metadataImportToApp" then
                        currentClipboardLabel = xml['root']['kids'][2]['kids'][8]['kids'][20]['kids'][1]['value']
                    end

                    --------------------------------------------------------------------------------
                    -- Clip copied form Secondary Storyline:
                    --------------------------------------------------------------------------------
                    if xml['kids'][2]['el'][1]['el'][4]['el'][17]['kids'][1]['value'] == "metadataImportToApp" then
                        -- Secondary Storyline:
                        currentClipboardLabel = xml['kids'][2]['el'][1]['el'][4]['el'][15]['kids'][1]['value']
                    end

                    --------------------------------------------------------------------------------
                    -- Clip copied form Browser:
                    --------------------------------------------------------------------------------
                    if xml['root']['kids'][2]['kids'][8]['kids'][30]['kids'][1]['value'] == "metadataImportToApp" then
                        -- Browser Clip:
                        currentClipboardLabel = xml['root']['kids'][2]['kids'][8]['kids'][18]['kids'][1]['value']
                    end

                    --------------------------------------------------------------------------------
                    -- Unknown:
                    --------------------------------------------------------------------------------
                    if currentClipboardLabel == nil then
                        -- Fail Safe:
                        currentClipboardLabel = os.date()
                    end

                --------------------------------------------------------------------------------
                -- Clean up temporary files:
                --------------------------------------------------------------------------------
                executeOutput, executeStatus, executeType, executeRC = hs.execute("rm " .. tostring(temporaryFileName))
                executeOutput, executeStatus, executeType, executeRC = hs.execute("rm " .. tostring(temporaryFileNameTwo))

                --------------------------------------------------------------------------------
                -- If all is good then...
                --------------------------------------------------------------------------------
                if addToClipboardHistory then

                    local currentClipboardItem = {currentClipboardData, currentClipboardLabel}

                    while (#clipboardHistory >= 5) do
                        table.remove(clipboardHistory,1)
                    end
                    table.insert(clipboardHistory, currentClipboardItem)

                    --------------------------------------------------------------------------------
                    -- Update Settings:
                    --------------------------------------------------------------------------------
                    settings.set("fcpxHacks.clipboardHistory", clipboardHistory)

                    --------------------------------------------------------------------------------
                    -- Refresh Menubar:
                    --------------------------------------------------------------------------------
                    refreshMenuBar()

                end
            end
            clipboardLastChange = clipboardCurrentChange
        end

    end)
    clipboardTimer:start()

end

--------------------------------------------------------------------------------
-- PASTE FROM CLIPBOARD HISTORY:
--------------------------------------------------------------------------------
function finalCutProPasteFromClipboardHistory(data)

    --------------------------------------------------------------------------------
    -- Write data back to Clipboard:
    --------------------------------------------------------------------------------
    clipboardTimer:stop()
    pasteboard.writePListForUTI(finalCutProClipboardUTI, data)
    clipboardCurrentChange = pasteboard.changeCount()
    clipboardTimer:start()

    --------------------------------------------------------------------------------
    -- Paste in FCPX:
    --------------------------------------------------------------------------------
    keyStrokeFromPlist("Paste")

end

--------------------------------------------------------------------------------
-- CLEAR CLIPBOARD HISTORY:
--------------------------------------------------------------------------------
function clearClipboardHistory()
    clipboardHistory = {}
    settings.set("fcpxHacks.clipboardHistory", clipboardHistory)
    clipboardCurrentChange = pasteboard.changeCount()
    refreshMenuBar()
end
latenitefilms commented 8 years ago

@asmagill - Also... is there a better way to do a clipboard watcher than using a timer?

asmagill commented 8 years ago

Re the clipboard watcher, unfortunately not that I've found... I've seen a lot of questions about this on sites like StackOverflow and the like, but everything I've read (even Apple's own sample code) says that you have to poll it regularly to detect changes.

latenitefilms commented 8 years ago

Sorry to be a pain @asmagill - but someone testing my script on macOS 10.10.5 with the new pasteboard code, is getting the following error:

Welcome to the Hammerspoon Console!
You can run any Lua code in here.

-- Lazy extension loading enabled
-- Loading ~/.hammerspoon/init.lua
-- Loading extension: uielement
-- Loading extension: window
*** ERROR: error loading module 'hs.pasteboard.internal' from file '/Volumes/OSX_Users/Users/Jose_Angel/.hammerspoon/hs/pasteboard/internal.so':
    dlopen(/Volumes/OSX_Users/Users/Jose_Angel/.hammerspoon/hs/pasteboard/internal.so, 6): Symbol not found: ___NSDictionary0__
  Referenced from: /Volumes/OSX_Users/Users/Jose_Angel/.hammerspoon/hs/pasteboard/internal.so (which was built for Mac OS X 10.12)
  Expected in: /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
 in /Volumes/OSX_Users/Users/Jose_Angel/.hammerspoon/hs/pasteboard/internal.so
stack traceback:
    [C]: in ?
    [C]: in function 'rawrequire'
    ...app/Contents/Resources/extensions/hs/_coresetup/init.lua:449: in function 'require'
    ...ers/Users/Jose_Angel/.hammerspoon/hs/pasteboard/init.lua:7: in main chunk
    [C]: in function 'rawrequire'
    ...app/Contents/Resources/extensions/hs/_coresetup/init.lua:449: in function 'require'
    /Volumes/OSX_Users/Users/Jose_Angel/.hammerspoon/init.lua:181: in main chunk
    [C]: in function 'xpcall'
    ...app/Contents/Resources/extensions/hs/_coresetup/init.lua:481: in function 'hs._coresetup.setup'
    (...tail calls...)

Fixable?

asmagill commented 8 years ago

Most likely because I'm not setting a compile target in the Makefile, so it uses the default, which in my case is 10.12... I'll try a new compile with an explicit target of 10.10 later tonight, and if it works for you, make a point to do so in the future for all of my modules, since that's our current "minimum" supported OS.

In the mean time, if you want, you can try downloading the source code and compiling it yourself on your machine -- that way it will default to your target automatically.

latenitefilms commented 8 years ago

Awesome, thanks @asmagill !

I'm currently using macOS 10.11, so I'll wait for you to do your magic - as I tried looking at your Makefile, and it's well beyond my knowledge.

asmagill commented 8 years ago

Oh, forgot to post last night -- https://github.com/asmagill/hammerspoon_asm/tree/master/pasteboard has been updated to be built with a 10.10 target, so give it a shot and let me know if that fixes the latest problem.

latenitefilms commented 8 years ago

@asmagill - Perfect, thanks heaps - works perfectly!

Although I've realised that all my XML code above is seriously buggy. Need to re-think all of that.

latenitefilms commented 8 years ago

I'm still trying to get my head around the CONTENTS of a Final Cut Pro pasteboard item. Attached is some examples, where I've taken the original pasteboard contents (which is a binary plist), converted it back to human readable XML, then base64 decoded the ffpasteboardobject which gives you another binary plist, and again, converted that back to a human readable XML file.

The problem I'm now having is actually understanding the final readable XML file - and how I actually make use of it's data. All I really want from it is the clip name. For example, looking at the attached files, the clip names are:

FCPX - Browser Multicam Clip = test FCPX - Browser Video Clip = TWOA-WEB-EP2-20151229 FCPX - Primary Storyline Multicam Clip = Multicam 1 FCPX - Primary Storyline Video Clip = TWOA-WEB-EP1-20151229 FCPX - Secondary Storyline Multicam Clip = Multicam 1 FCPX - Secondary Storyline Video Clip = TWOA-WEB-EP1-20151229

All of the XML files look different though, and there's no "key" for the clip name, so I'm not sure how I can accurately programmatically return the value I want each time.

Any ideas or suggestions?

Final Cut Pro Pasteboard.zip