retake-demos 0.13.2 → 0.14.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/cards.js CHANGED
@@ -9,13 +9,69 @@
9
9
  *
10
10
  * All of it is render-time: change a title, `retake render`, seconds.
11
11
  */
12
+ import { launchChromium } from "./browser-home.js";
12
13
  import fs from "node:fs";
13
14
  import os from "node:os";
14
15
  import path from "node:path";
15
- import { chromium } from "playwright";
16
16
  import { execFileSync } from "node:child_process";
17
17
  const AMBER = "#e8b04b";
18
18
  const ease = "t<0?0:t>1?1:t*t*(3-2*t)"; // smoothstep, in the page
19
+ /** A file as a data: URI, so the card page needs nothing from disk or network. */
20
+ function dataUri(file) {
21
+ const ext = path.extname(file).toLowerCase();
22
+ const mime = ext === ".svg" ? "image/svg+xml" : ext === ".png" ? "image/png" : ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" : ext === ".woff2" ? "font/woff2" : ext === ".woff" ? "font/woff" : ext === ".ttf" ? "font/ttf" : ext === ".otf" ? "font/otf" : "application/octet-stream";
23
+ return `data:${mime};base64,${fs.readFileSync(path.resolve(file)).toString("base64")}`;
24
+ }
25
+ /**
26
+ * A card that looks like the product rather than like Retake: the mark above
27
+ * the title, the brand's own typeface, its ground and ink, and — if asked —
28
+ * two soft circles in opposite corners. Built to reproduce a north-star card,
29
+ * which before this could only be made outside Retake and recoloured in.
30
+ *
31
+ * Same contract as the plain card: `__seek(ms)` lays out that exact moment,
32
+ * `__still` names the window where nothing moves (the poster comes from it).
33
+ */
34
+ function brandedCardHtml(w, h, c, title, subtitle, ms) {
35
+ // Measured off a north-star card at 1920×1080: a 143px mark, the title
36
+ // 66px below it, two lines of ~92px type spanning 192px, wrapping at ~1300px.
37
+ const titleSize = Math.round(Math.min(w * 0.048, h * 0.085));
38
+ const subSize = Math.round(titleSize * 0.4);
39
+ const mark = Math.round(h * 0.133);
40
+ const font = c.font ? `@font-face{font-family:"BrandCard";src:url(${dataUri(c.font)});font-weight:100 900;font-display:block}` : "";
41
+ const family = c.font ? `"BrandCard",Georgia,serif` : `-apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif`;
42
+ const logo = c.logo ? `<img class="mark" src="${dataUri(c.logo)}" alt="">` : "";
43
+ const circles = c.ornament === "circles"
44
+ ? `<i class="orb a"></i><i class="orb b"></i>` : "";
45
+ return `<!doctype html><meta charset="utf-8"><style>
46
+ ${font}
47
+ html,body{margin:0;width:${w}px;height:${h}px;overflow:hidden;background:${c.background}}
48
+ .orb{position:absolute;border-radius:50%;background:${c.orb ?? `color-mix(in srgb, ${c.ink} 6%, ${c.background})`}}
49
+ .orb.a{width:${Math.round(h * 0.6)}px;height:${Math.round(h * 0.6)}px;left:${Math.round(-h * 0.25)}px;top:${Math.round(-h * 0.3)}px}
50
+ .orb.b{width:${Math.round(h * 0.7)}px;height:${Math.round(h * 0.7)}px;right:${Math.round(-h * 0.3)}px;bottom:${Math.round(-h * 0.33)}px}
51
+ .wrap{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:${Math.round(h * 0.06)}px}
52
+ .mark{width:${mark}px;height:${mark}px;border-radius:${Math.round(mark * 0.22)}px;box-shadow:0 ${Math.round(mark * 0.12)}px ${Math.round(mark * 0.3)}px rgba(0,0,0,.10);opacity:0;object-fit:cover}
53
+ .text{display:flex;flex-direction:column;align-items:center;gap:${Math.round(subSize * 0.8)}px}
54
+ .title{font-family:${family};font-size:${titleSize}px;font-weight:${c.weight};line-height:1.1;letter-spacing:-.022em;color:${c.ink};text-align:center;max-width:${Math.round(w * 0.68)}px;opacity:0;font-variation-settings:"opsz" 144}
55
+ .sub{font-family:${family};font-size:${subSize}px;color:${c.ink};opacity:0;text-align:center;max-width:${Math.round(w * 0.6)}px}
56
+ </style>
57
+ ${circles}
58
+ <div class="wrap">${logo}<div class="text"><div class="title"></div><div class="sub"></div></div></div>
59
+ <script>
60
+ const M = document.querySelector(".mark"), T = document.querySelector(".title"), S = document.querySelector(".sub");
61
+ T.textContent = ${JSON.stringify(title)}; S.textContent = ${JSON.stringify(subtitle ?? "")};
62
+ if (!S.textContent) S.remove();
63
+ const DUR = ${ms}, FADE = Math.min(460, DUR * 0.24), OUT = Math.min(320, DUR * 0.18);
64
+ const ez = (t) => (${ease});
65
+ window.__still = [FADE * 1.6, DUR - OUT];
66
+ window.__seek = (ms) => {
67
+ const a = ez(ms / FADE), b = ez((ms - FADE * 0.35) / FADE), o = ez((DUR - ms) / OUT);
68
+ if (M) { M.style.opacity = Math.min(a, o); M.style.transform = "translateY(" + (1 - a) * 10 + "px)"; }
69
+ T.style.opacity = Math.min(b, o); T.style.transform = "translateY(" + (1 - b) * 12 + "px)";
70
+ if (S.isConnected) { S.style.opacity = Math.min(b, o); }
71
+ return true;
72
+ };
73
+ </script>`;
74
+ }
19
75
  function cardHtml(w, h, theme, title, subtitle, ms) {
20
76
  const titleSize = Math.round(Math.min(w * 0.055, h * 0.14));
21
77
  const subSize = Math.round(titleSize * 0.4);
@@ -88,7 +144,7 @@ function calloutHtml(w, h, box, label, ms, fontSize, ink) {
88
144
  * `__still` gets every frame shot — the slow path is the safe default. */
89
145
  async function renderFrames(html, w, h, ms, fps, transparent) {
90
146
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "retake-frames-"));
91
- const browser = await chromium.launch();
147
+ const browser = await launchChromium();
92
148
  try {
93
149
  const page = await browser.newPage({ viewport: { width: w, height: h } });
94
150
  await page.setContent(html, { waitUntil: "load" });
@@ -120,8 +176,9 @@ async function renderFrames(html, w, h, ms, fps, transparent) {
120
176
  }
121
177
  /** A title card as an H.264 segment at the final canvas size, plus (for the
122
178
  intro) the settled middle frame as cover.png — the poster. */
123
- export async function renderCard(ffmpeg, outDir, kind, spec, w, h, fps, theme) {
124
- const dir = await renderFrames(cardHtml(w, h, theme, spec.title, spec.subtitle, spec.ms), w, h, spec.ms, fps, false);
179
+ export async function renderCard(ffmpeg, outDir, kind, spec, w, h, fps, theme, style) {
180
+ const html = style ? brandedCardHtml(w, h, style, spec.title, spec.subtitle, spec.ms) : cardHtml(w, h, theme, spec.title, spec.subtitle, spec.ms);
181
+ const dir = await renderFrames(html, w, h, spec.ms, fps, false);
125
182
  const mp4 = path.join(outDir, `.card-${kind}.mp4`);
126
183
  execFileSync(ffmpeg, ["-y", "-loglevel", "error", "-framerate", String(fps), "-i", path.join(dir, "f%05d.png"), "-c:v", "libx264", "-crf", "17", "-preset", "veryfast", "-pix_fmt", "yuv420p", mp4], { stdio: "inherit" });
127
184
  let cover;
@@ -160,7 +217,48 @@ export async function renderTitledCover(ffmpeg, frame, out, spec, w, h, theme) {
160
217
  </style>
161
218
  <div class="shot"></div><div class="veil"></div>
162
219
  <div class="txt"><p class="t">${escapeHtml(spec.title)}</p><div class="r"></div>${spec.subtitle ? `<p class="s">${escapeHtml(spec.subtitle)}</p>` : ""}</div>`;
163
- const browser = await chromium.launch();
220
+ const browser = await launchChromium();
221
+ try {
222
+ const page = await browser.newPage({ viewport: { width: w, height: h } });
223
+ await page.setContent(html, { waitUntil: "load" });
224
+ await page.screenshot({ path: out });
225
+ return out;
226
+ }
227
+ finally {
228
+ await browser.close();
229
+ }
230
+ }
231
+ /**
232
+ * A poster that stops a scroll: the strongest frame of the video, the
233
+ * brand's colour sweeping in from the left behind big type, the product's
234
+ * mark on a light badge so it reads on any frame, and who made it. The bare
235
+ * frame (the old thumbnail) is honest but anonymous; this one says whose it
236
+ * is and what it is before anyone presses play.
237
+ */
238
+ export async function renderPoster(frame, out, spec, w, h, brand) {
239
+ const b64 = fs.readFileSync(frame).toString("base64");
240
+ const rgb = (hex) => { const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); const n = m ? parseInt(m[1], 16) : 0; return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`; };
241
+ const m = rgb(brand.matte);
242
+ const face = brand.font ? `@font-face{font-family:poster;src:url(${dataUri(brand.font)})}` : "";
243
+ const family = `${brand.font ? "poster," : ""}-apple-system,BlinkMacSystemFont,"SF Pro Display","Helvetica Neue",Arial,sans-serif`;
244
+ const titleSize = Math.round(h * (spec.title.length > 16 ? 0.088 : 0.108));
245
+ const html = `<!doctype html><meta charset="utf-8"><style>${face}
246
+ html,body{margin:0;width:${w}px;height:${h}px;overflow:hidden;background:${brand.matte};font-family:${family}}
247
+ .shot{position:absolute;inset:0;background:url(data:image/png;base64,${b64}) center/cover no-repeat;transform:scale(1.04);transform-origin:70% 50%}
248
+ .veil{position:absolute;inset:0;background:linear-gradient(90deg,rgba(${m},.97) 0%,rgba(${m},.93) 30%,rgba(${m},.6) 50%,rgba(${m},0) 70%)}
249
+ .foot{position:absolute;left:0;right:0;bottom:0;height:${Math.round(h * 0.22)}px;background:linear-gradient(0deg,rgba(${m},.55),rgba(${m},0))}
250
+ .badge{position:absolute;left:${Math.round(w * 0.055)}px;top:${Math.round(h * 0.075)}px;background:${brand.ground};border-radius:${Math.round(h * 0.022)}px;padding:${Math.round(h * 0.018)}px ${Math.round(h * 0.028)}px;box-shadow:0 ${Math.round(h * 0.01)}px ${Math.round(h * 0.035)}px rgba(0,0,0,.28)}
251
+ .badge img{display:block;height:${Math.round(h * 0.052)}px;width:auto}
252
+ .txt{position:absolute;left:${Math.round(w * 0.055)}px;bottom:${Math.round(h * 0.12)}px;width:${Math.round(w * 0.46)}px;color:${brand.ground}}
253
+ .k{display:inline-block;font-size:${Math.round(h * 0.024)}px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;padding:${Math.round(h * 0.009)}px ${Math.round(h * 0.016)}px;border:${Math.max(2, Math.round(h * 0.0022))}px solid rgba(255,255,255,.55);border-radius:999px;margin-bottom:${Math.round(h * 0.03)}px}
254
+ .t{font-size:${titleSize}px;line-height:.98;font-weight:800;letter-spacing:-.035em;margin:0;text-shadow:0 ${Math.round(h * 0.004)}px ${Math.round(h * 0.02)}px rgba(0,0,0,.25)}
255
+ .bar{width:${Math.round(h * 0.085)}px;height:${Math.max(4, Math.round(h * 0.008))}px;border-radius:99px;background:${brand.ground};opacity:.9;margin:${Math.round(h * 0.034)}px 0 ${Math.round(h * 0.026)}px}
256
+ .s{font-size:${Math.round(h * 0.04)}px;font-weight:600;margin:0;opacity:.95}
257
+ </style>
258
+ <div class="shot"></div><div class="veil"></div><div class="foot"></div>
259
+ ${brand.logo ? `<div class="badge"><img src="${dataUri(brand.logo)}" alt=""></div>` : ""}
260
+ <div class="txt">${spec.kicker ? `<div class="k">${escapeHtml(spec.kicker)}</div>` : ""}<p class="t">${escapeHtml(spec.title)}</p>${spec.subtitle ? `<div class="bar"></div><p class="s">${escapeHtml(spec.subtitle)}</p>` : ""}</div>`;
261
+ const browser = await launchChromium();
164
262
  try {
165
263
  const page = await browser.newPage({ viewport: { width: w, height: h } });
166
264
  await page.setContent(html, { waitUntil: "load" });