Skip to content

Commit de2fc91

Browse files
committed
fix(website): make the loop tooling refuse what would corrupt a cut
From the review of #980: - publish-loops refuses an incomplete cut (a loop the site names, or any loop in --dir, without all five files) before uploading or writing anything. - publish-loops refuses to rewrite a published cut: it lists the folder in R2 and compares each object's ETag, the MD5 of a single-part upload, with the local file. Different bytes, or objects --dir lacks, stop the run with nothing sent; equal files are skipped, so re-running an interrupted upload is safe. Tested on the live cut: 90 files recognised, none re-sent. - encode-loops writes each output under a temporary name and renames it once its encode succeeded, and skips a master only when all five outputs exist, rather than trusting a poster that may be all an interrupted run left. - check-loops gives every CDN request a 15 s deadline, so a stalled connection fails its URL instead of holding the workflow.
1 parent 79bed86 commit de2fc91

3 files changed

Lines changed: 76 additions & 6 deletions

File tree

‎website/scripts/check-loops.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ if (live && problems.length === 0) {
5757
let res;
5858
for (let attempt = 0; attempt < 2 && !res?.ok; attempt++) {
5959
try {
60-
res = await fetch(url, { method: "HEAD" });
60+
// A stalled connection must fail this URL, not hold the workflow.
61+
res = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(15_000) });
6162
} catch (err) {
6263
if (attempt === 1) problems.push(`${url}: ${err.message}`);
6364
}

‎website/scripts/media/encode-loops.sh‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,27 @@
1212
# around 94-98, 23% lighter than x264 at equal quality. Needs an ffmpeg built
1313
# with libx264, libx265 and libwebp (FFMPEG=..., e.g. `npm i ffmpeg-static`);
1414
# the LGPL build under crates/thirdparty has neither encoder.
15-
# A master whose poster already exists in <out dir> is skipped.
15+
# Each output is written to a temporary name and renamed only once its encode
16+
# succeeded, so an interrupted run leaves no plausible-looking partial file; a
17+
# master is skipped only when all five of its outputs are there.
1618
set -e
1719
F=${FFMPEG:-ffmpeg}
1820
S=${1:?masters dir}; O=${2:?out dir}; mkdir -p $O
1921
for src in $S/*.mp4; do
2022
slug=${src:t:r}
21-
[[ -f $O/$slug-poster.webp ]] && continue
23+
outs=($O/$slug-1080-hevc.mp4 $O/$slug-1080-h264.mp4 $O/$slug-720-hevc.mp4 $O/$slug-720-h264.mp4 $O/$slug-poster.webp)
24+
all=1; for f in $outs; do [[ -f $f ]] || all=0; done
25+
(( all )) && continue
2226
for h in 1080 720; do
2327
vf="scale=-2:${h}:flags=lanczos"
2428
$F -v error -y -i $src -an -vf $vf -c:v libx265 -crf 25 -preset slow -tag:v hvc1 \
25-
-x265-params log-level=error:keyint=120:min-keyint=120 -pix_fmt yuv420p -movflags +faststart $O/$slug-$h-hevc.mp4
29+
-x265-params log-level=error:keyint=120:min-keyint=120 -pix_fmt yuv420p -movflags +faststart -f mp4 $O/.part-$slug-$h-hevc.mp4
30+
mv $O/.part-$slug-$h-hevc.mp4 $O/$slug-$h-hevc.mp4
2631
$F -v error -y -i $src -an -vf $vf -c:v libx264 -crf 23 -preset veryslow -g 120 -profile:v high -level 4.2 \
27-
-pix_fmt yuv420p -movflags +faststart $O/$slug-$h-h264.mp4
32+
-pix_fmt yuv420p -movflags +faststart -f mp4 $O/.part-$slug-$h-h264.mp4
33+
mv $O/.part-$slug-$h-h264.mp4 $O/$slug-$h-h264.mp4
2834
done
29-
$F -v error -y -i $src -frames:v 1 -vf "scale=1280:-2:flags=lanczos" -c:v libwebp -quality 80 $O/$slug-poster.webp
35+
$F -v error -y -i $src -frames:v 1 -vf "scale=1280:-2:flags=lanczos" -c:v libwebp -quality 80 -f webp $O/.part-$slug-poster.webp
36+
mv $O/.part-$slug-poster.webp $O/$slug-poster.webp
3037
echo "$slug done"
3138
done

‎website/scripts/media/publish-loops.mjs‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@
2222
* a new --cut. Upload BEFORE anything points at the folder: the zone caches a
2323
* 404 for an hour.
2424
*
25+
* Two refusals, both before anything is uploaded or written:
26+
* - an incomplete cut: every loop the site names (LOOP_NAMES), and every
27+
* loop in <dir>, must have all five files;
28+
* - a rewrite: if the folder already holds objects, each must be byte-equal
29+
* to the local file of the same name (R2's ETag is the MD5 of a single-part
30+
* upload) and the folder must hold nothing <dir> lacks. Equal files are not
31+
* re-sent, so re-running after an interrupted upload is safe.
32+
*
2533
* Needs the `cf` CLI logged in to the account that owns getopenscreen.com, and
2634
* ffprobe (FFPROBE, or on PATH) for the durations.
2735
*/
@@ -32,6 +40,8 @@ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "
3240
import { join, resolve } from "node:path";
3341
import { parseArgs } from "node:util";
3442

43+
import { LOOP_NAMES } from "../../src/lib/demo-loop.ts";
44+
3545
const ROOT = resolve(import.meta.dirname, "../..");
3646
const BUCKET = "openscreen-media";
3747
const ORIGIN = "https://media.getopenscreen.com";
@@ -79,8 +89,60 @@ for (const name of names) {
7989
}
8090
}
8191

92+
// ── refuse an incomplete cut ──────────────────────────────────────────────
93+
const VARIANTS = ["1080-hevc.mp4", "1080-h264.mp4", "720-hevc.mp4", "720-h264.mp4", "poster.webp"];
94+
const slugs = new Set([...LOOP_NAMES, ...names.map((n) => FILE.exec(n)[1])]);
95+
const missing = [...slugs].flatMap((slug) =>
96+
VARIANTS.map((v) => `${slug}-${v}`).filter((f) => !files[f]),
97+
);
98+
if (missing.length) {
99+
console.error(`incomplete cut, nothing uploaded or written:\n ${missing.join("\n ")}`);
100+
process.exit(1);
101+
}
102+
82103
if (!opts["no-upload"]) {
104+
// ── refuse to rewrite a published cut ───────────────────────────────────
105+
const listed = JSON.parse(
106+
execFileSync(
107+
"cf",
108+
[
109+
"r2",
110+
"objects",
111+
"list",
112+
"--bucket-name",
113+
BUCKET,
114+
"--prefix",
115+
`loops/${opts.cut}/`,
116+
"--per-page",
117+
"1000",
118+
],
119+
{ encoding: "utf8", maxBuffer: 1 << 26 },
120+
),
121+
);
122+
if (listed.length >= 1000)
123+
throw new Error("more than 1000 objects under one cut: paginate before trusting this check");
124+
const remote = new Map(listed.map((o) => [o.key.slice(`loops/${opts.cut}/`.length), o]));
125+
const md5 = (name) =>
126+
createHash("md5")
127+
.update(readFileSync(join(dir, name)))
128+
.digest("hex");
129+
const changed = names.filter(
130+
(n) => remote.has(n) && remote.get(n).etag.replaceAll('"', "") !== md5(n),
131+
);
132+
const extra = [...remote.keys()].filter((n) => !files[n]);
133+
if (changed.length || extra.length) {
134+
console.error(
135+
`loops/${opts.cut}/ is already published and differs; a published cut is never rewritten. Use a new --cut.` +
136+
(changed.length ? `\n different bytes: ${changed.join(", ")}` : "") +
137+
(extra.length ? `\n online but not in --dir: ${extra.join(", ")}` : ""),
138+
);
139+
process.exit(1);
140+
}
83141
for (const name of names) {
142+
if (remote.has(name)) {
143+
console.log("already there", name);
144+
continue;
145+
}
84146
execFileSync(
85147
"cf",
86148
[

0 commit comments

Comments
 (0)