paperlint 6.1.0 → 6.2.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/dist/adapters/mp4box/index.d.ts +4 -0
- package/dist/adapters/mp4box/index.d.ts.map +1 -0
- package/dist/adapters/mp4box/index.js +46 -0
- package/dist/adapters/mp4box/index.js.map +1 -0
- package/dist/adapters/node/index.d.ts +1 -0
- package/dist/adapters/node/index.d.ts.map +1 -1
- package/dist/adapters/node/index.js +1 -0
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/adapters/node/list-dir.io.d.ts +3 -0
- package/dist/adapters/node/list-dir.io.d.ts.map +1 -0
- package/dist/adapters/node/list-dir.io.js +15 -0
- package/dist/adapters/node/list-dir.io.js.map +1 -0
- package/dist/adapters/png/index.d.ts +11 -0
- package/dist/adapters/png/index.d.ts.map +1 -0
- package/dist/adapters/png/index.js +21 -0
- package/dist/adapters/png/index.js.map +1 -0
- package/dist/adapters/srt/index.d.ts +4 -0
- package/dist/adapters/srt/index.d.ts.map +1 -0
- package/dist/adapters/srt/index.js +12 -0
- package/dist/adapters/srt/index.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +25 -4
- package/dist/cli.js.map +1 -1
- package/dist/domain/talk.d.ts +121 -0
- package/dist/domain/talk.d.ts.map +1 -0
- package/dist/domain/talk.js +121 -0
- package/dist/domain/talk.js.map +1 -0
- package/dist/lib/paper-config.d.ts +2 -0
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +3 -0
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/paper-settings.d.ts +3 -0
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +15 -9
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/talk-media.d.ts +18 -0
- package/dist/ports/talk-media.d.ts.map +1 -0
- package/dist/ports/talk-media.js +2 -0
- package/dist/ports/talk-media.js.map +1 -0
- package/dist/presets.d.ts +3 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +6 -10
- package/dist/presets.js.map +1 -1
- package/dist/talk-rules.d.ts +65 -0
- package/dist/talk-rules.d.ts.map +1 -0
- package/dist/talk-rules.js +451 -0
- package/dist/talk-rules.js.map +1 -0
- package/dist/tex-requirements.d.ts +3 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +2 -0
- package/dist/tex-requirements.js.map +1 -1
- package/docs/configuration.md +2 -1
- package/docs/rules/talk/captions-cover.md +47 -0
- package/docs/rules/talk/duration-floor.md +38 -0
- package/docs/rules/talk/duration.md +43 -0
- package/docs/rules/talk/one-slide-size.md +38 -0
- package/docs/rules/talk/profile.md +64 -0
- package/docs/rules/talk/required-files.md +39 -0
- package/docs/rules/talk/undeclared.md +35 -0
- package/docs/rules/talk/video-format.md +43 -0
- package/docs/rules.md +11 -0
- package/docs/talk.md +94 -0
- package/fixtures/talk/audio-only.mp4 +0 -0
- package/fixtures/talk/one-slide.png +0 -0
- package/fixtures/talk/short-audio.mp4 +0 -0
- package/fixtures/talk/talk.mov +0 -0
- package/fixtures/talk/talk.mp4 +0 -0
- package/fixtures/talk/talk.srt +7 -0
- package/lib/paper-config.harness.mjs +5 -2
- package/lib/paper-config.ts +4 -0
- package/package.json +3 -1
- package/presets/agenticdev.jsonc +29 -0
- package/presets/venue-profile.schema.json +132 -0
- package/src/adapters/mp4box/index.test.ts +63 -0
- package/src/adapters/mp4box/index.ts +50 -0
- package/src/adapters/node/index.ts +1 -0
- package/src/adapters/node/list-dir.io.ts +15 -0
- package/src/adapters/node/list-dir.test.ts +30 -0
- package/src/adapters/png/index.test.ts +43 -0
- package/src/adapters/png/index.ts +29 -0
- package/src/adapters/srt/index.test.ts +24 -0
- package/src/adapters/srt/index.ts +13 -0
- package/src/cli.test.ts +12 -0
- package/src/cli.ts +31 -3
- package/src/domain/talk.test.ts +125 -0
- package/src/domain/talk.ts +274 -0
- package/src/new-paper.test.ts +1 -0
- package/src/paper-settings.test.ts +18 -1
- package/src/paper-settings.ts +21 -11
- package/src/ports/talk-media.ts +19 -0
- package/src/presets.ts +9 -10
- package/src/rule-cases.test.ts +110 -3
- package/src/talk-rules.test.ts +459 -0
- package/src/talk-rules.ts +672 -0
- package/src/tex-requirements.ts +9 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TALK RULES — a paper's finished talk files judged against what its venue asks a presenter to
|
|
3
|
+
* send. paperlint does not make the talk and does not know how it was made; it reads the files.
|
|
4
|
+
*
|
|
5
|
+
* talk/profile error the paper's `talk` can be judged: a venue preset with a `talk`
|
|
6
|
+
* block, a mode it allows, a slot for the paper's kind
|
|
7
|
+
* talk/required-files error every file the venue requires for the paper's mode is in the folder
|
|
8
|
+
* talk/undeclared warn a video sits in the paper's `talk/` folder, and no `talk` is declared
|
|
9
|
+
* talk/duration error the video is no longer than the slot's talk; its audio track runs as
|
|
10
|
+
* long as the container
|
|
11
|
+
* talk/duration-floor warn the video is no shorter than the talk the venue asks for
|
|
12
|
+
* talk/video-format error the video is an MP4, tall enough, small enough
|
|
13
|
+
* talk/one-slide-size error the one-slide image has the declared size
|
|
14
|
+
* talk/captions-cover warn the captions cover the video: start near its start, end near its end,
|
|
15
|
+
* no long gap
|
|
16
|
+
*
|
|
17
|
+
* ── WHAT THEY READ ───────────────────────────────────────────────────────────────
|
|
18
|
+
* Like the venue rules, they run on a paper's `paper.tex` and judge the files beside it: the paper's
|
|
19
|
+
* `paperlint.json` (`talk`, `kind`, `extends`), the preset it resolves to (`talk` block), and the
|
|
20
|
+
* files in the talk folder, read through the `TalkMedia` port — MP4Box.js for the video, the PNG
|
|
21
|
+
* header for the image, an SRT parser for the captions. No ffmpeg, no build step, no facts file.
|
|
22
|
+
*
|
|
23
|
+
* ── WHO SPEAKS WHEN THE INPUT CANNOT BE JUDGED ───────────────────────────────────
|
|
24
|
+
* no `talk` declared every rule silent but talk/undeclared
|
|
25
|
+
* settings or preset broken every talk rule silent — pdf/profile says why
|
|
26
|
+
* talk, but nothing to judge by talk/profile; the rules that need the missing part are silent
|
|
27
|
+
* a file missing talk/required-files; the rules reading that file are silent
|
|
28
|
+
* a video that does not parse talk/video-format; duration and captions silent
|
|
29
|
+
*/
|
|
30
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
31
|
+
import { callerPath } from "./caller-path.ts";
|
|
32
|
+
import { CONFIG_FILE } from "#lib/paper-config";
|
|
33
|
+
import { paperPreset } from "./presets.ts";
|
|
34
|
+
import { fieldOf } from "./domain/record.ts";
|
|
35
|
+
import type { Files } from "./ports/files.ts";
|
|
36
|
+
import type { ListDir, TalkMedia } from "./ports/talk-media.ts";
|
|
37
|
+
import {
|
|
38
|
+
DEFAULT_TALK_DIR,
|
|
39
|
+
type Cue,
|
|
40
|
+
type PaperTalk,
|
|
41
|
+
type PixelSize,
|
|
42
|
+
type TalkArtifact,
|
|
43
|
+
type TalkSlot,
|
|
44
|
+
type VenueTalk,
|
|
45
|
+
type VideoProbe,
|
|
46
|
+
} from "./domain/talk.ts";
|
|
47
|
+
import {
|
|
48
|
+
rulePageUrl,
|
|
49
|
+
type VenueRuleContext,
|
|
50
|
+
type VenueRuleModule,
|
|
51
|
+
} from "./venue-rules.ts";
|
|
52
|
+
|
|
53
|
+
// ── what one lint run knows about a paper's talk ─────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/** What the rules are built with. */
|
|
56
|
+
export interface TalkRuleDeps {
|
|
57
|
+
readonly files: Files;
|
|
58
|
+
/** The package's presets directory: the shipped presets and their schema. */
|
|
59
|
+
readonly venuesDir: string;
|
|
60
|
+
readonly media: TalkMedia;
|
|
61
|
+
readonly listDir: ListDir;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A paper that declares a talk, with what its venue says about talks (or null). */
|
|
65
|
+
interface Declared {
|
|
66
|
+
readonly kind: "declared";
|
|
67
|
+
readonly paperDir: string;
|
|
68
|
+
readonly talk: PaperTalk;
|
|
69
|
+
readonly paperKind: string | null;
|
|
70
|
+
/** The preset's label and its `talk` block (null when it has none); null when the paper names no preset. */
|
|
71
|
+
readonly venue: {
|
|
72
|
+
readonly label: string;
|
|
73
|
+
readonly talk: VenueTalk | null;
|
|
74
|
+
} | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type Assessment =
|
|
78
|
+
/** No `talk` in the paper's settings; the paths of stray videos in the default folder. */
|
|
79
|
+
| { readonly kind: "undeclared"; readonly strays: readonly string[] }
|
|
80
|
+
/** Settings or preset broken — `pdf/profile` reports it. */
|
|
81
|
+
| { readonly kind: "unreadable" }
|
|
82
|
+
| Declared;
|
|
83
|
+
|
|
84
|
+
/** The `.mp4` files in the paper's default talk folder. */
|
|
85
|
+
const straysOf = (paperDir: string, deps: TalkRuleDeps): readonly string[] =>
|
|
86
|
+
deps
|
|
87
|
+
.listDir(callerPath(join(paperDir, DEFAULT_TALK_DIR)))
|
|
88
|
+
.filter((n) => n.toLowerCase().endsWith(".mp4"))
|
|
89
|
+
.map((n) => join(paperDir, DEFAULT_TALK_DIR, n));
|
|
90
|
+
|
|
91
|
+
/** One paper, assessed. Reads through `deps` only; never throws on a paper's files. */
|
|
92
|
+
function assess(paperDir: string, deps: TalkRuleDeps): Assessment {
|
|
93
|
+
const p = paperPreset(paperDir, deps);
|
|
94
|
+
if (p.kind === "settings-problem" || p.kind === "preset-problem")
|
|
95
|
+
return { kind: "unreadable" };
|
|
96
|
+
const settings = p.settings;
|
|
97
|
+
if (settings?.talk == null)
|
|
98
|
+
return { kind: "undeclared", strays: straysOf(paperDir, deps) };
|
|
99
|
+
return {
|
|
100
|
+
kind: "declared",
|
|
101
|
+
paperDir,
|
|
102
|
+
talk: settings.talk,
|
|
103
|
+
paperKind: settings.kind,
|
|
104
|
+
venue:
|
|
105
|
+
p.kind === "resolved"
|
|
106
|
+
? { label: p.preset.label, talk: p.preset.talk }
|
|
107
|
+
: null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── the verdict's vocabulary ─────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
interface Finding {
|
|
114
|
+
readonly messageId: string;
|
|
115
|
+
readonly data: Readonly<Record<string, string | number>>;
|
|
116
|
+
}
|
|
117
|
+
const finding = (
|
|
118
|
+
messageId: string,
|
|
119
|
+
data: Readonly<Record<string, string | number>> = {},
|
|
120
|
+
): Finding => ({ messageId, data });
|
|
121
|
+
|
|
122
|
+
/** Seconds as `m:ss.s (N s)`: what a person reads off a player, and the exact number. */
|
|
123
|
+
export function clock(s: number): string {
|
|
124
|
+
const tenths = Math.round(s * 10) / 10;
|
|
125
|
+
const m = Math.floor(tenths / 60);
|
|
126
|
+
const rest = (tenths - m * 60).toFixed(1).padStart(4, "0");
|
|
127
|
+
return `${String(m)}:${rest} (${String(Math.round(s * 100) / 100)} s)`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Where artifact `a` is looked for. */
|
|
131
|
+
const fileOf = (d: Declared, a: TalkArtifact): string =>
|
|
132
|
+
join(d.paperDir, d.talk.dir, d.talk.files[a]);
|
|
133
|
+
|
|
134
|
+
const bytesOf = (deps: TalkRuleDeps, path: string): Uint8Array | null =>
|
|
135
|
+
deps.files.readBytes(callerPath(path));
|
|
136
|
+
|
|
137
|
+
/** The venue's label and `talk` block, when the paper names a preset that has one. */
|
|
138
|
+
interface Asked {
|
|
139
|
+
readonly label: string;
|
|
140
|
+
readonly talk: VenueTalk;
|
|
141
|
+
}
|
|
142
|
+
const askedOf = (d: Declared): Asked | null =>
|
|
143
|
+
d.venue === null || d.venue.talk === null
|
|
144
|
+
? null
|
|
145
|
+
: { label: d.venue.label, talk: d.venue.talk };
|
|
146
|
+
|
|
147
|
+
/** The slot of the paper's kind, with the names it is reported under. */
|
|
148
|
+
export interface Slotted {
|
|
149
|
+
readonly venue: string;
|
|
150
|
+
readonly kind: string;
|
|
151
|
+
readonly slot: TalkSlot;
|
|
152
|
+
}
|
|
153
|
+
function slotOf(d: Declared): Slotted | null {
|
|
154
|
+
const asked = askedOf(d);
|
|
155
|
+
if (asked === null || d.paperKind === null) return null;
|
|
156
|
+
const slot = asked.talk.kinds.get(d.paperKind);
|
|
157
|
+
return slot === undefined
|
|
158
|
+
? null
|
|
159
|
+
: { venue: asked.label, kind: d.paperKind, slot };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The video's probe; null when the file is absent or does not parse (other rules say which). */
|
|
163
|
+
function videoOf(d: Declared, deps: TalkRuleDeps): VideoProbe | null {
|
|
164
|
+
const bytes = bytesOf(deps, fileOf(d, "video"));
|
|
165
|
+
return bytes === null ? null : deps.media.probeVideo(bytes);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── the judges ───────────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
interface JudgeContext {
|
|
171
|
+
readonly deps: TalkRuleDeps;
|
|
172
|
+
readonly options: readonly unknown[];
|
|
173
|
+
readonly shown: (path: string) => string;
|
|
174
|
+
}
|
|
175
|
+
type Judge = (d: Declared, ctx: JudgeContext) => readonly Finding[];
|
|
176
|
+
|
|
177
|
+
/** What keeps the declaration from being judged, most basic first. */
|
|
178
|
+
export function judgeProfile(d: Declared): readonly Finding[] {
|
|
179
|
+
if (d.venue === null) return [finding("noPreset")];
|
|
180
|
+
const venue = d.venue.label;
|
|
181
|
+
const vt = d.venue.talk;
|
|
182
|
+
if (vt === null) return [finding("noTalkBlock", { venue })];
|
|
183
|
+
const mode = d.talk.mode;
|
|
184
|
+
const modes = vt.modes.join(", ");
|
|
185
|
+
const modeProblem =
|
|
186
|
+
vt.modes.length > 0 && !vt.modes.includes(mode)
|
|
187
|
+
? [finding("mode", { venue, mode, modes })]
|
|
188
|
+
: [];
|
|
189
|
+
const known = [...vt.kinds.keys()].join(", ") || "(none)";
|
|
190
|
+
const kind = d.paperKind;
|
|
191
|
+
const kindProblem =
|
|
192
|
+
kind === null
|
|
193
|
+
? [finding("kindMissing", { venue, known })]
|
|
194
|
+
: vt.kinds.has(kind)
|
|
195
|
+
? []
|
|
196
|
+
: [finding("kindNoSlot", { venue, kind, known })];
|
|
197
|
+
return [...modeProblem, ...kindProblem];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The artifacts the paper's mode owes at its venue, captions included when the venue requires them. */
|
|
201
|
+
export function requiredArtifacts(
|
|
202
|
+
vt: VenueTalk,
|
|
203
|
+
mode: PaperTalk["mode"],
|
|
204
|
+
): readonly TalkArtifact[] {
|
|
205
|
+
const owed = vt.artifacts.get(mode)?.required ?? [];
|
|
206
|
+
const captions: readonly TalkArtifact[] =
|
|
207
|
+
vt.video.captions === "required" && owed.includes("video")
|
|
208
|
+
? ["captions"]
|
|
209
|
+
: [];
|
|
210
|
+
return [...new Set([...owed, ...captions])];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const judgeRequired: Judge = (d, { deps, shown }) => {
|
|
214
|
+
const asked = askedOf(d);
|
|
215
|
+
return asked === null
|
|
216
|
+
? []
|
|
217
|
+
: requiredArtifacts(asked.talk, d.talk.mode)
|
|
218
|
+
.filter((a) => !deps.files.isFile(callerPath(fileOf(d, a))))
|
|
219
|
+
.map((a) =>
|
|
220
|
+
finding("missing", {
|
|
221
|
+
artifact: a,
|
|
222
|
+
file: shown(fileOf(d, a)),
|
|
223
|
+
venue: asked.label,
|
|
224
|
+
mode: d.talk.mode,
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/** Seconds a track may differ from the container before it is reported. */
|
|
230
|
+
export const DEFAULT_TRACK_TOLERANCE_S = 1;
|
|
231
|
+
|
|
232
|
+
const numberOption = (options: unknown, key: string, dflt: number): number => {
|
|
233
|
+
const v = fieldOf(options, key);
|
|
234
|
+
return typeof v === "number" ? v : dflt;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/** Over the slot's ceiling, and an audio track that ends away from the container's end. */
|
|
238
|
+
export function judgeDuration(
|
|
239
|
+
v: VideoProbe,
|
|
240
|
+
slotted: Slotted | null,
|
|
241
|
+
at: { readonly file: string; readonly tolS: number },
|
|
242
|
+
): readonly Finding[] {
|
|
243
|
+
const over =
|
|
244
|
+
slotted !== null && v.durationS > slotted.slot.talkSMax
|
|
245
|
+
? [
|
|
246
|
+
finding("tooLong", {
|
|
247
|
+
file: at.file,
|
|
248
|
+
got: clock(v.durationS),
|
|
249
|
+
max: clock(slotted.slot.talkSMax),
|
|
250
|
+
venue: slotted.venue,
|
|
251
|
+
}),
|
|
252
|
+
]
|
|
253
|
+
: [];
|
|
254
|
+
const off = v.audioS === null ? 0 : Math.abs(v.durationS - v.audioS);
|
|
255
|
+
const mismatch =
|
|
256
|
+
v.audioS !== null && off > at.tolS
|
|
257
|
+
? [
|
|
258
|
+
finding("trackMismatch", {
|
|
259
|
+
file: at.file,
|
|
260
|
+
video: clock(v.durationS),
|
|
261
|
+
audio: clock(v.audioS),
|
|
262
|
+
tol: at.tolS,
|
|
263
|
+
}),
|
|
264
|
+
]
|
|
265
|
+
: [];
|
|
266
|
+
return [...over, ...mismatch];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const judgeDurationRule: Judge = (d, { deps, options, shown }) => {
|
|
270
|
+
const v = videoOf(d, deps);
|
|
271
|
+
return v === null
|
|
272
|
+
? []
|
|
273
|
+
: judgeDuration(v, slotOf(d), {
|
|
274
|
+
file: shown(fileOf(d, "video")),
|
|
275
|
+
tolS: numberOption(
|
|
276
|
+
options[0],
|
|
277
|
+
"trackToleranceS",
|
|
278
|
+
DEFAULT_TRACK_TOLERANCE_S,
|
|
279
|
+
),
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const judgeFloor: Judge = (d, { deps, shown }) => {
|
|
284
|
+
const v = videoOf(d, deps);
|
|
285
|
+
const s = slotOf(d);
|
|
286
|
+
const min = s?.slot.talkSMin ?? null;
|
|
287
|
+
return v === null || s === null || min === null || v.durationS >= min
|
|
288
|
+
? []
|
|
289
|
+
: [
|
|
290
|
+
finding("tooShort", {
|
|
291
|
+
file: shown(fileOf(d, "video")),
|
|
292
|
+
got: clock(v.durationS),
|
|
293
|
+
min: clock(min),
|
|
294
|
+
venue: s.venue,
|
|
295
|
+
kind: s.kind,
|
|
296
|
+
}),
|
|
297
|
+
];
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/** The video's container, height and size against the venue's limits. */
|
|
301
|
+
export function judgeVideoFormat(
|
|
302
|
+
v: VideoProbe,
|
|
303
|
+
bytes: number,
|
|
304
|
+
limits: VenueTalk["video"],
|
|
305
|
+
): readonly Finding[] {
|
|
306
|
+
const qt = v.brands[0] === "qt ";
|
|
307
|
+
const container =
|
|
308
|
+
limits.container === "mp4" && qt ? [finding("container", {})] : [];
|
|
309
|
+
const min = limits.minHeightPx;
|
|
310
|
+
const height =
|
|
311
|
+
min === null
|
|
312
|
+
? []
|
|
313
|
+
: v.heightPx === null
|
|
314
|
+
? [finding("noVideoTrack", {})]
|
|
315
|
+
: v.heightPx < min
|
|
316
|
+
? [finding("height", { got: v.heightPx, min })]
|
|
317
|
+
: [];
|
|
318
|
+
const max = limits.maxBytes;
|
|
319
|
+
const size =
|
|
320
|
+
max !== null && bytes > max ? [finding("bytes", { got: bytes, max })] : [];
|
|
321
|
+
return [...container, ...height, ...size];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const judgeFormat: Judge = (d, { deps, shown }) => {
|
|
325
|
+
const bytes = bytesOf(deps, fileOf(d, "video"));
|
|
326
|
+
const asked = askedOf(d);
|
|
327
|
+
if (bytes === null || asked === null) return [];
|
|
328
|
+
const v = deps.media.probeVideo(bytes);
|
|
329
|
+
const file = shown(fileOf(d, "video"));
|
|
330
|
+
return v === null
|
|
331
|
+
? [finding("unreadable", { file })]
|
|
332
|
+
: judgeVideoFormat(v, bytes.length, asked.talk.video).map((f) =>
|
|
333
|
+
finding(f.messageId, { ...f.data, file, venue: asked.label }),
|
|
334
|
+
);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/** The size to hold the one-slide to: the paper's own, else the venue's; null when neither says. */
|
|
338
|
+
const oneSlideSizeOf = (d: Declared): PixelSize | null =>
|
|
339
|
+
d.talk.oneSlide ?? askedOf(d)?.talk.oneSlide ?? null;
|
|
340
|
+
|
|
341
|
+
const judgeOneSlide: Judge = (d, { deps, shown }) => {
|
|
342
|
+
const want = oneSlideSizeOf(d);
|
|
343
|
+
const bytes = bytesOf(deps, fileOf(d, "one-slide"));
|
|
344
|
+
if (want === null || bytes === null) return [];
|
|
345
|
+
const got = deps.media.imageSize(bytes);
|
|
346
|
+
const file = shown(fileOf(d, "one-slide"));
|
|
347
|
+
const size = (s: PixelSize) => `${String(s.width)}×${String(s.height)}`;
|
|
348
|
+
return got === null
|
|
349
|
+
? [finding("notPng", { file })]
|
|
350
|
+
: got.width === want.width && got.height === want.height
|
|
351
|
+
? []
|
|
352
|
+
: [finding("size", { file, got: size(got), want: size(want) })];
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/** The thresholds of `talk/captions-cover`, in seconds. Uncalibrated: one real talk measured. */
|
|
356
|
+
export interface CoverLimits {
|
|
357
|
+
readonly maxStartS: number;
|
|
358
|
+
readonly maxEndGapS: number;
|
|
359
|
+
readonly maxGapS: number;
|
|
360
|
+
}
|
|
361
|
+
export const DEFAULT_COVER: CoverLimits = {
|
|
362
|
+
maxStartS: 1,
|
|
363
|
+
maxEndGapS: 2,
|
|
364
|
+
maxGapS: 5,
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
/** A silence between captions: where it starts, and how long it lasts. */
|
|
368
|
+
interface Gap {
|
|
369
|
+
readonly at: number;
|
|
370
|
+
readonly s: number;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The longest silence between cues in time order, and where it starts. Overlapping cues leave none. */
|
|
374
|
+
function largestGap(cues: readonly Cue[]): Gap | null {
|
|
375
|
+
const sorted = [...cues].sort((a, b) => a.startS - b.startS);
|
|
376
|
+
return sorted.reduce<{
|
|
377
|
+
readonly end: number | null;
|
|
378
|
+
readonly best: Gap | null;
|
|
379
|
+
}>(
|
|
380
|
+
(acc, cue) => {
|
|
381
|
+
const gap =
|
|
382
|
+
acc.end === null ? null : { at: acc.end, s: cue.startS - acc.end };
|
|
383
|
+
const longer = gap !== null && (acc.best === null || gap.s > acc.best.s);
|
|
384
|
+
return {
|
|
385
|
+
end: Math.max(acc.end ?? cue.endS, cue.endS),
|
|
386
|
+
best: longer ? gap : acc.best,
|
|
387
|
+
};
|
|
388
|
+
},
|
|
389
|
+
{ end: null, best: null },
|
|
390
|
+
).best;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The first cue later than the limit into the video. */
|
|
394
|
+
const lateStart = (first: number, l: CoverLimits): readonly Finding[] =>
|
|
395
|
+
first > l.maxStartS
|
|
396
|
+
? [finding("lateStart", { first: clock(first), max: l.maxStartS })]
|
|
397
|
+
: [];
|
|
398
|
+
|
|
399
|
+
/** The last cue ending earlier than the limit before the video's end. */
|
|
400
|
+
const earlyEnd = (
|
|
401
|
+
last: number,
|
|
402
|
+
durationS: number,
|
|
403
|
+
l: CoverLimits,
|
|
404
|
+
): readonly Finding[] =>
|
|
405
|
+
last < durationS - l.maxEndGapS
|
|
406
|
+
? [
|
|
407
|
+
finding("earlyEnd", {
|
|
408
|
+
last: clock(last),
|
|
409
|
+
video: clock(durationS),
|
|
410
|
+
max: l.maxEndGapS,
|
|
411
|
+
}),
|
|
412
|
+
]
|
|
413
|
+
: [];
|
|
414
|
+
|
|
415
|
+
/** How the cues cover a video of `durationS`. */
|
|
416
|
+
export function judgeCover(
|
|
417
|
+
cues: readonly Cue[],
|
|
418
|
+
durationS: number,
|
|
419
|
+
limits: CoverLimits,
|
|
420
|
+
): readonly Finding[] {
|
|
421
|
+
if (cues.length === 0) return [finding("noCues", {})];
|
|
422
|
+
const gap = largestGap(cues);
|
|
423
|
+
return [
|
|
424
|
+
...lateStart(Math.min(...cues.map((c) => c.startS)), limits),
|
|
425
|
+
...earlyEnd(Math.max(...cues.map((c) => c.endS)), durationS, limits),
|
|
426
|
+
...(gap !== null && gap.s > limits.maxGapS
|
|
427
|
+
? [
|
|
428
|
+
finding("gap", {
|
|
429
|
+
at: clock(gap.at),
|
|
430
|
+
s: gap.s.toFixed(1),
|
|
431
|
+
max: limits.maxGapS,
|
|
432
|
+
}),
|
|
433
|
+
]
|
|
434
|
+
: []),
|
|
435
|
+
];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const coverLimitsOf = (options: unknown): CoverLimits => ({
|
|
439
|
+
maxStartS: numberOption(options, "maxStartS", DEFAULT_COVER.maxStartS),
|
|
440
|
+
maxEndGapS: numberOption(options, "maxEndGapS", DEFAULT_COVER.maxEndGapS),
|
|
441
|
+
maxGapS: numberOption(options, "maxGapS", DEFAULT_COVER.maxGapS),
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const judgeCaptions: Judge = (d, { deps, options, shown }) => {
|
|
445
|
+
const text = bytesOf(deps, fileOf(d, "captions"));
|
|
446
|
+
const v = videoOf(d, deps);
|
|
447
|
+
if (text === null || v === null) return [];
|
|
448
|
+
const cues = deps.media.captions(new TextDecoder().decode(text));
|
|
449
|
+
const file = shown(fileOf(d, "captions"));
|
|
450
|
+
return judgeCover(cues, v.durationS, coverLimitsOf(options[0])).map((f) =>
|
|
451
|
+
finding(f.messageId, { ...f.data, file }),
|
|
452
|
+
);
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// ── the ESLint rules ─────────────────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
export type TalkRuleName =
|
|
458
|
+
| "profile"
|
|
459
|
+
| "required-files"
|
|
460
|
+
| "undeclared"
|
|
461
|
+
| "duration"
|
|
462
|
+
| "duration-floor"
|
|
463
|
+
| "video-format"
|
|
464
|
+
| "one-slide-size"
|
|
465
|
+
| "captions-cover";
|
|
466
|
+
|
|
467
|
+
/** Which rule reports what, given a paper that declares a talk. `undeclared` is judged apart. */
|
|
468
|
+
const JUDGES: Readonly<Record<Exclude<TalkRuleName, "undeclared">, Judge>> = {
|
|
469
|
+
profile: (d) => judgeProfile(d),
|
|
470
|
+
"required-files": judgeRequired,
|
|
471
|
+
duration: judgeDurationRule,
|
|
472
|
+
"duration-floor": judgeFloor,
|
|
473
|
+
"video-format": judgeFormat,
|
|
474
|
+
"one-slide-size": judgeOneSlide,
|
|
475
|
+
"captions-cover": judgeCaptions,
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
function findingsOf(
|
|
479
|
+
name: TalkRuleName,
|
|
480
|
+
a: Assessment,
|
|
481
|
+
ctx: JudgeContext,
|
|
482
|
+
): readonly Finding[] {
|
|
483
|
+
if (name === "undeclared")
|
|
484
|
+
return a.kind === "undeclared"
|
|
485
|
+
? a.strays.map((f) =>
|
|
486
|
+
finding("undeclared", { file: ctx.shown(f), config: CONFIG_FILE }),
|
|
487
|
+
)
|
|
488
|
+
: [];
|
|
489
|
+
return a.kind === "declared" ? JUDGES[name](a, ctx) : [];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const TALK = "`talk` in the paper's paperlint.json";
|
|
493
|
+
|
|
494
|
+
type Meta = Pick<VenueRuleModule["meta"], "messages"> & {
|
|
495
|
+
readonly type?: "suggestion";
|
|
496
|
+
readonly description: string;
|
|
497
|
+
readonly schema?: readonly object[];
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const META: Readonly<Record<TalkRuleName, Meta>> = {
|
|
501
|
+
profile: {
|
|
502
|
+
description:
|
|
503
|
+
"a paper's `talk` can be judged: its venue preset has a `talk` block, allows the paper's mode, and sets a slot for its kind",
|
|
504
|
+
messages: {
|
|
505
|
+
noPreset: `the paper declares ${TALK} and names no venue preset ("extends"), so nothing says what the talk must be`,
|
|
506
|
+
noTalkBlock:
|
|
507
|
+
"the paper declares a talk, and the preset `{{venue}}` has no `talk` block — so its files are not judged. Add one to your own preset, quoting the organizers",
|
|
508
|
+
mode: "`{{venue}}` allows the talk modes {{modes}}; this paper declares `{{mode}}`",
|
|
509
|
+
kindMissing: `${CONFIG_FILE} names no \`kind\`, so the talk slot of \`{{venue}}\` is not known; its talk kinds: {{known}}`,
|
|
510
|
+
kindNoSlot:
|
|
511
|
+
"`{{venue}}` sets no talk slot for a `{{kind}}` paper, so the video's length is not checked; its talk kinds: {{known}}",
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
"required-files": {
|
|
515
|
+
description:
|
|
516
|
+
"every file the venue requires for the paper's talk mode is in the talk folder",
|
|
517
|
+
messages: {
|
|
518
|
+
missing:
|
|
519
|
+
"{{venue}} asks for the {{artifact}} of a {{mode}} talk, and {{file}} is not there — add it, or name the file in `talk.files`",
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
undeclared: {
|
|
523
|
+
type: "suggestion",
|
|
524
|
+
description:
|
|
525
|
+
"a video in the paper's talk folder while the paper declares no `talk`, so nothing checks it",
|
|
526
|
+
messages: {
|
|
527
|
+
undeclared:
|
|
528
|
+
'{{file}} is a talk video, and this paper declares no `talk`, so its length and format are not checked — declare it in {{config}}: "talk": { "mode": "remote-video" }',
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
duration: {
|
|
532
|
+
description:
|
|
533
|
+
"the talk video is no longer than the slot's talk, and its audio track runs as long as the video",
|
|
534
|
+
schema: [
|
|
535
|
+
{
|
|
536
|
+
type: "object",
|
|
537
|
+
properties: { trackToleranceS: { type: "number", minimum: 0 } },
|
|
538
|
+
additionalProperties: false,
|
|
539
|
+
},
|
|
540
|
+
],
|
|
541
|
+
messages: {
|
|
542
|
+
tooLong:
|
|
543
|
+
"{{file}} runs {{got}}, over the {{max}} talk {{venue}} sets for this kind of paper — cut it",
|
|
544
|
+
trackMismatch:
|
|
545
|
+
"{{file}}: the video runs {{video}} and its audio track {{audio}}, more than {{tol}} s apart — a cut or a re-encode dropped or padded sound",
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
"duration-floor": {
|
|
549
|
+
type: "suggestion",
|
|
550
|
+
description:
|
|
551
|
+
"the talk video is no shorter than the talk the venue asks for",
|
|
552
|
+
messages: {
|
|
553
|
+
tooShort:
|
|
554
|
+
"{{file}} runs {{got}}, under the {{min}} talk {{venue}} asks for a {{kind}} paper — decide whether that is fine, or set this rule to off for the paper",
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
"video-format": {
|
|
558
|
+
description:
|
|
559
|
+
"the talk video is an MP4 file, at least the venue's height, and within its size limit",
|
|
560
|
+
messages: {
|
|
561
|
+
unreadable:
|
|
562
|
+
"{{file}} is not an MP4 or QuickTime file (no `ftyp`/`moov` box), so its length and format cannot be read — export it as MP4 (H.264)",
|
|
563
|
+
container:
|
|
564
|
+
"{{file}} is a QuickTime (.mov) file inside, and {{venue}} asks for MP4 — re-export as MP4",
|
|
565
|
+
noVideoTrack: "{{file}} has no video track",
|
|
566
|
+
height:
|
|
567
|
+
"{{file}} is {{got}} px high, under the {{min}} px {{venue}} asks for — export at a higher resolution",
|
|
568
|
+
bytes:
|
|
569
|
+
"{{file}} is {{got}} bytes, over the {{max}} {{venue}} accepts — re-encode at a lower bitrate",
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
"one-slide-size": {
|
|
573
|
+
description:
|
|
574
|
+
"the one-slide image is a PNG of the size the venue or the paper states",
|
|
575
|
+
messages: {
|
|
576
|
+
notPng: "{{file}} is not a PNG file",
|
|
577
|
+
size: "{{file}} is {{got}} px, and the one-slide must be {{want}} px",
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
"captions-cover": {
|
|
581
|
+
type: "suggestion",
|
|
582
|
+
description:
|
|
583
|
+
"the talk's captions cover the video: they start near its start, end near its end, and leave no long gap",
|
|
584
|
+
schema: [
|
|
585
|
+
{
|
|
586
|
+
type: "object",
|
|
587
|
+
properties: {
|
|
588
|
+
maxStartS: { type: "number", minimum: 0 },
|
|
589
|
+
maxEndGapS: { type: "number", minimum: 0 },
|
|
590
|
+
maxGapS: { type: "number", minimum: 0 },
|
|
591
|
+
},
|
|
592
|
+
additionalProperties: false,
|
|
593
|
+
},
|
|
594
|
+
],
|
|
595
|
+
messages: {
|
|
596
|
+
noCues: "{{file}} holds no caption cues",
|
|
597
|
+
lateStart:
|
|
598
|
+
"{{file}}: the first caption starts at {{first}}, later than {{max}} s into the video",
|
|
599
|
+
earlyEnd:
|
|
600
|
+
"{{file}}: the last caption ends at {{last}}, and the video runs {{video}} — more than {{max}} s uncaptioned at the end",
|
|
601
|
+
gap: "{{file}}: {{s}} s without a caption from {{at}}, longer than {{max}} s",
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
/** The level each talk rule is on at, for every `paper.tex`. Silent for a paper without `talk`. */
|
|
607
|
+
export const TALK_RULE_LEVELS: Readonly<
|
|
608
|
+
Record<`talk/${TalkRuleName}`, "error" | "warn">
|
|
609
|
+
> = {
|
|
610
|
+
"talk/profile": "error",
|
|
611
|
+
"talk/required-files": "error",
|
|
612
|
+
"talk/undeclared": "warn",
|
|
613
|
+
"talk/duration": "error",
|
|
614
|
+
"talk/duration-floor": "warn",
|
|
615
|
+
"talk/video-format": "error",
|
|
616
|
+
"talk/one-slide-size": "error",
|
|
617
|
+
"talk/captions-cover": "warn",
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
/** Every finding of rule `name` on the paper whose `paper.tex` ESLint is linting, reported at line 1. */
|
|
621
|
+
function reportAll(
|
|
622
|
+
name: TalkRuleName,
|
|
623
|
+
context: VenueRuleContext,
|
|
624
|
+
deps: TalkRuleDeps,
|
|
625
|
+
): void {
|
|
626
|
+
const cwd = context.cwd;
|
|
627
|
+
const shown = (p: string) => (cwd ? relative(cwd, p) : p);
|
|
628
|
+
const a = assess(dirname(context.filename), deps);
|
|
629
|
+
const loc = { start: { line: 1, column: 1 }, end: { line: 1, column: 2 } };
|
|
630
|
+
findingsOf(name, a, { deps, options: context.options, shown }).forEach(
|
|
631
|
+
(f) => {
|
|
632
|
+
context.report({ loc, messageId: f.messageId, data: f.data });
|
|
633
|
+
},
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function rule(name: TalkRuleName, deps: TalkRuleDeps): VenueRuleModule {
|
|
638
|
+
const meta = META[name];
|
|
639
|
+
return {
|
|
640
|
+
meta: {
|
|
641
|
+
type: meta.type ?? "problem",
|
|
642
|
+
docs: { description: meta.description, url: rulePageUrl(`talk/${name}`) },
|
|
643
|
+
schema: meta.schema ?? [],
|
|
644
|
+
messages: meta.messages,
|
|
645
|
+
},
|
|
646
|
+
create(context) {
|
|
647
|
+
// Judged once per paper, on its paper.tex: the paper directory is the file's directory.
|
|
648
|
+
if (basename(context.filename) !== "paper.tex") return {};
|
|
649
|
+
return {
|
|
650
|
+
root() {
|
|
651
|
+
reportAll(name, context, deps);
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** The talk rules, as the rules of the `talk` plugin. */
|
|
659
|
+
export function talkRules(
|
|
660
|
+
deps: TalkRuleDeps,
|
|
661
|
+
): Readonly<Record<TalkRuleName, VenueRuleModule>> {
|
|
662
|
+
return {
|
|
663
|
+
profile: rule("profile", deps),
|
|
664
|
+
"required-files": rule("required-files", deps),
|
|
665
|
+
undeclared: rule("undeclared", deps),
|
|
666
|
+
duration: rule("duration", deps),
|
|
667
|
+
"duration-floor": rule("duration-floor", deps),
|
|
668
|
+
"video-format": rule("video-format", deps),
|
|
669
|
+
"one-slide-size": rule("one-slide-size", deps),
|
|
670
|
+
"captions-cover": rule("captions-cover", deps),
|
|
671
|
+
};
|
|
672
|
+
}
|