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.
- Drop a
.jsfile (or a folder) into the plugins folder. The Plugins window has a button that opens it. - Every plugin starts disabled. Putting a file there grants nothing; the user flips a toggle.
- It runs in an iframe with an opaque origin. It cannot read the app's cookies, storage, DOM, or reach the network directly.
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.
sourceTime(): the recording's own clock, the strip above the transcript. Transcript times are in it.sceneTime(): the composition. Every layer'sstart/endis in this one.
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:
label: what the timeline calls the layer. Without it a text layer is named after its own text, so a life total becomes a lane called "40" that renames itself on every change.laneColor:#rrggbbfor its row, so two of your objects do not read as one.group: layers sharing this string are one object. Selecting any of them selects all, so they drag, move and delete together. Use it when one object needs several layers, like a name in one font above a number in another.
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
atis scene seconds. The first piece is the layer's start.blankshows nothing until the next piece, so an object can drop out and come back.enter/exitis the transition at a switch. The outgoing piece's exit meets the incoming piece's enter, so the two never overlap. Types:fade,pop,spin,slam,punch,slideU/D/L/R, andflip(images only; text is drawn by libass, not a scale filter).- A segment names a cached file, never a URL. Fetch first:
await jc.fetchImage(url)returns the file name.
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.
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.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.sourceId() alongside it, or your work lands on the wrong footage the
moment the user switches sources.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.Limits and denials
- 8KB of data per claimed layer. It is a reference budget, not a cache: store an id and resolve it, rather than keeping the object.
- No app DOM, globals, cookies or storage.
localStorageanddocument.cookiethrow;parentis unreachable; there is noeval. - No direct network. Images from a remote host are blocked in your frame;
thumbnails arrive as
data:URLs through the app. - Plugins cannot share code. Two plugins by the same author are two separate programs with separate storage and separate host grants.
- A locked layer refuses everything with a 423, including a claim.
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.