commit d6edb1bb6cf4da337a959a2364d06c0faf231529
parent 803bdb283911bbd617593cf29ec1f04f9af244dc
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 8 Oct 2026 23:18:49 -0400
umtool: notes capability on the kind registry; the timeline's token race
- kinds.mjs declares notes: true on report-video and kindTakesNotes(); targets.mjs
asks it instead of naming the kind (projects.spec: no kind leaks outside the
registry)
- the timeline list waits for its token instead of sending none, and the route
no longer reads a JSON null as the token "null"; the structure editors adopt
each write's token at once
- video-notes-fixture has the deck on, which is where the built cut is shown
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 30 insertions(+), 8 deletions(-)
diff --git a/umtool/app/api/report/timeline/route.ts b/umtool/app/api/report/timeline/route.ts
@@ -86,7 +86,9 @@ export async function POST(request: Request) {
const r = await resolveReport(String(body.project ?? ""));
if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
const dir = r.project.dir;
- const token = body.token === undefined ? null : String(body.token);
+ // A string, or no guard at all: `String(null)` would be the token "null",
+ // which matches no file and refuses every write.
+ const token = typeof body.token === "string" ? body.token : null;
const id = String(body.id ?? "");
const at = atOf(body.at);
diff --git a/umtool/components/projects/StructureEditors.tsx b/umtool/components/projects/StructureEditors.tsx
@@ -61,6 +61,9 @@ export default function StructureEditors({ project }: { project: string }) {
if (res.json.stale) await load();
return false;
}
+ // Adopt the new token now: the reload the announcement starts may land
+ // after the next save is pressed.
+ if (typeof res.json.token === "string") setDoc((d) => (d ? { ...d, token: res.json.token as string } : d));
announceManifestChanged(project);
if (wroteNotes(res.json)) announceNotesChanged(project);
router.refresh();
diff --git a/umtool/components/projects/TimelineEditor.tsx b/umtool/components/projects/TimelineEditor.tsx
@@ -29,7 +29,9 @@ const rowKey = (li: HTMLLIElement) => ({ id: li.dataset.entry ?? "", at: Number(
export function TimelineList({ project, count, children }: { project: string; count: number; children: React.ReactNode }) {
const router = useRouter();
- const [token, setToken] = useState<string | null>(null);
+ // The manifest's write token, in a ref as well as state: a key pressed before
+ // the first read came back must wait for it, not send no token at all.
+ const tokenRef = useRef<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [over, setOver] = useState<number | null>(null);
@@ -38,7 +40,8 @@ export function TimelineList({ project, count, children }: { project: string; co
const loadToken = useCallback(async () => {
const r = await fetch(`/api/report/timeline?project=${encodeURIComponent(project)}`, { cache: "no-store" });
const j = await r.json().catch(() => ({}));
- if (r.ok) setToken(j.token ?? null);
+ if (r.ok) tokenRef.current = typeof j.token === "string" ? j.token : null;
+ return tokenRef.current;
}, [project]);
useEffect(() => {
void loadToken();
@@ -53,20 +56,20 @@ export function TimelineList({ project, count, children }: { project: string; co
async (op: string, args: Record<string, unknown>) => {
setBusy(true);
setError(null);
- const res = await timelineOp(project, token, op, args);
+ const res = await timelineOp(project, tokenRef.current ?? (await loadToken()), op, args);
setBusy(false);
if (!res.ok) {
setError(refusalOf(res));
if (res.json.stale) await loadToken();
return false;
}
- setToken(typeof res.json.token === "string" ? res.json.token : null);
+ tokenRef.current = typeof res.json.token === "string" ? res.json.token : null;
announceManifestChanged(project);
if (wroteNotes(res.json)) announceNotesChanged(project);
router.refresh();
return true;
},
- [project, token, loadToken, router],
+ [project, loadToken, router],
);
const onKeyDown = (e: React.KeyboardEvent) => {
diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -1852,6 +1852,13 @@ const VNOTES = writeProject("video-notes-fixture", {
]),
generatedBy: "polemics/video/make-videos.py",
});
+{
+ // The deck on: the On-screen section shows the built cut (and its timed
+ // notes) only under it.
+ const m = JSON.parse(readFileSync(path.join(VNOTES, "video.manifest.json"), "utf8"));
+ m.render.chrome = { engine: "hyperframes", layout: "deck" };
+ writeFileSync(path.join(VNOTES, "video.manifest.json"), JSON.stringify(m, null, 2) + "\n");
+}
mkdirSync(path.join(VNOTES, "out", "sourced"), { recursive: true });
twoSeconds(path.join(VNOTES, "out", "video-notes-fixture.mp4"), 300);
writeFileSync(path.join(VNOTES, "out", "sourced", "schedule.json"), JSON.stringify(NOTES_SCHEDULE, null, 2));
diff --git a/umtool/lib/annotations/targets.mjs b/umtool/lib/annotations/targets.mjs
@@ -15,6 +15,7 @@ import { readdir, readFile, realpath, stat } from "node:fs/promises";
import path from "node:path";
import { NOTES_FILENAME, REPORTS_ROOT, SEGMENT_RE, SITES_DIR, corpusNotesFile, inside, isCorpusNotesFile } from "../paths.mjs";
import { projectRefs, resolveProject } from "../projects/core.mjs";
+import { kindTakesNotes } from "../projects/kinds.mjs";
import { sourceFor, tildify } from "../articles/sources.mjs";
import { readNotes, writeOp } from "./store.mjs";
@@ -76,7 +77,7 @@ export async function projectTarget(spec, { reportsRoot = REPORTS_ROOT } = {}) {
* @param {{ reportsRoot?: string }} [opts]
*/
export async function projectTargetFor(p, { reportsRoot = REPORTS_ROOT } = {}) {
- if (p.kind !== "report-video") throw new TargetError(`${p.id} is a ${p.kind} project; notes are for report videos`);
+ if (!kindTakesNotes(p.kind)) throw new TargetError(`${p.id} is a ${p.kind} project; notes are for report videos`);
const [realRoot, realDir] = await Promise.all([
realpath(/* turbopackIgnore: true */ reportsRoot).catch(() => null),
realpath(/* turbopackIgnore: true */ p.dir).catch(() => null),
@@ -160,7 +161,7 @@ export async function listNotesFiles({ sitesDir = SITES_DIR, reportsRoot = REPOR
}
}
for (const p of await projectRefs(reportsRoot)) {
- if (p.kind !== "report-video") continue;
+ if (!kindTakesNotes(p.kind)) continue;
const file = path.join(/* turbopackIgnore: true */ p.dir, NOTES_FILENAME);
if (!(await exists(file))) continue;
out.push({ kind: "video-project", id: p.id, file, ...(await readNotes(file)) });
diff --git a/umtool/lib/projects/kinds.mjs b/umtool/lib/projects/kinds.mjs
@@ -104,6 +104,9 @@ export const PROJECT_KINDS = [
// `brand` offers report-to-video's presets (render.brand); none is the
// default and writes the manifest it always did.
scaffold: { fields: ["from", "siteOrigin", "seed", "brand"], brands: BRAND_CHOICES },
+ // Takes a notes.json beside its manifest (lib/annotations/targets.mjs):
+ // timed notes on its cuts, row notes, take notes, edit notes.
+ notes: true,
},
{
id: "song",
@@ -182,6 +185,9 @@ if (process.env.E2E_UMTOOL_EXTRA_KINDS) {
export const kindById = (id) => PROJECT_KINDS.find((k) => k.id === id) ?? null;
+/** Does a project of this kind keep a notes.json (lib/annotations)? Declared on the kind, never branched on its id. */
+export const kindTakesNotes = (id) => kindById(id)?.notes === true;
+
/** What a client component needs, with none of what it must not have. */
export const kindMeta = (k) => ({
id: k.id,