History

Show URLs in e2e PR media

Rhys Sullivan committed 4 months ago

Commit 95b9e2a1 parent 59a17e11 file · +210 −8Browse files

Unified · Split

e2e/scripts/pr-media.ts+210−8
@@ -16,13 +16,17 @@ · 15 unmodified lines
16// *.png run screenshot, uploaded as-is16// *.png run screenshot, uploaded as-is
17import { execFileSync } from "node:child_process";17import { execFileSync } from "node:child_process";
18import { createHash } from "node:crypto";18import { createHash } from "node:crypto";
19import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";19import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
20import { tmpdir } from "node:os";20import { tmpdir } from "node:os";
21import { basename, join, resolve } from "node:path";21import { basename, join, resolve } from "node:path";
22 22
23import { chromium } from "playwright";
24
23const MEDIA_BRANCH = "e2e-media";25const MEDIA_BRANCH = "e2e-media";
24// Above this GitHub's image proxy stops rendering the gif inline.26// Above this GitHub's image proxy stops rendering the gif inline.
25const RENDER_LIMIT_BYTES = 10 * 1024 * 1024;27const RENDER_LIMIT_BYTES = 10 * 1024 * 1024;
28const GIF_WIDTH = 960;
29const CHROME_HEIGHT = 40;
26 30
27const run = (command: string, args: ReadonlyArray<string>): string =>31const run = (command: string, args: ReadonlyArray<string>): string =>
28 execFileSync(command, [...args], {32 execFileSync(command, [...args], {
@@ -36,6 +40,18 @@ interface Artifact { · 7 unmodified lines
36 readonly path: string;40 readonly path: string;
37 readonly label: string;41 readonly label: string;
38 readonly slug: string;42 readonly slug: string;
43 readonly runDir?: string;
44}
45
46interface Timeline {
47 readonly anchors: { readonly browser?: number };
48 readonly nav?: ReadonlyArray<{ readonly at: number; readonly url: string }>;
49}
50
51interface UrlSegment {
52 readonly from: number;
53 readonly to: number;
54 readonly url: string;
39}55}
40 56
41/** A run dir resolves to its recording; a file stands for itself. */57/** A run dir resolves to its recording; a file stands for itself. */
@@ -66,20 +82,206 @@ const resolveArtifact = (input: string): Artifact => { · 24 unmodified lines
66 } catch {82 } catch {
67 // skipped/partial runs still have a recording worth showing83 // skipped/partial runs still have a recording worth showing
68 }84 }
69 return { path: recording, label, slug: basename(path) };85 return { path: recording, label, slug: basename(path), runDir: path };
86};
87
88const videoDuration = (file: string): number =>
89 Number(
90 run("ffprobe", [
91 ...["-v", "quiet", "-show_entries", "format=duration"],
92 ...["-of", "csv=p=0", file],
93 ]).trim(),
94 );
95
96const readTimeline = (artifact: Artifact): Timeline | null => {
97 if (!artifact.runDir) return null;
98 const timelinePath = join(artifact.runDir, "timeline.json");
99 if (!existsSync(timelinePath)) return null;
100 return JSON.parse(readFileSync(timelinePath, "utf8")) as Timeline;
101};
102
103const urlSegments = (artifact: Artifact, duration: number): ReadonlyArray<UrlSegment> => {
104 if (basename(artifact.path) !== "session.mp4") return [];
105 const timeline = readTimeline(artifact);
106 const browserAnchor = timeline?.anchors.browser;
107 if (!timeline || browserAnchor === undefined) return [];
108
109 const nav = [...(timeline.nav ?? [])]
110 .map((entry) => ({
111 at: Math.max(0, (entry.at - browserAnchor) / 1000),
112 url: entry.url,
113 }))
114 .filter((entry) => entry.at <= duration)
115 .sort((a, b) => a.at - b.at);
116
117 if (nav.length === 0) return [{ from: 0, to: duration, url: "about:blank" }];
118 if ((nav[0]?.at ?? 0) <= 0.25) nav[0] = { ...nav[0], at: 0 };
119
120 const segments: UrlSegment[] = [];
121 let cursor = 0;
122 let currentUrl = "about:blank";
123 for (const entry of nav) {
124 if (entry.at > cursor) segments.push({ from: cursor, to: entry.at, url: currentUrl });
125 currentUrl = entry.url;
126 cursor = entry.at;
127 }
128 segments.push({ from: cursor, to: duration, url: currentUrl });
129
130 return segments
131 .filter((segment) => segment.to - segment.from > 0.01)
132 .reduce<UrlSegment[]>((merged, segment) => {
133 const previous = merged.at(-1);
134 if (previous?.url === segment.url && Math.abs(previous.to - segment.from) < 0.01) {
135 merged[merged.length - 1] = { ...previous, to: segment.to };
136 } else {
137 merged.push(segment);
138 }
139 return merged;
140 }, []);
141};
142
143const htmlEscape = (value: string): string =>
144 value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
145
146const renderUrlBars = async (
147 segments: ReadonlyArray<UrlSegment>,
148 workDir: string,
149): Promise<ReadonlyArray<{ readonly path: string; readonly segment: UrlSegment }>> => {
150 const browser = await chromium.launch();
151 try {
152 const page = await browser.newPage({
153 viewport: { width: GIF_WIDTH, height: CHROME_HEIGHT },
154 deviceScaleFactor: 1,
155 });
156 const bars: Array<{ path: string; segment: UrlSegment }> = [];
157 for (const [index, segment] of segments.entries()) {
158 const displayUrl = segment.url.replace(/^https?:\/\//, "") || "about:blank";
159 await page.setContent(`<!doctype html>
160<html>
161 <head>
162 <style>
163 * { box-sizing: border-box; }
164 body {
165 margin: 0;
166 width: ${GIF_WIDTH}px;
167 height: ${CHROME_HEIGHT}px;
168 background: #111318;
169 color: #d7dde8;
170 font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
171 }
172 .chrome {
173 height: ${CHROME_HEIGHT}px;
174 display: flex;
175 align-items: center;
176 gap: 10px;
177 padding: 7px 12px;
178 border-bottom: 1px solid #272c36;
179 }
180 .lights {
181 display: flex;
182 gap: 6px;
183 flex: 0 0 auto;
184 }
185 .lights i {
186 width: 10px;
187 height: 10px;
188 border-radius: 50%;
189 display: block;
190 }
191 .lights i:nth-child(1) { background: #ff5f56; }
192 .lights i:nth-child(2) { background: #ffbd2e; }
193 .lights i:nth-child(3) { background: #27c93f; }
194 .urlbar {
195 min-width: 0;
196 flex: 1 1 auto;
197 height: 26px;
198 display: flex;
199 align-items: center;
200 border: 1px solid #313846;
201 border-radius: 6px;
202 background: #0b0d12;
203 padding: 0 10px;
204 color: #d7dde8;
205 font-size: 12px;
206 line-height: 1;
207 white-space: nowrap;
208 overflow: hidden;
209 text-overflow: ellipsis;
210 }
211 .lock {
212 color: #8b95a7;
213 margin-right: 7px;
214 flex: 0 0 auto;
215 }
216 </style>
217 </head>
218 <body>
219 <div class="chrome">
220 <span class="lights"><i></i><i></i><i></i></span>
221 <div class="urlbar"><span class="lock">url</span>${htmlEscape(displayUrl)}</div>
222 </div>
223 </body>
224</html>`);
225 const path = join(workDir, `urlbar-${index}.png`);
226 await page.screenshot({ path, animations: "disabled" });
227 bars.push({ path, segment });
228 }
229 return bars;
230 } finally {
231 await browser.close();
232 }
70};233};
71 234
72const toGif = (artifact: Artifact): string => {235const toGif = async (artifact: Artifact): Promise<string> => {
73 if (artifact.path.endsWith(".png")) return artifact.path;236 if (artifact.path.endsWith(".png")) return artifact.path;
74 const out = join(tmpdir(), `${artifact.slug}.gif`);237 const out = join(tmpdir(), `${artifact.slug}.gif`);
75 if (artifact.path.endsWith(".cast")) {238 if (artifact.path.endsWith(".cast")) {
76 run("agg", ["--idle-time-limit", "2", "--font-size", "14", artifact.path, out]);239 run("agg", ["--idle-time-limit", "2", "--font-size", "14", artifact.path, out]);
77 } else {240 } else {
78 // Two-pass palette keeps UI text crisp at a size GitHub will render.241 // Two-pass palette keeps UI text crisp at a size GitHub will render.
79 const filter =242 const duration = videoDuration(artifact.path);
80 "fps=8,scale=960:-2:flags=lanczos,split[a][b];" +243 const segments = urlSegments(artifact, duration);
81 "[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=5";244 if (segments.length > 0) {
82 run("ffmpeg", ["-y", "-loglevel", "error", "-i", artifact.path, "-vf", filter, out]);245 const workDir = mkdtempSync(join(tmpdir(), "pr-media-urlbar-"));
246 try {
247 const bars = await renderUrlBars(segments, workDir);
248 const scaled =
249 `[0:v]fps=8,scale=${GIF_WIDTH}:-2:flags=lanczos,` +
250 `pad=iw:ih+${CHROME_HEIGHT}:0:${CHROME_HEIGHT}:color=0x111318,setsar=1[v0]`;
251 const overlays = bars.map(({ segment }, index) => {
252 const input = index + 1;
253 const previous = `v${index}`;
254 const next = `v${index + 1}`;
255 const from = segment.from.toFixed(3);
256 const to = segment.to.toFixed(3);
257 return `[${previous}][${input}:v]overlay=0:0:enable='between(t,${from},${to})'[${next}]`;
258 });
259 const finalVideo = `v${bars.length}`;
260 const palette =
261 `[${finalVideo}]split[a][b];` +
262 "[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=5[out]";
263 run("ffmpeg", [
264 "-y",
265 "-loglevel",
266 "error",
267 "-i",
268 artifact.path,
269 ...bars.flatMap((bar) => ["-i", bar.path]),
270 "-filter_complex",
271 [scaled, ...overlays, palette].join(";"),
272 "-map",
273 "[out]",
274 out,
275 ]);
276 } finally {
277 rmSync(workDir, { recursive: true, force: true });
278 }
279 } else {
280 const filter =
281 `fps=8,scale=${GIF_WIDTH}:-2:flags=lanczos,split[a][b];` +
282 "[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=5";
283 run("ffmpeg", ["-y", "-loglevel", "error", "-i", artifact.path, "-vf", filter, out]);
284 }
83 }285 }
84 return out;286 return out;
85};287};
@@ -152,7 +354,7 @@ const repo = run("gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameW · 66 unmodified lines
152const blocks: Array<string> = [];354const blocks: Array<string> = [];
153for (const input of inputs) {355for (const input of inputs) {
154 const artifact = resolveArtifact(input);356 const artifact = resolveArtifact(input);
155 const gif = toGif(artifact);357 const gif = await toGif(artifact);
156 const size = statSync(gif).size;358 const size = statSync(gif).size;
157 if (size > RENDER_LIMIT_BYTES) {359 if (size > RENDER_LIMIT_BYTES) {
158 console.error(360 console.error(