Twitch-Resolve Marker Converter - Doc

thing's been in progress for like 5 years now

Created: Mar 4, 2025

By

~7 min read


Note I: If all you’re interested in is the source code, that can be found at the end of the page.

Note II: The code is written in typescript. I’ll try to explain the logic so that it’s understandable even without TS knowledge, but it might still be difficult.

Explainer

There are two functions in use here. Once to parse the CSV file that Twitch provides, and another to generate the EDL that Resolve specifically uses. In order to generate the EDL, the methodology was basically to just export some EDL from Resolve, and look over the file to see if I can replicate the structure. Maybe messing with it a bit as well to see if I understand correctly.

But first, the easy bit.

Parsing the CSV

Here is an example of the marker CSV that Twitch exports:

00:05:31,Broadcaster,enbyss_,Good moment
00:06:04,Broadcaster,enbyss_,Good moment
00:06:25,Broadcaster,enbyss_,Good moment
00:06:42,Broadcaster,enbyss_,Good moment
00:07:51,Broadcaster,enbyss_,Good moment
00:08:25,Broadcaster,enbyss_,Good moment

CSV is a simple format — a comma (,) separates different ”columns”, and each row should have the same number of columns. In this case, Twitch exports 4 columns:

  1. The timestamp of the marker.
  2. The role of whoever took the marker.
  3. The name of the account that took the marker.
  4. A description of the marker itself.

For parsing the CSV, I suggest you use an existing parser. In my case, a REGEX is being used - one that I admittedly do not fully understand:

const obj_pattern = new RegExp(
    (
        `(\${str_delimiter}|\r?\n|\r|^)` +
        `(?:"([^"]*(?:""[^"]*)*)"|` +
        `([^"\${str_delimiter}\r\n]*))`
    ),
    "gi"
);

This ends up looking like the following:

/(,|\r?\n|\r|^)(?:"([^"]*(?:""[^"]*)*)"|([^",\r\n]*))/gi

For an indepth explanation…

  1. (\,|\r?\n|\r|^)

    • This will capture either:
      • The first comma (,)
      • The first new line / carriage return (\n, \r\n, \r)
      • The start of the line.
  2. (?:"([^"]*(?:""[^"]*)*)"|([^"\,\r\n]*))

    • (?: ...) means a non-capturing group. Meaning it won’t be stored for future use.
    1. "([^"]*(?:""[^"]*)*)"

      • The purpose of this regex is to capture the value of the cell within “quotation marks”.
    2. OR (|), ([^"\,\r\n]*)

      • If there’s no quotation marks, then we capture any string of characters without newlines, commas, or quotation marks.

As you can see — it’s pretty convoluted, but it works - and it’ll be used to extract the values in each cell, for each row - before we then give the information some more structure:

return arr_data.map(marker => ({
    timecode: marker[0],
    class: marker[1],
    username: marker[2],
    comment: marker[3]
})).slice(0, -1);

This way, the code becomes a lot more readable from now on, and we actually understand what we’re using and in what way. At some point I might revisit the regex bit, because I’m pretty sure I took it from somewhere and it’s a bit overkill - but for now, it works!

Generating the EDL

Understanding(?) EDL

Now, to generate the EDL, first we need to know what it looks like. A long time ago I did some research into it, before giving up once realising there’s multiple different formats for it. So instead, here’s an example of an EDL file that Resolve understands:

EDL
TITLE: Timeline 1
FCM:NON-DROP-FRAME

000  001      V    C        01:05:31:00 01:05:31:01 01:05:31:00 01:05:31:01  
 |C:ResolveColorBlue |M:Good moment by enbyss_ [Broadcaster] |D:1

001  001      V    C        01:06:04:00 01:06:04:01 01:06:04:00 01:06:04:01  
 |C:ResolveColorBlue |M:Good moment by enbyss_ [Broadcaster] |D:1

Since I did this by observation rather than by rigorous research, there’s some things I don’t fully understand - but realistically speaking for the purposes of this project, we don’t need a fully comprehensive look into Resolve’s EDL.

The first 3 lines can be skipped, since they have nothing to do with our tool — the main info there is the TITLE, but we’re only using this to import markers, so that’s extra - yet still required.

The final couple of lines show off 2 markers. Each marker takes up 2 lines, which I’ll name here as the Timestamp Line and the Metadata Line. Each marker is also separated from the next one with an additional new line.

The Timestamp Line has 2 key points of information.

  • The starting bit (000, 001…) which specifies what order marker this is.
  • The 4 timestamps at the end — which you’ll notice are just 2 duplicated timestamps:
    • The start/end source timestamp.
    • The start/end destination timestamp.

A marker only covers a single point of time, so 3 of these timestamps are extra - the first one is what actually points to the marker itself. The second one is just 1 frame ahead, since otherwise it’d be invalid, and the other 2 are copies since we don’t care about the “destination timestamp”.

For the Metadata Line, you have two sections:

  • |C:... points to the color of the marker, here set to ResolveColorBlue
  • |M:... points to the name of the marker itself.

Now that we’re done with this, it’s time for the easy part.

The Actual Generation

First, to explain some TypeScript specifics, here’s the signature of the method:

interface MarkerData {
    timecode: string
    class: string
    username: string
    comment: string
}

export const process_marker_data = (data: MarkerData[], addHour: boolean) => { /*...*/ }

Here, we’re accepting the parsed CSV that we got from Twitch, and some additional options. For example, Resolve’s timestamp starts at 01:00:00:00 by default, but it can be customized to start at 00:00:00:00 instead - hence the addHour boolean.

const edl_data = data.map((marker, index) => {
    // Split timestamp into Hours, Minutes, and Seconds.
    const [a, b, c] = marker.timecode.split(':');

    // Recreate the timecode - where each part needs 2 digits.
    // Here, only the hours can potentially be just a single digit, so
    //  everything else can be kept as is.
    // We also have addHour to potentially bump up the hour if need be.
    const timecode = `${(parseInt(a)+(addHour ? 1 : 0)).toLocaleString('en', {minimumIntegerDigits: 2})}:${b}:${c}`;

    const data = {
        // Since Resolve timecodes include the FRAME, we have to add it manually here.
        start: `${timecode}:00`,
        end: `${timecode}:01`,
        // Iteration is as easy as just taking note of the index.
        // Note that it HAS to be 3 digits.
        iteration: (index).toLocaleString('en', {minimumIntegerDigits: 3}),
        // The name we just create by using all the information.
        name: `${marker.comment} by ${marker.username} [${marker.class}]`,
    }

    // Lastly, we create the marker entry itself.
    // The number of spaces is SPECIFIC and cannot be modified - which is why this looks awkward.
    return `${data.iteration}  001      V    C        ${data.start} ${data.end} ${data.start} ${data.end}  \n |C:ResolveColorBlue |M:${data.name} |D:1\n\n`;
}).join('');

Everything else is web specific, such as creating a Blob (file) so that it can be downloaded by the user.

Improvements?

There can be some improvements to this code, but it does its job pretty well. It can definitely be extended to support some of the following:

  • More flexible starting timestamp.
  • Colour-coded marker author.
  • Customizeable marker name.

Buuuuut I’m not sure about the worth of doing something like this, at least yet. In any case, it took me like 5 years to explain this code - and now, here you go! Hope this helps - and feel free to use my logic here to make a different version if you so wish.

Source Code

interface MarkerData {
    timecode: string
    class: string
    username: string
    comment: string
}

export const process_csv = (str_data: string) => {
    const str_delimiter = ",";

    const obj_pattern = new RegExp(
        (
            `(\${str_delimiter}|\r?\n|\r|^)` +
            `(?:"([^"]*(?:""[^"]*)*)"|` +
            `([^"\${str_delimiter}\r\n]*))`
        ),
        "gi"
    );

    let arr_data = [[] as string[]];
    let arr_matches = null;

    while (arr_matches = obj_pattern.exec(str_data)) {
        let str_matched_delimiter = arr_matches[1];
        if (
            str_matched_delimiter.length &&
            (str_matched_delimiter != str_delimiter)
        ){
            arr_data.push( [] );
        }
        if (arr_matches[2]) {
            var str_matched_value = arr_matches[2].replace(/""/g, """);
        } else {
            var str_matched_value = arr_matches[3];
        }
        arr_data[arr_data.length-1].push(str_matched_value);
    }

    return arr_data.map(marker => ({
        timecode: marker[0],
        class: marker[1],
        username: marker[2],
        comment: marker[3]
    })).slice(0, -1);
}

export const process_marker_data = (data: MarkerData[], addHour: boolean) => {
    const edl_data = data.map((marker, index) => {
        const [a, b, c] = marker.timecode.split(':');
        const timecode = `${(parseInt(a)+(addHour ? 1 : 0)).toLocaleString('en', {minimumIntegerDigits: 2})}:${b}:${c}`;
        const data = {
            start: `${timecode}:00`,
            end: `${timecode}:01`,
            iteration: (index).toLocaleString('en', {minimumIntegerDigits: 3}),
            name: `${marker.comment} by ${marker.username} [${marker.class}]`,
        }
        return `${data.iteration}  001      V    C        ${data.start} ${data.end} ${data.start} ${data.end}  \n |C:ResolveColorBlue |M:${data.name} |D:1\n\n`;
    }).join('');

    const header = "EDL\nTITLE: Timeline 1\nFCM:NON-DROP-FRAME\n\n";

    const processed = new Blob([header + edl_data], { type: 'text/plain' });
    return window.URL.createObjectURL(processed);
}