StreamPot / StreamPot

Run FFmpeg as an API with fluent-ffmpeg compatibility, queues and S3 storage.
https://streampot.io
MIT License
784 stars 19 forks source link

Workflow failed with no error message #25

Closed omzi closed 2 months ago

omzi commented 2 months ago

Hi @jackbridger. Good day to you!

You & @osbre built a really cool, developer-focused product! It's super useful & I wish I found it earlier.

Please I need help debugging my StreamPot config. I'm an (fluent-)FFmpeg noob(ish) so it must be a stupid mistake I made. The workflow runs alright but its fails. I don't have a workspace assets error so I don't know how to debug it 😿. It also doesn't throw an error if it fails. I've tried asking ChatGPT (with some snippets from StreamPot, like the toCommandArguments function) but it doesn't come up with the right solution.

Here's the Unix: 1722271942.

Here are my workflow actions:

[
  {
    "name": "addInputOptions",
    "value": [
      [
        "-loop",
        "1",
        "-t",
        "5",
        "-i",
        "https://files.edgestore.dev/xxxxx/projectFiles/_public/64712bf2-8b54-489e-82e6-a786bcac5054.jpg",
        "-loop",
        "1",
        "-t",
        "5",
        "-i",
        "https://files.edgestore.dev/xxxxx/projectFiles/_public/c494ca1d-1f1d-4879-a96c-d615493e88d9.jpg",
        "-loop",
        "1",
        "-t",
        "5",
        "-i",
        "https://files.edgestore.dev/xxxxx/projectFiles/_public/b12d7b46-0b08-4a4f-9caf-614d01386384.jpg",
        "-loop",
        "1",
        "-t",
        "5",
        "-i",
        "https://files.edgestore.dev/xxxxx/projectFiles/_public/88ee30d8-bf74-4f2e-8ec4-795802b2496c.jpg"
      ]
    ]
  },
  {
    "name": "videoFilters",
    "value": [
 "fade=1:black:duration=1:offset=0,xfade=transition=smoothup:duration=1:offset=5,xfade=transition=wipeleft:duration=1:offset=10,xfade=transition=wipeup:duration=1:offset=15"
    ]
  },
  {
    "name": "input",
    "value": [
      "https://files.edgestore.dev/xxxxx/projectFiles/_public/4afa945e-3a04-471b-b098-31ec9653665a.mp3"
    ]
  },
  {
    "name": "audioCodec",
    "value": [
      "aac"
    ]
  },
  {
    "name": "videoCodec",
    "value": [
      "libx264"
    ]
  },
  {
    "name": "outputOptions",
    "value": [
      [
        "-shortest",
        "-pix_fmt",
        "yuv420p",
        "-shortest",
        "-aspect",
        "16:9",
        "-r",
        "24"
      ]
    ]
  },
  {
    "name": "output",
    "value": [
      "generated.mp4"
    ]
  }
]

Here's my full Next.js API route showing how I'm running StreamPot:

import prisma from '#/lib/prisma';
import { Redis } from '@upstash/redis';
import { getToken } from 'next-auth/jwt';
import { TransitionType } from '#/types';
import StreamPot from '@streampot/client';
import { reorderByField } from '#/lib/utils';
import { Ratelimit } from '@upstash/ratelimit';
import { NextRequest, NextResponse } from 'next/server';

export const maxDuration = 30;
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(6, '60 s')
});
const streampot = new StreamPot({ secret: `${process.env.STREAMPOT_API_KEY}` });

const transitions: Record<TransitionType, string> = {
  fade: 'fade=1',
  fadeblack: 'fade=1:black',
  fadewhite: 'fade=1:white',
  distance: 'xfade=transition=fade',
  wipeleft: 'xfade=transition=wipeleft',
  wiperight: 'xfade=transition=wiperight',
  wipeup: 'xfade=transition=wipeup',
  wipedown: 'xfade=transition=wipedown',
  slideleft: 'xfade=transition=slideleft',
  slideright: 'xfade=transition=slideright',
  slideup: 'xfade=transition=slideup',
  slidedown: 'xfade=transition=slidedown',
  smoothleft: 'xfade=transition=smoothleft',
  smoothright: 'xfade=transition=smoothright',
  smoothup: 'xfade=transition=smoothup',
  smoothdown: 'xfade=transition=smoothdown'
};

export const GET = async (req: NextRequest, res: NextResponse) => {
    const token = await getToken({ req });
    if (!token) {
    return NextResponse.json({ message: 'Unauthenticated!' }, { status: 401 });
  }

  const { success } = await ratelimit.limit(`generatePreview-${token.sub}`);
  if (!success) {
    return new Response('Rate limited!', { status: 429 });
  }

    const { searchParams } = req.nextUrl;
    const projectId = searchParams.get('projectId');

    if (!projectId) {
        return NextResponse.json({ message: 'Invalid request. Provide projectId.' }, { status: 400 });
    }

    const [project, media, narration] = await Promise.all([
    prisma.project.findUnique({ where: { id: projectId, userId: token.sub } }),
    prisma.media.findMany({ where: { projectId } }),
    prisma.narration.findUnique({ where: { projectId }, select: { transcript: true, audioUrl: true, voice: true } })
  ]);

  if (!project) {
        return new Response('Project not found!', { status: 400 });
    }
  if (!narration) {
        return new Response('Narration not found!', { status: 400 });
    }
  if (!narration.audioUrl) {
        return new Response('Narration audio not found!', { status: 400 });
    }
  if (media.length === 0) {
        return new Response('No media uploaded yet!', { status: 400 });
    }

    const mediaItems = reorderByField(media, project.mediaOrder, 'id');

    // Process media items
    // Gather options for inputs and filters
  const inputs = [];
  const filters = [];
  let offset = 0;

  for (const media of mediaItems) {
        inputs.push('-loop', '1', '-t', media.duration.toString(), '-i', media.url);

    if (media.transition in transitions) {
            const transitionFilter = `${transitions[media.transition as TransitionType]}:duration=1:offset=${offset}`;
            filters.push(transitionFilter);
        }

        offset += media.duration;
  }

    console.log('Inputs:', inputs);
    console.log('Filters:', filters.join(','));

  try {
    // Add inputs, filters, and other options to streampot and run the command
    const clip = await streampot
      .addInputOptions(inputs)
      .videoFilters(filters.join(','))
      .input(narration.audioUrl)
      .audioCodec('aac')
      .audioBitrate('192k')
      .videoCodec('libx264')
      .videoBitrate('1000k', true)
      .outputOptions([
        '-shortest',
        '-pix_fmt',
        'yuv420p',
        `-aspect`,
        project.aspectRatio,
        `-r`,
        `${project.frameRate}`
      ])
      .output('generated.mp4')
      .runAndWait();

    const video = clip.outputs['generated.mp4'];
    console.log('Clip Outputs :>>', clip.outputs);
    console.log('Video :>>', video);

    return NextResponse.json({ message: 'Preview generated!', data: { preview: video } }, { status: 200 });
  } catch (error) {
    console.log('Preview Generation Error :>>', error);
    return new Response('Failed to generate preview!', { status: 500 });
  }
};

I'd really appreciate your help with this as I'm working on a time-sensitive project.

Thanks so much!

omzi commented 2 months ago

Never mind, I've fixed it ;). I had to create my custom StreamPot class. This allowed me to call any method without chaining (because running all commands at once is not feasible for me). I also made some mods to the route I shared earlier.

Here's the class for anyone interested (I totally removed chaining):

import fetch from 'cross-fetch';

interface AudioVideoFilter {
  filter: string;
  options: string | string[] | {};
};

interface FilterSpecification {
  filter: string;
  inputs?: string | string[] | undefined;
  outputs?: string | string[] | undefined;
  options?: any | string | any[] | undefined;
};

type JobStatus = 'pending' | 'completed' | 'failed' | 'uploading';

type Outputs = Record<string, string>

class JobEntity {
  public id: number
  public status: JobStatus
  public outputs: Outputs
  public logs?: string
  public created_at: string

  constructor(data: {
    id: number
    status: JobStatus
    outputs: Outputs
    logs?: string
    created_at: string
  }) {
    this.id = data.id;
    this.status = data.status;
    this.outputs = data.outputs;
    this.logs = data.logs;
    this.created_at = data.created_at;
  }
};

type StreamPotOptions = {
    secret: string;
    baseUrl?: string;
}

export default class StreamPot {
    protected secret: string;
    protected baseUrl: string;
    protected actions: any[] = []; // Array to store actions

    constructor({ secret, baseUrl = 'https://api.streampot.io/v1' }: StreamPotOptions) {
        this.secret = secret;
        this.baseUrl = baseUrl;
    }

    protected handleError(response: Response) {
        if (response.status === 401) {
            throw new Error('Invalid StreamPot secret key')
        }

        if (response.status >= 400) {
            throw new Error(`StreamPot API: ${response.status} ${response.statusText}`)
        }
    }

    public async getJob(jobId: number): Promise<JobEntity> {
        const response = await fetch(`${this.baseUrl}/jobs/${jobId}`, {
            headers: {
                'Accept': 'application/json',
                Authorization: `Bearer ${this.secret}`
            }
        })

        this.handleError(response);

        return new JobEntity(await response.json());
    }

    /**
     * @deprecated Use `getJob(jobId)` instead
     */
    public async checkStatus(jobId: string | number): Promise<JobEntity> {
        return await this.getJob(Number(jobId));
    }

    /**
     * Run the job and immediately return the job entity.
     */
    public async run(): Promise<JobEntity> {
        const response = await fetch(`${this.baseUrl}/`, {
            method: 'POST',
            headers: {
                'Accept': 'application/json',
                'Content-Type': 'application/json',
                Authorization: `Bearer ${this.secret}`
            },
            body: JSON.stringify(this.actions)
        })

        this.handleError(response);

        this.actions = []; // Clear actions after running
        return new JobEntity(await response.json());
    }

    /**
     * Run the job and wait for it to complete or fail.
     */
    public async runAndWait(intervalMs: number = 1000): Promise<JobEntity> {
        let job = await this.run();

        while (job.status !== 'completed' && job.status !== 'failed') {
            await new Promise(resolve => setTimeout(resolve, intervalMs))
            job = await this.getJob(job.id);
        }

        return job;
    }

    // Modified addAction to directly push actions 
    protected addAction(name: string, ...values: any) {
        this.actions.push({ name, value: values });
    }

    public mergeAdd(source: string) {
        this.addAction('mergeAdd', source);
    }

    public addInput(source: string) {
        this.addAction('addInput', source);
    }

  public input(source: string) {
        this.addAction('input', source);
  }

  public withInputFormat(format: string) {
        this.addAction('withInputFormat', format);
  }

  public inputFormat(format: string) {
        this.addAction('inputFormat', format);
  }

  public fromFormat(format: string) {
        this.addAction('fromFormat', format);
  }

  public withInputFps(fps: number) {
        this.addAction('withInputFps', fps);
  }

  public withInputFPS(fps: number) {
        this.addAction('withInputFPS', fps);
  }

  public withFpsInput(fps: number) {
        this.addAction('withFpsInput', fps);
  }

  public withFPSInput(fps: number) {
        this.addAction('withFPSInput', fps);
  }

  public inputFPS(fps: number) {
        this.addAction('inputFPS', fps);
  }

  public inputFps(fps: number) {
        this.addAction('inputFps', fps);
  }

  public fpsInput(fps: number) {
        this.addAction('fpsInput', fps);
  }

  public FPSInput(fps: number) {
        this.addAction('FPSInput', fps);
  }

  public nativeFramerate() {
        this.addAction('nativeFramerate');
  }

  public withNativeFramerate() {
        this.addAction('withNativeFramerate');
  }

  public native() {
        this.addAction('native');
  }

  public setStartTime(seek: string | number) {
        this.addAction('setStartTime', seek);
  }

  public seekInput(seek: string | number) {
        this.addAction('seekInput', seek);
  }

  public loop(duration: string | number | undefined) {
        this.addAction('loop', duration);
  }

  public withNoAudio() {
        this.addAction('withNoAudio');
  }

  public noAudio() {
        this.addAction('noAudio');
  }

  public withAudioCodec(codec: string) {
        this.addAction('withAudioCodec', codec);
  }

  public audioCodec(codec: string) {
        this.addAction('audioCodec', codec);
  }

  public withAudioBitrate(bitrate: string | number) {
        this.addAction('withAudioBitrate', bitrate);
  }

  public audioBitrate(bitrate: string | number) {
        this.addAction('audioBitrate', bitrate);
  }

  public withAudioChannels(channels: number) {
        this.addAction('withAudioChannels', channels);
  }

  public audioChannels(channels: number) {
        this.addAction('audioChannels', channels);
  }

  public withAudioFrequency(freq: number) {
        this.addAction('withAudioFrequency', freq);
  }

  public audioFrequency(freq: number) {
        this.addAction('audioFrequency', freq);
  }

  public withAudioQuality(quality: number) {
        this.addAction('withAudioQuality', quality);
  }

  public audioQuality(quality: number) {
        this.addAction('audioQuality', quality);
  }

  public withAudioFilter(filters: string | string[] | AudioVideoFilter[]) {
        this.addAction('withAudioFilter', filters);
  }

  public withAudioFilters(filters: string | string[] | AudioVideoFilter[]) {
        this.addAction('withAudioFilters', filters);
  }

  public audioFilter(filters: string | string[] | AudioVideoFilter[]) {
        this.addAction('audioFilter', filters);
  }

  public audioFilters(filters: string | string[] | AudioVideoFilter[]) {
        this.addAction('audioFilters', filters);
  }

  public withNoVideo() {
        this.addAction('withNoVideo');
  }

  public noVideo() {
        this.addAction('noVideo');
  }

  public withVideoCodec(codec: string) {
        this.addAction('withVideoCodec', codec);
  }

  public videoCodec(codec: string) {
        this.addAction('videoCodec', codec);
  }

  public withVideoBitrate(bitrate: string | number, constant: boolean | undefined) {
        this.addAction('withVideoBitrate', bitrate, constant);
  }

  public videoBitrate(bitrate: string | number, constant: boolean | undefined) {
        this.addAction('videoBitrate', bitrate, constant);
  }

  public withVideoFilter(filters: string | string[] | AudioVideoFilter[]) {
        this.addAction('withVideoFilter', filters);
  }

  public withVideoFilters(filters: string | string[] | AudioVideoFilter[]) {
        this.addAction('withVideoFilters', filters);
  }

  public videoFilter(filters: string | string[] | AudioVideoFilter[]) {
        this.addAction('videoFilter', filters);
  }

  public videoFilters(filters: string | string[] | AudioVideoFilter[]) {
        this.addAction('videoFilters', filters);
  }

  public withOutputFps(fps: number) {
        this.addAction('withOutputFps', fps);
  }

  public withOutputFPS(fps: number) {
        this.addAction('withOutputFPS', fps);
  }

  public withFpsOutput(fps: number) {
        this.addAction('withFpsOutput', fps);
  }

  public withFPSOutput(fps: number) {
        this.addAction('withFPSOutput', fps);
  }

  public withFps(fps: number) {
        this.addAction('withFps', fps);
  }

  public withFPS(fps: number) {
        this.addAction('withFPS', fps);
  }

  public outputFPS(fps: number) {
        this.addAction('outputFPS', fps);
  }

  public outputFps(fps: number) {
        this.addAction('outputFps', fps);
  }

  public fpsOutput(fps: number) {
        this.addAction('fpsOutput', fps);
  }

  public FPSOutput(fps: number) {
        this.addAction('FPSOutput', fps);
  }

  public fps(fps: number) {
        this.addAction('fps', fps);
  }

  public FPS(fps: number) {
        this.addAction('FPS', fps);
  }

  public takeFrames(frames: number) {
        this.addAction('takeFrames', frames);
  }

  public withFrames(frames: number) {
        this.addAction('withFrames', frames);
  }

  public frames(frames: number) {
        this.addAction('frames', frames);
  }

  public keepPixelAspect() {
        this.addAction('keepPixelAspect');
  }

  public keepDisplayAspect() {
        this.addAction('keepDisplayAspect');
  }

  public keepDisplayAspectRatio() {
        this.addAction('keepDisplayAspectRatio');
  }

  public keepDAR() {
        this.addAction('keepDAR');
  }

  public withSize(size: string) {
        this.addAction('withSize', size);
  }

  public setSize(size: string) {
        this.addAction('setSize', size);
  }

  public size(size: string) {
        this.addAction('size', size);
  }

  public withAspect(aspect: string | number) {
        this.addAction('withAspect', aspect);
  }

  public withAspectRatio(aspect: string | number) {
        this.addAction('withAspectRatio', aspect);
  }

  public setAspect(aspect: string | number) {
        this.addAction('setAspect', aspect);
  }

  public setAspectRatio(aspect: string | number) {
        this.addAction('setAspectRatio', aspect);
  }

  public aspect(aspect: string | number) {
        this.addAction('aspect', aspect);
  }

  public aspectRatio(aspect: string | number) {
        this.addAction('aspectRatio', aspect);
  }

  public applyAutopadding(pad: boolean | undefined, color: string | undefined) {
        this.addAction('applyAutopadding', pad, color);
  }

  public applyAutoPadding(pad: boolean | undefined, color: string | undefined) {
        this.addAction('applyAutoPadding', pad, color);
  }

  public applyAutopad(pad: boolean | undefined, color: string | undefined) {
        this.addAction('applyAutopad', pad, color);
  }

  public applyAutoPad(pad: boolean | undefined, color: string | undefined) {
        this.addAction('applyAutoPad', pad, color);
  }

  public withAutopadding(pad: boolean | undefined, color: string | undefined) {
        this.addAction('withAutopadding', pad, color);
  }

  public withAutoPadding(pad: boolean | undefined, color: string | undefined) {
        this.addAction('withAutoPadding', pad, color);
  }

  public withAutopad(pad: boolean | undefined, color: string | undefined) {
        this.addAction('withAutopad', pad, color);
  }

  public withAutoPad(pad: boolean | undefined, color: string | undefined) {
        this.addAction('withAutoPad', pad, color);
  }

  public autoPad(pad: boolean | undefined, color: string | undefined) {
        this.addAction('autoPad', pad, color);
  }

  public autopad(pad: boolean | undefined, color: string | undefined) {
        this.addAction('autopad', pad, color);
  }

  public addOutput(target: string) {
        this.addAction('addOutput', target);
  }

  public output(target: string) {
        this.addAction('output', target);
  }

  public seekOutput(seek: string | number) {
        this.addAction('seekOutput', seek);
  }

  public seek(seek: string | number) {
        this.addAction('seek', seek);
  }

  public withDuration(duration: string | number) {
        this.addAction('withDuration', duration);
  }

  public setDuration(duration: string | number) {
        this.addAction('setDuration', duration);
  }

  public duration(duration: string | number) {
        this.addAction('duration', duration);
  }

  public toFormat(format: string) {
        this.addAction('toFormat', format);
  }

  public withOutputFormat(format: string) {
        this.addAction('withOutputFormat', format);
  }

  public outputFormat(format: string) {
        this.addAction('outputFormat', format);
  }

  public format(format: string) {
        this.addAction('format', format);
  }

  public map(spec: string) {
        this.addAction('map', spec);
  }

  public updateFlvMetadata() {
        this.addAction('updateFlvMetadata');
  }

  public flvmeta() {
        this.addAction('flvmeta');
  }

  public addInputOption(...options: string[] | [string[]]) {
        this.addAction('addInputOption', ...options);
  }

  public withInputOptions(...options: string[] | [string[]]) {
        this.addAction('withInputOptions', ...options);
  }

  public withInputOption(...options: string[] | [string[]]) {
        this.addAction('withInputOption', ...options);
  }

  public inputOption(...options: string[] | [string[]]) {
        this.addAction('inputOption', ...options);
  }

  public addInputOptions(...options: string[] | [string[]]) {
        this.addAction('addInputOptions', ...options);
  }

  public addOutputOption(...options: string[] | [string[]]) {
        this.addAction('addOutputOption', ...options);
  }

  public addOutputOptions(...options: string[] | [string[]]) {
        this.addAction('addOutputOptions', ...options);
  }

  public addOption(...options: string[] | [string[]]) {
        this.addAction('addOption', ...options);
  }

  public withOutputOption(...options: string[] | [string[]]) {
        this.addAction('withOutputOption', ...options);
  }

  public withOutputOptions(...options: string[] | [string[]]) {
        this.addAction('withOutputOptions', ...options);
  }

  public withOption(...options: string[] | [string[]]) {
        this.addAction('withOption', ...options);
  }

  public withOptions(...options: string[] | [string[]]) {
        this.addAction('withOptions', ...options);
  }

  public outputOption(...options: string[] | [string[]]) {
        this.addAction('outputOption', ...options);
  }

  public outputOptions(...options: string[] | [string[]]) {
        this.addAction('outputOptions', ...options);
  }

  public filterGraph(
        spec: string | FilterSpecification | Array<string | FilterSpecification>,
    map?: string[] | string,
  ) {
        this.addAction('filterGraph', spec, map);
  }

  public complexFilter(
        spec: string | FilterSpecification | Array<string | FilterSpecification>,
        map?: string[] | string,
    ) {
        this.addAction('complexFilter', spec, map);
    }
}

Note to devs: I think it's best to create a separate chaining class for those that their use case fits.