Skip to content

Commit 93adf30

Browse files
Removed dead code and consolidated repeated helpers (#53)
* Removed the orphaned payload and annotations modules src/lib/payload.ts and src/lib/annotations.ts have had no importer in src/ since #8 and #41 respectively replaced the flows that used them; only their own unit tests kept them reachable. Deleted both, along with the types in lib/types.ts that existed solely to serve them (the Extracted* union, the annotations document shapes, PayloadMetadata, OutputKind, ImageFormat and PackagingType). TrimMode stays, being what lib/ffmpeg.ts and lib/extract.ts read. Also removed two other exports nothing references at all — format.ts's blobSize and testInjection.ts's testInjectionOrInert with its INERT constant — and moved archives.ts's "A video file in the archive" comment back onto ArchiveVideo, which a reorder had left it detached from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeLYzRaTJ7oFj6fmAYmC8x * Bumped the version and noted the cleanup in the changelog Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeLYzRaTJ7oFj6fmAYmC8x * Removed four more exports nothing in the app calls All four were reachable only from their own unit tests, which went with them: - sanitize.ts's sanitizePath, which was the same algorithm as bidsPath.ts's behAssetPath, the one the delivery path actually calls - sanitize.ts's sanitizeFilename, left behind once every filename the app writes came to be built from BIDS entities instead - blur.ts's hitRegion, a geometric point-in-circle test the blur tool does not use — it hit-tests through the focusable rings in the DOM - the sourceName field on ExtractFrameParams and ExtractOverlayParams, which main.ts filled in but neither function ever read. Kept on ExtractClipParams, which reads it for the source's extension. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeLYzRaTJ7oFj6fmAYmC8x * Folded repeated helpers together across lib/ Five shapes that had been written out more than once, now written once: - lib/etag.ts drove the same 16MB chunk loop, short-read guard and all, three times over. All three hashes are now one pass of `eachChunk`, differing only in which digest the bytes go into. - The NFKD-then-strip-combining-marks fold opening both `sanitizeSegment` and `bidsLabel` is now `foldDiacritics`. - The libx264/faststart argument list is now `X264_MP4_ARGS`, shared by lib/ffmpeg.ts's own encodes and lib/extract.ts's overlay. `-an` stays out of it and is added by the paths that have audio to drop: the overlay's input is a PNG sequence, and the command string is quoted verbatim as that file's `encoding` in its sidecar. - Decoding one sample to read its pixel format is now `decodedPixelFormatAt`, called by lib/videoFormat.ts and by lib/streaming.ts, which passes the frame its own index already names. - The dandiset-listing response shape, its title fallback and the next-page cursor are now declared once in lib/api.ts rather than copied between lib/dandisets.ts and lib/embargoed.ts. lib/upload.ts's own next-page handling is left alone deliberately: it strips the API prefix with `replace` rather than a prefix check, which differs from `nextPagePath` on a foreign URL that happens to contain the API base, so sharing the helper there would not be behavior-neutral. Verified against the full integration suite, including the four expectedOutput specs that byte-compare every emitted sidecar and dataset_description.json. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeLYzRaTJ7oFj6fmAYmC8x * Consolidated the repeated shapes inside main.ts Nothing moves out of the file and no signature changes; these are its own repetitions written once: - browseEmpty is now browseNote with the list cleared first, which is all it ever was. - The three status-line setters share applyHintClass, so the classes showsOutcome reads back cannot drift between them. - Every JSON a delivery writes — the extract's sidecar, the companions' and the three dataset_description.json files — goes out through deliverJson rather than four copies of serialize, hash and hand over. - pictureTechnicalFields picks BEP047's image or video keys once, for both the extract and its pose overlay. - The trim track's ruler and the overview's share appendTick. Their at-end thresholds differ (0.96 and 0.97) and are preserved exactly, passed in as RulerStyle.atEnd; whether that difference is deliberate is asked in #54 rather than settled here. - The dozen nested Math.max/Math.min clamps now call timeline.ts's own clamp, which was already there and private. - Two reads of `?test`'s faked counts, fakedDatasetCount and fakedListingCount, replace five spellings of the same null check. - wireSeg reads a button's dataset value once per click instead of twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeLYzRaTJ7oFj6fmAYmC8x * Tidied the mock-video synthesizers The three synthesizers in lib/testInjection.ts each set up their own canvas and each drew their own frame; both are now mockCanvas() and paintMockFrame(), with the hue step and the caption passed in since those are the only parts that actually differed. Also simplified the two nested ternaries deriving mock_video's and mock_video_long's frame counts — intParam has already dealt with anything unparseable, so only a deliberate `=0` is left to reject — and wrote FROM_EMBER_PATH_PREFIX as the literal it always evaluated to rather than running pad2 over two constants. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeLYzRaTJ7oFj6fmAYmC8x * Moved the blur tool into ui/blurTool.ts The rings over the picture, the controls beside them and the bookkeeping keeping the two in step were 275 lines in the middle of main.ts. They are now their own module, reached through createBlurTool(els, host). A pure move: every function keeps its body, its name and its comment, and the order blurChanged does its work in is preserved exactly, since the delivery card reads the areas as it re-derives itself. The areas themselves stay in state.blurRegions rather than moving into the tool — the player draws them, the delivery card decides on them and every extraction carries them, so the tool mutates the one array all of those already see. What it needed from the page around it (whether a video is loaded, whether a delivery is running, whether the human-subjects gate offers the tool at all, and what to retire when the areas change) is the BlurToolHost it is handed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeLYzRaTJ7oFj6fmAYmC8x * Moved the EMBER browse pane into ui/browsePane.ts The pane was 499 lines in the middle of main.ts and reached the rest of the page through four names: browseSay, refreshBrowse, syncBrowseToAuth, and the browse state itself as a "has this been read yet" check. Those are now say, refresh, syncToAuth and opened on the object createBrowsePane returns. A pure move, function for function — the only body to change is listOwnedEmbargoed's, which asked the archive for the signed-in username and cached it in main.ts's own currentUser. That caching stays where the header avatar and the provenance record can share it, and the pane asks for the answer through host.username instead. What it needs from the page around it is the BrowsePaneHost it is handed: the sign-in state, a config to call the archive under, the player a picked video opens in, and the stage that answers for a video asked for anywhere else. browseFailure stays in main.ts beside stageFailure, since the two are a pair — a refusal is said in whichever of the two places the video was asked for, and never in both. main.ts is 3199 lines, down from 3919 before this pass began. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeLYzRaTJ7oFj6fmAYmC8x * Dropped the changelog entry for this pass Per review. package.json stays at 1.4.2, so the entry under that heading is the author's to write. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeLYzRaTJ7oFj6fmAYmC8x --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5143eea commit 93adf30

27 files changed

Lines changed: 1225 additions & 1614 deletions

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "clip-extractor",
3-
"version": "1.4.1",
3+
"version": "1.4.2",
44
"description": "A single-page video player for selecting a frame range and extracting it as an upload-ready payload, built on sleap-io.js and ffmpeg.wasm.",
55
"type": "module",
66
"license": "MIT",

src/lib/annotations.ts

Lines changed: 0 additions & 70 deletions
This file was deleted.

src/lib/api.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,32 @@ export async function apiFetch<T = unknown>(
4444
if (resp.status === 204) return null;
4545
return (await resp.json()) as T;
4646
}
47+
48+
/** The slice of the archive's dandiset-listing response both lib/dandisets.ts (upload destinations)
49+
* and lib/embargoed.ts (what a signed-in visitor may browse) read. They ask for different sets and
50+
* do different things with the answer, but the archive describes a dandiset the same way to both. */
51+
export interface DandisetListItem {
52+
identifier: string;
53+
embargo_status?: string;
54+
draft_version?: { name?: string };
55+
most_recent_published_version?: { name?: string };
56+
}
57+
58+
export interface DandisetListResponse {
59+
results?: DandisetListItem[];
60+
next?: string | null;
61+
}
62+
63+
/** A listed dandiset's title: the published name where there is one, else the draft's, else nothing.
64+
* Every dandiset has a draft; only some have been published, and a published name is the more
65+
* considered of the two. */
66+
export function listedTitle(item: DandisetListItem): string {
67+
return item.most_recent_published_version?.name ?? item.draft_version?.name ?? "";
68+
}
69+
70+
/** The path to ask {@link apiFetch} for next, from the absolute `next` URL a paged response carries.
71+
* Null both when there is no next page and when it points somewhere other than this archive, which
72+
* is not ours to follow. */
73+
export function nextPagePath(cfg: ArchiveConfig, next: string | null | undefined): string | null {
74+
return next && next.startsWith(cfg.api) ? next.slice(cfg.api.length) : null;
75+
}

src/lib/archives.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,6 @@ export async function fetchDandisetName(dandiset: ArchiveDandiset, signal?: Abor
188188
}
189189
}
190190

191-
/** A video file in the archive, ready to be streamed. */
192191
/** Where in the archive a video came from — enough to name it again later without re-reading a
193192
* manifest. Built by {@link archiveSourceOf} from a listing entry, and recorded in a delivery's
194193
* derivatives `SourceDatasets` (see lib/provenance.ts's `buildSourceDatasetEntry`). */
@@ -216,6 +215,7 @@ export function archiveSourceOf(video: ArchiveVideo): ArchiveSource {
216215
return { dandisetId: video.dandisetId, path: video.path, blobId: blobIdFromBucketUrl(video.streamUrl) };
217216
}
218217

218+
/** A video file in the archive, ready to be streamed. */
219219
export interface ArchiveVideo {
220220
dandisetId: string;
221221
/** Path within the dataset, e.g. `sub-1/mice.mp4`. */

src/lib/bidsPath.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
// BEP047's own vocabulary has no entity for. The plain extracted clip carries no `desc-` of its own:
4343
// it is the delivery's primary output, so there is nothing it needs setting apart from.
4444

45-
import { sanitizeSegment } from "./sanitize";
45+
import { foldDiacritics, sanitizeSegment } from "./sanitize";
4646

4747
/** Every asset this app writes into a dandiset's derivatives sits under this pipeline name. */
4848
export const DERIVATIVES_PIPELINE = "clip-extractor";
@@ -51,10 +51,7 @@ export const DERIVATIVES_PIPELINE = "clip-extractor";
5151
* segment (see lib/sanitize.ts's `sanitizeSegment`, which keeps `._+-`). Accents fold to their base
5252
* letter first, so "café" reads as "cafe" rather than losing the character outright. */
5353
export function bidsLabel(value: string, fallback: string): string {
54-
const collapsed = value
55-
.normalize("NFKD")
56-
.replace(/[̀-ͯ]/g, "")
57-
.replace(/[^A-Za-z0-9]+/g, "");
54+
const collapsed = foldDiacritics(value).replace(/[^A-Za-z0-9]+/g, "");
5855
return collapsed || fallback;
5956
}
6057

src/lib/blur.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,6 @@ export function clampRegion(region: BlurRegion, width: number, height: number):
5353
};
5454
}
5555

56-
/** Which region a point falls in, latest first (the one drawn on top), or -1 for none. */
57-
export function hitRegion(regions: BlurRegion[], x: number, y: number): number {
58-
for (let i = regions.length - 1; i >= 0; i--) {
59-
if (Math.hypot(x - regions[i].x, y - regions[i].y) <= regions[i].radius) return i;
60-
}
61-
return -1;
62-
}
63-
6456
/** Where the frame sits inside the box the player draws it in: the scale it is drawn at, and the
6557
* letterbox margins left around it. */
6658
export interface FrameFit {

src/lib/dandisets.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ArchiveConfig } from "./types";
2-
import { apiFetch } from "./api";
2+
import { apiFetch, listedTitle, type DandisetListResponse } from "./api";
33

44
export interface IncomingDandiset {
55
identifier: string;
@@ -17,17 +17,6 @@ export interface IncomingDandisetsResult {
1717
unverified: number;
1818
}
1919

20-
interface DandisetListItem {
21-
identifier: string;
22-
embargo_status?: string;
23-
draft_version?: { name?: string };
24-
most_recent_published_version?: { name?: string };
25-
}
26-
27-
interface DandisetListResponse {
28-
results: DandisetListItem[];
29-
}
30-
3120
const INCOMING_PREFIX = "Incoming: ";
3221

3322
/**
@@ -72,7 +61,7 @@ export async function listIncomingDandisets(cfg: ArchiveConfig): Promise<Incomin
7261
const candidates = (resp?.results ?? [])
7362
.map((d) => ({
7463
identifier: d.identifier,
75-
title: d.most_recent_published_version?.name ?? d.draft_version?.name ?? "",
64+
title: listedTitle(d),
7665
embargoed: d.embargo_status === "EMBARGOED",
7766
}))
7867
.filter((d) => d.title.startsWith(INCOMING_PREFIX));

src/lib/embargoed.ts

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ArchiveConfig } from "./types";
22
import type { ArchiveDandiset, ArchiveVideo } from "./archives";
33
import { isVideoAsset } from "./archives";
4-
import { apiFetch } from "./api";
4+
import { apiFetch, listedTitle, nextPagePath, type DandisetListResponse } from "./api";
55

66
// The half of the browse pane that the public S3 bucket cannot answer.
77
//
@@ -11,18 +11,6 @@ import { apiFetch } from "./api";
1111
// same OAuth token the upload side already holds: which datasets they own, what is in one, and a
1212
// signed URL for the bytes of a chosen video.
1313

14-
interface DandisetListItem {
15-
identifier: string;
16-
embargo_status?: string;
17-
draft_version?: { name?: string };
18-
most_recent_published_version?: { name?: string };
19-
}
20-
21-
interface DandisetListResponse {
22-
results?: DandisetListItem[];
23-
next?: string | null;
24-
}
25-
2614
interface DandisetOwner {
2715
username?: string;
2816
}
@@ -48,7 +36,7 @@ export async function listPublicDandisetIds(cfg: ArchiveConfig, signal?: AbortSi
4836
if (signal?.aborted) throw new Error("Listing cancelled.");
4937
const body: DandisetListResponse = (await apiFetch<DandisetListResponse>(anonymous, path)) ?? {};
5038
for (const d of body.results ?? []) ids.add(d.identifier);
51-
path = body.next && body.next.startsWith(cfg.api) ? body.next.slice(cfg.api.length) : null;
39+
path = nextPagePath(cfg, body.next);
5240
}
5341
return ids;
5442
}
@@ -90,7 +78,7 @@ export async function listOwnedEmbargoedDandisets(cfg: ArchiveConfig, username:
9078
// No manifest is read for it, so there is no manifest size to report.
9179
manifestBytes: 0,
9280
embargoed: true,
93-
name: d.most_recent_published_version?.name ?? d.draft_version?.name ?? "",
81+
name: listedTitle(d),
9482
}));
9583

9684
const owned = await Promise.all(
@@ -146,9 +134,7 @@ export async function listEmbargoedVideos(cfg: ArchiveConfig, dandisetId: string
146134
embargoed: true,
147135
});
148136
}
149-
// The archive returns absolute `next` URLs; apiFetch takes paths, and a next page pointing at
150-
// another host is not this archive's to follow.
151-
path = body.next && body.next.startsWith(cfg.api) ? body.next.slice(cfg.api.length) : null;
137+
path = nextPagePath(cfg, body.next);
152138
}
153139
return videos.sort((a, b) => a.path.localeCompare(b.path));
154140
}

src/lib/etag.ts

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -49,22 +49,36 @@ export function planParts(fileSize: number): FilePart[] {
4949
return parts;
5050
}
5151

52-
/** MD5 of one part of a blob, streamed in 16MB chunks so a large source video never lands in
53-
* memory whole. */
54-
export async function hashPart(blob: Blob, part: FilePart, onChunk: (bytesDoneInPart: number) => void): Promise<Uint8Array> {
55-
const spark = new SparkMD5.ArrayBuffer();
52+
/** Reads `length` bytes of `blob` from `offset` in HASH_CHUNK-sized pieces, handing each to `take`
53+
* along with the running total read so far, so a multi-gigabyte source never lands in memory whole.
54+
* Every hash below is one pass of this: what differs between them is only which digest the bytes go
55+
* into and how the running total is reported.
56+
*
57+
* A short read means the file changed underneath the hash — a browser hands out a `File` as a live
58+
* handle on something the visitor can still edit or unmount — and a digest folded from part-old,
59+
* part-new bytes would name a blob that never existed. */
60+
async function eachChunk(blob: Blob, offset: number, length: number, take: (buf: ArrayBuffer, readSoFar: number) => void): Promise<void> {
5661
let read = 0;
57-
while (read < part.size) {
58-
const n = Math.min(HASH_CHUNK, part.size - read);
59-
const start = part.offset + read;
62+
while (read < length) {
63+
const n = Math.min(HASH_CHUNK, length - read);
64+
const start = offset + read;
6065
const buf = await blob.slice(start, start + n).arrayBuffer();
6166
if (buf.byteLength !== n) {
6267
throw new Error("The source file changed while hashing — please re-load it.");
6368
}
64-
spark.append(buf);
6569
read += n;
66-
onChunk(read);
70+
take(buf, read);
6771
}
72+
}
73+
74+
/** MD5 of one part of a blob, streamed in 16MB chunks so a large source video never lands in
75+
* memory whole. */
76+
export async function hashPart(blob: Blob, part: FilePart, onChunk: (bytesDoneInPart: number) => void): Promise<Uint8Array> {
77+
const spark = new SparkMD5.ArrayBuffer();
78+
await eachChunk(blob, part.offset, part.size, (buf, read) => {
79+
spark.append(buf);
80+
onChunk(read);
81+
});
6882
// end(true) yields the raw 16-byte digest as a binary string
6983
const raw = spark.end(true);
7084
const digest = new Uint8Array(16);
@@ -103,17 +117,10 @@ export async function computeDandiEtag(blob: Blob, parts: FilePart[], onProgress
103117
* digest at every part boundary and this one must not. */
104118
export async function computeMd5(blob: Blob, onProgress: (fraction: number) => void = () => {}): Promise<string> {
105119
const spark = new SparkMD5.ArrayBuffer();
106-
let read = 0;
107-
while (read < blob.size) {
108-
const n = Math.min(HASH_CHUNK, blob.size - read);
109-
const buf = await blob.slice(read, read + n).arrayBuffer();
110-
if (buf.byteLength !== n) {
111-
throw new Error("The source file changed while hashing — please re-load it.");
112-
}
120+
await eachChunk(blob, 0, blob.size, (buf, read) => {
113121
spark.append(buf);
114-
read += n;
115122
onProgress(blob.size ? read / blob.size : 1);
116-
}
123+
});
117124
onProgress(1);
118125
return spark.end();
119126
}
@@ -126,17 +133,10 @@ export async function computeMd5(blob: Blob, onProgress: (fraction: number) => v
126133
export async function computeSha256(blob: Blob, onProgress: (fraction: number) => void = () => {}): Promise<string> {
127134
const hasher = await createSHA256();
128135
hasher.init();
129-
let read = 0;
130-
while (read < blob.size) {
131-
const n = Math.min(HASH_CHUNK, blob.size - read);
132-
const buf = await blob.slice(read, read + n).arrayBuffer();
133-
if (buf.byteLength !== n) {
134-
throw new Error("The source file changed while hashing — please re-load it.");
135-
}
136+
await eachChunk(blob, 0, blob.size, (buf, read) => {
136137
hasher.update(new Uint8Array(buf));
137-
read += n;
138138
onProgress(blob.size ? read / blob.size : 1);
139-
}
139+
});
140140
onProgress(1);
141141
return hasher.digest("hex");
142142
}

0 commit comments

Comments
 (0)