Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mumtool/app/sites/[site]/[report]/evidence/page.tsx | 9+++++++--
Mumtool/app/sites/[site]/[report]/page.tsx | 10+++++-----
Aumtool/components/articles/AddToVideo.tsx | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/components/articles/ArticleReader.tsx | 27+++++++++++++++++++++++----
Mumtool/components/articles/ArticleVideo.tsx | 35++++++++++++++++++++++++++++-------
Mumtool/components/articles/EvidencePanel.tsx | 9+++++++--
Mumtool/components/articles/EvidenceWalk.tsx | 5++++-
Mumtool/components/notes/NotesProvider.tsx | 9+++++++++
Aumtool/e2e/article-integration.spec.ts | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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); +});