Skip to content
Moku
@moku-labs/ai pages

asset

Task plugin for provider assets. Registers a portrait with a provider once and returns an opaque AssetRecord, with the one-off facade app.asset.*.

On this page

What it does

Some video providers refuse a plain photo of a real face. They take the same face when it is registered first, as an asset in the provider's own account, and the video request names the asset id instead of sending the image. asset is the task plugin for that registration step.

It owns the contract every asset provider implements: AssetRequest, AssetRecord, AssetResult, AssetJobPoll and AssetHandler. It exposes a small typed facade, app.asset.*.

Providers that serve this task today:

ProviderPluginNotes
arkarkNeeds the public https url. Estimate is 0.

How it works

Registration is its own step. An asset item runs before any video item that uses it. It is not a retry after a refusal: no provider registers a face on the fly, and no provider falls back to sending the raw photo.

The runnerHow
Orders itA video item $refs the asset item, so the asset runs first.
Caches itThe done asset is an artifact like any other. A second run, or a second clip with the same face, reuses it at no cost.
Journals itRegistration is async upstream: submit, then poll until the asset is active. The job id is journaled, so a crash resumes by polling, never by registering twice.
Blocks earlyA refused portrait flags the asset item. Every item that $refs it is never dispatched. Nothing is paid for a clip that would be refused.

The stored record has the MIME type ASSET_MIME, application/vnd.moku.asset+json. When the runner resolves a $ref to a done asset item, the video handler gets a file with this MIME type. That is how a video provider tells an asset from an image. A .json $file stays application/json: it never becomes an asset. On export the record is written as <label>.json.

There is no execute. Every provider implements submit + poll. A refused registration is a failed poll, or a throw, whose error carries kind: "content-policy".

The plugin is stateless. No lifecycle hooks, no events.

Config

pluginConfigs.asset.

OptionTypeDefaultDescription
defaultProviderstring"ark"Provider the facade uses when a call names none.
pollIntervalMsnumber3000Delay between polls in the facade's submit/poll loop, ms. Ark's guidance is to poll every 3 s.
import { createApp } from "@moku-labs/ai";

const app = createApp({
  pluginConfigs: { asset: { defaultProvider: "ark", pollIntervalMs: 3000 } }
});

API

Mounted at app.asset, reachable with ctx.require(assetPlugin).

MethodSignatureDescription
register(request: AssetRequest, opts?: { provider?: string; signal?: AbortSignal }) => Promise<AssetRecord>One-off registration. Calls submit once, then poll every config.pollIntervalMs. Direct: not journaled and not cached.
estimate(request: AssetRequest, opts?: { provider?: string }) => { usd: number }Cost estimate from the provider handler. Ark returns 0.
providers() => string[]Registered asset providers, in registration order.

register parses a done poll's body with parseAssetRecord and returns the record. A failed poll throws its error unchanged. An abort during the wait rejects at once with the signal's reason.

ConditionError
Unknown provider, or a registered value that fails the shape check[ai] No asset provider named "<name>" is registered. then Available: <comma list or "none">.
Stored bytes are not a record[ai] Not an asset record: <reason>. then Expected JSON with assetId, provider, account, groupId, registeredAt.
Provider refused the portraitThe provider's flagged error, kind: "content-policy". In a run the item is flagged.

AssetRequest

FieldTypeRequiredDescription
imageAssetFileyesThe portrait file. Its sha256 hash is part of the artifact key, so the same bytes register once.
urlstringnoPublic https URL of the same bytes, for providers whose register API only fetches URLs. Ark needs it and fails before any call without it.
group"aigc"noGroup kind. Default "aigc", the only kind today.
namestringnoDisplay name sent to the provider, at most 64 characters. Default: the file's base name.
groupNamestringnoAsset group name, 1 to 64 characters. Default: the provider's configured group name.
paramsRecord<string, unknown>noProvider params. generation (a number) is the re-register knob.

AssetFile is { path: string; mimeType: string; hash: string }, the shape of the runner's resolved file.

AssetRecord

FieldTypeDescription
assetIdstringProvider asset id. Ark sends it as asset://<assetId>.
providerstringProvider that registered it, e.g. "ark".
accountstringProvider-computed, one-way account fingerprint. The id is valid only in this account.
groupIdstringProvider group the asset lives in.
registeredAtnumberEpoch ms when the provider reported the asset active.

Package exports

ExportDescription
ASSET_MIME"application/vnd.moku.asset+json".
encodeAssetRecord(record)UTF-8 JSON with a stable key order. Drops extra keys.
parseAssetRecord(bytes)Reads it back and checks every field. Throws [ai] Not an asset record: <reason>.

Example

The durable path is a build file. Register the portrait in an asset item, then $ref it from the video items:

mira.moku.yaml
version: 1
name: mira
items:
  - id: face-mira
    task: asset
    provider: ark
    input:
      image: { $file: faces/mira.png }
      url: "https://cdn.example/faces/mira.png"
  - id: clip-01
    task: video
    provider: ark
    input:
      model: dreamina-seedance-2-5-260628
      prompt: "image 1 walks into the rain"
      refs: [{ $ref: face-mira }]
      seconds: 10
      resolution: 720p
      aspect: "9:16"
  • face-mira runs first. Its artifact is the AssetRecord JSON.
  • clip-01 gets a ref with mimeType === ASSET_MIME. The video provider reads the record and sends the asset id, not the photo.
  • A second run reuses both items at no cost. So does another build file with the same face and URL.
  • If the provider refuses the portrait, face-mira is flagged and clip-01 stays queued. The runner logs runner:blocked.
  • A video provider that cannot use assets refuses the ref before any upload. fal does this.

Register again

The artifact key covers the image hash, url, name, group, groupName and params. Unchanged input means the stored record is reused, even when the provider has since deleted the asset. To register the same portrait again, bump params.generation. params sits next to input in a build item:

mira.moku.yaml
  - id: face-mira
    task: asset
    provider: ark
    input:
      image: { $file: faces/mira.png }
      url: "https://cdn.example/faces/mira.png"
    params: { generation: 2 }

The new key misses the cache, so the item registers again. Every video item that $refs it gets a new key too, so those clips render again with the new asset.

The facade is the direct path. Nothing is journaled and nothing is cached:

register.ts
import { app } from "./app";

await app.start();

const image = { path: "faces/mira.png", mimeType: "image/png", hash: "a".repeat(64) };

console.log(app.asset.estimate({ image })); // { usd: 0 }

const record = await app.asset.register({ image, url: "https://cdn.example/faces/mira.png" }, { provider: "ark" });
console.log(record.assetId, record.account, record.registeredAt);

console.log(app.asset.providers()); // ["ark"]
await app.stop();

A custom provider registers an AssetHandler under "asset":

acme-assets.ts
import { ASSET_MIME, createPlugin, encodeAssetRecord, registryPlugin } from "@moku-labs/ai";
import type { Asset } from "@moku-labs/ai";

declare function acmeUpload(path: string): Promise<string>;
declare function acmeStatus(jobId: string): Promise<{ ready: boolean; id: string }>;
declare function acmeAccount(): string;

const handler: Asset.AssetHandler = {
  estimate: () => ({ usd: 0 }),
  submit: async request => ({ jobId: await acmeUpload(request.image.path) }),
  poll: async jobId => {
    const asset = await acmeStatus(jobId);
    if (!asset.ready) return { state: "pending" };
    const record = { assetId: asset.id, provider: "acme", account: acmeAccount(), groupId: "default", registeredAt: Date.now() };
    return { state: "done", body: encodeAssetRecord(record), mimeType: ASSET_MIME, costUsd: 0 };
  }
};

export const acmeAssetsPlugin = createPlugin("acmeAssets", {
  depends: [registryPlugin],
  onInit: ctx => {
    ctx.require(registryPlugin).register("asset", "acme", handler);
  }
});
  • register is not journaled and not cached. Each call registers again. Prefer an asset item in a build file.
  • Ark needs url. Its register API fetches URLs only. Without url the handler fails before any call.
  • The record is account-bound. account is a fingerprint of the provider account. The asset id is valid there only.
  • params.generation is the only way to re-register. An unchanged input reuses the stored record even when the provider no longer has the asset.

Events

Emits

None.

Listens to

None.

Depends on

  • registry: resolve("asset", provider) and providers("asset").
  • video: the task that uses the asset through refs.
  • runner: the durable path. It stores the done body under ASSET_MIME.
  • ark: the provider.