@sprid/cli 1.0.0 → 1.0.4

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.
@@ -23,6 +23,7 @@ import {
23
23
  kit, readJson, resolveDevice, runScript, simkit, sleep, startRecording, writeJson,
24
24
  } from "../screen/sim.mjs";
25
25
  import { settle as settleFor } from "../screen/sim.mjs";
26
+ import { captionSafeRect } from "../rules/video-safe-zones.mjs";
26
27
  import { layout } from "../screen/captions.mjs";
27
28
  import { hasBaguette, openInput } from "../screen/input.mjs";
28
29
  import {
@@ -220,7 +221,7 @@ export async function buildScreen({ spec, script, root, out, tmp, recut = false,
220
221
  : FONT_CANDIDATES.find(existsSync);
221
222
  const rendered = renderCaptions(cards, {
222
223
  simkitBin: bin, dir: join(tmp, "captions"), fontFile,
223
- size: cfg.capSize, width: Math.round(W * 0.88), pill: cfg.pill ?? 0,
224
+ size: cfg.capSize, width: Math.floor(captionSafeRect(W, H, W / 45 + Math.abs(cfg.drift) * 1.6).width), pill: cfg.pill ?? 0,
224
225
  ...(cfg.stroke != null ? { stroke: cfg.stroke } : {}),
225
226
  });
226
227
 
@@ -0,0 +1,6 @@
1
+ export interface SafeZoneBand { readonly key: string; readonly left: number; readonly top: number; readonly width: number; readonly height: number; }
2
+ export interface VideoRect { x: number; y: number; width: number; height: number; }
3
+ export const SAFE_ZONE_BANDS: readonly SafeZoneBand[];
4
+ export function captionSafeRect(width: number, height: number, padding?: number): VideoRect;
5
+ export function overlapsSafeZones(rect: VideoRect, width: number, height: number): boolean;
6
+ export function captionPlacement(card: {w: number; h: number}, width: number, height: number, bottom: number, motion?: number): {x: number; y: number};
@@ -0,0 +1,27 @@
1
+ /** Approximate TikTok/Reels interface bands. Guides, never a crop or guarantee. */
2
+ export const SAFE_ZONE_BANDS = Object.freeze([
3
+ Object.freeze({ key: "caption", left: 0, top: 0.8, width: 1, height: 0.2 }),
4
+ Object.freeze({ key: "rail", left: 0.86, top: 0.38, width: 0.14, height: 0.42 }),
5
+ ]);
6
+
7
+ /** Conservative subtitle rectangle left of the rail and above the bottom band. */
8
+ export function captionSafeRect(width, height, padding = width / 45) {
9
+ if (![width, height, padding].every(Number.isFinite) || width <= 0 || height <= 0 || padding < 0) throw new Error("Invalid video dimensions");
10
+ const bottom = SAFE_ZONE_BANDS.find(b => b.key === "caption");
11
+ const rail = SAFE_ZONE_BANDS.find(b => b.key === "rail");
12
+ const rect = { x: padding, y: rail.top * height + padding, width: rail.left * width - 2 * padding, height: (bottom.top - rail.top) * height - 2 * padding };
13
+ if (rect.width <= 0 || rect.height <= 0) throw new Error("Safe zone has no room for captions");
14
+ return rect;
15
+ }
16
+
17
+ /** Rectangle includes every visible pixel, including text stroke or a caption box. */
18
+ export function overlapsSafeZones(rect, width, height) {
19
+ return SAFE_ZONE_BANDS.some(b => rect.x < (b.left + b.width) * width && rect.x + rect.width > b.left * width && rect.y < (b.top + b.height) * height && rect.y + rect.height > b.top * height);
20
+ }
21
+
22
+ /** Place a rendered caption card safely, reserving room for its rise animation. */
23
+ export function captionPlacement(card, width, height, bottom, motion = 0) {
24
+ const safe = captionSafeRect(width, height, width / 45 + motion);
25
+ if (![card.w, card.h, bottom, motion].every(Number.isFinite) || card.w <= 0 || card.h <= 0 || motion < 0 || card.w > safe.width || card.h > safe.height - 14) throw new Error("Caption exceeds safe zone; shorten the phrase or reduce its size");
26
+ return { x: safe.x + (safe.width - card.w) / 2, y: Math.max(safe.y, Math.min(bottom - card.h, safe.y + safe.height - card.h - 14)) };
27
+ }
@@ -14,6 +14,7 @@
14
14
  // Nothing in it is clever. The two places that cost a rebuild if you get them
15
15
  // wrong are marked.
16
16
 
17
+ import { captionSafeRect, captionPlacement } from "../rules/video-safe-zones.mjs";
17
18
  import { spawnSync } from "node:child_process";
18
19
  import { existsSync, mkdirSync } from "node:fs";
19
20
  import { dirname, join } from "node:path";
@@ -89,7 +90,7 @@ export function solveClock(plateaus, wallStartMs, wallEndMs) {
89
90
  // the caption cards
90
91
  // ---------------------------------------------------------------------------
91
92
 
92
- export function renderCaptions(cards, { simkitBin, dir, fontFile, size = 82, width = 940, stroke = 5, pill = 0, color = "ffffff" }) {
93
+ export function renderCaptions(cards, { simkitBin, dir, fontFile, size = 82, width = captionSafeRect(W, H).width, stroke = 5, pill = 0, color = "ffffff" }) {
93
94
  mkdirSync(dir, { recursive: true });
94
95
  return cards.map((c, i) => {
95
96
  const out = join(dir, `cap-${String(i).padStart(3, "0")}.png`);
@@ -137,8 +138,9 @@ function wrap(text, max = 22) {
137
138
  */
138
139
  function driftExpr(amp) {
139
140
  const a = amp;
140
- if (!a) return { x: "0", y: "0" };
141
+ if (!a) return { x: "0", y: "0", margin: 0 };
141
142
  return {
143
+ margin: Math.abs(a) * 1.6,
142
144
  x: `(${n(a * 1.0)}*sin(2*PI*t/7.3)+${n(a * 0.6)}*sin(2*PI*t/4.1+1.1))`,
143
145
  y: `(${n(a * 0.8)}*sin(2*PI*t/5.9+0.4)+${n(a * 0.5)}*sin(2*PI*t/3.3))`,
144
146
  };
@@ -224,8 +226,9 @@ export function buildGraph({ cards, gestures, geom, drift, videoOffsetMs, dotSiz
224
226
  if (t1 <= 0) { input += 1; continue; }
225
227
  // The pop: 14 pixels of rise over the first 90ms. A scale-up would be
226
228
  // truer to the platform and costs a second scaled copy of every card.
229
+ const placement = captionPlacement(c, W, H, capY, drift.margin ?? 0);
227
230
  const rise = `14*max(0,1-(t-${n(t0)})/0.09)`;
228
- parts.push(`[${last}][${input}:v]overlay=x='(W-w)/2+${drift.x}':y='${n(capY - c.h, 1)}+${rise}+${drift.y}':enable='between(t,${n(Math.max(0, t0))},${n(t1)})'[v${input}]`);
231
+ parts.push(`[${last}][${input}:v]overlay=x='${n(placement.x, 1)}+${drift.x}':y='${n(placement.y, 1)}+${rise}+${drift.y}':enable='between(t,${n(Math.max(0, t0))},${n(t1)})'[v${input}]`);
229
232
  last = `v${input}`;
230
233
  input += 1;
231
234
  }