mirror of
https://github.com/mvschwarz/openrig-studio-apps.git
synced 2026-11-07 05:56:21 -10:00
apps: restore declared media and timeline capabilities
This commit is contained in:
1 parent
78c41495e5
commit
edc530be1c
7 files changed
+235
-74
No files matched your search
@@ -44,6 +44,15 @@ say this. **If there is a field for it, nobody inlines it.**
|
||||
A manifest authored on a machine that already has everything installed will
|
||||
always under-declare. Test one by installing it somewhere that has nothing.
|
||||
|
||||
The video provider's app-contract and media-root regressions run after its
|
||||
dependencies are installed:
|
||||
|
||||
```sh
|
||||
cd providers/studio-video
|
||||
npm ci
|
||||
npm test
|
||||
```
|
||||
|
||||
## Provenance
|
||||
|
||||
Ported from the frozen `studio-box` spike, then de-bespoked: the original
|
||||
|
||||
+19
-1
@@ -59,9 +59,27 @@
|
||||
"/api/slot-history",
|
||||
"/api/history-since",
|
||||
"/api/project-assets",
|
||||
"/api/project-assets/rebuild",
|
||||
"/api/project-info",
|
||||
"/api/probe-duration",
|
||||
"/api/annotation",
|
||||
"/api/focus"
|
||||
"/api/approve",
|
||||
"/api/export-final",
|
||||
"/api/export-review",
|
||||
"/api/export-status/",
|
||||
"/api/focus",
|
||||
"/api/health",
|
||||
"/api/library",
|
||||
"/api/library/media",
|
||||
"/api/library/route",
|
||||
"/api/media",
|
||||
"/api/reconform",
|
||||
"/api/tags",
|
||||
"/api/templates",
|
||||
"/api/thumb",
|
||||
"/api/timeline/doc",
|
||||
"/api/validate-audio",
|
||||
"/api/validate-export"
|
||||
],
|
||||
"vendor": [],
|
||||
"requires": {
|
||||
|
||||
@@ -2104,18 +2104,19 @@
|
||||
<div id="nleNoteOverlay" class="nle-overlay" hidden>
|
||||
<div class="nle-note-panel">
|
||||
<h3>Review note <i id="nleNoteContext"></i></h3>
|
||||
<p class="nle-safe-note">Review handoff lives in AGENTS. MINI-NLE does not inject work into a rig.</p>
|
||||
<div class="nle-note-row">
|
||||
kind <select id="nleNoteKind"><option>general</option><option>regen</option><option>timing</option><option>audio</option><option>other</option></select>
|
||||
to <select id="nleNoteReceiver"></select>
|
||||
<input id="nleNoteNewReceiver" class="grow" type="text" placeholder="or type a session@rig (persists)">
|
||||
kind <select id="nleNoteKind" disabled><option>general</option><option>regen</option><option>timing</option><option>audio</option><option>other</option></select>
|
||||
to <select id="nleNoteReceiver" disabled><option>Use AGENTS</option></select>
|
||||
<input id="nleNoteNewReceiver" class="grow" type="text" placeholder="Use AGENTS for review handoff" disabled>
|
||||
</div>
|
||||
<textarea id="nleNoteText" placeholder="What should change, and why it matters at this moment"></textarea>
|
||||
<textarea id="nleNoteText" placeholder="Use the AGENTS surface to hand review work to a seat" disabled></textarea>
|
||||
<div class="nle-note-row">
|
||||
<button id="nleNoteAnnotate" type="button">Annotate frame...</button>
|
||||
<button id="nleNoteAnnotate" type="button" disabled>Annotate frame...</button>
|
||||
<span id="nleNoteAnnotChip" class="nle-annot-chip"></span>
|
||||
<span class="grow"></span>
|
||||
<button id="nleNoteSave" type="button" class="m-ib primary">Save draft</button>
|
||||
<button id="nleNoteSend" type="button">Send all</button>
|
||||
<button id="nleNoteSave" type="button" class="m-ib primary" disabled>Use AGENTS</button>
|
||||
<button id="nleNoteSend" type="button" disabled>Use AGENTS</button>
|
||||
<button id="nleNoteCancel" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5203,16 +5204,9 @@
|
||||
|
||||
async function nleLoadReceivers() {
|
||||
const note = nleNoteState();
|
||||
try {
|
||||
const payload = await callExportApi("/api/receivers");
|
||||
nleEls.nleNoteReceiver.innerHTML = (payload.receivers || []).map((receiver) =>
|
||||
`<option value="${escapeHtml(receiver)}">${escapeHtml(receiver)}</option>`).join("");
|
||||
note.lanes = payload.lanes || null;
|
||||
note.receiversLoaded = true;
|
||||
} catch {
|
||||
nleEls.nleNoteReceiver.innerHTML = '<option value="">(no receiver configured)</option>';
|
||||
}
|
||||
nleApplyNoteLaneDefault();
|
||||
nleEls.nleNoteReceiver.innerHTML = '<option value="">Use AGENTS for review handoff</option>';
|
||||
note.lanes = null;
|
||||
note.receiversLoaded = true;
|
||||
}
|
||||
|
||||
function nleOpenNoteForm({ slot, timestamp, lane }) {
|
||||
@@ -5306,16 +5300,10 @@
|
||||
}
|
||||
|
||||
async function nleLoadFeedbackDrafts({ silent = true, rerenderStrip = true } = {}) {
|
||||
try {
|
||||
const payload = await callExportApi("/api/note");
|
||||
nle.feedbackDrafts = (payload.drafts || []).filter((note) => note.status === "draft");
|
||||
nleUpdateFeedbackSurfaces({ rerenderStrip });
|
||||
return nle.feedbackDrafts;
|
||||
} catch (error) {
|
||||
if (!silent) nlePatchStatusShow(`Feedback drafts unavailable: ${error.message}`, "warn");
|
||||
window.__nleFeedbackDrafts = nle.feedbackDrafts || [];
|
||||
return nle.feedbackDrafts || [];
|
||||
}
|
||||
nle.feedbackDrafts = [];
|
||||
nleUpdateFeedbackSurfaces({ rerenderStrip });
|
||||
if (!silent) nlePatchStatusShow("Review handoff lives in AGENTS.", "warn");
|
||||
return nle.feedbackDrafts;
|
||||
}
|
||||
|
||||
function nleSelectedFeedbackReceiver() {
|
||||
@@ -5323,23 +5311,7 @@
|
||||
}
|
||||
|
||||
async function nleSaveFeedbackDraft({ slot, timestamp, kind, text, receiver, annotation }) {
|
||||
const cleanText = String(text || "").trim();
|
||||
if (!cleanText) throw new Error("Feedback text is required.");
|
||||
const payload = await callExportApi("/api/note", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
slot,
|
||||
timestamp,
|
||||
kind: kind || "general",
|
||||
text: cleanText,
|
||||
receiver,
|
||||
annotation,
|
||||
status: "draft",
|
||||
}),
|
||||
});
|
||||
await nleLoadFeedbackDrafts({ silent: true });
|
||||
return payload;
|
||||
throw new Error("Review handoff lives in AGENTS; MINI-NLE does not inject rig work.");
|
||||
}
|
||||
|
||||
async function nleSaveNoteDraft() {
|
||||
@@ -5364,16 +5336,7 @@
|
||||
}
|
||||
|
||||
async function nleFlushFeedbackDrafts(receiver = "") {
|
||||
const useReceiver = String(receiver || "").trim() || nleSelectedFeedbackReceiver();
|
||||
const payload = await callExportApi("/api/note/flush", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ receiver: useReceiver }),
|
||||
});
|
||||
window.__nleLastFeedbackFlush = payload;
|
||||
await nleLoadFeedbackDrafts({ silent: true });
|
||||
nlePatchStatusShow(`Sent ${payload.draftCount} feedback draft(s) to ${payload.receiver}${payload.qitemId ? ` (${payload.qitemId})` : ""}.`, "ok");
|
||||
return payload;
|
||||
throw new Error("Review handoff lives in AGENTS; MINI-NLE does not inject rig work.");
|
||||
}
|
||||
|
||||
async function nleSendNote() {
|
||||
@@ -5763,14 +5726,7 @@
|
||||
try { window.parent?.postMessage({ t: "open-agent" }, "*"); } catch {}
|
||||
});
|
||||
body.querySelector("[data-cu-dismiss]").addEventListener("click", async () => {
|
||||
for (const note of notes) {
|
||||
try {
|
||||
await callExportApi("/api/note/done", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: note.id, done: true }) });
|
||||
} catch (error) { nleEditStatusShow(`Dismiss failed: ${error.message}`, "error"); return; }
|
||||
}
|
||||
await nleLoadFeedbackDrafts({ silent: true, rerenderStrip: false });
|
||||
nle.catchup.asked = (nle.feedbackDrafts || []).filter((note) => note.slot);
|
||||
nleRenderCatchup();
|
||||
nleEditStatusShow("Review handoff lives in AGENTS; MINI-NLE does not close rig work.", "warn");
|
||||
});
|
||||
wrap.appendChild(node);
|
||||
}
|
||||
@@ -6235,7 +6191,6 @@
|
||||
<div class="p">${escapeHtml(asset.path)}</div>
|
||||
<div class="nle-fields">
|
||||
<button type="button" id="nleCopyPath">Copy path</button>
|
||||
<button type="button" id="nleReveal">Reveal in Finder</button>
|
||||
${nle.swapTarget && (asset.media_type === "video" || asset.media_type === "image") ? `<button type="button" id="nleUseForSwap" class="primary">Use for ${escapeHtml(nle.swapTarget)}</button>` : ""}
|
||||
</div>`;
|
||||
const useForSwap = document.getElementById("nleUseForSwap");
|
||||
@@ -6284,14 +6239,6 @@
|
||||
await navigator.clipboard.writeText(asset.path);
|
||||
nlePatchStatusShow(`Copied path: ${asset.path}`, "ok");
|
||||
});
|
||||
document.getElementById("nleReveal").addEventListener("click", async () => {
|
||||
try {
|
||||
await callExportApi("/api/reveal-file", { method: "POST", body: JSON.stringify({ path: asset.path }), headers: { "content-type": "application/json" } });
|
||||
nlePatchStatusShow(`Revealed in Finder: ${asset.path}`, "ok");
|
||||
} catch (error) {
|
||||
nlePatchStatusShow(error.message, "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function nleRelativeRef(asset) {
|
||||
|
||||
@@ -760,8 +760,8 @@ async function handleNleApi(req, res, url) {
|
||||
assets.push({ name, path: full, root: rootLabel, kind, size: stat.size, mtime: stat.mtimeMs, meta });
|
||||
}
|
||||
};
|
||||
for (const root of roots) walk(path.join(root, "library"), path.basename(root), 0);
|
||||
return sendJson(res, 200, { ok: true, assets, roots, note: "library/ subtrees of the configured media roots; <file>.meta.json sidecars are informational (license/credit/source) - never gates" });
|
||||
for (const root of roots) walk(root, path.basename(root), 0);
|
||||
return sendJson(res, 200, { ok: true, assets, roots, note: "configured media roots are scanned directly; <file>.meta.json sidecars are informational (license/credit/source) - never gates" });
|
||||
}
|
||||
if (url.pathname === "/api/library/media" && req.method === "GET") {
|
||||
// roots-only gate for library previews (library files are not in the
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"type": "module",
|
||||
"description": "The video/timeline provider for OpenRig Studio surfaces. Serves the verbs mini-nle, media-manager and canvas call. Ported from studio-box; not extracted, not rewritten.",
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"test": "node --test ../../test/mini-nle-contract.test.mjs ../../test/media-library.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const SERVER = path.join(REPO, "providers", "studio-video", "export-server.mjs");
|
||||
|
||||
const freePort = () => new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address();
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
|
||||
async function startProvider({ projectRoot, mediaRoot }) {
|
||||
const port = await freePort();
|
||||
const proc = spawn(process.execPath, [
|
||||
SERVER,
|
||||
"--port", String(port),
|
||||
"--slice-root", projectRoot,
|
||||
"--media-root", mediaRoot,
|
||||
], { cwd: REPO, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let log = "";
|
||||
proc.stdout.on("data", (chunk) => { log += chunk; });
|
||||
proc.stderr.on("data", (chunk) => { log += chunk; });
|
||||
|
||||
const endpoint = `http://127.0.0.1:${port}/api/library`;
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (proc.exitCode != null) throw new Error(`provider exited ${proc.exitCode}:\n${log}`);
|
||||
try {
|
||||
const response = await fetch(endpoint);
|
||||
if (response.ok) return { proc, endpoint, log: () => log };
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 75));
|
||||
}
|
||||
proc.kill();
|
||||
throw new Error(`provider did not start:\n${log}`);
|
||||
}
|
||||
|
||||
test("the library walks the media root the install actually declared", async (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "studio-media-root-"));
|
||||
const projectRoot = path.join(tmp, "project");
|
||||
const mediaRoot = path.join(tmp, "media");
|
||||
const outsideRoot = path.join(tmp, "outside");
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
fs.mkdirSync(path.join(mediaRoot, "library"), { recursive: true });
|
||||
fs.mkdirSync(path.join(mediaRoot, "footage"), { recursive: true });
|
||||
fs.mkdirSync(outsideRoot, { recursive: true });
|
||||
|
||||
const control = path.join(mediaRoot, "library", "known.mp4");
|
||||
const expected = path.join(mediaRoot, "footage", "expected.mp4");
|
||||
const outside = path.join(outsideRoot, "must-not-appear.mp4");
|
||||
fs.writeFileSync(control, "positive control");
|
||||
fs.writeFileSync(expected, "declared-root media");
|
||||
fs.writeFileSync(outside, "outside binding");
|
||||
|
||||
const provider = await startProvider({ projectRoot, mediaRoot });
|
||||
t.after(() => { provider.proc.kill(); fs.rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
const payload = await (await fetch(provider.endpoint)).json();
|
||||
assert.equal(payload.ok, true, JSON.stringify(payload));
|
||||
const paths = payload.assets.map((asset) => asset.path).sort();
|
||||
const realControl = fs.realpathSync(control);
|
||||
const realExpected = fs.realpathSync(expected);
|
||||
const realOutside = fs.realpathSync(outside);
|
||||
assert.ok(paths.includes(realControl), `positive control missing: the harness did not see root/library; payload=${JSON.stringify(payload)}`);
|
||||
assert.ok(paths.includes(realExpected), "media under the declared root's footage/ subtree was silently narrowed out");
|
||||
assert.ok(!paths.includes(realOutside), "provider escaped the declared media root");
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const MANIFEST = path.join(REPO, "apps", "mini-nle", "app.json");
|
||||
const SURFACE = path.join(REPO, "apps", "mini-nle", "app", "mini-nle.html");
|
||||
|
||||
const forbidden = [
|
||||
"/api/note",
|
||||
"/api/note/done",
|
||||
"/api/note/flush",
|
||||
"/api/reveal-file",
|
||||
];
|
||||
|
||||
// Exact routes the surface legitimately uses. Parameterized export-status is
|
||||
// asserted separately once the SDK's approved prefix syntax lands.
|
||||
const requiredExact = [
|
||||
"/api/annotation",
|
||||
"/api/approve",
|
||||
"/api/export-final",
|
||||
"/api/export-review",
|
||||
"/api/focus",
|
||||
"/api/health",
|
||||
"/api/history-since",
|
||||
"/api/library",
|
||||
"/api/library/media",
|
||||
"/api/library/route",
|
||||
"/api/media",
|
||||
"/api/patch",
|
||||
"/api/patch/apply",
|
||||
"/api/patch/discard",
|
||||
"/api/patch/validate",
|
||||
"/api/probe-duration",
|
||||
"/api/project-assets",
|
||||
"/api/project-assets/rebuild",
|
||||
"/api/project-info",
|
||||
"/api/reconform",
|
||||
"/api/slot-history",
|
||||
"/api/tags",
|
||||
"/api/templates",
|
||||
"/api/thumb",
|
||||
"/api/timeline/doc",
|
||||
"/api/timeline/select",
|
||||
"/api/timelines",
|
||||
"/api/validate-audio",
|
||||
"/api/validate-export",
|
||||
];
|
||||
|
||||
const requiredPrefixes = ["/api/export-status/"];
|
||||
|
||||
function readContract() {
|
||||
return {
|
||||
manifest: JSON.parse(fs.readFileSync(MANIFEST, "utf8")),
|
||||
surface: fs.readFileSync(SURFACE, "utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
function missingRoutes(manifest) {
|
||||
const declared = new Set(manifest.verbs ?? []);
|
||||
return requiredExact.filter((route) => !declared.has(route));
|
||||
}
|
||||
|
||||
test("MINI-NLE declares every legitimate exact route it uses", () => {
|
||||
const { manifest } = readContract();
|
||||
assert.deepEqual(missingRoutes(manifest), []);
|
||||
});
|
||||
|
||||
test("MINI-NLE declares parameterized status polling with the SDK trailing-slash prefix contract", () => {
|
||||
const { manifest, surface } = readContract();
|
||||
assert.match(surface, /callExportApi\(`\/api\/export-status\/\$\{encodeURIComponent\(jobId\)\}`/);
|
||||
for (const prefix of requiredPrefixes) assert.ok(manifest.verbs.includes(prefix), `${prefix} prefix is undeclared`);
|
||||
});
|
||||
|
||||
test("the route checker demonstrably fails when a known declaration is removed", () => {
|
||||
const { manifest } = readContract();
|
||||
const altered = { ...manifest, verbs: (manifest.verbs ?? []).filter((route) => route !== "/api/patch") };
|
||||
assert.ok(missingRoutes(altered).includes("/api/patch"));
|
||||
});
|
||||
|
||||
test("ruled-out rig injection and Finder reveal have no live surface callers", () => {
|
||||
const { manifest, surface } = readContract();
|
||||
for (const route of forbidden) {
|
||||
assert.ok(!(manifest.verbs ?? []).includes(route), `${route} must not be declared`);
|
||||
const escaped = route.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
assert.doesNotMatch(
|
||||
surface,
|
||||
new RegExp(`(?:callExportApi|fetch)\\(\\s*[\\\"'\\x60]${escaped}(?:[?\\\"'\\x60])`),
|
||||
`${route} still has a live network caller`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("disabled review handoff explains the AGENTS path at the interaction point", () => {
|
||||
const { surface } = readContract();
|
||||
assert.match(surface, /review handoff[^<\n]*AGENTS|AGENTS[^<\n]*review handoff/i);
|
||||
});
|
||||
|
||||
test("dynamic export mode resolves to concrete declared review and final routes", () => {
|
||||
const { manifest, surface } = readContract();
|
||||
assert.match(surface, /callExportApi\(`\/api\/export-\$\{mode\}`/);
|
||||
assert.ok(manifest.verbs.includes("/api/export-review"));
|
||||
assert.ok(manifest.verbs.includes("/api/export-final"));
|
||||
assert.ok(!manifest.verbs.includes("/api/export-"), "dynamic source fragment is not a route declaration");
|
||||
});
|
||||
Reference in new issue
Block a user