@rig-c/review 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 +21 -11
- package/docs/DESIGN.md +1 -1
- package/docs/REVIEW_PACKAGE.md +13 -7
- package/package.json +1 -1
- package/src/baseline-reach.ts +177 -0
- package/src/baseline.ts +14 -9
- package/src/cli.ts +2 -2
- package/src/compare-server.ts +1 -1
- package/src/frame-clock.ts +30 -0
- package/src/front.ts +3 -0
- package/src/handoff.ts +59 -7
- package/src/manifest.ts +13 -3
- package/src/open-protocol.ts +4 -3
- package/src/open.ts +12 -3
- package/src/rebuild.ts +198 -21
- package/src/server.ts +8 -1
- package/web/compare.ts +7 -5
- package/web/index.html +1 -1
- package/web/label-placement.ts +46 -0
- package/web/main.ts +73 -10
- package/web/rebuild-client.ts +37 -7
- package/web/rig-overlays.ts +24 -9
- package/web/styles.css +1 -0
package/src/rebuild.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { createHash } from 'node:crypto';
|
|
12
12
|
import { existsSync } from 'node:fs';
|
|
13
|
-
import { copyFile, mkdir, readFile, readdir, realpath, rm, writeFile, lstat } from 'node:fs/promises';
|
|
13
|
+
import { copyFile, mkdir, readFile, readdir, realpath, rename, rm, writeFile, lstat } from 'node:fs/promises';
|
|
14
14
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
15
15
|
import { object, packagePath, safeFile, type ReviewPackage } from './manifest.ts';
|
|
16
16
|
import { compileArgs, firstRefusal, modelingProducerTooOld, REMESH_DELTA_SINCE, remeshArgs, remeshDeltaArgs, resolvePose, resolveProducer, runTool, versionAtLeast, type CompileRequest, type InstalledTool, type ProcessResult } from './producer.ts';
|
|
@@ -24,11 +24,13 @@ import { CHEST_REGIONS_MAX } from './chest-regions.ts';
|
|
|
24
24
|
import { bustMeshEntry, deriveBust, parseBustSpec, BustSpecError, type BustSpec } from './chest-landmarks.ts';
|
|
25
25
|
import { modelMeshTriangles, remeshCounts, type RemeshCounts } from './mesh-entry.ts';
|
|
26
26
|
import { patchRoute } from './retune-route.ts';
|
|
27
|
+
import { producerHint } from './baseline-reach.ts';
|
|
27
28
|
import { carriedCheck, RETUNE_CHECK_SINCE, RETUNE_FILE, retuneCheckSummary, revisionBinding } from './retune-check.ts';
|
|
28
29
|
import { absolutizeBuildFiles, buildLines, buildNamedFiles, buildReadout, MAX_BUILD_READOUT, needsBuild, newBuildFile, recompositeFigures, type RecompositeFigures } from './build-route.ts';
|
|
29
30
|
import { jsonDiff } from './config-patch.ts';
|
|
30
31
|
import { CLOTH_KEYPOINTS_UNDECLARED, CLOTH_PATCH_MIXED, CLOTH_SOURCE_UNAVAILABLE, CLOTH_WRITE_SINCE, clothLinks, clothPatchKind, readClothTargets, writtenDiff, type ClothTagView, type WrittenDiff } from './cloth-recipe.ts';
|
|
31
32
|
import { CHECK_ENVIRONMENT_SINCE, parseSchedule, ScheduleError, scheduleText } from './environment-schedule.ts';
|
|
33
|
+
import { samplesClock } from './frame-clock.ts';
|
|
32
34
|
import { cleanArgs, mapText, motionSummary, parseRetargetRequest, retargetArgs, retargetLines, retargetRecord, retargetRecordText, RetargetError, type MotionSummary, type RetargetLine, type RetargetRecord } from './retarget.ts';
|
|
33
35
|
|
|
34
36
|
export const REBUILD_SCHEMA = 'rig-review-rebuild/1';
|
|
@@ -200,6 +202,25 @@ export function designationId(input: { sourceHash: string; configHash: string; p
|
|
|
200
202
|
}
|
|
201
203
|
export const rebuildRoot = (stateDir: string, sourceHash: string) => join(stateDir, 'rebuild', sourceHash);
|
|
202
204
|
export const rebuildDir = (stateDir: string, sourceHash: string, id: string) => join(rebuildRoot(stateDir, sourceHash), id);
|
|
205
|
+
/**
|
|
206
|
+
* #166: where the applied records' earlier builds wait for an undo — outside the scratch root, so the scratch root still
|
|
207
|
+
* holds only the build on the stage. At most `MAX_KEPT_BUILDS` builds are on disk per package (the stage's included);
|
|
208
|
+
* an older record's build is evicted, and undoing to it re-runs its producer step from the record.
|
|
209
|
+
*/
|
|
210
|
+
export const keptRoot = (stateDir: string, sourceHash: string) => join(stateDir, 'rebuild-kept', sourceHash);
|
|
211
|
+
export const MAX_KEPT_BUILDS = 8;
|
|
212
|
+
/** The undo refusal: the predecessor cannot be restored byte for byte, and review does not guess. */
|
|
213
|
+
export const UNDO_PREDECESSOR_CHANGED = 'UNDO_PREDECESSOR_CHANGED';
|
|
214
|
+
/** One applied record of the session's stack (#166): the request as sent (to re-run it), its inputs and its build by hash. */
|
|
215
|
+
export interface AppliedEntry {
|
|
216
|
+
id: string; body: unknown; configHash: string;
|
|
217
|
+
producer: { package: string; version: string }; compiler: { package: string; version: string };
|
|
218
|
+
files: Record<string, string>; modelHash: string;
|
|
219
|
+
/** The job as it settled, served back on undo. */
|
|
220
|
+
job: RebuildJob | PatchJob | DeltaJob | RetargetJob;
|
|
221
|
+
}
|
|
222
|
+
/** `GET /api/rebuild/history`: the applied records in order, the last one on the stage; `kept`: its build is still on disk. */
|
|
223
|
+
export interface AppliedHistory { schema: 'rig-review-applied-history/1'; depth: number; records: { id: string; kind: 'regions' | 'patch' | 'delta' | 'retarget'; op: string; what: string; modelHash: string; kept: boolean }[] }
|
|
203
224
|
|
|
204
225
|
export type RebuildStatus = 'running' | 'built' | 'refused' | 'failed';
|
|
205
226
|
export interface RebuildBuild { model: string; modelUrl: string; modelHash: string; files: Record<string, string> }
|
|
@@ -209,6 +230,8 @@ export interface RebuildJob {
|
|
|
209
230
|
producer: { package: string; version: string }; compiler: { package: string; version: string };
|
|
210
231
|
lines: string[]; producerLine: string | null; exitCode: number | null; wallMs: { remesh: number | null; compile: number | null };
|
|
211
232
|
build: RebuildBuild | null;
|
|
233
|
+
/** #148: review's reading of a producer refusal it can explain (`producerHint`), beside the producer's line; absent otherwise. */
|
|
234
|
+
hint?: string;
|
|
212
235
|
}
|
|
213
236
|
/**
|
|
214
237
|
* A patch Apply (#102): the config patch, its hash and the config bytes it was made against; the op's, the compile's
|
|
@@ -222,6 +245,8 @@ export interface PatchJob {
|
|
|
222
245
|
request: { patch: ConfigPatch; patchSha256: string; configHash: string };
|
|
223
246
|
producer: { package: string; version: string }; compiler: { package: string; version: string };
|
|
224
247
|
lines: string[]; producerLine: string | null; exitCode: number | null; wallMs: { op: number | null; compile: number | null; check: number | null };
|
|
248
|
+
/** #148: as `RebuildJob.hint`. */
|
|
249
|
+
hint?: string;
|
|
225
250
|
check: CheckSummary | null; diff: { count: number; paths: string[] } | null;
|
|
226
251
|
/** #125: the per-file diff of every producer document (`src/producer-files.ts`) and the images by hash. */
|
|
227
252
|
files: FilesDiff | null;
|
|
@@ -302,6 +327,8 @@ export interface RetargetJob {
|
|
|
302
327
|
lines: string[]; producerLine: string | null; refusals: string[]; exitCode: number | null;
|
|
303
328
|
wallMs: { clean: number | null; retarget: number | null; compile: number | null };
|
|
304
329
|
files: FilesDiff | null; motion: MotionSummary | null; readings: RetargetLine[];
|
|
330
|
+
/** The run's report `frameTimes` (`motion.report.json`): the new animation's own clock, null when absent or malformed (#164). */
|
|
331
|
+
frameTimes: number[] | null;
|
|
305
332
|
build: RebuildBuild | null;
|
|
306
333
|
}
|
|
307
334
|
/** Deterministic: the package, the config bytes, the record's bytes and both tool versions. No clock. */
|
|
@@ -337,6 +364,8 @@ export interface DeltaJob {
|
|
|
337
364
|
request: { part: string; delta: Record<string, unknown>; deltaSha256: string; configHash: string };
|
|
338
365
|
producer: { package: string; version: string }; compiler: { package: string; version: string };
|
|
339
366
|
lines: string[]; producerLine: string | null; exitCode: number | null; wallMs: { op: number | null; compile: number | null };
|
|
367
|
+
/** #148: as `RebuildJob.hint`. */
|
|
368
|
+
hint?: string;
|
|
340
369
|
files: FilesDiff | null;
|
|
341
370
|
remesh: (RemeshCounts & { modelTriangles: number | null }) | null;
|
|
342
371
|
build: RebuildBuild | null;
|
|
@@ -482,6 +511,11 @@ async function hashBuild(build: string, id: string): Promise<RebuildBuild> {
|
|
|
482
511
|
export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
483
512
|
const jobs = new Map<string, RebuildJob | PatchJob | DeltaJob | RetargetJob>();
|
|
484
513
|
let running: string | null = null;
|
|
514
|
+
/** #166: the session's applied records, in order; the last is on the stage. The request bodies by job id. */
|
|
515
|
+
const stack: AppliedEntry[] = [];
|
|
516
|
+
const bodies = new Map<string, unknown>();
|
|
517
|
+
/** #166: an undo re-running an evicted predecessor: its id and the build files it must reproduce. */
|
|
518
|
+
let restoring: { id: string; files: Record<string, string> } | null = null;
|
|
485
519
|
const snapshot = <J extends RebuildJob | PatchJob | DeltaJob | RetargetJob>(job: J): J => ({ ...job, lines: [...job.lines] });
|
|
486
520
|
|
|
487
521
|
/** The state directory may not sit inside the package or the handoff's root (the parent of an opened out/). */
|
|
@@ -500,7 +534,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
500
534
|
try { assertStateOutside(); } catch { return null; }
|
|
501
535
|
const handoff = p.handoff, key = baselineKey(p.sourceHash, handoff, resolved.producer, resolved.compiler), rig = handoff.rig ?? p.root;
|
|
502
536
|
const show = (rel: string) => relative(p.root, join(rig, rel)).split(sep).join('/');
|
|
503
|
-
baseline = { schema: 'rig-review-baseline/1', key, status: 'running', producer: tool(resolved.producer), firstDifference: null, hashes: null, line: `baseline: running ${resolved.producer.package} ${resolved.producer.version} rig into scratch…`, exitCode: null, wallMs: null };
|
|
537
|
+
baseline = { schema: 'rig-review-baseline/1', key, status: 'running', producer: tool(resolved.producer), firstDifference: null, hashes: null, reach: null, line: `baseline: running ${resolved.producer.package} ${resolved.producer.version} rig into scratch…`, exitCode: null, wallMs: null };
|
|
504
538
|
const started = baseline;
|
|
505
539
|
baselineRun = runBaseline(baselineDir(options.stateDir, key), key, handoff, resolved.producer, show)
|
|
506
540
|
.catch((error): BaselineResult => ({ ...started, status: 'failed', line: `baseline: failed — ${error instanceof Error ? error.message : String(error)}` }))
|
|
@@ -581,6 +615,121 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
581
615
|
const dir = job ? rebuildDir(options.stateDir, p.sourceHash, job.id) : '';
|
|
582
616
|
return job && job.status === 'built' && job.build ? { id: job.id, build: join(dir, 'build'), modelHash: job.build.modelHash, spec: join(dir, !('kind' in job) ? 'revision' : job.op === 'build' || (job.kind === 'patch' && job.cloth?.route === 'build') ? 'out/rig' : 'rig') } : null;
|
|
583
617
|
}
|
|
618
|
+
/**
|
|
619
|
+
* #166: the line refusing an undo's re-run whose build is not the one its record names (the first differing file by
|
|
620
|
+
* hash), or null — also null for any job that is not an undo's re-run.
|
|
621
|
+
*/
|
|
622
|
+
function restoredDifference(id: string, files: Record<string, string>): string | null {
|
|
623
|
+
if (restoring?.id !== id) return null;
|
|
624
|
+
const names = [...new Set([...Object.keys(restoring.files), ...Object.keys(files)])].sort();
|
|
625
|
+
const file = names.find(n => restoring!.files[n] !== files[n]);
|
|
626
|
+
if (!file) return null;
|
|
627
|
+
const side = (h: string | undefined) => (h ? `${h.slice(0, 12)}…` : 'absent'), recorded = restoring.files[file];
|
|
628
|
+
restoring = null;
|
|
629
|
+
return `${UNDO_PREDECESSOR_CHANGED}: ${file} — the record's build has sha256 ${side(recorded)}, the re-run wrote ${side(files[file])}; the predecessor cannot be restored byte for byte, so the stage stays on the last Apply`;
|
|
630
|
+
}
|
|
631
|
+
/** #166: an undo's re-run computed another build id than its record's — some input it hashes is not what it was. */
|
|
632
|
+
function guardRestore(id: string): void {
|
|
633
|
+
if (!restoring || restoring.id === id) return;
|
|
634
|
+
const was = restoring.id; restoring = null;
|
|
635
|
+
throw new RebuildError(UNDO_PREDECESSOR_CHANGED, `build id ${was.slice(0, 12)}… → ${id.slice(0, 12)}… — an input the record's build id hashes (the package, the handoff config, the build's painting, runs or named files, the keypoints, the producer or the compiler) is not what it was; the stage stays on the last Apply`);
|
|
636
|
+
}
|
|
637
|
+
/** #166: a kept build asked for again moves back into the scratch root (where reuse reads it). */
|
|
638
|
+
async function unkeep(id: string): Promise<void> {
|
|
639
|
+
const from = join(keptRoot(options.stateDir, p.sourceHash), id), to = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
640
|
+
if (!(await exists(from))) return;
|
|
641
|
+
if (await exists(to)) { await rm(from, { recursive: true, force: true }); return; }
|
|
642
|
+
await mkdir(rebuildRoot(options.stateDir, p.sourceHash), { recursive: true, mode: 0o700 });
|
|
643
|
+
await rename(from, to);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* #166: a built (or reused) Apply lands on the stack — or, for an undo's re-run or reuse of the predecessor, the undone
|
|
647
|
+
* record leaves it — and the scratch tree is arranged: the stage's build in the scratch root, the other records' most
|
|
648
|
+
* recent `MAX_KEPT_BUILDS - 1` builds in keptRoot, every other build removed.
|
|
649
|
+
*/
|
|
650
|
+
async function landed(job: RebuildJob | PatchJob | DeltaJob | RetargetJob): Promise<void> {
|
|
651
|
+
if (restoring?.id === job.id) { stack.pop(); restoring = null; }
|
|
652
|
+
else if (stack.at(-1)?.id !== job.id && job.build) stack.push({ id: job.id, body: bodies.get(job.id) ?? null, configHash: job.request.configHash, producer: { ...job.producer }, compiler: { ...job.compiler }, files: { ...job.build.files }, modelHash: job.build.modelHash, job: snapshot(job) });
|
|
653
|
+
latestId = stack.at(-1)?.id ?? null;
|
|
654
|
+
await arrange();
|
|
655
|
+
}
|
|
656
|
+
async function arrange(): Promise<void> {
|
|
657
|
+
const root = rebuildRoot(options.stateDir, p.sourceHash), kept = keptRoot(options.stateDir, p.sourceHash);
|
|
658
|
+
const top = stack.at(-1)?.id ?? null, order: string[] = [];
|
|
659
|
+
for (let i = stack.length - 2; i >= 0; i--) { const id = stack[i]!.id; if (id !== top && !order.includes(id)) order.push(id); }
|
|
660
|
+
const keep = new Set(order.slice(0, MAX_KEPT_BUILDS - 1));
|
|
661
|
+
const list = async (dir: string) => { try { return (await readdir(dir)).filter(n => /^[a-f0-9]{64}$/.test(n)); } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return []; throw e; } };
|
|
662
|
+
if (top) await unkeep(top);
|
|
663
|
+
for (const id of await list(root)) {
|
|
664
|
+
if (id === top) continue;
|
|
665
|
+
if (keep.has(id) && !(await exists(join(kept, id)))) { await mkdir(kept, { recursive: true, mode: 0o700 }); await rename(join(root, id), join(kept, id)); }
|
|
666
|
+
else await rm(join(root, id), { recursive: true, force: true });
|
|
667
|
+
jobs.delete(id);
|
|
668
|
+
}
|
|
669
|
+
for (const id of await list(kept)) if (!keep.has(id)) await rm(join(kept, id), { recursive: true, force: true });
|
|
670
|
+
}
|
|
671
|
+
/** `GET /api/rebuild/history` (#166). */
|
|
672
|
+
async function history(): Promise<AppliedHistory> {
|
|
673
|
+
const records: AppliedHistory['records'] = [];
|
|
674
|
+
for (const [i, e] of stack.entries()) {
|
|
675
|
+
const job = e.job, last = i === stack.length - 1;
|
|
676
|
+
const kind = !('kind' in job) ? 'regions' as const : job.kind;
|
|
677
|
+
const what = !('kind' in job) ? `${job.request.bust ? 'contour.bust landmarks' : `${job.request.regions.length} region(s)`} on ${job.request.part}`
|
|
678
|
+
: job.kind === 'patch' ? job.request.patch.ops.map(o => `${o.op} ${o.path}`).slice(0, 3).join(', ') + (job.request.patch.ops.length > 3 ? `, +${job.request.patch.ops.length - 3}` : '')
|
|
679
|
+
: job.kind === 'delta' ? `mesh edit on ${job.request.part}` : `retarget "${job.request.record.flags.animation}"`;
|
|
680
|
+
const kept = await exists(join(last ? rebuildRoot(options.stateDir, p.sourceHash) : keptRoot(options.stateDir, p.sourceHash), e.id));
|
|
681
|
+
records.push({ id: e.id, kind, op: 'op' in job ? job.op : 'remesh', what, modelHash: e.modelHash, kept });
|
|
682
|
+
}
|
|
683
|
+
return { schema: 'rig-review-applied-history/1', depth: stack.length, records };
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* `POST /api/rebuild/undo` with `{from}` (#166): the last applied record leaves the stack and the stage returns to its
|
|
687
|
+
* predecessor's build, or to the inspected build when none is left. `from` is the record the page shows on the stage
|
|
688
|
+
* (`UNDO_STALE` otherwise). A kept predecessor is restored from disk after its files are re-hashed against its record;
|
|
689
|
+
* an evicted one re-runs its recorded request through the same runner, refused before anything runs when the handoff
|
|
690
|
+
* config or a tool version is not the record's, and refused after when the build is not byte-identical — either way
|
|
691
|
+
* `UNDO_PREDECESSOR_CHANGED: <what>`, the stage left on the last Apply. Undo writes nothing into the package.
|
|
692
|
+
*/
|
|
693
|
+
async function undo(input: unknown): Promise<{ status: number; body: unknown }> {
|
|
694
|
+
const v = object(input, 'undo');
|
|
695
|
+
if (Object.keys(v).join(',') !== 'from' || typeof v.from !== 'string' || !/^[a-f0-9]{64}$/.test(v.from)) bad('expected exactly {from: <the build id on the stage>}');
|
|
696
|
+
assertStateOutside();
|
|
697
|
+
if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
|
|
698
|
+
const top = stack.at(-1);
|
|
699
|
+
if (!top) throw new RebuildError('UNDO_NOTHING_APPLIED', 'no Apply is on the stage; the inspected build is');
|
|
700
|
+
if (top.id !== v.from) throw new RebuildError('UNDO_STALE', `the stage's last Apply is ${top.id.slice(0, 12)}…, not ${String(v.from).slice(0, 12)}…; reload`);
|
|
701
|
+
const pred = stack.at(-2);
|
|
702
|
+
if (!pred) { stack.pop(); latestId = null; await arrange(); return { status: 200, body: { status: 'inspected', undone: top.id, history: await history() } }; }
|
|
703
|
+
// (i) the kept build: back into the scratch root, re-hashed against result.json and against the record.
|
|
704
|
+
await unkeep(pred.id);
|
|
705
|
+
const dir = rebuildDir(options.stateDir, p.sourceHash, pred.id);
|
|
706
|
+
const kept = await reuse<RebuildJob | PatchJob | DeltaJob | RetargetJob>(dir, pred.id);
|
|
707
|
+
if (kept && JSON.stringify(kept.build!.files) === JSON.stringify(pred.files)) {
|
|
708
|
+
jobs.set(pred.id, kept); stack.pop(); latestId = pred.id; await arrange();
|
|
709
|
+
return { status: 200, body: { status: 'built', undone: top.id, job: snapshot(kept), history: await history() } };
|
|
710
|
+
}
|
|
711
|
+
await rm(dir, { recursive: true, force: true });
|
|
712
|
+
// (ii) evicted (or not its record's bytes): re-run the record's request, only on the inputs it was built from.
|
|
713
|
+
if (pred.body === null) throw new RebuildError(UNDO_PREDECESSOR_CHANGED, `${pred.id.slice(0, 12)}… — the record carries no request to re-run and its build is gone`);
|
|
714
|
+
const handoff = p.handoff;
|
|
715
|
+
if (handoff) {
|
|
716
|
+
let now = 'unreadable';
|
|
717
|
+
try { now = sha256(await readFile(handoff.config)); } catch { /* named below */ }
|
|
718
|
+
if (now !== pred.configHash) throw new RebuildError(UNDO_PREDECESSOR_CHANGED, `${handoff.record.config.path} sha256 ${pred.configHash.slice(0, 12)}… → ${now === 'unreadable' ? now : `${now.slice(0, 12)}…`}; the evicted build was made from the config as it was`);
|
|
719
|
+
}
|
|
720
|
+
const retargeting = 'kind' in pred.job && pred.job.kind === 'retarget';
|
|
721
|
+
const resolved = retargeting ? resolvePose(options.starts) : resolveProducer(options.starts);
|
|
722
|
+
if (!resolved.ok) throw new RebuildError(UNDO_PREDECESSOR_CHANGED, `${pred.producer.package} ${pred.producer.version} → ${resolved.error}`);
|
|
723
|
+
for (const [was, now] of [[pred.producer, resolved.producer], [pred.compiler, resolved.compiler]] as const) {
|
|
724
|
+
if (was.package !== now.package || was.version !== now.version) throw new RebuildError(UNDO_PREDECESSOR_CHANGED, `${was.package} ${was.version} → ${now.package} ${now.version}; the evicted build was made with the version its record names`);
|
|
725
|
+
}
|
|
726
|
+
restoring = { id: pred.id, files: { ...pred.files } };
|
|
727
|
+
let started: { status: number; body: unknown };
|
|
728
|
+
try { started = await start(pred.body); } catch (e) { restoring = null; throw e; }
|
|
729
|
+
const body = started.body as { id?: string; status?: string };
|
|
730
|
+
if (body.id !== pred.id) { restoring = null; return started; }
|
|
731
|
+
return { status: started.status, body: { status: body.status === 'running' ? 'running' : body.status, undone: top.id, job: body, ...(body.status === 'running' ? {} : { history: await history() }) } };
|
|
732
|
+
}
|
|
584
733
|
/** The baseline once it settles (tests, and the whole-rig ops of #102/#103 through assertWholeRigBaseline). */
|
|
585
734
|
async function settledBaseline(): Promise<BaselineResult | null> { if (!baselineRun) ensureBaseline(resolveProducer(options.starts)); return baselineRun ? await baselineRun : baseline; }
|
|
586
735
|
|
|
@@ -592,14 +741,22 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
592
741
|
if (stored.schema !== REBUILD_SCHEMA || stored.id !== id || stored.status !== 'built' || !stored.build) return null;
|
|
593
742
|
const now = await hashBuild(join(dir, 'build'), id);
|
|
594
743
|
if (JSON.stringify(now.files) !== JSON.stringify(stored.build.files)) return null;
|
|
595
|
-
|
|
744
|
+
const job = { ...stored, reused: true };
|
|
745
|
+
// A retarget built before the job carried its report's frameTimes (#164): read them from the build's own report.
|
|
746
|
+
if ('kind' in job && job.kind === 'retarget' && (job as RetargetJob).frameTimes === undefined) {
|
|
747
|
+
let times: unknown = null;
|
|
748
|
+
try { times = (JSON.parse(await readFile(join(dir, 'motion.report.json'), 'utf8')) as { frameTimes?: unknown }).frameTimes; } catch { /* no report: no clock */ }
|
|
749
|
+
const clock = samplesClock(times); (job as RetargetJob).frameTimes = clock?.kind === 'samples' ? clock.frameTimes : null;
|
|
750
|
+
}
|
|
751
|
+
return job;
|
|
596
752
|
} catch { return null; }
|
|
597
753
|
}
|
|
598
754
|
|
|
599
755
|
/**
|
|
600
756
|
* The tail every Apply shares (#121), once its producer op has written a rig spec into `<dir>/<op.spec>`: the per-file
|
|
601
757
|
* diff when asked (#125), the compile with the revision's own pack flags, check when asked (#102), the build hashed,
|
|
602
|
-
* result.json written, the package's other scratch builds replaced
|
|
758
|
+
* result.json written, the package's other scratch builds replaced (#166: the applied records' earlier builds kept
|
|
759
|
+
* beside the scratch root for undo); anything but a built job leaves nothing behind.
|
|
603
760
|
* Each runner passes only its producer op and the optional steps it records.
|
|
604
761
|
*/
|
|
605
762
|
async function apply(job: RebuildJob | PatchJob | DeltaJob | RetargetJob, dir: string, inputs: ProducerInputs, resolved: Resolved, op: ProducerOp, steps: ApplySteps = {}): Promise<void> {
|
|
@@ -610,7 +767,13 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
610
767
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
611
768
|
const run = await op.run(push);
|
|
612
769
|
job.exitCode = run.exitCode; op.wallMs(run.wallMs);
|
|
613
|
-
if (run.exitCode !== 0) {
|
|
770
|
+
if (run.exitCode !== 0) {
|
|
771
|
+
job.producerLine = firstRefusal(run.lines) ?? `${op.tool ?? 'rig-parts'} ${op.cmd} exited ${run.exitCode} without a named refusal`;
|
|
772
|
+
// #148: the producer's line stays as printed; review adds what it means here, when it knows.
|
|
773
|
+
const hint = producerHint(job.producerLine);
|
|
774
|
+
if (hint && !('refusals' in job)) { (job as RebuildJob | PatchJob | DeltaJob).hint = hint; push(`review: ${hint}`); }
|
|
775
|
+
job.status = 'refused'; return;
|
|
776
|
+
}
|
|
614
777
|
job.producerLine = run.stdout.filter(l => l.trim()).at(-1)?.trim() ?? null;
|
|
615
778
|
const { pageEdges, packShape, note } = await op.flags(push);
|
|
616
779
|
if (steps.diff) {
|
|
@@ -642,15 +805,20 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
642
805
|
try { await options.unchanged(); } catch { throw new RebuildError('REBUILD_INPUTS_CHANGED', 'the package or its producer handoff changed during the rebuild'); }
|
|
643
806
|
job.build = await hashBuild(join(dir, 'build'), job.id);
|
|
644
807
|
if (steps.hashed) await steps.hashed();
|
|
808
|
+
// #166: an undo's re-run must reproduce the build its record names, file for file; otherwise it is refused, not guessed.
|
|
809
|
+
const changed = restoredDifference(job.id, job.build.files);
|
|
810
|
+
if (changed) { job.producerLine = changed; job.status = 'refused'; push(`review: ${changed}`); return; }
|
|
645
811
|
latestId = job.id;
|
|
646
812
|
await writeFile(join(dir, 'result.json'), JSON.stringify({ ...job, status: 'built', reused: false }, null, 1) + '\n', { mode: 0o600 });
|
|
647
|
-
// Each Apply replaces the previous scratch build of this package
|
|
648
|
-
|
|
813
|
+
// Each Apply replaces the previous scratch build of this package in the scratch root; #166 keeps the applied records'
|
|
814
|
+
// earlier builds beside it (keptRoot) for undo.
|
|
815
|
+
await landed({ ...job, status: 'built' });
|
|
649
816
|
// "built" is published last: a reader that sees it finds the scratch root already settled (no sibling mid-removal).
|
|
650
817
|
job.status = 'built';
|
|
651
818
|
} catch (error) {
|
|
652
819
|
job.status = 'failed'; job.producerLine = error instanceof Error ? error.message : String(error); push(`review: ${job.producerLine}`);
|
|
653
820
|
} finally {
|
|
821
|
+
if (restoring?.id === job.id && job.status !== 'built') restoring = null;
|
|
654
822
|
// Only a built Apply keeps its scratch directory; a refusal or failure leaves nothing behind.
|
|
655
823
|
if (job.status !== 'built') await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
656
824
|
}
|
|
@@ -801,7 +969,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
801
969
|
* on a producer that has it (#136, `src/retune-route.ts`); any other `rig` patch rebuilds the whole rig and first needs
|
|
802
970
|
* the baseline to have reproduced the inspected rig (`EDIT_BASELINE_MISMATCH` naming the path). Refusals come before anything is run.
|
|
803
971
|
*/
|
|
804
|
-
async function startPatch(input: unknown, world: ApplyWorld | null = null): Promise<{ status: number; body: unknown }> {
|
|
972
|
+
async function startPatch(input: unknown, world: ApplyWorld | null = null, body: unknown = { patch: input }): Promise<{ status: number; body: unknown }> {
|
|
805
973
|
let patch: ConfigPatch;
|
|
806
974
|
try { patch = parseConfigPatch(input); } catch (e) { if (e instanceof ConfigPatchError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
|
|
807
975
|
assertStateOutside();
|
|
@@ -857,14 +1025,16 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
857
1025
|
const current = jobs.get(id);
|
|
858
1026
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
859
1027
|
if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
|
|
1028
|
+
guardRestore(id);
|
|
860
1029
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1030
|
+
await unkeep(id);
|
|
861
1031
|
const reused = await reuse<PatchJob>(dir, id);
|
|
862
|
-
if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
|
|
1032
|
+
if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, body); await landed(reused); return { status: 200, body: snapshot(reused) }; }
|
|
863
1033
|
const job: PatchJob = { schema: REBUILD_SCHEMA, kind: 'patch', id, status: 'running', reused: false, op, request: { patch, patchSha256: patchSha256(patch), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, exitCode: null, wallMs: { op: null, compile: null, check: null }, check: null, diff: null, files: null, remesh: null, build: null,
|
|
864
1034
|
...(plan ? { assembled: { inputs: plan.inputs, refusals: [], checkFails: [], lines: [], seam: [], seamPairs: [], recomposite: { line: null, before: null, after: null, errorMap: null }, parts: null } } : {}),
|
|
865
1035
|
...(world ? { environment: world.record } : {}),
|
|
866
1036
|
...(clothPlan ? { cloth: { route: building ? 'build' as const : 'rig' as const, inputs: { source: p.handoff!.record.source?.path ?? '', keypoints: { ...p.handoff!.record.keypoints! } }, refusals: [], line: null, written: null, links: [], wallMs: { propose: null, build: null } } } : {}) };
|
|
867
|
-
jobs.set(id, job); running = id;
|
|
1037
|
+
jobs.set(id, job); running = id; bodies.set(id, body);
|
|
868
1038
|
void executePatch(job, dir, inputs, resolved, patched, part, plan, world, clothPlan).finally(() => { if (running === id) running = null; });
|
|
869
1039
|
return { status: 202, body: snapshot(job) };
|
|
870
1040
|
}
|
|
@@ -917,11 +1087,13 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
917
1087
|
const current = jobs.get(id);
|
|
918
1088
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
919
1089
|
if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
|
|
1090
|
+
guardRestore(id);
|
|
920
1091
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1092
|
+
await unkeep(id);
|
|
921
1093
|
const reused = await reuse<DeltaJob>(dir, id);
|
|
922
|
-
if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
|
|
1094
|
+
if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, input); await landed(reused); return { status: 200, body: snapshot(reused) }; }
|
|
923
1095
|
const job: DeltaJob = { schema: REBUILD_SCHEMA, kind: 'delta', id, status: 'running', reused: false, op: 'remesh', request: { part: request.part, delta: request.delta, deltaSha256: sha256(text), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, exitCode: null, wallMs: { op: null, compile: null }, files: null, remesh: null, build: null };
|
|
924
|
-
jobs.set(id, job); running = id;
|
|
1096
|
+
jobs.set(id, job); running = id; bodies.set(id, input);
|
|
925
1097
|
void executeDelta(job, dir, inputs, resolved, text).finally(() => { if (running === id) running = null; });
|
|
926
1098
|
return { status: 202, body: snapshot(job) };
|
|
927
1099
|
}
|
|
@@ -991,6 +1163,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
991
1163
|
await writeFile(join(out, 'motion.json'), text, { mode: 0o600 });
|
|
992
1164
|
let frameTimes: number[] | null = null;
|
|
993
1165
|
try { const report = JSON.parse(await readFile(join(dir, 'motion.report.json'), 'utf8')) as { frameTimes?: unknown }; if (Array.isArray(report.frameTimes) && report.frameTimes.every(t => typeof t === 'number')) frameTimes = report.frameTimes as number[]; } catch { /* no report: the lines' own t= */ }
|
|
1166
|
+
const clock = samplesClock(frameTimes); job.frameTimes = clock?.kind === 'samples' ? clock.frameTimes : null;
|
|
994
1167
|
job.readings = retargetLines(retargetRun?.lines ?? [], frameTimes).slice(0, MAX_RETARGET_LINES);
|
|
995
1168
|
let inspected: unknown = null;
|
|
996
1169
|
try { inspected = JSON.parse(await readFile(join(inputs.rig, 'motion.json'), 'utf8')); } catch { /* a rig without motion.json: nothing to keep */ }
|
|
@@ -1007,7 +1180,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1007
1180
|
* still be the handoff's, the sequence a declared `rig-pose-sequence/1` reference, and `@rig-c/pose` installed. The
|
|
1008
1181
|
* map's resolution against the rig, the flags' combinations and the pose are the producer's to judge, verbatim.
|
|
1009
1182
|
*/
|
|
1010
|
-
async function startRetarget(input: unknown): Promise<{ status: number; body: unknown }> {
|
|
1183
|
+
async function startRetarget(input: unknown, body: unknown = { retarget: input }): Promise<{ status: number; body: unknown }> {
|
|
1011
1184
|
let request: ReturnType<typeof parseRetargetRequest>;
|
|
1012
1185
|
try { request = parseRetargetRequest(input); } catch (e) { if (e instanceof RetargetError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
|
|
1013
1186
|
assertStateOutside();
|
|
@@ -1028,11 +1201,13 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1028
1201
|
const current = jobs.get(id);
|
|
1029
1202
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
1030
1203
|
if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
|
|
1204
|
+
guardRestore(id);
|
|
1031
1205
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1206
|
+
await unkeep(id);
|
|
1032
1207
|
const reused = await reuse<RetargetJob>(dir, id);
|
|
1033
|
-
if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
|
|
1034
|
-
const job: RetargetJob = { schema: REBUILD_SCHEMA, kind: 'retarget', id, status: 'running', reused: false, op: 'retarget', request: { record, recordSha256: sha256(retargetRecordText(record)), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, refusals: [], exitCode: null, wallMs: { clean: null, retarget: null, compile: null }, files: null, motion: null, readings: [], build: null };
|
|
1035
|
-
jobs.set(id, job); running = id;
|
|
1208
|
+
if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, body); await landed(reused); return { status: 200, body: snapshot(reused) }; }
|
|
1209
|
+
const job: RetargetJob = { schema: REBUILD_SCHEMA, kind: 'retarget', id, status: 'running', reused: false, op: 'retarget', request: { record, recordSha256: sha256(retargetRecordText(record)), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, refusals: [], exitCode: null, wallMs: { clean: null, retarget: null, compile: null }, files: null, motion: null, readings: [], frameTimes: null, build: null };
|
|
1210
|
+
jobs.set(id, job); running = id; bodies.set(id, body);
|
|
1036
1211
|
void executeRetarget(job, dir, inputs, resolved, sequence).finally(() => { if (running === id) running = null; });
|
|
1037
1212
|
return { status: 202, body: snapshot(job) };
|
|
1038
1213
|
}
|
|
@@ -1041,7 +1216,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1041
1216
|
async function start(input: unknown): Promise<{ status: number; body: unknown }> {
|
|
1042
1217
|
if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'retarget')) {
|
|
1043
1218
|
if (Object.keys(input).length !== 1) bad('expected exactly {retarget}');
|
|
1044
|
-
return startRetarget((input as { retarget: unknown }).retarget);
|
|
1219
|
+
return startRetarget((input as { retarget: unknown }).retarget, input);
|
|
1045
1220
|
}
|
|
1046
1221
|
if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'delta')) return startDelta(input);
|
|
1047
1222
|
if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'patch')) {
|
|
@@ -1049,7 +1224,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1049
1224
|
const keys = Object.keys(input).sort().join(',');
|
|
1050
1225
|
if (keys !== 'patch' && keys !== 'environment,patch') bad('expected exactly {patch}, {patch, environment} or {part, regions, configHash}');
|
|
1051
1226
|
const world = Object.hasOwn(input, 'environment') ? parseApplyWorld((input as { environment: unknown }).environment) : null;
|
|
1052
|
-
return startPatch((input as { patch: unknown }).patch, world);
|
|
1227
|
+
return startPatch((input as { patch: unknown }).patch, world, input);
|
|
1053
1228
|
}
|
|
1054
1229
|
const request = parseRebuildRequest(input, options.bones);
|
|
1055
1230
|
assertStateOutside();
|
|
@@ -1063,11 +1238,13 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1063
1238
|
const current = jobs.get(id);
|
|
1064
1239
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
1065
1240
|
if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
|
|
1241
|
+
guardRestore(id);
|
|
1066
1242
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1243
|
+
await unkeep(id);
|
|
1067
1244
|
const reused = await reuse(dir, id);
|
|
1068
|
-
if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
|
|
1245
|
+
if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, input); await landed(reused); return { status: 200, body: snapshot(reused) }; }
|
|
1069
1246
|
const job: RebuildJob = { schema: REBUILD_SCHEMA, id, status: 'running', reused: false, request: { ...request, entrySha256: sha256(entry) }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, exitCode: null, wallMs: { remesh: null, compile: null }, build: null };
|
|
1070
|
-
jobs.set(id, job); running = id;
|
|
1247
|
+
jobs.set(id, job); running = id; bodies.set(id, input);
|
|
1071
1248
|
void execute(job, dir, inputs, resolved, entry).finally(() => { if (running === id) running = null; });
|
|
1072
1249
|
return { status: 202, body: snapshot(job) };
|
|
1073
1250
|
}
|
|
@@ -1087,5 +1264,5 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1087
1264
|
return safeFile(await realpath(join(rebuildDir(options.stateDir, p.sourceHash, id), 'build')), packagePath(name));
|
|
1088
1265
|
}
|
|
1089
1266
|
|
|
1090
|
-
return { describe, start, read, file, running: () => running, baseline: settledBaseline, inspectedCheck: settledInspectedCheck, latest };
|
|
1267
|
+
return { describe, start, read, file, running: () => running, baseline: settledBaseline, inspectedCheck: settledInspectedCheck, latest, undo, history };
|
|
1091
1268
|
}
|
package/src/server.ts
CHANGED
|
@@ -45,7 +45,7 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
|
|
|
45
45
|
const handle=async(req:Request,port:number|undefined):Promise<Response>=>{try {
|
|
46
46
|
const url=new URL(req.url);const origin=`http://127.0.0.1:${port}`;
|
|
47
47
|
if(url.origin!==origin)return json({error:'Loopback host required'},403);
|
|
48
|
-
const refused=openNonceGate(req,options.open)??accessGate(req,options.capability);if(refused)return refused;
|
|
48
|
+
const refused=openNonceGate(req,options.open)??accessGate(req,options.capability,!!control);if(refused)return refused;
|
|
49
49
|
const controlled=await control?.(req,origin);if(controlled)return controlled;
|
|
50
50
|
options.activity?.();const gate=openGate(req,options.open);if(gate){if(gate.status===200){try{await unchanged();}catch{return staleOpenStatus(options.open!);}}return gate;}
|
|
51
51
|
if(req.method==='PUT'&&url.pathname==='/api/session'){
|
|
@@ -59,6 +59,12 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
|
|
|
59
59
|
// A refusal is an outcome the page shows verbatim (200), so the managed front passes its text through; only a malformed request is a 400.
|
|
60
60
|
catch(e){if(e instanceof RebuildError)return json({status:'refused',error:e.message,producerLine:null},MALFORMED.has(e.code)?400:200);throw e;}
|
|
61
61
|
}
|
|
62
|
+
// #166: undo the last Apply — {from: <the build id on the stage>}; nothing is read from the client but that id.
|
|
63
|
+
if(req.method==='POST'&&url.pathname==='/api/rebuild/undo'){
|
|
64
|
+
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
65
|
+
try{const result=await rebuilds.undo(await bodyJson(req,1024,'REBUILD_BODY_LIMIT: maximum 1 KiB'));return json(result.body,result.status);}
|
|
66
|
+
catch(e){if(e instanceof RebuildError)return json({status:'refused',error:e.message,producerLine:null},MALFORMED.has(e.code)?400:200);throw e;}
|
|
67
|
+
}
|
|
62
68
|
if(req.method==='POST'&&url.pathname==='/api/bones-check'){
|
|
63
69
|
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
64
70
|
try{return json(await bonesCheck.run(await bodyJson(req,BONES_CHECK_LIMIT,'BONES_CHECK_BODY_LIMIT: maximum 64 KiB')));}
|
|
@@ -88,6 +94,7 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
|
|
|
88
94
|
if(url.pathname==='/api/review'){await unchanged();return json({manifest:p.manifest,initialAnimation:options.initialAnimation??null,sourceHash:p.sourceHash,reviewedSource:p.reviewedSource,...(referenceBinding(p)?{referenceBinding:referenceBinding(p),explicitReference:{...referenceBinding(p)!.reference,url:'/review-reference'}}:{}),report:p.report,feedbackCatalog:p.feedbackCatalog,assetsBase:'/assets/',capabilities:{playback:!!p.manifest.model,modeling:false,motion:false,feedback:true,audio:true}});}
|
|
89
95
|
if(url.pathname==='/api/session'){await unchanged();return json(await readSession(p,options.stateDir));}
|
|
90
96
|
if(url.pathname==='/api/rebuild')return json(await rebuilds.describe());
|
|
97
|
+
if(url.pathname==='/api/rebuild/history')return json(await rebuilds.history());
|
|
91
98
|
const rebuildStatus=/^\/api\/rebuild\/([a-f0-9]{64})$/.exec(url.pathname);
|
|
92
99
|
if(rebuildStatus){try{return json(rebuilds.read(rebuildStatus[1]!));}catch(e){if(e instanceof RebuildError)return json({error:e.message},404);throw e;}}
|
|
93
100
|
if(url.pathname==='/api/requirements')return json(await requirements.describe());
|
package/web/compare.ts
CHANGED
|
@@ -49,6 +49,8 @@ let stripBusy = false;
|
|
|
49
49
|
let persistence:PersistenceConnection|null=null,submittedBaseline:CompareSession|null=null,draftConflict=false;
|
|
50
50
|
// First, before any URL use: take the `#cap=` handoff into memory/session storage and strip it.
|
|
51
51
|
const access=pageInstanceAccess();let readOnly:string|null=null;
|
|
52
|
+
/** Whether this instance serves agent control (`/api/access`); a plain open is never probed for it (#164). */
|
|
53
|
+
let controlServed=true;
|
|
52
54
|
let referenceFrames:VideoInfo|null=null;
|
|
53
55
|
const presentation=new ComparePresentation();
|
|
54
56
|
const rendererBadges={a:new RendererBadge(),b:new RendererBadge()};
|
|
@@ -393,9 +395,9 @@ function bind(): void {
|
|
|
393
395
|
|
|
394
396
|
async function start(): Promise<void> {
|
|
395
397
|
try {
|
|
396
|
-
let writes: { capabilityRequired: boolean };
|
|
397
|
-
[data, session, writes] = await Promise.all([api<CompareResponse>('/api/compare'), api<CompareSession>('/api/compare/session'), api<{ capabilityRequired: boolean }>('/api/access')]);
|
|
398
|
-
readOnly = readOnlyReason(access, writes.capabilityRequired); if (readOnly) message(readOnly, true);
|
|
398
|
+
let writes: { capabilityRequired: boolean; control?: boolean };
|
|
399
|
+
[data, session, writes] = await Promise.all([api<CompareResponse>('/api/compare'), api<CompareSession>('/api/compare/session'), api<{ capabilityRequired: boolean; control?: boolean }>('/api/access')]);
|
|
400
|
+
readOnly = readOnlyReason(access, writes.capabilityRequired); if (readOnly) message(readOnly, true); controlServed = writes.control !== false;
|
|
399
401
|
submittedBaseline=structuredClone(session);
|
|
400
402
|
if (data.id !== session.id || data.sourceHash !== session.sourceHash || JSON.stringify(data.binding) !== JSON.stringify(session.binding)) throw new Error('Comparison and submission session refer to different source identities. Reopen the review.');
|
|
401
403
|
text('title', data.title); text('animation', `${data.animation} · A/B order and reference video hash are fixed`); document.title = data.title + ' · rig review compare'; select('status').value = session.status; provenance(); bind(); parts=new PartExplorer(el('parts-explorer'),el('selected-part'),previous=>{redrawOverlays(true);const part=parts?.value();if(part){activeCamera=part.side==='b'?'b':'a';workspace?.revealInspector();document.querySelector<HTMLButtonElement>('[data-inspector=rig]')?.click();renderCameraControls();}if(!cameraRestoring&&(previous?.side!==part?.side||previous?.slot!==part?.slot))cameraAction(()=>fitActiveCamera());persistence?.save();},part=>{select('side').value=part.side;select('target-kind').value='slot';renderFeedbackSelection();input('target-name').value=part.slot;document.querySelector<HTMLButtonElement>('[data-inspector=feedback]')?.click();}); updateStripControls(); stripStatus('Choose the entered start–end frames or all feedback ranges to download. Unsubmitted feedback is included; exporting does not submit the review.'); renderFeedbackSelection(); renderFeedback(); savedState();
|
|
@@ -410,7 +412,7 @@ async function start(): Promise<void> {
|
|
|
410
412
|
input('scrub').max = String(domain.end); input('time-input').max = String(domain.end); text('domain', `Shared range 0–${domain.end} s · Neither side is independently clamped or looped`); ready = true;
|
|
411
413
|
input('scrub').disabled = false; input('time-input').disabled = false; button('seek').disabled = false; button('play').disabled = domain.end === 0;
|
|
412
414
|
await seekTo(0);activeCamera='a';if(cameraSync)alignCameras();fit();for(const id of ['layer-images','layer-mesh','layer-bones'])input(id).disabled=false;renderIdle();const restored=await setupPersistence();if(!restored&&canRepeatIdle())void playComparison().catch(error=>message(describe(error),true));
|
|
413
|
-
if(!readOnly)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
|
|
415
|
+
if(!readOnly&&controlServed)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
|
|
414
416
|
} catch (error) { failPair(error); for (const side of ['a', 'b'] as const) if (!players[side]) text(`${side}-empty`, `${side.toUpperCase()} Could not open comparison rig.`); text('reference-empty', 'Check the reference video and comparison inputs.'); }
|
|
415
417
|
}
|
|
416
418
|
function sharedBox():PoseBounds|null {const a=assets.a?.stage??players.a?.bounds(),b=assets.b?.stage??players.b?.bounds();return a&&b?unionBounds(a,b):a??b??null;}
|
|
@@ -431,7 +433,7 @@ function showSurvivors():void {
|
|
|
431
433
|
updateFeedbackAvailability();
|
|
432
434
|
const state=renderPairLayout();activeCamera=state.effective==='b'?'b':state.effective==='reference'?'reference':'a';fit();
|
|
433
435
|
message(`Comparison unavailable · ${state.reasons.compare??''}${state.effective&&state.effective!=='compare'?` · showing ${state.effective==='reference'?'the reference':state.effective.toUpperCase()} alone`:''}`,true);
|
|
434
|
-
if(!readOnly)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
|
|
436
|
+
if(!readOnly&&controlServed)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
|
|
435
437
|
}
|
|
436
438
|
function paneFrame(target:CameraTarget,hidden=false):CameraFrame|null {
|
|
437
439
|
const viewport=el(target+'-viewport');if(viewport.clientWidth<=0||viewport.clientHeight<=0)return hidden?cameraFrames[target]??null:null;
|