@olegkoval/agent-skills 1.60.0 → 1.62.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/.github/copilot-instructions.md +2 -0
- package/.github/prompts/carousel-maker.prompt.md +102 -0
- package/.github/prompts/shorts-animator.prompt.md +103 -0
- package/.kiro/steering/carousel-maker.md +101 -0
- package/.kiro/steering/shorts-animator.md +102 -0
- package/.windsurf/rules/carousel-maker.md +100 -0
- package/.windsurf/rules/shorts-animator.md +101 -0
- package/README.md +4 -2
- package/adapters/claude/olko-creative/skills/carousel-maker/SKILL.md +112 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/SKILL.md +113 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/references/kit.md +59 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/references/looks.md +32 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/references/writing.md +9 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +30 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/context-gathering.md +3 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/finding-state.mjs +207 -0
- package/adapters/codex/olko-creative/README.md +2 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/SKILL.md +112 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/SKILL.md +113 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/references/kit.md +59 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/references/looks.md +32 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/references/writing.md +9 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/SKILL.md +112 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/SKILL.md +113 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/references/kit.md +59 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/references/looks.md +32 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/references/writing.md +9 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
- package/adapters/hermes/olko-creative/README.md +2 -0
- package/catalog/skills.json +46 -0
- package/package.json +1 -1
- package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-creative/skills/carousel-maker/SKILL.md +111 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
- package/plugins/olko-creative/skills/shorts-animator/SKILL.md +112 -0
- package/plugins/olko-creative/skills/shorts-animator/references/kit.md +59 -0
- package/plugins/olko-creative/skills/shorts-animator/references/looks.md +32 -0
- package/plugins/olko-creative/skills/shorts-animator/references/writing.md +9 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
- package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +30 -0
- package/plugins/olko-github-pr/skills/lekker-review/references/context-gathering.md +3 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/finding-state.mjs +207 -0
- package/plugins/olko-github-pr/skills/lekker-review/workflow.js +46 -3
- package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
- package/scripts/lib/catalog.mjs +2 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// `SA check`: catch the defects a render would show, before paying for one. Each rule is a pure
|
|
2
|
+
// function over plain facts; the edge gathers the facts (scene probes, kit and look vocabularies).
|
|
3
|
+
import { words } from "./captions.ts";
|
|
4
|
+
import { isMap } from "./schema.ts";
|
|
5
|
+
import type { JsonMap, Short, Timeline } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export type Call = { readonly fn: string; readonly opts: JsonMap };
|
|
8
|
+
export type Probe = { readonly scene: string; readonly calls: readonly Call[]; readonly error?: string };
|
|
9
|
+
export type Facts = {
|
|
10
|
+
readonly short: Short;
|
|
11
|
+
readonly timeline: Timeline;
|
|
12
|
+
readonly probes: readonly Probe[];
|
|
13
|
+
readonly kit: readonly string[];
|
|
14
|
+
readonly lookKeys: readonly string[];
|
|
15
|
+
};
|
|
16
|
+
export type Finding = { readonly level: "error" | "warn"; readonly where: string; readonly msg: string };
|
|
17
|
+
|
|
18
|
+
export const MOVES = ["push", "pull", "pan-left", "pan-right", "rise", "fall", "drift", "still"];
|
|
19
|
+
export const TRANSITIONS = ["cut", "fade", "dip"];
|
|
20
|
+
const LOOK_META = ["base", "palette", "captions", "about"];
|
|
21
|
+
const WORDS = { min: 4, max: 12 };
|
|
22
|
+
const SECONDS = { min: 20, max: 45 };
|
|
23
|
+
const BAND = 0.04;
|
|
24
|
+
|
|
25
|
+
const err = (where: string, msg: string): Finding => ({ level: "error", where, msg });
|
|
26
|
+
const warn = (where: string, msg: string): Finding => ({ level: "warn", where, msg });
|
|
27
|
+
const num = (o: JsonMap, k: string, d: number): number => {
|
|
28
|
+
const v = o[k];
|
|
29
|
+
return typeof v === "number" ? v : d;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const unknownScenes = ({ timeline, probes }: Facts): readonly Finding[] => {
|
|
33
|
+
const known = new Set(probes.map((p) => p.scene));
|
|
34
|
+
return timeline.beats
|
|
35
|
+
.filter((b) => !known.has(b.scene))
|
|
36
|
+
.map((b) => err(`lines[${b.line}]`, `no scene "${b.scene}" in scenes.js (renders as a red frame)`));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const unknownMoves = ({ timeline }: Facts): readonly Finding[] =>
|
|
40
|
+
timeline.beats.flatMap((b) => [
|
|
41
|
+
...(MOVES.includes(b.move) ? [] : [warn(`lines[${b.line}]`, `move "${b.move}" is not one of ${MOVES.join(", ")}; it renders as still`)]),
|
|
42
|
+
...(TRANSITIONS.includes(b.transition) ? [] : [warn(`lines[${b.line}]`, `transition "${b.transition}" is not one of ${TRANSITIONS.join(", ")}; it renders as a cut`)]),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const unknownLookKeys = ({ short, lookKeys }: Facts): readonly Finding[] => {
|
|
46
|
+
if (!isMap(short.look)) return [];
|
|
47
|
+
const known = new Set([...lookKeys, ...LOOK_META]);
|
|
48
|
+
return Object.keys(short.look)
|
|
49
|
+
.filter((k) => !known.has(k))
|
|
50
|
+
.map((k) => warn("look", `"${k}" is not a look setting; it is ignored`));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const lineLengths = ({ short }: Facts): readonly Finding[] =>
|
|
54
|
+
short.lines.flatMap((l, i) => {
|
|
55
|
+
const n = words(l.say).length;
|
|
56
|
+
return n < WORDS.min || n > WORDS.max ? [warn(`lines[${i}]`, `${n} words; lines of ${WORDS.min} to ${WORDS.max} read best`)] : [];
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const totalLength = ({ timeline: { duration } }: Facts): readonly Finding[] =>
|
|
60
|
+
duration < SECONDS.min || duration > SECONDS.max
|
|
61
|
+
? [warn("short", `${duration.toFixed(1)}s long; ${SECONDS.min} to ${SECONDS.max}s holds attention best`)]
|
|
62
|
+
: [];
|
|
63
|
+
|
|
64
|
+
const sceneErrors = ({ probes }: Facts): readonly Finding[] =>
|
|
65
|
+
probes.flatMap((p) => (p.error === undefined ? [] : [err(`scene "${p.scene}"`, `throws: ${p.error}`)]));
|
|
66
|
+
|
|
67
|
+
const unknownKit = ({ probes, kit }: Facts): readonly Finding[] => {
|
|
68
|
+
const known = new Set(kit);
|
|
69
|
+
return probes.flatMap((p) =>
|
|
70
|
+
[...new Set(p.calls.map((c) => c.fn))].filter((fn) => !known.has(fn)).map((fn) => err(`scene "${p.scene}"`, `k.${fn} is not in the kit`)),
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** A figure whose body spans the caption line gets text drawn across it. */
|
|
75
|
+
const crossesBand = (c: Call, y: number): boolean => {
|
|
76
|
+
if (c.fn !== "person") return false;
|
|
77
|
+
const feet = num(c.opts, "y", 0.85);
|
|
78
|
+
const head = feet - num(c.opts, "h", 0.3) * num(c.opts, "scale", 1);
|
|
79
|
+
return head < y + BAND && feet > y - BAND;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const captionOverlap = ({ short, timeline, probes }: Facts): readonly Finding[] => {
|
|
83
|
+
if (short.captions.style === "none" || timeline.track.length === 0) return [];
|
|
84
|
+
const y = num(short.captions, "y", 0.775);
|
|
85
|
+
const used = new Set(timeline.beats.map((b) => b.scene));
|
|
86
|
+
return probes
|
|
87
|
+
.filter((p) => used.has(p.scene) && p.calls.some((c) => crossesBand(c, y)))
|
|
88
|
+
.map((p) => warn(`scene "${p.scene}"`, `a person crosses the caption line at y=${y}; raise them, shrink them, or set captions.y`));
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const RULES = [sceneErrors, unknownScenes, unknownKit, unknownMoves, unknownLookKeys, captionOverlap, lineLengths, totalLength];
|
|
92
|
+
|
|
93
|
+
export const check = (facts: Facts): readonly Finding[] => RULES.flatMap((rule) => rule(facts));
|
|
94
|
+
|
|
95
|
+
export const report = (fs: readonly Finding[]): string =>
|
|
96
|
+
fs.length === 0 ? "check: clean" : fs.map((f) => `${f.level === "error" ? "ERROR" : "warn "} ${f.where}: ${f.msg}`).join("\n");
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { isMap } from "./schema.ts";
|
|
2
|
+
import type { Json, JsonMap, Result, Short, Timeline } from "./types.ts";
|
|
3
|
+
import { fail, ok } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export type Named = (name: string) => Result<JsonMap>;
|
|
6
|
+
|
|
7
|
+
const sub = (o: JsonMap, k: string): JsonMap => {
|
|
8
|
+
const v = o[k];
|
|
9
|
+
return isMap(v) ? v : {};
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** A look is a name from looks/, or an object with an optional `base` name plus overrides. */
|
|
13
|
+
export function resolveLook(spec: Json, named: Named): Result<JsonMap> {
|
|
14
|
+
if (typeof spec === "string") return named(spec || "oil");
|
|
15
|
+
if (!isMap(spec)) return spec === null ? named("oil") : fail("look must be a name or an object");
|
|
16
|
+
const base = typeof spec.base === "string" ? named(spec.base) : ok<JsonMap>({});
|
|
17
|
+
if (!base.ok) return base;
|
|
18
|
+
return ok({ ...base.value, ...spec, palette: { ...sub(base.value, "palette"), ...sub(spec, "palette") } });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What the page reads as window.SHORT: the timeline, the look with the short's palette over it, the captions. */
|
|
22
|
+
export const pageState = (s: Short, tl: Timeline, look: JsonMap) => ({
|
|
23
|
+
...tl,
|
|
24
|
+
look: { ...look, ...(s.palette ? { palette: { ...sub(look, "palette"), ...s.palette } } : {}) },
|
|
25
|
+
captions: { ...sub(look, "captions"), ...s.captions },
|
|
26
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// `SA set dir key.path=value`: an immutable path update over plain JSON.
|
|
2
|
+
import { isJson, isMap } from "./schema.ts";
|
|
3
|
+
import type { Json, Result } from "./types.ts";
|
|
4
|
+
import { fail, ok } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export type Assignment = { readonly path: readonly string[]; readonly value: Json };
|
|
7
|
+
|
|
8
|
+
const parseValue = (raw: string): Json => {
|
|
9
|
+
try {
|
|
10
|
+
const v: unknown = JSON.parse(raw);
|
|
11
|
+
return isJson(v) ? v : raw;
|
|
12
|
+
} catch {
|
|
13
|
+
return raw;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** "lines.0.say=Hello" becomes a path and a value; values are JSON when they parse, text otherwise. */
|
|
18
|
+
export function parseAssignment(arg: string): Result<Assignment> {
|
|
19
|
+
const i = arg.indexOf("=");
|
|
20
|
+
if (i <= 0) return fail(`"${arg}" is not key=value (e.g. look=watercolor, lines.0.hold=0.8)`);
|
|
21
|
+
const path = arg.slice(0, i).split(".");
|
|
22
|
+
if (path.some((k) => k === "")) return fail(`"${arg.slice(0, i)}" has an empty key`);
|
|
23
|
+
return ok({ path, value: parseValue(arg.slice(i + 1)) });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const isIndex = (k: string): boolean => /^\d+$/.test(k);
|
|
27
|
+
|
|
28
|
+
export function setPath(doc: Json | undefined, path: readonly string[], value: Json): Json {
|
|
29
|
+
const [k, ...rest] = path;
|
|
30
|
+
if (k === undefined) return value;
|
|
31
|
+
if (Array.isArray(doc) && isIndex(k)) {
|
|
32
|
+
const i = Number(k);
|
|
33
|
+
return Array.from({ length: Math.max(doc.length, i + 1) }, (_, j): Json => (j === i ? setPath(doc[j], rest, value) : (doc[j] ?? null)));
|
|
34
|
+
}
|
|
35
|
+
const base = isMap(doc) ? doc : {};
|
|
36
|
+
const next = rest.length && base[k] === undefined && isIndex(rest[0] ?? "") ? [] : base[k];
|
|
37
|
+
return { ...base, [k]: setPath(next, rest, value) };
|
|
38
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// short.json arrives as unknown JSON; this is the one place it is narrowed into a Short.
|
|
2
|
+
import type { Json, JsonMap, Line, Music, Result, Short, Shot, Voice } from "./types.ts";
|
|
3
|
+
import { fail, ok } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export const isMap = (x: unknown): x is JsonMap => typeof x === "object" && x !== null && !Array.isArray(x);
|
|
6
|
+
export const isJson = (x: unknown): x is Json =>
|
|
7
|
+
x === null || ["boolean", "number", "string"].includes(typeof x) || (Array.isArray(x) ? x.every(isJson) : isMap(x) && Object.values(x).every(isJson));
|
|
8
|
+
|
|
9
|
+
const str = (o: JsonMap, k: string): string | undefined => (typeof o[k] === "string" ? String(o[k]) : undefined);
|
|
10
|
+
const num = (o: JsonMap, k: string): number | undefined => {
|
|
11
|
+
const v = o[k];
|
|
12
|
+
return typeof v === "number" ? v : undefined;
|
|
13
|
+
};
|
|
14
|
+
const map = (o: JsonMap, k: string): JsonMap | undefined => {
|
|
15
|
+
const v = o[k];
|
|
16
|
+
return isMap(v) ? v : undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const shotOf = (x: Json): Shot | undefined => {
|
|
20
|
+
if (typeof x === "string") return { scene: x };
|
|
21
|
+
if (!isMap(x)) return undefined;
|
|
22
|
+
const scene = str(x, "scene");
|
|
23
|
+
return scene === undefined
|
|
24
|
+
? undefined
|
|
25
|
+
: { scene, move: str(x, "move"), amount: num(x, "amount"), seed: num(x, "seed"), transition: str(x, "transition") };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const shotsOf = (x: Json | undefined): readonly Shot[] | undefined => {
|
|
29
|
+
const list = Array.isArray(x) ? x : x === undefined ? [] : [x];
|
|
30
|
+
const shots = list.map(shotOf);
|
|
31
|
+
return shots.length > 0 && shots.every((s) => s !== undefined) ? shots.filter((s) => s !== undefined) : undefined;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const lineOf = (x: Json, i: number): Result<Line> => {
|
|
35
|
+
if (!isMap(x)) return fail(`lines[${i}] is not an object`);
|
|
36
|
+
const say = typeof x.say === "string" || typeof x.say === "number" ? String(x.say) : undefined;
|
|
37
|
+
const shots = shotsOf(x.scene);
|
|
38
|
+
if (say === undefined) return fail(`lines[${i}] has no "say"`);
|
|
39
|
+
if (shots === undefined) return fail(`lines[${i}] needs "scene": a name, {scene, ...}, or a list of them`);
|
|
40
|
+
return ok({
|
|
41
|
+
say, shots, speak: str(x, "speak"), caption: str(x, "caption"), move: str(x, "move"), amount: num(x, "amount"),
|
|
42
|
+
seed: num(x, "seed"), hold: num(x, "hold"), transition: str(x, "transition"),
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_VOICE = { kind: "kokoro", id: "bm_george", speed: 0.92 } satisfies Voice;
|
|
47
|
+
|
|
48
|
+
const voiceOf = (x: Json | undefined): Voice => {
|
|
49
|
+
if (x === "none") return { kind: "none" };
|
|
50
|
+
if (typeof x === "string") return { ...DEFAULT_VOICE, id: x };
|
|
51
|
+
if (!isMap(x)) return DEFAULT_VOICE;
|
|
52
|
+
const file = str(x, "file");
|
|
53
|
+
if (str(x, "id") === "none") return { kind: "none" };
|
|
54
|
+
if (file !== undefined) return { kind: "file", file };
|
|
55
|
+
return { kind: "kokoro", id: str(x, "id") ?? DEFAULT_VOICE.id, speed: num(x, "speed") ?? DEFAULT_VOICE.speed };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const musicOf = (x: Json | undefined): Music =>
|
|
59
|
+
typeof x === "string"
|
|
60
|
+
? { mood: x }
|
|
61
|
+
: isMap(x)
|
|
62
|
+
? { mood: str(x, "mood") ?? "tender", volume: num(x, "volume"), seed: num(x, "seed"), start: num(x, "start") }
|
|
63
|
+
: { mood: "tender" };
|
|
64
|
+
|
|
65
|
+
const collect = <T>(rs: readonly Result<T>[]): Result<readonly T[]> => {
|
|
66
|
+
const errors = rs.flatMap((r) => (r.ok ? [] : r.errors));
|
|
67
|
+
return errors.length ? fail(...errors) : ok(rs.flatMap((r) => (r.ok ? [r.value] : [])));
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export function parseShort(raw: unknown): Result<Short> {
|
|
71
|
+
if (!isMap(raw)) return fail("short.json is not a JSON object");
|
|
72
|
+
const lines = Array.isArray(raw.lines) ? collect(raw.lines.map(lineOf)) : fail<readonly Line[]>("short.json has no lines");
|
|
73
|
+
if (!lines.ok) return lines;
|
|
74
|
+
if (lines.value.length === 0) return fail("short.json has no lines");
|
|
75
|
+
return ok({
|
|
76
|
+
title: str(raw, "title") ?? "short", format: str(raw, "format") ?? "9:16", fps: num(raw, "fps") || 24,
|
|
77
|
+
twos: num(raw, "twos") || 2, lead: num(raw, "lead") ?? 0.35, pause: num(raw, "pause") ?? 0.3, tail: num(raw, "tail") ?? 1.2,
|
|
78
|
+
transition: str(raw, "transition") ?? "cut", look: raw.look ?? "oil", palette: map(raw, "palette"),
|
|
79
|
+
captions: map(raw, "captions") ?? {}, voice: voiceOf(raw.voice), music: musicOf(raw.music), lines: lines.value,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// When every shot and caption is on screen. The arithmetic order is load-bearing: renders are compared by hash.
|
|
2
|
+
import { captionChunks } from "./captions.ts";
|
|
3
|
+
import type { Beat, Cue, Line, Result, Short, Take, Timeline } from "./types.ts";
|
|
4
|
+
import { fail, ok } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export const FORMATS: Readonly<Record<string, readonly [number, number]>> = {
|
|
7
|
+
"9:16": [1080, 1920],
|
|
8
|
+
"16:9": [1920, 1080],
|
|
9
|
+
"1:1": [1080, 1080],
|
|
10
|
+
"4:5": [1080, 1350],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type Span = { readonly start: number; readonly speak: number; readonly end: number };
|
|
14
|
+
|
|
15
|
+
const spanOf = (s: Short, i: number, at: number, take: Take): Span => {
|
|
16
|
+
const line = s.lines[i];
|
|
17
|
+
const speak = at + (i === 0 ? s.lead : 0.05);
|
|
18
|
+
const end = speak + take.dur + s.pause + (line?.hold || 0);
|
|
19
|
+
return { start: at, speak, end: i === s.lines.length - 1 ? end + s.tail : end };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Each caption chunk starts in proportion to the characters before it, across the spoken part. */
|
|
23
|
+
const chunkTimes = (cs: readonly string[], span: Span, take: Take): readonly number[] => {
|
|
24
|
+
const total = cs.reduce((a, c) => a + c.length + 4, 0);
|
|
25
|
+
const before = cs.map((_, j) => cs.slice(0, j).reduce((a, c) => a + (c.length + 4), 0));
|
|
26
|
+
return before.map((acc) => span.speak + (take.dur * acc) / total);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const cuesOf = (cs: readonly string[], times: readonly number[], span: Span): readonly Cue[] =>
|
|
30
|
+
cs.map((text, j) => ({ at: j === 0 ? span.start : (times[j] ?? 0), end: j === cs.length - 1 ? span.end : (times[j + 1] ?? 0), text }));
|
|
31
|
+
|
|
32
|
+
/** Up to as many shots as chunks: cut on a chunk. More shots than chunks: cut evenly through the line. */
|
|
33
|
+
const cutsOf = (n: number, times: readonly number[], span: Span): readonly number[] =>
|
|
34
|
+
Array.from({ length: n }, (_, j) =>
|
|
35
|
+
j === 0 ? span.start : n <= times.length ? (times[Math.floor((j * times.length) / n)] ?? 0) : span.speak + ((span.end - span.speak) * j) / n,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const beatsOf = (s: Short, i: number, line: Line, cuts: readonly number[], span: Span): readonly Beat[] =>
|
|
39
|
+
line.shots.map((shot, j) => {
|
|
40
|
+
const bs = cuts[j] ?? span.start;
|
|
41
|
+
const be = j === line.shots.length - 1 ? span.end : (cuts[j + 1] ?? span.end);
|
|
42
|
+
return {
|
|
43
|
+
line: i, scene: shot.scene, start: +bs.toFixed(4), dur: +(be - bs).toFixed(4),
|
|
44
|
+
move: shot.move ?? line.move ?? "push", moveAmount: shot.amount ?? line.amount, seed: shot.seed ?? line.seed,
|
|
45
|
+
transition: j === 0 ? (line.transition ?? s.transition) : (shot.transition ?? "cut"), say: line.say,
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
type Acc = { readonly t: number; readonly beats: readonly Beat[]; readonly track: readonly Cue[]; readonly voiceAt: readonly number[] };
|
|
50
|
+
|
|
51
|
+
export function timeline(s: Short, takes: readonly Take[]): Result<Timeline> {
|
|
52
|
+
const size = FORMATS[s.format];
|
|
53
|
+
if (!size) return fail(`format ${s.format}? (${Object.keys(FORMATS).join(", ")})`);
|
|
54
|
+
if (takes.length !== s.lines.length) return fail(`${takes.length} takes for ${s.lines.length} lines`);
|
|
55
|
+
const maxWords = typeof s.captions.words === "number" ? s.captions.words : 5;
|
|
56
|
+
const acc = s.lines.reduce<Acc>(
|
|
57
|
+
(a, line, i) => {
|
|
58
|
+
const take = takes[i] ?? { file: null, dur: 0 };
|
|
59
|
+
const span = spanOf(s, i, a.t, take);
|
|
60
|
+
const cs = captionChunks(line, maxWords);
|
|
61
|
+
const times = chunkTimes(cs, span, take);
|
|
62
|
+
const cuts = cutsOf(line.shots.length, times, span);
|
|
63
|
+
return {
|
|
64
|
+
t: span.end, beats: [...a.beats, ...beatsOf(s, i, line, cuts, span)],
|
|
65
|
+
track: [...a.track, ...cuesOf(cs, times, span)], voiceAt: [...a.voiceAt, span.speak],
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
{ t: 0, beats: [], track: [], voiceAt: [] },
|
|
69
|
+
);
|
|
70
|
+
const [W, H] = size;
|
|
71
|
+
return ok({
|
|
72
|
+
W, H, fps: s.fps, twos: s.twos, duration: +acc.t.toFixed(3), beats: acc.beats,
|
|
73
|
+
track: s.captions.style === "none" ? [] : acc.track, voiceAt: acc.voiceAt,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The data a short is made of. Maps stay open: unknown keys pass through to the page untouched.
|
|
2
|
+
|
|
3
|
+
export type Json = null | boolean | number | string | readonly Json[] | { readonly [k: string]: Json };
|
|
4
|
+
export type JsonMap = { readonly [k: string]: Json };
|
|
5
|
+
|
|
6
|
+
export type Shot = {
|
|
7
|
+
readonly scene: string;
|
|
8
|
+
readonly move?: string;
|
|
9
|
+
readonly amount?: number;
|
|
10
|
+
readonly seed?: number;
|
|
11
|
+
readonly transition?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type Line = {
|
|
15
|
+
readonly say: string;
|
|
16
|
+
readonly speak?: string;
|
|
17
|
+
readonly caption?: string;
|
|
18
|
+
readonly shots: readonly Shot[];
|
|
19
|
+
readonly move?: string;
|
|
20
|
+
readonly amount?: number;
|
|
21
|
+
readonly seed?: number;
|
|
22
|
+
readonly hold?: number;
|
|
23
|
+
readonly transition?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type Voice =
|
|
27
|
+
| { readonly kind: "none" }
|
|
28
|
+
| { readonly kind: "file"; readonly file: string }
|
|
29
|
+
| { readonly kind: "kokoro"; readonly id: string; readonly speed: number };
|
|
30
|
+
|
|
31
|
+
export type Music = {
|
|
32
|
+
readonly mood: string;
|
|
33
|
+
readonly volume?: number;
|
|
34
|
+
readonly seed?: number;
|
|
35
|
+
readonly start?: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type Short = {
|
|
39
|
+
readonly title: string;
|
|
40
|
+
readonly format: string;
|
|
41
|
+
readonly fps: number;
|
|
42
|
+
readonly twos: number;
|
|
43
|
+
readonly lead: number;
|
|
44
|
+
readonly pause: number;
|
|
45
|
+
readonly tail: number;
|
|
46
|
+
readonly transition: string;
|
|
47
|
+
readonly look: Json;
|
|
48
|
+
readonly palette?: JsonMap;
|
|
49
|
+
readonly captions: JsonMap;
|
|
50
|
+
readonly voice: Voice;
|
|
51
|
+
readonly music: Music;
|
|
52
|
+
readonly lines: readonly Line[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** One narrated line as the timeline needs it: how long it speaks, and where its audio comes from. */
|
|
56
|
+
export type Take = {
|
|
57
|
+
readonly file: string | null;
|
|
58
|
+
readonly dur: number;
|
|
59
|
+
readonly from?: number;
|
|
60
|
+
readonly slice?: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type Beat = {
|
|
64
|
+
readonly line: number;
|
|
65
|
+
readonly scene: string;
|
|
66
|
+
readonly start: number;
|
|
67
|
+
readonly dur: number;
|
|
68
|
+
readonly move: string;
|
|
69
|
+
readonly moveAmount?: number;
|
|
70
|
+
readonly seed?: number;
|
|
71
|
+
readonly transition: string;
|
|
72
|
+
readonly say: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type Cue = { readonly at: number; readonly end: number; readonly text: string };
|
|
76
|
+
|
|
77
|
+
export type Timeline = {
|
|
78
|
+
readonly W: number;
|
|
79
|
+
readonly H: number;
|
|
80
|
+
readonly fps: number;
|
|
81
|
+
readonly twos: number;
|
|
82
|
+
readonly duration: number;
|
|
83
|
+
readonly beats: readonly Beat[];
|
|
84
|
+
readonly track: readonly Cue[];
|
|
85
|
+
readonly voiceAt: readonly number[];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type Result<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly errors: readonly string[] };
|
|
89
|
+
|
|
90
|
+
export const ok = <T>(value: T): Result<T> => ({ ok: true, value });
|
|
91
|
+
export const fail = <T>(...errors: readonly string[]): Result<T> => ({ ok: false, errors });
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// A headless Chrome driven over the DevTools protocol with Node's own WebSocket and fetch:
|
|
2
|
+
// no puppeteer, nothing to npm install.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { isMap } from "../domain/schema.ts";
|
|
8
|
+
import type { Json, JsonMap } from "../domain/types.ts";
|
|
9
|
+
|
|
10
|
+
export type Chrome = { readonly bin: string; readonly shell: boolean };
|
|
11
|
+
export type Page = {
|
|
12
|
+
readonly evaluate: (expression: string) => Promise<Json | undefined>;
|
|
13
|
+
readonly logs: () => readonly string[];
|
|
14
|
+
readonly close: () => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const SHELL = process.platform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell";
|
|
18
|
+
const SYSTEM: Readonly<Record<string, readonly string[]>> = {
|
|
19
|
+
darwin: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
|
|
20
|
+
linux: ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"],
|
|
21
|
+
win32: [
|
|
22
|
+
`${process.env["PROGRAMFILES"]}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
23
|
+
`${process.env["PROGRAMFILES(X86)"]}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
24
|
+
`${process.env["PROGRAMFILES(X86)"]}\\Microsoft\\Edge\\Application\\msedge.exe`,
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const dirs = (d: string): readonly string[] =>
|
|
29
|
+
fs.existsSync(d) ? fs.readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => path.join(d, e.name)) : [];
|
|
30
|
+
|
|
31
|
+
/** The headless shell HyperFrames downloads, newest first. */
|
|
32
|
+
const hyperframesShell = (): string | undefined =>
|
|
33
|
+
dirs(path.join(os.homedir(), ".cache/hyperframes/chrome/chrome-headless-shell"))
|
|
34
|
+
.toSorted()
|
|
35
|
+
.reverse()
|
|
36
|
+
.flatMap((v) => dirs(v).map((sub) => path.join(sub, SHELL)))
|
|
37
|
+
.find((f) => fs.existsSync(f));
|
|
38
|
+
|
|
39
|
+
export function findChrome(): Chrome | undefined {
|
|
40
|
+
const env = process.env.CHROME;
|
|
41
|
+
if (env && fs.existsSync(env)) return { bin: env, shell: /headless-shell/.test(env) };
|
|
42
|
+
const hf = hyperframesShell();
|
|
43
|
+
if (hf) return { bin: hf, shell: true };
|
|
44
|
+
const sys = (SYSTEM[process.platform] ?? []).find((c) => fs.existsSync(c));
|
|
45
|
+
return sys ? { bin: sys, shell: false } : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const flags = (c: Chrome, profile: string, width: number, height: number): readonly string[] => {
|
|
49
|
+
// the GPU through Metal on a Mac is ~15x faster than the software fallback
|
|
50
|
+
const angle = process.env.SHORTS_GL || (process.platform === "darwin" ? "metal" : "");
|
|
51
|
+
return [
|
|
52
|
+
"--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check",
|
|
53
|
+
"--allow-file-access-from-files", "--hide-scrollbars", "--mute-audio", "--enable-webgl", "--ignore-gpu-blocklist",
|
|
54
|
+
`--window-size=${width},${height}`, ...(c.shell ? [] : ["--headless=new"]), ...(angle ? [`--use-angle=${angle}`] : []), "about:blank",
|
|
55
|
+
];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const field = (o: Json | undefined, ...keys: readonly string[]): Json | undefined =>
|
|
59
|
+
keys.reduce<Json | undefined>((v, k) => (isMap(v) ? v[k] : undefined), o);
|
|
60
|
+
|
|
61
|
+
const parse = (data: unknown): JsonMap | undefined => {
|
|
62
|
+
try {
|
|
63
|
+
const v: unknown = typeof data === "string" ? JSON.parse(data) : undefined;
|
|
64
|
+
return isMap(v) ? v : undefined;
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** What the page printed: console calls and uncaught exceptions. */
|
|
71
|
+
const logLine = (m: JsonMap): string | undefined => {
|
|
72
|
+
if (m.method === "Runtime.consoleAPICalled") {
|
|
73
|
+
const args = field(m, "params", "args");
|
|
74
|
+
return Array.isArray(args) ? args.map((a) => String(field(a, "value") ?? field(a, "description") ?? "")).join(" ") : "";
|
|
75
|
+
}
|
|
76
|
+
if (m.method !== "Runtime.exceptionThrown") return undefined;
|
|
77
|
+
return "ERROR " + String(field(m, "params", "exceptionDetails", "exception", "description") ?? field(m, "params", "exceptionDetails", "text") ?? "");
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const devtoolsUrl = (proc: ReturnType<typeof spawn>): Promise<string> =>
|
|
81
|
+
new Promise((resolve, reject) => {
|
|
82
|
+
const seen: string[] = [];
|
|
83
|
+
const to = setTimeout(() => reject(new Error("Chrome didn't start:\n" + seen.join("").slice(-800))), 30000);
|
|
84
|
+
proc.stderr?.on("data", (d) => {
|
|
85
|
+
seen.push(String(d));
|
|
86
|
+
const m = seen.join("").match(/DevTools listening on (ws:\/\/\S+)/);
|
|
87
|
+
if (m?.[1]) { clearTimeout(to); resolve(m[1]); }
|
|
88
|
+
});
|
|
89
|
+
proc.on("exit", (c) => { clearTimeout(to); reject(new Error(`Chrome exited (${c}):\n` + seen.join("").slice(-800))); });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const pageSocketUrl = async (browserWs: string): Promise<string> => {
|
|
93
|
+
const list: unknown = await (await fetch(`http://127.0.0.1:${new URL(browserWs).port}/json/list`)).json();
|
|
94
|
+
const pg = Array.isArray(list) ? list.find((t) => isMap(t) && t.type === "page") : undefined;
|
|
95
|
+
const url = isMap(pg) ? pg.webSocketDebuggerUrl : undefined;
|
|
96
|
+
if (typeof url !== "string") throw new Error("Chrome opened no page");
|
|
97
|
+
return url;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
type Wire = { readonly send: (method: string, params?: JsonMap) => Promise<JsonMap>; readonly logs: () => readonly string[]; readonly ws: WebSocket };
|
|
101
|
+
|
|
102
|
+
/** The one stateful edge: replies waiting on an id, and the page's log. */
|
|
103
|
+
async function connect(url: string): Promise<Wire> {
|
|
104
|
+
const ws = new WebSocket(url);
|
|
105
|
+
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
|
|
106
|
+
const pending = new Map<number, (m: JsonMap) => void>();
|
|
107
|
+
const logs: string[] = [];
|
|
108
|
+
const ids = { next: 0 };
|
|
109
|
+
ws.onmessage = (ev) => {
|
|
110
|
+
const m = parse(ev.data);
|
|
111
|
+
if (!m) return;
|
|
112
|
+
const id = typeof m.id === "number" && pending.has(m.id) ? m.id : undefined;
|
|
113
|
+
if (id !== undefined) { const reply = pending.get(id); pending.delete(id); reply?.(m); return; }
|
|
114
|
+
const line = logLine(m);
|
|
115
|
+
if (line !== undefined) logs.push(line);
|
|
116
|
+
};
|
|
117
|
+
const send = (method: string, params: JsonMap = {}) =>
|
|
118
|
+
new Promise<JsonMap>((r) => {
|
|
119
|
+
const id = ++ids.next;
|
|
120
|
+
pending.set(id, r);
|
|
121
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
122
|
+
});
|
|
123
|
+
return { send, logs: () => logs, ws };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const evaluator = (send: Wire["send"]) => async (expression: string): Promise<Json | undefined> => {
|
|
127
|
+
const r = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
|
|
128
|
+
const ex = field(r, "result", "exceptionDetails");
|
|
129
|
+
if (ex !== undefined) throw new Error(String(field(ex, "exception", "description") ?? field(ex, "text")));
|
|
130
|
+
if (r.error !== undefined) throw new Error(String(field(r, "error", "message")));
|
|
131
|
+
return field(r, "result", "result", "value");
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
async function waitFor(evaluate: Page["evaluate"], expr: string, deadline: number, logs: () => readonly string[]): Promise<void> {
|
|
135
|
+
const ok = await evaluate(expr).catch(() => false);
|
|
136
|
+
if (ok === true) return;
|
|
137
|
+
if (Date.now() > deadline) throw new Error("The page didn't load:\n" + logs().join("\n"));
|
|
138
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
139
|
+
return waitFor(evaluate, expr, deadline, logs);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function openPage(url: string, width: number, height: number, readyWhen = "window.ready === true"): Promise<Page> {
|
|
143
|
+
const c = findChrome();
|
|
144
|
+
if (!c) throw new Error("No Chrome found. Install Google Chrome, run `npx --yes hyperframes@0.8.150 browser ensure`, or set CHROME=/path/to/chrome");
|
|
145
|
+
const profile = fs.mkdtempSync(path.join(os.tmpdir(), "shorts-chrome-"));
|
|
146
|
+
const proc = spawn(c.bin, flags(c, profile, width, height), { stdio: ["ignore", "ignore", "pipe"] });
|
|
147
|
+
const close = () => {
|
|
148
|
+
proc.kill("SIGKILL");
|
|
149
|
+
// A killed Chrome can still be flushing its profile, so retry, and leave it to the OS if it will not go.
|
|
150
|
+
try {
|
|
151
|
+
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
152
|
+
} catch {
|
|
153
|
+
console.error(`· could not remove the browser profile at ${profile}`);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
try {
|
|
157
|
+
const wire = await connect(await pageSocketUrl(await devtoolsUrl(proc)));
|
|
158
|
+
await wire.send("Runtime.enable");
|
|
159
|
+
await wire.send("Page.enable");
|
|
160
|
+
await wire.send("Emulation.setDeviceMetricsOverride", { width: Math.min(width, 1600), height: Math.min(height, 1600), deviceScaleFactor: 1, mobile: false });
|
|
161
|
+
await wire.send("Page.navigate", { url });
|
|
162
|
+
const evaluate = evaluator(wire.send);
|
|
163
|
+
await waitFor(evaluate, readyWhen, Date.now() + 30000, wire.logs);
|
|
164
|
+
return { evaluate, logs: wire.logs, close: () => { wire.ws.close(); close(); } };
|
|
165
|
+
} catch (e) {
|
|
166
|
+
close();
|
|
167
|
+
throw e;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// `SA doctor`: checks what the pipeline needs, sets up the narrator, and renders a 3-second test short.
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { findChrome } from "./chrome.ts";
|
|
7
|
+
import { has } from "./media.ts";
|
|
8
|
+
import { HYPERFRAMES, setupNarrator } from "./voice.ts";
|
|
9
|
+
|
|
10
|
+
const FFMPEG_HINT: Readonly<Record<string, string>> = { darwin: "brew install ffmpeg", win32: "winget install ffmpeg" };
|
|
11
|
+
const TEST_SCENE = `window.SCENES = { test: (k) => { k.sky({ stops: ["#3f8ad6", "#e6eef2"] }); k.clouds({}); k.ground({ y: 0.6, color: "#58a531", stripes: 12 }); k.person({ x: 0.5, y: 0.82, h: 0.26, pose: "wave" }); } };`;
|
|
12
|
+
|
|
13
|
+
type Check = { readonly ok: boolean; readonly msg: string };
|
|
14
|
+
const say = (c: Check): Check => (console.log(`${c.ok ? "ok" : "!!"} ${c.msg}`), c);
|
|
15
|
+
|
|
16
|
+
const node = (): Check => {
|
|
17
|
+
const v = process.versions.node;
|
|
18
|
+
const [major = 0, minor = 0] = v.split(".").map(Number);
|
|
19
|
+
// .ts runs without a flag from 22.18 (type stripping)
|
|
20
|
+
return major > 22 || (major === 22 && minor >= 18) ? { ok: true, msg: `Node ${v}` } : { ok: false, msg: `Node ${v} is too old: install Node 22.18+ from https://nodejs.org` };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const ffmpeg = (): Check =>
|
|
24
|
+
has("ffmpeg") ? { ok: true, msg: "ffmpeg" } : { ok: false, msg: `ffmpeg is missing: ${FFMPEG_HINT[process.platform] ?? "sudo apt install ffmpeg"}` };
|
|
25
|
+
|
|
26
|
+
const chrome = (): Check => {
|
|
27
|
+
const found = findChrome() ?? (console.log("· no Chrome found, fetching the headless one HyperFrames uses..."),
|
|
28
|
+
spawnSync(process.platform === "win32" ? "npx.cmd" : "npx", ["--yes", HYPERFRAMES, "browser", "ensure"], { stdio: "inherit" }), findChrome());
|
|
29
|
+
return found ? { ok: true, msg: `Chrome: ${found.bin}` } : { ok: false, msg: `no Chrome: install Google Chrome, or run npx --yes ${HYPERFRAMES} browser ensure` };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function testRender(cli: string, withVoice: boolean): Check {
|
|
33
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), "shorts-test-"));
|
|
34
|
+
fs.writeFileSync(path.join(work, "short.json"), JSON.stringify({
|
|
35
|
+
title: "test", look: "oil", voice: withVoice ? { id: "bm_george" } : "none", music: { mood: "tender" },
|
|
36
|
+
lines: [{ say: "It works.", scene: "test", move: "push" }], tail: 1,
|
|
37
|
+
}));
|
|
38
|
+
fs.writeFileSync(path.join(work, "scenes.js"), TEST_SCENE);
|
|
39
|
+
const r = spawnSync(process.execPath, [cli, "render", work], { stdio: "inherit" });
|
|
40
|
+
const mp4 = path.join(work, "test.mp4");
|
|
41
|
+
return r.status === 0 && fs.existsSync(mp4) ? { ok: true, msg: `test short: ${mp4}` } : { ok: false, msg: "the test render failed (see above)" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Returns the exit code. */
|
|
45
|
+
export async function doctor(cli: string): Promise<number> {
|
|
46
|
+
const basics = [node, ffmpeg, chrome].map((c) => say(c()));
|
|
47
|
+
if (basics.some((c) => !c.ok)) return 1;
|
|
48
|
+
const why = await setupNarrator();
|
|
49
|
+
const voice = say(why ? { ok: false, msg: why } : { ok: true, msg: "the narrator (Kokoro, runs on this computer)" });
|
|
50
|
+
const render = say(testRender(cli, voice.ok));
|
|
51
|
+
return voice.ok && render.ok ? 0 : 1;
|
|
52
|
+
}
|