Studio productions
Create Studio productions from a plan, edit them with operations, generate stills and clips, review planned frames, and share or copy them from TypeScript.
Available on Nodaro Cloud
A Studio production is a Nodaro workflow whose settings hold the shots of a film. Each shot has a framed still, an optional animated clip, and the plan, looks, cast bindings and voice that made them. client.studio reads and writes productions, so a script, an AI assistant and the Studio app work on one production. client.shots stores the shared shot records behind share links. The methods call the Studio productions REST API. See Studio productions over MCP for the authoring guide.
Studio productions run on Nodaro Cloud. Where the routes are not served, every method throws NotFoundError. To check once, call client.studio.productions.list(): a deployment with productions answers an empty page, and one without them throws NotFoundError.
Two sets of methods
client.studio has two layers. Both work on the same productions.
| Layer | Use it for | Returns |
|---|---|---|
client.studio.productions.* | The production document: create from a plan, edit with operations, generate stills and clips, add voice and music, share and copy | The payload itself |
client.studio.* | Planned frames: capabilities, keyframe generation and review, bundles, editor saves and link sharing with revision checks | The API's { data } envelope |
The envelopes are typed; the production document is not. A production, a shot and an operation are open JSON, Record<string, unknown>. Everything you branch on is typed: version, rebased, receipts, warnings, a quote's credits and a run's jobIds. The operation vocabulary comes from the server: read it from skill().
Methods of client.studio.productions
| Method | What it does |
|---|---|
skill() | Read the authoring guide, catalog, plan schema and operating guide |
validatePlan(plan) | Check a plan, for free |
list(opts?) | List your productions |
get(productionId, opts?) | Read a production |
exportPlan(productionId, opts?) | Plan and price the export steps |
create(input?) | Create a production, optionally from a plan |
ops(productionId, input) | Apply, or preview, a batch of operations |
reconcile(productionId) | Land finished generations |
importPlan(productionId, plan, opts?) | Add a plan's scenes to a production |
describe(productionId, input) | Turn a brief into scenes |
generate(), generateStill(), generateClip() | Frame or animate a shot |
frame(productionId, input) | Take a still from a shot's clip |
voice(productionId, input) | Speak a shot's line |
revoice(productionId, input) | Recast the voices of a shot's clip |
music(productionId, input) | Score the film |
share(), unshare(), clone() | Open or close the share link, or copy the production |
client.studio.productions
productions.skill()
Returns the authoring guide, the full catalog, the plan's JSON Schema and the operating guide, rendered from the version the server runs. It is free.
skill(): Promise<{ skill: string; catalog: string; schema: Record<string, unknown>; operating: string; generatedFrom: object }>const { skill, schema, operating } = await client.studio.productions.skill()operating lists the operations ops() accepts. Read it at run time instead of hard-coding the vocabulary.
productions.validatePlan(plan)
Checks a plan before it becomes a production. It is free, stores nothing, and resolves cast names against your library. Loop on errors until valid is true, then call create({ plan }).
validatePlan(plan: Record<string, unknown>): Promise<{
valid: boolean
errors: Array<{ path: string; message: string; hint?: string }>
warnings: Array<{ path: string; message: string; hint?: string }>
summary?: { name?: string; scenes: number; shots: number; cast: number; bound: number }
}>Prop
Type
const check = await client.studio.productions.validatePlan(plan)
if (!check.valid) console.log(check.errors)summary.bound counts the cast entries that matched a character in your library.
productions.list(opts?)
Lists your productions, newest first.
list(opts?: { limit?: number; cursor?: string; includeArchived?: boolean }): Promise<{ data: StudioProduction[]; nextCursor?: string }>Prop
Type
const { data: productions } = await client.studio.productions.list({ limit: 20 })productions.get(productionId, opts?)
Reads a production. It is a pure read and never lands a finished job, so call reconcile() first when you wait for one.
get(productionId: string, opts?: { detail?: "summary" | "full"; shotId?: string }): Promise<StudioProduction>Prop
Type
const production = await client.studio.productions.get(productionId, { detail: "full" })productions.exportPlan(productionId, opts?)
Returns the ordered steps that assemble the film, with their prices. It runs nothing and spends nothing: run the steps yourself with the ordinary node methods.
exportPlan(productionId: string, opts?: { upscale?: boolean }): Promise<{
canExport: boolean
steps: Array<{ id: string; label: string; node: string; creditModel: string; credits: number | null; params: Record<string, unknown> }>
resultStepId: string | null
estimate: number | null
unpriced: string[]
}>Prop
Type
const plan = await client.studio.productions.exportPlan(productionId)
console.log(plan.canExport, plan.estimate)
for (const step of plan.steps) console.log(step.id, step.label, step.node, step.credits)canExport is false when the production has fewer than two clips. estimate is null when any step has no price, because a partial sum would understate the cost, and unpriced names those steps' models. Each step's node is a node type, such as merge-video-audio, combine-videos or video-upscale.
productions.create(input?)
Creates a production, and optionally lands a plan in the same call.
create(input?: { name?: string; plan?: Record<string, unknown> }): Promise<{
production: StudioProduction
warnings?: Array<{ path: string; message: string; hint?: string }>
summary?: { shotsAdded: number; castEnrolled: number; castBound: number }
}>Prop
Type
const { production, summary } = await client.studio.productions.create({ name: "Rome chase", plan })productions.ops(productionId, input)
Applies a batch of operations to a production. Every change is an operation, addressed by a stable key, such as a shot id, a role slug or a result's job id, never by position.
ops(productionId: string, input: StudioOpsRequest): Promise<StudioOpsResponse>
ops(productionId: string, input: StudioOpsRequest & { dryRun: true }): Promise<StudioOpsDryRunResponse>Prop
Type
const result = await client.studio.productions.ops(productionId, {
ops: [/* operations from the operating guide */],
baseVersion: version,
clientRequestId: crypto.randomUUID(),
})
version = result.version // carry it forward as the next baseVersion
for (const r of result.receipts) console.log(r.summary)- Atomic. One bad operation refuses the whole batch with a
StudioOpErrorwhoseopIndexnames it, and nothing is written. - Rebased. Two people can edit one production at once. A batch composed against an older version still applies to the newest one, and
rebasedistrue. - Receipts.
receiptshas one past-tense line per operation, such as "Deleted take 2 of Shot 1 (in the bin)". Where an operation's effect reaches past what it names, itsimpactlists thekeyframeIdsandshotIdsto refresh. - Adopt the answer. Replace your copy with
productionand carryversionforward. Do not merge into your old copy.
Preview a batch. With dryRun: true, the answer says what the batch would do, so a person can approve an assistant's edits first. It has dryRun, version, receipts and warnings, and no production. Each receipt adds class: S safe, D deletes, P changes who can reach the work, $ spends credits. It also adds restorable, which is present only when the operation put something in the bin. Read it as restorable ?? false.
Write dryRun: true as a literal in the call's own object. Passed through a variable, it widens to boolean, and the call types as an apply while it still previews.
A preview sends two requests: first an empty batch that proves the deployment can preview, then your batch. A deployment that cannot preview would otherwise apply your batch without warning. Two errors can result:
import { StudioPreviewAppliedError, StudioPreviewUnavailable } from "@nodaro/sdk"
try {
const preview = await client.studio.productions.ops(productionId, { ops, baseVersion, dryRun: true })
for (const r of preview.receipts) console.log(r.class, r.summary, r.restorable ?? false)
} catch (err) {
if (err instanceof StudioPreviewUnavailable) {
// Nothing was sent. Say that no preview is available; do not apply the batch instead.
} else if (err instanceof StudioPreviewAppliedError) {
// The batch was applied. Adopt err.applied.production and err.applied.version.
// Do not send it again. When err.applied is undefined, read the production first.
} else {
throw err
}
}productions.reconcile(productionId)
Lands every generation that finished since you last looked, and reports what is still running. It is the one call that turns finished jobs into results without the app open, and it writes only when something landed.
reconcile(productionId: string): Promise<{
landed: string[]
pending: string[]
failed: string[]
warnings: string[]
production: StudioProduction
version: number
}>Prop
Type
const { landed, pending } = await client.studio.productions.reconcile(productionId)landed lists the jobs whose media is now on the production, pending the jobs still running, and failed the jobs that failed or were cancelled.
productions.importPlan(productionId, plan, opts?)
Adds a plan's scenes to an existing production.
importPlan(productionId: string, plan: Record<string, unknown>, opts?: { mode?: "append" }): Promise<{
production: StudioProduction
warnings?: Array<{ path: string; message: string; hint?: string }>
summary?: { shotsAdded: number; castEnrolled: number; castBound: number }
}>Prop
Type
await client.studio.productions.importPlan(productionId, extraScenesPlan)productions.describe(productionId, input)
Turns a brief into scenes with the Director. It starts a job and returns at once; the scenes land through reconcile(). The production comes back with the run recorded as a pending draft.
describe(productionId: string, input: {
brief: string
llmModel: string
mode?: "append" | "replace"
label?: string
clientRequestId?: string
}): Promise<{ production: StudioProduction; jobId: string }>Prop
Type
const { jobId } = await client.studio.productions.describe(productionId, {
brief: "A courier races across Rome in the rain to deliver a violin.",
llmModel,
})Generate stills and clips
generateStill() frames a shot, generateClip() animates it, and generate() does either by kind. A run submits the jobs, records a pending marker on the production, and returns: nothing waits for minutes. The request is built on the server from the shot's own plan, looks and references, so a script and a click in the app produce the same media.
generate(productionId: string, input: StudioGenerateRequest): Promise<StudioGenerateResult>
generateStill(productionId: string, shotId: string, opts?: StudioGenerateOptions): Promise<StudioGenerateResult>
generateClip(productionId: string, shotId: string, opts?: StudioGenerateOptions): Promise<StudioGenerateResult>Prop
Type
import { isStudioGenerateEstimate } from "@nodaro/sdk"
const quote = await client.studio.productions.generateStill(productionId, "shot-2", { count: 2, dryRun: true })
if (isStudioGenerateEstimate(quote)) console.log(quote.credits) // null means unpriced, not free
const run = await client.studio.productions.generateStill(productionId, "shot-2", {
count: 2,
clientRequestId: crypto.randomUUID(),
})- Quote first.
dryRun: trueprices the run and writes nothing. Narrow the answer withisStudioGenerateEstimate(). - Retry safely. With the same
clientRequestId, a retry answers with the jobs the first call started, markeddeduped: true, and submits and charges nothing. Never retry a paid call without one. Every paid call on this page accepts it,frame()andvoice()included. - The lane is chosen for you. For a clip, the video route is picked from the shot's inputs and returned as
lane:generate-videoortext-to-video.
productions.frame(productionId, input)
Takes a still from a shot's active clip and puts it where target says. It waits for the job, which takes seconds, and returns the changed production and the image url.
frame(productionId: string, input: {
shotId: string
mode?: "first" | "last" | "timestamp"
timestamp?: number
target?: "new-shot" | "start-frame" | "end-frame" | "still"
clientRequestId?: string
}): Promise<StudioMediaResponse>Prop
Type
const { url } = await client.studio.productions.frame(productionId, { shotId: "shot-2", mode: "last" })productions.voice(productionId, input)
Speaks a shot's line and records it on the shot. It waits for the job.
voice(productionId: string, input: {
shotId: string
text: string
voiceId?: string
voiceType?: "premade" | "custom" | "library"
ttsProvider?: string
delivery?: Record<string, number>
clientRequestId?: string
}): Promise<StudioMediaResponse>Prop
Type
await client.studio.productions.voice(productionId, { shotId: "shot-3", text: "We're out of time." })productions.revoice(productionId, input)
Recasts the voices of a shot's active clip. It takes minutes, so it returns a jobId, and the new clip lands through its marker.
revoice(productionId: string, input: { shotId: string; plan: Record<string, unknown>; clientRequestId?: string }): Promise<{ production: StudioProduction; jobId: string }>Prop
Type
const { jobId } = await client.studio.productions.revoice(productionId, {
shotId: "shot-3",
plan: recastPlan, // the speaker-ordered plan the voice recast route takes
})productions.music(productionId, input)
Scores the film. The finished track lands through its pending marker.
music(productionId: string, input: {
prompt: string
duration?: number
instrumental?: boolean
vocalGender?: string
model?: string
clientRequestId?: string
}): Promise<{ production: StudioProduction; jobId: string }>Prop
Type
const { jobId } = await client.studio.productions.music(productionId, {
prompt: "Tense strings building to a chase",
instrumental: true,
})Share and copy
share() opens the share-by-link view and unshare() closes it again. Sharing is its own call, never an operation, so who can see the work never changes as a side effect of an edit. clone() copies a production you own or can see into your own Studio project.
share(productionId: string): Promise<StudioProduction>
unshare(productionId: string): Promise<StudioProduction>
clone(productionId: string, input?: { name?: string }): Promise<StudioProduction>Prop
Type
await client.studio.productions.share(productionId)
const copy = await client.studio.productions.clone(productionId, { name: "Rome chase, take 2" })A copy starts private and visible: sharing and archiving never carry over. It is copied through your view of the source, so someone else's bin does not come with it.
client.studio: planned frames
These methods cover planned keyframes and their review. Check capabilities() before you offer a control, and call reconcile() once when you reopen a production, because a submission's answer may have been lost. Nothing here starts generation or accepts a candidate unless you call that method.
| Method | What it does |
|---|---|
capabilities() | Read the plan versions and the operations this deployment supports |
skill(), list(), validatePlan(), create() | The same reads and create as the productions layer, in the envelope |
get(id, options?) | Read a production with its capabilities |
edit(id, input) | Apply operations with revision conditions |
saveEditorState(id, input) | Save ordinary editor fields against the loaded revision |
generateKeyframe(id, input) | Generate a planned frame, without accepting it |
generateShot(id, input) | Quote or submit a still or a clip |
acceptKeyframe(id, review, concurrency?) | Accept a reviewed candidate |
reconcile(id) | Record finished jobs, without accepting anything |
setShared(id, input) | Share or unshare, bound to a reviewed revision |
clone(id, input?) | Copy a saved production |
importBundle(input) | Import a portable production |
appendBundle(id, input) | Append a bundle to a production |
studio.capabilities()
Returns the plan versions and which planned-frame operations this deployment supports.
capabilities(): Promise<{ data: StudioProductionCapabilities }>const { data: caps } = await client.studio.capabilities()
if (caps.operations.generateKeyframes) showGenerateFrameButton()operations has one flag per operation, such as readKeyframes, editKeyframes, generateKeyframes, acceptKeyframes, rejectKeyframes, editSequencePlans, generateLinkedClips, retakeLinkedClips, saveEditorState, revisionedSharing, editableSharedCopies, cloneLinkedProductions, importPlannedBundles, importLinkedBundles, appendPlannedBundles and appendLinkedBundles. automaticAcceptance and unattendedGeneration are always false.
studio.skill(), list(), validatePlan() and create()
The same calls as productions.skill(), list(), validatePlan() and create(), returned in the { data } envelope. In list(), the rows are in response.data.data.
skill(): Promise<{ data: Record<string, unknown> }>
list(options?: { limit?: number; cursor?: string; includeArchived?: boolean }): Promise<{ data: {
data: Array<{ id: string; name: string; version: number; updatedAt: string; thumbnailUrl: string | null; shared: boolean; archived: boolean; shotCount: number }>
nextCursor?: string
} }>
validatePlan(plan: Record<string, unknown>): Promise<{ data: { valid: boolean; errors: object[]; warnings: object[]; summary?: object } }>
create(input: { name?: string; plan?: Record<string, unknown> }): Promise<{ data: StudioProductionReply }>Prop
Type
const { data: page } = await client.studio.list({ limit: 20 })
for (const row of page.data) console.log(row.name, row.shotCount)studio.get(id, options?)
Reads a production with its capabilities. It never lands jobs.
get(id: string, options?: { detail?: "summary" | "full"; shotId?: string }): Promise<{ data: StudioProductionReply }>Prop
Type
const { data: { production } } = await client.studio.get(productionId, { detail: "full" })
const frame = production.keyframes?.[0] // { id, label, revision, previewUrl, acceptedUrl, pending, ... }studio.edit(id, input)
Applies operations with revision conditions (POST .../:id/ops), in the envelope. Use a strict baseVersion for remove_shot, restore_trashed and purge_trashed, and detach a bound sequence segment before you remove its scene.
edit(id: string, input: { ops: Array<{ op: string; [field: string]: unknown }>; baseVersion?: number; strict?: boolean; clientRequestId?: string }): Promise<{ data: StudioProductionReply & { version: number; rebased: boolean; receipts: object[] } }>Prop
Type
await client.studio.edit(productionId, {
ops: [{ op: "reject_keyframe_result", keyframeId, expectedRevision, resultKey, expectedAcceptedResultKey, reason: "Face drifted" }],
baseVersion: version,
strict: true,
})A few planned-frame operations sent through edit():
reject_keyframe_resultrecords Needs revision without generating. It needsoperations.rejectKeyframes.update_sequence_planedits a sequence's ordered segments, each{ shotId, startKeyframeId, endKeyframeId }, and keeps the scene ids. It needsoperations.editSequencePlans.detach_sequence_segmentmakes one segment independent, withmodeset toclearorkeep-accepted. It needsoperations.editSequencePlans.purge_trashedempties the bin entries you show, andclear_trashempties every bin, planned frames included.
studio.saveEditorState(id, input)
Saves ordinary editor fields against the revision you loaded. Check operations.saveEditorState first. The save is always strict: a conflict fails with a 409, so keep the local draft and reload before you resolve it.
saveEditorState(id: string, input: { expectedVersion: number; graph: object; clientRequestId?: string })Prop
Type
await client.studio.saveEditorState(productionId, { expectedVersion: version, graph })The save cannot change frame plans, acceptance, endpoint bindings, job history, protected bin entries or sharing. Use their own actions for those.
studio.generateKeyframe(id, input)
Generates a planned frame without accepting it. There is no dry run for frames.
generateKeyframe(id: string, input: { keyframeId: string; expectedRevision: number; clientRequestId?: string; overrides?: Record<string, unknown> }): Promise<{ data: { jobIds: string[]; deduped?: true; lane?: string } }>Prop
Type
const { data: caps } = await client.studio.capabilities()
const { data: { production } } = await client.studio.get(productionId, { detail: "full" })
const frame = production.keyframes?.[0]
if (frame && caps.operations.generateKeyframes) {
const { data: generation } = await client.studio.generateKeyframe(productionId, {
keyframeId: frame.id,
expectedRevision: frame.revision,
clientRequestId: crypto.randomUUID(),
})
// follow generation.jobIds with client.jobs, then call reconcile()
}Generating does not accept a candidate and does not create a character portrait. A cast reference that has only a description needs no portrait.
studio.generateShot(id, input)
Quotes or submits a still or a clip for a shot.
generateShot(id: string, input: StudioShotGenerationInput): Promise<{ data: StudioGenerationReply }>Prop
Type
const { data: quote } = await client.studio.generateShot(productionId, { kind: "clip", shotId, dryRun: true })
if ("inputHash" in quote) {
await client.studio.generateShot(productionId, {
kind: "clip",
shotId,
expectedInputHash: quote.inputHash,
clientRequestId: crypto.randomUUID(),
})
}Linked clips. A quote for a clip between planned frames includes inputHash, the accepted endpointPins, the normalized duration, resolution, aspect ratio and sound settings, and the creditIdentifier used for the price. Pass the reviewed inputHash as expectedInputHash. When the settings or the accepted frames changed since the quote, the call fails with 409 sequence_quote_changed before anything is submitted; ask for a new quote. The credits in a quote are an estimate: the generation reserves the current price.
Retakes. When operations.retakeLinkedClips is true, pass retakeResultKey with kind: "clip" and shotId, and quote it with dryRun: true. Submit with the reviewed expectedInputHash and a new clientRequestId, and leave out mode, overrides and count. A retake reuses the original request and the kept frame images, even after the plan or the acceptance changed. Earlier takes stay in the history. A take without a verifiable original request or kept images is refused, and a retake does not reproduce the same video bytes.
studio.acceptKeyframe(id, review, concurrency?)
Accepts a reviewed candidate for a planned frame. It is a separate, explicit step: generation never calls it.
acceptKeyframe(id: string, review: StudioKeyframeAcceptanceInput, concurrency?: { baseVersion?: number; strict?: boolean; clientRequestId?: string })Prop
Type
await client.studio.acceptKeyframe(productionId, {
keyframeId: frame.id,
expectedRevision: frame.revision,
resultKey,
expectedAcceptedResultKey: frame.acceptedResultKey,
requirementChecks, // one { requirementId, outcome } per requirement of the frame
})A conflict throws the usual error. The SDK never picks another result or retries against a newer revision on its own.
studio.reconcile(id)
Records finished jobs, without accepting any candidate and without starting generation. It also checks jobs of scenes in the bin: a finished clip stays in that scene's stored graph, and you recover it with restore_trashed.
reconcile(id: string): Promise<{ data: StudioProductionReply & { landed: string[]; pending: string[]; failed: string[]; version: number } }>Prop
Type
const { data } = await client.studio.reconcile(productionId)
console.log(data.landed, data.pending)studio.setShared(id, input)
Shares or unshares a production, bound to the revision you reviewed. Check operations.revisionedSharing and pass expectedVersion: a concurrent edit then fails with 409 workflow_conflict, and the SDK does not retry. Only callers allowed to change visibility can use it.
setShared(id: string, input: { shared: boolean; allowEditableCopy?: boolean; expectedVersion?: number }): Promise<{ data: StudioProductionReply }>Prop
Type
await client.studio.setShared(productionId, { shared: true, allowEditableCopy: true, expectedVersion: version })With allowEditableCopy, an owner or workspace admin lets link viewers copy the saved plan, prompts, cast descriptions, kept reference inputs and take history. The bin and private review notes are never included. Copies start private, with no frames accepted. Turning copying off, or unsharing, blocks new copies; copies already made stay independent.
studio.clone(id, input?)
Copies a saved production. Check operations.cloneLinkedProductions before you copy one with linked frames, and pass its loaded expectedVersion: a changed source fails with a 409.
clone(id: string, input?: { name?: string; projectId?: string; expectedVersion?: number }): Promise<{ data: StudioProductionReply }>Prop
Type
const { data: { production: copy } } = await client.studio.clone(productionId, {
name: "Rome chase copy",
expectedVersion: version,
})The copy starts private. It keeps the frame inputs, which count against your storage, gets new frame, scene and sequence ids, and carries no running jobs and no accepted frames. Review and accept its frames before you generate media that depends on them. Copying submits no generation.
studio.importBundle(input)
Imports a portable production as a new, private production with new scene, frame and sequence ids (POST .../import-bundle). Check operations.importPlannedBundles for recipes and plans without media, and importLinkedBundles for bundles with kept frame media.
importBundle(input: { bundle: Record<string, unknown>; projectId?: string }): Promise<{ data: StudioProductionReply }>Prop
Type
const { data: { production } } = await client.studio.importBundle({ bundle })A linked bundle names its source production; the server checks that you own it and verifies every kept image before it copies anything. Missing access or forged provenance refuses the import before the new production is created. Neither kind of import carries acceptance or running jobs, and neither generates media.
studio.appendBundle(id, input)
Appends a complete bundle to an editable production, with new ids and an exact revision check (POST .../:id/import-bundle). An OAuth token needs workflows:write, and you need edit access to the production. Check appendPlannedBundles or appendLinkedBundles first.
appendBundle(id: string, input: { bundle: Record<string, unknown>; expectedVersion: number; afterShotId?: string; applyFilm?: boolean }): Promise<{
data: { production: StudioProductionRecord; importedShotIds: string[]; importedKeyframeIds: string[] }
}>Prop
Type
const { data } = await client.studio.appendBundle(productionId, { bundle, expectedVersion: version })
console.log(data.importedShotIds)Existing scenes, frames, jobs, sharing and other settings stay; imported cast roles are merged. The imported frames need to be accepted again. An unknown afterShotId or a stale expectedVersion fails.
client.shots
Shot records behind /s/:id share links, for Share and Remix. A shot stores a builder's state: picker selections, prompts, target models, @ mention references and result URLs, under an unguessable 12-character id that is also the share key. Shots are private by default; sharing is a visibility change you make.
create(input?: CreateShotInput): Promise<{ id: string }>
get(id: string): Promise<{ shot: Shot }>
update(id: string, input: UpdateShotInput): Promise<{ shot: Shot }>
delete(id: string): Promise<void>Prop
Type
const { id } = await client.shots.create({ mode: "single", freeText: "A lighthouse in a storm", models: ["nano-banana-2"] })
await client.shots.update(id, { visibility: "public" }) // anyone with the id can now read it
const { shot } = await client.shots.get(id)A public shot is readable by anyone with its id. A private shot is readable only by its owner, and others get NotFoundError. Only the owner can update or delete a shot.
Frequently asked questions
Related
Studio productions
Studio productions
Errors
Recast
Jobs and executions
Last updated on
Community library
Browse the Nodaro community library from TypeScript, clone shared characters, locations and objects into your account, favorite them, and report a listing.
Recast
Quote, buy and follow Recast runs from TypeScript, answer their picks, import an authored script, and change the music mix of a finished recast.