Hi there! This is a no code post, at least as of yet. I'd like to be able to copy my edits (cuts and fades), from one track to another. I expect I'd use this a lot when cutting backgrounds and I'm asked to add another layer.
Here's what the situation would look like:
And here's one of the empty spaces zoomed in so you can see the fades:
What I usually do is click into all of the silences and break the long region in the middle of each, then go to the beginning, top track, and press:
' ; a p ' ; d p ' ' ; s p l ; g p '
Thoughts?
Thanks!
Linked from:
Ryan DeRemer @Ryan_DeRemerHey Nick! Funny, I went to high school with a guy with the same name!
The script below should do what you're looking for. It can also be accomplished in a macro by using Press Keys, Wait, and Click Menu Item actions. You can use the specifics in from the code in the macro actions. As far as I know there's no way for SF to know what's in the actual timeline, but using PT's menus to navigate and do the edits will work for this purpose. Hope this helps!
sf.ui.proTools.appActivateMainWindow(); sf.keyboard.press({ keys: `l` }); sf.wait({ intervalMs: 50 }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Selection', 'Extend Edit Down'] }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Selection', 'Remove Edit from Top'] }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Separate Clip', 'At Selection'] }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Selection', 'Extend Edit Up'] }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Selection', 'Remove Edit from Bottom'] }); sf.wait({ intervalMs: 50 }); sf.keyboard.press({ keys: `l` }); sf.wait({ intervalMs: 50 }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Selection', 'Extend Edit Down'] }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Selection', 'Remove Edit from Top'] }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Fades', 'Fade to End'] }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Selection', 'Extend Edit Up'] }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Selection', 'Remove Edit from Bottom'] }); sf.wait({ intervalMs: 50 }); sf.keyboard.press({ keys: `tab`, repetitions: 2 }); sf.wait({ intervalMs: 50 }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Selection', 'Extend Edit Down'] }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Selection', 'Remove Edit from Top'] }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Trim Clip', 'Start to Insertion'] }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Selection', 'Extend Edit Up'] }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Selection', 'Remove Edit from Bottom'] }); sf.wait({ intervalMs: 50 }); sf.keyboard.press({ keys: `tab` }); sf.wait({ intervalMs: 50 }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Selection', 'Extend Edit Down'] }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Selection', 'Remove Edit from Top'] }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Fades', 'Fade to Start'] }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Selection', 'Extend Edit Up'] }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Selection', 'Remove Edit from Bottom'] });
Nick Norton @notNickNortonDUDE I THINK THAT WAS ME. Newbury Park High School?
Thank you for this! Gonna test drive it today.
Nick Norton @notNickNortonThis works great!
The only thing I'd hope for would be a way to select the whole of the edited part, rather than clicking into each silence, and having it loop through. But this is already a huge timesaver, thank you!
Ryan DeRemer @Ryan_DeRemerDUDE! What a small world! How you been man? Hit me on IG @ryanmixedit!
Oh ok I took the pics literally lol. The goal with SF is to use built in functions or UI automation as much as possible, only doing keyboard/mouse simulation when absolutely necessary. The script I wrote was just the simplest/easiest to write and understand. Hopefully it'll help until I or someone else can write something a bit more substantial. But from what I've seen, it should be possible to automate what you want to do for the entire track, not just one clip at a time. It is a bit more involved though and I would imagine it can't be done properly with macros.
Long processes also need to take workflow into account, so I'd probably need more detail on how you'd want this command to run.
Nick Norton @notNickNortonOh yeah, more detail for how I'd like it to run!
I imagine I'd just drag my selection around the edited section of the top track and run it, having it make all the cuts on the lower track.
- DDenis Estevez @Denis_Estevez
Did this ever get improved upon even further (and would you share the outcome, please?)?? I would also find this very useful!
Nick Norton @notNickNortonNot yet!
I ran into @Kitch at an event last weekend and he had some thoughts on how to do this too...Kitch?
Kitch Membery @Kitch2022-09-30 18:44:09.064ZHi Nick,
Great to meet you on the weekend. And great to hear you are friends with @Ryan_DeRemer. Such a small world.
I for sure have some thoughts on this script. I made a similar script for my own use a while back. However it will need some editing to get it working the way you want.
See this post. Need the 'voiceover with some light background noise' script #post-2
I'll see if I can edit it today to get it working for you. :-)
- In reply tonotNickNorton⬆:
Kitch Membery @Kitch2022-09-30 19:29:32.558ZHi @notNickNorton,
Try the following script.
/** * @param {'Up'|'Down'} direction */ function navigateTrack(direction) { if (direction === 'Up') { sf.ui.proTools.getMenuItem("Edit", "Selection", "Extend Edit Up").elementClick(); sf.ui.proTools.getMenuItem("Edit", "Selection", "Remove Edit from Bottom").elementClick(); } if (direction === 'Down') { sf.ui.proTools.getMenuItem("Edit", "Selection", "Extend Edit Down").elementClick(); sf.ui.proTools.getMenuItem("Edit", "Selection", "Remove Edit from Top").elementClick(); } } function copy() { sf.ui.proTools.getMenuItem('Edit', 'Copy').elementClick(); } function paste() { sf.ui.proTools.getMenuItem('Edit', 'Paste').elementClick(); } function paste() { sf.ui.proTools.getMenuItem('Edit', 'Paste').elementClick(); } function main() { sf.ui.proTools.appActivateMainWindow(); var originalSelection = sf.ui.proTools.selectionGet(); const oldEditMode = sf.ui.proTools.editModeSet({ mode: "Slip", }).oldMode; const cursorToolCluster = sf.ui.proTools.mainWindow.cursorToolCluster; cursorToolCluster.buttons.whoseTitle.startsWith('Grabber tool').first.popupMenuSelect({ isRightClick: true, menuPath: ['Object'] }); navigateTrack("Down"); navigateTrack("Up"); copy() navigateTrack("Down"); paste() sf.ui.proTools.toolsSelect({ tool: "Selector", }); sf.ui.proTools.selectionSet({ selectionStart: originalSelection.selectionStart, selectionLength: originalSelection.selectionLength, }); navigateTrack("Up"); sf.ui.proTools.editModeSet({ mode: oldEditMode, }); } //Change the Main Counter to Samples for the duration of the main function sf.ui.proTools.mainCounterDoWithValue({ targetValue: 'Samples', action: () => { main(); } });Select the audio regions and fades on the source track, then run the script.
This
Becomes this
Let me know how it goes for you. :-)
Nick Norton @notNickNortonThat's amazing! And it actually does a step better than your screenshot, here's where I ended up:
Which is exactly what I was asking for. Beautiful!
- In reply toKitch⬆:DDenis Estevez @Denis_Estevez
Hi @Kitch & @notNickNorton ! For me this seems to just copy the edits from the first lane, but also the media. I'm after adding extra a few layers onto my existing atmos regions below (not just copy them). this can be of something different (example adding birds onto two existing wind and meadow sfx regions). With this script, for me it seems to just copy and paste my previous layer with the edits, and replaces the second new layer with this former? Am I doing something wrong? Nick's image seems to convey this as well. It has just copied '95 Room Tone' clips to the below track.
Nick Norton @notNickNortonAw man, you're right. I was so excited by seeing my edits pop up that I didn't read the region names. Just checked it on a pink noise region and you're exactly right - it copies the regions as well as the edits, not just the edits.
@Kitch any ideas?
For reference, here's a before/after for where Ryan's script gets it, but you have to manually click into each silence and then run it a bunch of times:
Before:
After:
Kitch Membery @Kitch2022-10-06 18:09:17.832ZHi @Denis_Estevez & @notNickNorton,
I must have edited my original script incorrectly. I'll take a look at it when I have a moment. :-)
- In reply tonotNickNorton⬆:
Kitch Membery @Kitch2022-10-06 19:38:24.787ZHi Hi @Denis_Estevez & @notNickNorton,
Looking at the script I posted, the behavior I intended worked as expected.
I'm not sure I quite understand what you are trying to achieve. In the original post you asked how to "copy my edits (cuts and fades)" to the track below. Does the script I shared not do this?
All my script was designed to do was copy the clips with edits from one track and place them on the track below. keeping the areas between the copied clips intact.
If you could share before and after screenshots of what you are trying to achieve that would be helpful. It would also be great if you could list out each step (in clear point form) you'd take to complete the workflow, that may make it more clear to me what you are after.
Even better would be to do a screen recording walking me through the process.
Rock on!
Kitch Membery @Kitch2022-10-06 20:02:16.359ZLooking at Ryan's script more closely, I think I now understand what you are trying to achieve.
Correct me if I'm wrong...
1.) You want to cut/trim the lower track audio to match the lengths of the upper tracks clips including fades.
2.) You want to reproduce (not copy) the fades from the upper track on the lower room tone track.
So essentially the lower track would be room tone cut and faded to match the upper track's edit.
Let me know if that seems right?
Nick Norton @notNickNortonYup that's right!
- In reply toKitch⬆:
Nick Norton @notNickNortonThe trick is not to copy the clips to the track below, but to apply the same edits to a long clip on the track below. But no problem on the walk through!
Say I've got BGs cut like this:
And the director is like "add birds". So I lay a clip of birds across the whole scene (in this case represented by noise, but you get the idea):
And then I manually match all the cuts, just using the navigation keys, to end up with this:
So, the goal is to skip the manual adding cuts to the second track's long region, and just automate that process.
Let me know if you need the key command combo I'd use!
Kitch Membery @Kitch2022-10-06 20:10:23.174ZThanks Nick,
That makes way more sense. :-)
This process is quite a bit more in depth, so I'll see what I can do when I get a chance.
Nick Norton @notNickNortonI appreciate it!
- In reply toKitch⬆:
Brenden @nednednerbDid you happen to get a chance to look at this again? I tried the script above and saw the same result!
I am interested in this script! I sometimes find I've got to replace a file I've started editing and I wish I could copy the "edits" to the new file. PT has an option to relink files in the Workspace, but this is not always ideal.
When I saw the script above seem to finish that quickly, the idea of the laborious process of copying edits is relieved.
For now I might make a basic loop, because my clips have no fades at the moment!
- MIn reply tonotNickNorton⬆:Martin Pavey @Martin_Pavey
+1 here for this.
If Kitch could get it to work it would be a fantastic timesaver!
I agree the best workflow would be make a selection over the clips you want to mirror the edits/fades on
and then run the shortcut.
It seems tantalisingly close to working and would save me and I'm sure lots of other people
loads of boring keypresses.
Pretty please. ;-)
In reply tonotNickNorton⬆:Nick Norton @notNickNortonJust tagging @Kitch because I've got a show starting this week this would be a huge help for (lots of perspective cuts), and it seems like other people want it too!
Kitch Membery @Kitch2023-12-11 00:35:48.734ZHi @notNickNorton,
No promises but I'll see how I go.
In reply tonotNickNorton⬆:Kitch Membery @Kitch2023-12-11 08:22:05.406ZHi @notNickNorton,
I did not have a lot of time today to look at this, but take this script for a spin.
Note: I discovered a bug with the
sf.ui.proTools.selectionGet()method while writing the script. It seems that for SoundFlow to determine if there is a "fade-in", there needs to be a clip on the track preceding it. And the same goes for fade-outs, there needs to be a clip somewhere on the track after the fade-out. (Mentioning this in case the script does not apply a fade in or fade out at the start/end of the fill region)./** * @param {'Up'|'Down'} direction */ function navigateTrack(direction) { if (direction === 'Up') { sf.ui.proTools.getMenuItem("Edit", "Selection", "Extend Edit Up").elementClick(); sf.ui.proTools.getMenuItem("Edit", "Selection", "Remove Edit from Bottom").elementClick(); } if (direction === 'Down') { sf.ui.proTools.getMenuItem("Edit", "Selection", "Extend Edit Down").elementClick(); sf.ui.proTools.getMenuItem("Edit", "Selection", "Remove Edit from Top").elementClick(); } } function getClipBoundaries() { const selection = sf.ui.proTools.selectionGet(); const boundaries = { start: selection.selectionStart, end: selection.selectionEnd, }; return boundaries; } function moveToNextClipOrFade() { // Select next Clip or Fade sf.keyboard.press({ keys: "ctrl+tab", }); } function deselectSelection() { // Deslect Selection sf.keyboard.press({ keys: "down", }); } function clearSelection() { sf.ui.proTools.getMenuItem("Edit", "Clear").elementClick(); } function createFadeIn() { sf.ui.proTools.getMenuItem("Edit", "Fades", "Fade to Start").elementClick(); } function createFadeOut() { sf.ui.proTools.getMenuItem("Edit", "Fades", "Fade to End").elementClick(); } function main() { sf.ui.proTools.appActivateMainWindow(); sf.ui.proTools.invalidate(); const oldEditMode = sf.ui.proTools.editModeSet({ mode: "Slip", }).oldMode; // Move down a track navigateTrack("Down"); // Trim Fill Clip sf.ui.proTools.menuClick({ menuPath: ["Edit", "Trim Clip", "To Selection"] }); // Move up a track navigateTrack("Up"); const initialSelection = getClipBoundaries(); const originalSelectionStart = initialSelection.start; const originalSelectionEnd = initialSelection.end; let previousClipsEndPoint; let storedSelections = []; // Deselect selection deselectSelection(); while (getClipBoundaries().end !== originalSelectionEnd) { moveToNextClipOrFade(); let selectionInfo = sf.ui.proTools.selectionGetInfo(); let hasStartFade = selectionInfo.hasStartFade; let hasEndFade = selectionInfo.hasEndFade; let currentClipBoundaries = getClipBoundaries(); let start = currentClipBoundaries.start; let end = currentClipBoundaries.end; if (start !== previousClipsEndPoint) { storedSelections.push({ task: "Clear", start: previousClipsEndPoint, end: getClipBoundaries().start, }); } if (hasStartFade) { storedSelections.push({ task: "Fade in", start, end, }); } if (hasEndFade) { storedSelections.push({ task: "Fade out", start, end, }); } previousClipsEndPoint = getClipBoundaries().end; } // Move down a track navigateTrack("Down"); // Clear Selections (Run this first before fades). storedSelections.filter(selection => selection.task === "Clear").forEach(selection => { sf.ui.proTools.selectionSet({ selectionStart: selection.start, selectionEnd: selection.end, }); clearSelection(); }); // Create Fades storedSelections.filter(selection => selection.task !== "Clear").forEach(selection => { if (selection.task === "Fade in") { sf.ui.proTools.selectionSet({ selectionStart: selection.end, selectionEnd: selection.end, }); createFadeIn(); } if (selection.task === "Fade out") { sf.ui.proTools.selectionSet({ selectionStart: selection.start, selectionEnd: selection.start, }); createFadeOut(); } }); // Move up a track navigateTrack("Up"); // Restore Selection sf.ui.proTools.selectionSet({ selectionStart: originalSelectionStart, selectionEnd: originalSelectionEnd, }); sf.ui.proTools.editModeSet({ mode: oldEditMode, }); } sf.ui.proTools.mainCounterDoWithValue({ targetValue: 'Samples', action: main, });Hopefully, this will get you through the week of heavy perspective cuts. :-)
Rock on!
CC: @Martin_Pavey - I unfortunately did not see your reply back in late August, as you replied to Nick's thread not to one of my replies. (Sorry I missed it.)
Nick Norton @notNickNortonHoly cow, this is awesome!
I discovered a second use case, which is cutting back and forth between two clips (for perspective edits for instance).
So everyone reading this knows what's up, here's how it works. Select this:
To get this:
And for perspective cuts, select this:
To get this:
It does feel a little counterintuitive for what you select before running it, so I might try to tweak that myself a bit.
Thank you Kitch!
- MMartin Pavey @Martin_Pavey
Hi Nick
Did you get time to tweak this script at all?
It would be great to have the perspective cuts thing working etc.I was using it yesterday on a show and it's saving me loads of mousing and clicking,
love it!
- MIn reply tonotNickNorton⬆:Martin Pavey @Martin_Pavey
Thanks Kitch, this is great!
It's a shame it doesn't do the first or last fades if there's nothing on the track ahead, but I can live with that.
Nick, I don't get the same results as you when making the selections on your pictures.
Soundflow just goes into some weird loop and I have to quit out to get it to stop.
The only way it works for me is to select the regions I want to copy. Then it will duplicate the cuts and fades to the track underneath.
How are you getting that to work? Perspective cuts would be massive I agree, big timesaver.
Maybe it's a difference in our default PT setups? - OIn reply tonotNickNorton⬆:Owen Granich-Young @Owen_Granich_Young
UPDATE -- Many problems addressed (including if you only have 1 region selected also If you have Stereo tracks :) way more work on both process clips and how it handles cross fades. Not to mention realizing there's a bunch more functions i can do with SDK ... you could do the fades with SDK too but then user needs presets set so I decided to keep that simple for now.
Here's one I've given waaaaaay too much time to today, but it takes advantage of SDK and works like gangbusters.
processCLipsWithTransitionsis the sweet sweet sweetness to redefine clips with fades and cross fades as whole clips. Feels only right I share it and give back to all who have helped me on here along the way. Report any edge cases please... I put in some Adjust Bounds Guardrails last second:const trimClip = () => sf.app.proTools.trimToSelection() const fadeToStart = () => sf.ui.proTools.menuClick({ menuPath: [`Edit`, `Fades`, `Fade to Start`] }); const fadeToEnd = () => sf.ui.proTools.menuClick({ menuPath: [`Edit`, `Fades`, `Fade to End`] }); const clear = () => sf.app.proTools.clear() const buttonFade = () => sf.keyboard.press({ keys: "f", }); //// try out SDK fade const separateClip = () => sf.ui.proTools.menuClick({ menuPath: [`Edit`, `Separate Clip`, `At Selection`] }); function dismissDialog() { const dlg = sf.ui.proTools.confirmationDialog; //Wait 100ms for dialog box to appear dlg.elementWaitFor({ timeout: 100, pollingInterval: 10, onError: 'Continue' }); if (dlg.children.whoseRole.is("AXStaticText").whoseValue.is("One or more fade requests are invalid due to insufficient audio data within the fade bounds. You may skip the invalid fade request(s), or adjust the bounds for those fades (where possible).").first.exists) { dlg.buttons.whoseTitle.is("Adjust Bounds").first.elementClick(); dlg.elementWaitFor({ waitType: 'Disappear', timeout: 100, pollingInterval: 10, onError: 'Continue' }); } } function combineMultiTrack(clips) { // Filter out clips with unique start and end times const uniqueClips = clips.filter((clip, index, array) => { return array.findIndex(c => c.StartTime === clip.StartTime && c.EndTime === clip.EndTime) === index; }); return uniqueClips; } function processClipsWithTransitions(makeWholeCips) { let clips = combineMultiTrack(makeWholeCips) let combinedClips = []; let crossFadeInProgress = false; let crossFadeStartTime = 0; let crossFadeEndTime = 0; for (let i = 0; i < clips.length; i++) { let clip = clips[i]; if (clip.ClipName.includes("(cross fade)") && i > 0 && i < clips.length - 1) { if (!crossFadeInProgress) { crossFadeInProgress = true; crossFadeStartTime = clips[i - 1].StartTime; crossFadeEndTime = clips[i + 1].EndTime; } else { crossFadeEndTime = clips[i + 1].EndTime; } } else if (crossFadeInProgress) { combinedClips[combinedClips.length - 1].EndTime = crossFadeEndTime; crossFadeInProgress = false; } else if (clip.ClipName.includes("(fade in)") && i < clips.length - 1) { let nextClip = clips[i + 1]; let combinedClip = { TrackName: clip.TrackName, ClipName: nextClip.ClipName, StartTime: clip.StartTime, EndTime: nextClip.EndTime }; combinedClips.push(combinedClip); i++; // Skip the next clip as it's already combined } else if (clip.ClipName.includes("(fade out)") && i > 0) { combinedClips[combinedClips.length - 1].EndTime = clip.EndTime; } else { combinedClips.push(clip); } } return combinedClips; } function extractUniqueTrackNames(clipsArray) { let uniqueTrackNames = []; clipsArray.forEach(clip => { if (!uniqueTrackNames.includes(clip.TrackName)) { uniqueTrackNames.push(clip.TrackName); } }); return uniqueTrackNames; } function extractClipsFromFirstTrack(clipsArray, originalTracks, pickIndex) { // Get the name of the first track const firstTrackName = originalTracks[pickIndex]; // Filter the clips array to include only clips from the first track const clipsFromFirstTrack = clipsArray.filter(clip => clip.TrackName === firstTrackName); return clipsFromFirstTrack; } function createNewArrayWithAdjustedTimes(clipsArray) { let newArray = []; for (let i = 1; i < clipsArray.length; i++) { let startTime = clipsArray[i - 1].EndTime; let endTime = clipsArray[i].StartTime; newArray.push({ StartTime: startTime, EndTime: endTime }); } return newArray; } function performFadeActions(clips, originalTracks) { let highestEndTime = Math.max(...clips.map(clip => clip.EndTime)); let processedCrossfades = new Set(); clips.some((clip, index) => { if (clip.EndTime >= highestEndTime) { if (clip.ClipName === "(fade out)") { // Process the last "(fade out)" clip sf.ui.proTools.selectionSetInSamples({ selectionStart: clip.StartTime, selectionEnd: clip.StartTime }); fadeToEnd(); } return true; // Break out of the loop if the clip's end time is greater than or equal to the highest end time } if (clip.ClipName === "(fade in)") { // Move to the end time of the fade in clip and perform fadeToStart function sf.ui.proTools.selectionSetInSamples({ selectionStart: clip.EndTime, selectionEnd: clip.EndTime }); fadeToStart(); } else if (clip.ClipName === "(fade out)") { // Move to the start time of the fade out clip and perform fadeToEnd function sf.ui.proTools.selectionSetInSamples({ selectionStart: clip.StartTime, selectionEnd: clip.StartTime }); fadeToEnd(); } else if (clip.ClipName === "(cross fade)" && !processedCrossfades.has(index)) { sf.ui.proTools.selectionSetInSamples({ selectionStart: clip.StartTime, selectionEnd: clip.EndTime }); //// this is not working right now... if (!sf.ui.proTools.getMenuItem("Edit", "Fades", "Create...").isEnabled) { // Calculate halfway point const halfwayTime = (clip.StartTime + clip.EndTime) / 2; // Select halfway point sf.ui.proTools.selectionSetInSamples({ selectionStart: halfwayTime, selectionEnd: halfwayTime }); // Execute separateClip() function separateClip(); // Continue with the rest of the actions sf.ui.proTools.selectionSetInSamples({ selectionStart: clip.StartTime, selectionEnd: clip.EndTime }); } buttonFade(); dismissDialog(); sf.app.proTools.selectTracksByName({ trackNames: originalTracks.slice(1), selectionMode: "Replace" }) sf.wait({ intervalMs: 50 }); // Mark crossfade as processed processedCrossfades.add(index); } return false; }); } function main() { sf.ui.proTools.invalidate(); ///get Info on all Selected Clips & Tracks let getInfo = sf.app.proTools.getSelectedClipInfo(); /// get Unique Track Names for Navigating let originalTracks = extractUniqueTrackNames(getInfo.clips) /// Get Clip Info on Tracks 1 let firstTrackClips = extractClipsFromFirstTrack(getInfo.clips, originalTracks, 0); /// Get full clip info on Track 1 let cuts = processClipsWithTransitions(firstTrackClips) /// Sets Clips only range let originalSelection = () => sf.ui.proTools.selectionSetInSamples({ selectionStart: firstTrackClips[0].StartTime, selectionEnd: firstTrackClips[firstTrackClips.length - 1].EndTime }); //select all tracks but the guide track /* sf.ui.proTools.trackSelectByName({ names: originalTracks.slice(1) }) */ sf.app.proTools.selectTracksByName({ trackNames: originalTracks.slice(1), selectionMode: "Replace" }) if (cuts.length > 1) { /// Create Array of ranges to Match Track 2 let bside = createNewArrayWithAdjustedTimes(cuts) bside.forEach(clip => { sf.ui.proTools.selectionSetInSamples({ selectionStart: clip.StartTime, selectionEnd: clip.EndTime }); clear() }); } originalSelection() trimClip() performFadeActions(firstTrackClips, originalTracks) } sf.ui.proTools.mainCounterDoWithValue({ targetValue: `Samples`, action: main })
Nick Norton @notNickNortonDUDE.
I had not worked on this in a while so hadn't been checking in. This script is now an F-ing miracle. Holy cow! Thank you!
- In reply toOwen_Granich_Young⬆:MMatt Still @Matt_Still
It's hard to explain the euphoria I'm feeling now that I've found this!! You are a genius!! It even does it to multiple tracks at once. THANK YOU, THANK YOU, THANK YOU!!
- In reply toOwen_Granich_Young⬆:MMartin Pavey @Martin_Pavey
Owen you are a star!
The old script was a massive timesaver, but this new one is super cool.
Thanks so much for making my life much easier. - In reply toOwen_Granich_Young⬆:
James Wasserman @James_WassermanHi SF fam -- I posted a quick demo video about a very similar task/script idea I was playing with on the facebook group and it seemed a few people were interested. Huge thanks to @Owen_Granich_Young for pointing me in the direction of this already existing thread to continue the discussion.
I will link to two videos on the script:
1st is a quick demo in PT I posted on FB: https://youtu.be/97VUm13BhLs
2nd is an in-depth walkthrough and breakdown of the script made specifically for the forum: https://youtu.be/uAm1lvDKqrcI know the 2nd video is quite long-winded--sorry!! lol
Here is a quick walkthrough for those of you who don't like video demos:
Step 1: Make any selection of an edit you want to "match" or duplicate the "geometry" of eventually.
Step 2: Fire the macro and you will be greeted with two core options: "Learn" or "Apply"
Step 3: Click "Learn" -- you will see SF automation of PT happen where fades, clip boundaries, and "gaps" are learned.
Step 4: Select a target clip(s) or selection length equal to or greater in length to the OG learned selection/edit.
Step 5 (Final): Fire the Macro again and click "Apply" and you will see this "new" content trimmed and matched perfectly to your OG edit
You will notice that throughout the process you will always have two options... "Gaps Only" and "Full Edit Fade/Gaps". Gaps only will simply delete content around the clips, while Full Edit matches all clip lengths, fades, and fade types perfectly.
I know the script is a little long, but so far I am having a lot of fun with it and hope to have some of ya'll give it a try. Always open to improvements or suggestions.
Future Plans: look into multi-track-OG-edit learning aka "checkerboard edits"... you can still do this by running the exact process above for A checkerboad, then B checkerboard. I know it's definitely possible to handle this as one script or an alternative one. If anyone is dying for that I can look into trying something. As mentioned in the videos, I am still sorta confused as to whether or not crossfade handling should be considered.Hope you enjoy and would love some feedback or testers! Watch the videos if you are curious or confused about anything.
/** Match Edits - Learn & Apply * 4-Button System: * 1. Learn Gaps - Learn gap deletions only * 2. Learn Full Edit - Learn gaps + fades (ignore crossfades) * 3. Apply Gaps - Apply gap deletions only * 4. Apply Full Edit - Apply gaps + recreate fades */ sf.ui.proTools.appActivateMainWindow(); function L(s){ try{ log(s); }catch(e){} } // PORTABLE: Use system temp directory for any user var GAPS_FILE = "/tmp/match_edits_gaps.json"; var FULL_EDIT_FILE = "/tmp/match_edits_full_edit.json"; // ===== SPEED DEMON FADE LEARNING FUNCTIONS ===== function S(ms){ sf.wait({ intervalMs: ms }); } function closeFadeIfOpen(){ try{ var w = sf.ui.proTools.windows.whoseTitle.contains("Fade").first; if (w && w.exists) { sf.keyboard.press({ keys: "escape" }); S(100); } }catch(e){} } function waitFadeWin(timeoutMs){ var start = Date.now(); while (Date.now() - start < timeoutMs) { try{ var w = sf.ui.proTools.windows.whoseTitle.contains("Fade").first; if (w && w.exists) return w; }catch(e){} S(20); } return null; } function waitFadeClosed(timeoutMs){ var start = Date.now(); while (Date.now() - start < timeoutMs) { try{ var w = sf.ui.proTools.windows.whoseTitle.contains("Fade").first; if (!w || !w.exists) return true; }catch(e){ return true; } S(20); } return false; } function pick(group, options){ if (!group || !group.exists) return null; try{ for (var i=0;i<options.length;i++){ var opt = group.radioButtons.whoseTitle.is(options[i]).first; if (opt && opt.exists && opt.value.invalidate().isSelected) return options[i]; } }catch(e){} return null; } function readOpenFast(){ var w = sf.ui.proTools.windows.whoseTitle.contains("Fade").first; if (!w || !w.exists) return null; function ttl(el){ try{ return el.title.invalidate().value || ""; } catch(e){ return ""; } } function iv(el){ try{ return el.value.invalidate().intValue; } catch(e){ return null; } } function isSel(rb){ var i = iv(rb); if (i === 1) return true; if (i === 0) return false; try{ if (rb.value.invalidate().isSelected === true) return true; }catch(e){} try{ if (rb.value.invalidate().valueDescription === "selected") return true; }catch(e){} return false; } function pick(group, titles){ if (!group || !group.exists) return null; for (var i=0;i<titles.length;i++){ try{ var rb = group.radioButtons.whoseTitle.is(titles[i]).first; if (rb.exists && isSel(rb)) return titles[i]; }catch(e){} } try{ var rbs = group.radioButtons.allItems; for (var j=0;j<rbs.length;j++){ if (isSel(rbs[j])) return ttl(rbs[j]) || "(unnamed)"; } }catch(e){} return null; } var title = ""; try{ title = ttl(w); }catch(e){} var kind = /cross\s*fade/i.test(title) ? "xfade" : (/in/i.test(title) ? "in" : "out"); L("Reading fade properties from window: " + title + " (kind: " + kind + ")"); var slope = (function(){ try{ var g = w.groups.whoseTitle.is("Slope").first; if (!g || !g.exists) { L("No Slope group found"); return "unknown"; } var result = pick(g, ["Equal Power","Equal Gain"]); L("Slope found: " + result); return result || "unknown"; }catch(e){ L("Slope error: " + e.message); return "unknown"; } })(); var inShape="n/a", outShape="n/a"; if (kind === "in"){ try{ var gIn = w.groups.whoseTitle.contains("In Shape").first; if (!gIn.exists) gIn = w.groups.whoseTitle.contains("Shape").first; if (gIn && gIn.exists) { inShape = pick(gIn, ["Standard","S-Curve"]) || "Standard"; L("In Shape found: " + inShape); } }catch(e){ L("In Shape error: " + e.message); } } else if (kind === "out"){ try{ var gOut = w.groups.whoseTitle.contains("Out Shape").first; if (!gOut.exists) gOut = w.groups.whoseTitle.contains("Shape").last; if (gOut && gOut.exists) { outShape = pick(gOut, ["Standard","S-Curve"]) || "Standard"; L("Out Shape found: " + outShape); } }catch(e){ L("Out Shape error: " + e.message); } } else { try{ var gIn2 = w.groups.whoseTitle.contains("In Shape").first; var gOut2 = w.groups.whoseTitle.contains("Out Shape").first; if (!gIn2.exists) gIn2 = w.groups.whoseTitle.contains("Shape").first; if (!gOut2.exists) gOut2 = w.groups.whoseTitle.contains("Shape").last; if (gIn2 && gIn2.exists) { inShape = pick(gIn2, ["Standard","S-Curve"]) || "Standard"; L("Crossfade In Shape found: " + inShape); } if (gOut2 && gOut2.exists) { outShape = pick(gOut2, ["Standard","S-Curve"]) || "Standard"; L("Crossfade Out Shape found: " + outShape); } }catch(e){ L("Crossfade shapes error: " + e.message); } } var result = { kind: kind, slope: slope, inShape: inShape, outShape: outShape, preset: null }; L("Final fade properties: " + JSON.stringify(result)); return result; } function near(a,b,t){ return Math.abs((+a)-(+b)) <= t; } function uniqueFades(info){ var raw = []; for (var i=0;i<info.clips.length;i++){ var c = info.clips[i], n = String((c && (c.name || c.ClipName)) || ""); if (n.indexOf("(fade") === 0 || n === "(cross fade)") raw.push(c); } var groups = []; for (var j=0;j<raw.length;j++){ var s = +raw[j].StartTime, e = +raw[j].EndTime, len = e - s, g = null; for (var k=0;k<groups.length;k++){ var G = groups[k]; if (near(G.start,s,240) && near(G.length,len,240)) { g = G; break; } // 5ms tolerance } if (g) g.reps.push(raw[j]); else groups.push({ start:s, end:e, length:len, reps:[raw[j]] }); } groups.sort(function(a,b){ return a.start - b.start; }); return groups; } // Ask user which phase (2-step approach due to 3-button limit) var phaseChoice = sf.interaction.displayDialog({ title: "Match Edits", prompt: "Choose phase:", buttons: ["Learn", "Apply"], defaultButton: "Learn" }); if (!phaseChoice) return; var phase = phaseChoice && phaseChoice.button ? phaseChoice.button : phaseChoice; if (phase === "Learn") { // Learn phase - ask for type var learnChoice = sf.interaction.displayDialog({ title: "Learn Phase", prompt: "What to learn:", buttons: ["Gaps Only", "Full Edit (Gaps + Fades)"], defaultButton: "Full Edit (Gaps + Fades)" }); if (!learnChoice) return; var learnType = learnChoice && learnChoice.button ? learnChoice.button : learnChoice; if (learnType === "Gaps Only") { learnGaps(); } else if (learnType === "Full Edit (Gaps + Fades)") { learnFullEdit(); } } else if (phase === "Apply") { // Apply phase - ask for type var applyChoice = sf.interaction.displayDialog({ title: "Apply Phase", prompt: "What to apply:", buttons: ["Gaps Only", "Full Edit (Gaps + Fades)"], defaultButton: "Full Edit (Gaps + Fades)" }); if (!applyChoice) return; var applyType = applyChoice && applyChoice.button ? applyChoice.button : applyChoice; if (applyType === "Gaps Only") { applyGaps(); } else if (applyType === "Full Edit (Gaps + Fades)") { applyFullEdit(); } } function learnGaps() { L("=== LEARNING GAPS ==="); var sel = sf.ui.proTools.selectionGetInSamples(); if (!sel) { L("ERROR: Make a selection on reference track"); return; } var selectionStart = sel.selectionStart; var selectionEnd = sel.selectionEnd; var selectionLength = selectionEnd - selectionStart; L("Reference selection: " + selectionStart + " → " + selectionEnd + " (" + selectionLength + ")"); var info = sf.app.proTools.getSelectedClipInfo(); if (!info || !info.clips) { L("ERROR: No clips found"); return; } // Collect all boundaries var boundaries = [selectionStart, selectionEnd]; for (var i = 0; i < info.clips.length; i++) { var clip = info.clips[i]; var start = +clip.StartTime; var end = +clip.EndTime; if (start >= selectionStart && start <= selectionEnd) boundaries.push(start); if (end >= selectionStart && end <= selectionEnd) boundaries.push(end); } // Sort and dedupe boundaries = boundaries.sort(function(a,b){return a-b;}); var unique = []; for (var j = 0; j < boundaries.length; j++) { if (unique.length === 0 || boundaries[j] !== unique[unique.length-1]) { unique.push(boundaries[j]); } } L("Found " + unique.length + " boundaries"); // Calculate gaps (inverse of clips) var gaps = []; for (var k = 0; k < unique.length - 1; k++) { var gapStart = unique[k]; var gapEnd = unique[k + 1]; // Check if this is a gap (no clip spans this exact range) var isGap = true; for (var m = 0; m < info.clips.length; m++) { var c = info.clips[m]; if (c.StartTime === gapStart && c.EndTime === gapEnd) { isGap = false; break; } } if (isGap) { // Store as relative positions (will work after trimming) gaps.push({ start: gapStart - selectionStart, end: gapEnd - selectionStart }); L("Gap: " + (gapStart - selectionStart) + " → " + (gapEnd - selectionStart) + " (" + (gapEnd - gapStart) + ")"); } } // Save gaps var data = { referenceStart: selectionStart, // Store absolute start position referenceEnd: selectionEnd, // Store absolute end position referenceLength: selectionLength, gaps: gaps, timestamp: Date.now() }; try { sf.system.exec({ commandLine: `bash -lc 'cat > "${GAPS_FILE}" << "JSON"\n${JSON.stringify(data, null, 2)}\nJSON'`, timeout: 5000 }); L("Saved " + gaps.length + " gaps to " + GAPS_FILE); } catch (e) { L("ERROR: Could not save gaps: " + e.message); } } function applyGaps() { L("=== APPLYING GAPS (MULTI-TRACK) ==="); // Load gaps var data; try { var result = sf.system.exec({ commandLine: `bash -lc 'cat "${GAPS_FILE}"'`, timeout: 5000 }); data = JSON.parse(result.result); } catch (e) { L("ERROR: Could not load gaps: " + e.message); return; } var sel = sf.ui.proTools.selectionGetInSamples(); if (!sel) { L("ERROR: Make a selection on target track(s)"); return; } var targetStart = sel.selectionStart; var targetEnd = sel.selectionEnd; L("Target selection: " + targetStart + " → " + targetEnd + " (multi-track)"); // RESTORE EXACT ORIGINAL SELECTION BOUNDARIES (preserves height) L("Restoring original selection boundaries: " + data.referenceStart + " → " + data.referenceEnd); sf.ui.proTools.selectionSetInSamples({ selectionStart: data.referenceStart, selectionEnd: data.referenceEnd }); L("Selection restored to original boundaries (height preserved)"); // TRIM THE CLIP TO MATCH THE SELECTION (works on all selected tracks) sf.keyboard.press({ keys: "cmd+t" }); L("Trimmed clips to match selection (all tracks)"); // Update our variables to match the restored selection targetStart = data.referenceStart; targetEnd = data.referenceEnd; targetLength = data.referenceLength; // Now apply gaps using simple math (identical selection lengths) - works on all selected tracks L("Applying " + data.gaps.length + " gaps to all selected tracks..."); for (var i = 0; i < data.gaps.length; i++) { var gap = data.gaps[i]; var absStart = targetStart + gap.start; var absEnd = targetStart + gap.end; L("Deleting gap: " + absStart + " → " + absEnd + " (" + (absEnd - absStart) + ")"); sf.ui.proTools.selectionSetInSamples({ selectionStart: absStart, selectionEnd: absEnd }); sf.keyboard.press({ keys: "backspace" }); } L("Applied " + data.gaps.length + " gaps to all selected tracks"); // CLEANUP: Delete JSON file to avoid clutter try { sf.system.exec({ commandLine: `rm -f "${GAPS_FILE}"`, timeout: 2000 }); L("Cleaned up temporary file: " + GAPS_FILE); } catch (e) { L("Note: Could not clean up temp file (non-critical)"); } } function learnFullEdit() { L("=== LEARNING FULL EDIT (GAPS + FADES) ==="); var sel = sf.ui.proTools.selectionGetInSamples(); if (!sel) { L("ERROR: Make a selection on reference track"); return; } var selectionStart = sel.selectionStart; var selectionEnd = sel.selectionEnd; var selectionLength = selectionEnd - selectionStart; L("Reference selection: " + selectionStart + " → " + selectionEnd + " (" + selectionLength + ")"); var info = sf.app.proTools.getSelectedClipInfo(); if (!info || !info.clips) { L("ERROR: No clips found"); return; } // Collect all boundaries (same as learnGaps) var boundaries = [selectionStart, selectionEnd]; for (var i = 0; i < info.clips.length; i++) { var clip = info.clips[i]; var start = +clip.StartTime; var end = +clip.EndTime; if (start >= selectionStart && start <= selectionEnd) boundaries.push(start); if (end >= selectionStart && end <= selectionEnd) boundaries.push(end); } // Sort and dedupe boundaries = boundaries.sort(function(a,b){return a-b;}); var unique = []; for (var j = 0; j < boundaries.length; j++) { if (unique.length === 0 || boundaries[j] !== unique[unique.length-1]) { unique.push(boundaries[j]); } } L("Found " + unique.length + " boundaries"); // Calculate gaps (same as learnGaps) var gaps = []; for (var k = 0; k < unique.length - 1; k++) { var gapStart = unique[k]; var gapEnd = unique[k + 1]; // Check if this is a gap (no clip spans this exact range) var isGap = true; for (var m = 0; m < info.clips.length; m++) { var c = info.clips[m]; if (c.StartTime === gapStart && c.EndTime === gapEnd) { isGap = false; break; } } if (isGap) { gaps.push({ start: gapStart - selectionStart, end: gapEnd - selectionStart }); L("Gap: " + (gapStart - selectionStart) + " → " + (gapEnd - selectionStart) + " (" + (gapEnd - gapStart) + ")"); } } // NEW: Learn fades using Speed Demon approach (with properties) var fades = []; var fadeGroups = uniqueFades(info); L("Found " + fadeGroups.length + " unique fade group(s) in selection"); // Close any open fade dialogs first closeFadeIfOpen(); // Learn each fade group for (var i = 0; i < fadeGroups.length; i++) { var fadeGroup = fadeGroups[i]; // Skip if fade is outside our selection if (fadeGroup.start >= selectionEnd || fadeGroup.end <= selectionStart) { L("Skipping fade outside selection: " + fadeGroup.start + " → " + fadeGroup.end); continue; } // Read fade properties using Speed Demon approach first sf.ui.proTools.selectionSetInSamples({ selectionStart: fadeGroup.start, selectionEnd: fadeGroup.end }); sf.keyboard.press({ keys: "cmd+f" }); var w = waitFadeWin(2000); if (!w) { L("— Fade " + (i+1) + ": no window"); continue; } var props = readOpenFast() || { kind:"unknown", slope:"unknown", inShape:"unknown", outShape:"unknown", preset:null }; sf.keyboard.press({ keys: "escape" }); waitFadeClosed(120); // Check if this is a crossfade (skip it) if (props.kind === "xfade") { L("Skipping crossfade: " + fadeGroup.start + " → " + fadeGroup.end + " (kind: " + props.kind + ")"); continue; } // Also check if it spans across clip boundaries var isCrossfade = false; for (var j = 0; j < unique.length - 1; j++) { var boundary = unique[j]; if (fadeGroup.start < boundary && fadeGroup.end > boundary) { isCrossfade = true; break; } } if (isCrossfade) { L("Skipping crossfade: " + fadeGroup.start + " → " + fadeGroup.end + " (spans boundaries)"); continue; } // Store relative to selection start var relativeStart = fadeGroup.start - selectionStart; var relativeEnd = fadeGroup.end - selectionStart; fades.push({ start: relativeStart, end: relativeEnd, duration: relativeEnd - relativeStart, kind: props.kind, slope: props.slope, inShape: props.inShape, outShape: props.outShape, preset: props.preset }); L("Learned fade: " + props.kind + " @ " + relativeStart + "→" + relativeEnd + " (" + props.slope + ", " + props.inShape + "/" + props.outShape + ")"); } // Save full edit data var data = { referenceStart: selectionStart, referenceEnd: selectionEnd, referenceLength: selectionLength, gaps: gaps, fades: fades, timestamp: Date.now() }; try { sf.system.exec({ commandLine: `bash -lc 'cat > "${FULL_EDIT_FILE}" << "JSON"\n${JSON.stringify(data, null, 2)}\nJSON'`, timeout: 5000 }); L("Saved " + gaps.length + " gaps and " + fades.length + " fades to " + FULL_EDIT_FILE); } catch (e) { L("ERROR: Could not save full edit: " + e.message); } } function applyFullEdit() { L("=== APPLYING FULL EDIT (GAPS + FADES) - MULTI-TRACK ==="); // Load full edit data var data; try { var result = sf.system.exec({ commandLine: `bash -lc 'cat "${FULL_EDIT_FILE}"'`, timeout: 5000 }); data = JSON.parse(result.result); } catch (e) { L("ERROR: Could not load full edit: " + e.message); return; } var sel = sf.ui.proTools.selectionGetInSamples(); if (!sel) { L("ERROR: Make a selection on target track(s)"); return; } var targetStart = sel.selectionStart; var targetEnd = sel.selectionEnd; L("Target selection: " + targetStart + " → " + targetEnd + " (multi-track)"); // RESTORE EXACT ORIGINAL SELECTION BOUNDARIES (preserves height) L("Restoring original selection boundaries: " + data.referenceStart + " → " + data.referenceEnd); sf.ui.proTools.selectionSetInSamples({ selectionStart: data.referenceStart, selectionEnd: data.referenceEnd }); L("Selection restored to original boundaries (height preserved)"); // TRIM THE CLIP TO MATCH THE SELECTION (works on all selected tracks) sf.keyboard.press({ keys: "cmd+t" }); L("Trimmed clips to match selection (all tracks)"); // Update our variables to match the restored selection targetStart = data.referenceStart; targetEnd = data.referenceEnd; targetLength = data.referenceLength; // Apply gaps first (works on all selected tracks) L("Applying " + data.gaps.length + " gaps to all selected tracks..."); for (var i = 0; i < data.gaps.length; i++) { var gap = data.gaps[i]; var absStart = targetStart + gap.start; var absEnd = targetStart + gap.end; L("Deleting gap: " + absStart + " → " + absEnd + " (" + (absEnd - absStart) + ")"); sf.ui.proTools.selectionSetInSamples({ selectionStart: absStart, selectionEnd: absEnd }); sf.keyboard.press({ keys: "backspace" }); } // NEW: Apply fades with detailed properties (works on all selected tracks) L("Applying " + data.fades.length + " fades with properties to all selected tracks..."); for (var i = 0; i < data.fades.length; i++) { var fade = data.fades[i]; var absStart = targetStart + fade.start; var absEnd = targetStart + fade.end; L("Creating " + fade.kind + " fade: " + absStart + " → " + absEnd + " (" + fade.duration + ") - " + fade.slope + ", " + fade.inShape + "/" + fade.outShape); sf.ui.proTools.selectionSetInSamples({ selectionStart: absStart, selectionEnd: absEnd }); // Create fade using Pro Tools menu (exact path with ellipsis) sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Fades', 'Create...'] }); S(200); // Apply fade properties if we have detailed info if (fade.kind !== "unknown" && fade.slope !== "unknown") { try { var fadeWin = waitFadeWin(2000); if (fadeWin) { // Set slope if (fade.slope !== "unknown") { try { var slopeGroup = fadeWin.groups.whoseTitle.is("Slope").first; if (slopeGroup && slopeGroup.exists) { var slopeRadio = slopeGroup.radioButtons.whoseTitle.is(fade.slope).first; if (slopeRadio && slopeRadio.exists) { slopeRadio.elementClick(); S(50); } } } catch(e) { L("Could not set slope: " + e.message); } } // Set shapes based on fade kind if (fade.kind === "in" && fade.inShape !== "unknown") { try { var inGroup = fadeWin.groups.whoseTitle.contains("In Shape").first; if (!inGroup.exists) inGroup = fadeWin.groups.whoseTitle.contains("Shape").first; if (inGroup && inGroup.exists) { var inRadio = inGroup.radioButtons.whoseTitle.is(fade.inShape).first; if (inRadio && inRadio.exists) { inRadio.elementClick(); S(50); } } } catch(e) { L("Could not set in shape: " + e.message); } } else if (fade.kind === "out" && fade.outShape !== "unknown") { try { var outGroup = fadeWin.groups.whoseTitle.contains("Out Shape").first; if (!outGroup.exists) outGroup = fadeWin.groups.whoseTitle.contains("Shape").last; if (outGroup && outGroup.exists) { var outRadio = outGroup.radioButtons.whoseTitle.is(fade.outShape).first; if (outRadio && outRadio.exists) { outRadio.elementClick(); S(50); } } } catch(e) { L("Could not set out shape: " + e.message); } } // Close fade window sf.keyboard.press({ keys: "return" }); waitFadeClosed(120); } } catch(e) { L("Error applying fade properties: " + e.message); // Close window if open try { sf.keyboard.press({ keys: "escape" }); waitFadeClosed(120); } catch(e2) {} } } else { // Simple fade creation without properties sf.keyboard.press({ keys: "return" }); S(100); } } L("Applied " + data.gaps.length + " gaps and " + data.fades.length + " fades to all selected tracks"); // CLEANUP: Delete JSON file to avoid clutter try { sf.system.exec({ commandLine: `rm -f "${FULL_EDIT_FILE}"`, timeout: 2000 }); L("Cleaned up temporary file: " + FULL_EDIT_FILE); } catch (e) { L("Note: Could not clean up temp file (non-critical)"); } }Cheers!
James
Nick Norton @notNickNortonThis took me absolutely forever to get around to responding to. JFC this is cool. Awesome. Consider my script replaced!
James Wasserman @James_WassermanNick! I just realized this is you lol we were talking recently on Facebook right? Dude glad you like 🤘 I use this script all the time! Have a few other really crazy edit matching concepts I’ve come up with as well. I like exploring this concept.
Nick Norton @notNickNortonWow, okay love that! Also love that this thread gets revived every six months or so and improves each time.
- In reply toJames_Wasserman⬆:
Sreejesh Nair @Sreejesh_NairHi @James_Wasserman ,
This is such great idea! For some reason it kept giving me errors so I modified it slightly to use the sdk calls and sfx methods. I also used the Soundflow method to read and write JSON so to avoid the commandline calls.
The only thing I cant workout when transferring fade is a custom S-Curve Fade. I also put all the calls on one display dialog with 3 buttons. This way I could reduce the need for 2 jsons since the learn would capture the gap and fades anyways.// Match Edits - Learn & Apply sf.ui.proTools.appActivateMainWindow(); // ===== CONSTANTS ===== const CONFIG = { DATA_FILE: "/tmp/match_edits.json", NOTIFICATION_ID: sf.system.newGuid().guid, WAIT: { SHORT: 50, MEDIUM: 100, LONG: 200 }, FADE_TIMEOUT: 2000 }; const IS_SFX = sf.ui.proTools.isSfxApplication; function showNotification(title, message, progress) { var notification = { uid: CONFIG.NOTIFICATION_ID, title: title, message: message }; if (progress !== undefined) notification.progress = progress; sf.interaction.notify(notification); } // ===== FADE WINDOW MANAGEMENT ===== function waitForFadeWindow(timeoutMs) { var endTime = Date.now() + timeoutMs; while (Date.now() < endTime) { try { var window = sf.ui.proTools.sfx.windows.invalidate().whoseTitle.contains("Fade").first; if (window && window.exists) return window; } catch (e) { } sf.wait({ intervalMs: 20 }); } return null; } function closeFadeWindow() { try { var window = sf.ui.proTools.sfx.windows.whoseTitle.contains("Fade").first; if (window && window.exists) { window.buttons.whoseTitle.is("Cancel").first.elementClick(); sf.waitFor({ callback: !window.exists, timeout: 5000 }); } } catch (e) { } } function getSelectedRadioButton(group, options) { if (!group || !group.exists) return null; for (var i = 0; i < options.length; i++) { try { var radioButton = group.radioButtons.whoseTitle.is(options[i]).first; if (radioButton && radioButton.exists && radioButton.value.invalidate().intValue === 1) { return options[i]; } } catch (e) { } } return null; } function getSelectedFadeShapeIndices(popupButton) { var selectedIndices = []; try { var menuItems = popupButton.popupMenuFetchAllItems().menuItems; for (var i = 0; i < menuItems.length; i++) { try { if (menuItems[i].element.isMenuChecked === true) selectedIndices.push(i); } catch (e) { } } } catch (e) { } return selectedIndices; } function readCustomShapeSettings(window, shapeGroupName) { try { var shapeGroup = window.groups.whoseTitle.is(shapeGroupName).first; if (!shapeGroup || !shapeGroup.exists) return null; var customShapeRadio = shapeGroup.radioButtons.whoseTitle.is(shapeGroupName).first; if (!customShapeRadio || !customShapeRadio.exists) return null; if (!customShapeRadio.invalidate().isCheckBoxChecked) return null; var popupButton = shapeGroup.popupButtons.first; if (!popupButton || !popupButton.exists) return null; return { useCustomShape: true, customShapeIndices: getSelectedFadeShapeIndices(popupButton) }; } catch (e) { return null; } } // ===== FADE PROPERTIES ===== function readFadeProperties() { var window = waitForFadeWindow(CONFIG.FADE_TIMEOUT); if (!window) return null; var windowTitle = ""; try { windowTitle = window.title.invalidate().value || ""; } catch (e) { } var fadeKind = windowTitle.indexOf("Fade In") !== -1 ? "in" : windowTitle.indexOf("Fade Out") !== -1 ? "out" : "unknown"; var slopeGroup = window.groups.whoseTitle.contains("Slope").first; var shapeGroup = window.groups.whoseTitle.contains("Shape").first; var properties = { kind: fadeKind, slope: getSelectedRadioButton(slopeGroup, ["Equal Power", "Equal Gain"]) || "Equal Power", shape: getSelectedRadioButton(shapeGroup, ["Standard", "S-Curve"]) || "Standard", useCustomShape: false, customShapeIndices: [] }; var shapeGroupName = fadeKind === "in" ? "In Shape" : "Out Shape"; var customSettings = readCustomShapeSettings(window, shapeGroupName); if (customSettings) { properties.useCustomShape = customSettings.useCustomShape; properties.customShapeIndices = customSettings.customShapeIndices; } return properties; } function clickRadioButton(group, buttonName) { try { if (!group || !group.exists) return; var radio = group.radioButtons.whoseTitle.is(buttonName).first; if (radio && radio.exists) { radio.elementClick(); sf.wait({ intervalMs: CONFIG.WAIT.SHORT }); } } catch (e) { } } function applyCustomFadeShape(window, shapeGroupName, shapeIndices) { try { var shapeGroup = window.groups.whoseTitle.is(shapeGroupName).first; if (!shapeGroup || !shapeGroup.exists) return; clickRadioButton(shapeGroup, shapeGroupName); var popupButton = shapeGroup.popupButtons.first; if (!popupButton || !popupButton.exists) return; popupButton.elementClick({ asyncSwallow: true }); sf.wait({ intervalMs: CONFIG.WAIT.MEDIUM }); for (var i = 0; i < shapeIndices.length; i++) { try { var menuItems = sf.ui.proTools.invalidate().confirmationDialog.menuItems.whoseTitle.is(" ").allItems; if (menuItems && menuItems[shapeIndices[i]]) { menuItems[shapeIndices[i]].elementClick(); sf.wait({ intervalMs: CONFIG.WAIT.SHORT }); } } catch (e) { } } } catch (e) { } } function applyFadeProperties(fadeData) { sf.app.proTools.setTimelineSelection({ inTime: String(fadeData.absoluteStart), outTime: String(fadeData.absoluteEnd) }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Fades', 'Create...'] }); sf.wait({ intervalMs: CONFIG.WAIT.LONG }); var window = waitForFadeWindow(1000); if (!window) return; try { var slopeGroup = window.groups.whoseTitle.contains("Slope").first; clickRadioButton(slopeGroup, fadeData.slope); if (fadeData.useCustomShape && fadeData.customShapeIndices.length > 0) { var shapeGroupName = fadeData.kind === "in" ? "In Shape" : "Out Shape"; applyCustomFadeShape(window, shapeGroupName, fadeData.customShapeIndices); } else { var shapeGroup = window.groups.whoseTitle.contains("Shape").first; clickRadioButton(shapeGroup, fadeData.shape); } window.buttons.whoseTitle.is("OK").first.elementClick({ asyncSwallow: true }); sf.wait({ intervalMs: CONFIG.WAIT.MEDIUM }); } catch (e) { try { window.buttons.whoseTitle.is("Cancel").first.elementClick(); sf.waitFor({ callback: !window.exists, timeout: 5000 }); } catch (e2) { } } } // ===== CLIP PROCESSING ===== function analyzeClips(clips, selStart, selEnd) { if (!clips || !clips.length) return { gaps: [], fades: [] }; var sorted = clips.slice().sort(function (a, b) { return Number(a.startTime) - Number(b.startTime); }); var merged = []; var fades = []; // Process clips in one pass - build merged intervals and extract fades for (var i = 0; i < sorted.length; i++) { var clipStart = Number(sorted[i].startTime); var clipEnd = Number(sorted[i].endTime); var clipName = (sorted[i].clipName || "").toLowerCase(); // Extract fade clips (but not crossfades) if (clipName.indexOf("fade") !== -1 && clipName.indexOf("cross") === -1) { if (clipStart < selEnd && clipEnd > selStart) { fades.push({ relativeStart: clipStart - selStart, relativeEnd: clipEnd - selStart, kind: clipName.indexOf("fade in") !== -1 ? "in" : "out" }); } } // Build merged intervals if (merged.length === 0) { merged.push([clipStart, clipEnd]); } else { var last = merged[merged.length - 1]; var prevName = i > 0 ? (sorted[i - 1].clipName || "").toLowerCase() : ""; var hasFade = prevName.indexOf("fade") !== -1 || clipName.indexOf("fade") !== -1; if (clipStart < last[1] || (clipStart === last[1] && hasFade)) { last[1] = Math.max(last[1], clipEnd); } else { merged.push([clipStart, clipEnd]); } } } // Calculate gaps from merged intervals var gaps = []; if (merged[0][0] > selStart) { gaps.push([0, merged[0][0] - selStart]); } for (var i = 0; i < merged.length - 1; i++) { if (merged[i + 1][0] > merged[i][1]) { gaps.push([merged[i][1] - selStart, merged[i + 1][0] - selStart]); } } if (merged[merged.length - 1][1] < selEnd) { gaps.push([merged[merged.length - 1][1] - selStart, selEnd - selStart]); } return { gaps: gaps, fades: fades }; } // ===== PRO TOOLS OPERATIONS ===== function setSelection(start, end) { sf.app.proTools.setTimelineSelection({ inTime: String(start), outTime: String(end) }); } function trimClips() { sf.ui.proTools.menuClick({ menuPath: ["Edit", "Trim Clip", "To Selection"] }); } function deleteSelection() { IS_SFX ? sf.ui.proTools.sfx.dawCommands.getByUniquePersistedName("DeleteSelection").run() : sf.keyboard.press({ keys: "backspace" }); } function openFadeDialog() { IS_SFX ? sf.ui.proTools.sfx.dawCommands.getByUniquePersistedName("CreateFades").run() : sf.ui.proTools.menuClick({ menuPath: ["Edit", "Fades", "Create..."] }); } function getSelectionAndClips() { var sel = sf.app.proTools.getTimelineSelection({ timeScale: "Samples" }); var info = sf.app.proTools.getSelectedClipInfo(); if (!sel || !info || !info.clips) { showNotification("Error", "No selection or clips"); return null; } return { start: Number(sel.inTime), end: Number(sel.outTime), clips: info.clips }; } // ===== LEARN FUNCTION ===== function learn() { showNotification("Match Edits", "Learning edits...", 0); var data = getSelectionAndClips(); if (!data) return; showNotification("Match Edits", "Calculating gaps...", 0.1); var analyzed = analyzeClips(data.clips, data.start, data.end); var gaps = analyzed.gaps; showNotification("Match Edits", "Finding fades...", 0.2); var fadeClips = analyzed.fades; var learnedFades = []; if (fadeClips.length > 0) { closeFadeWindow(); for (var i = 0; i < fadeClips.length; i++) { var fade = fadeClips[i]; showNotification("Match Edits", "Learning fade " + (i + 1) + "/" + fadeClips.length + ": " + fade.kind, 0.2 + (0.7 * (i / fadeClips.length))); setSelection(data.start + fade.relativeStart, data.start + fade.relativeEnd); openFadeDialog(); var props = readFadeProperties(); closeFadeWindow(); if (props) { learnedFades.push({ start: fade.relativeStart, end: fade.relativeEnd, kind: props.kind, slope: props.slope, shape: props.shape, useCustomShape: props.useCustomShape, customShapeIndices: props.customShapeIndices }); } } } showNotification("Match Edits", "Saving data...", 0.9); sf.file.writeJson({ path: CONFIG.DATA_FILE, json: { start: data.start, end: data.end, gaps: gaps, fades: learnedFades } }); showNotification("Match Edits", "Saved " + gaps.length + " gaps, " + learnedFades.length + " fades", 1); } // ===== APPLY FUNCTION ===== function apply(includeGaps, includeFades) { var operationName = includeGaps && includeFades ? "gaps and fades" : "gaps only"; showNotification("Match Edits", "Applying " + operationName + "...", 0); var data = sf.file.readJson({ path: CONFIG.DATA_FILE, onError: "Continue" }).json; if (!data) { showNotification("Error", "Could not load data. Please learn first."); return; } var totalOps = (includeGaps ? data.gaps.length : 0) + (includeFades ? data.fades.length : 0); if (totalOps === 0) { showNotification("Error", "No " + operationName + " to apply"); return; } showNotification("Match Edits", "Trimming clips...", 0.05); setSelection(data.start, data.end); trimClips(); sf.wait({ intervalMs: CONFIG.WAIT.MEDIUM }); var currentOp = 0; // Apply gaps if (includeGaps && data.gaps.length > 0) { for (var i = 0; i < data.gaps.length; i++) { currentOp++; showNotification("Match Edits", "Deleting gap " + (i + 1) + "/" + data.gaps.length, 0.05 + (0.9 * (currentOp / totalOps))); setSelection(data.start + data.gaps[i][0], data.start + data.gaps[i][1]); deleteSelection(); } } // Apply fades if (includeFades && data.fades.length > 0) { for (var i = 0; i < data.fades.length; i++) { currentOp++; var fade = data.fades[i]; showNotification("Match Edits", "Applying fade " + (i + 1) + "/" + data.fades.length + ": " + fade.kind, 0.05 + (0.9 * (currentOp / totalOps))); applyFadeProperties({ absoluteStart: data.start + fade.start, absoluteEnd: data.start + fade.end, kind: fade.kind, slope: fade.slope, shape: fade.shape, useCustomShape: fade.useCustomShape, customShapeIndices: fade.customShapeIndices }); } } var summary = []; if (includeGaps) summary.push(data.gaps.length + " gaps"); if (includeFades) summary.push(data.fades.length + " fades"); showNotification("Match Edits", "Applied " + summary.join(", "), 1); } // ===== MAIN ===== var action = sf.interaction.displayDialog({ title: "Match Edits", prompt: "Choose action:", buttons: ["Learn", "Apply Gaps", "Apply Gaps + Fades"], defaultButton: "Learn" }).button; if (action === "Learn") { learn(); } else if (action === "Apply Gaps") { apply(true, false); sf.file.delete({ path: CONFIG.DATA_FILE }); } else { apply(true, true); sf.file.delete({ path: CONFIG.DATA_FILE }); }
James Wasserman @James_WassermanAmazing dude! I will test this out today. You are right--I did wind up updating this to use SFX framework and just never updated the forum on that. I am going to compare your new iteration with my updated one. Glad we are both on the same page. Yes, I think I just outright ignored S fades and don't really care about those at this point for a script like this personally.
- In reply toSreejesh_Nair⬆:
James Wasserman @James_WassermanWOW!!!! Ok this has been super helpful... obviously you're utilizing the SDK / SFX stuff better than me so this has been super insightful.... for anyone reading this or interested you can compare the two variants of the script and see just HOW QUICK @Sreejesh_Nair 's script runs with amazing improvements during the learn phase and less user input with the buttons. SICK!!! I encourage anyone coming across this thread to check out testing here!: https://youtu.be/ZeCmPJyOLro
Sreejesh Nair @Sreejesh_NairThats such a nice video!! Amazing! There was one change I did in addition. The way i manage the gaps and fades is based on the getclipinfo and sorting that part out in the analyzeClips function. This extracts the fades and gaps and is actually a variant of another code I wrote to sort out clips in the least number of tracks with the least number of moves. This is more like a greedy interval kind of sorting where the clips are merged (Fade in, crossfade, fade out based) and gaps are calculated in one go. The other reason why the fades come up faster is the SFX version.
The one other change I did, was to also get the fade slope if it was not the S-Curve and was actually a slope chosen from one of the dropdown. This was a challenge because the dropdowns all have the same path name! So I calculated which index was ticked, stored that and used that same index when recreating the fade in the other tracks. :)
James Wasserman @James_WassermanI'm blown away dude. Huge thanks and props on the optimization!!!
- In reply toSreejesh_Nair⬆:Ddanielkassulke @danielkassulke
This is incredible, @Sreejesh_Nair . Only thought is that if the original selection is recalled at the end of the script, then you can avoid using the mouse to select the tracks requiring the edit. Amazing stuff.
Sreejesh Nair @Sreejesh_NairInteresting. But how would one know which track to learn the edits from? In this case you can select any track to learn and any selection order to apply to. Unless I misunderstood the idea. ☺️
- Ddanielkassulke @danielkassulke
My poor description, sorry! Once you've selected the the reference track and learned the selection, the original selection is lost, and the timeline selection will be either a gap or a fade, rather than the initial selection. If the initial selection is recalled after learning, then you can proceed with keyboard shortcuts.
The current selection after using "learn":
What I think would be more useful:
Does that make sense? I won't pretend to know the minutiae of everyone's workflow, but I would assume that commonly the tracks requiring a matched edit will be directly above/below the reference. Either way, the unedited tracks still need to be selected manually, but giving the option of using a keyboard shortcuts to do it seems easier to me.
James Wasserman @James_WassermanHey @danielkassulke ! I know exactly what you're talking about and it's a great addition. I often like to script things that "respect the user's OG selection" as it were. I didn't want to add too much bloat to @Sreejesh_Nair 's amazing work, but here is your proposal incorporated (at least I believe what you're talking about) and thus no need for extra mouse work here -- key commands should work fine... demo here: https://www.youtube.com/watch?v=vUD9biNZSf0
// Match Edits - Learn & Apply sf.ui.proTools.appActivateMainWindow(); // ===== CONSTANTS ===== const CONFIG = { DATA_FILE: "/tmp/match_edits.json", NOTIFICATION_ID: sf.system.newGuid().guid, WAIT: { SHORT: 50, MEDIUM: 100, LONG: 200 }, FADE_TIMEOUT: 2000 }; const IS_SFX = sf.ui.proTools.isSfxApplication; function showNotification(title, message, progress) { var notification = { uid: CONFIG.NOTIFICATION_ID, title: title, message: message }; if (progress !== undefined) notification.progress = progress; sf.interaction.notify(notification); } // ===== FADE WINDOW MANAGEMENT ===== function waitForFadeWindow(timeoutMs) { var endTime = Date.now() + timeoutMs; while (Date.now() < endTime) { try { var window = sf.ui.proTools.sfx.windows.invalidate().whoseTitle.contains("Fade").first; if (window && window.exists) return window; } catch (e) { } sf.wait({ intervalMs: 20 }); } return null; } function closeFadeWindow() { try { var window = sf.ui.proTools.sfx.windows.whoseTitle.contains("Fade").first; if (window && window.exists) { window.buttons.whoseTitle.is("Cancel").first.elementClick(); sf.waitFor({ callback: !window.exists, timeout: 5000 }); } } catch (e) { } } function getSelectedRadioButton(group, options) { if (!group || !group.exists) return null; for (var i = 0; i < options.length; i++) { try { var radioButton = group.radioButtons.whoseTitle.is(options[i]).first; if (radioButton && radioButton.exists && radioButton.value.invalidate().intValue === 1) { return options[i]; } } catch (e) { } } return null; } function getSelectedFadeShapeIndices(popupButton) { var selectedIndices = []; try { var menuItems = popupButton.popupMenuFetchAllItems().menuItems; for (var i = 0; i < menuItems.length; i++) { try { if (menuItems[i].element.isMenuChecked === true) selectedIndices.push(i); } catch (e) { } } } catch (e) { } return selectedIndices; } function readCustomShapeSettings(window, shapeGroupName) { try { var shapeGroup = window.groups.whoseTitle.is(shapeGroupName).first; if (!shapeGroup || !shapeGroup.exists) return null; var customShapeRadio = shapeGroup.radioButtons.whoseTitle.is(shapeGroupName).first; if (!customShapeRadio || !customShapeRadio.exists) return null; if (!customShapeRadio.invalidate().isCheckBoxChecked) return null; var popupButton = shapeGroup.popupButtons.first; if (!popupButton || !popupButton.exists) return null; return { useCustomShape: true, customShapeIndices: getSelectedFadeShapeIndices(popupButton) }; } catch (e) { return null; } } // ===== FADE PROPERTIES ===== function readFadeProperties() { var window = waitForFadeWindow(CONFIG.FADE_TIMEOUT); if (!window) return null; var windowTitle = ""; try { windowTitle = window.title.invalidate().value || ""; } catch (e) { } var fadeKind = windowTitle.indexOf("Fade In") !== -1 ? "in" : windowTitle.indexOf("Fade Out") !== -1 ? "out" : "unknown"; var slopeGroup = window.groups.whoseTitle.contains("Slope").first; var shapeGroup = window.groups.whoseTitle.contains("Shape").first; var properties = { kind: fadeKind, slope: getSelectedRadioButton(slopeGroup, ["Equal Power", "Equal Gain"]) || "Equal Power", shape: getSelectedRadioButton(shapeGroup, ["Standard", "S-Curve"]) || "Standard", useCustomShape: false, customShapeIndices: [] }; var shapeGroupName = fadeKind === "in" ? "In Shape" : "Out Shape"; var customSettings = readCustomShapeSettings(window, shapeGroupName); if (customSettings) { properties.useCustomShape = customSettings.useCustomShape; properties.customShapeIndices = customSettings.customShapeIndices; } return properties; } function clickRadioButton(group, buttonName) { try { if (!group || !group.exists) return; var radio = group.radioButtons.whoseTitle.is(buttonName).first; if (radio && radio.exists) { radio.elementClick(); sf.wait({ intervalMs: CONFIG.WAIT.SHORT }); } } catch (e) { } } function applyCustomFadeShape(window, shapeGroupName, shapeIndices) { try { var shapeGroup = window.groups.whoseTitle.is(shapeGroupName).first; if (!shapeGroup || !shapeGroup.exists) return; clickRadioButton(shapeGroup, shapeGroupName); var popupButton = shapeGroup.popupButtons.first; if (!popupButton || !popupButton.exists) return; popupButton.elementClick({ asyncSwallow: true }); sf.wait({ intervalMs: CONFIG.WAIT.MEDIUM }); for (var i = 0; i < shapeIndices.length; i++) { try { var menuItems = sf.ui.proTools.invalidate().confirmationDialog.menuItems.whoseTitle.is(" ").allItems; if (menuItems && menuItems[shapeIndices[i]]) { menuItems[shapeIndices[i]].elementClick(); sf.wait({ intervalMs: CONFIG.WAIT.SHORT }); } } catch (e) { } } } catch (e) { } } function applyFadeProperties(fadeData) { sf.app.proTools.setTimelineSelection({ inTime: String(fadeData.absoluteStart), outTime: String(fadeData.absoluteEnd) }); sf.ui.proTools.menuClick({ menuPath: ['Edit', 'Fades', 'Create...'] }); sf.wait({ intervalMs: CONFIG.WAIT.LONG }); var window = waitForFadeWindow(1000); if (!window) return; try { var slopeGroup = window.groups.whoseTitle.contains("Slope").first; clickRadioButton(slopeGroup, fadeData.slope); if (fadeData.useCustomShape && fadeData.customShapeIndices.length > 0) { var shapeGroupName = fadeData.kind === "in" ? "In Shape" : "Out Shape"; applyCustomFadeShape(window, shapeGroupName, fadeData.customShapeIndices); } else { var shapeGroup = window.groups.whoseTitle.contains("Shape").first; clickRadioButton(shapeGroup, fadeData.shape); } window.buttons.whoseTitle.is("OK").first.elementClick({ asyncSwallow: true }); sf.wait({ intervalMs: CONFIG.WAIT.MEDIUM }); } catch (e) { try { window.buttons.whoseTitle.is("Cancel").first.elementClick(); sf.waitFor({ callback: !window.exists, timeout: 5000 }); } catch (e2) { } } } // ===== CLIP PROCESSING ===== function analyzeClips(clips, selStart, selEnd) { if (!clips || !clips.length) return { gaps: [], fades: [] }; var sorted = clips.slice().sort(function (a, b) { return Number(a.startTime) - Number(b.startTime); }); var merged = []; var fades = []; // Process clips in one pass - build merged intervals and extract fades for (var i = 0; i < sorted.length; i++) { var clipStart = Number(sorted[i].startTime); var clipEnd = Number(sorted[i].endTime); var clipName = (sorted[i].clipName || "").toLowerCase(); // Extract fade clips (but not crossfades) if (clipName.indexOf("fade") !== -1 && clipName.indexOf("cross") === -1) { if (clipStart < selEnd && clipEnd > selStart) { fades.push({ relativeStart: clipStart - selStart, relativeEnd: clipEnd - selStart, kind: clipName.indexOf("fade in") !== -1 ? "in" : "out" }); } } // Build merged intervals if (merged.length === 0) { merged.push([clipStart, clipEnd]); } else { var last = merged[merged.length - 1]; var prevName = i > 0 ? (sorted[i - 1].clipName || "").toLowerCase() : ""; var hasFade = prevName.indexOf("fade") !== -1 || clipName.indexOf("fade") !== -1; if (clipStart < last[1] || (clipStart === last[1] && hasFade)) { last[1] = Math.max(last[1], clipEnd); } else { merged.push([clipStart, clipEnd]); } } } // Calculate gaps from merged intervals var gaps = []; if (merged[0][0] > selStart) { gaps.push([0, merged[0][0] - selStart]); } for (var i = 0; i < merged.length - 1; i++) { if (merged[i + 1][0] > merged[i][1]) { gaps.push([merged[i][1] - selStart, merged[i + 1][0] - selStart]); } } if (merged[merged.length - 1][1] < selEnd) { gaps.push([merged[merged.length - 1][1] - selStart, selEnd - selStart]); } return { gaps: gaps, fades: fades }; } // ===== PRO TOOLS OPERATIONS ===== function setSelection(start, end) { sf.app.proTools.setTimelineSelection({ inTime: String(start), outTime: String(end) }); } function trimClips() { sf.ui.proTools.menuClick({ menuPath: ["Edit", "Trim Clip", "To Selection"] }); } function deleteSelection() { IS_SFX ? sf.ui.proTools.sfx.dawCommands.getByUniquePersistedName("DeleteSelection").run() : sf.keyboard.press({ keys: "backspace" }); } function openFadeDialog() { IS_SFX ? sf.ui.proTools.sfx.dawCommands.getByUniquePersistedName("CreateFades").run() : sf.ui.proTools.menuClick({ menuPath: ["Edit", "Fades", "Create..."] }); } function getSelectionAndClips() { var sel = sf.app.proTools.getTimelineSelection({ timeScale: "Samples" }); var info = sf.app.proTools.getSelectedClipInfo(); if (!sel || !info || !info.clips) { showNotification("Error", "No selection or clips"); return null; } return { start: Number(sel.inTime), end: Number(sel.outTime), clips: info.clips }; } // ===== LEARN FUNCTION ===== function learn() { showNotification("Match Edits", "Learning edits...", 0); var data = getSelectionAndClips(); if (!data) return; // Save original selection to restore after learning var originalSelection = { start: data.start, end: data.end }; showNotification("Match Edits", "Calculating gaps...", 0.1); var analyzed = analyzeClips(data.clips, data.start, data.end); var gaps = analyzed.gaps; showNotification("Match Edits", "Finding fades...", 0.2); var fadeClips = analyzed.fades; var learnedFades = []; if (fadeClips.length > 0) { closeFadeWindow(); for (var i = 0; i < fadeClips.length; i++) { var fade = fadeClips[i]; showNotification("Match Edits", "Learning fade " + (i + 1) + "/" + fadeClips.length + ": " + fade.kind, 0.2 + (0.7 * (i / fadeClips.length))); setSelection(data.start + fade.relativeStart, data.start + fade.relativeEnd); openFadeDialog(); var props = readFadeProperties(); closeFadeWindow(); if (props) { learnedFades.push({ start: fade.relativeStart, end: fade.relativeEnd, kind: props.kind, slope: props.slope, shape: props.shape, useCustomShape: props.useCustomShape, customShapeIndices: props.customShapeIndices }); } } } showNotification("Match Edits", "Saving data...", 0.9); sf.file.writeJson({ path: CONFIG.DATA_FILE, json: { start: data.start, end: data.end, gaps: gaps, fades: learnedFades } }); // Restore original selection for keyboard-driven workflow setSelection(originalSelection.start, originalSelection.end); showNotification("Match Edits", "Saved " + gaps.length + " gaps, " + learnedFades.length + " fades", 1); } // ===== APPLY FUNCTION ===== function apply(includeGaps, includeFades) { var operationName = includeGaps && includeFades ? "gaps and fades" : "gaps only"; showNotification("Match Edits", "Applying " + operationName + "...", 0); var data = sf.file.readJson({ path: CONFIG.DATA_FILE, onError: "Continue" }).json; if (!data) { showNotification("Error", "Could not load data. Please learn first."); return; } var totalOps = (includeGaps ? data.gaps.length : 0) + (includeFades ? data.fades.length : 0); if (totalOps === 0) { showNotification("Error", "No " + operationName + " to apply"); return; } showNotification("Match Edits", "Trimming clips...", 0.05); setSelection(data.start, data.end); trimClips(); sf.wait({ intervalMs: CONFIG.WAIT.MEDIUM }); var currentOp = 0; // Apply gaps if (includeGaps && data.gaps.length > 0) { for (var i = 0; i < data.gaps.length; i++) { currentOp++; showNotification("Match Edits", "Deleting gap " + (i + 1) + "/" + data.gaps.length, 0.05 + (0.9 * (currentOp / totalOps))); setSelection(data.start + data.gaps[i][0], data.start + data.gaps[i][1]); deleteSelection(); } } // Apply fades if (includeFades && data.fades.length > 0) { for (var i = 0; i < data.fades.length; i++) { currentOp++; var fade = data.fades[i]; showNotification("Match Edits", "Applying fade " + (i + 1) + "/" + data.fades.length + ": " + fade.kind, 0.05 + (0.9 * (currentOp / totalOps))); applyFadeProperties({ absoluteStart: data.start + fade.start, absoluteEnd: data.start + fade.end, kind: fade.kind, slope: fade.slope, shape: fade.shape, useCustomShape: fade.useCustomShape, customShapeIndices: fade.customShapeIndices }); } } var summary = []; if (includeGaps) summary.push(data.gaps.length + " gaps"); if (includeFades) summary.push(data.fades.length + " fades"); showNotification("Match Edits", "Applied " + summary.join(", "), 1); } // ===== MAIN ===== var action = sf.interaction.displayDialog({ title: "Match Edits", prompt: "Choose action:", buttons: ["Learn", "Apply Gaps", "Apply Gaps + Fades"], defaultButton: "Learn" }).button; if (action === "Learn") { learn(); } else if (action === "Apply Gaps") { apply(true, false); sf.file.delete({ path: CONFIG.DATA_FILE }); } else { apply(true, true); sf.file.delete({ path: CONFIG.DATA_FILE }); }- In reply todanielkassulke⬆:
Sreejesh Nair @Sreejesh_NairAh I get it. Yeah. @James_Wasserman ‘s new script works. The reason I didn’t do that was because the selection post the learn is immaterial because it will do the exact timeline selections across the tracks you select so I left it at that. But I agree it’s neat to have that.
Regarding track positions, sometimes the ambiance would be scattered onto other tracks say an A and B set. So it may not always be near but it most usually is as you said. 😊
Good one!
- MMartin Pavey @Martin_Pavey
Wow!
Next level stuff guys.
Thanks so much for putting this together.