@kheleshikhi/studio-agent 0.8.0 → 0.9.0
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 +410 -92
- 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.0";
|
|
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"];
|
|
@@ -812,6 +826,67 @@ async function readRecipe(parsed) {
|
|
|
812
826
|
throw new CliError("Recipe is not valid JSON.", "VIDEO_RECIPE_INVALID", 2);
|
|
813
827
|
}
|
|
814
828
|
}
|
|
829
|
+
const PARTIAL_VIDEO = "video.partial.mp4";
|
|
830
|
+
const FAILURE_MANIFEST = "render-failed.json";
|
|
831
|
+
/**
|
|
832
|
+
* A failed render, in one place: remove what it wrote (frames unless
|
|
833
|
+
* --keep-frames, sound copies, the mix and any unverified MP4), write
|
|
834
|
+
* `render-failed.json`, and return an error that names the source, the stage
|
|
835
|
+
* that failed and that no output is reusable. A render.json and video.mp4
|
|
836
|
+
* from an earlier successful render into the same directory are left alone;
|
|
837
|
+
* this attempt never replaced them.
|
|
838
|
+
*/
|
|
839
|
+
async function renderFailure(error, context) {
|
|
840
|
+
const failure = error instanceof CliError ? error : new CliError(String(error), "VIDEO_RENDER_FAILED");
|
|
841
|
+
const details = failure.details && typeof failure.details === "object" ? failure.details : {};
|
|
842
|
+
const stage = typeof details.stage === "string" ? details.stage : context.stage;
|
|
843
|
+
const { destination } = context;
|
|
844
|
+
if (!context.keepFrames)
|
|
845
|
+
await rm(path.join(destination, "frames"), { recursive: true, force: true }).catch(() => undefined);
|
|
846
|
+
for (const leftover of ["audio", "audio-mix.wav", PARTIAL_VIDEO]) {
|
|
847
|
+
await rm(path.join(destination, leftover), { recursive: true, force: true }).catch(() => undefined);
|
|
848
|
+
}
|
|
849
|
+
const report = {
|
|
850
|
+
kind: "kheleshikhi.video-render-failure.v1",
|
|
851
|
+
subject: context.subject,
|
|
852
|
+
...context.identity,
|
|
853
|
+
stage,
|
|
854
|
+
code: failure.code,
|
|
855
|
+
message: failure.message,
|
|
856
|
+
output_reusable: false,
|
|
857
|
+
canonical: false,
|
|
858
|
+
frames_kept: context.keepFrames,
|
|
859
|
+
failed_at: new Date().toISOString(),
|
|
860
|
+
};
|
|
861
|
+
await writeFile(path.join(destination, FAILURE_MANIFEST), `${JSON.stringify(report, null, 2)}\n`).catch(() => undefined);
|
|
862
|
+
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) });
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Capture a render's frames through a daemon job (see render-jobs.ts): short
|
|
866
|
+
* status polls instead of one request held open for minutes. Ctrl-C cancels
|
|
867
|
+
* the job; progress goes to stderr on a terminal.
|
|
868
|
+
*/
|
|
869
|
+
async function renderFramesThroughDaemon(kind, legacyRoute, request, json) {
|
|
870
|
+
const interrupt = new AbortController();
|
|
871
|
+
const onInterrupt = () => interrupt.abort("interrupted");
|
|
872
|
+
process.once("SIGINT", onInterrupt);
|
|
873
|
+
let reported = 0;
|
|
874
|
+
const onProgress = (job) => {
|
|
875
|
+
if (json || !process.stderr.isTTY || Date.now() - reported < 5_000)
|
|
876
|
+
return;
|
|
877
|
+
reported = Date.now();
|
|
878
|
+
const { stage, frame, frames } = job.progress;
|
|
879
|
+
process.stderr.write(`nerdbro: rendering ${stage}${frames ? ` ${frame ?? 0}/${frames} frames` : ""} (${Math.round(job.elapsed_ms / 1000)} s)\n`);
|
|
880
|
+
};
|
|
881
|
+
try {
|
|
882
|
+
return await runRenderJob(
|
|
883
|
+
// Polls are short; only the pre-job fallback route may run long.
|
|
884
|
+
(route, init) => daemonRpc(route, route === legacyRoute ? init : { ...init, signal: AbortSignal.timeout(60_000) }), kind, legacyRoute, request, { signal: interrupt.signal, onProgress });
|
|
885
|
+
}
|
|
886
|
+
finally {
|
|
887
|
+
process.removeListener("SIGINT", onInterrupt);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
815
890
|
/**
|
|
816
891
|
* Mix, encode, verify and describe one render. The audio is mixed from the
|
|
817
892
|
* canonical bytes the runtime would have played, at the times it logged;
|
|
@@ -819,6 +894,15 @@ async function readRecipe(parsed) {
|
|
|
819
894
|
* derivative of and how it was made.
|
|
820
895
|
*/
|
|
821
896
|
async function finishRender(input) {
|
|
897
|
+
let stage = "audio_mix";
|
|
898
|
+
try {
|
|
899
|
+
return await finishRenderStages(input, (value) => { stage = value; });
|
|
900
|
+
}
|
|
901
|
+
catch (error) {
|
|
902
|
+
throw await renderFailure(error, { destination: input.destination, keepFrames: input.keepFrames, identity: input.identity, subject: input.subject, stage });
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
async function finishRenderStages(input, setStage) {
|
|
822
906
|
const { encoder, recipe, destination, frames } = input;
|
|
823
907
|
const durationMs = Math.round((frames.frames / recipe.fps) * 1000);
|
|
824
908
|
const log = (frames.audio?.events ?? []);
|
|
@@ -834,15 +918,21 @@ async function finishRender(input) {
|
|
|
834
918
|
mixArgs = await mix(encoder, segments, new Map(Object.entries(sources).map(([key, value]) => [key, value.file])), durationMs, mixed);
|
|
835
919
|
}
|
|
836
920
|
const encodeStarted = Date.now();
|
|
921
|
+
// The encode lands under a partial name and becomes video.mp4 only once it
|
|
922
|
+
// is verified: a failed render never leaves an MP4 that looks finished.
|
|
837
923
|
const output = path.join(destination, "video.mp4");
|
|
838
|
-
|
|
839
|
-
|
|
924
|
+
const partial = path.join(destination, PARTIAL_VIDEO);
|
|
925
|
+
setStage("encode");
|
|
926
|
+
await encode(encoder, recipe, path.join(destination, "frames", "%06d.png"), partial, mixed);
|
|
927
|
+
setStage("verify");
|
|
928
|
+
const probed = await probe(encoder, partial);
|
|
840
929
|
verifyProbe(recipe, probed);
|
|
841
|
-
const level = await audioLevel(encoder,
|
|
930
|
+
const level = await audioLevel(encoder, partial);
|
|
842
931
|
if (segments.length && (level.max_db === null || level.max_db < -60)) {
|
|
843
932
|
throw new CliError("Sounds were logged but the encoded audio track is silent.", "VIDEO_AUDIO_SILENT", 1, { segments: segments.length });
|
|
844
933
|
}
|
|
845
|
-
|
|
934
|
+
setStage("manifest");
|
|
935
|
+
const bytes = await readFile(partial);
|
|
846
936
|
const manifest = {
|
|
847
937
|
kind: RENDER_KIND,
|
|
848
938
|
subject: input.subject,
|
|
@@ -890,13 +980,16 @@ async function finishRender(input) {
|
|
|
890
980
|
output: { file: "video.mp4", bytes: bytes.length, sha256: sha256(bytes), probe: probed },
|
|
891
981
|
determinism: "Video times, learner actions and sound placements are exact. Pixels depend on the browser and GPU; compare renders by frame_sha256.",
|
|
892
982
|
performance: { ...(frames.playback_timing ? { playback_timing: frames.playback_timing } : {}),
|
|
893
|
-
capture_seconds: frames.capture_seconds,
|
|
983
|
+
capture_seconds: frames.capture_seconds, ...(frames.capture_phases ? { capture_phases: frames.capture_phases } : {}),
|
|
984
|
+
mix_seconds: (encodeStarted - mixStarted) / 1000,
|
|
894
985
|
encode_seconds: (Date.now() - encodeStarted) / 1000,
|
|
895
986
|
total_seconds: (Date.now() - input.started) / 1000, frames_per_second_captured: frames.frames / Math.max(frames.capture_seconds, 0.001),
|
|
896
987
|
agent_rss_mb: Math.round(process.memoryUsage().rss / 1048576) },
|
|
897
988
|
rendered_at: new Date().toISOString(),
|
|
898
989
|
};
|
|
990
|
+
await rename(partial, output);
|
|
899
991
|
await writeFile(path.join(destination, "render.json"), `${JSON.stringify({ ...manifest, frame_sha256: frames.frame_sha256 }, null, 2)}\n`);
|
|
992
|
+
await rm(path.join(destination, FAILURE_MANIFEST), { force: true });
|
|
900
993
|
if (!input.keepFrames)
|
|
901
994
|
await rm(path.join(destination, "frames"), { recursive: true, force: true });
|
|
902
995
|
if (mixed)
|
|
@@ -905,6 +998,93 @@ async function finishRender(input) {
|
|
|
905
998
|
width: probed.width, height: probed.height, duration_seconds: probed.duration, audio_segments: segments.length,
|
|
906
999
|
audio_max_dbfs: level.max_db, sha256: manifest.output.sha256 };
|
|
907
1000
|
}
|
|
1001
|
+
/** One Scene presentation render (see video.ts): capture through a daemon job, then mix, encode, verify, describe. */
|
|
1002
|
+
async function renderSceneVideo(input) {
|
|
1003
|
+
const { asset, candidate, recipe, encoder, destination, keepFrames } = input;
|
|
1004
|
+
const hash = sourceHash(candidate);
|
|
1005
|
+
await mkdir(destination, { recursive: true });
|
|
1006
|
+
const started = Date.now();
|
|
1007
|
+
const frames = await renderFramesThroughDaemon("scene", "/browser/render-frames", {
|
|
1008
|
+
asset, sourceHash: input.remoteHash, candidate, candidateHash: hash, recipe,
|
|
1009
|
+
framesDirectory: path.join(destination, "frames"), audioDirectory: path.join(destination, "audio"),
|
|
1010
|
+
measurePlaybackSeconds: input.measurePlaybackSeconds ?? 0, keepFramesOnFailure: keepFrames,
|
|
1011
|
+
out: path.join(destination, "mounted.png"), timeoutMs: input.timeoutMs
|
|
1012
|
+
}, input.json).catch(async (error) => {
|
|
1013
|
+
throw await renderFailure(error, { destination, keepFrames, subject: "scene", stage: "capture", identity: { asset, source_hash: hash } });
|
|
1014
|
+
});
|
|
1015
|
+
// The exact Component, Behavior and Audio definitions this source
|
|
1016
|
+
// compiles against right now (a shot cache keys on its digest).
|
|
1017
|
+
const compiledDependencies = await compileDependencies(api, asset, candidate).catch(() => null);
|
|
1018
|
+
return finishRender({
|
|
1019
|
+
subject: "scene", encoder, recipe, destination, frames, started, keepFrames,
|
|
1020
|
+
identity: { asset, source_hash: hash, source_revision: frames.source_revision ?? null, runtime_fingerprint: frames.runtime_fingerprint ?? null,
|
|
1021
|
+
dependencies_digest: compiledDependencies?.dependencies?.digest ?? null, dependencies: compiledDependencies?.dependencies ?? null },
|
|
1022
|
+
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.",
|
|
1023
|
+
clock: "video time; the SceneClock follows it only while it plays (an authored pause holds the Scene)",
|
|
1024
|
+
extra: { scene_clock: frames.scene_clock },
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
/** What an Experience render plays: the local source.json, or (with saved) the saved head the lock names. */
|
|
1028
|
+
async function experienceRenderSource(target, saved) {
|
|
1029
|
+
const result = { revision: null };
|
|
1030
|
+
try {
|
|
1031
|
+
const lock = await target.lock();
|
|
1032
|
+
result.revision = lock.head_revision_public_id || null;
|
|
1033
|
+
if (saved) {
|
|
1034
|
+
result.expectedRevision = result.revision || undefined;
|
|
1035
|
+
result.expectedHash = lock.source_hash;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
catch { /* No local checkout: render the saved revision. */ }
|
|
1039
|
+
if (!saved) {
|
|
1040
|
+
try {
|
|
1041
|
+
result.candidate = await target.source();
|
|
1042
|
+
result.candidateHash = sourceHash(result.candidate);
|
|
1043
|
+
}
|
|
1044
|
+
catch { /* No local source: render the saved revision. */ }
|
|
1045
|
+
}
|
|
1046
|
+
return result;
|
|
1047
|
+
}
|
|
1048
|
+
/** One Experience presentation render (see video.ts). */
|
|
1049
|
+
async function renderExperienceVideo(input) {
|
|
1050
|
+
const { stableId, recipe, encoder, destination, keepFrames, candidate, candidateHash, expectedRevision, expectedHash, revision } = input;
|
|
1051
|
+
await mkdir(destination, { recursive: true });
|
|
1052
|
+
const started = Date.now();
|
|
1053
|
+
const frames = await renderFramesThroughDaemon("experience", "/browser/render-experience-frames", {
|
|
1054
|
+
asset: stableId, sourceHash: "", recipe, timeoutMs: input.timeoutMs,
|
|
1055
|
+
framesDirectory: path.join(destination, "frames"), audioDirectory: path.join(destination, "audio"),
|
|
1056
|
+
out: path.join(destination, "mounted.png"), keepFramesOnFailure: keepFrames,
|
|
1057
|
+
library: { workspace: "experiences", selection: stableId, revision: expectedRevision, definitionHash: expectedHash },
|
|
1058
|
+
...(candidateHash ? { candidate, candidateHash } : {})
|
|
1059
|
+
}, input.json).catch(async (error) => {
|
|
1060
|
+
throw await renderFailure(error, { destination, keepFrames, subject: "experience", stage: "capture",
|
|
1061
|
+
identity: { experience: stableId, source_hash: candidateHash ?? expectedHash ?? null, source_origin: candidateHash ? "local" : "saved" } });
|
|
1062
|
+
});
|
|
1063
|
+
const context = await api("GET", "/v1/agent/context").catch(() => ({}));
|
|
1064
|
+
const document = (candidate ?? {});
|
|
1065
|
+
const scenes = document.scenes && typeof document.scenes === "object" ? document.scenes : null;
|
|
1066
|
+
// Per-Scene identity (approved revision, stored manifest, source hash and
|
|
1067
|
+
// dependency digest) and every sound's sha256: what a shot cache keys on.
|
|
1068
|
+
const sceneProvenance = await (async () => {
|
|
1069
|
+
const rendered = candidate ?? (await api("GET", `${authoringRoute("experience", stableId)}?purpose=inspect`)).document;
|
|
1070
|
+
return experienceProvenance(api, rendered);
|
|
1071
|
+
})().catch((error) => ({ error: error instanceof Error ? error.message : String(error) }));
|
|
1072
|
+
return finishRender({
|
|
1073
|
+
subject: "experience", encoder, recipe, destination, frames, started, keepFrames,
|
|
1074
|
+
identity: {
|
|
1075
|
+
experience: stableId,
|
|
1076
|
+
source_hash: candidateHash ?? frames.source_hash ?? expectedHash ?? null,
|
|
1077
|
+
source_origin: candidateHash ? "local" : "saved",
|
|
1078
|
+
source_revision: revision,
|
|
1079
|
+
scenes,
|
|
1080
|
+
scene_provenance: sceneProvenance,
|
|
1081
|
+
scene_runtime_fingerprint: context.runtime_fingerprint ?? null,
|
|
1082
|
+
},
|
|
1083
|
+
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.",
|
|
1084
|
+
clock: "video time (the Experience master timeline advances with it while it runs)",
|
|
1085
|
+
extra: { scenes_shown: frames.scenes, final_state: frames.final_state, final_scene: frames.final_scene, timeline_ms: frames.timeline_ms, completed: frames.completed },
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
908
1088
|
async function libraryInspectBrowser(parsed, kind, stableId) {
|
|
909
1089
|
const target = libraryWorkspace(parsed, kind, stableId);
|
|
910
1090
|
const viewport = flag(parsed, "viewport", "desktop");
|
|
@@ -1029,56 +1209,10 @@ async function library(parsed, kind, action, argument) {
|
|
|
1029
1209
|
const recipe = parseRecipe(await readRecipe(parsed), "experience");
|
|
1030
1210
|
const encoder = await findEncoder();
|
|
1031
1211
|
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
|
-
}
|
|
1212
|
+
const source = await experienceRenderSource(target, parsed.flags.has("saved"));
|
|
1053
1213
|
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
|
-
});
|
|
1214
|
+
const result = await renderExperienceVideo({ stableId, ...source, recipe, encoder, destination,
|
|
1215
|
+
keepFrames: parsed.flags.has("keep-frames"), timeoutMs: Number(flag(parsed, "timeout", "30000")), json: parsed.json });
|
|
1082
1216
|
return { ok: true, command: "experience.render-video", stable_id: stableId, ...result };
|
|
1083
1217
|
}
|
|
1084
1218
|
if (kind === "experience" && action === "watch") {
|
|
@@ -1123,6 +1257,173 @@ async function library(parsed, kind, action, argument) {
|
|
|
1123
1257
|
return libraryExport(parsed, kind, stableId);
|
|
1124
1258
|
return libraryInspectBrowser(parsed, kind, stableId);
|
|
1125
1259
|
}
|
|
1260
|
+
// ---------------------------------------------------------------------------
|
|
1261
|
+
// Episodes (see episode.ts): validate | plan | render | status <episode.json>
|
|
1262
|
+
// ---------------------------------------------------------------------------
|
|
1263
|
+
export const EPISODE_MAX_WORKERS = 4;
|
|
1264
|
+
/**
|
|
1265
|
+
* Shots rendered at once: what was asked, but never more than the daemon has
|
|
1266
|
+
* render browsers. A daemon started without `NERDBRO_RENDER_WORKERS` owns one,
|
|
1267
|
+
* and a second concurrent shot would fail `VIDEO_RENDER_BUSY` mid-episode.
|
|
1268
|
+
*/
|
|
1269
|
+
export function effectiveEpisodeWorkers(requested, daemonRenderWorkers) {
|
|
1270
|
+
const available = typeof daemonRenderWorkers === "number" && Number.isInteger(daemonRenderWorkers) && daemonRenderWorkers >= 1
|
|
1271
|
+
? daemonRenderWorkers : 1;
|
|
1272
|
+
if (requested <= available)
|
|
1273
|
+
return { workers: requested, note: null };
|
|
1274
|
+
return {
|
|
1275
|
+
workers: available,
|
|
1276
|
+
note: `--workers ${requested} reduced to ${available}: the running daemon has ${available} render browser(s). `
|
|
1277
|
+
+ `Restart it with NERDBRO_RENDER_WORKERS=${requested} (nerdbro agent stop) to render ${requested} shots at once.`,
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
async function readEpisodeFile(file) {
|
|
1281
|
+
const episodePath = path.resolve(required(file, "episode.json is required: nerdbro episode <validate|plan|render|status> <episode.json>."));
|
|
1282
|
+
if ((await stat(episodePath)).size > 1_048_576)
|
|
1283
|
+
throw new CliError("episode.json exceeds 1 MiB.", "EPISODE_INVALID", 2);
|
|
1284
|
+
let raw;
|
|
1285
|
+
try {
|
|
1286
|
+
raw = JSON.parse(await readFile(episodePath, "utf8"));
|
|
1287
|
+
}
|
|
1288
|
+
catch {
|
|
1289
|
+
throw new CliError("episode.json is not valid JSON.", "EPISODE_INVALID", 2);
|
|
1290
|
+
}
|
|
1291
|
+
return { episodePath, episode: parseEpisode(raw) };
|
|
1292
|
+
}
|
|
1293
|
+
/** Resolve one shot's exact identity from the live server (never from file times). */
|
|
1294
|
+
async function resolveEpisodeShot(parsed, shot) {
|
|
1295
|
+
const failed = (message, code = "EPISODE_SHOT_UNRESOLVED") => new CliError(`Shot ${shot.id}: ${message}`, code, 2, { shot: shot.id, stage: "resolve" });
|
|
1296
|
+
if (shot.scene) {
|
|
1297
|
+
const { asset } = shot.scene;
|
|
1298
|
+
let candidate;
|
|
1299
|
+
let remoteHash = "";
|
|
1300
|
+
let revision = null;
|
|
1301
|
+
if (shot.scene.source === "approved") {
|
|
1302
|
+
const loaded = await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}`);
|
|
1303
|
+
if (loaded.source_origin !== "approved" || !loaded.source)
|
|
1304
|
+
throw failed("the asset's current source is not its approved revision (a draft is open); render source local or approve it first.");
|
|
1305
|
+
if (shot.scene.revision && loaded.base_revision_public_id !== shot.scene.revision)
|
|
1306
|
+
throw failed(`revision ${shot.scene.revision} is not the asset's approved head ${loaded.base_revision_public_id}.`);
|
|
1307
|
+
candidate = loaded.source;
|
|
1308
|
+
remoteHash = String(loaded.source_hash || "");
|
|
1309
|
+
revision = loaded.base_revision_public_id ?? null;
|
|
1310
|
+
}
|
|
1311
|
+
else {
|
|
1312
|
+
candidate = await workspace(parsed, asset).source();
|
|
1313
|
+
const status = await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}/status`);
|
|
1314
|
+
remoteHash = String(status.source_hash || "");
|
|
1315
|
+
}
|
|
1316
|
+
const hash = sourceHash(candidate);
|
|
1317
|
+
const compiled = await compileDependencies(api, asset, candidate);
|
|
1318
|
+
if (!compiled.compiled)
|
|
1319
|
+
throw failed("the Scene source does not compile; run `nerdbro studio validate`.", "EPISODE_SHOT_SOURCE_INVALID");
|
|
1320
|
+
const audio = Object.fromEntries((compiled.dependencies?.audio ?? []).map((item) => [item.id, item.sha256 ?? "unresolved"]));
|
|
1321
|
+
return {
|
|
1322
|
+
sources: { main: hash },
|
|
1323
|
+
dependency_digests: { main: compiled.dependencies?.digest ?? null },
|
|
1324
|
+
audio_sha256: audio,
|
|
1325
|
+
identity: { asset, source: shot.scene.source, source_hash: hash, revision, dependencies_digest: compiled.dependencies?.digest ?? null,
|
|
1326
|
+
components: compiled.dependencies?.components ?? [], behaviors: compiled.dependencies?.behaviors ?? [] },
|
|
1327
|
+
render: { asset, candidate, remoteHash },
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
const id = shot.experience.id;
|
|
1331
|
+
const target = libraryWorkspace(parsed, "experience", id);
|
|
1332
|
+
const source = await experienceRenderSource(target, shot.experience.source === "saved");
|
|
1333
|
+
const document = source.candidate ?? (await api("GET", `${authoringRoute("experience", id)}?purpose=inspect`)).document;
|
|
1334
|
+
if (!document)
|
|
1335
|
+
throw failed("the Experience has no readable source.");
|
|
1336
|
+
const provenance = await experienceProvenance(api, document);
|
|
1337
|
+
const sources = { experience: source.candidateHash ?? source.expectedHash ?? sourceHash(document) };
|
|
1338
|
+
const digests = {};
|
|
1339
|
+
for (const [scene, identity] of Object.entries(provenance.scenes)) {
|
|
1340
|
+
sources[`scene:${scene}`] = digestOf({ asset: identity.asset ?? null, revision: identity.revision ?? null, manifest: identity.manifest_path ?? null,
|
|
1341
|
+
package: identity.package ?? null, package_sha256: identity.package_sha256 ?? null, source_hash: identity.source_hash ?? null });
|
|
1342
|
+
digests[scene] = identity.dependencies_digest;
|
|
1343
|
+
}
|
|
1344
|
+
return {
|
|
1345
|
+
sources, dependency_digests: digests, audio_sha256: provenance.audio,
|
|
1346
|
+
identity: { experience: id, source: shot.experience.source, scene_provenance: provenance },
|
|
1347
|
+
render: source,
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
async function renderEpisodeShot(parsed, encoder, shot, recipe, resolved, destination) {
|
|
1351
|
+
const timeoutMs = Number(flag(parsed, "timeout", "30000"));
|
|
1352
|
+
if (shot.scene) {
|
|
1353
|
+
const render = resolved.render;
|
|
1354
|
+
await renderSceneVideo({ ...render, recipe, encoder, destination, keepFrames: false, timeoutMs, json: true });
|
|
1355
|
+
}
|
|
1356
|
+
else {
|
|
1357
|
+
await renderExperienceVideo({ stableId: shot.experience.id, ...resolved.render, recipe, encoder, destination,
|
|
1358
|
+
keepFrames: false, timeoutMs, json: true });
|
|
1359
|
+
}
|
|
1360
|
+
return { output: path.join(destination, "video.mp4"), manifest: path.join(destination, "render.json") };
|
|
1361
|
+
}
|
|
1362
|
+
async function episodeCommand(parsed, action, file) {
|
|
1363
|
+
if (!action || !["validate", "plan", "render", "status"].includes(action)) {
|
|
1364
|
+
throw new CliError("Usage: nerdbro episode <validate|plan|render|status> <episode.json>.", "AGENT_COMMAND_UNKNOWN", 2);
|
|
1365
|
+
}
|
|
1366
|
+
const { episodePath, episode } = await readEpisodeFile(file);
|
|
1367
|
+
const timeline = episodeTimeline(episode);
|
|
1368
|
+
if (action === "validate") {
|
|
1369
|
+
return { ok: true, command: "episode.validate", episode: episode.id, title: episode.title, shots: episode.shots.length,
|
|
1370
|
+
total_frames: timeline.total_frames, total_seconds: timeline.total_seconds,
|
|
1371
|
+
timeline: timeline.shots.map((item) => ({ id: item.id, frames: item.frames, offset_seconds: item.offset_seconds })),
|
|
1372
|
+
subtitles: episodeCues(episode, timeline).length };
|
|
1373
|
+
}
|
|
1374
|
+
const out = path.resolve(flag(parsed, "out", path.join(path.dirname(episodePath), `episode-out-${episode.id}`)));
|
|
1375
|
+
const cache = path.resolve(flag(parsed, "cache", path.join(cacheDirectory(), "episode-shots")));
|
|
1376
|
+
const workers = Number(flag(parsed, "workers", "1"));
|
|
1377
|
+
if (!Number.isInteger(workers) || workers < 1 || workers > EPISODE_MAX_WORKERS) {
|
|
1378
|
+
throw new CliError(`--workers must be 1 to ${EPISODE_MAX_WORKERS}.`, "AGENT_ARGUMENT_INVALID", 2);
|
|
1379
|
+
}
|
|
1380
|
+
const encoder = await findEncoder();
|
|
1381
|
+
await ensureCompatibility();
|
|
1382
|
+
const context = await api("GET", "/v1/agent/context");
|
|
1383
|
+
const deps = {
|
|
1384
|
+
encoder, runtimeFingerprint: context.runtime_fingerprint ?? null,
|
|
1385
|
+
resolveShot: (shot) => resolveEpisodeShot(parsed, shot),
|
|
1386
|
+
renderShot: (shot, recipe, resolved, destination) => renderEpisodeShot(parsed, encoder, shot, recipe, resolved, destination),
|
|
1387
|
+
log: (message) => { if (!parsed.json && process.stderr.isTTY)
|
|
1388
|
+
process.stderr.write(`nerdbro: ${message}\n`); },
|
|
1389
|
+
};
|
|
1390
|
+
const options = { episode, baseDirectory: path.dirname(episodePath), out, cache, workers, deps };
|
|
1391
|
+
if (action === "plan" || action === "status") {
|
|
1392
|
+
const plan = await planEpisode(options);
|
|
1393
|
+
let state = null;
|
|
1394
|
+
try {
|
|
1395
|
+
state = JSON.parse(await readFile(path.join(out, "episode-state.json"), "utf8"));
|
|
1396
|
+
}
|
|
1397
|
+
catch { /* never rendered here */ }
|
|
1398
|
+
let output = null;
|
|
1399
|
+
try {
|
|
1400
|
+
const manifest = JSON.parse(await readFile(path.join(out, "episode.json"), "utf8"));
|
|
1401
|
+
const current = manifest.shots?.map((shot) => shot.key).join() === plan.shots.map((shot) => shot.key).join();
|
|
1402
|
+
output = { file: path.join(out, `${episode.id}.mp4`), sha256: manifest.output?.sha256 ?? null, matches_current_shots: current,
|
|
1403
|
+
qc_passed: Array.isArray(manifest.qc) ? manifest.qc.every((check) => check.pass) : null };
|
|
1404
|
+
}
|
|
1405
|
+
catch { /* no assembled output */ }
|
|
1406
|
+
return { ok: true, command: `episode.${action}`, episode: episode.id, out, cache,
|
|
1407
|
+
total_frames: plan.timeline.total_frames, total_seconds: plan.timeline.total_seconds,
|
|
1408
|
+
shots: plan.shots.map((shot) => ({ id: shot.id, subject: shot.subject, key: shot.key, frames: shot.frames, cache: shot.cache,
|
|
1409
|
+
...(shot.cache_reason ? { cache_reason: shot.cache_reason } : {}), offset_seconds: shot.offset_seconds })),
|
|
1410
|
+
would_render: plan.shots.filter((shot) => shot.cache !== "valid").map((shot) => shot.id),
|
|
1411
|
+
...(action === "status" ? { state, output } : {}) };
|
|
1412
|
+
}
|
|
1413
|
+
const daemon = await startDaemon(true);
|
|
1414
|
+
const concurrency = effectiveEpisodeWorkers(workers, daemon.render_workers);
|
|
1415
|
+
if (concurrency.note)
|
|
1416
|
+
deps.log?.(concurrency.note);
|
|
1417
|
+
const result = await renderEpisode({ ...options, workers: concurrency.workers });
|
|
1418
|
+
if (!result.ok) {
|
|
1419
|
+
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) });
|
|
1420
|
+
}
|
|
1421
|
+
return { ok: result.ok, command: "episode.render", episode: episode.id, output: result.output, srt: result.srt, manifest: result.manifest,
|
|
1422
|
+
state: result.state, reused: result.reused, rendered: result.rendered,
|
|
1423
|
+
shots: result.shots.map((shot) => ({ id: shot.id, key: shot.key, state: shot.state, render_seconds: shot.render_seconds ?? null })),
|
|
1424
|
+
qc: result.qc, timings: result.timings, workers: concurrency.workers,
|
|
1425
|
+
...(concurrency.note ? { workers_note: concurrency.note } : {}) };
|
|
1426
|
+
}
|
|
1126
1427
|
async function run(parsed) {
|
|
1127
1428
|
const [first, second, third] = parsed.args;
|
|
1128
1429
|
assertSupportedFlags(parsed);
|
|
@@ -1144,13 +1445,14 @@ async function run(parsed) {
|
|
|
1144
1445
|
if (await readAttemptPointer())
|
|
1145
1446
|
throw new CliError("This workspace already has an Attempt.", "AGENT_ATTEMPT_ALREADY_ACTIVE", 3);
|
|
1146
1447
|
const origin = (await readConfig()).apiOrigin;
|
|
1147
|
-
const
|
|
1448
|
+
const project = flag(parsed, "project");
|
|
1449
|
+
const server = await api("POST", "/v1/agent/attempts", { label, mode, server_origin: origin, ...(project ? { project: projectId(project) } : {}) });
|
|
1148
1450
|
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") };
|
|
1451
|
+
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
1452
|
}
|
|
1151
1453
|
if (second === "current") {
|
|
1152
1454
|
const active = await readAttemptPointer();
|
|
1153
|
-
return { ok: true, command: "attempt.current", attempt_id: active?.pointer.attempt_id || null };
|
|
1455
|
+
return { ok: true, command: "attempt.current", attempt_id: active?.pointer.attempt_id || null, ...(active?.pointer.project ? { project: active.pointer.project } : {}) };
|
|
1154
1456
|
}
|
|
1155
1457
|
if (second === "status") {
|
|
1156
1458
|
const active = await readAttemptPointer();
|
|
@@ -1248,18 +1550,24 @@ async function run(parsed) {
|
|
|
1248
1550
|
const plural = `${first}s`;
|
|
1249
1551
|
if (second === "search") {
|
|
1250
1552
|
const query = parsed.args.slice(2).join(" ");
|
|
1251
|
-
|
|
1252
|
-
const
|
|
1253
|
-
const
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1553
|
+
// Project Attempts get the compact scoped view by default (server-side); see project.ts.
|
|
1554
|
+
const parameters = searchParameters(query, (name) => flag(parsed, name), (name) => parsed.flags.has(name));
|
|
1555
|
+
const result = await api("GET", `/v1/agent/catalogue/${plural}/search?${parameters}`);
|
|
1556
|
+
const notice = searchNotice(result, first);
|
|
1557
|
+
if (notice && !parsed.json)
|
|
1558
|
+
process.stderr.write(`nerdbro: ${notice}\n`);
|
|
1559
|
+
return { ok: true, command: `${first}.search`, result };
|
|
1560
|
+
}
|
|
1561
|
+
if (second === "inspect") {
|
|
1562
|
+
const result = await api("GET", `/v1/agent/catalogue/${plural}/${encodeURIComponent(required(third, "ID is required."))}`);
|
|
1563
|
+
const notice = inspectNotice(result, first);
|
|
1564
|
+
if (notice && !parsed.json)
|
|
1565
|
+
process.stderr.write(`nerdbro: ${notice}\n`);
|
|
1566
|
+
return { ok: true, command: `${first}.inspect`, result };
|
|
1259
1567
|
}
|
|
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
1568
|
}
|
|
1569
|
+
if (first === "project")
|
|
1570
|
+
return projectCommand(second, third, (name) => flag(parsed, name), api, (await readAttemptPointer())?.pointer.project);
|
|
1263
1571
|
if (first === "world") {
|
|
1264
1572
|
// Live World discovery; no World list is bundled with this package.
|
|
1265
1573
|
if (!second || second === "list")
|
|
@@ -1276,6 +1584,8 @@ async function run(parsed) {
|
|
|
1276
1584
|
if (result !== undefined)
|
|
1277
1585
|
return result;
|
|
1278
1586
|
}
|
|
1587
|
+
if (first === "episode")
|
|
1588
|
+
return episodeCommand(parsed, second, third);
|
|
1279
1589
|
if (first === "studio") {
|
|
1280
1590
|
const action = required(second, "Studio command is required.");
|
|
1281
1591
|
if (action === "create") {
|
|
@@ -1360,8 +1670,15 @@ async function run(parsed) {
|
|
|
1360
1670
|
}
|
|
1361
1671
|
if (action === "source")
|
|
1362
1672
|
return { ok: true, command: "studio.source", asset, source: await target.source() };
|
|
1363
|
-
if (["validate", "compile", "preview"].includes(action))
|
|
1364
|
-
|
|
1673
|
+
if (["validate", "compile", "preview"].includes(action)) {
|
|
1674
|
+
const result = await api("POST", `/v1/agent/studio/${encodeURIComponent(asset)}/${action}`, { source: await target.source() });
|
|
1675
|
+
// Project scope: out-of-scope dependencies warn here and block approval later.
|
|
1676
|
+
const warnings = scopeWarnings(result);
|
|
1677
|
+
const notice = scopeWarningNotice(warnings);
|
|
1678
|
+
if (notice && !parsed.json)
|
|
1679
|
+
process.stderr.write(`nerdbro: ${notice}\n`);
|
|
1680
|
+
return { ok: true, command: `studio.${action}`, asset, source_hash: sourceHash(await target.source()), result, ...(warnings.length ? { scope_warnings: warnings } : {}) };
|
|
1681
|
+
}
|
|
1365
1682
|
if (action === "history")
|
|
1366
1683
|
return { ok: true, command: "studio.history", asset, result: await api("GET", `/v1/agent/studio/${encodeURIComponent(asset)}/history`) };
|
|
1367
1684
|
if (action === "save") {
|
|
@@ -1475,23 +1792,9 @@ async function run(parsed) {
|
|
|
1475
1792
|
throw new CliError("Studio has a draft without a readable source.", "STUDIO_SOURCE_UNAVAILABLE", 3);
|
|
1476
1793
|
}
|
|
1477
1794
|
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
|
-
});
|
|
1795
|
+
const result = await renderSceneVideo({ asset, candidate, remoteHash, recipe, encoder, destination,
|
|
1796
|
+
keepFrames: parsed.flags.has("keep-frames"), timeoutMs: Number(flag(parsed, "timeout", "30000")),
|
|
1797
|
+
measurePlaybackSeconds: Number(flag(parsed, "measure-playback", "0")), json: parsed.json });
|
|
1495
1798
|
return { ok: true, command: "studio.render-video", asset, source_hash: hash, ...result };
|
|
1496
1799
|
}
|
|
1497
1800
|
if (action === "capture-preview") {
|
|
@@ -1557,6 +1860,15 @@ function help() {
|
|
|
1557
1860
|
" docs | docs search <query> | docs open <slug>",
|
|
1558
1861
|
" agent start|status|stop|instructions|init --provider claude|codex|generic",
|
|
1559
1862
|
" component|behavior|example search <query>|inspect <id>",
|
|
1863
|
+
" component|behavior search [--limit n] [--offset n] [--all-libraries] [--full | --compact]",
|
|
1864
|
+
" in a project Attempt search is compact and scoped; stderr names related ids outside the scope",
|
|
1865
|
+
"",
|
|
1866
|
+
"Authoring projects (the libraries an Attempt may author from):",
|
|
1867
|
+
" project list | project libraries [<project>] | project show [<project>] | project current",
|
|
1868
|
+
" project create --title <text> --output-target lesson|game|experience|video --home-library <slug> [--purpose <text>] [--permitted a,b]",
|
|
1869
|
+
" attempt start --label <text> --project <project> scope every later command in this workspace",
|
|
1870
|
+
" project import <stable-id> [--kind component|behavior|audio|experience] --reason <text> [--project <project>]",
|
|
1871
|
+
"",
|
|
1560
1872
|
" studio checkout|status|source|history|validate|compile|preview|save <asset>",
|
|
1561
1873
|
" studio create --scratch --title <text> --concept <text> [--target sceneComposition] [--starter world:<world>/<zone>] [--command-id <id>]",
|
|
1562
1874
|
" --target defaults to sceneComposition; targets the server lists as frozen (combined, mathGraph) are refused",
|
|
@@ -1573,6 +1885,12 @@ function help() {
|
|
|
1573
1885
|
" presentation MP4 (H.264/AAC) of the learner surface at exact SceneClock times; needs local FFmpeg; writes render.json",
|
|
1574
1886
|
" studio export <asset> --revision <revision> [--out file.kspkg]",
|
|
1575
1887
|
"",
|
|
1888
|
+
"Episodes (kheleshikhi.episode.v1: ordered Scene/Experience shots, narration, music, subtitles):",
|
|
1889
|
+
" episode validate <episode.json> check the manifest and its timeline",
|
|
1890
|
+
" episode plan|status <episode.json> [--out dir] [--cache dir] shot keys, cache hits, what would render",
|
|
1891
|
+
" episode render <episode.json> [--out dir] [--cache dir] [--workers 1-4]",
|
|
1892
|
+
" renders changed shots only, then concat + ducked mix + loudnorm + SRT + QC; needs local FFmpeg",
|
|
1893
|
+
"",
|
|
1576
1894
|
"Library authoring (<kind> = experience|audio|component|behavior):",
|
|
1577
1895
|
" experience|audio search <query> [--limit n] | inspect <stable-id>",
|
|
1578
1896
|
" audio upload <file.m4a|file.mp4> AAC in MP4/M4A, at most 24 MiB",
|