commit 615f3af4b6c09c8116576e95bfceaafdaaa169d8
parent 5f34b0da817fc8672b9b6551d3d495960bcf5ac4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 8 Oct 2026 23:47:59 -0400
umtool: the article video takes timed notes; "Add to video" from a citation
The reader shares its one notes handle with the report video's TimedVideo
(ShareNotes), so marks and text notes never race a token. A citation's
evidence panel inserts its span at the end of a linked project's timeline
(/api/report/timeline insert; snapshot first, edit note on a generated one).
e2e/article-integration.spec.ts covers both.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
9 files changed, 267 insertions(+), 21 deletions(-)
diff --git a/umtool/app/sites/[site]/[report]/evidence/page.tsx b/umtool/app/sites/[site]/[report]/evidence/page.tsx
@@ -4,6 +4,8 @@ import BrowseHeader from "@/components/BrowseHeader";
import EvidenceWalk, { type WalkItem } from "@/components/articles/EvidenceWalk";
import { readArticleNotes, readReportFile, siteById } from "@/lib/articles/sites";
import { corpusNotesFile } from "@/lib/paths";
+import { linkedProjects } from "@/lib/articles/links.mjs";
+import { sourceFor } from "@/lib/articles/sources.mjs";
import type { NotesRead } from "@/lib/annotations/types";
export const dynamic = "force-dynamic";
@@ -29,7 +31,8 @@ export default async function EvidenceWalkPage({
.sort((a, b) => a[1] - b[1])
.map(([id, number]) => ({ id, number, kind: r.citations?.[id]?.kind ?? "?", quote: r.citations?.[id]?.quote ?? "" }));
const at = Math.max(0, items.findIndex((i) => i.id === c));
- const notes = await readArticleNotes(site.siteId, reportId);
+ const [notes, source] = await Promise.all([readArticleNotes(site.siteId, reportId), sourceFor(site.siteId, reportId)]);
+ const links = await linkedProjects(site.siteId, reportId, { workspace: source?.workspace ?? null });
const initialNotes: NotesRead = {
subject: { kind: "article", site: site.siteId, report: reportId },
file: corpusNotesFile(site.siteId, reportId) ?? "",
@@ -49,7 +52,9 @@ export default async function EvidenceWalkPage({
]}
note={`${items.length} citations`}
/>
- <EvidenceWalk site={site.siteId} report={reportId} items={items} initial={at} initialNotes={initialNotes} />
+ <EvidenceWalk site={site.siteId} report={reportId} items={items} initial={at} initialNotes={initialNotes}
+ videoProjects={links.linked.map((p: { id: string; title: string; generatedBy: string | null }) => ({ id: p.id, title: p.title, generatedBy: p.generatedBy }))}
+ />
</div>
);
}
diff --git a/umtool/app/sites/[site]/[report]/page.tsx b/umtool/app/sites/[site]/[report]/page.tsx
@@ -5,7 +5,6 @@ import { readRevisionHead, REPORT_HISTORY_GIT_DIRNAME } from "yt-dlp-transcript-
import { siteReportDir } from "yt-dlp-transcript-common/publish/reportMedia";
import BrowseHeader from "@/components/BrowseHeader";
import ArticleReader from "@/components/articles/ArticleReader";
-import ArticleVideo from "@/components/articles/ArticleVideo";
import WorkspacePanel from "@/components/articles/WorkspacePanel";
import { badgeVariants } from "@/components/ui/badge";
import { articleView } from "@/lib/articles/article";
@@ -157,9 +156,9 @@ export default async function ArticlePage({
...(notes.error ? { error: notes.error } : {}),
};
const status = sp.status === "resolved" || sp.status === "all" ? sp.status : "open";
- const videoSlot = r.video ? (
- <ArticleVideo src={media(r.video.src)} poster={r.video.poster ? media(r.video.poster) : undefined} caption={r.video.caption} />
- ) : null;
+ const video = r.video
+ ? { file: r.video.src, src: media(r.video.src), poster: r.video.poster ? media(r.video.poster) : undefined, caption: r.video.caption }
+ : null;
return (
<div className="flex h-full flex-col">
@@ -173,7 +172,8 @@ export default async function ArticlePage({
initialFilter={status}
initialNote={sp.note ?? null}
meta={meta}
- videoSlot={videoSlot}
+ video={video}
+ videoProjects={links.linked.map((p: { id: string; title: string; generatedBy: string | null }) => ({ id: p.id, title: p.title, generatedBy: p.generatedBy }))}
/>
</div>
);
diff --git a/umtool/components/articles/AddToVideo.tsx b/umtool/components/articles/AddToVideo.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import type { Evidence } from "@/lib/articles/evidence";
+
+// "Add to video": the cited span as a new clip at the END of a linked
+// report-video project's timeline, through the structure route
+// (/api/report/timeline, op insert). That route snapshots the manifest first
+// (Undo on the project page restores it) and, on a GENERATED manifest, leaves
+// an `edit` note so the agent ports the clip into the generator's inputs.
+
+export type VideoProjectLink = { id: string; title: string; generatedBy: string | null };
+
+export default function AddToVideo({ ev, projects }: { ev: Evidence; projects: VideoProjectLink[] }) {
+ const [project, setProject] = useState(projects[0]?.id ?? "");
+ const [state, setState] = useState<{ busy: boolean; done?: { id: string; project: string }; error?: string }>({ busy: false });
+ if (!projects.length || !ev.record || ev.start === undefined || ev.end === undefined) return null;
+ const chosen = projects.find((p) => p.id === project) ?? projects[0];
+
+ const add = async () => {
+ setState({ busy: true });
+ try {
+ const g = await fetch(`/api/report/timeline?project=${encodeURIComponent(chosen.id)}`, { cache: "no-store" });
+ const gj = await g.json();
+ if (!g.ok) throw new Error(gj.error ?? g.statusText);
+ const last = gj.timeline.length ? gj.timeline[gj.timeline.length - 1] : null;
+ const entry: Record<string, unknown> = {
+ type: "clip",
+ channel: ev.record!.channel,
+ video: ev.record!.id,
+ start: ev.start,
+ end: ev.end,
+ quote: ev.quote,
+ };
+ if (/^[A-Za-z0-9_-]{1,60}$/.test(ev.cite)) entry.id = `cite-${ev.cite}`;
+ const r = await fetch("/api/report/timeline", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project: chosen.id, token: gj.token, op: "insert", afterId: last?.id ?? null, at: last ? gj.timeline.length - 1 : null, entry }),
+ });
+ const j = await r.json();
+ if (!r.ok) throw new Error(j.error ?? r.statusText);
+ setState({ busy: false, done: { id: j.id ?? j.result?.id ?? "the clip", project: chosen.id } });
+ } catch (err) {
+ setState({ busy: false, error: err instanceof Error ? err.message : String(err) });
+ }
+ };
+
+ return (
+ <div data-add-to-video className="flex flex-wrap items-center gap-2 border-t border-[var(--color-line)] pt-2">
+ {projects.length > 1 ? (
+ <select value={chosen.id} onChange={(e) => setProject(e.target.value)} aria-label="video project" className="rounded border border-[var(--color-line)] bg-transparent px-1 py-0.5 font-mono text-[11px]">
+ {projects.map((p) => (
+ <option key={p.id} value={p.id}>
+ {p.id}
+ </option>
+ ))}
+ </select>
+ ) : (
+ <span className="font-mono text-[11px] text-[var(--color-dim)]">{chosen.id}</span>
+ )}
+ <button type="button" onClick={add} disabled={state.busy} className="rounded border border-[var(--color-line)] px-2 py-0.5 hover:border-[var(--color-sel)] disabled:opacity-50">
+ Add to video
+ </button>
+ {chosen.generatedBy && <span className="text-[11px] text-[var(--color-dim)]">generated; leaves an edit note</span>}
+ {state.done && (
+ <span role="status" className="text-[11px]">
+ added <span className="font-mono">{state.done.id}</span> โ{" "}
+ <Link href={`/browse/${state.done.project}`} className="underline">
+ open
+ </Link>
+ </span>
+ )}
+ {state.error && <span role="alert" className="text-[11px] text-[var(--color-bad)]">{state.error}</span>}
+ </div>
+ );
+}
diff --git a/umtool/components/articles/ArticleReader.tsx b/umtool/components/articles/ArticleReader.tsx
@@ -6,7 +6,10 @@ import CopyButton from "@/components/CopyButton";
import { locateQuote, quoteAnchor } from "@/lib/annotations/anchor.mjs";
import type { Anchor, Note, NotesRead } from "@/lib/annotations/types";
import { useNotes } from "@/lib/annotations/useNotes";
+import { ShareNotes } from "@/components/notes/NotesProvider";
import ArticleBody, { ArticleActionsContext, type ArticleActions } from "./ArticleBody";
+import ArticleVideo from "./ArticleVideo";
+import type { VideoProjectLink } from "./AddToVideo";
import { blockText, selectionIn, unwrapMarks, wrapRange } from "./anchorDom";
import EvidencePanel, { useEvidence } from "./EvidencePanel";
import { Composer, NoteCard, anchorLabel } from "./NoteCards";
@@ -41,7 +44,8 @@ export default function ArticleReader({
initialFilter = "open",
initialNote = null,
meta,
- videoSlot,
+ video,
+ videoProjects = [],
viewError,
}: {
site: string;
@@ -51,11 +55,24 @@ export default function ArticleReader({
initialFilter?: Filter;
initialNote?: string | null;
meta: ReactNode;
- videoSlot?: ReactNode;
+ /** The report's own video (report.json `video`), played with timed notes. */
+ video?: { file: string; src: string; poster?: string; caption?: string } | null;
viewError?: string | null;
+ /** The article's LINKED video projects: where "Add to video" puts a cited span. */
+ videoProjects?: VideoProjectLink[];
}) {
const notesApi = useNotes({ article: `${site}/${report}` }, { initial: initialNotes });
const { notes, write, busy, error } = notesApi;
+ // Stable across note writes: ArticleBody is memoised on it (its marks are
+ // laid over the rendered DOM). The player reads live notes through
+ // <ShareNotes>, not through this element.
+ const videoSlot = useMemo(
+ () =>
+ video ? (
+ <ArticleVideo site={site} report={report} file={video.file} src={video.src} poster={video.poster} caption={video.caption} />
+ ) : null,
+ [site, report, video?.file, video?.src, video?.poster, video?.caption],
+ );
const [filter, setFilter] = useState<Filter>(initialFilter);
const [selected, setSelected] = useState<string | null>(initialNote);
const [editing, setEditing] = useState<string | null>(null);
@@ -282,7 +299,9 @@ export default function ArticleReader({
}
}}
>
- <ArticleBody view={view} video={videoSlot} />
+ <ShareNotes target={{ article: `${site}/${report}` }} notes={notesApi}>
+ <ArticleBody view={view} video={videoSlot} />
+ </ShareNotes>
</div>
</ArticleActionsContext.Provider>
</div>
@@ -363,7 +382,7 @@ export default function ArticleReader({
โ
</button>
</div>
- <EvidencePanel ev={evidence.ev} error={evidence.error} number={numbers[cite]} />
+ <EvidencePanel ev={evidence.ev} error={evidence.error} number={numbers[cite]} projects={videoProjects} />
{citeNotes.length > 0 && <ul className="space-y-2">{citeNotes.map(card)}</ul>}
{composer?.anchor.kind === "cite" && composer.anchor.cite === cite ? (
<Composer label={composer.label} busy={busy} onSave={(text) => add(composer.anchor, text)} onCancel={() => setComposer(null)} />
diff --git a/umtool/components/articles/ArticleVideo.tsx b/umtool/components/articles/ArticleVideo.tsx
@@ -1,12 +1,33 @@
-// THE ARTICLE'S OWN VIDEO SLOT -- the report's video.mp4, as the published page
-// plays it. A plain <video> for now; the integration swaps in the shared timed
-// notes player (components/notes/TimedNotes.tsx, release track B) here, with
-// moment anchors `{ kind: "moment", file: "video.mp4", t }` on the article's
-// notes. Keep this the only place the article page renders its video.
-export default function ArticleVideo({ src, poster, caption }: { src: string; poster?: string; caption?: string }) {
+"use client";
+
+import { TimedVideo } from "@/components/notes/TimedNotes";
+
+// THE ARTICLE'S OWN VIDEO -- the report's video (report.json `video.src`), as
+// the published page plays it, with timed notes under it: `n` or Mark at the
+// playhead writes a moment anchor `{ kind: "moment", file: <video.src>, t }`
+// to the ARTICLE's notes. The handle is the reader's own, shared through
+// <ShareNotes> (components/notes/NotesProvider.tsx), so a mark and a text note
+// never race each other's token. No schedule: a report video is a published
+// file, so a mark keeps its time only. Keep this the only place the article
+// page renders its video.
+export default function ArticleVideo({
+ site,
+ report,
+ file,
+ src,
+ poster,
+ caption,
+}: {
+ site: string;
+ report: string;
+ file: string;
+ src: string;
+ poster?: string;
+ caption?: string;
+}) {
return (
<figure data-article-video className="space-y-1">
- <video controls preload="metadata" src={src} poster={poster} className="aspect-video w-full rounded bg-black" />
+ <TimedVideo target={{ article: `${site}/${report}` }} file={file} src={src} poster={poster} testId="article-video" />
{caption && <figcaption className="text-[12px] text-[var(--color-dim)]">{caption}</figcaption>}
</figure>
);
diff --git a/umtool/components/articles/EvidencePanel.tsx b/umtool/components/articles/EvidencePanel.tsx
@@ -3,6 +3,7 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import CopyButton from "@/components/CopyButton";
import type { Evidence } from "@/lib/articles/evidence";
+import AddToVideo, { type VideoProjectLink } from "./AddToVideo";
// One citation's evidence: what it quotes, who and when, the transcript around
// it with the cited cues marked, and the media that plays it -- the prepared
@@ -39,8 +40,11 @@ export function useEvidence(site: string, report: string, cite: string | null) {
return { ev, error };
}
-const EvidencePanel = forwardRef<EvidenceHandle, { ev: Evidence | null; error: string | null; number?: number; autoPlay?: boolean }>(
- function EvidencePanel({ ev, error, number, autoPlay = false }, ref) {
+const EvidencePanel = forwardRef<
+ EvidenceHandle,
+ { ev: Evidence | null; error: string | null; number?: number; autoPlay?: boolean; projects?: VideoProjectLink[] }
+>(
+ function EvidencePanel({ ev, error, number, autoPlay = false, projects = [] }, ref) {
const media = useRef<HTMLMediaElement | null>(null);
useImperativeHandle(ref, () => ({
togglePlay: () => {
@@ -154,6 +158,7 @@ const EvidencePanel = forwardRef<EvidenceHandle, { ev: Evidence | null; error: s
) : ev.cuesNote ? (
<div className="micro">{ev.cuesNote}</div>
) : null}
+ <AddToVideo ev={ev} projects={projects} />
</div>
);
},
diff --git a/umtool/components/articles/EvidenceWalk.tsx b/umtool/components/articles/EvidenceWalk.tsx
@@ -1,5 +1,6 @@
"use client";
+import type { VideoProjectLink } from "./AddToVideo";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import type { NotesRead } from "@/lib/annotations/types";
@@ -19,12 +20,14 @@ export default function EvidenceWalk({
items,
initial,
initialNotes,
+ videoProjects = [],
}: {
site: string;
report: string;
items: WalkItem[];
initial: number;
initialNotes: NotesRead;
+ videoProjects?: VideoProjectLink[];
}) {
const [idx, setIdx] = useState(initial);
const [composing, setComposing] = useState(false);
@@ -88,7 +91,7 @@ export default function EvidenceWalk({
<span className="micro ml-2">{item.kind}</span>
<span className="micro ml-auto">space plays ยท n notes</span>
</div>
- <EvidencePanel key={item.id} ref={panel} ev={ev} error={error} number={item.number} />
+ <EvidencePanel key={item.id} ref={panel} ev={ev} error={error} number={item.number} projects={videoProjects} />
</div>
<div className="min-w-0 space-y-2">
<div className="micro">notes on [{item.number}]</div>
diff --git a/umtool/components/notes/NotesProvider.tsx b/umtool/components/notes/NotesProvider.tsx
@@ -40,6 +40,15 @@ export function NotesProvider({ target, children }: { target: NotesTarget; child
return <Ctx.Provider value={{ key: notesQuery(target), notes }}>{children}</Ctx.Provider>;
}
+/**
+ * Share a handle the page already holds (the article reader's) with the notes
+ * components under it, so they write with ITS token rather than opening a
+ * second one on the same file.
+ */
+export function ShareNotes({ target, notes, children }: { target: NotesTarget; notes: UseNotes; children: React.ReactNode }) {
+ return <Ctx.Provider value={{ key: notesQuery(target), notes }}>{children}</Ctx.Provider>;
+}
+
/** The page's shared handle for `target`, or a handle of this component's own. */
export function useSharedNotes(target: NotesTarget | null): UseNotes {
const ctx = useContext(Ctx);
diff --git a/umtool/e2e/article-integration.spec.ts b/umtool/e2e/article-integration.spec.ts
@@ -0,0 +1,106 @@
+import { test, expect, type Locator } from "@playwright/test";
+import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { WARM_TIMEOUT, warm } from "./warm";
+
+// ---------------------------------------------------------------------------
+// Where the two tracks meet on the article page:
+//
+// * the report's own video carries timed notes, written to the ARTICLE's
+// notes through the reader's one handle (a mark and a text note do not
+// 409 each other);
+// * "Add to video" puts a cited span at the end of the linked project's
+// timeline; the project is GENERATED, so an `edit` note is left for the
+// agent, and the change is undoable from the auto snapshot.
+//
+// priv/polemic-alpha video.mp4 (4 s); c1 โ sitechan/sv1@3-6
+// sitews/polemic-alpha the linked project (generatedBy set), one clip a1
+// ---------------------------------------------------------------------------
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const FIX = path.join(HERE, "..", ".e2e-song");
+const NOTES = path.join(FIX, "sites", "priv", "reports", "polemic-alpha", "notes.json");
+const PROJ = path.join(FIX, "sitews", "polemic-alpha");
+const MANIFEST = path.join(PROJ, "video.manifest.json");
+const SAVED = path.join(PROJ, ".manifest.e2e-integration");
+const PROJ_NOTES = path.join(PROJ, "notes.json");
+
+const readJson = (f: string) => JSON.parse(readFileSync(f, "utf8"));
+
+async function seek(video: Locator, t: number) {
+ await video.evaluate(async (el: HTMLVideoElement, at: number) => {
+ if (el.readyState < 1) await new Promise((r) => el.addEventListener("loadedmetadata", r, { once: true }));
+ el.currentTime = at;
+ await new Promise((r) => el.addEventListener("seeked", r, { once: true }));
+ }, t);
+}
+
+function restoreProject() {
+ if (existsSync(SAVED)) {
+ copyFileSync(SAVED, MANIFEST);
+ rmSync(SAVED, { force: true });
+ }
+ rmSync(PROJ_NOTES, { force: true });
+ rmSync(`${MANIFEST}.bak`, { force: true });
+ const rev = path.join(PROJ, "revisions");
+ if (existsSync(rev)) for (const f of readdirSync(rev)) if (f.includes("auto-before")) rmSync(path.join(rev, f), { recursive: true, force: true });
+}
+
+test.beforeAll(async ({ playwright }) => {
+ test.setTimeout(WARM_TIMEOUT);
+ await warm(playwright, ["/sites/priv/polemic-alpha", "/api/notes?article=priv/polemic-alpha", "/api/report/timeline?project=sitews/polemic-alpha"]);
+});
+test.beforeEach(() => {
+ rmSync(NOTES, { force: true });
+ copyFileSync(MANIFEST, SAVED);
+});
+test.afterEach(() => {
+ rmSync(NOTES, { force: true });
+ restoreProject();
+});
+
+test("the article's video takes timed notes, on the article's notes, beside a text note", async ({ page }) => {
+ await page.goto("/sites/priv/polemic-alpha");
+ const video = page.getByTestId("article-video");
+ await expect(video).toBeVisible();
+ const scope = page.locator("[data-timed-notes='video.mp4']");
+ await seek(video, 1);
+ await scope.locator("[data-action='mark']").click();
+ const input = scope.getByTestId("timed-note-input");
+ await input.fill("the cut is late here");
+ await input.press("Enter");
+ await expect(scope.locator("[data-mark-at]")).toHaveCount(1);
+
+ // The rail's handle is the same one: a whole-article note right after does not 409.
+ await page.getByRole("button", { name: "+ whole article" }).click();
+ await page.getByLabel("note text").fill("Retitle it.");
+ await page.getByRole("button", { name: "save note" }).click();
+ await expect.poll(() => (existsSync(NOTES) ? readJson(NOTES).notes.length : 0)).toBe(2);
+ const doc = readJson(NOTES);
+ expect(doc.subject).toEqual({ kind: "article", site: "priv", report: "polemic-alpha" });
+ const moment = doc.notes.find((n: { anchor: { kind: string } }) => n.anchor.kind === "moment");
+ expect(moment.anchor).toMatchObject({ kind: "moment", file: "video.mp4", t: 1 });
+ expect(moment.author).toBe("operator");
+});
+
+test("Add to video: the cited span lands at the end of the linked project, with an edit note; undo restores it", async ({ page, request }) => {
+ const before = readJson(MANIFEST).timeline.length;
+ await page.goto("/sites/priv/polemic-alpha");
+ await page.locator("button[data-cite='c1']").first().click();
+ const add = page.locator("[data-evidence='c1'] [data-add-to-video]");
+ await expect(add).toContainText("sitews/polemic-alpha");
+ await add.getByRole("button", { name: "Add to video" }).click();
+ await expect(add.getByRole("status")).toContainText("cite-c1");
+
+ const m = readJson(MANIFEST);
+ expect(m.timeline.length).toBe(before + 1);
+ expect(m.timeline.at(-1)).toMatchObject({ type: "clip", id: "cite-c1", channel: "sitechan", video: "sv1", start: 3, end: 6 });
+ const notes = readJson(PROJ_NOTES).notes;
+ expect(notes.some((n: { anchor: { kind: string } }) => n.anchor.kind === "edit")).toBe(true);
+
+ const g = await (await request.get("/api/report/timeline?project=sitews/polemic-alpha")).json();
+ const undo = await request.post("/api/report/timeline", { data: { project: "sitews/polemic-alpha", token: g.token, op: "undo" } });
+ expect(undo.ok()).toBe(true);
+ expect(readJson(MANIFEST).timeline.length).toBe(before);
+});