@kheleshikhi/studio-agent 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/attempt.d.ts +4 -0
- package/dist/attempt.js +11 -1
- package/dist/attempt.js.map +1 -1
- package/dist/browser.d.ts +18 -4
- package/dist/browser.js +84 -22
- package/dist/browser.js.map +1 -1
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +429 -97
- package/dist/cli.js.map +1 -1
- package/dist/daemon.js +72 -8
- package/dist/daemon.js.map +1 -1
- package/dist/episode-runner.d.ts +109 -0
- package/dist/episode-runner.js +381 -0
- package/dist/episode-runner.js.map +1 -0
- package/dist/episode.d.ts +332 -0
- package/dist/episode.js +648 -0
- package/dist/episode.js.map +1 -0
- package/dist/project.d.ts +30 -0
- package/dist/project.js +204 -0
- package/dist/project.js.map +1 -0
- package/dist/render-jobs.d.ts +101 -0
- package/dist/render-jobs.js +241 -0
- package/dist/render-jobs.js.map +1 -0
- package/dist/shot-provenance.d.ts +66 -0
- package/dist/shot-provenance.js +114 -0
- package/dist/shot-provenance.js.map +1 -0
- package/dist/state.js +10 -6
- package/dist/state.js.map +1 -1
- package/dist/video.d.ts +38 -5
- package/dist/video.js +73 -12
- package/dist/video.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
3
3
|
import { spawn, spawnSync } from "node:child_process";
|
|
4
4
|
import { realpathSync, watch as watchFile } from "node:fs";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
|
-
import { access, appendFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { access, appendFile, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
7
7
|
import os from "node:os";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -13,18 +13,24 @@ import { attemptDirectory, createAttemptPointer, finalizeClient, readAttemptPoin
|
|
|
13
13
|
import { runDaemon } from "./daemon.js";
|
|
14
14
|
import { audioUploadExtension, isLibraryKind, LIBRARY_STUDIO_WORKSPACE, libraryLockAfterSave, LibraryWorkspace, } from "./library.js";
|
|
15
15
|
import { daemonRpc, setCliTrace } from "./protocol.js";
|
|
16
|
-
import {
|
|
16
|
+
import { runRenderJob } from "./render-jobs.js";
|
|
17
|
+
import { compileDependencies, experienceProvenance } from "./shot-provenance.js";
|
|
18
|
+
import { digestOf } from "./shot-provenance.js";
|
|
19
|
+
import { episodeCues, episodeTimeline, parseEpisode } from "./episode.js";
|
|
20
|
+
import { planEpisode, renderEpisode } from "./episode-runner.js";
|
|
21
|
+
import { cacheDirectory, cleanStaleDaemonMetadata, readConfig, readDaemonMetadata, stateDirectory, validateOrigin, writeConfig, } from "./state.js";
|
|
17
22
|
import { CliError } from "./types.js";
|
|
18
23
|
import { sourceHash, StudioWorkspace } from "./workspace.js";
|
|
19
24
|
import { parseScenario, parseExperienceScenario } from "./scenario.js";
|
|
20
25
|
import { audioLevel, audioSegments, encode, encodeArguments, findEncoder, mix, parseRecipe, probe, RENDER_KIND, sha256, verifyProbe } from "./video.js";
|
|
21
26
|
import { changedPaths } from "./presence.js";
|
|
27
|
+
import { inspectNotice, PROJECT_FLAGS, projectCommand, projectId, SCOPED_SEARCH_FLAGS, scopeWarningNotice, scopeWarnings, searchNotice, searchParameters } from "./project.js";
|
|
22
28
|
import { capabilitiesView, compatibilityReport, contextSummary, experiencePreviewSummary, experienceSchemaNotice, mathPlaneRequirement, resolveCreateTarget, schemaTopicView } from "./context.js";
|
|
23
|
-
const VERSION = "0.
|
|
29
|
+
const VERSION = "0.9.1";
|
|
24
30
|
const AGENT_PROTOCOL = 1;
|
|
25
31
|
const INSTRUCTIONS = `# NerdBro Interactive Studio agent\n\nUse the provider-neutral \`nerdbro\` CLI. Before substantial authoring: read \`nerdbro context\`, fetch the live \`nerdbro guide\`, then search only relevant docs, exact contracts, Components, Behaviors and examples. Do not read the entire catalogue blindly.\n\nFor complex work, prove risky capabilities in a small spike first. Checkout once, edit only supported source, validate, compile, drive the learner scenario where supported, inspect visuals, save, certify, then stop for human approval. Never force a conflict, approve, publish, administer or deploy. Disconnect when work is over.\n\nIf this is a Git-backed workspace, inspect recent history first. Work in coherent batches; after each, run focused checks, inspect the diff, and commit the green checkpoint with intent and verification. Never force-push or rewrite another Agent's work.\n\nEverything visual is a Scene (\`studio create\` defaults to the sceneComposition target); a graph is a Scene math plane, never a separate target. Interaction is an Experience: final learner content containing one or more Scenes, with inputs, requests, readouts/exports, tools, rounds, presentation and assessment outcomes (game is scoring only). The targets and schemas \`nerdbro context\` lists as frozen (InteractiveScene, mathGraph, combined) are read for existing assets only. Read \`nerdbro schema experience\`, \`scene.stateMachines\`, and \`stateMachine.actions\` for current contracts. Library definitions (experience, audio, component, behavior) use \`create\` or \`checkout\`, edit \`source.json\`, then \`status\`, \`save\`, \`validate\` and \`certify\`. Audio must be AAC in MP4/M4A.\n\nCurrent live playbook: /docs/studio-language/agent-authoring-playbook\n`;
|
|
26
32
|
// Presence-only options never consume the following positional argument.
|
|
27
|
-
const BOOLEAN_FLAGS = new Set(["force", "full-page", "headless", "no-browser", "declarative", "assessment", "no-assessment", "scratch", "saved", "math-plane", "no-math-plane"]);
|
|
33
|
+
const BOOLEAN_FLAGS = new Set(["force", "full-page", "headless", "no-browser", "declarative", "assessment", "no-assessment", "scratch", "saved", "math-plane", "no-math-plane", "all-libraries", "full", "compact"]);
|
|
28
34
|
export function parse(argv) {
|
|
29
35
|
const args = [];
|
|
30
36
|
const flags = new Map();
|
|
@@ -70,13 +76,17 @@ export function assertSupportedFlags(parsed) {
|
|
|
70
76
|
if (command === "_daemon")
|
|
71
77
|
allowed = ["origin", "headless", "no-browser", "version-tag"];
|
|
72
78
|
else if (command === "attempt" && action === "start")
|
|
73
|
-
allowed = ["label", "mode"];
|
|
79
|
+
allowed = ["label", "mode", "project"];
|
|
74
80
|
else if (command === "attempt" && action === "finish")
|
|
75
81
|
allowed = ["outcome"];
|
|
76
82
|
else if (command === "agent" && action === "init")
|
|
77
83
|
allowed = ["provider"];
|
|
78
|
-
else if (["component", "behavior"
|
|
84
|
+
else if (["component", "behavior"].includes(command) && action === "search")
|
|
85
|
+
allowed = SCOPED_SEARCH_FLAGS;
|
|
86
|
+
else if (command === "example" && action === "search")
|
|
79
87
|
allowed = ["limit", "offset"];
|
|
88
|
+
else if (command === "project")
|
|
89
|
+
allowed = PROJECT_FLAGS[action || "list"] || [];
|
|
80
90
|
else if (command === "world" && action === "inspect")
|
|
81
91
|
allowed = ["zone"];
|
|
82
92
|
else if (isLibraryKind(command)) {
|
|
@@ -99,6 +109,10 @@ export function assertSupportedFlags(parsed) {
|
|
|
99
109
|
else if (action === "search")
|
|
100
110
|
allowed = ["limit"];
|
|
101
111
|
}
|
|
112
|
+
else if (command === "episode") {
|
|
113
|
+
if (["plan", "render", "status"].includes(action))
|
|
114
|
+
allowed = ["workspace", "out", "cache", "workers", "timeout"];
|
|
115
|
+
}
|
|
102
116
|
else if (command === "studio") {
|
|
103
117
|
if (action === "create")
|
|
104
118
|
allowed = ["title", "target", ...BRIEF_FLAGS.filter((name) => name !== "no-assessment" && name !== "no-math-plane"), "command-id", "scratch", "starter"];
|
|
@@ -398,19 +412,34 @@ async function checkout(parsed, asset) {
|
|
|
398
412
|
await target.write(source, lock);
|
|
399
413
|
return { ok: true, command: "studio.checkout", asset, source: target.sourcePath, lock: target.lockPath, source_hash: hash, base_snapshot_public_id: lock.base_snapshot_public_id, authoring_brief: remote.authoring_brief || {}, authoring_warnings: remote.authoring_warnings || [] };
|
|
400
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* The hash of the source the server serves, as the Studio page will hash it.
|
|
417
|
+
*
|
|
418
|
+
* A draft's stored hash was computed from the JSON it was saved with; read
|
|
419
|
+
* back from Postgres JSONB, a whole-number decimal such as 0.0 returns as 0,
|
|
420
|
+
* so the mounted Studio (which hashes what it loaded) can disagree with the
|
|
421
|
+
* stored value for older drafts. Browser mount checks therefore compare
|
|
422
|
+
* against the served source; conflict detection keeps the stored snapshot.
|
|
423
|
+
*/
|
|
424
|
+
async function servedSourceHash(asset) {
|
|
425
|
+
const loaded = await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}`);
|
|
426
|
+
if (loaded.source && typeof loaded.source === "object")
|
|
427
|
+
return sourceHash(loaded.source);
|
|
428
|
+
return String(loaded.source_hash || "");
|
|
429
|
+
}
|
|
401
430
|
async function studioStatus(parsed, asset) {
|
|
402
431
|
const target = workspace(parsed, asset);
|
|
403
|
-
const [source, lock, remote] = await Promise.all([target.source(), target.lock(), api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}/status`)]);
|
|
432
|
+
const [source, lock, remote, served] = await Promise.all([target.source(), target.lock(), api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}/status`), servedSourceHash(asset)]);
|
|
404
433
|
const localHash = sourceHash(source);
|
|
405
434
|
const remoteHead = remote.head_snapshot?.public_id || null;
|
|
406
|
-
return { ok: true, command: "studio.status", asset, local_source_hash: localHash, local_changed: localHash !== lock.checkout_source_hash, base_snapshot_public_id: lock.base_snapshot_public_id, remote_snapshot_public_id: remoteHead, remote_source_hash: remote.source_hash, remote_changed: remoteHead !== lock.base_snapshot_public_id, conflict: remoteHead !== lock.base_snapshot_public_id && localHash !== lock.checkout_source_hash, authoring_warnings: remote.authoring_warnings || [] };
|
|
435
|
+
return { ok: true, command: "studio.status", asset, local_source_hash: localHash, local_changed: localHash !== lock.checkout_source_hash, base_snapshot_public_id: lock.base_snapshot_public_id, remote_snapshot_public_id: remoteHead, remote_source_hash: remote.source_hash, served_source_hash: served, remote_changed: remoteHead !== lock.base_snapshot_public_id, conflict: remoteHead !== lock.base_snapshot_public_id && localHash !== lock.checkout_source_hash, authoring_warnings: remote.authoring_warnings || [] };
|
|
407
436
|
}
|
|
408
437
|
async function assertBrowserSource(parsed, asset) {
|
|
409
438
|
const target = workspace(parsed, asset);
|
|
410
439
|
const source = await target.source();
|
|
411
440
|
const hash = sourceHash(source);
|
|
412
441
|
const status = await studioStatus(parsed, asset);
|
|
413
|
-
if (status.local_changed || status.
|
|
442
|
+
if (status.local_changed || status.served_source_hash !== hash)
|
|
414
443
|
throw new CliError("Save the local source before browser inspection; local, server, and runtime hashes must match.", "STUDIO_SOURCE_MISMATCH", 3, status);
|
|
415
444
|
return { target, source, hash };
|
|
416
445
|
}
|
|
@@ -812,6 +841,67 @@ async function readRecipe(parsed) {
|
|
|
812
841
|
throw new CliError("Recipe is not valid JSON.", "VIDEO_RECIPE_INVALID", 2);
|
|
813
842
|
}
|
|
814
843
|
}
|
|
844
|
+
const PARTIAL_VIDEO = "video.partial.mp4";
|
|
845
|
+
const FAILURE_MANIFEST = "render-failed.json";
|
|
846
|
+
/**
|
|
847
|
+
* A failed render, in one place: remove what it wrote (frames unless
|
|
848
|
+
* --keep-frames, sound copies, the mix and any unverified MP4), write
|
|
849
|
+
* `render-failed.json`, and return an error that names the source, the stage
|
|
850
|
+
* that failed and that no output is reusable. A render.json and video.mp4
|
|
851
|
+
* from an earlier successful render into the same directory are left alone;
|
|
852
|
+
* this attempt never replaced them.
|
|
853
|
+
*/
|
|
854
|
+
async function renderFailure(error, context) {
|
|
855
|
+
const failure = error instanceof CliError ? error : new CliError(String(error), "VIDEO_RENDER_FAILED");
|
|
856
|
+
const details = failure.details && typeof failure.details === "object" ? failure.details : {};
|
|
857
|
+
const stage = typeof details.stage === "string" ? details.stage : context.stage;
|
|
858
|
+
const { destination } = context;
|
|
859
|
+
if (!context.keepFrames)
|
|
860
|
+
await rm(path.join(destination, "frames"), { recursive: true, force: true }).catch(() => undefined);
|
|
861
|
+
for (const leftover of ["audio", "audio-mix.wav", PARTIAL_VIDEO]) {
|
|
862
|
+
await rm(path.join(destination, leftover), { recursive: true, force: true }).catch(() => undefined);
|
|
863
|
+
}
|
|
864
|
+
const report = {
|
|
865
|
+
kind: "kheleshikhi.video-render-failure.v1",
|
|
866
|
+
subject: context.subject,
|
|
867
|
+
...context.identity,
|
|
868
|
+
stage,
|
|
869
|
+
code: failure.code,
|
|
870
|
+
message: failure.message,
|
|
871
|
+
output_reusable: false,
|
|
872
|
+
canonical: false,
|
|
873
|
+
frames_kept: context.keepFrames,
|
|
874
|
+
failed_at: new Date().toISOString(),
|
|
875
|
+
};
|
|
876
|
+
await writeFile(path.join(destination, FAILURE_MANIFEST), `${JSON.stringify(report, null, 2)}\n`).catch(() => undefined);
|
|
877
|
+
return new CliError(`Render failed at ${stage}: ${failure.message}`, failure.code, failure.exitCode, { ...details, stage, source: context.identity, output_reusable: false, failure_report: path.join(destination, FAILURE_MANIFEST) });
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Capture a render's frames through a daemon job (see render-jobs.ts): short
|
|
881
|
+
* status polls instead of one request held open for minutes. Ctrl-C cancels
|
|
882
|
+
* the job; progress goes to stderr on a terminal.
|
|
883
|
+
*/
|
|
884
|
+
async function renderFramesThroughDaemon(kind, legacyRoute, request, json) {
|
|
885
|
+
const interrupt = new AbortController();
|
|
886
|
+
const onInterrupt = () => interrupt.abort("interrupted");
|
|
887
|
+
process.once("SIGINT", onInterrupt);
|
|
888
|
+
let reported = 0;
|
|
889
|
+
const onProgress = (job) => {
|
|
890
|
+
if (json || !process.stderr.isTTY || Date.now() - reported < 5_000)
|
|
891
|
+
return;
|
|
892
|
+
reported = Date.now();
|
|
893
|
+
const { stage, frame, frames } = job.progress;
|
|
894
|
+
process.stderr.write(`nerdbro: rendering ${stage}${frames ? ` ${frame ?? 0}/${frames} frames` : ""} (${Math.round(job.elapsed_ms / 1000)} s)\n`);
|
|
895
|
+
};
|
|
896
|
+
try {
|
|
897
|
+
return await runRenderJob(
|
|
898
|
+
// Polls are short; only the pre-job fallback route may run long.
|
|
899
|
+
(route, init) => daemonRpc(route, route === legacyRoute ? init : { ...init, signal: AbortSignal.timeout(60_000) }), kind, legacyRoute, request, { signal: interrupt.signal, onProgress });
|
|
900
|
+
}
|
|
901
|
+
finally {
|
|
902
|
+
process.removeListener("SIGINT", onInterrupt);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
815
905
|
/**
|
|
816
906
|
* Mix, encode, verify and describe one render. The audio is mixed from the
|
|
817
907
|
* canonical bytes the runtime would have played, at the times it logged;
|
|
@@ -819,6 +909,15 @@ async function readRecipe(parsed) {
|
|
|
819
909
|
* derivative of and how it was made.
|
|
820
910
|
*/
|
|
821
911
|
async function finishRender(input) {
|
|
912
|
+
let stage = "audio_mix";
|
|
913
|
+
try {
|
|
914
|
+
return await finishRenderStages(input, (value) => { stage = value; });
|
|
915
|
+
}
|
|
916
|
+
catch (error) {
|
|
917
|
+
throw await renderFailure(error, { destination: input.destination, keepFrames: input.keepFrames, identity: input.identity, subject: input.subject, stage });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
async function finishRenderStages(input, setStage) {
|
|
822
921
|
const { encoder, recipe, destination, frames } = input;
|
|
823
922
|
const durationMs = Math.round((frames.frames / recipe.fps) * 1000);
|
|
824
923
|
const log = (frames.audio?.events ?? []);
|
|
@@ -834,15 +933,21 @@ async function finishRender(input) {
|
|
|
834
933
|
mixArgs = await mix(encoder, segments, new Map(Object.entries(sources).map(([key, value]) => [key, value.file])), durationMs, mixed);
|
|
835
934
|
}
|
|
836
935
|
const encodeStarted = Date.now();
|
|
936
|
+
// The encode lands under a partial name and becomes video.mp4 only once it
|
|
937
|
+
// is verified: a failed render never leaves an MP4 that looks finished.
|
|
837
938
|
const output = path.join(destination, "video.mp4");
|
|
838
|
-
|
|
839
|
-
|
|
939
|
+
const partial = path.join(destination, PARTIAL_VIDEO);
|
|
940
|
+
setStage("encode");
|
|
941
|
+
await encode(encoder, recipe, path.join(destination, "frames", "%06d.png"), partial, mixed);
|
|
942
|
+
setStage("verify");
|
|
943
|
+
const probed = await probe(encoder, partial);
|
|
840
944
|
verifyProbe(recipe, probed);
|
|
841
|
-
const level = await audioLevel(encoder,
|
|
945
|
+
const level = await audioLevel(encoder, partial);
|
|
842
946
|
if (segments.length && (level.max_db === null || level.max_db < -60)) {
|
|
843
947
|
throw new CliError("Sounds were logged but the encoded audio track is silent.", "VIDEO_AUDIO_SILENT", 1, { segments: segments.length });
|
|
844
948
|
}
|
|
845
|
-
|
|
949
|
+
setStage("manifest");
|
|
950
|
+
const bytes = await readFile(partial);
|
|
846
951
|
const manifest = {
|
|
847
952
|
kind: RENDER_KIND,
|
|
848
953
|
subject: input.subject,
|
|
@@ -890,13 +995,16 @@ async function finishRender(input) {
|
|
|
890
995
|
output: { file: "video.mp4", bytes: bytes.length, sha256: sha256(bytes), probe: probed },
|
|
891
996
|
determinism: "Video times, learner actions and sound placements are exact. Pixels depend on the browser and GPU; compare renders by frame_sha256.",
|
|
892
997
|
performance: { ...(frames.playback_timing ? { playback_timing: frames.playback_timing } : {}),
|
|
893
|
-
capture_seconds: frames.capture_seconds,
|
|
998
|
+
capture_seconds: frames.capture_seconds, ...(frames.capture_phases ? { capture_phases: frames.capture_phases } : {}),
|
|
999
|
+
mix_seconds: (encodeStarted - mixStarted) / 1000,
|
|
894
1000
|
encode_seconds: (Date.now() - encodeStarted) / 1000,
|
|
895
1001
|
total_seconds: (Date.now() - input.started) / 1000, frames_per_second_captured: frames.frames / Math.max(frames.capture_seconds, 0.001),
|
|
896
1002
|
agent_rss_mb: Math.round(process.memoryUsage().rss / 1048576) },
|
|
897
1003
|
rendered_at: new Date().toISOString(),
|
|
898
1004
|
};
|
|
1005
|
+
await rename(partial, output);
|
|
899
1006
|
await writeFile(path.join(destination, "render.json"), `${JSON.stringify({ ...manifest, frame_sha256: frames.frame_sha256 }, null, 2)}\n`);
|
|
1007
|
+
await rm(path.join(destination, FAILURE_MANIFEST), { force: true });
|
|
900
1008
|
if (!input.keepFrames)
|
|
901
1009
|
await rm(path.join(destination, "frames"), { recursive: true, force: true });
|
|
902
1010
|
if (mixed)
|
|
@@ -905,6 +1013,93 @@ async function finishRender(input) {
|
|
|
905
1013
|
width: probed.width, height: probed.height, duration_seconds: probed.duration, audio_segments: segments.length,
|
|
906
1014
|
audio_max_dbfs: level.max_db, sha256: manifest.output.sha256 };
|
|
907
1015
|
}
|
|
1016
|
+
/** One Scene presentation render (see video.ts): capture through a daemon job, then mix, encode, verify, describe. */
|
|
1017
|
+
async function renderSceneVideo(input) {
|
|
1018
|
+
const { asset, candidate, recipe, encoder, destination, keepFrames } = input;
|
|
1019
|
+
const hash = sourceHash(candidate);
|
|
1020
|
+
await mkdir(destination, { recursive: true });
|
|
1021
|
+
const started = Date.now();
|
|
1022
|
+
const frames = await renderFramesThroughDaemon("scene", "/browser/render-frames", {
|
|
1023
|
+
asset, sourceHash: input.remoteHash, candidate, candidateHash: hash, recipe,
|
|
1024
|
+
framesDirectory: path.join(destination, "frames"), audioDirectory: path.join(destination, "audio"),
|
|
1025
|
+
measurePlaybackSeconds: input.measurePlaybackSeconds ?? 0, keepFramesOnFailure: keepFrames,
|
|
1026
|
+
out: path.join(destination, "mounted.png"), timeoutMs: input.timeoutMs
|
|
1027
|
+
}, input.json).catch(async (error) => {
|
|
1028
|
+
throw await renderFailure(error, { destination, keepFrames, subject: "scene", stage: "capture", identity: { asset, source_hash: hash } });
|
|
1029
|
+
});
|
|
1030
|
+
// The exact Component, Behavior and Audio definitions this source
|
|
1031
|
+
// compiles against right now (a shot cache keys on its digest).
|
|
1032
|
+
const compiledDependencies = await compileDependencies(api, asset, candidate).catch(() => null);
|
|
1033
|
+
return finishRender({
|
|
1034
|
+
subject: "scene", encoder, recipe, destination, frames, started, keepFrames,
|
|
1035
|
+
identity: { asset, source_hash: hash, source_revision: frames.source_revision ?? null, runtime_fingerprint: frames.runtime_fingerprint ?? null,
|
|
1036
|
+
dependencies_digest: compiledDependencies?.dependencies?.digest ?? null, dependencies: compiledDependencies?.dependencies ?? null },
|
|
1037
|
+
note: "A presentation derivative of the Scene below. The Scene Source stays the authority and stays interactive; this file is never Experience content, never certification evidence and never part of a KSPKG.",
|
|
1038
|
+
clock: "video time; the SceneClock follows it only while it plays (an authored pause holds the Scene)",
|
|
1039
|
+
extra: { scene_clock: frames.scene_clock },
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
/** What an Experience render plays: the local source.json, or (with saved) the saved head the lock names. */
|
|
1043
|
+
async function experienceRenderSource(target, saved) {
|
|
1044
|
+
const result = { revision: null };
|
|
1045
|
+
try {
|
|
1046
|
+
const lock = await target.lock();
|
|
1047
|
+
result.revision = lock.head_revision_public_id || null;
|
|
1048
|
+
if (saved) {
|
|
1049
|
+
result.expectedRevision = result.revision || undefined;
|
|
1050
|
+
result.expectedHash = lock.source_hash;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
catch { /* No local checkout: render the saved revision. */ }
|
|
1054
|
+
if (!saved) {
|
|
1055
|
+
try {
|
|
1056
|
+
result.candidate = await target.source();
|
|
1057
|
+
result.candidateHash = sourceHash(result.candidate);
|
|
1058
|
+
}
|
|
1059
|
+
catch { /* No local source: render the saved revision. */ }
|
|
1060
|
+
}
|
|
1061
|
+
return result;
|
|
1062
|
+
}
|
|
1063
|
+
/** One Experience presentation render (see video.ts). */
|
|
1064
|
+
async function renderExperienceVideo(input) {
|
|
1065
|
+
const { stableId, recipe, encoder, destination, keepFrames, candidate, candidateHash, expectedRevision, expectedHash, revision } = input;
|
|
1066
|
+
await mkdir(destination, { recursive: true });
|
|
1067
|
+
const started = Date.now();
|
|
1068
|
+
const frames = await renderFramesThroughDaemon("experience", "/browser/render-experience-frames", {
|
|
1069
|
+
asset: stableId, sourceHash: "", recipe, timeoutMs: input.timeoutMs,
|
|
1070
|
+
framesDirectory: path.join(destination, "frames"), audioDirectory: path.join(destination, "audio"),
|
|
1071
|
+
out: path.join(destination, "mounted.png"), keepFramesOnFailure: keepFrames,
|
|
1072
|
+
library: { workspace: "experiences", selection: stableId, revision: expectedRevision, definitionHash: expectedHash },
|
|
1073
|
+
...(candidateHash ? { candidate, candidateHash } : {})
|
|
1074
|
+
}, input.json).catch(async (error) => {
|
|
1075
|
+
throw await renderFailure(error, { destination, keepFrames, subject: "experience", stage: "capture",
|
|
1076
|
+
identity: { experience: stableId, source_hash: candidateHash ?? expectedHash ?? null, source_origin: candidateHash ? "local" : "saved" } });
|
|
1077
|
+
});
|
|
1078
|
+
const context = await api("GET", "/v1/agent/context").catch(() => ({}));
|
|
1079
|
+
const document = (candidate ?? {});
|
|
1080
|
+
const scenes = document.scenes && typeof document.scenes === "object" ? document.scenes : null;
|
|
1081
|
+
// Per-Scene identity (approved revision, stored manifest, source hash and
|
|
1082
|
+
// dependency digest) and every sound's sha256: what a shot cache keys on.
|
|
1083
|
+
const sceneProvenance = await (async () => {
|
|
1084
|
+
const rendered = candidate ?? (await api("GET", `${authoringRoute("experience", stableId)}?purpose=inspect`)).document;
|
|
1085
|
+
return experienceProvenance(api, rendered);
|
|
1086
|
+
})().catch((error) => ({ error: error instanceof Error ? error.message : String(error) }));
|
|
1087
|
+
return finishRender({
|
|
1088
|
+
subject: "experience", encoder, recipe, destination, frames, started, keepFrames,
|
|
1089
|
+
identity: {
|
|
1090
|
+
experience: stableId,
|
|
1091
|
+
source_hash: candidateHash ?? frames.source_hash ?? expectedHash ?? null,
|
|
1092
|
+
source_origin: candidateHash ? "local" : "saved",
|
|
1093
|
+
source_revision: revision,
|
|
1094
|
+
scenes,
|
|
1095
|
+
scene_provenance: sceneProvenance,
|
|
1096
|
+
scene_runtime_fingerprint: context.runtime_fingerprint ?? null,
|
|
1097
|
+
},
|
|
1098
|
+
note: "A presentation derivative of the Experience below. The Experience Source and its Scenes stay the authority and stay interactive; this file is never Experience content, never certification evidence and never part of a KSPKG.",
|
|
1099
|
+
clock: "video time (the Experience master timeline advances with it while it runs)",
|
|
1100
|
+
extra: { scenes_shown: frames.scenes, final_state: frames.final_state, final_scene: frames.final_scene, timeline_ms: frames.timeline_ms, completed: frames.completed },
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
908
1103
|
async function libraryInspectBrowser(parsed, kind, stableId) {
|
|
909
1104
|
const target = libraryWorkspace(parsed, kind, stableId);
|
|
910
1105
|
const viewport = flag(parsed, "viewport", "desktop");
|
|
@@ -1029,56 +1224,10 @@ async function library(parsed, kind, action, argument) {
|
|
|
1029
1224
|
const recipe = parseRecipe(await readRecipe(parsed), "experience");
|
|
1030
1225
|
const encoder = await findEncoder();
|
|
1031
1226
|
await ensureCompatibility();
|
|
1032
|
-
|
|
1033
|
-
let candidateHash;
|
|
1034
|
-
let expectedRevision;
|
|
1035
|
-
let expectedHash;
|
|
1036
|
-
let revision = null;
|
|
1037
|
-
try {
|
|
1038
|
-
const lock = await target.lock();
|
|
1039
|
-
revision = lock.head_revision_public_id || null;
|
|
1040
|
-
if (parsed.flags.has("saved")) {
|
|
1041
|
-
expectedRevision = revision || undefined;
|
|
1042
|
-
expectedHash = lock.source_hash;
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
catch { /* No local checkout: render the saved revision. */ }
|
|
1046
|
-
if (!parsed.flags.has("saved")) {
|
|
1047
|
-
try {
|
|
1048
|
-
candidate = await target.source();
|
|
1049
|
-
candidateHash = sourceHash(candidate);
|
|
1050
|
-
}
|
|
1051
|
-
catch { /* No local source: render the saved revision. */ }
|
|
1052
|
-
}
|
|
1227
|
+
const source = await experienceRenderSource(target, parsed.flags.has("saved"));
|
|
1053
1228
|
const destination = path.resolve(flag(parsed, "out", `render-experience-${stableId}-${Date.now()}`));
|
|
1054
|
-
await
|
|
1055
|
-
|
|
1056
|
-
const response = await daemonRpc("/browser/render-experience-frames", {
|
|
1057
|
-
method: "POST",
|
|
1058
|
-
body: JSON.stringify({ asset: stableId, sourceHash: "", recipe, timeoutMs: Number(flag(parsed, "timeout", "30000")),
|
|
1059
|
-
framesDirectory: path.join(destination, "frames"), audioDirectory: path.join(destination, "audio"),
|
|
1060
|
-
out: path.join(destination, "mounted.png"),
|
|
1061
|
-
library: { workspace: "experiences", selection: stableId, revision: expectedRevision, definitionHash: expectedHash },
|
|
1062
|
-
...(candidateHash ? { candidate, candidateHash } : {}) }),
|
|
1063
|
-
});
|
|
1064
|
-
const frames = response.result;
|
|
1065
|
-
const context = await api("GET", "/v1/agent/context").catch(() => ({}));
|
|
1066
|
-
const document = (candidate ?? {});
|
|
1067
|
-
const scenes = document.scenes && typeof document.scenes === "object" ? document.scenes : null;
|
|
1068
|
-
const result = await finishRender({
|
|
1069
|
-
subject: "experience", encoder, recipe, destination, frames, started, keepFrames: parsed.flags.has("keep-frames"),
|
|
1070
|
-
identity: {
|
|
1071
|
-
experience: stableId,
|
|
1072
|
-
source_hash: candidateHash ?? frames.source_hash ?? expectedHash ?? null,
|
|
1073
|
-
source_origin: candidateHash ? "local" : "saved",
|
|
1074
|
-
source_revision: revision,
|
|
1075
|
-
scenes,
|
|
1076
|
-
scene_runtime_fingerprint: context.runtime_fingerprint ?? null,
|
|
1077
|
-
},
|
|
1078
|
-
note: "A presentation derivative of the Experience below. The Experience Source and its Scenes stay the authority and stay interactive; this file is never Experience content, never certification evidence and never part of a KSPKG.",
|
|
1079
|
-
clock: "video time (the Experience master timeline advances with it while it runs)",
|
|
1080
|
-
extra: { scenes_shown: frames.scenes, final_state: frames.final_state, final_scene: frames.final_scene, timeline_ms: frames.timeline_ms, completed: frames.completed },
|
|
1081
|
-
});
|
|
1229
|
+
const result = await renderExperienceVideo({ stableId, ...source, recipe, encoder, destination,
|
|
1230
|
+
keepFrames: parsed.flags.has("keep-frames"), timeoutMs: Number(flag(parsed, "timeout", "30000")), json: parsed.json });
|
|
1082
1231
|
return { ok: true, command: "experience.render-video", stable_id: stableId, ...result };
|
|
1083
1232
|
}
|
|
1084
1233
|
if (kind === "experience" && action === "watch") {
|
|
@@ -1123,6 +1272,172 @@ async function library(parsed, kind, action, argument) {
|
|
|
1123
1272
|
return libraryExport(parsed, kind, stableId);
|
|
1124
1273
|
return libraryInspectBrowser(parsed, kind, stableId);
|
|
1125
1274
|
}
|
|
1275
|
+
// ---------------------------------------------------------------------------
|
|
1276
|
+
// Episodes (see episode.ts): validate | plan | render | status <episode.json>
|
|
1277
|
+
// ---------------------------------------------------------------------------
|
|
1278
|
+
export const EPISODE_MAX_WORKERS = 4;
|
|
1279
|
+
/**
|
|
1280
|
+
* Shots rendered at once: what was asked, but never more than the daemon has
|
|
1281
|
+
* render browsers. A daemon started without `NERDBRO_RENDER_WORKERS` owns one,
|
|
1282
|
+
* and a second concurrent shot would fail `VIDEO_RENDER_BUSY` mid-episode.
|
|
1283
|
+
*/
|
|
1284
|
+
export function effectiveEpisodeWorkers(requested, daemonRenderWorkers) {
|
|
1285
|
+
const available = typeof daemonRenderWorkers === "number" && Number.isInteger(daemonRenderWorkers) && daemonRenderWorkers >= 1
|
|
1286
|
+
? daemonRenderWorkers : 1;
|
|
1287
|
+
if (requested <= available)
|
|
1288
|
+
return { workers: requested, note: null };
|
|
1289
|
+
return {
|
|
1290
|
+
workers: available,
|
|
1291
|
+
note: `--workers ${requested} reduced to ${available}: the running daemon has ${available} render browser(s). `
|
|
1292
|
+
+ `Restart it with NERDBRO_RENDER_WORKERS=${requested} (nerdbro agent stop) to render ${requested} shots at once.`,
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
async function readEpisodeFile(file) {
|
|
1296
|
+
const episodePath = path.resolve(required(file, "episode.json is required: nerdbro episode <validate|plan|render|status> <episode.json>."));
|
|
1297
|
+
if ((await stat(episodePath)).size > 1_048_576)
|
|
1298
|
+
throw new CliError("episode.json exceeds 1 MiB.", "EPISODE_INVALID", 2);
|
|
1299
|
+
let raw;
|
|
1300
|
+
try {
|
|
1301
|
+
raw = JSON.parse(await readFile(episodePath, "utf8"));
|
|
1302
|
+
}
|
|
1303
|
+
catch {
|
|
1304
|
+
throw new CliError("episode.json is not valid JSON.", "EPISODE_INVALID", 2);
|
|
1305
|
+
}
|
|
1306
|
+
return { episodePath, episode: parseEpisode(raw) };
|
|
1307
|
+
}
|
|
1308
|
+
/** Resolve one shot's exact identity from the live server (never from file times). */
|
|
1309
|
+
async function resolveEpisodeShot(parsed, shot) {
|
|
1310
|
+
const failed = (message, code = "EPISODE_SHOT_UNRESOLVED") => new CliError(`Shot ${shot.id}: ${message}`, code, 2, { shot: shot.id, stage: "resolve" });
|
|
1311
|
+
if (shot.scene) {
|
|
1312
|
+
const { asset } = shot.scene;
|
|
1313
|
+
let candidate;
|
|
1314
|
+
let remoteHash = "";
|
|
1315
|
+
let revision = null;
|
|
1316
|
+
if (shot.scene.source === "approved") {
|
|
1317
|
+
const loaded = await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}`);
|
|
1318
|
+
if (loaded.source_origin !== "approved" || !loaded.source)
|
|
1319
|
+
throw failed("the asset's current source is not its approved revision (a draft is open); render source local or approve it first.");
|
|
1320
|
+
if (shot.scene.revision && loaded.base_revision_public_id !== shot.scene.revision)
|
|
1321
|
+
throw failed(`revision ${shot.scene.revision} is not the asset's approved head ${loaded.base_revision_public_id}.`);
|
|
1322
|
+
candidate = loaded.source;
|
|
1323
|
+
remoteHash = sourceHash(candidate);
|
|
1324
|
+
revision = loaded.base_revision_public_id ?? null;
|
|
1325
|
+
}
|
|
1326
|
+
else {
|
|
1327
|
+
candidate = await workspace(parsed, asset).source();
|
|
1328
|
+
remoteHash = await servedSourceHash(asset);
|
|
1329
|
+
}
|
|
1330
|
+
const hash = sourceHash(candidate);
|
|
1331
|
+
const compiled = await compileDependencies(api, asset, candidate);
|
|
1332
|
+
if (!compiled.compiled)
|
|
1333
|
+
throw failed("the Scene source does not compile; run `nerdbro studio validate`.", "EPISODE_SHOT_SOURCE_INVALID");
|
|
1334
|
+
const audio = Object.fromEntries((compiled.dependencies?.audio ?? []).map((item) => [item.id, item.sha256 ?? "unresolved"]));
|
|
1335
|
+
return {
|
|
1336
|
+
sources: { main: hash },
|
|
1337
|
+
dependency_digests: { main: compiled.dependencies?.digest ?? null },
|
|
1338
|
+
audio_sha256: audio,
|
|
1339
|
+
identity: { asset, source: shot.scene.source, source_hash: hash, revision, dependencies_digest: compiled.dependencies?.digest ?? null,
|
|
1340
|
+
components: compiled.dependencies?.components ?? [], behaviors: compiled.dependencies?.behaviors ?? [] },
|
|
1341
|
+
render: { asset, candidate, remoteHash },
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
const id = shot.experience.id;
|
|
1345
|
+
const target = libraryWorkspace(parsed, "experience", id);
|
|
1346
|
+
const source = await experienceRenderSource(target, shot.experience.source === "saved");
|
|
1347
|
+
const document = source.candidate ?? (await api("GET", `${authoringRoute("experience", id)}?purpose=inspect`)).document;
|
|
1348
|
+
if (!document)
|
|
1349
|
+
throw failed("the Experience has no readable source.");
|
|
1350
|
+
const provenance = await experienceProvenance(api, document);
|
|
1351
|
+
const sources = { experience: source.candidateHash ?? source.expectedHash ?? sourceHash(document) };
|
|
1352
|
+
const digests = {};
|
|
1353
|
+
for (const [scene, identity] of Object.entries(provenance.scenes)) {
|
|
1354
|
+
sources[`scene:${scene}`] = digestOf({ asset: identity.asset ?? null, revision: identity.revision ?? null, manifest: identity.manifest_path ?? null,
|
|
1355
|
+
package: identity.package ?? null, package_sha256: identity.package_sha256 ?? null, source_hash: identity.source_hash ?? null });
|
|
1356
|
+
digests[scene] = identity.dependencies_digest;
|
|
1357
|
+
}
|
|
1358
|
+
return {
|
|
1359
|
+
sources, dependency_digests: digests, audio_sha256: provenance.audio,
|
|
1360
|
+
identity: { experience: id, source: shot.experience.source, scene_provenance: provenance },
|
|
1361
|
+
render: source,
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
async function renderEpisodeShot(parsed, encoder, shot, recipe, resolved, destination) {
|
|
1365
|
+
const timeoutMs = Number(flag(parsed, "timeout", "30000"));
|
|
1366
|
+
if (shot.scene) {
|
|
1367
|
+
const render = resolved.render;
|
|
1368
|
+
await renderSceneVideo({ ...render, recipe, encoder, destination, keepFrames: false, timeoutMs, json: true });
|
|
1369
|
+
}
|
|
1370
|
+
else {
|
|
1371
|
+
await renderExperienceVideo({ stableId: shot.experience.id, ...resolved.render, recipe, encoder, destination,
|
|
1372
|
+
keepFrames: false, timeoutMs, json: true });
|
|
1373
|
+
}
|
|
1374
|
+
return { output: path.join(destination, "video.mp4"), manifest: path.join(destination, "render.json") };
|
|
1375
|
+
}
|
|
1376
|
+
async function episodeCommand(parsed, action, file) {
|
|
1377
|
+
if (!action || !["validate", "plan", "render", "status"].includes(action)) {
|
|
1378
|
+
throw new CliError("Usage: nerdbro episode <validate|plan|render|status> <episode.json>.", "AGENT_COMMAND_UNKNOWN", 2);
|
|
1379
|
+
}
|
|
1380
|
+
const { episodePath, episode } = await readEpisodeFile(file);
|
|
1381
|
+
const timeline = episodeTimeline(episode);
|
|
1382
|
+
if (action === "validate") {
|
|
1383
|
+
return { ok: true, command: "episode.validate", episode: episode.id, title: episode.title, shots: episode.shots.length,
|
|
1384
|
+
total_frames: timeline.total_frames, total_seconds: timeline.total_seconds,
|
|
1385
|
+
timeline: timeline.shots.map((item) => ({ id: item.id, frames: item.frames, offset_seconds: item.offset_seconds })),
|
|
1386
|
+
subtitles: episodeCues(episode, timeline).length };
|
|
1387
|
+
}
|
|
1388
|
+
const out = path.resolve(flag(parsed, "out", path.join(path.dirname(episodePath), `episode-out-${episode.id}`)));
|
|
1389
|
+
const cache = path.resolve(flag(parsed, "cache", path.join(cacheDirectory(), "episode-shots")));
|
|
1390
|
+
const workers = Number(flag(parsed, "workers", "1"));
|
|
1391
|
+
if (!Number.isInteger(workers) || workers < 1 || workers > EPISODE_MAX_WORKERS) {
|
|
1392
|
+
throw new CliError(`--workers must be 1 to ${EPISODE_MAX_WORKERS}.`, "AGENT_ARGUMENT_INVALID", 2);
|
|
1393
|
+
}
|
|
1394
|
+
const encoder = await findEncoder();
|
|
1395
|
+
await ensureCompatibility();
|
|
1396
|
+
const context = await api("GET", "/v1/agent/context");
|
|
1397
|
+
const deps = {
|
|
1398
|
+
encoder, runtimeFingerprint: context.runtime_fingerprint ?? null,
|
|
1399
|
+
resolveShot: (shot) => resolveEpisodeShot(parsed, shot),
|
|
1400
|
+
renderShot: (shot, recipe, resolved, destination) => renderEpisodeShot(parsed, encoder, shot, recipe, resolved, destination),
|
|
1401
|
+
log: (message) => { if (!parsed.json && process.stderr.isTTY)
|
|
1402
|
+
process.stderr.write(`nerdbro: ${message}\n`); },
|
|
1403
|
+
};
|
|
1404
|
+
const options = { episode, baseDirectory: path.dirname(episodePath), out, cache, workers, deps };
|
|
1405
|
+
if (action === "plan" || action === "status") {
|
|
1406
|
+
const plan = await planEpisode(options);
|
|
1407
|
+
let state = null;
|
|
1408
|
+
try {
|
|
1409
|
+
state = JSON.parse(await readFile(path.join(out, "episode-state.json"), "utf8"));
|
|
1410
|
+
}
|
|
1411
|
+
catch { /* never rendered here */ }
|
|
1412
|
+
let output = null;
|
|
1413
|
+
try {
|
|
1414
|
+
const manifest = JSON.parse(await readFile(path.join(out, "episode.json"), "utf8"));
|
|
1415
|
+
const current = manifest.shots?.map((shot) => shot.key).join() === plan.shots.map((shot) => shot.key).join();
|
|
1416
|
+
output = { file: path.join(out, `${episode.id}.mp4`), sha256: manifest.output?.sha256 ?? null, matches_current_shots: current,
|
|
1417
|
+
qc_passed: Array.isArray(manifest.qc) ? manifest.qc.every((check) => check.pass) : null };
|
|
1418
|
+
}
|
|
1419
|
+
catch { /* no assembled output */ }
|
|
1420
|
+
return { ok: true, command: `episode.${action}`, episode: episode.id, out, cache,
|
|
1421
|
+
total_frames: plan.timeline.total_frames, total_seconds: plan.timeline.total_seconds,
|
|
1422
|
+
shots: plan.shots.map((shot) => ({ id: shot.id, subject: shot.subject, key: shot.key, frames: shot.frames, cache: shot.cache,
|
|
1423
|
+
...(shot.cache_reason ? { cache_reason: shot.cache_reason } : {}), offset_seconds: shot.offset_seconds })),
|
|
1424
|
+
would_render: plan.shots.filter((shot) => shot.cache !== "valid").map((shot) => shot.id),
|
|
1425
|
+
...(action === "status" ? { state, output } : {}) };
|
|
1426
|
+
}
|
|
1427
|
+
const daemon = await startDaemon(true);
|
|
1428
|
+
const concurrency = effectiveEpisodeWorkers(workers, daemon.render_workers);
|
|
1429
|
+
if (concurrency.note)
|
|
1430
|
+
deps.log?.(concurrency.note);
|
|
1431
|
+
const result = await renderEpisode({ ...options, workers: concurrency.workers });
|
|
1432
|
+
if (!result.ok) {
|
|
1433
|
+
throw new CliError("The episode was assembled but failed QC; see qc.json.", "EPISODE_QC_FAILED", 1, { output: result.output, manifest: result.manifest, qc: result.qc?.filter((check) => !check.pass) });
|
|
1434
|
+
}
|
|
1435
|
+
return { ok: result.ok, command: "episode.render", episode: episode.id, output: result.output, srt: result.srt, manifest: result.manifest,
|
|
1436
|
+
state: result.state, reused: result.reused, rendered: result.rendered,
|
|
1437
|
+
shots: result.shots.map((shot) => ({ id: shot.id, key: shot.key, state: shot.state, render_seconds: shot.render_seconds ?? null })),
|
|
1438
|
+
qc: result.qc, timings: result.timings, workers: concurrency.workers,
|
|
1439
|
+
...(concurrency.note ? { workers_note: concurrency.note } : {}) };
|
|
1440
|
+
}
|
|
1126
1441
|
async function run(parsed) {
|
|
1127
1442
|
const [first, second, third] = parsed.args;
|
|
1128
1443
|
assertSupportedFlags(parsed);
|
|
@@ -1144,13 +1459,14 @@ async function run(parsed) {
|
|
|
1144
1459
|
if (await readAttemptPointer())
|
|
1145
1460
|
throw new CliError("This workspace already has an Attempt.", "AGENT_ATTEMPT_ALREADY_ACTIVE", 3);
|
|
1146
1461
|
const origin = (await readConfig()).apiOrigin;
|
|
1147
|
-
const
|
|
1462
|
+
const project = flag(parsed, "project");
|
|
1463
|
+
const server = await api("POST", "/v1/agent/attempts", { label, mode, server_origin: origin, ...(project ? { project: projectId(project) } : {}) });
|
|
1148
1464
|
const pointer = await createAttemptPointer(server, origin);
|
|
1149
|
-
return { ok: true, command: "attempt.start", attempt_id: pointer.attempt_id, label, mode, folder: pointer.attempt_root, pointer: path.join(process.cwd(), ".nerdbro-attempt.json") };
|
|
1465
|
+
return { ok: true, command: "attempt.start", attempt_id: pointer.attempt_id, label, mode, folder: pointer.attempt_root, pointer: path.join(process.cwd(), ".nerdbro-attempt.json"), ...(project ? { project: pointer.project || null, project_scope: server.project_scope ?? null } : {}) };
|
|
1150
1466
|
}
|
|
1151
1467
|
if (second === "current") {
|
|
1152
1468
|
const active = await readAttemptPointer();
|
|
1153
|
-
return { ok: true, command: "attempt.current", attempt_id: active?.pointer.attempt_id || null };
|
|
1469
|
+
return { ok: true, command: "attempt.current", attempt_id: active?.pointer.attempt_id || null, ...(active?.pointer.project ? { project: active.pointer.project } : {}) };
|
|
1154
1470
|
}
|
|
1155
1471
|
if (second === "status") {
|
|
1156
1472
|
const active = await readAttemptPointer();
|
|
@@ -1248,18 +1564,24 @@ async function run(parsed) {
|
|
|
1248
1564
|
const plural = `${first}s`;
|
|
1249
1565
|
if (second === "search") {
|
|
1250
1566
|
const query = parsed.args.slice(2).join(" ");
|
|
1251
|
-
|
|
1252
|
-
const
|
|
1253
|
-
const
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1567
|
+
// Project Attempts get the compact scoped view by default (server-side); see project.ts.
|
|
1568
|
+
const parameters = searchParameters(query, (name) => flag(parsed, name), (name) => parsed.flags.has(name));
|
|
1569
|
+
const result = await api("GET", `/v1/agent/catalogue/${plural}/search?${parameters}`);
|
|
1570
|
+
const notice = searchNotice(result, first);
|
|
1571
|
+
if (notice && !parsed.json)
|
|
1572
|
+
process.stderr.write(`nerdbro: ${notice}\n`);
|
|
1573
|
+
return { ok: true, command: `${first}.search`, result };
|
|
1574
|
+
}
|
|
1575
|
+
if (second === "inspect") {
|
|
1576
|
+
const result = await api("GET", `/v1/agent/catalogue/${plural}/${encodeURIComponent(required(third, "ID is required."))}`);
|
|
1577
|
+
const notice = inspectNotice(result, first);
|
|
1578
|
+
if (notice && !parsed.json)
|
|
1579
|
+
process.stderr.write(`nerdbro: ${notice}\n`);
|
|
1580
|
+
return { ok: true, command: `${first}.inspect`, result };
|
|
1259
1581
|
}
|
|
1260
|
-
if (second === "inspect")
|
|
1261
|
-
return { ok: true, command: `${first}.inspect`, result: await api("GET", `/v1/agent/catalogue/${plural}/${encodeURIComponent(required(third, "ID is required."))}`) };
|
|
1262
1582
|
}
|
|
1583
|
+
if (first === "project")
|
|
1584
|
+
return projectCommand(second, third, (name) => flag(parsed, name), api, (await readAttemptPointer())?.pointer.project);
|
|
1263
1585
|
if (first === "world") {
|
|
1264
1586
|
// Live World discovery; no World list is bundled with this package.
|
|
1265
1587
|
if (!second || second === "list")
|
|
@@ -1276,6 +1598,8 @@ async function run(parsed) {
|
|
|
1276
1598
|
if (result !== undefined)
|
|
1277
1599
|
return result;
|
|
1278
1600
|
}
|
|
1601
|
+
if (first === "episode")
|
|
1602
|
+
return episodeCommand(parsed, second, third);
|
|
1279
1603
|
if (first === "studio") {
|
|
1280
1604
|
const action = required(second, "Studio command is required.");
|
|
1281
1605
|
if (action === "create") {
|
|
@@ -1360,8 +1684,15 @@ async function run(parsed) {
|
|
|
1360
1684
|
}
|
|
1361
1685
|
if (action === "source")
|
|
1362
1686
|
return { ok: true, command: "studio.source", asset, source: await target.source() };
|
|
1363
|
-
if (["validate", "compile", "preview"].includes(action))
|
|
1364
|
-
|
|
1687
|
+
if (["validate", "compile", "preview"].includes(action)) {
|
|
1688
|
+
const result = await api("POST", `/v1/agent/studio/${encodeURIComponent(asset)}/${action}`, { source: await target.source() });
|
|
1689
|
+
// Project scope: out-of-scope dependencies warn here and block approval later.
|
|
1690
|
+
const warnings = scopeWarnings(result);
|
|
1691
|
+
const notice = scopeWarningNotice(warnings);
|
|
1692
|
+
if (notice && !parsed.json)
|
|
1693
|
+
process.stderr.write(`nerdbro: ${notice}\n`);
|
|
1694
|
+
return { ok: true, command: `studio.${action}`, asset, source_hash: sourceHash(await target.source()), result, ...(warnings.length ? { scope_warnings: warnings } : {}) };
|
|
1695
|
+
}
|
|
1365
1696
|
if (action === "history")
|
|
1366
1697
|
return { ok: true, command: "studio.history", asset, result: await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}/history`) };
|
|
1367
1698
|
if (action === "save") {
|
|
@@ -1426,7 +1757,7 @@ async function run(parsed) {
|
|
|
1426
1757
|
const candidate = await target.source();
|
|
1427
1758
|
const hash = sourceHash(candidate);
|
|
1428
1759
|
const remote = await studioStatus(parsed, asset);
|
|
1429
|
-
const remoteHash = String(remote.remote_source_hash || "");
|
|
1760
|
+
const remoteHash = String(remote.served_source_hash || remote.remote_source_hash || "");
|
|
1430
1761
|
// A new scratch asset deliberately has no server source yet. The
|
|
1431
1762
|
// browser opens its empty Studio route, then receives the unsaved local
|
|
1432
1763
|
// candidate through the bounded authoring/test bridge.
|
|
@@ -1470,28 +1801,14 @@ async function run(parsed) {
|
|
|
1470
1801
|
const candidate = await target.source();
|
|
1471
1802
|
const hash = sourceHash(candidate);
|
|
1472
1803
|
const remote = await studioStatus(parsed, asset);
|
|
1473
|
-
const remoteHash = String(remote.remote_source_hash || "");
|
|
1804
|
+
const remoteHash = String(remote.served_source_hash || remote.remote_source_hash || "");
|
|
1474
1805
|
if (!remoteHash && remote.remote_snapshot_public_id) {
|
|
1475
1806
|
throw new CliError("Studio has a draft without a readable source.", "STUDIO_SOURCE_UNAVAILABLE", 3);
|
|
1476
1807
|
}
|
|
1477
1808
|
const destination = path.resolve(flag(parsed, "out", `render-${asset}-${Date.now()}`));
|
|
1478
|
-
await
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
method: "POST",
|
|
1482
|
-
body: JSON.stringify({ asset, sourceHash: remoteHash, candidate, candidateHash: hash, recipe,
|
|
1483
|
-
framesDirectory: path.join(destination, "frames"), audioDirectory: path.join(destination, "audio"),
|
|
1484
|
-
measurePlaybackSeconds: Number(flag(parsed, "measure-playback", "0")),
|
|
1485
|
-
out: path.join(destination, "mounted.png"), timeoutMs: Number(flag(parsed, "timeout", "30000")) }),
|
|
1486
|
-
});
|
|
1487
|
-
const frames = response.result;
|
|
1488
|
-
const result = await finishRender({
|
|
1489
|
-
subject: "scene", encoder, recipe, destination, frames, started, keepFrames: parsed.flags.has("keep-frames"),
|
|
1490
|
-
identity: { asset, source_hash: hash, source_revision: frames.source_revision ?? null, runtime_fingerprint: frames.runtime_fingerprint ?? null },
|
|
1491
|
-
note: "A presentation derivative of the Scene below. The Scene Source stays the authority and stays interactive; this file is never Experience content, never certification evidence and never part of a KSPKG.",
|
|
1492
|
-
clock: "video time; the SceneClock follows it only while it plays (an authored pause holds the Scene)",
|
|
1493
|
-
extra: { scene_clock: frames.scene_clock },
|
|
1494
|
-
});
|
|
1809
|
+
const result = await renderSceneVideo({ asset, candidate, remoteHash, recipe, encoder, destination,
|
|
1810
|
+
keepFrames: parsed.flags.has("keep-frames"), timeoutMs: Number(flag(parsed, "timeout", "30000")),
|
|
1811
|
+
measurePlaybackSeconds: Number(flag(parsed, "measure-playback", "0")), json: parsed.json });
|
|
1495
1812
|
return { ok: true, command: "studio.render-video", asset, source_hash: hash, ...result };
|
|
1496
1813
|
}
|
|
1497
1814
|
if (action === "capture-preview") {
|
|
@@ -1557,6 +1874,15 @@ function help() {
|
|
|
1557
1874
|
" docs | docs search <query> | docs open <slug>",
|
|
1558
1875
|
" agent start|status|stop|instructions|init --provider claude|codex|generic",
|
|
1559
1876
|
" component|behavior|example search <query>|inspect <id>",
|
|
1877
|
+
" component|behavior search [--limit n] [--offset n] [--all-libraries] [--full | --compact]",
|
|
1878
|
+
" in a project Attempt search is compact and scoped; stderr names related ids outside the scope",
|
|
1879
|
+
"",
|
|
1880
|
+
"Authoring projects (the libraries an Attempt may author from):",
|
|
1881
|
+
" project list | project libraries [<project>] | project show [<project>] | project current",
|
|
1882
|
+
" project create --title <text> --output-target lesson|game|experience|video --home-library <slug> [--purpose <text>] [--permitted a,b]",
|
|
1883
|
+
" attempt start --label <text> --project <project> scope every later command in this workspace",
|
|
1884
|
+
" project import <stable-id> [--kind component|behavior|audio|experience] --reason <text> [--project <project>]",
|
|
1885
|
+
"",
|
|
1560
1886
|
" studio checkout|status|source|history|validate|compile|preview|save <asset>",
|
|
1561
1887
|
" studio create --scratch --title <text> --concept <text> [--target sceneComposition] [--starter world:<world>/<zone>] [--command-id <id>]",
|
|
1562
1888
|
" --target defaults to sceneComposition; targets the server lists as frozen (combined, mathGraph) are refused",
|
|
@@ -1573,6 +1899,12 @@ function help() {
|
|
|
1573
1899
|
" presentation MP4 (H.264/AAC) of the learner surface at exact SceneClock times; needs local FFmpeg; writes render.json",
|
|
1574
1900
|
" studio export <asset> --revision <revision> [--out file.kspkg]",
|
|
1575
1901
|
"",
|
|
1902
|
+
"Episodes (kheleshikhi.episode.v1: ordered Scene/Experience shots, narration, music, subtitles):",
|
|
1903
|
+
" episode validate <episode.json> check the manifest and its timeline",
|
|
1904
|
+
" episode plan|status <episode.json> [--out dir] [--cache dir] shot keys, cache hits, what would render",
|
|
1905
|
+
" episode render <episode.json> [--out dir] [--cache dir] [--workers 1-4]",
|
|
1906
|
+
" renders changed shots only, then concat + ducked mix + loudnorm + SRT + QC; needs local FFmpeg",
|
|
1907
|
+
"",
|
|
1576
1908
|
"Library authoring (<kind> = experience|audio|component|behavior):",
|
|
1577
1909
|
" experience|audio search <query> [--limit n] | inspect <stable-id>",
|
|
1578
1910
|
" audio upload <file.m4a|file.mp4> AAC in MP4/M4A, at most 24 MiB",
|