Back to Topaz for SBS VR180

The Topaz FFMPEG CLI is the best tool because the UI is … awful. I’m sure it has improved since the 2024 license that we are using, but a CLI is all we need.

Here’s a nice utility for building the CLI commands for nodejs:

// TvaiSettings: holds the Topaz model + slider values
export class TvaiSettings {
  constructor({
    model = 'prob-4',
    scale = 0,
    width = 8192,
    height = 4096,
    preblur = -0.220446,
    noise = 0.39,
    details = 0.42,
    halo = 0.02,
    blur = 0.39,
    compression = 0.25,
    blend = 0.2,
    device = 0,
    vram = 1,
    instances = 1,
  } = {}) {
    this.model = model;
    this.scale = scale;
    this.width = width;
    this.height = height;
    this.preblur = preblur;
    this.noise = noise;
    this.details = details;
    this.halo = halo;
    this.blur = blur;
    this.compression = compression;
    this.blend = blend;
    this.device = device;
    this.vram = vram;
    this.instances = instances;
  }

  // Build the tvai_up filter string for ffmpeg
  toFilterString() {
    const parts = [
      `model=${this.model}`,
      `scale=${this.scale}`,
      `w=${this.width}`,
      `h=${this.height}`,
      `preblur=${this.preblur}`,
      `noise=${this.noise}`,
      `details=${this.details}`,
      `halo=${this.halo}`,
      `blur=${this.blur}`,
      `compression=${this.compression}`,
      `blend=${this.blend}`,
      `device=${this.device}`,
      `vram=${this.vram}`,
      `instances=${this.instances}`,
    ];
    return `tvai_up=${parts.join(':')}`;
  }

  // Build a human-readable metadata string similar to Topaz UI
  toMetadataString() {
    const pct = (v) => Math.round(v * 100);
    const preblurVal = (this.preblur * 100).toFixed(4);

    return [
      `Enhanced using ${this.model}`,
      'mode: manual',
      `revert compression at ${pct(this.compression)}`,
      `recover details at ${pct(this.details)}`,
      `sharpen at ${pct(this.blur)}`,
      `reduce noise at ${pct(this.noise)}`,
      `dehalo at ${pct(this.halo)}`,
      `anti-alias/deblur at ${preblurVal}`,
      'focus fix Off',
      `and recover original detail at ${pct(this.blend)}`,
    ].join('; ');
  }

  // For saving to a DB
  toJSON() {
    return {
      model: this.model,
      scale: this.scale,
      width: this.width,
      height: this.height,
      preblur: this.preblur,
      noise: this.noise,
      details: this.details,
      halo: this.halo,
      blur: this.blur,
      compression: this.compression,
      blend: this.blend,
      device: this.device,
      vram: this.vram,
      instances: this.instances,
    };
  }

  // For restoring from DB JSON
  static fromJSON(json) {
    return new TvaiSettings(json);
  }
}

// TvaiCommandBuilder: builds the actual ffmpeg commands
export class TvaiCommandBuilder {
  constructor({
    ffmpegPath = '/Applications/Topaz Video AI.app/Contents/MacOS/ffmpeg',
    tvaiSettings = new TvaiSettings(),
    swsFlags = 'spline+accurate_rnd+full_chroma_int',
    videoCodec = 'prores_videotoolbox',
    videoProfile = 'hq',
    pixFmt = 'p210le',
    movflags = 'frag_keyframe+empty_moov+delay_moov+use_metadata_tags+write_colr',
  } = {}) {
    this.ffmpegPath = ffmpegPath;
    this.tvaiSettings = tvaiSettings;
    this.swsFlags = swsFlags;
    this.videoCodec = videoCodec;
    this.videoProfile = videoProfile;
    this.pixFmt = pixFmt;
    this.movflags = movflags;
  }

  // Convenience for tweaking sliders programmatically
  setTvaiSettings(partial) {
    Object.assign(this.tvaiSettings, partial);
    return this;
  }

  // Build a preview command (short segment, usually no audio)
  buildPreview({
    inputPath,
    outputPath,
    startSeconds = 0,
    durationSeconds = 1.0,
    includeAudio = false,
  }) {
    if (!inputPath || !outputPath) {
      throw new Error('inputPath and outputPath are required');
    }

    const args = [
      '-hide_banner',
      '-t',
      String(durationSeconds),
      '-ss',
      String(startSeconds),
      '-i',
      inputPath,
      '-flush_packets',
      '1',
      '-sws_flags',
      this.swsFlags,
      '-filter_complex',
      this.tvaiSettings.toFilterString(),
      '-fflags',
      '+flush_packets',
      '-c:v',
      this.videoCodec,
      '-profile:v',
      this.videoProfile,
      '-pix_fmt',
      this.pixFmt,
      '-allow_sw',
      '1',
    ];

    if (!includeAudio) {
      args.push('-an');
    } else {
      // Optional: preview with audio
      args.push('-map', '0:a?', '-c:a', 'copy');
    }

    args.push(
      '-map_metadata',
      '0',
      '-map_metadata:s:v',
      '0:s:v',
      '-fps_mode:v',
      'passthrough',
      '-movflags',
      this.movflags,
      '-bf',
      '0',
      '-metadata',
      `videoai=${this.tvaiSettings.toMetadataString()}`,
      outputPath
    );

    return { command: this.ffmpegPath, args };
  }

  // Build a trimmed preview of the untouched source
  buildSourcePreview({
    inputPath,
    outputPath,
    startSeconds = 0,
    durationSeconds = 1.0,
    includeAudio = false,
  }) {
    if (!inputPath || !outputPath) {
      throw new Error('inputPath and outputPath are required');
    }

    const args = [
      '-hide_banner',
      '-t',
      String(durationSeconds),
      '-ss',
      String(startSeconds),
      '-i',
      inputPath,
      '-flush_packets',
      '1',
      '-fflags',
      '+flush_packets',
      '-c:v',
      this.videoCodec,
      '-profile:v',
      this.videoProfile,
      '-pix_fmt',
      this.pixFmt,
      '-allow_sw',
      '1',
    ];

    if (!includeAudio) {
      args.push('-an');
    } else {
      args.push('-map', '0:a?', '-c:a', 'copy');
    }

    args.push(
      '-map_metadata',
      '0',
      '-map_metadata:s:v',
      '0:s:v',
      '-fps_mode:v',
      'passthrough',
      '-movflags',
      this.movflags,
      '-bf',
      '0',
      outputPath
    );

    return { command: this.ffmpegPath, args };
  }

  // Build a full render command
  buildFullRender({
    inputPath,
    outputPath,
    copyAudio = true,
  }) {
    if (!inputPath || !outputPath) {
      throw new Error('inputPath and outputPath are required');
    }

    const args = [
      '-hide_banner',
      '-i',
      inputPath,
      '-sws_flags',
      this.swsFlags,
      '-filter_complex',
      this.tvaiSettings.toFilterString(),
      '-c:v',
      this.videoCodec,
      '-profile:v',
      this.videoProfile,
      '-pix_fmt',
      this.pixFmt,
      '-allow_sw',
      '1',
    ];

    if (copyAudio) {
      args.push(
        '-map',
        '0:a?',
        '-map_metadata:s:a:0',
        '0:s:a:0',
        '-c:a',
        'copy'
      );
    } else {
      args.push('-an');
    }

    args.push(
      '-map_metadata',
      '0',
      '-map_metadata:s:v',
      '0:s:v',
      '-fps_mode:v',
      'passthrough',
      '-movflags',
      this.movflags,
      '-bf',
      '0',
      '-metadata',
      `videoai=${this.tvaiSettings.toMetadataString()}`,
      outputPath
    );

    return { command: this.ffmpegPath, args };
  }

  // Serialize builder + settings to DB
  toJSON() {
    return {
      ffmpegPath: this.ffmpegPath,
      tvaiSettings: this.tvaiSettings.toJSON(),
      swsFlags: this.swsFlags,
      videoCodec: this.videoCodec,
      videoProfile: this.videoProfile,
      pixFmt: this.pixFmt,
      movflags: this.movflags,
    };
  }

  static fromJSON(json) {
    return new TvaiCommandBuilder({
      ffmpegPath: json.ffmpegPath,
      tvaiSettings: TvaiSettings.fromJSON(json.tvaiSettings),
      swsFlags: json.swsFlags,
      videoCodec: json.videoCodec,
      videoProfile: json.videoProfile,
      pixFmt: json.pixFmt,
      movflags: json.movflags,
    });
  }
}

// Optional helper if you want a printable shell command:
export function toShellString({ command, args }) {
  const quote = (s) =>
    /[^A-Za-z0-9_\/.\-:+=]/.test(s) ? `"${String(s).replace(/"/g, '\\"')}"` : s;
  return [quote(command), ...args.map(quote)].join(' ');
}

usage looks something like:



const builder = new TvaiCommandBuilder({
    tvaiSettings: new TvaiSettings({
      preblur: -0.220446,
      noise: 0.39,
      details: 0.42,
      halo: 0.02,
      blur: 0.39,
      compression: 0.25,
      blend: 0.2,
    }),
  });

  const command = builder.buildPreview({
    inputPath: source.path,
    outputPath: previewFile.outputPath,
    startSeconds: source.previewOptions.startSeconds,
    durationSeconds: source.previewOptions.durationSeconds,
    includeAudio: source.previewOptions.includeAudio,
  });

  await runCommand(command.command, command.args);

and with this we have scripted out a workflow that goes something like this for SBS VR180 shot with the Canon R5C:

  1. Canon EOS VR Utility Export 8192×4096 ProRes 442, CLOG3
  2. Import into Adobe Premiere, Override color to CLOG3, trim and mix audio as needed
  3. Export from Premiere to ProRes 442 HQ
  4. Run through Topaz with TvaiCommandBuilder
  5. Take final Topaz SBS video, run through avconvert or spatial command to get the final APMP or spatial video output

This workflow has been working well, and the denoise and sharpening out of the Topaz workflow yields a better image on our test shots than just plain exporting from Premiere Pro -> RealESRGANx2_plus upscale (JPEG) -> re-stitch back to MV-HEVC.

Further testing is being done with taking the Topaz output and running that through the Upscaler, but strangely, new noise artifacts appear on the JPEG out after the upscaling …. another mystery for another day.