overlay-factory-worker 0.1.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/README.md +215 -0
- package/package.json +64 -0
- package/public/fonts/Handjet-variable.woff2 +0 -0
- package/remotion.config.ts +14 -0
- package/scripts/check-legibility.ts +284 -0
- package/scripts/check-safe-area.ts +148 -0
- package/scripts/custom-fonts.ts +114 -0
- package/scripts/export.sh +31 -0
- package/scripts/field-images.ts +93 -0
- package/scripts/ig-probe.ts +83 -0
- package/scripts/ig-sync.ts +204 -0
- package/scripts/ingest.sh +34 -0
- package/scripts/library.ts +143 -0
- package/scripts/look-card.ts +107 -0
- package/scripts/look-store.ts +176 -0
- package/scripts/make-card.ts +237 -0
- package/scripts/merge-index.ts +74 -0
- package/scripts/new-episode.ts +149 -0
- package/scripts/overlay-worker.ts +1119 -0
- package/scripts/place-overlay.ts +359 -0
- package/scripts/prep-card.ts +73 -0
- package/scripts/quality.ts +0 -0
- package/scripts/render-overlay.ts +150 -0
- package/scripts/report.ts +127 -0
- package/scripts/rerender-cards.ts +116 -0
- package/scripts/series.ts +816 -0
- package/scripts/set-difficulty.ts +62 -0
- package/scripts/state-dir.ts +102 -0
- package/scripts/stock.ts +254 -0
- package/scripts/verify.ts +149 -0
- package/scripts/wp-restock.ts +281 -0
- package/src/Root.tsx +112 -0
- package/src/index.css +1 -0
- package/src/index.ts +4 -0
- package/src/lab/FontLab.tsx +50 -0
- package/src/lab/FontSheet.tsx +188 -0
- package/src/lab/PillLab.tsx +121 -0
- package/src/overlay/Composition.tsx +297 -0
- package/src/overlay/DifficultyMeter.tsx +86 -0
- package/src/overlay/PixelText.tsx +134 -0
- package/src/overlay/Title.tsx +75 -0
- package/src/overlay/brandFonts.ts +58 -0
- package/src/overlay/cardLayout.ts +94 -0
- package/src/overlay/fonts.ts +19 -0
- package/src/overlay/look.ts +155 -0
- package/src/overlay/safeArea.ts +89 -0
- package/src/overlay/types.ts +134 -0
- package/src/series/what-prints/CodeCard.tsx +107 -0
- package/src/series/what-prints/Composition.tsx +106 -0
- package/src/series/what-prints/codeCardTypes.ts +105 -0
- package/src/series/what-prints/types.ts +22 -0
- package/tsconfig.json +17 -0
- package/worker/cli.mjs +151 -0
- package/worker/service.mjs +404 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { sansFamily } from "./fonts";
|
|
3
|
+
import { PixelText } from "./PixelText";
|
|
4
|
+
|
|
5
|
+
// Mixed-typography title in the style of the inspiration reel:
|
|
6
|
+
// line 1 in Instagram-style geometric bold, line 2 rasterized to a dot matrix.
|
|
7
|
+
export const Title: React.FC<{
|
|
8
|
+
line1: string;
|
|
9
|
+
line2: string;
|
|
10
|
+
/** From the series look — see src/overlay/look.ts. */
|
|
11
|
+
sansSize?: number;
|
|
12
|
+
rows?: number;
|
|
13
|
+
cell?: number;
|
|
14
|
+
gap?: number;
|
|
15
|
+
weight?: number;
|
|
16
|
+
/** Family name; must be one the renderer loads (see brandFonts.ts). */
|
|
17
|
+
family?: string;
|
|
18
|
+
}> = ({
|
|
19
|
+
line1,
|
|
20
|
+
line2,
|
|
21
|
+
sansSize = 112,
|
|
22
|
+
rows = 16,
|
|
23
|
+
cell = 9,
|
|
24
|
+
gap = 2,
|
|
25
|
+
weight = 700,
|
|
26
|
+
family,
|
|
27
|
+
}) => {
|
|
28
|
+
// Falls back to Poppins rather than to a system font: a title in the wrong
|
|
29
|
+
// face is a silent design regression, and this is the last place to catch it.
|
|
30
|
+
const face = family ? `"${family}", ${sansFamily}` : sansFamily;
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
style={{
|
|
34
|
+
display: "flex",
|
|
35
|
+
flexDirection: "column",
|
|
36
|
+
alignItems: "center",
|
|
37
|
+
gap: 18,
|
|
38
|
+
textAlign: "center",
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<div
|
|
42
|
+
style={{
|
|
43
|
+
fontFamily: face,
|
|
44
|
+
fontWeight: 700,
|
|
45
|
+
fontSize: sansSize,
|
|
46
|
+
lineHeight: 1.02,
|
|
47
|
+
color: "white",
|
|
48
|
+
textShadow:
|
|
49
|
+
"0 0 4px rgba(0,0,0,0.9), 0 4px 20px rgba(0,0,0,0.6)",
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
{line1}
|
|
53
|
+
</div>
|
|
54
|
+
{/*
|
|
55
|
+
Fine dots, thick strokes. The old 10/15/3 at weight 300 drew big sparse
|
|
56
|
+
squares that read as a pattern rather than as a dot-matrix display; the
|
|
57
|
+
reference is denser and heavier than that.
|
|
58
|
+
|
|
59
|
+
Chosen by rendering the alternatives side by side at this exact size —
|
|
60
|
+
see src/lab/FontSheet.tsx, which also shows why this stays a rasterizer.
|
|
61
|
+
Every real pixel font available (Handjet at any weight or shape axis,
|
|
62
|
+
Silkscreen, Pixelify Sans, Micro 5, DotGothic16, Jersey) draws SOLID
|
|
63
|
+
blocks. None of them make separated dots, which is the whole look.
|
|
64
|
+
*/}
|
|
65
|
+
<PixelText
|
|
66
|
+
text={line2}
|
|
67
|
+
fontFamily={face}
|
|
68
|
+
fontWeight={weight}
|
|
69
|
+
rows={rows}
|
|
70
|
+
cell={cell}
|
|
71
|
+
gap={gap}
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The brand fonts, made available to generated series templates.
|
|
2
|
+
//
|
|
3
|
+
// A template writes font-family: var(--brand-font-heading), and that variable
|
|
4
|
+
// names a family — naming it isn't loading it. Without this the family silently
|
|
5
|
+
// falls through to the system fallback, which looks like the brand simply not
|
|
6
|
+
// applying and gives no error to chase.
|
|
7
|
+
//
|
|
8
|
+
// These are the curated families the Goose Tools brand form offers (see
|
|
9
|
+
// HEADING_FONTS / BODY_FONTS in goosetools' src/lib/brand-kit.ts). Keep the two
|
|
10
|
+
// lists in step: a family offered there but missing here renders as fallback.
|
|
11
|
+
//
|
|
12
|
+
// Loading all of them costs almost nothing at render time — loadFont only
|
|
13
|
+
// injects @font-face rules, and the browser fetches a face only when something
|
|
14
|
+
// actually uses it. That beats loading conditionally, which would mean calling
|
|
15
|
+
// loaders from inside a render.
|
|
16
|
+
|
|
17
|
+
import { loadFont as instrumentSerif } from "@remotion/google-fonts/InstrumentSerif";
|
|
18
|
+
import { loadFont as spaceGrotesk } from "@remotion/google-fonts/SpaceGrotesk";
|
|
19
|
+
import { loadFont as fraunces } from "@remotion/google-fonts/Fraunces";
|
|
20
|
+
import { loadFont as archivo } from "@remotion/google-fonts/Archivo";
|
|
21
|
+
import { loadFont as bricolage } from "@remotion/google-fonts/BricolageGrotesque";
|
|
22
|
+
import { loadFont as dmSerifDisplay } from "@remotion/google-fonts/DMSerifDisplay";
|
|
23
|
+
import { loadFont as inter } from "@remotion/google-fonts/Inter";
|
|
24
|
+
import { loadFont as dmSans } from "@remotion/google-fonts/DMSans";
|
|
25
|
+
import { loadFont as workSans } from "@remotion/google-fonts/WorkSans";
|
|
26
|
+
import { loadFont as ibmPlexSans } from "@remotion/google-fonts/IBMPlexSans";
|
|
27
|
+
import { loadFont as spaceMono } from "@remotion/google-fonts/SpaceMono";
|
|
28
|
+
|
|
29
|
+
// Called individually rather than mapped over an array: each family's loadFont
|
|
30
|
+
// is generically typed over its own variant table, so a shared array collapses
|
|
31
|
+
// them to a union TypeScript won't call.
|
|
32
|
+
const LOADED = [
|
|
33
|
+
instrumentSerif(),
|
|
34
|
+
spaceGrotesk(),
|
|
35
|
+
fraunces(),
|
|
36
|
+
archivo(),
|
|
37
|
+
bricolage(),
|
|
38
|
+
dmSerifDisplay(),
|
|
39
|
+
inter(),
|
|
40
|
+
dmSans(),
|
|
41
|
+
workSans(),
|
|
42
|
+
ibmPlexSans(),
|
|
43
|
+
spaceMono(),
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export const BRAND_FONT_FAMILIES = LOADED.map((f) => f.fontFamily);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolves once every face is actually usable.
|
|
50
|
+
*
|
|
51
|
+
* Injecting @font-face is not the same as having the font: the renderer will
|
|
52
|
+
* happily capture a frame while the face is still loading, and the text comes
|
|
53
|
+
* out in the fallback with no warning. The composition holds the frame on this
|
|
54
|
+
* via delayRender — the same thing PixelText does before it rasterizes.
|
|
55
|
+
*/
|
|
56
|
+
export const brandFontsReady: Promise<unknown> = Promise.all(
|
|
57
|
+
LOADED.map((f) => f.waitUntilDone()),
|
|
58
|
+
);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the code card sits, and how big it is.
|
|
3
|
+
*
|
|
4
|
+
* This lived in three places that disagreed: src/overlay/Composition.tsx,
|
|
5
|
+
* src/series/what-prints/Composition.tsx, and scripts/place-overlay.ts. The
|
|
6
|
+
* placement script assumed the card was centred on H/2 and 86% wide; both
|
|
7
|
+
* compositions centre it on SAFE_CENTER_Y and cap its HEIGHT, which for a
|
|
8
|
+
* normal five-line card binds first and makes it ~83% wide, 85px higher than
|
|
9
|
+
* the script believed. The meter was therefore placed against a card that
|
|
10
|
+
* wasn't where it was thought to be.
|
|
11
|
+
*/
|
|
12
|
+
import { CARD_MAX_HEIGHT, FRAME, SAFE_AREA, SAFE_CENTER_Y } from "./safeArea";
|
|
13
|
+
|
|
14
|
+
/** Fraction of frame width the card may occupy when nothing is in its way. */
|
|
15
|
+
export const CARD_WIDTH_PCT = 0.86;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How small the card may get to avoid something — a face, mainly.
|
|
19
|
+
*
|
|
20
|
+
* Below about two thirds the code stops being readable on a phone, at which
|
|
21
|
+
* point a smaller card is worse than an obscured one.
|
|
22
|
+
*/
|
|
23
|
+
export const CARD_MIN_WIDTH_PCT = 0.62;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where the title stops, measured rather than guessed.
|
|
27
|
+
*
|
|
28
|
+
* Title.tsx draws line 1 at fontSize 112 (lineHeight 1.02), an 18px gap, then
|
|
29
|
+
* the dot-matrix line at rows 16 x cell 9. It starts at SAFE_AREA.top + 12.
|
|
30
|
+
* The card has to begin below all of that: dodging a face by sliding UP is
|
|
31
|
+
* useless if it lands on the title instead.
|
|
32
|
+
*/
|
|
33
|
+
export const TITLE_BOTTOM = SAFE_AREA.top + 12 + Math.round(112 * 1.02) + 18 + 16 * 9;
|
|
34
|
+
|
|
35
|
+
export type CardBox = {
|
|
36
|
+
/** Drawn size in frame pixels. */
|
|
37
|
+
width: number;
|
|
38
|
+
height: number;
|
|
39
|
+
/** Top-left, for hit-testing against the UI zones and the energy map. */
|
|
40
|
+
x: number;
|
|
41
|
+
y: number;
|
|
42
|
+
bottom: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The box a card of this aspect ratio actually draws, given a width fraction
|
|
47
|
+
* and an optional vertical centre.
|
|
48
|
+
*
|
|
49
|
+
* Both bounds are maxima with the aspect preserved, exactly as the `Img` in
|
|
50
|
+
* Composition.tsx behaves — so whichever binds first wins, and callers can't
|
|
51
|
+
* drift from what gets rendered.
|
|
52
|
+
*/
|
|
53
|
+
export const cardBox = (
|
|
54
|
+
aspect: number, // natural height / natural width
|
|
55
|
+
widthPct: number = CARD_WIDTH_PCT,
|
|
56
|
+
centerY: number = SAFE_CENTER_Y,
|
|
57
|
+
maxHeight: number = CARD_MAX_HEIGHT,
|
|
58
|
+
): CardBox => {
|
|
59
|
+
let width = FRAME.width * widthPct;
|
|
60
|
+
let height = width * aspect;
|
|
61
|
+
if (height > maxHeight) {
|
|
62
|
+
height = maxHeight;
|
|
63
|
+
width = height / aspect;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
width,
|
|
67
|
+
height,
|
|
68
|
+
x: (FRAME.width - width) / 2,
|
|
69
|
+
y: centerY - height / 2,
|
|
70
|
+
bottom: centerY + height / 2,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Vertical range the card's centre may be moved within.
|
|
76
|
+
*
|
|
77
|
+
* Bounded by Instagram's own chrome rather than by taste: above the top bar it
|
|
78
|
+
* gets covered by the Reels header, below the caption band it gets covered by
|
|
79
|
+
* the caption. Moving DOWN is the useful direction — a face is usually
|
|
80
|
+
* centre-frame and slightly high, and the calm space is under it.
|
|
81
|
+
*
|
|
82
|
+
* `reserveBelow` keeps room for whatever has to sit under the card. Without
|
|
83
|
+
* it the search happily shoves the card to the bottom of the legal area to
|
|
84
|
+
* dodge a face, leaving the difficulty meter — which reads as the answer to
|
|
85
|
+
* the snippet and so must sit below it — with nowhere legal to go at all.
|
|
86
|
+
*/
|
|
87
|
+
export const cardCenterRange = (
|
|
88
|
+
height: number,
|
|
89
|
+
reserveBelow = 0,
|
|
90
|
+
): [number, number] => {
|
|
91
|
+
const top = TITLE_BOTTOM + 24 + height / 2;
|
|
92
|
+
const bottom = 1500 - reserveBelow - height / 2 - 20; // 1500 = caption band
|
|
93
|
+
return [Math.min(top, bottom), Math.max(top, bottom)];
|
|
94
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { loadFont as loadPoppins } from "@remotion/google-fonts/Poppins";
|
|
2
|
+
import { loadFont as loadHandjet } from "@remotion/google-fonts/Handjet";
|
|
3
|
+
|
|
4
|
+
// Poppins is the closest free stand-in for Instagram's "Modern" title font —
|
|
5
|
+
// geometric, circular bowls, single-story g. Used for "What" and the pill.
|
|
6
|
+
export const { fontFamily: sansFamily } = loadPoppins("normal", {
|
|
7
|
+
weights: ["300", "400", "500", "700"],
|
|
8
|
+
subsets: ["latin"],
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
// Handjet is a variable dot-matrix face. Its separated square pixels (the look
|
|
12
|
+
// in the title inspiration) come from a light weight on the coarse element grid:
|
|
13
|
+
// heavier weights fill the gaps in, lighter ones fade out.
|
|
14
|
+
export const { fontFamily: pixelFamily } = loadHandjet("normal", {
|
|
15
|
+
weights: ["300", "400", "500"],
|
|
16
|
+
subsets: ["latin"],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const PIXEL_VARIATION = '"ELGR" 1, "ELSH" 0';
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The What Prints? look, as data.
|
|
3
|
+
*
|
|
4
|
+
* Generated series are HTML templates, so feedback can regenerate them
|
|
5
|
+
* wholesale. What Prints? isn't — its card is rendered by CodeCard.tsx with
|
|
6
|
+
* real syntax highlighting, and its answer-checking and quality gates depend
|
|
7
|
+
* on that path. So its look is exposed as parameters instead, and feedback
|
|
8
|
+
* edits those.
|
|
9
|
+
*
|
|
10
|
+
* That trade is deliberate and worth stating plainly: this can move, resize,
|
|
11
|
+
* restyle and re-weight what's already there. It cannot invent a new
|
|
12
|
+
* arrangement. "Make the code smaller", "less rounded", "move it down",
|
|
13
|
+
* "bigger title", "lose the language label" all work. "Put the answer in a
|
|
14
|
+
* speech bubble" does not — that needs a template, which is what the
|
|
15
|
+
* generated-series path is for.
|
|
16
|
+
*
|
|
17
|
+
* Everything here has a default that reproduces the current design, so an
|
|
18
|
+
* absent or partial look.json is always safe.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Typefaces available to the title.
|
|
24
|
+
*
|
|
25
|
+
* Every one of these is loaded by src/overlay/brandFonts.ts, which blocks the
|
|
26
|
+
* frame until the faces are usable. A family outside this list would render as
|
|
27
|
+
* a system fallback with no error — which looks like the change silently not
|
|
28
|
+
* working, and is exactly the failure this list exists to prevent.
|
|
29
|
+
*/
|
|
30
|
+
export const TITLE_FAMILIES = [
|
|
31
|
+
"Poppins",
|
|
32
|
+
"Instrument Serif",
|
|
33
|
+
"Space Grotesk",
|
|
34
|
+
"Fraunces",
|
|
35
|
+
"Archivo",
|
|
36
|
+
"Bricolage Grotesque",
|
|
37
|
+
"DM Serif Display",
|
|
38
|
+
"Inter",
|
|
39
|
+
"DM Sans",
|
|
40
|
+
"Work Sans",
|
|
41
|
+
"IBM Plex Sans",
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
export const lookSchema = z.object({
|
|
45
|
+
card: z
|
|
46
|
+
.object({
|
|
47
|
+
/** Fraction of frame width, 0.5–0.95. */
|
|
48
|
+
widthPct: z.number().min(0.5).max(0.95).optional(),
|
|
49
|
+
/** Corner radius as a fraction of card width. */
|
|
50
|
+
radiusPct: z.number().min(0).max(0.12).optional(),
|
|
51
|
+
/** Card fill. */
|
|
52
|
+
background: z.string().optional(),
|
|
53
|
+
/** 0–1. Below ~0.75 the code starts fighting the footage. */
|
|
54
|
+
opacity: z.number().min(0.3).max(1).optional(),
|
|
55
|
+
/** The "Python" label above the snippet. */
|
|
56
|
+
showLanguage: z.boolean().optional(),
|
|
57
|
+
/** Multiplier on the code type size. */
|
|
58
|
+
codeScale: z.number().min(0.6).max(1.4).optional(),
|
|
59
|
+
/**
|
|
60
|
+
* CENTRE of the card, in 1080x1920 space. Set only when it has been
|
|
61
|
+
* placed by hand; null means "let the placement script decide", which
|
|
62
|
+
* is the default and what handles footage it hasn't seen.
|
|
63
|
+
*
|
|
64
|
+
* x exists so the card can be moved like anything else on the frame.
|
|
65
|
+
* It stays centred when null, which is where a code card almost always
|
|
66
|
+
* wants to be — but "almost always" is not "always", and a face on one
|
|
67
|
+
* side of the shot is the obvious case.
|
|
68
|
+
*/
|
|
69
|
+
x: z.number().min(0).max(1080).nullable().optional(),
|
|
70
|
+
y: z.number().min(200).max(1700).nullable().optional(),
|
|
71
|
+
})
|
|
72
|
+
.optional(),
|
|
73
|
+
title: z
|
|
74
|
+
.object({
|
|
75
|
+
/**
|
|
76
|
+
* The typeface for BOTH title lines — line 1 directly, and line 2 as the
|
|
77
|
+
* face being rasterized into dots, so it changes the letterforms too.
|
|
78
|
+
* Restricted to families the renderer actually loads: naming one it
|
|
79
|
+
* doesn't have would silently fall back to a system font.
|
|
80
|
+
*/
|
|
81
|
+
// Not an enum: an uploaded font is a valid family too, and the worker
|
|
82
|
+
// is the only thing that knows which ones exist on this machine. The
|
|
83
|
+
// allowed set is checked at feedback time instead — see look-store.ts.
|
|
84
|
+
family: z.string().max(60).optional(),
|
|
85
|
+
/** Line 1, the geometric bold line. */
|
|
86
|
+
sansSize: z.number().min(60).max(160).optional(),
|
|
87
|
+
/** Dot-matrix cap height in grid cells — more rows = finer dots. */
|
|
88
|
+
rows: z.number().min(8).max(24).optional(),
|
|
89
|
+
/** Grid pitch in px. */
|
|
90
|
+
cell: z.number().min(5).max(20).optional(),
|
|
91
|
+
/** Space inside the pitch, so the dot is cell - gap. */
|
|
92
|
+
gap: z.number().min(0).max(6).optional(),
|
|
93
|
+
/** Weight of the face being rasterized — this is the stroke thickness. */
|
|
94
|
+
weight: z.number().min(100).max(900).optional(),
|
|
95
|
+
/** The dark wash behind the title. */
|
|
96
|
+
scrim: z.boolean().optional(),
|
|
97
|
+
})
|
|
98
|
+
.optional(),
|
|
99
|
+
meter: z
|
|
100
|
+
.object({
|
|
101
|
+
show: z.boolean().optional(),
|
|
102
|
+
/** Top-left of the badge. Null on either axis means automatic. */
|
|
103
|
+
x: z.number().min(0).max(1080).nullable().optional(),
|
|
104
|
+
y: z.number().min(0).max(1920).nullable().optional(),
|
|
105
|
+
})
|
|
106
|
+
.optional(),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* How far each piece may be moved, in 1080x1920 space.
|
|
111
|
+
*
|
|
112
|
+
* Exported because the browser needs to clamp a drag to exactly this. When it
|
|
113
|
+
* clamped to the frame instead, a card dragged near the top or bottom was
|
|
114
|
+
* accepted by the UI and rejected by the schema — the position was lost, the
|
|
115
|
+
* preview showed one thing and the burn did another, and nothing said so.
|
|
116
|
+
*/
|
|
117
|
+
export const LOOK_BOUNDS = {
|
|
118
|
+
card: { x: [0, 1080], y: [200, 1700] },
|
|
119
|
+
meter: { x: [0, 1080], y: [0, 1920] },
|
|
120
|
+
} as const;
|
|
121
|
+
|
|
122
|
+
export type Look = z.infer<typeof lookSchema>;
|
|
123
|
+
|
|
124
|
+
/** The shipped design. Every field the schema allows has a value here. */
|
|
125
|
+
export const DEFAULT_LOOK = {
|
|
126
|
+
card: {
|
|
127
|
+
widthPct: 0.86,
|
|
128
|
+
radiusPct: 0.045,
|
|
129
|
+
background: "#212121",
|
|
130
|
+
opacity: 1,
|
|
131
|
+
showLanguage: true,
|
|
132
|
+
codeScale: 1,
|
|
133
|
+
x: null,
|
|
134
|
+
y: null,
|
|
135
|
+
},
|
|
136
|
+
title: {
|
|
137
|
+
family: "Poppins" as (typeof TITLE_FAMILIES)[number],
|
|
138
|
+
sansSize: 112,
|
|
139
|
+
rows: 16,
|
|
140
|
+
cell: 9,
|
|
141
|
+
gap: 2,
|
|
142
|
+
weight: 700,
|
|
143
|
+
scrim: true,
|
|
144
|
+
},
|
|
145
|
+
meter: { show: true, x: null, y: null },
|
|
146
|
+
} as const;
|
|
147
|
+
|
|
148
|
+
/** A look with every gap filled from the shipped design. */
|
|
149
|
+
export const resolveLook = (look: Look | null | undefined) => ({
|
|
150
|
+
card: { ...DEFAULT_LOOK.card, ...(look?.card ?? {}) },
|
|
151
|
+
title: { ...DEFAULT_LOOK.title, ...(look?.title ?? {}) },
|
|
152
|
+
meter: { ...DEFAULT_LOOK.meter, ...(look?.meter ?? {}) },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
export type ResolvedLook = ReturnType<typeof resolveLook>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where Instagram covers a reel with its own interface.
|
|
3
|
+
*
|
|
4
|
+
* A 1080x1920 reel is never shown clean: the app draws the status bar and
|
|
5
|
+
* "Reels" header over the top, the caption / username / audio row over the
|
|
6
|
+
* bottom, and the like-comment-share-audio rail down the right. Anything the
|
|
7
|
+
* composition puts in those bands is either hidden or fighting IG's own UI.
|
|
8
|
+
*
|
|
9
|
+
* These are deliberately conservative. Instagram moves its chrome between app
|
|
10
|
+
* versions and the bars sit differently on a notched phone versus a flat one,
|
|
11
|
+
* so the numbers below leave slack rather than hugging any one build. Treat
|
|
12
|
+
* them as "don't put anything you need read here", not as measurements.
|
|
13
|
+
*/
|
|
14
|
+
export const FRAME = { width: 1080, height: 1920 } as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The chrome as actual rectangles, not margins.
|
|
18
|
+
*
|
|
19
|
+
* Modelling it as a single inset rectangle gets the right-hand side wrong: the
|
|
20
|
+
* action rail only runs down the *lower* half, so a flat "keep 260px clear on
|
|
21
|
+
* the right" reports the title and the code card as unsafe when nothing is
|
|
22
|
+
* drawn near them. A check that cries wolf gets ignored, so the zones match
|
|
23
|
+
* where the controls actually are.
|
|
24
|
+
*/
|
|
25
|
+
export const UI_ZONES = [
|
|
26
|
+
{
|
|
27
|
+
name: "top bar",
|
|
28
|
+
x: 0,
|
|
29
|
+
y: 0,
|
|
30
|
+
width: FRAME.width,
|
|
31
|
+
height: 250, // status bar + the Reels header row
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "action rail",
|
|
35
|
+
x: FRAME.width - 260,
|
|
36
|
+
y: 1080,
|
|
37
|
+
width: 260,
|
|
38
|
+
height: 620, // like / comment / share / save / audio disc
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "caption",
|
|
42
|
+
x: 0,
|
|
43
|
+
y: 1500,
|
|
44
|
+
width: FRAME.width - 260,
|
|
45
|
+
height: FRAME.height - 1500, // username, caption, audio title
|
|
46
|
+
},
|
|
47
|
+
] as const;
|
|
48
|
+
|
|
49
|
+
/** Convenience for layout code that just wants the clear middle band. */
|
|
50
|
+
export const SAFE_AREA = {
|
|
51
|
+
top: 250,
|
|
52
|
+
bottom: 420,
|
|
53
|
+
left: 60,
|
|
54
|
+
right: 260,
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
/** Vertical centre of the band that is never covered — where the card belongs. */
|
|
58
|
+
export const SAFE_CENTER_Y = Math.round((SAFE_AREA.top + 1500) / 2);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Tallest the code card may render. Centring alone isn't enough: a 6-line card
|
|
62
|
+
* centred on SAFE_CENTER_Y still reaches past y=1080 and puts its bottom-right
|
|
63
|
+
* corner behind the like button. Capping the height means a long snippet gets
|
|
64
|
+
* a little smaller instead of sliding under the rail.
|
|
65
|
+
*
|
|
66
|
+
* The 60px allowance is the drop shadow: it paints ~20px past the image on each
|
|
67
|
+
* axis, so a cap set to the bare image height still lands shadow under the rail
|
|
68
|
+
* (and check-safe-area.ts measures drawn pixels, shadow included).
|
|
69
|
+
*/
|
|
70
|
+
export const CARD_MAX_HEIGHT = (1080 - SAFE_CENTER_Y) * 2 - 60;
|
|
71
|
+
|
|
72
|
+
export const overlaps = (
|
|
73
|
+
box: { top: number; bottom: number; left: number; right: number },
|
|
74
|
+
z: (typeof UI_ZONES)[number],
|
|
75
|
+
) =>
|
|
76
|
+
box.right >= z.x &&
|
|
77
|
+
box.left <= z.x + z.width &&
|
|
78
|
+
box.bottom >= z.y &&
|
|
79
|
+
box.top <= z.y + z.height;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The profile grid crops a reel to 3:4 about its centre, so the cover
|
|
83
|
+
* thumbnail loses ~240px off the top and bottom of the frame. Anything that has
|
|
84
|
+
* to survive in the grid — the title, mainly — needs to sit inside this too.
|
|
85
|
+
*/
|
|
86
|
+
export const GRID_CROP = {
|
|
87
|
+
top: Math.round((FRAME.height - (FRAME.width * 4) / 3) / 2),
|
|
88
|
+
bottom: Math.round((FRAME.height - (FRAME.width * 4) / 3) / 2),
|
|
89
|
+
} as const;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Props for the generic `Overlay` composition — the overlay layers on their
|
|
5
|
+
* own, with nothing behind them.
|
|
6
|
+
*
|
|
7
|
+
* The composition renders at 1080x1920 and is scaled up at render time
|
|
8
|
+
* (`--scale=2` for 4K), so every coordinate in here is in 1080x1920 space no
|
|
9
|
+
* matter what resolution the footage is. That's deliberate: the safe-area
|
|
10
|
+
* numbers, the meter placement chosen by scripts/place-overlay.ts, and the
|
|
11
|
+
* type sizes baked into Title/DifficultyMeter are all tuned against that
|
|
12
|
+
* frame, and they stay correct for 4K output without being touched.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Every layer can be limited to a slice of the clip. Omitted = whole clip. */
|
|
16
|
+
const timing = {
|
|
17
|
+
fromSec: z.number().optional(),
|
|
18
|
+
toSec: z.number().optional(),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const titleLayer = z.object({
|
|
22
|
+
type: z.literal("title"),
|
|
23
|
+
line1: z.string(),
|
|
24
|
+
line2: z.string(),
|
|
25
|
+
// Look knobs, supplied by the series' look.json. Absent = the shipped design.
|
|
26
|
+
sansSize: z.number().optional(),
|
|
27
|
+
rows: z.number().optional(),
|
|
28
|
+
cell: z.number().optional(),
|
|
29
|
+
gap: z.number().optional(),
|
|
30
|
+
weight: z.number().optional(),
|
|
31
|
+
family: z.string().optional(),
|
|
32
|
+
/**
|
|
33
|
+
* The gradient wash behind the title. The dot-matrix line is mostly gaps, so
|
|
34
|
+
* over bright footage the squares stop reading as letters — this buys back
|
|
35
|
+
* contrast without touching the typography. On for anything shot outdoors or
|
|
36
|
+
* against a white desk.
|
|
37
|
+
*/
|
|
38
|
+
scrim: z.boolean().optional(),
|
|
39
|
+
/** Top offset. Defaults to just under Instagram's Reels header. */
|
|
40
|
+
y: z.number().optional(),
|
|
41
|
+
...timing,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const pixelTextLayer = z.object({
|
|
45
|
+
type: z.literal("pixelText"),
|
|
46
|
+
text: z.string(),
|
|
47
|
+
/** Cap height in grid cells — the stroke-thickness control. */
|
|
48
|
+
rows: z.number().optional(),
|
|
49
|
+
/** On-screen size of one grid cell, in px. */
|
|
50
|
+
cell: z.number().optional(),
|
|
51
|
+
gap: z.number().optional(),
|
|
52
|
+
color: z.string().optional(),
|
|
53
|
+
x: z.number().optional(),
|
|
54
|
+
y: z.number().optional(),
|
|
55
|
+
...timing,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const imageLayer = z.object({
|
|
59
|
+
type: z.literal("image"),
|
|
60
|
+
/** An http(s) URL, or a path under public/ for a local render. */
|
|
61
|
+
src: z.string(),
|
|
62
|
+
/**
|
|
63
|
+
* Omit both to centre on the band Instagram never covers — which is where a
|
|
64
|
+
* code card belongs. Dead-centring the frame instead puts the lower edge
|
|
65
|
+
* under the like/comment rail.
|
|
66
|
+
*/
|
|
67
|
+
x: z.number().optional(),
|
|
68
|
+
y: z.number().optional(),
|
|
69
|
+
/** Fraction of frame width, 0-1. Defaults to 0.86, matching the code cards. */
|
|
70
|
+
widthPct: z.number().optional(),
|
|
71
|
+
maxHeight: z.number().optional(),
|
|
72
|
+
/** Let the footage through the card. 1 = solid, the shipped design. */
|
|
73
|
+
opacity: z.number().optional(),
|
|
74
|
+
...timing,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A block of self-contained HTML/CSS drawn over the footage — how a generated
|
|
79
|
+
* series gets a look of its own without anyone writing a component for it.
|
|
80
|
+
*
|
|
81
|
+
* Remotion renders by seeking to each frame and screenshotting, so CSS
|
|
82
|
+
* animations and transitions never advance: they'd freeze at whatever the
|
|
83
|
+
* first frame painted. Motion therefore comes from `enter`, which is driven by
|
|
84
|
+
* Remotion's own spring and applied as an inline transform. Templates supply
|
|
85
|
+
* the look; the engine supplies the movement.
|
|
86
|
+
*/
|
|
87
|
+
const htmlLayer = z.object({
|
|
88
|
+
type: z.literal("html"),
|
|
89
|
+
html: z.string(),
|
|
90
|
+
/** Fraction of frame width, 0-1. Default 0.86 — matches the code cards. */
|
|
91
|
+
widthPct: z.number().optional(),
|
|
92
|
+
x: z.number().optional(),
|
|
93
|
+
y: z.number().optional(),
|
|
94
|
+
/** Used when x/y are absent. `center` means the safe band, not the frame. */
|
|
95
|
+
anchor: z.enum(["top", "center", "bottom"]).optional(),
|
|
96
|
+
enter: z.enum(["none", "fade", "slide-up", "slide-down", "pop"]).optional(),
|
|
97
|
+
...timing,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const meterLayer = z.object({
|
|
101
|
+
type: z.literal("meter"),
|
|
102
|
+
difficulty: z.enum(["easy", "medium", "hard"]),
|
|
103
|
+
x: z.number().optional(),
|
|
104
|
+
y: z.number().optional(),
|
|
105
|
+
...timing,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export const overlayLayerSchema = z.discriminatedUnion("type", [
|
|
109
|
+
titleLayer,
|
|
110
|
+
pixelTextLayer,
|
|
111
|
+
imageLayer,
|
|
112
|
+
htmlLayer,
|
|
113
|
+
meterLayer,
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A font the user uploaded, already fetched and verified by the worker.
|
|
118
|
+
* `file` is a path under public/ — never a remote URL, so a render can't hang
|
|
119
|
+
* waiting on the network.
|
|
120
|
+
*/
|
|
121
|
+
const fontFace = z.object({
|
|
122
|
+
family: z.string(),
|
|
123
|
+
file: z.string(),
|
|
124
|
+
format: z.string(),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
export const overlaySchema = z.object({
|
|
128
|
+
durationSec: z.number(),
|
|
129
|
+
layers: z.array(overlayLayerSchema),
|
|
130
|
+
fonts: z.array(fontFace).optional(),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
export type OverlayLayer = z.infer<typeof overlayLayerSchema>;
|
|
134
|
+
export type OverlayProps = z.infer<typeof overlaySchema>;
|