@docyrus/animaguru 0.13.0 → 0.15.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 +54 -3
- package/dist/animaguru.js +1025 -591
- package/package.json +1 -1
- package/skills/animaguru/SKILL.md +19 -89
- package/skills/animaguru/references/avatars.md +22 -0
- package/skills/animaguru/references/cloud.md +19 -0
- package/skills/animaguru/references/sharing.md +33 -0
- package/skills/animaguru/references/sprites.md +29 -0
- package/skills/animaguru-advertising/SKILL.md +2 -2
- package/skills/animaguru-analog-film-tape/SKILL.md +1 -0
- package/skills/animaguru-animated-data-visualization/SKILL.md +1 -0
- package/skills/animaguru-art-deco-motion/SKILL.md +1 -0
- package/skills/animaguru-ascii-crt/SKILL.md +1 -0
- package/skills/animaguru-blueprint-drafting/SKILL.md +1 -0
- package/skills/animaguru-brand-kit/SKILL.md +1 -1
- package/skills/animaguru-cartoon-character-animation/SKILL.md +1 -0
- package/skills/animaguru-cutout-animation/SKILL.md +1 -0
- package/skills/animaguru-dark-tech-stage/SKILL.md +2 -1
- package/skills/animaguru-dark-tech-stage/references/recipe.md +10 -0
- package/skills/animaguru-diagram-formula-motion/SKILL.md +1 -0
- package/skills/animaguru-engraved-relief-print/SKILL.md +1 -0
- package/skills/animaguru-flat-vector-motion/SKILL.md +1 -0
- package/skills/animaguru-generative-light-signal/SKILL.md +1 -0
- package/skills/animaguru-glass-mosaic/SKILL.md +1 -0
- package/skills/animaguru-halftone-editorial/SKILL.md +1 -0
- package/skills/animaguru-holographic-scan/SKILL.md +1 -0
- package/skills/animaguru-interface-motion/SKILL.md +1 -0
- package/skills/animaguru-isometric-worlds/SKILL.md +1 -0
- package/skills/animaguru-kinetic-typography/SKILL.md +1 -0
- package/skills/animaguru-miniature-diorama/SKILL.md +1 -0
- package/skills/animaguru-painterly-depth-animation/SKILL.md +1 -0
- package/skills/animaguru-paper-popup/SKILL.md +1 -0
- package/skills/animaguru-photo-footage-editorial/SKILL.md +1 -0
- package/skills/animaguru-pixel-art-animation/SKILL.md +1 -0
- package/skills/animaguru-portal-zoom/SKILL.md +1 -0
- package/skills/animaguru-risograph-print/SKILL.md +1 -0
- package/skills/animaguru-sand-drawing/SKILL.md +1 -0
- package/skills/animaguru-shadow-theatre/SKILL.md +1 -0
- package/skills/animaguru-silkscreen-poster/SKILL.md +1 -0
- package/skills/animaguru-stop-motion-objects/SKILL.md +1 -0
- package/skills/animaguru-studio-product-reveal/SKILL.md +1 -0
- package/skills/animaguru-video/SKILL.md +37 -5
- package/skills/animaguru-video/references/short-form-ad.md +57 -0
- package/skills/animaguru-whiteboard-line-drawing/SKILL.md +1 -0
- package/templates/video/AGENTS.md +2 -1
- package/templates/video/WORKSPACE-AGENTS.md +6 -2
- package/templates/video/package.json +7 -1
- package/templates/video/scripts/audio-mix.mjs +101 -0
- package/templates/video/scripts/audio-music.mjs +175 -0
- package/templates/video/scripts/audio-tools.mjs +241 -0
- package/templates/video/scripts/audio-vo.mjs +158 -0
- package/templates/video/scripts/export-mp4.mjs +5 -3
- package/templates/video/scripts/frames.mjs +81 -0
- package/templates/video/scripts/headless-browser.mjs +83 -10
- package/templates/video/scripts/review-analysis.mjs +71 -1
- package/templates/video/scripts/review-audio.mjs +2 -1
- package/templates/video/scripts/review.mjs +8 -5
- package/templates/video/scripts/site-inspect.mjs +128 -0
- package/templates/video/scripts/starter-film.sha256 +1 -1
- package/templates/video/scripts/workspace-plugin.mjs +2 -1
- package/templates/video/shared/safe-areas.json +7 -0
- package/templates/video/src/film.ts +97 -125
- package/templates/video/src/kit/components.ts +95 -0
- package/templates/video/src/kit/dark-stage.ts +55 -0
- package/templates/video/src/kit/film-base.ts +108 -0
- package/templates/video/src/kit/fonts.ts +29 -0
- package/templates/video/src/kit/index.ts +11 -0
- package/templates/video/src/kit/layout.ts +40 -0
- package/templates/video/src/kit/motion.ts +37 -0
- package/templates/video/src/kit/shapes.ts +68 -0
- package/templates/video/src/kit/text.ts +34 -0
- package/templates/video/src/kit/textures.ts +42 -0
- package/templates/video/src/kit/timing.ts +48 -0
- package/templates/video/src/player-types.ts +7 -1
- package/templates/video/src/player.ts +3 -5
- package/templates/video/src/review.ts +2 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Container, Graphics, type Text } from "pixi.js";
|
|
2
|
+
import { clamp01, win } from "./motion";
|
|
3
|
+
import { check } from "./shapes";
|
|
4
|
+
import { txt, type Family, type Weight } from "./text";
|
|
5
|
+
import type { Caption } from "./timing";
|
|
6
|
+
|
|
7
|
+
export type CaptionStyle = {
|
|
8
|
+
x: number; y: number; maxWidth?: number; size?: number; weight?: Weight;
|
|
9
|
+
color?: number; background?: number; backgroundAlpha?: number; border?: number; borderAlpha?: number; radius?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Burned-in captions for muted autoplay feeds. Hidden when film.captions is false (clean exports with .srt/.vtt).
|
|
14
|
+
* Its text is labelled "caption", so review treats it as a caption rather than film text.
|
|
15
|
+
*/
|
|
16
|
+
export class CaptionBar extends Container {
|
|
17
|
+
private readonly box = new Graphics();
|
|
18
|
+
private readonly line: Text;
|
|
19
|
+
private current = "";
|
|
20
|
+
constructor(private readonly captions: Caption[], private readonly style: CaptionStyle) {
|
|
21
|
+
super();
|
|
22
|
+
const maxWidth = style.maxWidth ?? 860, size = style.size ?? 38;
|
|
23
|
+
this.line = txt("", size, { weight: style.weight ?? "600", fill: style.color ?? 0xffffff, align: "center", wordWrap: true, wordWrapWidth: maxWidth, lineHeight: Math.round(size * 1.32) });
|
|
24
|
+
this.line.label = "caption";
|
|
25
|
+
this.line.anchor.set(0.5);
|
|
26
|
+
this.line.position.set(style.x, style.y);
|
|
27
|
+
this.addChild(this.box, this.line);
|
|
28
|
+
}
|
|
29
|
+
update(t: number, enabled = true) {
|
|
30
|
+
const cue = enabled ? this.captions.find((caption) => t >= caption.start && t < caption.end) : undefined;
|
|
31
|
+
this.visible = Boolean(cue);
|
|
32
|
+
if (!cue) return;
|
|
33
|
+
if (cue.text !== this.current) {
|
|
34
|
+
this.current = cue.text;
|
|
35
|
+
this.line.text = cue.text;
|
|
36
|
+
const { x, y, maxWidth = 860, radius = 22 } = this.style;
|
|
37
|
+
const w = Math.min(maxWidth + 100, this.line.width + 64), h = this.line.height + 30;
|
|
38
|
+
this.box.clear().roundRect(x - w / 2, y - h / 2, w, h, radius).fill({ color: this.style.background ?? 0x070d28, alpha: this.style.backgroundAlpha ?? 0.86 });
|
|
39
|
+
if (this.style.border !== undefined) this.box.roundRect(x - w / 2, y - h / 2, w, h, radius).stroke({ color: this.style.border, alpha: this.style.borderAlpha ?? 0.3, width: 2 });
|
|
40
|
+
}
|
|
41
|
+
// Back-to-back captions swap without a flash; isolated ones fade in and out.
|
|
42
|
+
const joinedBefore = this.captions.some((caption) => Math.abs(caption.end - cue.start) < 0.05);
|
|
43
|
+
const joinedAfter = this.captions.some((caption) => Math.abs(caption.start - cue.end) < 0.05);
|
|
44
|
+
this.alpha = (joinedBefore ? 1 : win(t, cue.start, 0.1)) * (joinedAfter ? 1 : 1 - win(t, cue.end - 0.1, 0.1));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Round status badge: an alert "!" that flips edge-on to a check mark. */
|
|
49
|
+
export class Badge extends Container {
|
|
50
|
+
private readonly alert = new Container();
|
|
51
|
+
private readonly ok = new Graphics();
|
|
52
|
+
constructor(readonly radius = 16, { alertColor = 0xe04a4a, alertText = 0xff8a8a, okColor = 0x15c783 } = {}) {
|
|
53
|
+
super();
|
|
54
|
+
this.alert.addChild(new Graphics().circle(0, 0, radius).fill({ color: alertColor, alpha: 0.18 }).circle(0, 0, radius).stroke({ color: alertColor, alpha: 0.75, width: 2 }));
|
|
55
|
+
const bang = txt("!", radius * 1.25, { weight: "700", fill: alertText });
|
|
56
|
+
bang.anchor.set(0.5, 0.52);
|
|
57
|
+
this.alert.addChild(bang);
|
|
58
|
+
this.ok.circle(0, 0, radius).fill({ color: okColor, alpha: 0.2 }).circle(0, 0, radius).stroke({ color: okColor, alpha: 0.9, width: 2 });
|
|
59
|
+
check(this.ok, 0, 0, radius * 0.95, okColor, Math.max(2.5, radius * 0.18));
|
|
60
|
+
this.addChild(this.alert, this.ok);
|
|
61
|
+
this.flip(0);
|
|
62
|
+
}
|
|
63
|
+
/** 0 = alert, 1 = ok. */
|
|
64
|
+
flip(progress: number) {
|
|
65
|
+
this.alert.visible = progress < 0.5;
|
|
66
|
+
this.ok.visible = progress >= 0.5;
|
|
67
|
+
this.scale.x = Math.max(0.04, Math.abs(Math.cos(Math.PI * clamp01(progress))));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type PillStyle = { color?: number; fill?: number; fillAlpha?: number; stroke?: number; strokeAlpha?: number; highlight?: number; padX?: number; padY?: number; family?: Family; weight?: Weight; left?: number };
|
|
72
|
+
|
|
73
|
+
/** A centred rounded label (chip). Fade `highlight.alpha` in to light it up; `left` reserves room for an icon. */
|
|
74
|
+
export class Pill extends Container {
|
|
75
|
+
readonly background = new Graphics();
|
|
76
|
+
readonly highlight = new Graphics();
|
|
77
|
+
readonly caption: Text;
|
|
78
|
+
readonly w: number;
|
|
79
|
+
readonly h: number;
|
|
80
|
+
constructor(text: string, size: number, style: PillStyle = {}) {
|
|
81
|
+
super();
|
|
82
|
+
this.caption = txt(text, size, { family: style.family ?? "mono", weight: style.weight ?? "500", fill: style.color ?? 0xedf1ff });
|
|
83
|
+
const padX = style.padX ?? 24, padY = style.padY ?? 14, left = style.left ?? 0;
|
|
84
|
+
this.w = this.caption.width + padX * 2 + left;
|
|
85
|
+
this.h = this.caption.height + padY * 2;
|
|
86
|
+
const draw = (g: Graphics, stroke: number, strokeAlpha: number, fill: number, fillAlpha: number) =>
|
|
87
|
+
g.roundRect(-this.w / 2, -this.h / 2, this.w, this.h, this.h / 2).fill({ color: fill, alpha: fillAlpha }).roundRect(-this.w / 2, -this.h / 2, this.w, this.h, this.h / 2).stroke({ color: stroke, alpha: strokeAlpha, width: 2 });
|
|
88
|
+
draw(this.background, style.stroke ?? 0x7282f2, style.strokeAlpha ?? 0.3, style.fill ?? 0x0a1030, style.fillAlpha ?? 0.92);
|
|
89
|
+
draw(this.highlight, style.highlight ?? 0x15c783, 0.95, style.highlight ?? 0x15c783, 0.14);
|
|
90
|
+
this.highlight.alpha = 0;
|
|
91
|
+
this.caption.anchor.set(0.5);
|
|
92
|
+
this.caption.position.set(left / 2, 1);
|
|
93
|
+
this.addChild(this.background, this.highlight, this.caption);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Container, Graphics, Sprite } from "pixi.js";
|
|
2
|
+
import type { FilmBase } from "./film-base";
|
|
3
|
+
import { lerp, rng } from "./motion";
|
|
4
|
+
import { gradientFill } from "./text";
|
|
5
|
+
import { glowTexture, vignetteTexture } from "./textures";
|
|
6
|
+
|
|
7
|
+
export type DarkStageOptions = {
|
|
8
|
+
/** Vertical background gradient, top to bottom. */
|
|
9
|
+
background?: string[];
|
|
10
|
+
/** Brand primary and a second accent for the two drifting glows. */
|
|
11
|
+
primary?: number; accent?: number;
|
|
12
|
+
ink?: number; grid?: number; motes?: number;
|
|
13
|
+
/** 0–1 extra glow over time, e.g. a bloom on the music drop: t => bump(win(t, drop - 0.1, 2.6)). */
|
|
14
|
+
pulse?: (t: number) => number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Dark Tech Stage backdrop: deep gradient, a slowly scrolling hairline grid, two drifting brand glows, a centre bloom
|
|
19
|
+
* driven by `pulse`, seeded dust motes and a vignette. Adds itself under film.world and returns its layers.
|
|
20
|
+
*/
|
|
21
|
+
export function darkStage(film: FilmBase, { background = ["#0C1438", "#071036", "#050A22"], primary = 0x7282f2, accent = 0xb45fc4, ink = 0xedf1ff, grid = 72, motes = 70, pulse = () => 0 }: DarkStageOptions = {}) {
|
|
22
|
+
const { width: W, height: H } = film;
|
|
23
|
+
const root = new Container();
|
|
24
|
+
const base = new Graphics().rect(0, 0, W, H).fill(gradientFill(background, "vertical"));
|
|
25
|
+
const lines = new Graphics();
|
|
26
|
+
for (let x = -grid; x <= W + grid; x += grid) lines.moveTo(x, -grid).lineTo(x, H + grid);
|
|
27
|
+
for (let y = -grid; y <= H + grid; y += grid) lines.moveTo(-grid, y).lineTo(W + grid, y);
|
|
28
|
+
lines.stroke({ color: ink, alpha: 0.035, width: 1 });
|
|
29
|
+
const size = Math.max(W, H);
|
|
30
|
+
const glowA = new Sprite(glowTexture(primary)), glowB = new Sprite(glowTexture(accent)), bloom = new Sprite(glowTexture(primary));
|
|
31
|
+
for (const [glow, scale] of [[glowA, 0.78], [glowB, 0.68], [bloom, 0.58]] as const) { glow.anchor.set(0.5); glow.width = glow.height = size * scale; }
|
|
32
|
+
const dust = new Graphics();
|
|
33
|
+
const random = rng(7);
|
|
34
|
+
const specks = Array.from({ length: motes }, () => ({ x: random() * W, y: random() * H, s: 1 + random() * 2.2, v: 6 + random() * 16, p: random() * 6.28 }));
|
|
35
|
+
const vignette = new Sprite(vignetteTexture(W, H));
|
|
36
|
+
vignette.width = W; vignette.height = H;
|
|
37
|
+
root.addChild(base, glowA, glowB, bloom, lines, dust, vignette);
|
|
38
|
+
film.world.addChildAt(root, 0);
|
|
39
|
+
film.on((t) => {
|
|
40
|
+
const lift = pulse(t);
|
|
41
|
+
lines.y = (t * 7) % grid;
|
|
42
|
+
glowA.position.set(W * 0.8 + 50 * Math.sin(t * 0.21), H * 0.22 + 60 * Math.cos(t * 0.17));
|
|
43
|
+
glowA.alpha = 0.2 + 0.08 * lift;
|
|
44
|
+
glowB.position.set(W * 0.16 + 60 * Math.cos(t * 0.19), H * 0.77 + 50 * Math.sin(t * 0.23));
|
|
45
|
+
glowB.alpha = 0.14 + 0.06 * lift;
|
|
46
|
+
bloom.position.set(W / 2, lerp(H * 0.6, H * 0.47, lift));
|
|
47
|
+
bloom.alpha = 0.05 + 0.3 * lift;
|
|
48
|
+
dust.clear();
|
|
49
|
+
for (const speck of specks) {
|
|
50
|
+
const y = (((speck.y - t * speck.v) % H) + H) % H;
|
|
51
|
+
dust.circle(speck.x + 8 * Math.sin(t * 0.5 + speck.p), y, speck.s).fill({ color: ink, alpha: 0.1 + 0.14 * (0.5 + 0.5 * Math.sin(t * 1.3 + speck.p)) });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return { root, base, grid: lines, glows: [glowA, glowB, bloom], dust, vignette };
|
|
55
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { AbstractSplitText, Container, Text, type Application } from "pixi.js";
|
|
2
|
+
import type { FilmConfig, FilmContext, TextBox } from "../player-types";
|
|
3
|
+
import type { ResourceTrack } from "../timeline-model";
|
|
4
|
+
import { CaptionBar, type CaptionStyle } from "./components";
|
|
5
|
+
import { safeArea } from "./layout";
|
|
6
|
+
import type { Caption } from "./timing";
|
|
7
|
+
|
|
8
|
+
export type Update = (t: number) => void;
|
|
9
|
+
export type Cue = { id: string; time: number; label: string };
|
|
10
|
+
|
|
11
|
+
/** Visible, opaque text with its bounds in film pixels, rendered font size and role; npm run review and npm run frames lint these. */
|
|
12
|
+
export function collectTexts(root: Container): TextBox[] {
|
|
13
|
+
const result: TextBox[] = [];
|
|
14
|
+
let index = 0;
|
|
15
|
+
const visit = (node: Container, visible: boolean, alpha: number, glyphs?: TextBox[]) => {
|
|
16
|
+
const shown = visible && node.visible, opacity = alpha * node.alpha;
|
|
17
|
+
if (node instanceof AbstractSplitText) {
|
|
18
|
+
// One string whose glyphs move on their own: report what is drawn as one box, so tight tracking is not an overlap.
|
|
19
|
+
const id = `text-${index++}`, drawn: TextBox[] = [];
|
|
20
|
+
for (const child of node.children) visit(child as Container, shown, opacity, drawn);
|
|
21
|
+
if (!drawn.length || !node.text.trim()) return;
|
|
22
|
+
const x = Math.min(...drawn.map((box) => box.x)), y = Math.min(...drawn.map((box) => box.y));
|
|
23
|
+
const sizes = drawn.flatMap((box) => box.fontSize ? [box.fontSize] : []);
|
|
24
|
+
result.push({ id, text: node.text, x, y, width: Math.max(...drawn.map((box) => box.x + box.width)) - x, height: Math.max(...drawn.map((box) => box.y + box.height)) - y, ...(sizes.length ? { fontSize: Math.min(...sizes) } : {}) });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (node instanceof Text) {
|
|
28
|
+
const id = `text-${index++}`;
|
|
29
|
+
if (shown && opacity >= 0.95 && node.text.trim()) {
|
|
30
|
+
const bounds = node.getBounds(), matrix = node.worldTransform;
|
|
31
|
+
const fontSize = typeof node.style.fontSize === "number" ? Math.round(node.style.fontSize * Math.hypot(matrix.c, matrix.d) * 10) / 10 : undefined;
|
|
32
|
+
(glyphs ?? result).push({ id, text: node.text, x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, ...(fontSize ? { fontSize } : {}), ...(node.label === "caption" ? { role: "caption" } : {}) });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const child of node.children) visit(child as Container, shown, opacity, glyphs);
|
|
36
|
+
};
|
|
37
|
+
visit(root, true, 1);
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type FilmBaseOptions = {
|
|
42
|
+
config: FilmConfig;
|
|
43
|
+
chapters: { start: number; end: number; label: string }[];
|
|
44
|
+
/** Coordinates the scenes are written in; the stage scales them to config.width × config.height. Defaults to the config size. */
|
|
45
|
+
design?: { width: number; height: number };
|
|
46
|
+
/** Burned-in captions (for example timing.json captions); omit for none. */
|
|
47
|
+
captions?: Caption[];
|
|
48
|
+
captionStyle?: Partial<CaptionStyle>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The player contract (stage, resources, captions, texts, cues, render) so a film only adds scenes.
|
|
53
|
+
* Put scenes in this.world from build() (or from scene modules given the film), and register per-frame updates with
|
|
54
|
+
* film.on(t => …); every update is a pure function of t.
|
|
55
|
+
*/
|
|
56
|
+
export abstract class FilmBase {
|
|
57
|
+
readonly stage = new Container();
|
|
58
|
+
/** Scene layer under the caption bar. */
|
|
59
|
+
readonly world = new Container();
|
|
60
|
+
resources: ResourceTrack[] = [];
|
|
61
|
+
captions = true;
|
|
62
|
+
/** Design size, in the coordinates scenes use. */
|
|
63
|
+
readonly width: number;
|
|
64
|
+
readonly height: number;
|
|
65
|
+
protected readonly updates: Update[] = [];
|
|
66
|
+
protected readonly markers: Cue[] = [];
|
|
67
|
+
protected readonly captionBar: CaptionBar | null;
|
|
68
|
+
private readonly ready: Promise<void>;
|
|
69
|
+
|
|
70
|
+
constructor(protected readonly app: Application, protected readonly context: FilmContext | undefined, protected readonly options: FilmBaseOptions) {
|
|
71
|
+
const { config } = options;
|
|
72
|
+
this.width = options.design?.width ?? config.width;
|
|
73
|
+
this.height = options.design?.height ?? config.height;
|
|
74
|
+
app.stage.addChild(this.stage);
|
|
75
|
+
this.stage.scale.set(config.width / this.width, config.height / this.height);
|
|
76
|
+
this.stage.addChild(this.world);
|
|
77
|
+
const safe = safeArea(config), toDesign = this.height / config.height, short = Math.min(config.width, config.height);
|
|
78
|
+
this.captionBar = options.captions?.length ? new CaptionBar(options.captions, {
|
|
79
|
+
x: safe.cx * toDesign, y: (safe.bottom - 0.06 * short) * toDesign, maxWidth: safe.width * 0.92 * toDesign, size: Math.round(0.035 * short * toDesign), ...options.captionStyle,
|
|
80
|
+
}) : null;
|
|
81
|
+
// Deferred so subclass fields exist before build() runs.
|
|
82
|
+
this.ready = Promise.resolve().then(() => this.build()).then(() => { if (this.captionBar) this.stage.addChild(this.captionBar); });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Load what the film needs (await fonts, textures), add scenes to this.world and register updates. Runs once. */
|
|
86
|
+
protected abstract build(): void | Promise<void>;
|
|
87
|
+
/** Registers a per-frame update; scene modules call film.on(t => …). */
|
|
88
|
+
on(update: Update) { this.updates.push(update); }
|
|
89
|
+
/** Adds a named review cue, such as a music hit the picture should land on. */
|
|
90
|
+
mark(time: number, label: string, id = `marker-${this.markers.length}`) { this.markers.push({ id, time, label }); }
|
|
91
|
+
|
|
92
|
+
get cues(): Cue[] {
|
|
93
|
+
return [
|
|
94
|
+
...this.options.chapters.map((chapter, index) => ({ id: `chapter-${index}`, time: chapter.start, label: chapter.label })),
|
|
95
|
+
...(this.options.captions ?? []).map((caption, index) => ({ id: `vo-${index}`, time: caption.start, label: caption.text })),
|
|
96
|
+
...this.markers,
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
get texts() { return collectTexts(this.stage); }
|
|
100
|
+
|
|
101
|
+
async render(seconds: number) {
|
|
102
|
+
await this.ready;
|
|
103
|
+
const t = Math.min(Math.max(seconds, 0), this.options.config.duration - 0.00001);
|
|
104
|
+
for (const update of this.updates) update(t);
|
|
105
|
+
this.captionBar?.update(t, this.captions);
|
|
106
|
+
this.app.render();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Font files the film loads itself. Variable fonts get their weight range, so 800 renders as 800 instead of a synthesized bold. */
|
|
2
|
+
export type FontSource = { family: string; url: string; weight?: string; style?: string };
|
|
3
|
+
|
|
4
|
+
/** Reads the font's table directory for an `fvar` table (TrueType/OpenType and WOFF 1). WOFF2 falls back to the file name. */
|
|
5
|
+
export async function isVariableFont(url: string, bytes?: ArrayBuffer) {
|
|
6
|
+
try {
|
|
7
|
+
const data = new DataView(bytes ?? await (await fetch(url)).arrayBuffer());
|
|
8
|
+
const signature = data.getUint32(0);
|
|
9
|
+
if (signature === 0x774f4632) return /variable|vf\b|\[wght/i.test(url);
|
|
10
|
+
const woff = signature === 0x774f4646;
|
|
11
|
+
const tables = data.getUint16(woff ? 12 : 4);
|
|
12
|
+
const first = woff ? 44 : 12, size = woff ? 20 : 16;
|
|
13
|
+
for (let index = 0; index < tables; index++) if (data.getUint32(first + index * size) === 0x66766172) return true;
|
|
14
|
+
return false;
|
|
15
|
+
} catch { return /variable|vf\b|\[wght/i.test(url); }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function loadFont({ family, url, weight, style }: FontSource) {
|
|
19
|
+
const bytes = await (await fetch(url)).arrayBuffer();
|
|
20
|
+
const range = weight ?? (await isVariableFont(url, bytes) ? "100 900" : undefined);
|
|
21
|
+
const face = new FontFace(family, bytes, { ...(range ? { weight: range } : {}), ...(style ? { style } : {}) });
|
|
22
|
+
document.fonts.add(await face.load());
|
|
23
|
+
return face;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function loadFonts(sources: FontSource[]) {
|
|
27
|
+
await Promise.all(sources.map(loadFont));
|
|
28
|
+
await document.fonts.ready;
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Shared film kit: import from "./kit" (standalone) or "../../../runtime/kit" (workspace animation). */
|
|
2
|
+
export * from "./motion";
|
|
3
|
+
export * from "./fonts";
|
|
4
|
+
export * from "./text";
|
|
5
|
+
export * from "./shapes";
|
|
6
|
+
export * from "./textures";
|
|
7
|
+
export * from "./components";
|
|
8
|
+
export * from "./timing";
|
|
9
|
+
export * from "./layout";
|
|
10
|
+
export * from "./film-base";
|
|
11
|
+
export * from "./dark-stage";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Container } from "pixi.js";
|
|
2
|
+
import safeAreas from "../../shared/safe-areas.json";
|
|
3
|
+
|
|
4
|
+
/** Margins in film pixels that platform UI may cover. Set "safeArea" in film.config.json; npm run review checks text against it. */
|
|
5
|
+
export type Insets = { top: number; right: number; bottom: number; left: number };
|
|
6
|
+
/** Insets, or a preset name from shared/safe-areas.json ("reels", "none"). */
|
|
7
|
+
export type SafeAreaSetting = Insets | string | null | undefined;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Presets are conservative starting points defined for a 1080-pixel-wide vertical film and scaled to the film's width.
|
|
11
|
+
* "reels": Instagram Reels, with the profile and caption at the bottom and actions on the right. Check current platform guidance.
|
|
12
|
+
*/
|
|
13
|
+
export const SAFE_AREA_PRESETS: Record<string, Insets> = safeAreas.presets;
|
|
14
|
+
|
|
15
|
+
export function safeInsets(config: { width: number; safeArea?: SafeAreaSetting }): Insets {
|
|
16
|
+
if (!config.safeArea) return SAFE_AREA_PRESETS.none;
|
|
17
|
+
if (typeof config.safeArea !== "string") return config.safeArea;
|
|
18
|
+
const preset = SAFE_AREA_PRESETS[config.safeArea], scale = config.width / safeAreas.referenceWidth;
|
|
19
|
+
if (!preset) throw new Error(`Unknown safeArea "${config.safeArea}" in film.config.json. Use ${Object.keys(SAFE_AREA_PRESETS).join(", ")} or { top, right, bottom, left } insets.`);
|
|
20
|
+
return { top: preset.top * scale, right: preset.right * scale, bottom: preset.bottom * scale, left: preset.left * scale };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The usable rectangle of a film: config.safeArea (insets or a preset name), else the full frame. */
|
|
24
|
+
export function safeArea(config: { width: number; height: number; safeArea?: SafeAreaSetting }) {
|
|
25
|
+
const insets = safeInsets(config);
|
|
26
|
+
const x = insets.left, y = insets.top, width = config.width - insets.left - insets.right, height = config.height - insets.top - insets.bottom;
|
|
27
|
+
return { x, y, width, height, right: x + width, bottom: y + height, cx: x + width / 2, cy: y + height / 2, insets };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Stacks nodes vertically from `y`, setting each anchor to the alignment; returns the y below the last one. */
|
|
31
|
+
export function stack(nodes: Container[], { x, y, gap = 0, align = "center" }: { x: number; y: number; gap?: number; align?: "left" | "center" | "right" }) {
|
|
32
|
+
let cursor = y;
|
|
33
|
+
for (const node of nodes) {
|
|
34
|
+
const anchor = (node as Container & { anchor?: { set(x: number, y: number): void } }).anchor;
|
|
35
|
+
anchor?.set(align === "left" ? 0 : align === "right" ? 1 : 0.5, 0);
|
|
36
|
+
node.position.set(x, cursor);
|
|
37
|
+
cursor += node.height + gap;
|
|
38
|
+
}
|
|
39
|
+
return cursor - gap;
|
|
40
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Container, Text } from "pixi.js";
|
|
2
|
+
|
|
3
|
+
/** Absolute-time helpers: every value is a pure function of film seconds, so seeking and export stay deterministic. */
|
|
4
|
+
export const clamp01 = (value: number) => Math.max(0, Math.min(1, value));
|
|
5
|
+
/** 0 → 1 progress of the window that starts `at` seconds and lasts `duration`. */
|
|
6
|
+
export const win = (t: number, at: number, duration: number) => clamp01((t - at) / duration);
|
|
7
|
+
export const easeOut = (x: number) => 1 - (1 - clamp01(x)) ** 3;
|
|
8
|
+
export const easeIn = (x: number) => clamp01(x) ** 3;
|
|
9
|
+
export const easeInOut = (x: number) => { x = clamp01(x); return x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2; };
|
|
10
|
+
/** Overshoots slightly before settling; for pops and snaps. */
|
|
11
|
+
export const easeBack = (x: number) => { x = clamp01(x); const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * (x - 1) ** 3 + c1 * (x - 1) ** 2; };
|
|
12
|
+
/** 0 → 1 → 0 over the unit interval; for flashes and pulses. */
|
|
13
|
+
export const bump = (x: number) => (x <= 0 || x >= 1 ? 0 : Math.sin(Math.PI * x));
|
|
14
|
+
export const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
15
|
+
/** Eases in at `at` and out at `out`; multiply alpha or offsets by it. */
|
|
16
|
+
export const inOut = (t: number, at: number, out = Infinity, inSeconds = 0.45, outSeconds = 0.3) => easeOut(win(t, at, inSeconds)) * (1 - easeIn(win(t, out, outSeconds)));
|
|
17
|
+
/** Linear blend of two 0xRRGGBB colors. */
|
|
18
|
+
export function mixColor(a: number, b: number, t: number) {
|
|
19
|
+
t = clamp01(t);
|
|
20
|
+
const channel = (shift: number) => Math.round(((a >> shift) & 255) + ((((b >> shift) & 255) - ((a >> shift) & 255)) * t));
|
|
21
|
+
return (channel(16) << 16) | (channel(8) << 8) | channel(0);
|
|
22
|
+
}
|
|
23
|
+
/** "#7282F2" → 0x7282f2, for brand.json colors. */
|
|
24
|
+
export const hex = (color: string) => Number.parseInt(color.replace(/^#/, ""), 16);
|
|
25
|
+
/** Seeded PRNG (mulberry32). Never call Math.random() while rendering a frame. */
|
|
26
|
+
export function rng(seed: number) {
|
|
27
|
+
return () => {
|
|
28
|
+
seed = (seed + 0x6d2b79f5) | 0;
|
|
29
|
+
let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
30
|
+
r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
|
|
31
|
+
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Sets alpha and hides fully transparent nodes, so review only measures text that is actually visible. */
|
|
35
|
+
export const show = (node: Container, alpha: number) => { node.alpha = alpha; node.visible = alpha > 0.002; };
|
|
36
|
+
/** Avoids re-rasterizing a Text whose content did not change. */
|
|
37
|
+
export const setText = (node: Text, value: string) => { if (node.text !== value) node.text = value; };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Graphics } from "pixi.js";
|
|
2
|
+
import { clamp01, lerp } from "./motion";
|
|
3
|
+
|
|
4
|
+
export type Point = [number, number];
|
|
5
|
+
export type PanelStyle = { radius?: number; fill?: number; fillAlpha?: number; stroke?: number; strokeAlpha?: number; strokeWidth?: number };
|
|
6
|
+
|
|
7
|
+
/** Rounded card with an optional hairline border, drawn from (0, 0). */
|
|
8
|
+
export function panel(width: number, height: number, { radius = 22, fill = 0x0c1437, fillAlpha = 0.9, stroke, strokeAlpha = 0.22, strokeWidth = 2 }: PanelStyle = {}) {
|
|
9
|
+
const g = new Graphics().roundRect(0, 0, width, height, radius).fill({ color: fill, alpha: fillAlpha });
|
|
10
|
+
if (stroke !== undefined) g.roundRect(0, 0, width, height, radius).stroke({ color: stroke, alpha: strokeAlpha, width: strokeWidth });
|
|
11
|
+
return g;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Strokes the first `amount` (0–1) of a polyline's length and returns the drawn points. */
|
|
15
|
+
export function partialLine(g: Graphics, points: Point[], amount: number) {
|
|
16
|
+
if (points.length < 2 || amount <= 0) return [] as Point[];
|
|
17
|
+
let total = 0;
|
|
18
|
+
for (let i = 1; i < points.length; i++) total += Math.hypot(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1]);
|
|
19
|
+
let left = total * clamp01(amount);
|
|
20
|
+
const out: Point[] = [points[0]];
|
|
21
|
+
for (let i = 1; i < points.length && left > 0; i++) {
|
|
22
|
+
const [ax, ay] = points[i - 1], [bx, by] = points[i];
|
|
23
|
+
const length = Math.hypot(bx - ax, by - ay);
|
|
24
|
+
if (left >= length) { out.push(points[i]); left -= length; } else { out.push([lerp(ax, bx, left / length), lerp(ay, by, left / length)]); left = 0; }
|
|
25
|
+
}
|
|
26
|
+
g.moveTo(...out[0]);
|
|
27
|
+
for (const point of out.slice(1)) g.lineTo(...point);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Samples a cubic Bézier into a polyline for partialLine() and pointAt(). */
|
|
32
|
+
export function bezier(p0: Point, p1: Point, p2: Point, p3: Point, steps = 36) {
|
|
33
|
+
const points: Point[] = [];
|
|
34
|
+
for (let i = 0; i <= steps; i++) {
|
|
35
|
+
const u = i / steps, v = 1 - u;
|
|
36
|
+
points.push([v * v * v * p0[0] + 3 * v * v * u * p1[0] + 3 * v * u * u * p2[0] + u * u * u * p3[0], v * v * v * p0[1] + 3 * v * v * u * p1[1] + 3 * v * u * u * p2[1] + u * u * u * p3[1]]);
|
|
37
|
+
}
|
|
38
|
+
return points;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The point `fraction` (0–1) of the way along a polyline; for packets travelling along a wire. */
|
|
42
|
+
export function pointAt(points: Point[], fraction: number): Point {
|
|
43
|
+
let total = 0;
|
|
44
|
+
const lengths = points.slice(1).map((point, i) => { const length = Math.hypot(point[0] - points[i][0], point[1] - points[i][1]); total += length; return length; });
|
|
45
|
+
let left = total * clamp01(fraction);
|
|
46
|
+
for (let i = 0; i < lengths.length; i++) {
|
|
47
|
+
if (left <= lengths[i]) return lengths[i] ? [lerp(points[i][0], points[i + 1][0], left / lengths[i]), lerp(points[i][1], points[i + 1][1], left / lengths[i])] : points[i];
|
|
48
|
+
left -= lengths[i];
|
|
49
|
+
}
|
|
50
|
+
return points[points.length - 1];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A check mark centred on (x, y) that draws on with `amount`. */
|
|
54
|
+
export function check(g: Graphics, x: number, y: number, size: number, color: number, width = 4, amount = 1) {
|
|
55
|
+
partialLine(g, [[x - size * 0.5, y], [x - size * 0.12, y + size * 0.38], [x + size * 0.55, y - size * 0.4]], amount);
|
|
56
|
+
return g.stroke({ width, color, cap: "round", join: "round" });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function cross(g: Graphics, x: number, y: number, size: number, color: number, width = 4, alpha = 1) {
|
|
60
|
+
return g.moveTo(x - size, y - size).lineTo(x + size, y + size).moveTo(x + size, y - size).lineTo(x - size, y + size).stroke({ width, color, alpha, cap: "round" });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A simple head-and-shoulders person icon. */
|
|
64
|
+
export function person(g: Graphics, x: number, y: number, size: number, color: number, alpha = 1) {
|
|
65
|
+
g.circle(x, y - size * 0.3, size * 0.25).fill({ color, alpha });
|
|
66
|
+
return g.moveTo(x - size * 0.46, y + size * 0.48).quadraticCurveTo(x - size * 0.46, y + size * 0.04, x, y + size * 0.04)
|
|
67
|
+
.quadraticCurveTo(x + size * 0.46, y + size * 0.04, x + size * 0.46, y + size * 0.48).closePath().fill({ color, alpha });
|
|
68
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { FillGradient, Text, type TextStyleOptions } from "pixi.js";
|
|
2
|
+
import type { Branding } from "../player-types";
|
|
3
|
+
|
|
4
|
+
export type Family = "heading" | "body" | "mono";
|
|
5
|
+
export type Weight = "300" | "400" | "500" | "600" | "700" | "800" | "900";
|
|
6
|
+
export type TextOptions = { family?: Family; weight?: Weight; fill?: TextStyleOptions["fill"] } & Partial<TextStyleOptions>;
|
|
7
|
+
|
|
8
|
+
/** Font stacks for txt(). useBrandFonts() puts the brand's families first. */
|
|
9
|
+
export const fontStacks: Record<Family, string[]> = { heading: ["Arial", "sans-serif"], body: ["Arial", "sans-serif"], mono: ["Menlo", "Consolas", "monospace"] };
|
|
10
|
+
|
|
11
|
+
/** Use the brand's heading/body families (already loaded by the player) and optionally a mono family the film loads itself. */
|
|
12
|
+
export function useBrandFonts(branding?: Branding, mono?: string) {
|
|
13
|
+
const stack = (family: string | undefined, fallback: string[]) => family ? [...family.split(",").map((part) => part.trim().replace(/^["']|["']$/g, "")).filter(Boolean), ...fallback] : fallback;
|
|
14
|
+
if (branding) {
|
|
15
|
+
fontStacks.heading = stack(branding.fonts.heading.family, ["system-ui", "sans-serif"]);
|
|
16
|
+
fontStacks.body = stack(branding.fonts.body.family, ["system-ui", "sans-serif"]);
|
|
17
|
+
}
|
|
18
|
+
if (mono) fontStacks.mono = stack(mono, ["Menlo", "Consolas", "monospace"]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Text in film pixels. Headings get slightly tight tracking at display sizes, mono gets open tracking,
|
|
23
|
+
* and line height follows the family. Every option is a Pixi TextStyle override.
|
|
24
|
+
*/
|
|
25
|
+
export function txt(value: string, size: number, { family = "body", weight = "400", fill = 0xffffff, ...style }: TextOptions = {}) {
|
|
26
|
+
const letterSpacing = family === "mono" ? Math.round(size * 0.12) : family === "heading" && size >= 56 ? -Math.round(size * 0.028) : 0;
|
|
27
|
+
return new Text({ text: value, style: { fontFamily: fontStacks[family], fontSize: size, fontWeight: weight, fill, letterSpacing, lineHeight: Math.round(size * (family === "heading" ? 1.1 : 1.3)), ...style } });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A left-to-right (or top-to-bottom) gradient for text or shape fills, e.g. gradientFill(["#B45FC4", "#5566E8"]). */
|
|
31
|
+
export function gradientFill(colors: string[], direction: "horizontal" | "vertical" = "horizontal") {
|
|
32
|
+
return new FillGradient({ start: { x: 0, y: 0 }, end: direction === "horizontal" ? { x: 1, y: 0 } : { x: 0, y: 1 },
|
|
33
|
+
colorStops: colors.map((color, index) => ({ offset: colors.length === 1 ? 0 : index / (colors.length - 1), color })) });
|
|
34
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Texture } from "pixi.js";
|
|
2
|
+
|
|
3
|
+
const rgb = (color: number) => `${(color >> 16) & 255},${(color >> 8) & 255},${color & 255}`;
|
|
4
|
+
function canvasTexture(width: number, height: number, draw: (context: CanvasRenderingContext2D) => void) {
|
|
5
|
+
const canvas = document.createElement("canvas");
|
|
6
|
+
canvas.width = width; canvas.height = height;
|
|
7
|
+
draw(canvas.getContext("2d")!);
|
|
8
|
+
return Texture.from(canvas);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Soft radial light; scale the sprite up and animate alpha for ambient glows and blooms. */
|
|
12
|
+
export function glowTexture(color: number, size = 512) {
|
|
13
|
+
return canvasTexture(size, size, (context) => {
|
|
14
|
+
const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
|
15
|
+
gradient.addColorStop(0, `rgba(${rgb(color)},1)`); gradient.addColorStop(0.35, `rgba(${rgb(color)},0.42)`); gradient.addColorStop(1, `rgba(${rgb(color)},0)`);
|
|
16
|
+
context.fillStyle = gradient; context.fillRect(0, 0, size, size);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Darkened edges at half resolution; stretch the sprite to the film size. */
|
|
21
|
+
export function vignetteTexture(width: number, height: number, color = 0x04081a, strength = 0.85) {
|
|
22
|
+
const w = Math.round(width / 2), h = Math.round(height / 2);
|
|
23
|
+
return canvasTexture(w, h, (context) => {
|
|
24
|
+
const gradient = context.createRadialGradient(w / 2, h * 0.46, Math.min(w, h) * 0.33, w / 2, h / 2, Math.max(w, h) * 0.73);
|
|
25
|
+
gradient.addColorStop(0, `rgba(${rgb(color)},0)`); gradient.addColorStop(1, `rgba(${rgb(color)},${strength})`);
|
|
26
|
+
context.fillStyle = gradient; context.fillRect(0, 0, w, h);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Rasterizes an SVG (e.g. a brand logo) at an exact pixel width, keeping its viewBox aspect ratio. */
|
|
31
|
+
export async function rasterSvg(url: string, width: number) {
|
|
32
|
+
const source = await (await fetch(url)).text();
|
|
33
|
+
const box = /viewBox="([\d.\s,-]+)"/.exec(source)?.[1].trim().split(/[\s,]+/).map(Number);
|
|
34
|
+
const image = new Image();
|
|
35
|
+
const aspect = box && box[2] > 0 ? box[3] / box[2] : null;
|
|
36
|
+
const height = Math.max(1, Math.round(width * (aspect ?? 1)));
|
|
37
|
+
const tag = /<svg\b[^>]*>/.exec(source)?.[0];
|
|
38
|
+
const sized = tag ? source.replace(tag, tag.replace(/\s(width|height)="[^"]*"/g, "").replace(/<svg\b/, `<svg width="${width}" height="${height}"`)) : source;
|
|
39
|
+
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(sized)}`;
|
|
40
|
+
await image.decode();
|
|
41
|
+
return canvasTexture(width, height, (context) => context.drawImage(image, 0, 0, width, height));
|
|
42
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Cue lookup over production/audio/timing.json (written by npm run audio:vo), so scenes follow the voiceover when it is re-cut. */
|
|
2
|
+
export type Caption = { start: number; end: number; text: string; segment?: string };
|
|
3
|
+
export type Timing = { captions: Caption[]; segments?: { id: string; start: number; end: number }[]; voEnd?: number };
|
|
4
|
+
|
|
5
|
+
export function cueSheet(timing: Timing) {
|
|
6
|
+
const bySegment = new Map<string, Caption[]>();
|
|
7
|
+
for (const caption of timing.captions) {
|
|
8
|
+
if (!caption.segment) continue;
|
|
9
|
+
bySegment.set(caption.segment, [...bySegment.get(caption.segment) ?? [], caption]);
|
|
10
|
+
}
|
|
11
|
+
const caption = (segment: string, index = 0) => {
|
|
12
|
+
const list = bySegment.get(segment);
|
|
13
|
+
const found = list?.at(index);
|
|
14
|
+
if (!found) throw new Error(`No caption ${index} in VO segment "${segment}". Known segments: ${[...bySegment.keys()].join(", ")}`);
|
|
15
|
+
return found;
|
|
16
|
+
};
|
|
17
|
+
return {
|
|
18
|
+
caption,
|
|
19
|
+
/** Start of the segment's index-th caption (negative counts from the end), plus an offset in seconds. */
|
|
20
|
+
at: (segment: string, index = 0, offset = 0) => caption(segment, index).start + offset,
|
|
21
|
+
/** End of the segment's index-th caption; the last one by default. */
|
|
22
|
+
end: (segment: string, index = -1, offset = 0) => caption(segment, index).end + offset,
|
|
23
|
+
segment: (id: string) => {
|
|
24
|
+
const found = timing.segments?.find((item) => item.id === id);
|
|
25
|
+
if (found) return found;
|
|
26
|
+
const list = bySegment.get(id);
|
|
27
|
+
if (!list?.length) throw new Error(`Unknown VO segment "${id}".`);
|
|
28
|
+
return { id, start: list[0].start, end: list.at(-1)!.end };
|
|
29
|
+
},
|
|
30
|
+
/** The first caption whose text contains `words` (case-insensitive); handy when phrases move between segments. */
|
|
31
|
+
find: (words: string) => {
|
|
32
|
+
const found = timing.captions.find((item) => item.text.toLowerCase().includes(words.toLowerCase()));
|
|
33
|
+
if (!found) throw new Error(`No caption contains "${words}".`);
|
|
34
|
+
return found;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Joins captions separated by less than `maxGap` seconds so on-screen captions don't flicker between phrases. */
|
|
40
|
+
export function joinCaptions(captions: Caption[], maxGap = 0.3) {
|
|
41
|
+
const sorted = [...captions].sort((a, b) => a.start - b.start);
|
|
42
|
+
return sorted.map((caption, index) => {
|
|
43
|
+
const next = sorted[index + 1];
|
|
44
|
+
return next && next.start - caption.end < maxGap && next.start > caption.end ? { ...caption, end: next.start } : { ...caption };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type CueSheet = ReturnType<typeof cueSheet>;
|
|
@@ -6,7 +6,13 @@ export type FilmConfig = {
|
|
|
6
6
|
duration: number; language: string; audio: string | null;
|
|
7
7
|
/** Seconds of the frame the MP4 export saves as its JPEG thumbnail. */
|
|
8
8
|
poster?: number | null;
|
|
9
|
+
/** Platform UI margins: "reels", "none", or { top, right, bottom, left } in pixels. Review flags text outside it. */
|
|
10
|
+
safeArea?: { top: number; right: number; bottom: number; left: number } | string | null;
|
|
11
|
+
/** Smallest readable font size in pixels for review; defaults to 2.2% of the shorter side (24 px at 1080). */
|
|
12
|
+
minTextSize?: number;
|
|
9
13
|
};
|
|
14
|
+
/** A visible text box in film pixels; fontSize and role come from the kit's collectTexts(). */
|
|
15
|
+
export type TextBox = { id: string; text: string; x: number; y: number; width: number; height: number; fontSize?: number; role?: string };
|
|
10
16
|
export type Branding = {
|
|
11
17
|
name: string; tagline: string; logo: string | null; assets?: Record<string, string>;
|
|
12
18
|
colors: { primary: string; background: string; text: string };
|
|
@@ -15,7 +21,7 @@ export type Branding = {
|
|
|
15
21
|
export type FilmContext = { branding: Branding; logo: Texture | null };
|
|
16
22
|
export type FilmRenderer = {
|
|
17
23
|
captions: boolean; resources: ResourceTrack[];
|
|
18
|
-
texts:
|
|
24
|
+
texts: TextBox[];
|
|
19
25
|
cues: unknown[];
|
|
20
26
|
render(seconds: number): void | Promise<void>;
|
|
21
27
|
};
|
|
@@ -3,6 +3,7 @@ import type { PlayerOptions } from "./player-types";
|
|
|
3
3
|
import copy from "../shared/locales/en/player.json";
|
|
4
4
|
import { publicAsset } from "./assets";
|
|
5
5
|
import { downloadHtml, downloadUrl } from "./download";
|
|
6
|
+
import { loadFonts } from "./kit/fonts";
|
|
6
7
|
import type { ResourceTimeline } from "./resource-timeline";
|
|
7
8
|
import "./style.css";
|
|
8
9
|
|
|
@@ -55,11 +56,8 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
55
56
|
app.canvas.setAttribute("aria-label", config.title);
|
|
56
57
|
element("canvas-host").append(app.canvas);
|
|
57
58
|
element("loading").remove();
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (font.file) { const face = new FontFace(font.family, `url(${JSON.stringify(publicAsset(font.file))})`); document.fonts.add(await face.load()); }
|
|
61
|
-
}
|
|
62
|
-
}
|
|
59
|
+
// Variable brand fonts are registered with their weight range, so bold weights are real rather than synthesized.
|
|
60
|
+
if (branding) await loadFonts(Object.values(branding.fonts).filter((font) => font.file).map((font) => ({ family: font.family.split(",")[0].trim().replace(/^["']|["']$/g, ""), url: publicAsset(font.file!) })));
|
|
63
61
|
const logo = branding?.logo ? await Assets.load(publicAsset(branding.logo)) : null;
|
|
64
62
|
const film = new Film(app, branding ? { branding, logo } : undefined);
|
|
65
63
|
const audio = config.audio ? new Audio() : null;
|