summer-engine 4.3.0 → 4.5.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -1
- package/.codex-plugin/plugin.json +5 -3
- package/.cursor-plugin/plugin.json +4 -2
- package/.factory-plugin/plugin.json +4 -2
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +87 -0
- package/README.md +67 -27
- package/commands/start.md +19 -15
- package/commands/summer.md +1 -0
- package/dist/cli/command-usage.d.ts +3 -1
- package/dist/cli/command-usage.js +10 -4
- package/dist/cli/commands/create.d.ts +1 -25
- package/dist/cli/commands/create.js +10 -138
- package/dist/cli/commands/dev.d.ts +66 -0
- package/dist/cli/commands/dev.js +234 -0
- package/dist/cli/commands/install.d.ts +15 -58
- package/dist/cli/commands/install.js +31 -481
- package/dist/cli/commands/login.js +1 -1
- package/dist/cli/commands/ops.d.ts +17 -1
- package/dist/cli/commands/ops.js +97 -9
- package/dist/cli/commands/preview.d.ts +58 -0
- package/dist/cli/commands/preview.js +181 -0
- package/dist/cli/commands/run.d.ts +1 -17
- package/dist/cli/commands/run.js +17 -206
- package/dist/cli/index.js +5 -0
- package/dist/core/agent-host.d.ts +3 -0
- package/dist/core/agent-host.js +1 -1
- package/dist/core/api-client.d.ts +10 -2
- package/dist/core/api-client.js +13 -8
- package/dist/core/capabilities/agent-playbook.js +1 -0
- package/dist/core/capabilities/apps.d.ts +9 -9
- package/dist/core/capabilities/asset-import.d.ts +4 -1
- package/dist/core/capabilities/asset-import.js +15 -6
- package/dist/core/capabilities/capture-gameplay.js +1 -1
- package/dist/core/capabilities/connect-signal.d.ts +2 -2
- package/dist/core/capabilities/content-make/index.d.ts +81 -0
- package/dist/core/capabilities/content-make/index.js +706 -0
- package/dist/core/capabilities/content-make/media.d.ts +57 -0
- package/dist/core/capabilities/content-make/media.js +172 -0
- package/dist/core/capabilities/content-make/plan.d.ts +130 -0
- package/dist/core/capabilities/content-make/plan.js +433 -0
- package/dist/core/capabilities/content-make/render.d.ts +64 -0
- package/dist/core/capabilities/content-make/render.js +370 -0
- package/dist/core/capabilities/content-make/study.d.ts +13 -0
- package/dist/core/capabilities/content-make/study.js +165 -0
- package/dist/core/capabilities/content-make/types.d.ts +173 -0
- package/dist/core/capabilities/content-make/types.js +6 -0
- package/dist/core/capabilities/content-make/voice.d.ts +7 -0
- package/dist/core/capabilities/content-make/voice.js +27 -0
- package/dist/core/capabilities/events.d.ts +2 -2
- package/dist/core/capabilities/export-game.d.ts +4 -1
- package/dist/core/capabilities/export-game.js +4 -3
- package/dist/core/capabilities/export-presets.d.ts +2 -1
- package/dist/core/capabilities/export-presets.js +2 -1
- package/dist/core/capabilities/go-live.d.ts +14 -0
- package/dist/core/capabilities/go-live.js +408 -0
- package/dist/core/capabilities/image-generation.d.ts +6 -6
- package/dist/core/capabilities/item-manifest.d.ts +92 -0
- package/dist/core/capabilities/item-manifest.js +247 -0
- package/dist/core/capabilities/placement.d.ts +7 -7
- package/dist/core/capabilities/projects-local.d.ts +68 -0
- package/dist/core/capabilities/projects-local.js +172 -0
- package/dist/core/capabilities/publish-build.d.ts +13 -2
- package/dist/core/capabilities/publish-build.js +87 -21
- package/dist/core/capabilities/publish-download.js +1 -1
- package/dist/core/capabilities/runtime-control.d.ts +7 -2
- package/dist/core/capabilities/runtime-control.js +27 -1
- package/dist/core/capabilities/seeing/args.d.ts +2 -2
- package/dist/core/capabilities/store-checks.d.ts +45 -0
- package/dist/core/capabilities/store-checks.js +85 -0
- package/dist/core/capabilities/store-details.d.ts +37 -0
- package/dist/core/capabilities/store-details.js +94 -0
- package/dist/core/capabilities/summer-bundle.d.ts +21 -2
- package/dist/core/capabilities/summer-bundle.js +54 -7
- package/dist/core/capabilities/sync-items.d.ts +89 -0
- package/dist/core/capabilities/sync-items.js +352 -0
- package/dist/core/capabilities/tool-dispatch.js +66 -6
- package/dist/core/capabilities/video-ogv.d.ts +49 -0
- package/dist/core/capabilities/video-ogv.js +260 -0
- package/dist/core/capability-skew.js +2 -2
- package/dist/core/cli-usage-beacon.js +26 -2
- package/dist/core/cli-usage.d.ts +5 -2
- package/dist/core/cli-usage.js +8 -2
- package/dist/core/engine-install.d.ts +53 -0
- package/dist/core/engine-install.js +93 -0
- package/dist/core/engine-installer.d.ts +134 -0
- package/dist/core/engine-installer.js +760 -0
- package/dist/core/engine-launch.d.ts +126 -0
- package/dist/core/engine-launch.js +398 -0
- package/dist/core/engine-self-start.d.ts +63 -0
- package/dist/core/engine-self-start.js +282 -0
- package/dist/core/engine.d.ts +7 -1
- package/dist/core/engine.js +9 -2
- package/dist/core/error-codes.d.ts +4 -1
- package/dist/core/error-codes.js +7 -3
- package/dist/core/library-read.d.ts +2 -2
- package/dist/core/library-search.d.ts +2 -2
- package/dist/core/local-project.d.ts +50 -0
- package/dist/core/local-project.js +234 -0
- package/dist/core/ops/areas.js +9 -3
- package/dist/core/ops/catalog.d.ts +9 -0
- package/dist/core/ops/catalog.js +56 -4
- package/dist/core/ops/engine-answer.d.ts +4 -1
- package/dist/core/ops/engine-answer.js +16 -3
- package/dist/core/ops/engine.js +4 -4
- package/dist/core/ops/save-files.d.ts +6 -1
- package/dist/core/ops/save-files.js +67 -50
- package/dist/core/ops/search.js +9 -2
- package/dist/core/preview/input.d.ts +35 -0
- package/dist/core/preview/input.js +105 -0
- package/dist/core/preview/launch-config.d.ts +52 -0
- package/dist/core/preview/launch-config.js +137 -0
- package/dist/core/preview/page.d.ts +22 -0
- package/dist/core/preview/page.js +232 -0
- package/dist/core/preview/preview-source.d.ts +179 -0
- package/dist/core/preview/preview-source.js +350 -0
- package/dist/core/preview/server.d.ts +26 -0
- package/dist/core/preview/server.js +218 -0
- package/dist/core/telemetry.d.ts +20 -16
- package/dist/core/telemetry.js +92 -16
- package/dist/core/templates.d.ts +20 -0
- package/dist/core/templates.js +300 -3
- package/dist/core/tool-profiles.d.ts +2 -2
- package/dist/core/tool-profiles.js +1 -0
- package/dist/core/usage-fields.d.ts +41 -0
- package/dist/core/usage-fields.js +150 -0
- package/dist/core/util/sleep.d.ts +3 -0
- package/dist/core/util/sleep.js +12 -0
- package/dist/mcp/ops-surface.js +28 -2
- package/dist/mcp/server.d.ts +14 -4
- package/dist/mcp/server.js +42 -8
- package/dist/mcp/tool-surface.d.ts +8 -0
- package/dist/mcp/tool-surface.js +19 -2
- package/dist/mcp/tools/creator-tools.js +92 -14
- package/dist/mcp/tools/debug-tools.js +2 -0
- package/dist/mcp/tools/local-project-tools.d.ts +6 -0
- package/dist/mcp/tools/local-project-tools.js +33 -0
- package/dist/mcp/tools/navigation-tools.js +2 -1
- package/dist/mcp/tools/project-tools.js +5 -8
- package/dist/mcp/tools/seeing-tools.js +1 -1
- package/dist/mcp/tools/visual-tools.js +1 -1
- package/dist/mcp/tools/with-engine.js +1 -1
- package/gemini-extension.json +3 -1
- package/library/references/mcp-tools-reference/mcp-tools-reference.md +18 -5
- package/library/skills/README.md +12 -8
- package/library/skills/animated-loop/SKILL.md +10 -16
- package/library/skills/cinematic-cutscene/SKILL.md +7 -18
- package/library/skills/headless-scripting/SKILL.md +7 -4
- package/library/skills/in-game-purchases/SKILL.md +25 -8
- package/library/skills/in-game-purchases/resource.yaml +2 -1
- package/library/skills/launch-ready/SKILL.md +351 -0
- package/library/skills/launch-ready/resource.yaml +45 -0
- package/library/skills/make-content/SKILL.md +202 -0
- package/library/skills/make-content/resource.yaml +32 -0
- package/library/skills/make-game/SKILL.md +2 -0
- package/library/skills/make-game/resource.yaml +2 -1
- package/library/skills/navigate-summer/SKILL.md +4 -2
- package/library/skills/new-project/SKILL.md +15 -22
- package/library/skills/new-project/resource.yaml +2 -1
- package/library/skills/painted-25d-world/SKILL.md +366 -0
- package/library/skills/painted-25d-world/_building-blocks/blender-route.md +33 -0
- package/library/skills/painted-25d-world/_building-blocks/blockout_builder.gd +149 -0
- package/library/skills/painted-25d-world/_building-blocks/capture_paint_view.gd +32 -0
- package/library/skills/painted-25d-world/_building-blocks/paint_projection.gdshader +23 -0
- package/library/skills/painted-25d-world/_building-blocks/paintcam_check.gd +25 -0
- package/library/skills/painted-25d-world/_building-blocks/painted_door.gd +17 -0
- package/library/skills/painted-25d-world/_building-blocks/painted_location.gd +56 -0
- package/library/skills/painted-25d-world/_building-blocks/painted_player.gd +125 -0
- package/library/skills/painted-25d-world/_building-blocks/painted_world.gd +111 -0
- package/library/skills/painted-25d-world/_building-blocks/tools/cam.py +57 -0
- package/library/skills/painted-25d-world/_building-blocks/tools/detail4k.py +81 -0
- package/library/skills/painted-25d-world/_building-blocks/tools/overlay.py +17 -0
- package/library/skills/painted-25d-world/_building-blocks/tools/preview.py +60 -0
- package/library/skills/painted-25d-world/resource.yaml +39 -0
- package/library/skills/publish-your-game/SKILL.md +13 -14
- package/library/skills/publish-your-game/resource.yaml +4 -3
- package/library/skills/release-checklist/SKILL.md +188 -0
- package/library/skills/release-checklist/resource.yaml +40 -0
- package/library/skills/release-readiness/SKILL.md +1 -1
- package/library/skills/release-readiness/resource.yaml +1 -1
- package/library/skills/store-art/SKILL.md +1 -1
- package/library/skills/store-art/resource.yaml +1 -1
- package/library/skills/store-listing/SKILL.md +1 -1
- package/library/skills/store-listing/resource.yaml +1 -1
- package/library/skills/summer-cli/SKILL.md +2 -0
- package/library/skills/summer-store/SKILL.md +295 -39
- package/library/skills/summer-store/resource.yaml +9 -3
- package/library/skills/trailer-shot/SKILL.md +3 -3
- package/library/templates/README.md +5 -0
- package/library/tools/content-make/resource.yaml +132 -0
- package/library/tools/frame-shot/GUIDE.md +1 -1
- package/library/tools/go-live/resource.yaml +67 -0
- package/library/tools/import-from-url/GUIDE.md +1 -0
- package/library/tools/import-from-url-batch/GUIDE.md +1 -1
- package/library/tools/play/GUIDE.md +2 -0
- package/library/tools/projects-create-local/resource.yaml +55 -0
- package/library/tools/projects-open-local/resource.yaml +51 -0
- package/library/tools/publish-build/resource.yaml +3 -3
- package/library/tools/screenshot/GUIDE.md +1 -1
- package/library/tools/store-set-details/resource.yaml +114 -0
- package/library/tools/sync-items/resource.yaml +72 -0
- package/package.json +1 -1
- package/registry/generated/aliases.json +6 -0
- package/registry/generated/counts.json +3 -3
- package/registry/generated/gemini-extension.json +3 -1
- package/registry/generated/index.json +463 -37
- package/registry/generated/marketplace.claude.json +1 -1
- package/registry/generated/plugin.claude-directory.json +4 -2
- package/registry/generated/plugin.claude.json +3 -1
- package/registry/generated/plugin.codex.json +5 -3
- package/registry/generated/plugin.cursor.json +4 -2
- package/registry/generated/plugin.factory.json +4 -2
- package/registry/generated/server.json +2 -2
- package/registry/generated/skills-index.md +12 -8
- package/registry/generated/skills-registry.json +62 -5
- package/registry/operations/legacy-names.json +40 -0
- package/registry/operations/public-catalog.json +10144 -387
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The judgment content.make makes without a model, all of it deterministic:
|
|
3
|
+
* which reference fits today, how the reference's captions look, which
|
|
4
|
+
* clip window plays each reference shot, where the cuts land under our
|
|
5
|
+
* voice, and whether the render passes its checks. Same inputs, same plan.
|
|
6
|
+
*/
|
|
7
|
+
const STOP = new Set(("game games play plays player players playing make makes made keep show shows clip clips video videos shot shots thing things way time first last every still much many need needs want using use used " +
|
|
8
|
+
"the and for with you your this that from into over then than they them their there what when where which while will just like have has had are was were been being its it's our out off one two all any can not but get got lot new now very more most some such only also each other about after before again under onto upon here how why who too own same so if or of on in at to by an a as is be do").split(" "));
|
|
9
|
+
/** Search words of a text: lowercase, letters and digits, three or more characters, no filler. */
|
|
10
|
+
export function wordsOf(text) {
|
|
11
|
+
if (!text)
|
|
12
|
+
return [];
|
|
13
|
+
return [...new Set(text.toLowerCase().replace(/['’]s\b/g, "").split(/[^a-z0-9]+/).filter((word) => word.length >= 3 && !STOP.has(word)))];
|
|
14
|
+
}
|
|
15
|
+
/** True when the reference speaks (a voice-over or a talking host), not only captions and music. */
|
|
16
|
+
export function refHasVoice(ref) {
|
|
17
|
+
if (/^(no voice|caption only|caption-only|music only|no vo)/i.test((ref.voPattern ?? "").trim()))
|
|
18
|
+
return false;
|
|
19
|
+
return (ref.beats ?? []).some((beat) => beat.vo.trim().length > 0);
|
|
20
|
+
}
|
|
21
|
+
/** Scores one reference for today; null when it cannot be used (locked, expired, no beats, already made). */
|
|
22
|
+
export function scoreReference(ref, ctx) {
|
|
23
|
+
if (ref.locked || !ref.beats?.length)
|
|
24
|
+
return null;
|
|
25
|
+
if (ctx.usedReferenceIds.has(ref.referenceId))
|
|
26
|
+
return null;
|
|
27
|
+
if (ref.expiresAt && ref.expiresAt < ctx.today)
|
|
28
|
+
return null;
|
|
29
|
+
const format = ref.formatType ?? ref.format ?? "";
|
|
30
|
+
const reasons = [];
|
|
31
|
+
let score = 0;
|
|
32
|
+
if (format && !ctx.usedFormats.has(format)) {
|
|
33
|
+
score += 3;
|
|
34
|
+
reasons.push(`a format this game has not posted yet (${format})`);
|
|
35
|
+
}
|
|
36
|
+
const genres = (ref.genres ?? []).filter((genre) => ctx.niches.includes(genre));
|
|
37
|
+
score += Math.min(2, genres.length * 0.5);
|
|
38
|
+
if (ref.breakoutRatio && ref.breakoutRatio > 1)
|
|
39
|
+
score += Math.log10(ref.breakoutRatio);
|
|
40
|
+
if (ref.stats?.postedAt) {
|
|
41
|
+
const days = (Date.parse(ctx.today) - Date.parse(ref.stats.postedAt)) / 86_400_000;
|
|
42
|
+
if (days <= 30)
|
|
43
|
+
score += 1;
|
|
44
|
+
}
|
|
45
|
+
const refWords = new Set(wordsOf([ref.title, ref.howGamesCanUseIt, ref.idea, ...(ref.ideas ?? []).map((idea) => idea.idea)].join(" ")));
|
|
46
|
+
let best = null;
|
|
47
|
+
for (const commit of ctx.commits) {
|
|
48
|
+
const hits = wordsOf(commit.title).filter((word) => refWords.has(word));
|
|
49
|
+
if (hits.length && (!best || hits.length > best.hits.length))
|
|
50
|
+
best = { commit, hits };
|
|
51
|
+
}
|
|
52
|
+
if (best) {
|
|
53
|
+
score += Math.min(2, best.hits.length * 0.5);
|
|
54
|
+
reasons.unshift(best.commit.sha ? `You shipped "${best.commit.title}" today (commit ${best.commit.sha.slice(0, 7)})` : `It fits what you asked for: "${best.commit.title}"`);
|
|
55
|
+
}
|
|
56
|
+
if (refHasVoice(ref) && !ctx.voiceReady)
|
|
57
|
+
score -= 2;
|
|
58
|
+
if (ref.breakoutRatio)
|
|
59
|
+
reasons.push(`it got ${Math.round(ref.breakoutRatio)}x its account's usual views`);
|
|
60
|
+
const fitReason = (reasons.join("; ") || "The best fit in today's references").replace(/^./, (c) => c.toUpperCase()).slice(0, 300);
|
|
61
|
+
return {
|
|
62
|
+
ref,
|
|
63
|
+
score: Math.round(score * 100) / 100,
|
|
64
|
+
fitReason,
|
|
65
|
+
...(best?.commit.sha ? { commit: { sha: best.commit.sha, title: best.commit.title } } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** The best reference for today, ties broken by id so the same inputs give the same pick. */
|
|
69
|
+
export function pickReference(refs, ctx) {
|
|
70
|
+
const scored = refs.map((ref) => scoreReference(ref, ctx)).filter((pick) => pick !== null);
|
|
71
|
+
scored.sort((a, b) => b.score - a.score || a.ref.referenceId.localeCompare(b.ref.referenceId));
|
|
72
|
+
return scored[0] ?? null;
|
|
73
|
+
}
|
|
74
|
+
const COLORS = [
|
|
75
|
+
[/yellow|gold/, "#FFD60A"],
|
|
76
|
+
[/lavender|lilac/, "#B8A2FF"],
|
|
77
|
+
[/pink/, "#FF8FC8"],
|
|
78
|
+
[/red/, "#FF3B30"],
|
|
79
|
+
[/cyan|light blue/, "#5AD8FF"],
|
|
80
|
+
[/green/, "#4CD964"],
|
|
81
|
+
[/orange/, "#FF9500"],
|
|
82
|
+
[/cream/, "#FFF4C4"],
|
|
83
|
+
[/white/, "#FFFFFF"],
|
|
84
|
+
];
|
|
85
|
+
function firstColor(text) {
|
|
86
|
+
let best = null;
|
|
87
|
+
for (const [pattern, hex] of COLORS) {
|
|
88
|
+
const at = text.search(pattern);
|
|
89
|
+
if (at >= 0 && (!best || at < best.at))
|
|
90
|
+
best = { at, hex };
|
|
91
|
+
}
|
|
92
|
+
return best?.hex ?? null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The reference's caption look, read from its plain-words description:
|
|
96
|
+
* where it sits, its case, colour, font kind, and whether it holds one
|
|
97
|
+
* line or follows the voice word by word.
|
|
98
|
+
*/
|
|
99
|
+
export function parseCaptionStyle(captionStyle, hasVoice) {
|
|
100
|
+
const text = (captionStyle ?? "").toLowerCase();
|
|
101
|
+
const head = text.split(/[.;]/)[0] ?? text;
|
|
102
|
+
const y = /top third|top-centre|top center|top-center|at the top|upper third|over the sky|top of the frame|\btop\b/.test(text)
|
|
103
|
+
? 0.25
|
|
104
|
+
: /upper-middle|upper middle|20% from the top/.test(text)
|
|
105
|
+
? 0.3
|
|
106
|
+
: /below the middle|centre-low|center-low|lower third|lower half|lower-middle/.test(text)
|
|
107
|
+
? 0.6
|
|
108
|
+
: /bottom/.test(text)
|
|
109
|
+
? 0.68
|
|
110
|
+
: 0.45;
|
|
111
|
+
const wordByWord = /one word at a time|word by word|word-by-word|1 to 3 words|1-3 words|2-3 words|2 to 3 words|in sync with the voice|spoken word/.test(text);
|
|
112
|
+
const held = /static|whole clip|stays|held|fixed for|on screen 0\.0|the whole time|never changes/.test(text);
|
|
113
|
+
const mode = hasVoice && (wordByWord || !held) ? "words" : held || !hasVoice ? "held" : "beats";
|
|
114
|
+
const chunk = /one word at a time|single word/.test(text) ? 1 : /2-3 words|2 to 3 words|1 to 3 words|1-3 words/.test(text) ? 3 : 3;
|
|
115
|
+
const highlightMatch = /(spoken|key|one|last) words? (turns?|in|pops?)[^.]*?(yellow|gold|red|cyan|green|orange|pink)/.exec(text);
|
|
116
|
+
const highlight = highlightMatch ? firstColor(highlightMatch[3]) : null;
|
|
117
|
+
return {
|
|
118
|
+
mode,
|
|
119
|
+
chunk,
|
|
120
|
+
case: /lower ?case|lowercase|lower case/.test(text) ? "lower" : /capitals|\bcaps\b|upper ?case|uppercase|all caps/.test(text) ? "upper" : "sentence",
|
|
121
|
+
y,
|
|
122
|
+
color: firstColor(head) ?? "#FFFFFF",
|
|
123
|
+
highlight,
|
|
124
|
+
font: /serif/.test(text) && !/sans/.test(head) ? "serif" : /rounded/.test(text) ? "rounded" : "sans",
|
|
125
|
+
size: mode === "words" ? 100 : 84,
|
|
126
|
+
outline: !/no outline|without outline/.test(text),
|
|
127
|
+
quoted: /quote marks|in quotes|inside quote/.test(text),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** Piecewise-linear map of reference times onto our timeline between matching anchors. */
|
|
131
|
+
export function mapTimes(times, refAnchors, ourAnchors) {
|
|
132
|
+
return times.map((t) => {
|
|
133
|
+
for (let i = 0; i < refAnchors.length - 1; i++) {
|
|
134
|
+
const a = refAnchors[i];
|
|
135
|
+
const b = refAnchors[i + 1];
|
|
136
|
+
if ((t >= a && t <= b) || i === refAnchors.length - 2) {
|
|
137
|
+
const k = (ourAnchors[i + 1] - ourAnchors[i]) / (b - a || 1);
|
|
138
|
+
return Math.round((ourAnchors[i] + (t - a) * k) * 1000) / 1000;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return t;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** Caption chunks of up to max words, broken at punctuation or a pause. */
|
|
145
|
+
export function chunkWords(words, max = 3, gap = 0.25) {
|
|
146
|
+
const chunks = [];
|
|
147
|
+
let current = [];
|
|
148
|
+
words.forEach((word, index) => {
|
|
149
|
+
current.push(word);
|
|
150
|
+
const next = words[index + 1];
|
|
151
|
+
const pause = next ? next.s - word.e : 9;
|
|
152
|
+
if (current.length >= max || /[.,?!]$/.test(word.w) || pause > gap || !next) {
|
|
153
|
+
chunks.push(current);
|
|
154
|
+
current = [];
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
return chunks;
|
|
158
|
+
}
|
|
159
|
+
const norm = (word) => word.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
160
|
+
/**
|
|
161
|
+
* Our script words on the times Whisper heard: matching words take their
|
|
162
|
+
* time; a run Whisper heard differently shares the span it took.
|
|
163
|
+
*/
|
|
164
|
+
export function alignScript(script, heard) {
|
|
165
|
+
const out = [];
|
|
166
|
+
let j = 0;
|
|
167
|
+
for (let i = 0; i < script.length; i++) {
|
|
168
|
+
const target = norm(script[i]);
|
|
169
|
+
let found = -1;
|
|
170
|
+
for (let k = j; k < Math.min(heard.length, j + 6); k++) {
|
|
171
|
+
if (norm(heard[k].w) === target) {
|
|
172
|
+
found = k;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (found >= 0) {
|
|
177
|
+
out.push({ w: script[i], s: heard[found].s, e: heard[found].e });
|
|
178
|
+
j = found + 1;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
const at = heard[Math.min(j, heard.length - 1)];
|
|
182
|
+
const prevEnd = out.length ? out[out.length - 1].e : 0;
|
|
183
|
+
const s = at ? Math.max(prevEnd, at.s) : prevEnd;
|
|
184
|
+
out.push({ w: script[i], s, e: at ? Math.max(s, at.e) : s });
|
|
185
|
+
if (at && norm(at.w).length <= target.length)
|
|
186
|
+
j++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
/** Start time of each non-empty line in the aligned words (null for an empty line). */
|
|
192
|
+
export function lineStarts(lines, aligned) {
|
|
193
|
+
const starts = [];
|
|
194
|
+
let index = 0;
|
|
195
|
+
for (const line of lines) {
|
|
196
|
+
const count = line.split(/\s+/).filter(Boolean).length;
|
|
197
|
+
starts.push(count ? aligned[index]?.s ?? null : null);
|
|
198
|
+
index += count;
|
|
199
|
+
}
|
|
200
|
+
return starts;
|
|
201
|
+
}
|
|
202
|
+
/** Our cut times under a voice-over: the reference's beat starts land on the starts of our matching lines. */
|
|
203
|
+
export function voiceAnchors(beats, starts, refDuration, voiceEnd) {
|
|
204
|
+
const ref = [0];
|
|
205
|
+
const ours = [0];
|
|
206
|
+
beats.forEach((beat, index) => {
|
|
207
|
+
const start = starts[index];
|
|
208
|
+
if (start === null || start === undefined || index === 0)
|
|
209
|
+
return;
|
|
210
|
+
const r = beat.t[0];
|
|
211
|
+
const o = Math.max(0, start - 0.05);
|
|
212
|
+
if (r > ref[ref.length - 1] + 0.05 && o > ours[ours.length - 1] + 0.05) {
|
|
213
|
+
ref.push(r);
|
|
214
|
+
ours.push(o);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
const lastVoiced = [...beats].reverse().find((beat) => beat.vo.trim());
|
|
218
|
+
const tail = Math.max(0.4, refDuration - (lastVoiced?.t[1] ?? refDuration));
|
|
219
|
+
ref.push(refDuration);
|
|
220
|
+
ours.push(Math.max(ours[ours.length - 1] + 0.5, voiceEnd + tail));
|
|
221
|
+
return { ref, ours };
|
|
222
|
+
}
|
|
223
|
+
const STILL_WORDS = /\b(card|cards|page|pages|photo|picture|poster|portrait|still|slide|carousel|screenshot)\b/;
|
|
224
|
+
function clipScore(clip, words) {
|
|
225
|
+
const tags = new Set(clip.tags);
|
|
226
|
+
return words.filter((word) => tags.has(word)).length;
|
|
227
|
+
}
|
|
228
|
+
function meanOf(values) {
|
|
229
|
+
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
|
|
230
|
+
}
|
|
231
|
+
/** The window of a clip with the most motion, outside the ranges already used, not too dark. */
|
|
232
|
+
export function bestWindow(clip, seconds, used, after = 0) {
|
|
233
|
+
if (clip.kind === "image" || !clip.motion.length)
|
|
234
|
+
return { t: 0, motion: 0, clean: true };
|
|
235
|
+
const half = 0.5;
|
|
236
|
+
const span = Math.max(1, Math.ceil(seconds / half));
|
|
237
|
+
const positions = Math.max(1, Math.floor((clip.duration - seconds) / half + 1e-6) + 1);
|
|
238
|
+
let best = { t: 0, score: -Infinity, motion: 0, clean: false };
|
|
239
|
+
for (let i = 0; i < positions; i++) {
|
|
240
|
+
const t = i * half;
|
|
241
|
+
const overlaps = used.some(([a, b]) => t < b - 0.05 && t + seconds > a + 0.05);
|
|
242
|
+
const motion = meanOf(clip.motion.slice(i, i + span));
|
|
243
|
+
const light = clip.luma.slice(i, i + span);
|
|
244
|
+
// black screens and white flashes (loading, a burst filling the frame) are not shots
|
|
245
|
+
const dark = meanOf(light) < 12 || Math.max(...light, 0) > 140;
|
|
246
|
+
// a frozen half second (a capture hitch, an idle tail) reads as a still frame on screen
|
|
247
|
+
const frozen = clip.motion.slice(i, i + span).filter((value) => value < 0.4).length;
|
|
248
|
+
const score = motion - (overlaps ? 100 : 0) - (dark ? 50 : 0) - frozen * 1.5 - (t + 0.01 < after ? 1.5 : 0);
|
|
249
|
+
if (score > best.score)
|
|
250
|
+
best = { t, score, motion, clean: !overlaps && !dark && frozen === 0 };
|
|
251
|
+
}
|
|
252
|
+
return { t: best.t, motion: best.motion, clean: best.clean };
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Which clip window plays each reference shot. One player's story: the
|
|
256
|
+
* clip that fits the most beats leads, and its windows move forward in
|
|
257
|
+
* time; a beat about cards or pages takes stills when there are any; a
|
|
258
|
+
* fast run of cuts never shows the same clip twice in a row; a reference
|
|
259
|
+
* black beat stays black.
|
|
260
|
+
*/
|
|
261
|
+
export function beatMap(input) {
|
|
262
|
+
const { study, beats, lines, clips, cuts } = input;
|
|
263
|
+
if (!clips.length)
|
|
264
|
+
return [];
|
|
265
|
+
const bounds = [0, ...study.cuts, study.duration];
|
|
266
|
+
const beatOf = (t) => Math.max(0, beats.findIndex((beat) => t >= beat.t[0] && t < beat.t[1]));
|
|
267
|
+
const beatWords = beats.map((beat, index) => wordsOf(`${beat.shot} ${lines[index] ?? ""} ${input.brief ?? ""}`));
|
|
268
|
+
const total = cuts[cuts.length - 1];
|
|
269
|
+
const lead = [...clips]
|
|
270
|
+
.filter((clip) => clip.kind === "video")
|
|
271
|
+
.map((clip) => ({ clip, score: beatWords.reduce((sum, words) => sum + clipScore(clip, words), 0) + meanOf(clip.motion) * 0.1 + (clip.duration >= Math.min(total, 8) ? 1 : 0) }))
|
|
272
|
+
.sort((a, b) => b.score - a.score || a.clip.path.localeCompare(b.clip.path))[0]?.clip;
|
|
273
|
+
const used = new Map();
|
|
274
|
+
const lastEnd = new Map();
|
|
275
|
+
const shots = [];
|
|
276
|
+
let previous = null;
|
|
277
|
+
for (let i = 0; i < cuts.length - 1; i++) {
|
|
278
|
+
const seconds = cuts[i + 1] - cuts[i];
|
|
279
|
+
const refMid = (bounds[i] + bounds[Math.min(i + 1, bounds.length - 1)]) / 2;
|
|
280
|
+
const beatIndex = beatOf(refMid);
|
|
281
|
+
const beat = beats[beatIndex];
|
|
282
|
+
if (study.black[i]) {
|
|
283
|
+
shots.push({ color: "black", beat: `ref shot ${i}: black` });
|
|
284
|
+
previous = null;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const wantsStill = STILL_WORDS.test((beat?.shot ?? "").toLowerCase()) && clips.some((clip) => clip.kind === "image");
|
|
288
|
+
const fast = seconds < 0.7;
|
|
289
|
+
const candidates = clips
|
|
290
|
+
.filter((clip) => (wantsStill ? clip.kind === "image" : clip.kind === "video" || !clips.some((other) => other.kind === "video")))
|
|
291
|
+
.map((clip) => {
|
|
292
|
+
let score = clipScore(clip, beatWords[beatIndex] ?? []);
|
|
293
|
+
if (clip === lead)
|
|
294
|
+
score += 0.75;
|
|
295
|
+
// a long take runs on within its beat; a still or a fast cut never repeats
|
|
296
|
+
if (previous && previous.clip === clip)
|
|
297
|
+
score += !fast && clip.kind === "video" && previous.beat === beatIndex ? 1 : -2;
|
|
298
|
+
if (clip.kind === "video" && clip.duration < seconds)
|
|
299
|
+
score -= 1;
|
|
300
|
+
score -= (used.get(clip.path)?.length ?? 0) * (fast ? 0.6 : 0.25);
|
|
301
|
+
return { clip, score };
|
|
302
|
+
})
|
|
303
|
+
.sort((a, b) => b.score - a.score || a.clip.path.localeCompare(b.clip.path));
|
|
304
|
+
const clip = candidates[0]?.clip ?? clips[0];
|
|
305
|
+
const ranges = used.get(clip.path) ?? [];
|
|
306
|
+
let t;
|
|
307
|
+
if (previous && previous.clip === clip && previous.beat === beatIndex && previous.end + seconds <= clip.duration + 0.05)
|
|
308
|
+
t = previous.end;
|
|
309
|
+
else
|
|
310
|
+
t = bestWindow(clip, seconds, ranges, clip === lead ? lastEnd.get(clip.path) ?? 0 : 0).t;
|
|
311
|
+
t = Math.round(t * 100) / 100;
|
|
312
|
+
ranges.push([t, t + seconds]);
|
|
313
|
+
used.set(clip.path, ranges);
|
|
314
|
+
lastEnd.set(clip.path, Math.max(lastEnd.get(clip.path) ?? 0, t + seconds));
|
|
315
|
+
const still = clip.kind === "image";
|
|
316
|
+
const long = seconds >= 1.2;
|
|
317
|
+
shots.push({
|
|
318
|
+
src: clip.path,
|
|
319
|
+
t,
|
|
320
|
+
...(still ? { z0: 1, z1: 1.08 } : long ? { z0: 1, z1: 1.05 } : {}),
|
|
321
|
+
beat: `ref shot ${i} (${beat ? beat.shot.slice(0, 80) : "no beat"})`,
|
|
322
|
+
});
|
|
323
|
+
previous = { clip, end: t + seconds, beat: beatIndex };
|
|
324
|
+
}
|
|
325
|
+
return shots;
|
|
326
|
+
}
|
|
327
|
+
/** Held or per-beat captions from our lines: consecutive beats sharing the reference's caption share one line. */
|
|
328
|
+
export function beatCaptions(beats, lines, cuts, refBounds, mode) {
|
|
329
|
+
if (mode === "words")
|
|
330
|
+
return [];
|
|
331
|
+
const toOurs = (t) => mapTimes([t], refBounds, cuts)[0];
|
|
332
|
+
const items = [];
|
|
333
|
+
let open = null;
|
|
334
|
+
beats.forEach((beat, index) => {
|
|
335
|
+
const text = (lines[index] ?? "").trim();
|
|
336
|
+
const from = toOurs(beat.t[0]);
|
|
337
|
+
const to = toOurs(beat.t[1]);
|
|
338
|
+
const sameAsBefore = index > 0 && beat.caption.trim() !== "" && beats[index - 1].caption.trim() === beat.caption.trim();
|
|
339
|
+
// a held line runs on through the beats that repeat the reference's caption (their own line left empty)
|
|
340
|
+
if (open && sameAsBefore && (mode === "held" || !text)) {
|
|
341
|
+
open.to = to;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
open = null;
|
|
345
|
+
if (!beat.caption.trim() || !text)
|
|
346
|
+
return;
|
|
347
|
+
open = { text, from, to };
|
|
348
|
+
items.push(open);
|
|
349
|
+
});
|
|
350
|
+
return items;
|
|
351
|
+
}
|
|
352
|
+
/** Lines still to write: the reference's own words stay out of our output. */
|
|
353
|
+
export function unwrittenLines(plan) {
|
|
354
|
+
return plan.lines.map((line, index) => (/^TODO\b/.test(line.trim()) ? index : -1)).filter((index) => index >= 0);
|
|
355
|
+
}
|
|
356
|
+
/** The safe area for text: clear of the top bar, the right-hand buttons and the caption block at the bottom. */
|
|
357
|
+
export const SAFE = { left: 0.06, top: 0.1, right: 0.86, bottom: 0.76 };
|
|
358
|
+
/** A shot may hold still this long (a beat of a held pose); longer reads as a frozen picture. */
|
|
359
|
+
export const FREEZE_LIMIT = 0.8;
|
|
360
|
+
/** The measured checks every piece shows next to it. */
|
|
361
|
+
export function evaluateChecks(input) {
|
|
362
|
+
const moving = input.shotMotion.filter((_, index) => input.shotKinds[index] === "video");
|
|
363
|
+
const minMotion = moving.length ? Math.min(...moving) : 0;
|
|
364
|
+
const lowShots = input.shotMotion.map((value, index) => (input.shotKinds[index] === "video" && (value < 2 || (input.shotFreeze?.[index] ?? 0) > FREEZE_LIMIT) ? index : -1)).filter((index) => index >= 0);
|
|
365
|
+
const hookOk = input.firstSecondMotion >= 2 && (input.refFirstWords === null || (input.firstWords !== null && input.firstWords <= Math.max(0.5, input.refFirstWords + 0.3)));
|
|
366
|
+
const outside = input.captionBoxes.filter(([l, t, r, b]) => l < SAFE.left || t < SAFE.top || r > SAFE.right || b > SAFE.bottom).length;
|
|
367
|
+
const lengthOk = Math.abs(input.length - input.refLength) <= Math.max(1, input.refLength * 0.12);
|
|
368
|
+
const gameplay = input.shotSeconds.reduce((sum, seconds, index) => sum + (input.shotKinds[index] === "video" ? seconds : 0), 0);
|
|
369
|
+
const round = (value) => Math.round(value * 100) / 100;
|
|
370
|
+
const seconds = (value) => `${value.toFixed(1)} s`;
|
|
371
|
+
const checks = [
|
|
372
|
+
{
|
|
373
|
+
name: "hook_time",
|
|
374
|
+
value: input.firstWords === null ? "no words" : seconds(input.firstWords),
|
|
375
|
+
pass: hookOk,
|
|
376
|
+
note: `motion ${round(input.firstSecondMotion)} in the first second${input.refFirstWords === null ? "" : `; the reference's words start at ${round(input.refFirstWords)} s`}`,
|
|
377
|
+
},
|
|
378
|
+
{ name: "lowest_motion", value: `${round(minMotion)} YDIF`, pass: lowShots.length === 0, note: lowShots.length ? `still or frozen shots: ${lowShots.join(", ")}` : `every shot moves (YDIF >= 2, no freeze over ${FREEZE_LIMIT} s)` },
|
|
379
|
+
{ name: "safe_zones", value: outside ? `${outside} outside` : "inside", pass: outside === 0 },
|
|
380
|
+
{ name: "length", value: seconds(input.length), pass: lengthOk, note: `the reference runs ${seconds(input.refLength)}` },
|
|
381
|
+
{ name: "gameplay_seconds", value: seconds(gameplay), pass: gameplay >= 2 },
|
|
382
|
+
];
|
|
383
|
+
if (input.loudness) {
|
|
384
|
+
const { lufs, truePeak } = input.loudness;
|
|
385
|
+
checks.push({ name: "loudness", value: `${lufs.toFixed(1)} LUFS, ${truePeak.toFixed(1)} dBTP`, pass: lufs >= -15.5 && lufs <= -12.5 && truePeak <= -0.5 });
|
|
386
|
+
}
|
|
387
|
+
return checks;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Where to start the song so the most cuts land on its beats: tries every
|
|
391
|
+
* start (0.01 s steps) and counts cuts within 70 ms of an onset. Ties go to
|
|
392
|
+
* the start closest to the beats, then the earliest.
|
|
393
|
+
*/
|
|
394
|
+
export function musicStart(onsets, cuts, songLength) {
|
|
395
|
+
const inner = cuts.slice(1, -1);
|
|
396
|
+
const length = cuts[cuts.length - 1] ?? 0;
|
|
397
|
+
let best = { start: 0, hits: -1, off: Infinity };
|
|
398
|
+
for (let step = 0; step * 0.01 + length <= songLength + 1e-6; step++) {
|
|
399
|
+
const start = step / 100;
|
|
400
|
+
let hits = 0;
|
|
401
|
+
let off = 0;
|
|
402
|
+
let j = 0;
|
|
403
|
+
for (const cut of inner) {
|
|
404
|
+
const t = cut + start;
|
|
405
|
+
while (j < onsets.length && onsets[j] < t - 0.07)
|
|
406
|
+
j++;
|
|
407
|
+
if (j < onsets.length && Math.abs(onsets[j] - t) <= 0.07) {
|
|
408
|
+
hits++;
|
|
409
|
+
off += Math.abs(onsets[j] - t);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (hits > best.hits || (hits === best.hits && off < best.off - 1e-9))
|
|
413
|
+
best = { start, hits, off };
|
|
414
|
+
}
|
|
415
|
+
return { start: best.start, hits: Math.max(0, best.hits) };
|
|
416
|
+
}
|
|
417
|
+
/** Moves each cut onto the song's nearest onset within reach (70 ms), keeping every shot at least 0.1 s. */
|
|
418
|
+
export function snapCuts(cuts, onsets, start, reach = 0.07) {
|
|
419
|
+
const out = [...cuts];
|
|
420
|
+
for (let i = 1; i < out.length - 1; i++) {
|
|
421
|
+
const t = out[i] + start;
|
|
422
|
+
let nearest = null;
|
|
423
|
+
for (const onset of onsets)
|
|
424
|
+
if (Math.abs(onset - t) <= reach && (nearest === null || Math.abs(onset - t) < Math.abs(nearest - t)))
|
|
425
|
+
nearest = onset;
|
|
426
|
+
if (nearest === null)
|
|
427
|
+
continue;
|
|
428
|
+
const moved = Math.round((nearest - start) * 1000) / 1000;
|
|
429
|
+
if (moved - out[i - 1] >= 0.1 && out[i + 1] - moved >= 0.1)
|
|
430
|
+
out[i] = moved;
|
|
431
|
+
}
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Word } from "./plan.js";
|
|
2
|
+
import type { CaptionItem, CaptionStyle, ShotSpec } from "./types.js";
|
|
3
|
+
/** The font file for a caption: the game's own (kit or input) when given, else a system font of the same kind. */
|
|
4
|
+
export declare function fontFile(kind: CaptionStyle["font"], own?: string | null): string;
|
|
5
|
+
/** The cut: every shot on its frame-exact slot, concatenated, plus the game-sound bed on the same grid. */
|
|
6
|
+
export declare function buildBase(shots: readonly ShotSpec[], cuts: readonly number[], dir: string, onShot?: (index: number, total: number) => void): Promise<{
|
|
7
|
+
base: string;
|
|
8
|
+
bed: string;
|
|
9
|
+
frameCuts: number[];
|
|
10
|
+
}>;
|
|
11
|
+
/** A transparent track of pictures, each held for its frame count (frame-exact, unlike image durations in a concat list). */
|
|
12
|
+
export declare function overlayTrack(states: ReadonlyArray<[string, number]>, out: string): Promise<string>;
|
|
13
|
+
export interface CaptionBox {
|
|
14
|
+
left: number;
|
|
15
|
+
top: number;
|
|
16
|
+
right: number;
|
|
17
|
+
bottom: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* One caption on a transparent frame, at the reference's place and in its
|
|
21
|
+
* look: the text with a dark outline (no shadow, no glow). With a highlight,
|
|
22
|
+
* the longest word takes that colour. Returns its box on the frame.
|
|
23
|
+
*/
|
|
24
|
+
export declare function captionPng(text: string, style: CaptionStyle, font: string, out: string): Promise<CaptionBox>;
|
|
25
|
+
/** A caption track: each item over its time, blank between. Returns the track and every caption's box. */
|
|
26
|
+
export declare function captionTrack(items: readonly CaptionItem[], end: number, style: CaptionStyle, font: string, dir: string): Promise<{
|
|
27
|
+
track: string;
|
|
28
|
+
boxes: CaptionBox[];
|
|
29
|
+
} | null>;
|
|
30
|
+
/** Word captions from the voice: chunks change with the words, each held until the next starts. */
|
|
31
|
+
export declare function wordCaptionItems(chunks: ReadonlyArray<readonly Word[]>, end: number): CaptionItem[];
|
|
32
|
+
/** A picture over a time span, as a track the length of the piece. */
|
|
33
|
+
export declare function pictureTrack(file: string, from: number, to: number, end: number, out: string): Promise<string>;
|
|
34
|
+
/**
|
|
35
|
+
* The finished piece: overlays on the cut, the voice (or a song) over the
|
|
36
|
+
* game-sound bed, -14 LUFS integrated and -1.5 dBTP.
|
|
37
|
+
*/
|
|
38
|
+
export declare function mix(opts: {
|
|
39
|
+
base: string;
|
|
40
|
+
overlays: readonly string[];
|
|
41
|
+
voice: string | null;
|
|
42
|
+
music: string | null;
|
|
43
|
+
musicStart?: number;
|
|
44
|
+
bed: string;
|
|
45
|
+
bedDb: number;
|
|
46
|
+
out: string;
|
|
47
|
+
duration: number;
|
|
48
|
+
}): Promise<void>;
|
|
49
|
+
/** Mean motion per shot (the first frame of each shot, the cut itself, left out) and over the first second. */
|
|
50
|
+
export declare function motionReport(video: string, frameCuts: readonly number[]): Promise<{
|
|
51
|
+
shots: number[];
|
|
52
|
+
freeze: number[];
|
|
53
|
+
firstSecond: number;
|
|
54
|
+
}>;
|
|
55
|
+
/** The cover: the frame with the most going on in the first two seconds' hook shot. */
|
|
56
|
+
export declare function cover(video: string, at: number, out: string): Promise<string>;
|
|
57
|
+
/**
|
|
58
|
+
* Reference and ours side by side, synced on every beat: each reference
|
|
59
|
+
* shot is stretched to the length of our matching shot. Our sound only;
|
|
60
|
+
* the reference stays silent.
|
|
61
|
+
*/
|
|
62
|
+
export declare function sideBySide(refFile: string, refBounds: readonly number[], ours: string, ourCuts: readonly number[], dir: string, out: string): Promise<string>;
|
|
63
|
+
/** A contact sheet: reference | ours at the middle of every shot, numbered. */
|
|
64
|
+
export declare function contactSheet(refFile: string, refBounds: readonly number[], ours: string, ourCuts: readonly number[], dir: string, out: string): Promise<string>;
|