Music List Combiner Spreadsheet

Script (May 2026)

Click Here to See the May 2026 Tutorial Google Sheet

// ===============================================================================
// ✦✦ SECTION 1 — EDITABLE DASHBOARD ✦✦
// ===============================================================================
// 1. SHEET NAMES: Must match the tab names in your Google Sheet.
// You can change these variables if you want to change your tab names;
// it just needs to be within the "" quotation marks.
const SOURCE_SHEET_NAME = "EDL";
const TARGET_SHEET_NAME = "Cues";
// 2. PROJECT SETTINGS:
// You can change the default frame rate below to any frame rate (e.g., 23.976, 24, 25, 29.97, 30).
const DEFAULT_FPS = 24;
const USE_POPUP = true; // Set to 'false' to always use the default FPS without asking.
const IGNORE_LABELS = ["SIGNATURE SOURCE MOB"];
// 3. THE BRIDGE RULE:
// A "Bridge" merges two clips of the same music if the gap between them is small.
// This is mainly to handle fades and crossfades, where small gaps often appear
// between segments of the same track.
// Standard practice is 5.0 seconds. If the gap is 5.1s or above, they stay as separate cues.
const MERGE_GAP_SECONDS = 5.0;
// 4. END PLAYHEAD POSITION:
// This determines if the End Time is the "Cut point" or the "Last Frame."
// Only set ONE of these to "YES". Leave the other blank ("").
// Option A — SOLID LINE (THE LEFT LINE)
// The solid line sits directly on the cut point.
// This is the standard "Dst Out" timecode provided by your Avid EDL.
const END_AT_CUT = "";
// Option B — DOTTED LINE (THE RIGHT LINE)
// The dotted line sits directly on the cut point which is the very last viewable frame.
const END_MINUS_ONE = "YES";
// ===============================================================================
// ✦✦ SECTION 2 — CORE ENGINE (DO NOT EDIT BELOW THIS POINT) ✦✦
// ===============================================================================
function mergeTracks() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const edlSheet = ss.getSheetByName(SOURCE_SHEET_NAME);
if (!edlSheet) {
SpreadsheetApp.getUi().alert("Could not find a sheet named '" + SOURCE_SHEET_NAME + "'.");
return;
}
let cueSheet = ss.getSheetByName(TARGET_SHEET_NAME) || ss.insertSheet(TARGET_SHEET_NAME);
const data = edlSheet.getDataRange().getValues();
if (data.length < 2) return;
let fps = DEFAULT_FPS;
if (USE_POPUP) {
const ui = SpreadsheetApp.getUi();
const response = ui.prompt('Project Frame Rate', "Enter your project's frame rate:", ui.ButtonSet.OK_CANCEL);
if (response.getSelectedButton() !== ui.Button.OK) return;
fps = parseFloat(response.getResponseText()) || DEFAULT_FPS;
}
// Identify column indices based on standard EDL headers
const headers = data[0].map(h => h.toString().toLowerCase().trim());
let fIn = headers.indexOf("dst in") !== -1 ? headers.indexOf("dst in") : 6;
let fOut = headers.indexOf("dst out") !== -1 ? headers.indexOf("dst out") : 7;
let fName = headers.indexOf("src dur") !== -1 ? headers.indexOf("src dur") : data[0].length - 1;
let rawSegments = [];
// 1. EXTRACTION with "Smart Label" logic (PASS 1 & 2 HYBRID)
for (let i = 1; i < data.length; i++) {
const rawLine = (data[i][fName] || "").toString().trim();
const type = (data[i][2] || "").toString().trim(); // C or D
const tIn = timeToFrames(data[i][fIn].toString(), fps);
const tOut = timeToFrames(data[i][fOut].toString(), fps);
if (tOut <= tIn) continue;
const isMix = /AUDIO MIXDOWN/i.test(rawLine);
// PASS 1: Extend segments for Dissolves/Fades
if (isMix && type === "D" && i > 0 && i < data.length - 1) {
const prevLine = (data[i - 1][fName] || "").toString().trim();
const nextLine = (data[i + 1][fName] || "").toString().trim();
if (/AUDIO MIXDOWN/i.test(prevLine) && /AUDIO MIXDOWN/i.test(nextLine)) {
extractSmartLabels(prevLine).forEach(l => {
rawSegments.push({ label: l, norm: normalizeLabel(l), in: timeToFrames(data[i-1][fIn].toString(), fps), out: tOut });
});
extractSmartLabels(nextLine).forEach(l => {
rawSegments.push({ label: l, norm: normalizeLabel(l), in: tIn, out: timeToFrames(data[i+1][fOut].toString(), fps) });
});
continue;
}
}
// PASS 2 logic: Handle multiple labels per line + Dissolve Start points
const labels = extractSmartLabels(rawLine);
labels.forEach(label => {
// If the label is just a shortened version of the track name,
// we use the normalized version as the key.
// This allows "PLAYBACK..." and "PLAYBACK...260114.WAV" to merge.
const norm = normalizeLabel(label);
rawSegments.push({
label: label,
norm: norm,
in: tIn,
out: tOut
});
});
} // <--- THIS IS THE MISSING BRACKET THAT FIXES LINE 339
// 2. GROUP & MERGE using the Bridge Rule
const grouped = rawSegments.reduce((acc, s) => {
if (!acc[s.norm]) {
acc[s.norm] = { label: s.label, clips: [] };
} else {
const currentLabel = acc[s.norm].label;
const isNewBetter = /\.(wav|mp3|aif)$/i.test(s.label);
const isCurrentBetter = /\.(wav|mp3|aif)$/i.test(currentLabel);
// If the new label has an extension and the current one doesn't, upgrade!
if (isNewBetter && !isCurrentBetter) {
acc[s.norm].label = s.label;
}
// Otherwise, just keep the longer one if they both have (or both lack) extensions
else if (s.label.length > currentLabel.length && (isNewBetter === isCurrentBetter)) {
acc[s.norm].label = s.label;
}
}
acc[s.norm].clips.push(s);
return acc;
}, {});
let mergedBlocks = [];
const bridgeFrames = Math.round(fps) * MERGE_GAP_SECONDS;
for (let key in grouped) {
let clips = grouped[key].clips.sort((a, b) => a.in - b.in);
let labelToUse = grouped[key].label;
for (let i = 0; i < clips.length; i++) {
let current = { ...clips[i] };
while (i + 1 < clips.length) {
let next = clips[i + 1];
if (next.in <= current.out + bridgeFrames) {
current.out = Math.max(current.out, next.out);
current.in = Math.min(current.in, next.in);
i++;
} else {
break;
}
}
mergedBlocks.push({ ...current, label: labelToUse });
}
}
// 3. REMOVE EXACT DUPLICATES (Handles truncated names vs full filenames)
mergedBlocks.sort((a, b) => a.in - b.in || (b.label.length - a.label.length));
let finalCues = [];
mergedBlocks.forEach(curr => {
let isDup = finalCues.some(prev =>
prev.in === curr.in &&
prev.out === curr.out &&
(prev.norm.includes(curr.norm) || curr.norm.includes(prev.norm))
);
if (!isDup) finalCues.push(curr);
});
// 3B. FINAL CLEANUP PASS — remove ignored labels that survived merge
finalCues = finalCues.filter(cue => {
const label = (cue.label || "").toString().trim();
return !IGNORE_LABELS.some(x => label.includes(x));
});
function getEndFrame(outFrame) {
if (END_MINUS_ONE === "YES") return outFrame - 1;
return outFrame;
}
// 4. OUTPUT GENERATION
const output = finalCues.map(r => [
framesToTime(r.in, fps),
framesToTime(getEndFrame(r.out), fps),
framesToTime(r.out - r.in, fps),
r.label
]);
if (output.length > 0) {
cueSheet.clear();
// Add Summary Information at the top
const dateStr = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyy-MM-dd");
// Updated to HH:mm to remove seconds and milliseconds
const timeStr = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "HH:mm");
cueSheet.getRange(1, 1).setValue("DATE GENERATED: " + dateStr).setFontWeight("bold");
cueSheet.getRange(2, 1).setValue("TIME GENERATED: " + timeStr).setFontWeight("bold");
cueSheet.getRange(3, 1).setValue("TOTAL CUES: " + output.length).setFontWeight("bold");
const headerRow = 5;
cueSheet.getRange(headerRow, 1, 1, 4).setValues([["Start", "End", "Duration", "Track Name"]]);
cueSheet.getRange(headerRow, 1, 1, 4).setFontWeight("bold").setBackground("#f3f3f3").setBorder(true, true, true, true, true, true);
cueSheet.getRange(headerRow + 1, 1, output.length, 4).setValues(output).setNumberFormat("@");
cueSheet.setFrozenRows(headerRow);
cueSheet.autoResizeColumns(1, 4);
cueSheet.activate();
}
}
// ===============================================================================
// ✦✦ SECTION 3 — UTILITIES (DO NOT EDIT) ✦✦
// ===============================================================================
/**
* Handles lines containing both a Timeline Label and a Source File.
* Determines if they are the same track (merges) or different (keeps both).
*/
function extractSmartLabels(text) {
if (!text) return [];
// Clean technical suffixes immediately
let cleanText = text.replace(/\.(sub|new)\.\d+/gi, "");
let parts = cleanText.split(/SOURCE FILE:/i);
let label = parts[0].replace(/["']/g, "").trim();
let source = (parts.length > 1) ? parts[1].replace(/["']/g, "").trim() : "";
if (!label || label === "(NULL)" || label === "BL") label = "";
if (!source || source === "(NULL)") source = "";
if (label && source) {
let nL = normalizeLabel(label);
let nS = normalizeLabel(source);
// If one is a subset of the other, they are the same track.
if (nS.includes(nL) || nL.includes(nS)) return [source];
return [label, source];
}
return label ? [label] : (source ? [source] : []);
}
/**
* Creates a unique "Fingerprint" for tracks.
* RESTORES 8-STEP LOGIC with fixed Version Detection to prevent Menage a Trois merge.
*/
function normalizeLabel(name) {
if (!name) return "";
let n = name.toLowerCase();
// 1. EXTRACT VERSIONING & STEMS (FIXED FOR MASHED TEXT)
// This looks for op1, v1, etc. even if they aren't separate words.
let versionMatch = n.match(/(op\d+|v\d+|take\d+|vocals|instrumental|inst|mix|flower song)/gi);
let trackMatch = n.match(/[, ](\d+)\b/);
let uniqueID = "";
if (versionMatch) uniqueID += "_" + versionMatch.join("_");
if (trackMatch) uniqueID += "_tr" + trackMatch[1];
// 2. REMOVE FILE EXTENSIONS
n = n.replace(/\.(wav|mp3|aif|m4a|mp4)$/i, "");
// 3. REMOVE "AUDIO MIXDOWN"
n = n.replace(/audio mixdown/gi, "");
// 4. REMOVE TECHNICAL SUFFIXES (.NEW and .SUB)
n = n.replace(/\.(new|sub)\.\d+$/i, "");
// 5. REMOVE TRAILING NUMBERS (The "Sextet 1" Fix)
n = n.replace(/\s\d+$/g, "");
// 6. REMOVE LEADING NUMBERS/TRACK PATTERNS
let cleaned = n.replace(/^[\d\s._-]+/, "");
if (cleaned.length > 0) {
n = cleaned;
}
// 7. REMOVE TECHNICAL JUNK (Dates, TC codes, Brackets)
n = n.replace(/(_\d{4,}|tc-\d+|\[.*?\]|\(.*?\))/gi, "");
// 8. FINAL CLEAN & TRUNCATE
// Increased to 30 to ensure the core name is distinct enough.
n = n.replace(/[^a-z0-9]/g, "");
return n.substring(0, 30) + uniqueID;
}
// end of utilities
function stage2Override(stage1Label, stage2Label) {
if (!stage1Label) return stage2Label;
if (!stage2Label) return stage1Label;
return stage2Label.length > stage1Label.length ? stage2Label : stage1Label;
}
// đź”§ ADD THIS BLOCK RIGHT ABOVE timeToFrames
function similarityScore(a, b) {
let longer = a.length > b.length ? a : b;
let shorter = a.length > b.length ? b : a;
if (longer.length === 0) return 1;
let same = 0;
for (let i = 0; i < shorter.length; i++) {
if (longer[i] === shorter[i]) same++;
}
return same / longer.length;
}
function timeToFrames(time, fps) {
if (!time || !time.includes(':')) return 0;
const p = time.split(':').map(v => parseInt(v.trim()));
const f = Math.round(fps);
return (p[0] * 3600 * f) + (p[1] * 60 * f) + (p[2] * f) + p[3];
}
function framesToTime(f, fps) {
if (f < 0) f = 0;
const r = Math.round(fps);
const h = Math.floor(f / (3600 * r));
const m = Math.floor((f % (3600 * r)) / (60 * r));
const s = Math.floor((f % (60 * r)) / r);
const fr = Math.floor(f % r);
return [h, m, s, fr].map(v => String(v).padStart(2, '0')).join(':');
}
function stage2Override(stage1Label, stage2Label) {
if (!stage1Label) return stage2Label;
if (!stage2Label) return stage1Label;
// Only upgrade if Stage 2 is meaningfully longer
return stage2Label.length > stage1Label.length ? stage2Label : stage1Label;
}
function onOpen() {
SpreadsheetApp.getUi().createMenu('✦ Cue Tools').addItem('Generate Cue Sheet', 'mergeTracks').addToUi();
}
Script (February 2026)

Update Note: Why I Upgraded the Script

While using the original script on new projects, I realised it didn’t always handle transitions properly when certain fades were involved. Although it worked on the basic fades I showed in my tutorial, I found that if an editor used a crossfade or layered fades across different tracks, the script would often fail to merge them.

This happens because the EDL logs these transitions as separate, disconnected events. Whether it is a crossfade on a single track or fades layered across different tracks, the data tells the script that one clip has finished and another has started. If those two points don’t line up perfectly, it creates a small technical gap in the data which causes the script to split the song into two separate rows on your cue sheet.

To fix this, I have upgraded the script with a Bridge Rule. This allows the script to look across those tiny gaps and bridge them into one continuous track, provided they are the same song.

What is new in the February 2026 version:

  • Clear Editing Instructions: Within the script, there are clear instructions on exactly what can be edited to suit your project. As seen from the screenshot below:
  • Better Handling of Fades: The script uses the Bridge Rule defaulted to 5 seconds to merge segments that the old script would have left broken because of the way transitions appear as separate events in the EDL.
  • Frame Perfect Alignment: This fix addresses a quirk in how EDLs record fades. When you add a fade, the EDL creates two separate entries: the main song and the transition. To connect them without any “pops” or silence, the EDL overlaps these two entries by exactly one frame. The script detects this 1-frame overlap (the point of transition) and snaps the timing together. This ensures the song and its fade are merged into one single, accurate row on your cue sheet.
  • No More Manual Column Copying: You no longer need to manually copy specific columns because you can just paste your full EDL into the EDL tab and the script does the rest. It is important if you use this spreadsheet to organise it the same way as you see on this example (click here to see). Make a tab saying EDL and another that says Cues. Paste the full EDL into the EDL tab and press the “button” and the results appear in the Cues tab.
  • Dynamic Frame Rates: Unlike the old script which was locked at 25fps, the update lets you choose any frame rate such as 23.976, 24, 25, 29.97 etc. via a popup window.

Note for anyone watching the tutorial video: The video is still the best way to see how to bring the script into Apps Script within Google Sheets and get it running. However, this updated version is much more efficient. It removes the need for manual column placement and includes smarter merging logic to handle transitions and fades automatically.

// ===============================================================================
// ✦✦ SECTION 1 — EDITABLE DASHBOARD ✦✦
// ===============================================================================
// 1. SHEET NAMES: Must match the tab names in your Google Sheet.
// You can change these variables if you want to change your tab names;
// it just needs to be within the "" quotation marks.
const SOURCE_SHEET_NAME = "EDL";
const TARGET_SHEET_NAME = "Cues";
// 2. PROJECT SETTINGS:
// You can change the default frame rate below to any frame rate (e.g., 23.976, 24, 25, 29.97, 30).
const DEFAULT_FPS = 24;
const USE_POPUP = true; // Set to 'false' to always use the default FPS without asking.
// 3. THE BRIDGE RULE:
// A "Bridge" merges two clips of the same music if the gap between them is small.
// This is mainly to handle fades and crossfades, where small gaps often appear
// between segments of the same track.
// Standard practice is 5.0 seconds. If the gap is 5.1s or above, they stay as separate cues.
const MERGE_GAP_SECONDS = 5.0;
// ===============================================================================
// ✦✦ SECTION 2 — CORE ENGINE (DO NOT EDIT BELOW THIS POINT) ✦✦
// ===============================================================================
function mergeTracks() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const edlSheet = ss.getSheetByName(SOURCE_SHEET_NAME);
if (!edlSheet) {
SpreadsheetApp.getUi().alert("Could not find a sheet named '" + SOURCE_SHEET_NAME + "'.");
return;
}
let cueSheet = ss.getSheetByName(TARGET_SHEET_NAME) || ss.insertSheet(TARGET_SHEET_NAME);
const data = edlSheet.getDataRange().getValues();
if (data.length < 2) return;
let fps = DEFAULT_FPS;
if (USE_POPUP) {
const ui = SpreadsheetApp.getUi();
const response = ui.prompt('Project Frame Rate', "Enter your project's frame rate:", ui.ButtonSet.OK_CANCEL);
if (response.getSelectedButton() !== ui.Button.OK) return;
fps = parseFloat(response.getResponseText()) || DEFAULT_FPS;
}
// Identify column indices based on standard EDL headers
const headers = data[0].map(h => h.toString().toLowerCase().trim());
let fIn = headers.indexOf("dst in") !== -1 ? headers.indexOf("dst in") : 6;
let fOut = headers.indexOf("dst out") !== -1 ? headers.indexOf("dst out") : 7;
let fName = headers.indexOf("src dur") !== -1 ? headers.indexOf("src dur") : data[0].length - 1;
let rawSegments = [];
// 1. EXTRACTION with "Smart Label" logic
for (let i = 1; i < data.length; i++) {
const rawLine = (data[i][fName] || "").toString().trim();
const tIn = timeToFrames(data[i][fIn].toString(), fps);
const tOut = timeToFrames(data[i][fOut].toString(), fps);
if (tOut <= tIn) continue;
const labels = extractSmartLabels(rawLine);
labels.forEach(label => {
rawSegments.push({
label: label,
norm: normalizeLabel(label),
in: tIn,
out: tOut
});
});
}
// 2. GROUP & MERGE using the Bridge Rule
const grouped = rawSegments.reduce((acc, s) => {
acc[s.norm] = acc[s.norm] || { label: s.label, clips: [] };
acc[s.norm].clips.push(s);
return acc;
}, {});
let mergedBlocks = [];
const bridgeFrames = Math.round(fps) * MERGE_GAP_SECONDS;
for (let key in grouped) {
let clips = grouped[key].clips.sort((a, b) => a.in - b.in);
let labelToUse = grouped[key].label;
for (let i = 0; i < clips.length; i++) {
let current = { ...clips[i] };
while (i + 1 < clips.length) {
let next = clips[i + 1];
if (next.in <= current.out + bridgeFrames) {
current.out = Math.max(current.out, next.out);
current.in = Math.min(current.in, next.in);
i++;
} else {
break;
}
}
mergedBlocks.push({ ...current, label: labelToUse });
}
}
// 3. REMOVE EXACT DUPLICATES (Handles truncated names vs full filenames)
mergedBlocks.sort((a, b) => a.in - b.in || (b.label.length - a.label.length));
let finalCues = [];
mergedBlocks.forEach(curr => {
let isDup = finalCues.some(prev =>
prev.in === curr.in &&
prev.out === curr.out &&
(prev.norm.includes(curr.norm) || curr.norm.includes(prev.norm))
);
if (!isDup) finalCues.push(curr);
});
// 4. OUTPUT GENERATION
const output = finalCues.map(r => [
framesToTime(r.in, fps),
framesToTime(r.out - 1, fps),
framesToTime(r.out - r.in, fps),
r.label
]);
if (output.length > 0) {
cueSheet.clear();
// Add Summary Information at the top
const dateStr = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyy-MM-dd");
// Updated to HH:mm to remove seconds and milliseconds
const timeStr = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "HH:mm");
cueSheet.getRange(1, 1).setValue("DATE GENERATED: " + dateStr).setFontWeight("bold");
cueSheet.getRange(2, 1).setValue("TIME GENERATED: " + timeStr).setFontWeight("bold");
cueSheet.getRange(3, 1).setValue("TOTAL CUES: " + output.length).setFontWeight("bold");
const headerRow = 5;
cueSheet.getRange(headerRow, 1, 1, 4).setValues([["Start", "End", "Duration", "Track Name"]]);
cueSheet.getRange(headerRow, 1, 1, 4).setFontWeight("bold").setBackground("#f3f3f3").setBorder(true, true, true, true, true, true);
cueSheet.getRange(headerRow + 1, 1, output.length, 4).setValues(output).setNumberFormat("@");
cueSheet.setFrozenRows(headerRow);
cueSheet.autoResizeColumns(1, 4);
cueSheet.activate();
}
}
// ===============================================================================
// ✦✦ SECTION 3 — UTILITIES (DO NOT EDIT) ✦✦
// ===============================================================================
/**
* Handles lines containing both a Timeline Label and a Source File.
* Determines if they are the same track (merges) or different (keeps both).
*/
function extractSmartLabels(text) {
if (!text) return [];
let parts = text.split(/SOURCE FILE:/i);
if (parts.length === 1) return [parts[0].replace(/["']/g, "").trim()];
let label = parts[0].replace(/["']/g, "").trim();
let source = parts[1].replace(/["']/g, "").trim();
if (label === "BL" || label === "" || label === "(NULL)") return [source];
if (source === "(NULL)") return [label];
let nL = normalizeLabel(label);
let nS = normalizeLabel(source);
// If one name is just a shorter version of the other, they are the same track.
if (nL.indexOf(nS) !== -1 || nS.indexOf(nL) !== -1) {
return [source.length >= label.length ? source : label];
}
return [label, source];
}
/**
* Strips technical suffixes and file extensions for cleaner comparison.
*/
function normalizeLabel(name) {
return name.toLowerCase()
.replace(/\.(wav|mp3|aif|m4a|mp4)$/i, "") // Remove extensions
.replace(/\.sub\.\d+$/i, "") // Remove .SUB.01 suffixes
.replace(/[_-]\d+$/i, "") // Remove _1 or -1 suffixes
.replace(/[^a-z0-9]/g, "") // Remove non-alphanumeric
.trim();
}
function timeToFrames(time, fps) {
if (!time || !time.includes(':')) return 0;
const p = time.split(':').map(v => parseInt(v.trim()));
const f = Math.round(fps);
return (p[0] * 3600 * f) + (p[1] * 60 * f) + (p[2] * f) + p[3];
}
function framesToTime(f, fps) {
if (f < 0) f = 0;
const r = Math.round(fps);
const h = Math.floor(f / (3600 * r));
const m = Math.floor((f % (3600 * r)) / (60 * r));
const s = Math.floor((f % (60 * r)) / r);
const fr = Math.floor(f % r);
return [h, m, s, fr].map(v => String(v).padStart(2, '0')).join(':');
}
function onOpen() {
SpreadsheetApp.getUi().createMenu('✦ Cue Tools').addItem('Generate Cue Sheet', 'mergeTracks').addToUi();
}
Tutorial Script (September 2025)

Click Here to See the May 2025 Tutorial Google Sheet

This spreadsheet makes it easy to get the in/out points and durations for each music track in your edit.

  1. Paste a music only EDL into the “Data Sheet” tab.
  2. Copy the Dst In, Dst Out, Src Dur, and Song Name columns into the active tab with the button.
  3. Press the button.

The spreadsheet will:

  • Merge tracks that have been cut and stitched with fades into one continuous track.
  • Merge the same track even if it appears on a different layer, as long as it’s the same song.

This tool was given to me by a first assistant doing some cover work on my first scripted job. The editor often Frankenstein’ed music together, so doing this manually took over an hour. With this Google Apps Script it’s much faster, and I think every assistant should have it in their toolkit.

function mergeTracks() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
const data = sheet.getDataRange().getValues();
const header = data.shift();
const fps = 25;
function timeToFrames(time) {
const parts = time.split(':').map(part => parseInt(part));
return (
parts[0] * 3600 * fps + // hours
parts[1] * 60 * fps + // minutes
parts[2] * fps + // seconds
parts[3] // frames
);
}
function framesToTime(totalFrames) {
const hours = Math.floor(totalFrames / (3600 * fps));
totalFrames %= 3600 * fps;
const minutes = Math.floor(totalFrames / (60 * fps));
totalFrames %= 60 * fps;
const seconds = Math.floor(totalFrames / fps);
const frames = totalFrames % fps;
return String(hours).padStart(2, '0') + ':' +
String(minutes).padStart(2, '0') + ':' +
String(seconds).padStart(2, '0') + ':' +
String(frames).padStart(2, '0');
}
// Normalise the song name so fades (NULL) get grouped with the track
function normalizeSongName(name) {
if (!name) return "";
let base = name.split("SOURCE FILE:")[0].trim();
// Remove "(NULL)" if present
base = base.replace("(NULL)", "").trim();
return base;
}
let adjustedData = [];
for (let i = 0; i < data.length; i++) {
const row = [...data[i]];
const songName = row[3];
// Only process rows that are not 'BL'
if (songName !== 'BL') {
let dstOut = row[1]; // Start with the row's own Dst Out
let j = i + 1;
// Look ahead for 'BL' entries and extend dstOut
while (j < data.length && data[j][3] === 'BL') {
dstOut = data[j][1]; // Use the Dst Out of the 'BL' entry
j++;
}
const originalDstInFrames = timeToFrames(row[0]);
const originalDstOutFrames = timeToFrames(dstOut);
const adjustedDstInFrames = originalDstInFrames;
const adjustedDstOutFrames = originalDstOutFrames - 1;
const newSrcDur = adjustedDstOutFrames - adjustedDstInFrames;
row[0] = framesToTime(adjustedDstInFrames);
row[1] = framesToTime(Math.max(adjustedDstInFrames, adjustedDstOutFrames));
row[2] = framesToTime(Math.max(0, newSrcDur));
row[3] = normalizeSongName(songName); // <-- normalise here
adjustedData.push(row);
}
}
const groupedTracks = {};
adjustedData.forEach(row => {
const dstIn = timeToFrames(row[0]);
const dstOut = timeToFrames(row[1]);
const srcDur = timeToFrames(row[2]);
const songName = row[3];
if (!groupedTracks[songName]) {
groupedTracks[songName] = [];
}
groupedTracks[songName].push({ dstIn, dstOut, srcDur, songName });
});
const mergedTracks = [];
for (let songName in groupedTracks) {
const tracks = groupedTracks[songName].sort((a, b) => a.dstIn - b.dstIn);
let currentTrack = tracks[0];
for (let i = 1; i < tracks.length; i++) {
const nextTrack = tracks[i];
// Allow for a 1-frame gap or overlap for merging
if (currentTrack.dstOut >= nextTrack.dstIn - 1) {
currentTrack.dstOut = Math.max(currentTrack.dstOut, nextTrack.dstOut);
currentTrack.srcDur = currentTrack.dstOut - currentTrack.dstIn;
} else {
mergedTracks.push({ ...currentTrack });
currentTrack = nextTrack;
}
}
mergedTracks.push({ ...currentTrack });
}
mergedTracks.sort((a, b) => a.dstIn - b.dstIn);
const output = mergedTracks.map(track => [
framesToTime(track.dstIn),
framesToTime(track.dstOut),
framesToTime(track.srcDur),
track.songName
]);
sheet.getRange(2, 1, sheet.getLastRow() - 1, sheet.getLastColumn()).clearContent();
sheet.getRange(1, 1, 1, header.length).setValues([header]);
sheet.getRange(2, 1, output.length, output[0].length).setValues(output);
}

Leave a Reply