Editing
Edit podcasts and long videos from TypeScript. Tighten an episode, cut clips, chapters or a trailer, sync and switch cameras, and render an edit decision list.
client.edit holds the editing tools for podcasts and long videos. In one call, it tightens a whole episode, lists its chapters, cuts a trailer or a pack of short clips, or frames the recording by speaker. It also finds silence, lines up several recordings, finds who is on which camera, plans a cut from a transcript and renders an edit decision list (EDL). Most methods start a job and return its id, which you poll with client.jobs.getStatus(). The estimate... methods and editPlanCapabilities() start nothing, and remapTranscript() runs locally without a request.
Methods
| Method | Endpoint | What it does |
|---|---|---|
edit.tightenEpisode(input, options?) | POST /v1/tighten-episode | Tighten a recording into a finished episode in one call |
edit.episodeChapters(input, options?) | POST /v1/episode-chapters | List a recording's chapters in one call. Nothing is rendered |
edit.episodeTrailer(input, options?) | POST /v1/episode-trailer | Cut a 20 to 40 second trailer in each chosen social format |
edit.clipPack(input, options?) | POST /v1/clip-pack | Cut a recording into a pack of short clips |
edit.speakerLayout(input, options?) | POST /v1/speaker-layout | Frame a whole recording by speaker, without cutting anything |
edit.estimateTightenEpisode(input) and the other estimate... methods | POST /v1/<type>/estimate | Quote one of the five runs above. Nothing is created or charged |
edit.continueUseCase(input, options?) | POST /v1/<type>/continue | Continue one of those runs as a new job |
edit.silenceDetect(input) | POST /v1/silence-detect | Find the silent ranges of an audio or video source |
edit.audioSync(input) | POST /v1/audio-sync | Measure the time offset between 2 to 6 recordings |
edit.speakerMap(input) | POST /v1/speaker-map | Find which camera each speaker is on, from the cameras' own microphones |
edit.editPlan(input) | POST /v1/edit-plan | Plan a tightened cut, short clips, chapters or a trailer from a transcript |
edit.editPlanCapabilities() | GET /v1/edit-plan/capabilities | Read the Edit Plan modes this server can plan. Nothing is charged |
edit.cameraSwitch(input) | POST /v1/camera-switch | Put each cut of an EDL on the camera of the person speaking |
edit.applyEdl(input) | POST /v1/apply-edl | Render an EDL into a video or an audio file |
edit.remapTranscript(edl, transcript) | None, local | Move a transcript's timings onto the edited timeline |
Edit a podcast from code
For a whole episode, tightenEpisode() runs every step below in one call. Use the steps one by one when you need control over each one.
Transcribe the recording
Run client.audio.transcribe() with an engine that returns word timings. Its output_data.json is the transcript the plan needs.
Find the silence
Run silenceDetect() on the master recording. Its output_data.json holds the silent ranges.
Plan the cut
Run editPlan() in tighten mode with the transcript, the sources and the silence. Read the plan with unwrapEditPlanOutput().
Render it
Pass the plan to applyEdl(). The finished job holds the edited video or audio.
import { unwrapEditPlanOutput } from "@nodaro/sdk"
async function outputOf(jobId: string): Promise<any> {
for (;;) {
const { data } = await client.jobs.getStatus(jobId)
if (data.status === "completed") return data.output_data
if (data.status === "failed" || data.status === "cancelled") throw new Error(data.error_message ?? data.status)
await new Promise((resolve) => setTimeout(resolve, 3_000))
}
}
// 1. Transcribe with word timings
const tr = await client.audio.transcribe({ audioUrl: masterUrl, provider: "elevenlabs-stt" })
const transcript = (await outputOf(tr.jobId)).json
// 2. Find the silence
const sd = await client.edit.silenceDetect({ audioUrl: masterUrl, thresholdDb: -35 })
const silence = (await outputOf(sd.jobId)).json
// 3. Plan a tighter cut
const plan = await client.edit.editPlan({
mode: "tighten",
planTier: "standard",
transcript,
sources: [{ id: "ep", url: masterUrl, kind: "video", role: "master-audio" }],
silence,
})
const edl = unwrapEditPlanOutput(await outputOf(plan.jobId))
// 4. Render the plan
const render = await client.edit.applyEdl({ edl, output: "video", quality: "final" })
const { videoUrl } = await outputOf(render.jobId)The same steps exist as nodes: Transcribe, Silence Detect, Edit Plan and Apply EDL.
Several cameras, step by step
For a conversation filmed on several cameras, add three steps. Transcribe with speaker labels, for example with diarize: true.
audioSync()measures how far apart the recordings' clocks are.editPlan()withoffsetsplans the edit on the master's clock.speakerMap()finds who is on which camera, andcameraSwitch()puts each cut on the speaker's camera.
Then render the switched EDL with applyEdl(): a Preview first, then the final.
client.edit
Edit a whole recording in one call
Five methods run a whole editing chain in one call, as the matching nodes do. Each returns { jobId, executionId?, deduped? } (UseCaseJobResult). Each has an estimate... method that quotes the same input and starts nothing. A run is priced as its steps at their own rates, plus a fixed fee per run.
options.idempotencyKey is sent as the Idempotency-Key header. Retry the same request with the same key, and the answer is the run already started, with deduped: true.
Several recordings of one conversation
tightenEpisode(), episodeTrailer(), clipPack() and speakerLayout() also take the cameras of one conversation (UseCaseRecordingsInput). The run lines them up, works out who is on which camera, and cuts to whoever is talking.
Prop
Type
Send mediaUrl or mediaUrls. Both, or neither, reject with a TypeError before any request. Several recordings or a Master audio need Speaker Map. Where Speaker Map is not available to you, the request is refused with 403 node_not_available before anything is read or charged.
A finished run on several recordings or a Master audio adds cameras to its output: { speakers: [{ speaker, ordinal, camera, marginDb }], syncNotes, mapNotes }. It says who was put on which recording, and camera: null means the wide shot.
const multicam = await client.edit.tightenEpisode({
mediaUrls: [hostCamUrl, guestCamUrl],
masterAudioUrl: mixerUrl,
cameras: { wide: null },
})edit.tightenEpisode(input, options?) and estimateTightenEpisode(input)
Tightens a podcast recording in one call, as the Tighten Episode node does. The run transcribes the recording, plans the tightened cut and renders it. As set, it also captions the episode, frames it and lays a music bed under it. Only the recording is required, and a field left out takes the node's default.
tightenEpisode(input: TightenEpisodeInput, options?: { idempotencyKey?: string }): Promise<TightenEpisodeJobResult>
estimateTightenEpisode(input: TightenEpisodeInput): Promise<{ data: TightenEpisodeEstimate }>Prop
Type
const quote = await client.edit.estimateTightenEpisode({ mediaUrl: episodeUrl })
console.log(quote.data.totalCredits)
const { jobId } = await client.edit.tightenEpisode(
{ mediaUrl: episodeUrl, framing: { aspect: "9:16", layout: "auto" } },
{ idempotencyKey: crypto.randomUUID() },
)Poll jobId. The finished job's output_data holds the episode as videoUrl, or audioUrl for an audio episode. It also holds the plan's EDL on json, and quality: "proxy" for a review preview, "final" otherwise.
estimateTightenEpisode() returns { totalCredits, stepsCredits, feeCredits, durationSec, estimate? }: the run's steps at their own rates, plus a fixed fee per run. estimate: true means a step is a metered LLM call's estimate, so show the total as ≈N. The estimate is on the high side, because it prices the render at the recording's full length before the edit is known. The charge follows the minutes actually rendered. durationSec is null when the recording's length could not be read. On an edition without credits, every credit amount is 0.
Both methods refuse before anything is created or charged:
- A 400 carries
validation_error,media_unreadable,media_has_no_audio,media_too_longormusic_has_no_audio. On several recordings, it can also carrytoo_many_recordings,recordings_two_audio_only,wide_camera_out_of_range,invalid_camerasormaster_audio_unreadable. - A 403 carries
node_not_available. - A 503 carries
nodaro_connection_required,provider_key_missingoruse_case_unavailable.provider_key_missingmeans a self-hosted install has no key for the transcription a step needs, and no Nodaro Cloud connection that covers it. The message names the key.
tightenEpisode() also throws InsufficientCreditsError when the balance does not cover the estimate. On a self-hosted install, the steps that run on Nodaro Cloud need a Nodaro Cloud connection.
edit.episodeChapters(input, options?) and estimateEpisodeChapters(input)
Lists a podcast recording's chapters in one call, as the Chapters node does. The run transcribes the recording, then plans its chapters, each with a title and a start time. Nothing is rendered.
episodeChapters(input: EpisodeChaptersInput, options?: { idempotencyKey?: string }): Promise<UseCaseJobResult>
estimateEpisodeChapters(input: EpisodeChaptersInput): Promise<{ data: UseCaseEstimate }>Prop
Type
const chapters = await client.edit.episodeChapters({ mediaUrl: masterUrl })The finished job's output_data.text is the chapter list as show notes: one M:SS Title line per chapter, the first at 0:00. Once a chapter starts at 1:00:00 or later, every line is H:MM:SS. output_data.json is the same list as [{ startMs, title }]. A run that finds no chapters fails, and its fee is refunded.
A Chapters run cannot be continued, because every step of it completed: continueUseCase() answers 409 continuation_not_failed. Run it again instead. The estimate prices a recording whose length cannot be read at 180 minutes. Both methods refuse with the codes of tightenEpisode(), except the music ones.
edit.episodeTrailer(input, options?) and estimateEpisodeTrailer(input)
Cuts a 20 to 40 second trailer from a podcast recording, as the Trailer node does. The run plans the trailer from the recording's strongest moments and renders it. It plays an optional intro card first, and pads the trailer to every chosen format, never cropped. The plan chooses the length and joins the moments with its own short crossfades.
episodeTrailer(input: EpisodeTrailerInput, options?: { idempotencyKey?: string }): Promise<UseCaseJobResult>
estimateEpisodeTrailer(input: EpisodeTrailerInput): Promise<{ data: UseCaseEstimate }>Prop
Type
The finished job's output_data.formats is [{ format, url }], one trailer per chosen format. output_data.videoUrl is the trailer of the first format that rendered, and json is the trailer's EDL. For a review preview, quality is "proxy", videoUrl is the preview, and formats is [] until the Render final.
A format that could not be rendered is named in output_data.failedFormats. The run completes with the others, keeps its fee and is not charged for that format. A run with no format rendered fails, and its fee is refunded.
The estimate prices the trailer at 40 seconds, the longest trailer the plan cuts. Both methods refuse with the codes of tightenEpisode(), except the music ones. They add 400 media_needs_video, 400 intro_card_unreadable for an intro card that is not a playable video, and 400 intro_card_too_long.
edit.clipPack(input, options?) and estimateClipPack(input)
Cuts a podcast recording into a pack of short clips, as the Clip Pack node does. The run plans the clips, each with a title and a hook, and renders each as its own clip. By default, each video clip is framed for 9:16 with the speakers stacked, and captioned. An audio recording makes audio clips, with no framing or captions. With several cameras, each clip cuts to whoever is talking.
clipPack(input: ClipPackInput, options?: { idempotencyKey?: string }): Promise<UseCaseJobResult>
estimateClipPack(input: ClipPackInput): Promise<{ data: UseCaseEstimate }>Prop
Type
The finished job's output_data.clips is [{ url, title?, hook?, clipKey, thumbnailUrl?, captionsFailed? }], one entry per clip in the plan's order. output_data.videoUrl is the first clip, or audioUrl for audio clips.
Every clip is tried. A clip that could not be rendered is skipped, not charged and counted in output_data.failedClips. A clip whose captions could not be added comes without captions, marked captionsFailed: true, and those captions are not charged. A run with no clip rendered fails, and its fee is refunded.
The estimate prices each clip at twice the target length, and the charge follows each clip's real length. Both methods refuse with the codes of tightenEpisode(), except the music ones.
edit.speakerLayout(input, options?) and estimateSpeakerLayout(input)
Frames a whole recording by speaker, as the Speaker Layout node does. The result has the same length: nothing is cut. With several recordings, each speaker is shown on their own camera.
speakerLayout(input: SpeakerLayoutInput, options?: { idempotencyKey?: string }): Promise<UseCaseJobResult>
estimateSpeakerLayout(input: SpeakerLayoutInput): Promise<{ data: UseCaseEstimate }>Prop
Type
The finished job's output_data.videoUrl is the framed video, durationSec its length, json the edit and transcript the transcript. A run on one recording reads the recording's size before it starts. When you start too many of those in a minute, one may be refused with 429 rate_limit_exceeded: try again in a moment.
The estimate prices Speaker View at its higher rate over the recording's full length. Both methods refuse with the codes of tightenEpisode(), except the music ones.
edit.continueUseCase(input, options?)
Continues a Tighten Episode, Chapters, Trailer, Clip Pack or Speaker Layout run as a new job, and returns { jobId, executionId?, deduped? }.
continueUseCase(input: UseCaseContinueInput, options?: { idempotencyKey?: string }): Promise<UseCaseJobResult>Prop
Type
Which route. With nodeType, the continuation goes straight to that type's route. Without it, continueUseCase first reads the job (GET /v1/jobs/:id) for the type the run records, then continues on that type's route. That read needs the jobs:read scope on a connected app's token. When the type cannot be read, continueUseCase throws a NodaroError with the code use_case_type_unknown (400), and nothing is sent. Pass nodeType to skip the read. An older SDK version may not know the newer types, so upgrade to continue their runs.
const preview = await client.edit.tightenEpisode({ mediaUrl: episodeUrl, reviewBeforeRender: true })
// ...after a person reviews the preview:
const final = await client.edit.continueUseCase({ jobId: preview.jobId, mode: "render-final" })A completed Render final does not block another Render final of the same preview with a changed edit. The final steps then run and are charged again, still without the fee. The very same edit again is refused. Refusals throw before any run exists. A refusal that names another job throws a ContinuationRefusedError, a NodaroError with status 409, whose jobId is that job. It has three codes. already_running means a continuation is under way or completed: follow it. continuation_superseded means a later continuation failed too: continue that one instead. render_final_already_rendered means its result is the one to use.
Other refusals include not_found, continuation_not_found, continuation_not_failed and continuation_not_completed. continuation_settling means try again in a moment, and render_final_not_preview means the job is not a review preview. use_case_nothing_kept (409) is a Clip Pack review that keeps no clip. A run the payer cannot cover throws InsufficientCreditsError.
edit.silenceDetect(input)
Finds the silent ranges of an audio or video source. It runs on the server without an AI model.
silenceDetect(input: SilenceDetectInput): Promise<{ jobId: string }>Prop
Type
const { jobId } = await client.edit.silenceDetect({ audioUrl: masterUrl, minSilenceMs: 900 })The finished job's output_data.json is a SilenceRanges object: { version, ranges: [{ startMs, endMs }], durationMs }. Pass the whole object as silence to editPlan(). A source with no sound, such as a picture-only video, fails the job, and its credits are refunded.
edit.audioSync(input)
Measures how far apart the clocks of 2 to 6 recordings of one conversation are, from their sound, as the Audio Sync node does. It runs on the server without an AI model. It costs 11 credits per source after the first: 11 for 2 sources, 33 for 4 and 55 for 6.
audioSync(input: AudioSyncInput): Promise<{ jobId: string }>Prop
Type
const { jobId } = await client.edit.audioSync({
sources: [
{ id: "mic", url: micUrl },
{ id: "camA", url: camAUrl },
],
reference: "mic",
})
// Wait for the job to finish. output_data is typed unknown, so name the result's type.
let job = (await client.jobs.getStatus(jobId)).data
while (job.status !== "completed" && job.status !== "failed" && job.status !== "cancelled") {
await new Promise((r) => setTimeout(r, 2000))
job = (await client.jobs.getStatus(jobId)).data
}
const sync = (job.output_data as { json: AudioSyncResult }).jsonThe finished job's output_data.json is an AudioSyncResult:
{
version: number
reference: string // the source every offset is measured against
offsets: Array<{
sourceId: string
offsetMs: number // referenceMs = sourceMs + offsetMs
confidence: number // 0 to 1; below 0.5 a note asks you to check by ear
driftMsPerHour: number | null // measured, never corrected; null when the overlap was too short
}>
notes: string[] // low confidence, drift above 33 ms over the overlap, no shared sound
}With the master recording as reference, each offsetMs is exactly the offsetMs of that source in your EDL. A malformed request is refused with a NodaroError whose code is validation_error, before any credits are reserved. That covers fewer than 2 or more than 6 sources, a repeated id, or a reference that is not one of the ids.
edit.speakerMap(input)
Finds which camera each speaker is on, from the cameras' own microphones, as the Speaker Map node does. It runs on the server without an AI model, at one flat price per run.
speakerMap(input: SpeakerMapInput): Promise<{ jobId: string }>Prop
Type
The finished job's output_data.json is a SpeakerMapResult, and output_data.edl is the recordings as one edit on the master clock:
{
version: 1
speakerMap: Record<string, string> // speaker label → camera id; "" = no clear camera (the wide shot, else any camera with picture)
confidence: Record<string, { camera: string; marginDb: number | null }> // the loudest camera's lead over the next
notes: string[] // no clear camera, a camera with no sound, and more
edl?: Edl // the recordings as one edit (also on output_data.edl)
}While a speaker talks, the camera that hears them loudest, by at least marginDb, is theirs. A camera that heard nothing is a note, never a failure. A malformed request throws a NodaroError (400, validation_error), and a transcript with no speaker labels throws 422 no_speakers, both before any credits are reserved.
const { jobId } = await client.edit.speakerMap({
sources: [{ id: "camA", url: camAUrl }, { id: "camB", url: camBUrl }, { id: "wide", url: wideUrl }],
transcript,
offsets: sync, // an audioSync result
wideSourceId: "wide",
})
// ...wait for the job to finish, as for audioSync above, then:
const map = ((await client.jobs.getStatus(jobId)).data.output_data as { json: SpeakerMapResult }).json
await client.edit.cameraSwitch({ edl, transcript, speakerMapResult: map })edit.editPlan(input)
Plans an edit from a timed transcript, as the Edit Plan node does. It writes one of four kinds of plan: a tighter cut of the whole recording, a set of short clips, chapters, or a trailer.
editPlan(input: EditPlanInput): Promise<{ jobId: string }>Prop
Type
import { unwrapEditPlanOutput } from "@nodaro/sdk"
const { jobId } = await client.edit.editPlan({
mode: "clips",
planTier: "standard",
transcript,
sources: [{ id: "ep", url: masterUrl, kind: "video", role: "master-audio" }],
silence,
count: 5,
targetAspect: "9:16",
})
const { data } = await client.jobs.getStatus(jobId) // poll until completed
const clips = unwrapEditPlanOutput(data.output_data) // one EDL per clipRead the finished job's output with unwrapEditPlanOutput(). It returns an Edl in tighten and trailer mode, an array of Edl in clips mode, and a ChapterSet in chapters mode.
With offsets, editPlan() checks the sources before any request. It throws a NodaroError with the code edit_plan_sources, naming the source, when a source was not measured or matched weakly, or when the master was not measured. Set that source's offsetMs by hand in that case. Each cut goes on the first camera that has picture for it.
On a self-hosted install, this method needs a Nodaro Cloud connection. Without one, it fails with 503 nodaro_connection_required.
edit.editPlanCapabilities()
Reads the Edit Plan modes this server can plan (GET /v1/edit-plan/capabilities). Nothing is created or charged. Ask it before you offer a mode, because editPlan() with a mode the answer does not list is refused with 400 mode_not_available.
editPlanCapabilities(): Promise<EditPlanCapabilities>| Field | Type | Description |
|---|---|---|
modes | EditPlanMode[] | The modes this server plans. tighten, clips and chapters are always listed, and trailer once the server can plan a trailer. |
source | "server" | "nodaro.ai" | "nodaro.ai-unreachable" | Who answered: this server, Nodaro Cloud for a connected self-hosted install, or a connected self-hosted install that could not reach Nodaro Cloud. In the last case, only tighten, clips and chapters are listed. A newer server may add a value. |
perMinute | boolean | true when Edit Plan is charged per started minute of the recording. false when it is charged by the length step the recording rounds up to. See Edit Plan. |
The answer is never cached, because a server update can change it.
const { modes } = await client.edit.editPlanCapabilities()
if (modes.includes("trailer")) {
await client.edit.editPlan({ mode: "trailer", planTier: "standard", transcript, sources })
}edit.cameraSwitch(input)
Puts each cut of an EDL on the camera of whoever is speaking, as the Camera Switch node does. It has one flat price per run. It runs on Nodaro Cloud. A self-hosted install relays it through its Nodaro Cloud connection, and fails with 503 nodaro_connection_required without one.
cameraSwitch(input: CameraSwitchInput): Promise<{ jobId: string }>Prop
Type
Every timing is a whole number, and the bounds are exported as CAMERA_SWITCH_BOUNDS from @nodaro/shared/edl. With a wide source, a shot longer than the wide break plus two shortest shots cuts to the wide shot one wide break in, for one shortest shot. At the defaults, a shot over 25 seconds cuts to the wide at 20 seconds.
The finished job's output_data.json is the switched EDL, ready for applyEdl(). output_data.transcript is the transcript with speakerNames applied. The route refuses before anything is charged:
400 invalid_edl: theedlis not one edit on the recording's clock, for example a clip set, a chapters plan or a rendered output's EDL.400 validation_error: a setting is outside its range, or a name is over 80 characters.422 no_speakers: the transcript has no speaker labels. The SDK checks this before the request too.
edit.applyEdl(input)
Renders an edit decision list into a video or an audio file, as the Apply EDL node does.
applyEdl(input: ApplyEdlInput): Promise<{ jobId: string }>Prop
Type
const { jobId } = await client.edit.applyEdl({ edl, output: "video", quality: "proxy", crossfadeMs: 80 })The finished job's output_data carries the cut: videoUrl and thumbnailUrl, or audioUrl. It also carries the moved transcript on json when you sent one, quality ("proxy" or "final"), and clipKey and planBasis when you sent them. A proxy render is a Preview and never appears in the public gallery. See Preview and Render final and the credits of each quality.
The EDL is checked before any credits are reserved. An unknown source, a segment without picture in a video edit, or more than 180 minutes of output in one render fails with a NodaroError whose code is invalid_edl. The length is measured after crossfades. The message names the problem, for example that the edit renders 200 minutes and must be split into parts of at most 180 minutes.
edit.remapTranscript(edl, transcript)
Moves a transcript onto the timeline of an edit, locally, without a request. It drops the words inside cut ranges, clips the words that cross a cut, and shifts every timing to the edited output. applyEdl() does the same on the server when you pass it a transcript.
remapTranscript(edl: Edl, transcript: Transcript): TranscriptProp
Type
const editedTranscript = client.edit.remapTranscript(edl, transcript)
// caption the edited video without another transcriptionUse it to caption an edit without a render or a second transcription. It is also the faster choice for a large transcript when you only need the new timings.
Frequently asked questions
How do I cut the pauses out of a podcast with the Nodaro SDK?
Transcribe the recording, run client.edit.silenceDetect, then client.edit.editPlan in tighten mode with the transcript and the silence ranges. Render the returned plan with client.edit.applyEdl.
Can I tighten a podcast episode in one call?
Yes. client.edit.tightenEpisode takes one recording URL, video or audio up to 3 hours, and returns a job. The run transcribes, plans and renders the tightened episode, with optional captions, music and framing.
Can I edit a podcast recorded on several cameras?
Yes. Pass the recordings of one conversation as mediaUrls, and the mixer track as masterAudioUrl, to tightenEpisode, episodeTrailer, clipPack or speakerLayout. The run lines them up and cuts to whoever is talking. Several recordings need Speaker Map: where it is not available to you, the request is refused with 403 node_not_available.
What is an EDL?
An edit decision list: the sources of an edit and the segments to keep, in order, with their transitions. client.edit.editPlan writes one, and client.edit.applyEdl renders it into a video or an audio file.
How long can one EDL render be?
At most 180 minutes of output per render. A longer edit is refused with invalid_edl before any credits are reserved. Split it into parts.
What does client.edit.audioSync cost?
11 credits per source after the first: 11 credits for 2 recordings, 33 for 4 and 55 for 6.
Related
Voices and audio
Media and uploads
Tighten Episode
Edit Plan
Apply EDL
Last updated on
Voices and audio
Browse voices, change and recast voices, design new voices, dub video, and separate, mix and transcribe audio from TypeScript with the Nodaro SDK.
3D scenes
Generate an editable 3D scene from a prompt, edit it, render it to MP4, and run 3D Render Pro from TypeScript with client.scene3d and the 3D scene nodes.