Writing a plugin

A plugin is a JavaScript file that talks to jumpcut.GG through one frozen API called jc.v1. It runs sandboxed, with no app DOM, no globals and no network of its own, so jc.v1 is the whole of what it can do.

Hello world

(() => {
  const jc = window.jc && window.jc.v1;
  if (!jc) return;

  jc.addMenuItem("toolbar", {
    label: "+ Hello",
    onClick: async () => {
      const ref = await jc.addText("hello", { x: 0.3, y: 0.4, size: 0.06 });
      await jc.setTextStyle(ref, { color: "#ffd54a", weight: 800 });
      jc.status("added a layer");
    },
  });
})();

Every call is await-able. Each one crosses the sandbox boundary as a message, so treat them all as async.

Folder plugins

A single file is fine when all you have is code. A folder adds a manifest, translations and assets:

my-plugin/
  plugin.json      name, id, version, declared hosts
  main.js          the entry point
  lang/de.json     one file per language you translate into
  lang/_dynamic.json   keys you pass to t() through a variable
{
  "id": "cards.skelly.gg",
  "name": "skelly.cards",
  "description": "Look up trading cards and put them in the scene.",
  "author": "skelly.cards",
  "version": "1.2.0",
  "api": 1,
  "minApp": "1.2.0",
  "hosts": ["api.scryfall.com", "cards.scryfall.io"]
}

Everything you own is filed under id: layer claims, stored settings, per-project data. Declare one (reverse-DNS shape, a dot is required) and you can rename the plugin later without losing a user's work. Without one the file name is used, and renaming the file loses all of it.

Your description is translated by your own lang/ files, with the English text as the key. It is the only string someone reads before enabling you.

Reading the project

await jc.canvas()        // {w, h}
await jc.texts()         // [{ref, text}]
await jc.images()        // [{ref, name, x, y, w, start, end}]
await jc.clips()         // [{start, end, title}]
await jc.transcript()    // {segments: [{start, end, text, words}]}
await jc.sourceId()      // which recording is open

The two clocks

The most common plugin bug is reading one and writing the other.

They are different numbers. While the user works in the Composer the source clock often sits at 0:00, so a plugin that times a layer from it stacks everything at the start. Cross between them:

const sceneAt  = await jc.sceneTimeOf(sourceSeconds); // null if the scene omits it
const sourceAt = await jc.sourceTimeAt(sceneSeconds); // null outside the cut

A scene cut from a two-hour recording contains almost none of it, so null is the ordinary answer rather than the error case. Store moments in source seconds with the sourceId() they belong to, and convert on the way out. A re-cut then moves your display without touching your data.

Making and changing layers

const t = await jc.addText("words", { x, y, size, above });
const i = await jc.addImage(url, { x, y, w, name, like, above });

await jc.setText(ref, "new words");        // content only; styling stays the user's
await jc.setImage(ref, url, { name });     // swaps the file, keeps the transform
await jc.setTransform(ref, { x, y, rot, opacity, z, w });
await jc.setTextStyle(ref, { size, color, font, weight, outline, shadow, lineHeight });
await jc.setTiming(ref, { start, end, fadeIn, fadeOut });   // scene seconds; end 0 = to the end
await jc.setAnim(ref, { x: [{t, v, ease}, …] });
await jc.remove(ref);

Positions are canvas fractions, so the same numbers hold at any output resolution. Text size is a fraction of canvas height.

Give a text layer bounds and it wraps itself. Never insert line breaks by hand, or the user cannot reflow what you wrote:

await jc.setTransform(textRef, { w: 0.42 });   // 0 clears the bounds

Smart objects

Claim a layer and it stays yours across saves, reloads and undo:

const id = await jc.layers.claim(ref, {
  kind: "card",
  data: { name: "Black Lotus", label: "Black Lotus", laneColor: "#b57cf2", group: "card:1" },
});
await jc.layers.find({ kind: "card" });   // only ever YOUR layers
await jc.layers.get(id);                  // the whole state of one
await jc.layers.getData(id);              // just your payload
await jc.selection();                     // {kind, ref, id, mine, pluginKind, data}

Three keys in data the app itself reads:

A claimed layer shows a ◈ naming you. That is how the user knows it is rebuilt rather than hand-edited.

Content over time

One layer can show different things at different moments: a card that changes partway through, a total that ticks down. Use segments for that, not a layer per appearance.

await jc.setSegments(ref, [
  { at: 0,  file: "a.png", label: "Black Lotus", exit:  { type: "flip", dur: 0.22 } },
  { at: 8,  file: "b.png", label: "Rancor",      enter: { type: "flip", dur: 0.22 } },
  { at: 20, blank: true },                       // a gap: nothing on screen
]);
await jc.moveSegments(ref, [0, 6, 18]);          // only the times changed
await jc.segments(ref);                          // [] when it has none

Clips, markers, transcript

await jc.clips.add({ start, end, title, excerpt });  // an ordinary manual clip
await jc.clips.clear();                             // only clips YOU added
await jc.markers.add(12.5);                         // or an array
await jc.transcriptMarks.set([{ start, end, color: "#ffd54a", label: "…" }]);

Transcript marks are data, not DOM. The transcript is virtualised, so you name the stretches and the app paints them as rows scroll into view. The same marks burn into the captions.

Right-click actions get the row and the selection:

jc.addTranscriptAction({ label: "Mark as card", onClick: (d) => {
  // d = {index, start, end, text, speaker, selection}
  // d.selection is {start, end, text} when words are selected, else null
} });

Your panel

jc.setPanel({
  title: "My plugin",
  width: 320, height: 420,
  render(root) { root.appendChild(jc.ui.button("Do it", run)); },
});
jc.showPanel();  jc.closePanel();  jc.panelOpen();  jc.dockPanel(true);
jc.addMenuItem("toolbar", { label: "My plugin", onClick: () => jc.showPanel() });

Inside your panel the DOM is yours, and jc.ui.button/input/row build the app's real components. Docking is the app's call: it grants the rail while one of your own layers is selected.

Remembering things

await jc.prefs.get();  await jc.prefs.set({ … });          // yours everywhere
await jc.projectData.get();  await jc.projectData.set({ … }); // yours HERE, in the project file

Both are namespaced to you and invisible to other plugins. projectData is saved in the project file and tracked by undo, so it moves with the edit and rewinds with it. Spread what is already there rather than replacing it: set({ ...raw, mine: next }).

Network

await jc.fetchJson(url);   // parsed JSON, through the app
await jc.thumb(url);       // a data: URL you can put in an <img>
await jc.fetchImage(url);  // fetched into the project cache, returns the file name

Your frame cannot originate a request. These go through the app, and the first time you reach a host it did not ship with, the user is asked, by host and for your plugin only. Declare the hosts you need in plugin.json. Grants are per plugin: two plugins reaching the same API are two separate permissions.

Speaking the user's language

jc.strings({ de: { "Insert": "Einfügen" }, ja: { "Insert": "挿入" } });
jc.t("Insert");
jc.t("added {name}", { name: card.name });
await jc.locale();   // "de", "pt-BR", …

The English string is the key. Keep {tokens} intact in every translation; a dropped token puts a literal brace on screen. Never build a sentence by joining pieces. Translate the whole thing and substitute.

Traps

Each one has a symptom that points somewhere else.

Reading the wrong clock sourceTime() looks like "the playhead" and is wrong for anything you place in the scene. Symptom: everything lands at 0:00, or nothing appears to happen. See the two clocks.
Rebuilding a list instead of carrying it forward setSegments replaces the whole list, and a piece holds more than its content: its transition, its label. Re-timing by hand with {at, text} drops the rest, and the symptom is a transition vanishing from a layer nobody edited. Use moveSegments when only the times change.
Assuming a moment belongs to the project A source second means nothing without the recording it came from. Store sourceId() alongside it, or your work lands on the wrong footage the moment the user switches sources.
Keys the checker cannot see jc.t(someVariable) is invisible to a static scan, so a string can ship English in every language while coverage reads 100%. List those keys in lang/_dynamic.json.
Hand-editing what you rebuild If your object is derived from stored state, a user's edit to it is replaced on your next rebuild. The ◈ marker warns them, but decide which layers are yours to own and leave the rest alone.

Limits and denials

The plugins folder ships a README.txt with the same reference in plain text, and the Plugins window opens it. Both bundled plugins are meant to be read: skelly.cards is a card lookup with panels, network and smart objects; dead air marks the silent stretches on the ruler in 193 lines.