@impractical-ai/motion 0.1.7 → 0.1.9
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/assets/engine.d.ts +65 -1
- package/assets/impractical/SKILL.md +35 -263
- package/dist/{api-DR3NJ7TI.js → api-NVZQJBVP.js} +2 -2
- package/dist/{chunk-Z74SLKEU.js → chunk-H5TR3QJN.js} +6 -3
- package/dist/{chunk-MQKW7NFI.js → chunk-KI5E2464.js} +1 -1
- package/dist/{chunk-M7SHFM25.js → chunk-NG3Q3BYW.js} +57 -4
- package/dist/{commands-R3LJU2BW.js → commands-56WH3ZVJ.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/{stdio-XUVQKDCB.js → stdio-MJ4LTXME.js} +2717 -1454
- package/package.json +3 -3
- package/assets/kits/actions.ts +0 -803
- package/assets/kits/ensemble.ts +0 -483
- package/assets/kits/highlight.ts +0 -374
- package/assets/kits/impact.ts +0 -103
- package/assets/kits/kits.ts +0 -343
- package/assets/kits/looks.ts +0 -935
- package/assets/kits/morphkit.ts +0 -438
- package/assets/kits/reveal.ts +0 -1364
- package/assets/kits/sentence.ts +0 -218
- package/assets/kits/streamkit.ts +0 -195
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
openBrowser
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-NG3Q3BYW.js";
|
|
5
5
|
import {
|
|
6
6
|
ApiError,
|
|
7
7
|
admitMcpCall,
|
|
@@ -11,12 +11,13 @@ import {
|
|
|
11
11
|
saveConfig,
|
|
12
12
|
validateApiResponse,
|
|
13
13
|
withAdmissionReceipt
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-KI5E2464.js";
|
|
15
15
|
import {
|
|
16
|
+
BUILD_COMMIT,
|
|
16
17
|
PACKAGE_VERSION,
|
|
17
18
|
PRODUCTION_APP_URL,
|
|
18
19
|
PRODUCTION_SUPABASE_URL
|
|
19
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-H5TR3QJN.js";
|
|
20
21
|
import {
|
|
21
22
|
__commonJS,
|
|
22
23
|
__export,
|
|
@@ -3639,9 +3640,9 @@ var require_fast_uri = __commonJS({
|
|
|
3639
3640
|
if (baseMalformed || relativeMalformed) {
|
|
3640
3641
|
throw new Error(baseParsed.error || relativeParsed.error || "URI is malformed.");
|
|
3641
3642
|
}
|
|
3642
|
-
const
|
|
3643
|
+
const resolved2 = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true);
|
|
3643
3644
|
schemelessOptions.skipEscape = true;
|
|
3644
|
-
return serialize(
|
|
3645
|
+
return serialize(resolved2, schemelessOptions);
|
|
3645
3646
|
}
|
|
3646
3647
|
function resolveComponent(base, relative, options, skipNormalization) {
|
|
3647
3648
|
const target = {};
|
|
@@ -6907,7 +6908,7 @@ var require_dist = __commonJS({
|
|
|
6907
6908
|
|
|
6908
6909
|
// src/mcp/stdio.ts
|
|
6909
6910
|
import { readFileSync as readFileSync2 } from "node:fs";
|
|
6910
|
-
import { fileURLToPath
|
|
6911
|
+
import { fileURLToPath } from "node:url";
|
|
6911
6912
|
|
|
6912
6913
|
// node_modules/zod/v3/helpers/util.js
|
|
6913
6914
|
var util;
|
|
@@ -25139,8 +25140,8 @@ function convertBaseSchema(schema, ctx) {
|
|
|
25139
25140
|
});
|
|
25140
25141
|
}
|
|
25141
25142
|
ctx.processing.add(refPath);
|
|
25142
|
-
const
|
|
25143
|
-
const zodSchema2 = convertSchema(
|
|
25143
|
+
const resolved2 = resolveRef(refPath, ctx);
|
|
25144
|
+
const zodSchema2 = convertSchema(resolved2, ctx);
|
|
25144
25145
|
ctx.refs.set(refPath, zodSchema2);
|
|
25145
25146
|
ctx.processing.delete(refPath);
|
|
25146
25147
|
return zodSchema2;
|
|
@@ -31012,1226 +31013,807 @@ var UNMETERED_MCP_TOOLS = /* @__PURE__ */ new Set([
|
|
|
31012
31013
|
var encoder = new TextEncoder();
|
|
31013
31014
|
var decoder = new TextDecoder();
|
|
31014
31015
|
|
|
31015
|
-
// src/mcp/
|
|
31016
|
-
|
|
31017
|
-
|
|
31018
|
-
|
|
31019
|
-
|
|
31020
|
-
|
|
31021
|
-
|
|
31022
|
-
|
|
31023
|
-
|
|
31024
|
-
|
|
31025
|
-
|
|
31026
|
-
|
|
31027
|
-
|
|
31028
|
-
|
|
31029
|
-
|
|
31030
|
-
|
|
31031
|
-
|
|
31032
|
-
|
|
31033
|
-
|
|
31034
|
-
|
|
31035
|
-
|
|
31036
|
-
|
|
31037
|
-
|
|
31038
|
-
var
|
|
31039
|
-
|
|
31040
|
-
|
|
31041
|
-
|
|
31042
|
-
|
|
31043
|
-
|
|
31044
|
-
|
|
31045
|
-
|
|
31046
|
-
|
|
31047
|
-
|
|
31048
|
-
|
|
31049
|
-
|
|
31050
|
-
|
|
31051
|
-
}
|
|
31052
|
-
var createQualityLoopState = () => ({
|
|
31053
|
-
finalReviewByDraft: /* @__PURE__ */ new Map(),
|
|
31054
|
-
finalMotionReviewByDraft: /* @__PURE__ */ new Map(),
|
|
31055
|
-
lockedContentByDraft: /* @__PURE__ */ new Map(),
|
|
31056
|
-
beatsByDraft: /* @__PURE__ */ new Map(),
|
|
31057
|
-
referenceLedger: null
|
|
31058
|
-
});
|
|
31059
|
-
function qualityLoopEnabled(env = process.env) {
|
|
31060
|
-
return !/^(0|false|off|no)$/i.test(env[QUALITY_LOOP_ENV]?.trim() ?? "");
|
|
31016
|
+
// src/mcp/frames.ts
|
|
31017
|
+
import { chromium as chromium3 } from "playwright-core";
|
|
31018
|
+
|
|
31019
|
+
// src/mcp/browser-pool.ts
|
|
31020
|
+
import { cpus } from "node:os";
|
|
31021
|
+
import { chromium as chromium2 } from "playwright-core";
|
|
31022
|
+
|
|
31023
|
+
// src/mcp/browser-daemon.ts
|
|
31024
|
+
import { spawn } from "node:child_process";
|
|
31025
|
+
import {
|
|
31026
|
+
existsSync,
|
|
31027
|
+
mkdirSync,
|
|
31028
|
+
mkdtempSync,
|
|
31029
|
+
readdirSync,
|
|
31030
|
+
readFileSync,
|
|
31031
|
+
renameSync,
|
|
31032
|
+
rmSync,
|
|
31033
|
+
writeFileSync
|
|
31034
|
+
} from "node:fs";
|
|
31035
|
+
import { homedir, tmpdir } from "node:os";
|
|
31036
|
+
import { join } from "node:path";
|
|
31037
|
+
import { chromium } from "playwright-core";
|
|
31038
|
+
var PROTOCOL = 3;
|
|
31039
|
+
var MAX_AGE_MS = 12 * 60 * 60 * 1e3;
|
|
31040
|
+
var STATE_DIR = () => join(homedir(), ".impractical");
|
|
31041
|
+
var ADVERT = () => join(STATE_DIR(), "frames-daemon.json");
|
|
31042
|
+
var LOCK = () => join(STATE_DIR(), "frames-daemon.lock");
|
|
31043
|
+
var CLIENTS = () => join(STATE_DIR(), "frames-clients");
|
|
31044
|
+
var BINARIES = () => join(STATE_DIR(), "frames-binaries.json");
|
|
31045
|
+
function alive(pid) {
|
|
31046
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
31047
|
+
try {
|
|
31048
|
+
process.kill(pid, 0);
|
|
31049
|
+
return true;
|
|
31050
|
+
} catch (error51) {
|
|
31051
|
+
return error51.code === "EPERM";
|
|
31052
|
+
}
|
|
31061
31053
|
}
|
|
31062
|
-
|
|
31063
|
-
|
|
31064
|
-
|
|
31065
|
-
|
|
31066
|
-
|
|
31067
|
-
if (!hasEnding(times, duration3)) {
|
|
31068
|
-
return `Final review must include a settled closing frame near t=${duration3.toFixed(2)}s.`;
|
|
31054
|
+
function registerClient() {
|
|
31055
|
+
try {
|
|
31056
|
+
mkdirSync(CLIENTS(), { recursive: true });
|
|
31057
|
+
writeFileSync(join(CLIENTS(), String(process.pid)), String(Date.now()));
|
|
31058
|
+
} catch {
|
|
31069
31059
|
}
|
|
31070
|
-
return null;
|
|
31071
31060
|
}
|
|
31072
|
-
function
|
|
31073
|
-
|
|
31061
|
+
function unregisterClient() {
|
|
31062
|
+
rmSync(join(CLIENTS(), String(process.pid)), { force: true });
|
|
31074
31063
|
}
|
|
31075
|
-
function
|
|
31076
|
-
|
|
31077
|
-
|
|
31078
|
-
|
|
31079
|
-
|
|
31080
|
-
|
|
31081
|
-
|
|
31082
|
-
|
|
31064
|
+
function otherClients() {
|
|
31065
|
+
try {
|
|
31066
|
+
return readdirSync(CLIENTS()).filter((name) => {
|
|
31067
|
+
const pid = Number(name);
|
|
31068
|
+
if (!Number.isInteger(pid) || pid === process.pid) return false;
|
|
31069
|
+
if (alive(pid)) return true;
|
|
31070
|
+
rmSync(join(CLIENTS(), name), { force: true });
|
|
31071
|
+
return false;
|
|
31072
|
+
}).length;
|
|
31073
|
+
} catch {
|
|
31074
|
+
return 0;
|
|
31083
31075
|
}
|
|
31084
|
-
return "\nQUALITY LOOP \u2014 LAYOUT REVIEW. Identify the primary subject at each timestamp and report clipping, unreadable text, weak occupancy, competing hierarchy, missing assets, or empty frames. Use visible evidence rather than saying the frames look clean.";
|
|
31085
31076
|
}
|
|
31086
|
-
function
|
|
31087
|
-
|
|
31088
|
-
|
|
31089
|
-
|
|
31090
|
-
|
|
31091
|
-
|
|
31092
|
-
return
|
|
31077
|
+
function readAdvert() {
|
|
31078
|
+
try {
|
|
31079
|
+
const advert = JSON.parse(readFileSync(ADVERT(), "utf8"));
|
|
31080
|
+
if (advert.protocol !== PROTOCOL) return void 0;
|
|
31081
|
+
return advert;
|
|
31082
|
+
} catch {
|
|
31083
|
+
return void 0;
|
|
31093
31084
|
}
|
|
31094
|
-
return null;
|
|
31095
31085
|
}
|
|
31096
|
-
function
|
|
31097
|
-
|
|
31098
|
-
|
|
31099
|
-
|
|
31086
|
+
function writeAdvert(advert) {
|
|
31087
|
+
mkdirSync(STATE_DIR(), { recursive: true });
|
|
31088
|
+
const temp = `${ADVERT()}.${process.pid}.tmp`;
|
|
31089
|
+
writeFileSync(temp, JSON.stringify(advert, null, 2));
|
|
31090
|
+
renameSync(temp, ADVERT());
|
|
31091
|
+
}
|
|
31092
|
+
function clearAdvert() {
|
|
31093
|
+
rmSync(ADVERT(), { force: true });
|
|
31094
|
+
}
|
|
31095
|
+
async function healthy(advert) {
|
|
31096
|
+
if (!alive(advert.pid)) return false;
|
|
31097
|
+
if (Date.now() - Date.parse(advert.startedAt) > MAX_AGE_MS) return false;
|
|
31098
|
+
try {
|
|
31099
|
+
const controller = new AbortController();
|
|
31100
|
+
const timer = setTimeout(() => controller.abort(), 2e3);
|
|
31101
|
+
const response = await fetch(`http://127.0.0.1:${advert.port}/json/version`, {
|
|
31102
|
+
signal: controller.signal
|
|
31103
|
+
});
|
|
31104
|
+
clearTimeout(timer);
|
|
31105
|
+
return response.ok;
|
|
31106
|
+
} catch {
|
|
31107
|
+
return false;
|
|
31100
31108
|
}
|
|
31101
|
-
|
|
31102
|
-
|
|
31109
|
+
}
|
|
31110
|
+
function killDaemon(advert) {
|
|
31111
|
+
try {
|
|
31112
|
+
process.kill(advert.pid, "SIGTERM");
|
|
31113
|
+
} catch {
|
|
31103
31114
|
}
|
|
31104
|
-
if (
|
|
31105
|
-
|
|
31115
|
+
if (advert.userDataDir.includes("impractical-frames")) {
|
|
31116
|
+
try {
|
|
31117
|
+
rmSync(advert.userDataDir, { recursive: true, force: true });
|
|
31118
|
+
} catch {
|
|
31119
|
+
}
|
|
31106
31120
|
}
|
|
31107
|
-
|
|
31108
|
-
|
|
31121
|
+
clearAdvert();
|
|
31122
|
+
}
|
|
31123
|
+
function cachedBinary(launcher) {
|
|
31124
|
+
try {
|
|
31125
|
+
const cache = JSON.parse(readFileSync(BINARIES(), "utf8"));
|
|
31126
|
+
const path = cache[launcher];
|
|
31127
|
+
return path && existsSync(path) ? path : void 0;
|
|
31128
|
+
} catch {
|
|
31129
|
+
return void 0;
|
|
31109
31130
|
}
|
|
31110
|
-
|
|
31111
|
-
|
|
31131
|
+
}
|
|
31132
|
+
function cacheBinary(launcher, path) {
|
|
31133
|
+
try {
|
|
31134
|
+
mkdirSync(STATE_DIR(), { recursive: true });
|
|
31135
|
+
let cache = {};
|
|
31136
|
+
try {
|
|
31137
|
+
cache = JSON.parse(readFileSync(BINARIES(), "utf8"));
|
|
31138
|
+
} catch {
|
|
31139
|
+
}
|
|
31140
|
+
cache[launcher] = path;
|
|
31141
|
+
writeFileSync(BINARIES(), JSON.stringify(cache, null, 2));
|
|
31142
|
+
} catch {
|
|
31112
31143
|
}
|
|
31113
|
-
|
|
31114
|
-
|
|
31115
|
-
|
|
31116
|
-
|
|
31144
|
+
}
|
|
31145
|
+
async function resolveBinary(launcher, options) {
|
|
31146
|
+
const cached2 = cachedBinary(launcher);
|
|
31147
|
+
if (cached2) return cached2;
|
|
31148
|
+
const server = await chromium.launchServer(options);
|
|
31149
|
+
try {
|
|
31150
|
+
const path = server.process()?.spawnfile;
|
|
31151
|
+
if (!path) throw new Error("Playwright launched a browser without recording its path");
|
|
31152
|
+
cacheBinary(launcher, path);
|
|
31153
|
+
return path;
|
|
31154
|
+
} finally {
|
|
31155
|
+
await server.close().catch(() => void 0);
|
|
31117
31156
|
}
|
|
31118
|
-
const average = scores.reduce((sum, [, score]) => sum + score, 0) / scores.length;
|
|
31119
|
-
if (average < 4) return `Quality score average ${average.toFixed(2)} is below the 4.0 export floor.`;
|
|
31120
|
-
return null;
|
|
31121
31157
|
}
|
|
31122
|
-
function
|
|
31123
|
-
|
|
31124
|
-
|
|
31125
|
-
|
|
31126
|
-
|
|
31127
|
-
|
|
31128
|
-
|
|
31129
|
-
|
|
31130
|
-
|
|
31158
|
+
function browserArgs(executable, port, userDataDir) {
|
|
31159
|
+
const shell = /headless_shell/i.test(executable);
|
|
31160
|
+
return [
|
|
31161
|
+
...shell ? [] : ["--headless=new"],
|
|
31162
|
+
// Playwright passes this on CI runners and containers; a hand-rolled spawn
|
|
31163
|
+
// has to as well. Chrome refuses to start as root without it, and the only
|
|
31164
|
+
// symptom is that the debugging port never opens — which is exactly how
|
|
31165
|
+
// this first broke CI.
|
|
31166
|
+
...process.env.CI || process.getuid?.() === 0 ? ["--no-sandbox"] : [],
|
|
31167
|
+
`--remote-debugging-port=${port}`,
|
|
31168
|
+
`--user-data-dir=${userDataDir}`,
|
|
31169
|
+
"--enable-unsafe-swiftshader",
|
|
31170
|
+
"--force-color-profile=srgb",
|
|
31171
|
+
"--hide-scrollbars",
|
|
31172
|
+
"--mute-audio",
|
|
31173
|
+
"--no-first-run",
|
|
31174
|
+
"--no-default-browser-check",
|
|
31175
|
+
"--disable-extensions",
|
|
31176
|
+
"--disable-background-networking",
|
|
31177
|
+
"--disable-component-update",
|
|
31178
|
+
"--disable-default-apps",
|
|
31179
|
+
"--disable-dev-shm-usage",
|
|
31180
|
+
"--disable-popup-blocking",
|
|
31181
|
+
"--metrics-recording-only"
|
|
31182
|
+
];
|
|
31131
31183
|
}
|
|
31132
|
-
|
|
31133
|
-
|
|
31134
|
-
|
|
31135
|
-
|
|
31136
|
-
|
|
31184
|
+
var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
31185
|
+
async function waitForPort(userDataDir, timeoutMs = 3e4) {
|
|
31186
|
+
const file2 = join(userDataDir, "DevToolsActivePort");
|
|
31187
|
+
const deadline = Date.now() + timeoutMs;
|
|
31188
|
+
while (Date.now() < deadline) {
|
|
31189
|
+
try {
|
|
31190
|
+
const [line] = readFileSync(file2, "utf8").split("\n");
|
|
31191
|
+
const port = Number(line);
|
|
31192
|
+
if (Number.isInteger(port) && port > 0) return port;
|
|
31193
|
+
} catch {
|
|
31194
|
+
}
|
|
31195
|
+
await delay(100);
|
|
31196
|
+
}
|
|
31197
|
+
throw new Error("The browser never opened its debugging port.");
|
|
31137
31198
|
}
|
|
31138
|
-
|
|
31139
|
-
|
|
31140
|
-
|
|
31141
|
-
|
|
31142
|
-
|
|
31143
|
-
|
|
31144
|
-
|
|
31145
|
-
|
|
31146
|
-
|
|
31147
|
-
|
|
31148
|
-
|
|
31149
|
-
|
|
31150
|
-
|
|
31151
|
-
|
|
31152
|
-
|
|
31153
|
-
|
|
31154
|
-
|
|
31155
|
-
|
|
31156
|
-
|
|
31157
|
-
|
|
31158
|
-
|
|
31159
|
-
|
|
31160
|
-
|
|
31161
|
-
|
|
31162
|
-
|
|
31163
|
-
|
|
31164
|
-
|
|
31165
|
-
|
|
31166
|
-
|
|
31167
|
-
|
|
31168
|
-
|
|
31169
|
-
}
|
|
31170
|
-
|
|
31171
|
-
|
|
31172
|
-
|
|
31173
|
-
|
|
31174
|
-
|
|
31175
|
-
|
|
31176
|
-
|
|
31177
|
-
|
|
31178
|
-
|
|
31179
|
-
external_exports.object({
|
|
31180
|
-
name: meaningful2("component", 3),
|
|
31181
|
-
realism: external_exports.enum(["exact-product", "realistic-class", "stylized"]),
|
|
31182
|
-
referenceSource: external_exports.string().trim().min(4).optional()
|
|
31183
|
-
})
|
|
31184
|
-
).min(1)
|
|
31185
|
-
});
|
|
31186
|
-
var contentSchema = external_exports.object({
|
|
31187
|
-
/** Every editorial line the film will show, in viewing order. Locked. */
|
|
31188
|
-
lines: external_exports.array(
|
|
31189
|
-
external_exports.object({
|
|
31190
|
-
text: external_exports.string().trim().min(2).max(90),
|
|
31191
|
-
kind: external_exports.enum(["statement", "label", "endcard"])
|
|
31192
|
-
})
|
|
31193
|
-
).min(3)
|
|
31194
|
-
});
|
|
31195
|
-
function phasedGateFailure(args) {
|
|
31196
|
-
if (!args.concept || !args.content) {
|
|
31197
|
-
return "The phased flow requires BOTH `concept` and `content` before scaffolding. Phase 1 (concept): from the brief alone \u2014 premise, recurringArtifact, references (look for the product's real code or site FIRST), beats (each with start, end, subject, move, purpose, energy, and outgoing \u2014 what the elements already on stage do when the beat starts), componentInventory. Phase 2 (content): every editorial line the film will show, in viewing order, each marked statement/label/endcard, written so someone who has never heard of the product could repeat it. Do this BEFORE studying the engine or library. The lines are locked: the motion report diffs the rendered film against them.";
|
|
31199
|
+
async function spawnDaemon(launcher, executable) {
|
|
31200
|
+
mkdirSync(STATE_DIR(), { recursive: true });
|
|
31201
|
+
const userDataDir = mkdtempSync(join(tmpdir(), "impractical-frames-"));
|
|
31202
|
+
const child = spawn(executable, browserArgs(executable, 0, userDataDir), {
|
|
31203
|
+
detached: true,
|
|
31204
|
+
stdio: "ignore"
|
|
31205
|
+
});
|
|
31206
|
+
child.unref();
|
|
31207
|
+
if (!child.pid) throw new Error("Could not start a browser process.");
|
|
31208
|
+
try {
|
|
31209
|
+
const port = await waitForPort(userDataDir);
|
|
31210
|
+
const raced = readAdvert();
|
|
31211
|
+
if (raced && await healthy(raced)) {
|
|
31212
|
+
try {
|
|
31213
|
+
process.kill(child.pid, "SIGKILL");
|
|
31214
|
+
} catch {
|
|
31215
|
+
}
|
|
31216
|
+
try {
|
|
31217
|
+
rmSync(userDataDir, { recursive: true, force: true });
|
|
31218
|
+
} catch {
|
|
31219
|
+
}
|
|
31220
|
+
return raced;
|
|
31221
|
+
}
|
|
31222
|
+
const advert = {
|
|
31223
|
+
protocol: PROTOCOL,
|
|
31224
|
+
pid: child.pid,
|
|
31225
|
+
port,
|
|
31226
|
+
userDataDir,
|
|
31227
|
+
launcher,
|
|
31228
|
+
executable,
|
|
31229
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
31230
|
+
};
|
|
31231
|
+
writeAdvert(advert);
|
|
31232
|
+
return advert;
|
|
31233
|
+
} catch (error51) {
|
|
31234
|
+
try {
|
|
31235
|
+
process.kill(child.pid, "SIGKILL");
|
|
31236
|
+
} catch {
|
|
31237
|
+
}
|
|
31238
|
+
rmSync(userDataDir, { recursive: true, force: true });
|
|
31239
|
+
throw error51;
|
|
31198
31240
|
}
|
|
31199
|
-
|
|
31200
|
-
|
|
31201
|
-
|
|
31202
|
-
|
|
31203
|
-
|
|
31241
|
+
}
|
|
31242
|
+
function tryLock() {
|
|
31243
|
+
mkdirSync(STATE_DIR(), { recursive: true });
|
|
31244
|
+
try {
|
|
31245
|
+
writeFileSync(LOCK(), String(Date.now()), { flag: "wx" });
|
|
31246
|
+
return true;
|
|
31247
|
+
} catch {
|
|
31248
|
+
try {
|
|
31249
|
+
const stamp = Number(readFileSync(LOCK(), "utf8"));
|
|
31250
|
+
if (!Number.isFinite(stamp) || stamp <= 0) return false;
|
|
31251
|
+
if (Date.now() - stamp <= 6e4) return false;
|
|
31252
|
+
rmSync(LOCK(), { force: true });
|
|
31253
|
+
writeFileSync(LOCK(), String(Date.now()), { flag: "wx" });
|
|
31254
|
+
return true;
|
|
31255
|
+
} catch {
|
|
31256
|
+
return false;
|
|
31257
|
+
}
|
|
31204
31258
|
}
|
|
31205
|
-
|
|
31206
|
-
|
|
31207
|
-
|
|
31208
|
-
|
|
31209
|
-
|
|
31259
|
+
}
|
|
31260
|
+
function releaseLock() {
|
|
31261
|
+
rmSync(LOCK(), { force: true });
|
|
31262
|
+
}
|
|
31263
|
+
function lockHeld() {
|
|
31264
|
+
return existsSync(LOCK());
|
|
31265
|
+
}
|
|
31266
|
+
async function connect(advert) {
|
|
31267
|
+
return chromium.connectOverCDP(`http://127.0.0.1:${advert.port}`, { timeout: 15e3 });
|
|
31268
|
+
}
|
|
31269
|
+
async function obtain(resolve) {
|
|
31270
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
31271
|
+
const existing = readAdvert();
|
|
31272
|
+
if (existing) {
|
|
31273
|
+
if (await healthy(existing)) {
|
|
31274
|
+
return { browser: await connect(existing), advert: existing };
|
|
31275
|
+
}
|
|
31276
|
+
killDaemon(existing);
|
|
31277
|
+
}
|
|
31278
|
+
if (!tryLock()) {
|
|
31279
|
+
const adopted = await waitForAdvert();
|
|
31280
|
+
if (adopted) return adopted;
|
|
31281
|
+
continue;
|
|
31282
|
+
}
|
|
31283
|
+
try {
|
|
31284
|
+
const fresh = readAdvert();
|
|
31285
|
+
if (fresh && await healthy(fresh)) {
|
|
31286
|
+
return { browser: await connect(fresh), advert: fresh };
|
|
31287
|
+
}
|
|
31288
|
+
const launcher = await resolve();
|
|
31289
|
+
const executable = await resolveBinary(launcher.name, launcher.options);
|
|
31290
|
+
const advert = await spawnDaemon(launcher.name, executable);
|
|
31291
|
+
return { browser: await connect(advert), advert };
|
|
31292
|
+
} finally {
|
|
31293
|
+
releaseLock();
|
|
31294
|
+
}
|
|
31210
31295
|
}
|
|
31211
|
-
|
|
31212
|
-
|
|
31296
|
+
throw new Error(
|
|
31297
|
+
"Could not obtain a shared browser: another process held the start lock and never advertised one. Remove ~/.impractical/frames-daemon.lock and retry."
|
|
31213
31298
|
);
|
|
31214
|
-
|
|
31215
|
-
|
|
31299
|
+
}
|
|
31300
|
+
async function waitForAdvert() {
|
|
31301
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
31302
|
+
await delay(500);
|
|
31303
|
+
const advert = readAdvert();
|
|
31304
|
+
if (advert && await healthy(advert)) {
|
|
31305
|
+
return { browser: await connect(advert), advert };
|
|
31306
|
+
}
|
|
31307
|
+
if (!lockHeld()) return void 0;
|
|
31216
31308
|
}
|
|
31217
|
-
return
|
|
31309
|
+
return void 0;
|
|
31218
31310
|
}
|
|
31219
|
-
|
|
31220
|
-
|
|
31221
|
-
return !/^(0|false|off|no)$/i.test(env[CONTENT_DOCTRINE_ENV]?.trim() ?? "");
|
|
31311
|
+
async function detach(browser) {
|
|
31312
|
+
await browser?.close().catch(() => void 0);
|
|
31222
31313
|
}
|
|
31223
|
-
|
|
31224
|
-
|
|
31225
|
-
|
|
31226
|
-
|
|
31227
|
-
|
|
31228
|
-
PHASED FLOW IS ENABLED \u2014 work in three phases, in order:
|
|
31229
|
-
1. CONCEPT \u2014 from the brief alone, before engine_types or motion_context: FIRST study references, then call gather_references (required \u2014 create_video refuses without it). If you have native web/image search and an image-viewing tool, USE THEM: search real screenshots of the product in use and of each component class, download and VIEW them before designing anything. Hunt references of the ACTUAL SOFTWARE, not the marketing homepage \u2014 the product's real front-end code on this machine (pass it as appCode) \u2192 screenshots of the product IN USE (its app/editor/dashboard, found via its docs, help center, changelog, demo videos, review sites) \u2192 its site code \u2192 marketing site (styling tokens only) \u2192 real images of the component class \u2192 mockups; design components to match what the references actually show. Then ask of every component the film will show: MUST THIS LOOK 1:1 REAL? Product UI and real-world objects (a terminal, an invoice, a dashboard) do; abstract backdrops and statement cards do not. For each realism-required component, pull a real reference image, VIEW it, and record it as that component's referenceSource in the inventory. Then write the premise, the one recurring artifact the viewer follows, and beats where every beat names a MOVE, declares its energy (rest / low / high) \u2014 rest is legitimate stillness, high must visibly move; the motion report verdicts each beat against its declaration \u2014 and declares its OUTGOING: what the elements already on stage do when this beat starts. Nothing new enters without saying what the things still showing do \u2014 exit (and how), transform into the new thing, or deliberately stay. Entrances are cheap; exits are choreography. A beat that only describes what arrives leaves the last scene's props loitering in frame. The first beat may say "empty stage".
|
|
31230
|
-
2. CONTENT \u2014 write every editorial line of the film in viewing order (statement/label/endcard).${contentDoctrine()} Statements are the big lines the film is built around \u2014 10-15% of frame height each; no text anyone MUST read below 4% unless the user explicitly asks.${contentTextRule()} These lines are LOCKED once passed to create_video: the motion report flags any on-screen line that is not in the list, and any listed line the camera never shows.
|
|
31231
|
-
RUNTIME \u2014 when the user gives no duration, plan 20-30 seconds and EARN it with at least two distinct proof chapters (different capabilities or angles), never by stretching holds. A user-specified runtime always wins.
|
|
31232
|
-
3. CREATION \u2014 only now read engine_types and motion_context. Call install_kits on the new draft FIRST and import the primitives from './kit/*' \u2014 the cursor actor, showpieces, looks and sentence typography are already built and tuned; hand-rolling them produces cursors that teleport and clicks that fire early. Then cast the beats onto the library and kits, build, measure, revise, review, export.
|
|
31233
|
-
create_video requires the concept and content artifacts and will refuse without them.`;
|
|
31314
|
+
function lastOneOut() {
|
|
31315
|
+
unregisterClient();
|
|
31316
|
+
if (otherClients() > 0) return;
|
|
31317
|
+
const advert = readAdvert();
|
|
31318
|
+
if (advert) killDaemon(advert);
|
|
31234
31319
|
}
|
|
31235
31320
|
|
|
31236
|
-
// src/mcp/
|
|
31237
|
-
var
|
|
31238
|
-
|
|
31239
|
-
|
|
31240
|
-
|
|
31241
|
-
|
|
31321
|
+
// src/mcp/browser-pool.ts
|
|
31322
|
+
var CANDIDATES = [
|
|
31323
|
+
{ name: "chromium-headless-shell", options: { channel: "chromium-headless-shell", headless: true } },
|
|
31324
|
+
{ name: "chromium", options: { headless: true } },
|
|
31325
|
+
{
|
|
31326
|
+
name: "system-chrome",
|
|
31327
|
+
options: { channel: "chrome", headless: true },
|
|
31328
|
+
advice: "Frame capture is using your installed Google Chrome because no Playwright browser is available. Run `npx playwright install chromium-headless-shell` for a smaller, faster browser that is not shared with your own."
|
|
31329
|
+
}
|
|
31330
|
+
];
|
|
31331
|
+
var resolved;
|
|
31332
|
+
function launcherCandidates() {
|
|
31333
|
+
const forced = process.env.PLAYWRIGHT_CHANNEL?.trim();
|
|
31334
|
+
if (forced) return [{ name: `channel:${forced}`, options: { channel: forced, headless: true } }];
|
|
31335
|
+
const exe = process.env.IMPRACTICAL_CHROMIUM?.trim();
|
|
31336
|
+
if (exe) return [{ name: `executable:${exe}`, options: { executablePath: exe, headless: true } }];
|
|
31337
|
+
if (process.env.CI) return CANDIDATES.filter((candidate) => candidate.name !== "system-chrome");
|
|
31338
|
+
return CANDIDATES;
|
|
31339
|
+
}
|
|
31340
|
+
async function resolveLauncher() {
|
|
31341
|
+
if (resolved) return resolved;
|
|
31342
|
+
const advert = readAdvert();
|
|
31343
|
+
if (advert) {
|
|
31344
|
+
const known = launcherCandidates().find((candidate) => candidate.name === advert.launcher);
|
|
31345
|
+
if (known) {
|
|
31346
|
+
resolved = known;
|
|
31347
|
+
return known;
|
|
31348
|
+
}
|
|
31349
|
+
}
|
|
31350
|
+
return pickLauncher();
|
|
31351
|
+
}
|
|
31352
|
+
function firstLine(error51) {
|
|
31353
|
+
return String(error51 instanceof Error ? error51.message : error51).split("\n")[0].slice(0, 160);
|
|
31354
|
+
}
|
|
31355
|
+
function noBrowser(failures) {
|
|
31356
|
+
if (process.env.CI) {
|
|
31357
|
+
return new Error(
|
|
31358
|
+
"CI requires the Playwright-managed Chromium pinned by cli/package-lock.json. Run `npx playwright-core install chromium-headless-shell`; the system Chrome fallback is disabled in CI.\nTried:\n " + failures.join("\n ")
|
|
31359
|
+
);
|
|
31360
|
+
}
|
|
31361
|
+
return new Error(
|
|
31362
|
+
"No usable Chromium found for frame capture. Install one with `npx playwright install chromium-headless-shell`, or install Google Chrome. If Chrome is present but blocked by Gatekeeper, set PLAYWRIGHT_CHANNEL=chrome. You can also point IMPRACTICAL_CHROMIUM at a browser binary directly.\nTried:\n " + failures.join("\n ")
|
|
31242
31363
|
);
|
|
31243
|
-
return esbuildPromise;
|
|
31244
31364
|
}
|
|
31245
|
-
var
|
|
31246
|
-
|
|
31247
|
-
|
|
31248
|
-
|
|
31249
|
-
|
|
31250
|
-
|
|
31251
|
-
|
|
31252
|
-
|
|
31253
|
-
|
|
31254
|
-
|
|
31255
|
-
|
|
31256
|
-
|
|
31257
|
-
|
|
31258
|
-
|
|
31259
|
-
|
|
31260
|
-
|
|
31261
|
-
`${clean}/index.ts`,
|
|
31262
|
-
`${clean}/index.tsx`
|
|
31263
|
-
];
|
|
31264
|
-
return candidates.find((candidate) => candidate in files) ?? null;
|
|
31365
|
+
var registered = false;
|
|
31366
|
+
var exitHooked = false;
|
|
31367
|
+
var local;
|
|
31368
|
+
async function pickLauncher() {
|
|
31369
|
+
const failures = [];
|
|
31370
|
+
for (const candidate of launcherCandidates()) {
|
|
31371
|
+
try {
|
|
31372
|
+
const probe = await chromium2.launch(candidate.options);
|
|
31373
|
+
await probe.close().catch(() => void 0);
|
|
31374
|
+
resolved = candidate;
|
|
31375
|
+
return candidate;
|
|
31376
|
+
} catch (error51) {
|
|
31377
|
+
failures.push(`${candidate.name}: ${firstLine(error51)}`);
|
|
31378
|
+
}
|
|
31379
|
+
}
|
|
31380
|
+
throw noBrowser(failures);
|
|
31265
31381
|
}
|
|
31266
|
-
async function
|
|
31267
|
-
|
|
31268
|
-
|
|
31269
|
-
|
|
31270
|
-
|
|
31382
|
+
async function attachLocal() {
|
|
31383
|
+
if (!local) {
|
|
31384
|
+
local = (async () => {
|
|
31385
|
+
const launcher2 = resolved ?? await pickLauncher();
|
|
31386
|
+
return chromium2.launch(launcher2.options);
|
|
31387
|
+
})();
|
|
31388
|
+
}
|
|
31271
31389
|
try {
|
|
31272
|
-
await
|
|
31273
|
-
|
|
31274
|
-
|
|
31275
|
-
|
|
31276
|
-
|
|
31277
|
-
|
|
31278
|
-
|
|
31279
|
-
|
|
31280
|
-
|
|
31281
|
-
|
|
31282
|
-
|
|
31283
|
-
|
|
31284
|
-
|
|
31285
|
-
|
|
31286
|
-
|
|
31287
|
-
|
|
31288
|
-
|
|
31289
|
-
|
|
31290
|
-
|
|
31291
|
-
|
|
31292
|
-
|
|
31293
|
-
|
|
31294
|
-
|
|
31295
|
-
|
|
31296
|
-
|
|
31297
|
-
errors: [
|
|
31298
|
-
{
|
|
31299
|
-
text: `"${args.path}" (from ${args.importer}) does not match any draft file. Draft files: ${Object.keys(files).sort().join(", ")}`
|
|
31300
|
-
}
|
|
31301
|
-
]
|
|
31302
|
-
};
|
|
31303
|
-
}
|
|
31304
|
-
return { path: args.path, external: true };
|
|
31305
|
-
});
|
|
31306
|
-
build.onLoad({ filter: /.*/, namespace: "draft" }, (args) => {
|
|
31307
|
-
const ext = args.path.slice(args.path.lastIndexOf("."));
|
|
31308
|
-
return {
|
|
31309
|
-
contents: files[args.path],
|
|
31310
|
-
loader: LOADERS[ext] ?? "ts"
|
|
31311
|
-
};
|
|
31312
|
-
});
|
|
31313
|
-
}
|
|
31314
|
-
}
|
|
31315
|
-
]
|
|
31390
|
+
const browser = await local;
|
|
31391
|
+
if (browser.isConnected()) return { browser, shared: false };
|
|
31392
|
+
} catch {
|
|
31393
|
+
}
|
|
31394
|
+
local = void 0;
|
|
31395
|
+
const launcher = resolved ?? await pickLauncher();
|
|
31396
|
+
local = chromium2.launch(launcher.options);
|
|
31397
|
+
return { browser: await local, shared: false };
|
|
31398
|
+
}
|
|
31399
|
+
async function attach() {
|
|
31400
|
+
if (!exitHooked) {
|
|
31401
|
+
exitHooked = true;
|
|
31402
|
+
process.once("exit", lastOneOut);
|
|
31403
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
31404
|
+
process.once(signal, () => {
|
|
31405
|
+
lastOneOut();
|
|
31406
|
+
process.exit(0);
|
|
31407
|
+
});
|
|
31408
|
+
}
|
|
31409
|
+
}
|
|
31410
|
+
if (local) return attachLocal();
|
|
31411
|
+
try {
|
|
31412
|
+
const { browser, advert } = await obtain(async () => {
|
|
31413
|
+
const launcher = await pickLauncher();
|
|
31414
|
+
return { name: launcher.name, options: launcher.options };
|
|
31316
31415
|
});
|
|
31317
|
-
|
|
31416
|
+
if (!resolved) {
|
|
31417
|
+
resolved = launcherCandidates().find((candidate) => candidate.name === advert.launcher) ?? { name: advert.launcher, options: {} };
|
|
31418
|
+
}
|
|
31419
|
+
if (!registered) {
|
|
31420
|
+
registerClient();
|
|
31421
|
+
registered = true;
|
|
31422
|
+
}
|
|
31423
|
+
return { browser, shared: true };
|
|
31318
31424
|
} catch (error51) {
|
|
31319
|
-
|
|
31320
|
-
return
|
|
31425
|
+
if (/No usable Chromium|CI requires the Playwright-managed/.test(String(error51))) throw error51;
|
|
31426
|
+
return attachLocal();
|
|
31321
31427
|
}
|
|
31322
31428
|
}
|
|
31323
|
-
|
|
31324
|
-
|
|
31325
|
-
|
|
31326
|
-
|
|
31327
|
-
var nap = (ctx, ms) => ctx.sleep ? ctx.sleep(ms) : new Promise((resolve) => setTimeout(resolve, ms));
|
|
31328
|
-
var TERMINAL = /* @__PURE__ */ new Set(["completed", "failed", "canceled"]);
|
|
31329
|
-
var POLL_ERROR_LIMIT = 5;
|
|
31330
|
-
var JOB_WAIT_SECONDS = 300;
|
|
31331
|
-
var BRAND_WAIT_SECONDS = 300;
|
|
31332
|
-
var MAX_WAIT_SECONDS = 540;
|
|
31333
|
-
var maxWaitSeconds = () => {
|
|
31334
|
-
const override = Number(process.env.IMPRACTICAL_MAX_WAIT_SECONDS);
|
|
31335
|
-
return Number.isFinite(override) && override > 0 ? override : MAX_WAIT_SECONDS;
|
|
31336
|
-
};
|
|
31337
|
-
function waitBudgetMs(requested, fallbackSeconds) {
|
|
31338
|
-
const asked = Number(requested);
|
|
31339
|
-
const seconds = Number.isFinite(asked) && asked > 0 ? asked : fallbackSeconds;
|
|
31340
|
-
return Math.min(seconds, maxWaitSeconds()) * 1e3;
|
|
31429
|
+
function browserVanished(error51) {
|
|
31430
|
+
return /has been closed|Target (page|browser).*closed|Connection closed|disconnected|ECONNREFUSED/i.test(
|
|
31431
|
+
String(error51 instanceof Error ? error51.message : error51)
|
|
31432
|
+
);
|
|
31341
31433
|
}
|
|
31342
|
-
var
|
|
31343
|
-
|
|
31344
|
-
|
|
31345
|
-
|
|
31346
|
-
|
|
31347
|
-
|
|
31348
|
-
|
|
31349
|
-
|
|
31350
|
-
|
|
31351
|
-
|
|
31434
|
+
var MAX_CONCURRENT = Math.max(1, Math.min(4, (cpus()?.length ?? 4) - 2));
|
|
31435
|
+
var active = 0;
|
|
31436
|
+
var waiting = [];
|
|
31437
|
+
async function enter() {
|
|
31438
|
+
if (active < MAX_CONCURRENT) {
|
|
31439
|
+
active += 1;
|
|
31440
|
+
return;
|
|
31441
|
+
}
|
|
31442
|
+
await new Promise((resolve) => waiting.push(resolve));
|
|
31443
|
+
active += 1;
|
|
31352
31444
|
}
|
|
31353
|
-
|
|
31354
|
-
|
|
31355
|
-
|
|
31356
|
-
|
|
31357
|
-
|
|
31358
|
-
}
|
|
31359
|
-
|
|
31360
|
-
let
|
|
31361
|
-
|
|
31362
|
-
|
|
31363
|
-
|
|
31364
|
-
|
|
31365
|
-
|
|
31366
|
-
|
|
31367
|
-
|
|
31368
|
-
|
|
31369
|
-
|
|
31370
|
-
|
|
31371
|
-
return { text: `No change \u2014 the draft already matches at revision ${draft.revision}.` };
|
|
31445
|
+
function leave() {
|
|
31446
|
+
active -= 1;
|
|
31447
|
+
const next = waiting.shift();
|
|
31448
|
+
if (next) next();
|
|
31449
|
+
}
|
|
31450
|
+
async function acquireContext(viewport = { width: 1920, height: 1080 }) {
|
|
31451
|
+
await enter();
|
|
31452
|
+
let attached;
|
|
31453
|
+
let context;
|
|
31454
|
+
try {
|
|
31455
|
+
attached = await attach();
|
|
31456
|
+
try {
|
|
31457
|
+
context = await attached.browser.newContext({ viewport, deviceScaleFactor: 1 });
|
|
31458
|
+
} catch (error51) {
|
|
31459
|
+
if (!browserVanished(error51)) throw error51;
|
|
31460
|
+
if (attached.shared) await detach(attached.browser);
|
|
31461
|
+
attached = await attach();
|
|
31462
|
+
context = await attached.browser.newContext({ viewport, deviceScaleFactor: 1 });
|
|
31372
31463
|
}
|
|
31373
|
-
|
|
31374
|
-
|
|
31375
|
-
|
|
31376
|
-
|
|
31377
|
-
|
|
31378
|
-
|
|
31464
|
+
} catch (error51) {
|
|
31465
|
+
if (attached?.shared) await detach(attached.browser);
|
|
31466
|
+
leave();
|
|
31467
|
+
throw error51;
|
|
31468
|
+
}
|
|
31469
|
+
const owned = attached;
|
|
31470
|
+
let released = false;
|
|
31471
|
+
const release = async () => {
|
|
31472
|
+
if (released) return;
|
|
31473
|
+
released = true;
|
|
31474
|
+
await context?.close().catch(() => void 0);
|
|
31475
|
+
if (owned.shared) await detach(owned.browser);
|
|
31476
|
+
leave();
|
|
31477
|
+
};
|
|
31478
|
+
return { context, release };
|
|
31479
|
+
}
|
|
31480
|
+
async function launcherAdvice() {
|
|
31481
|
+
try {
|
|
31482
|
+
return (await resolveLauncher()).advice;
|
|
31483
|
+
} catch {
|
|
31484
|
+
return void 0;
|
|
31485
|
+
}
|
|
31486
|
+
}
|
|
31487
|
+
|
|
31488
|
+
// src/mcp/frames.ts
|
|
31489
|
+
var PREVIEW_ORIGIN = "https://preview.impractical.local";
|
|
31490
|
+
var PREVIEW_URL = `${PREVIEW_ORIGIN}/stage.html`;
|
|
31491
|
+
var FRAME_TYPE = "jpeg";
|
|
31492
|
+
var FRAME_QUALITY = 80;
|
|
31493
|
+
var FRAME_MIME = "image/jpeg";
|
|
31494
|
+
async function launchChromium(launcher = chromium3) {
|
|
31495
|
+
if (process.env.CI) {
|
|
31496
|
+
try {
|
|
31497
|
+
return await launcher.launch({ headless: true });
|
|
31498
|
+
} catch (error51) {
|
|
31499
|
+
throw new Error(
|
|
31500
|
+
"CI requires the Playwright-managed Chromium pinned by cli/package-lock.json. Run `npx playwright-core install chromium`; system Chrome fallback is disabled in CI.",
|
|
31501
|
+
{ cause: error51 }
|
|
31379
31502
|
);
|
|
31380
31503
|
}
|
|
31504
|
+
}
|
|
31505
|
+
if (process.env.PLAYWRIGHT_CHANNEL === "chrome") {
|
|
31506
|
+
return launcher.launch({ channel: "chrome", headless: true });
|
|
31507
|
+
}
|
|
31508
|
+
try {
|
|
31509
|
+
return await launcher.launch({ headless: true });
|
|
31510
|
+
} catch {
|
|
31381
31511
|
try {
|
|
31382
|
-
|
|
31383
|
-
draftId,
|
|
31384
|
-
expectedRevision: draft.revision,
|
|
31385
|
-
files: change.files,
|
|
31386
|
-
deletedPaths: change.deletedPaths
|
|
31387
|
-
});
|
|
31388
|
-
ctx.qualityLoopState?.finalReviewByDraft.delete(draftId);
|
|
31389
|
-
ctx.qualityLoopState?.finalMotionReviewByDraft.delete(draftId);
|
|
31390
|
-
return reportBuildStart(
|
|
31391
|
-
draftId,
|
|
31392
|
-
saved,
|
|
31393
|
-
changedCount,
|
|
31394
|
-
change.deletedPaths.length
|
|
31395
|
-
);
|
|
31512
|
+
return await launcher.launch({ channel: "chrome", headless: true });
|
|
31396
31513
|
} catch (error51) {
|
|
31397
|
-
|
|
31398
|
-
|
|
31399
|
-
|
|
31400
|
-
|
|
31401
|
-
continue;
|
|
31402
|
-
}
|
|
31403
|
-
if (status === 409) {
|
|
31404
|
-
return fail(
|
|
31405
|
-
`Save rejected (409): ${message}. The draft changed underneath this edit \u2014 re-read the file and re-apply your change on top of the new content.`
|
|
31406
|
-
);
|
|
31407
|
-
}
|
|
31408
|
-
throw error51;
|
|
31514
|
+
throw new Error(
|
|
31515
|
+
`No usable Chromium found for frame capture. Install Google Chrome, or run \`npx playwright install chromium\`. If Chrome is installed but blocked by Gatekeeper, set PLAYWRIGHT_CHANNEL=chrome.
|
|
31516
|
+
Underlying error: ${error51 instanceof Error ? error51.message : String(error51)}`
|
|
31517
|
+
);
|
|
31409
31518
|
}
|
|
31410
31519
|
}
|
|
31411
31520
|
}
|
|
31412
|
-
function
|
|
31413
|
-
|
|
31414
|
-
|
|
31415
|
-
|
|
31416
|
-
|
|
31417
|
-
|
|
31418
|
-
|
|
31419
|
-
|
|
31420
|
-
|
|
31421
|
-
|
|
31422
|
-
|
|
31423
|
-
|
|
31424
|
-
|
|
31425
|
-
|
|
31426
|
-
|
|
31427
|
-
|
|
31428
|
-
|
|
31429
|
-
|
|
31430
|
-
|
|
31431
|
-
|
|
31432
|
-
|
|
31433
|
-
|
|
31434
|
-
|
|
31435
|
-
|
|
31436
|
-
}
|
|
31437
|
-
|
|
31438
|
-
|
|
31439
|
-
|
|
31440
|
-
|
|
31441
|
-
|
|
31442
|
-
|
|
31443
|
-
|
|
31444
|
-
|
|
31445
|
-
|
|
31446
|
-
|
|
31447
|
-
|
|
31448
|
-
|
|
31449
|
-
}
|
|
31450
|
-
|
|
31451
|
-
|
|
31452
|
-
title: "Explain Pro and get the billing link",
|
|
31453
|
-
description: "Explain what Pro costs and unlocks for this workspace, and return the authenticated billing URL. Only a workspace admin can buy \u2014 checking out subscribes the whole workspace. Set open=true only after the user explicitly asks to open it in their browser.",
|
|
31454
|
-
inputSchema: {
|
|
31455
|
-
open: external_exports.boolean().optional().describe("Open billing in the user's browser. Defaults to false.")
|
|
31456
|
-
},
|
|
31457
|
-
local: true,
|
|
31458
|
-
handler: async (args, ctx) => {
|
|
31459
|
-
const url2 = `${(ctx.appUrl ?? "https://motion.impractical.ai").replace(/\/$/, "")}/account/billing`;
|
|
31460
|
-
const account = await ctx.invoke("workspace-bootstrap").catch(() => null);
|
|
31461
|
-
const role = account?.role === "admin" ? "admin" : "member";
|
|
31462
|
-
const canPurchase = role === "admin";
|
|
31463
|
-
const plan = account?.plan ?? "free";
|
|
31464
|
-
const seats = Math.max(1, account?.billing?.seatCount ?? 1);
|
|
31465
|
-
const limit = account?.quota?.limit ?? FREE_WEEKLY_LIMIT;
|
|
31466
|
-
const remaining = account?.quota?.remaining ?? 0;
|
|
31467
|
-
const opened = args.open === true ? ctx.openExternal?.(url2) ?? false : false;
|
|
31468
|
-
return json2({
|
|
31469
|
-
plan,
|
|
31470
|
-
role,
|
|
31471
|
-
canPurchase,
|
|
31472
|
-
price: { amountUsd: 50, per: "user / month" },
|
|
31473
|
-
billedFor: `${seats} seat${seats === 1 ? "" : "s"} \u2014 every admin and member is a paid seat`,
|
|
31474
|
-
unlocks: [
|
|
31475
|
-
"Unlimited videos and exports \u2014 no weekly ceiling",
|
|
31476
|
-
"Iterate on one video as many times as it takes",
|
|
31477
|
-
"A dedicated allowance per person, not shared across workspaces"
|
|
31478
|
-
],
|
|
31479
|
-
// Exact, because we meter calls. How many videos that is depends on how
|
|
31480
|
-
// hard they iterate, so do not turn it into a per-user video count.
|
|
31481
|
-
freePlanNow: limit >= 1e6 ? "This workspace is already on Pro." : `${remaining} of ${limit} free calls left this week (${FREE_WEEKLY_VIDEO_ESTIMATE}).`,
|
|
31482
|
-
url: url2,
|
|
31483
|
-
opened,
|
|
31484
|
-
instructions: "Summarise the value in one or two sentences, then give the URL. Do not open a browser unless asked. " + (canPurchase ? "Checking out subscribes the whole workspace \u2014 say so before they act on it." : "Only a workspace admin can approve the purchase, so tell this user to ask an admin to upgrade.")
|
|
31485
|
-
});
|
|
31486
|
-
}
|
|
31487
|
-
};
|
|
31488
|
-
var listProjects = {
|
|
31489
|
-
name: "list_projects",
|
|
31490
|
-
title: "List video projects",
|
|
31491
|
-
description: "List video projects in the connected workspace, newest first. Large workspaces: pass limit/offset to page instead of relying on a truncated dump, and nameFilter to search.",
|
|
31492
|
-
inputSchema: {
|
|
31493
|
-
limit: external_exports.number().int().min(1).max(200).optional().describe("Return at most this many projects (default: all)."),
|
|
31494
|
-
offset: external_exports.number().int().min(0).optional().describe("Skip this many projects, for paging."),
|
|
31495
|
-
nameFilter: external_exports.string().optional().describe("Case-insensitive substring match on the project name/slug.")
|
|
31496
|
-
},
|
|
31497
|
-
handler: async (args, ctx) => {
|
|
31498
|
-
const result = await ctx.invoke("list-projects", {});
|
|
31499
|
-
let projects = result.projects ?? [];
|
|
31500
|
-
const total = projects.length;
|
|
31501
|
-
const filter = typeof args.nameFilter === "string" ? args.nameFilter.trim().toLowerCase() : "";
|
|
31502
|
-
if (filter) {
|
|
31503
|
-
projects = projects.filter(
|
|
31504
|
-
(project) => `${project.name ?? ""} ${project.slug ?? ""}`.toLowerCase().includes(filter)
|
|
31521
|
+
async function renderSettledFrame(page, time3) {
|
|
31522
|
+
await page.evaluate(
|
|
31523
|
+
(seconds) => (async () => {
|
|
31524
|
+
const stage = window;
|
|
31525
|
+
if (stage.__renderAt) stage.__renderAt(seconds);
|
|
31526
|
+
else stage.__seek(seconds);
|
|
31527
|
+
if (stage.__frameSettle) {
|
|
31528
|
+
await stage.__frameSettle();
|
|
31529
|
+
} else {
|
|
31530
|
+
await new Promise((resolve) => {
|
|
31531
|
+
const pending = [...document.querySelectorAll("video")].filter(
|
|
31532
|
+
(video) => video.currentSrc && !video.error && video.seeking
|
|
31533
|
+
);
|
|
31534
|
+
if (!pending.length) {
|
|
31535
|
+
resolve();
|
|
31536
|
+
return;
|
|
31537
|
+
}
|
|
31538
|
+
let finished = false;
|
|
31539
|
+
let remaining = pending.length;
|
|
31540
|
+
const finish = () => {
|
|
31541
|
+
if (finished) return;
|
|
31542
|
+
finished = true;
|
|
31543
|
+
clearTimeout(cap);
|
|
31544
|
+
resolve();
|
|
31545
|
+
};
|
|
31546
|
+
const cap = setTimeout(finish, 1e3);
|
|
31547
|
+
for (const video of pending) {
|
|
31548
|
+
video.addEventListener(
|
|
31549
|
+
"seeked",
|
|
31550
|
+
() => {
|
|
31551
|
+
remaining -= 1;
|
|
31552
|
+
if (remaining === 0) finish();
|
|
31553
|
+
},
|
|
31554
|
+
{ once: true }
|
|
31555
|
+
);
|
|
31556
|
+
}
|
|
31557
|
+
});
|
|
31558
|
+
}
|
|
31559
|
+
await new Promise(
|
|
31560
|
+
(resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
|
31505
31561
|
);
|
|
31506
|
-
}
|
|
31507
|
-
|
|
31508
|
-
|
|
31509
|
-
|
|
31510
|
-
|
|
31511
|
-
|
|
31512
|
-
|
|
31513
|
-
|
|
31514
|
-
|
|
31515
|
-
|
|
31516
|
-
|
|
31517
|
-
|
|
31562
|
+
})(),
|
|
31563
|
+
time3
|
|
31564
|
+
);
|
|
31565
|
+
}
|
|
31566
|
+
async function captureReferenceFrames(input) {
|
|
31567
|
+
const { context, release } = await acquireContext({ width: 1920, height: 1080 });
|
|
31568
|
+
try {
|
|
31569
|
+
const page = await context.newPage();
|
|
31570
|
+
const base = input.appUrl.replace(/\/$/, "");
|
|
31571
|
+
const url2 = `${base}/stage.html?scene=${encodeURIComponent(input.scene)}`;
|
|
31572
|
+
await page.goto(url2, { waitUntil: "load", timeout: 6e4 });
|
|
31573
|
+
await page.waitForFunction(() => window.__ready === true, void 0, {
|
|
31574
|
+
timeout: 6e4
|
|
31518
31575
|
});
|
|
31519
|
-
|
|
31520
|
-
|
|
31521
|
-
|
|
31522
|
-
name: "get_project",
|
|
31523
|
-
title: "Get one project",
|
|
31524
|
-
description: "Get one project with its drafts (each draft is one cut of the video) and recent jobs. Use this to find the draftId you need for the editing tools.",
|
|
31525
|
-
inputSchema: { projectId: external_exports.string().describe("The project id.") },
|
|
31526
|
-
handler: async (args, ctx) => json2(await ctx.invoke("list-projects", { projectId: args.projectId }))
|
|
31527
|
-
};
|
|
31528
|
-
var createVideo = {
|
|
31529
|
-
name: "create_video",
|
|
31530
|
-
title: "Create a video project",
|
|
31531
|
-
description: "Create a new video project and an empty draft for you to direct. This does NOT run Impractical's own director \u2014 it scaffolds App.tsx, direction.ts and brand.json and hands them to you. Read the `impractical://engine.d.ts` resource, then write the scene yourself with edit_draft_file / write_draft_files.",
|
|
31532
|
-
inputSchema: {
|
|
31533
|
-
valueProp: external_exports.string().min(1).describe("One or two sentences on what ships and why it matters."),
|
|
31534
|
-
name: external_exports.string().optional().describe("Human-readable project name. Defaults to the slug."),
|
|
31535
|
-
slug: external_exports.string().optional().describe("URL-safe project slug. Generated when omitted."),
|
|
31536
|
-
vibe: external_exports.string().optional().describe("Tone, e.g. 'confident'. Defaults to 'confident'."),
|
|
31537
|
-
brandSlug: external_exports.string().optional().describe("Brand kit slug from brand_list. Its colors and fonts become brand.json."),
|
|
31538
|
-
appCode: external_exports.string().optional().describe("The product's front-end code, used as the starting point for App.tsx."),
|
|
31539
|
-
templateId: external_exports.string().optional().describe("Template id recommended by motion_context. Defaults to a blank direction."),
|
|
31540
|
-
templateParams: external_exports.record(external_exports.string(), external_exports.string()).optional().describe("Parameters for templateId, using the schema returned by motion_context."),
|
|
31541
|
-
motionContextId: external_exports.string().optional().describe(
|
|
31542
|
-
"contextId from the motion_context result. Pins the draft to the personalized recommendations that produced it. Omit to resolve a fresh context."
|
|
31543
|
-
),
|
|
31544
|
-
creativeContract: creativeContractSchema.optional().describe(
|
|
31545
|
-
"Required only when the local quality-loop experiment is enabled: the audience, one memorable idea, visible proof, reference strategy, product source, and opening/proof/payoff."
|
|
31546
|
-
),
|
|
31547
|
-
concept: conceptSchema.optional().describe(
|
|
31548
|
-
"Required only when the phased flow is enabled: premise, recurringArtifact, references, beats (each with a MOVE), and componentInventory \u2014 authored from the brief alone."
|
|
31549
|
-
),
|
|
31550
|
-
content: contentSchema.optional().describe(
|
|
31551
|
-
"Required only when the phased flow is enabled: every editorial line in viewing order (claim/label/endcard). Locked \u2014 the motion report diffs the film against these lines."
|
|
31552
|
-
)
|
|
31553
|
-
},
|
|
31554
|
-
handler: async (args, ctx) => {
|
|
31555
|
-
if (ctx.qualityLoop && !args.creativeContract) {
|
|
31556
|
-
return fail(
|
|
31557
|
-
"The local quality loop requires creativeContract before scaffolding. Name the audience, singleIdea, visualProof, referenceStrategy, productSource, opening, proof, and payoff. This is a planning gate only; it does not change the scene schema."
|
|
31558
|
-
);
|
|
31559
|
-
}
|
|
31560
|
-
if (ctx.phasedFlow) {
|
|
31561
|
-
const gate = phasedGateFailure({ concept: args.concept, content: args.content });
|
|
31562
|
-
if (gate) return fail(gate);
|
|
31563
|
-
if (!ctx.qualityLoopState?.referenceLedger) {
|
|
31564
|
-
return fail(
|
|
31565
|
-
"The phased flow requires gather_references BEFORE create_video: record the real references this film is designed against (or an honest noneFound after exhausting the ladder: local code -> product site -> screenshots -> component-class images)."
|
|
31566
|
-
);
|
|
31567
|
-
}
|
|
31576
|
+
const config2 = await page.evaluate(() => window.__config);
|
|
31577
|
+
if (config2?.width && config2?.height) {
|
|
31578
|
+
await page.setViewportSize({ width: config2.width, height: config2.height });
|
|
31568
31579
|
}
|
|
31569
|
-
|
|
31570
|
-
|
|
31571
|
-
|
|
31572
|
-
|
|
31573
|
-
|
|
31574
|
-
|
|
31575
|
-
|
|
31580
|
+
const duration3 = Number(await page.evaluate(() => window.__duration)) || 0;
|
|
31581
|
+
const count = Math.max(1, Math.min(input.count ?? 4, 6));
|
|
31582
|
+
const times = Array.from(
|
|
31583
|
+
{ length: count },
|
|
31584
|
+
(_, index) => duration3 > 0 ? index / Math.max(1, count - 1) * Math.max(0, duration3 - 0.05) : index
|
|
31585
|
+
);
|
|
31586
|
+
const images = [];
|
|
31587
|
+
for (const time3 of times) {
|
|
31588
|
+
await renderSettledFrame(page, time3);
|
|
31589
|
+
const frame = await page.screenshot({ type: FRAME_TYPE, quality: FRAME_QUALITY });
|
|
31590
|
+
images.push({ base64: frame.toString("base64"), mimeType: FRAME_MIME });
|
|
31576
31591
|
}
|
|
31577
|
-
|
|
31578
|
-
|
|
31579
|
-
|
|
31580
|
-
|
|
31581
|
-
|
|
31582
|
-
|
|
31583
|
-
|
|
31584
|
-
|
|
31585
|
-
|
|
31586
|
-
|
|
31587
|
-
|
|
31588
|
-
|
|
31589
|
-
|
|
31590
|
-
|
|
31592
|
+
return { images, duration: duration3 };
|
|
31593
|
+
} finally {
|
|
31594
|
+
await release();
|
|
31595
|
+
}
|
|
31596
|
+
}
|
|
31597
|
+
function normalizeBundle(html) {
|
|
31598
|
+
return html.replace(
|
|
31599
|
+
"style-src 'unsafe-inline';",
|
|
31600
|
+
"style-src 'unsafe-inline' https://fonts.googleapis.com;"
|
|
31601
|
+
).replace("font-src data:;", "font-src data: https://fonts.gstatic.com;");
|
|
31602
|
+
}
|
|
31603
|
+
var INLINEABLE = /\.(png|jpe?g|gif|webp|svg|mp4|webm|mov|woff2)$/i;
|
|
31604
|
+
function skippedMedia(html, slug) {
|
|
31605
|
+
const pattern = new RegExp(`/scenes/${slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/[^"'\\s)]+`, "g");
|
|
31606
|
+
return [...new Set(html.match(pattern) ?? [])].filter((path) => INLINEABLE.test(path));
|
|
31607
|
+
}
|
|
31608
|
+
async function resolvePreview(ctx, draftId) {
|
|
31609
|
+
const draft = await ctx.invoke("get-draft", { draftId });
|
|
31610
|
+
const result = await ctx.invoke("list-projects", { projectId: draft.projectId });
|
|
31611
|
+
const row = result.project.drafts.find((entry) => entry.id === draftId);
|
|
31612
|
+
const preview = (row?.artifacts ?? []).find((artifact) => artifact.kind === "preview");
|
|
31613
|
+
if (!preview?.url) {
|
|
31614
|
+
throw new Error(
|
|
31615
|
+
`Draft ${draftId} has no preview bundle yet. Save App.tsx and direction.ts with write_draft_files, wait for the build to finish, then capture frames.`
|
|
31616
|
+
);
|
|
31617
|
+
}
|
|
31618
|
+
return {
|
|
31619
|
+
url: preview.url,
|
|
31620
|
+
slug: draft.slug,
|
|
31621
|
+
revision: draft.revision,
|
|
31622
|
+
artifactRevision: preview.source_revision
|
|
31623
|
+
};
|
|
31624
|
+
}
|
|
31625
|
+
async function openPreview(ctx, draftId) {
|
|
31626
|
+
const preview = await resolvePreview(ctx, draftId);
|
|
31627
|
+
const warnings = [];
|
|
31628
|
+
if (preview.artifactRevision !== preview.revision) {
|
|
31629
|
+
warnings.push(
|
|
31630
|
+
`The preview bundle is at revision ${preview.artifactRevision} but the draft is at ${preview.revision}. These frames show older code \u2014 save again and wait for the build.`
|
|
31631
|
+
);
|
|
31632
|
+
}
|
|
31633
|
+
const response = await fetch(preview.url);
|
|
31634
|
+
if (!response.ok) {
|
|
31635
|
+
throw new Error(
|
|
31636
|
+
`Preview download failed (${response.status}). Signed preview URLs expire after 15 minutes; retry to get a fresh one.`
|
|
31637
|
+
);
|
|
31638
|
+
}
|
|
31639
|
+
const html = normalizeBundle(await response.text());
|
|
31640
|
+
const skipped = skippedMedia(html, preview.slug);
|
|
31641
|
+
if (skipped.length) {
|
|
31642
|
+
warnings.push(
|
|
31643
|
+
`The preview omits ${skipped.length} asset(s) that were too large to inline (${skipped.join(", ")}). They render blank or black here but will be correct in the exported MP4.`
|
|
31644
|
+
);
|
|
31645
|
+
}
|
|
31646
|
+
const advice = await launcherAdvice();
|
|
31647
|
+
if (advice) warnings.push(advice);
|
|
31648
|
+
const { context, release } = await acquireContext({ width: 1920, height: 1080 });
|
|
31649
|
+
try {
|
|
31650
|
+
const page = await context.newPage();
|
|
31651
|
+
await page.route(
|
|
31652
|
+
`${PREVIEW_ORIGIN}/**`,
|
|
31653
|
+
(route) => route.fulfill({ status: 200, contentType: "text/html; charset=utf-8", body: html })
|
|
31654
|
+
);
|
|
31655
|
+
await page.goto(PREVIEW_URL, { waitUntil: "load", timeout: 6e4 });
|
|
31656
|
+
await page.waitForFunction(() => window.__ready === true, void 0, {
|
|
31657
|
+
timeout: 6e4
|
|
31591
31658
|
});
|
|
31592
|
-
const
|
|
31593
|
-
if (
|
|
31594
|
-
|
|
31595
|
-
if (parsedContent.success) {
|
|
31596
|
-
ctx.qualityLoopState?.lockedContentByDraft.set(draft.id, parsedContent.data.lines);
|
|
31597
|
-
}
|
|
31598
|
-
const parsedConcept = conceptSchema.safeParse(args.concept);
|
|
31599
|
-
if (parsedConcept.success) {
|
|
31600
|
-
ctx.qualityLoopState?.beatsByDraft.set(
|
|
31601
|
-
draft.id,
|
|
31602
|
-
parsedConcept.data.beats.map((beat) => ({
|
|
31603
|
-
start: beat.start,
|
|
31604
|
-
end: beat.end,
|
|
31605
|
-
subject: beat.subject,
|
|
31606
|
-
energy: beat.energy
|
|
31607
|
-
}))
|
|
31608
|
-
);
|
|
31609
|
-
}
|
|
31659
|
+
const config2 = await page.evaluate(() => window.__config);
|
|
31660
|
+
if (config2?.width && config2?.height) {
|
|
31661
|
+
await page.setViewportSize({ width: config2.width, height: config2.height });
|
|
31610
31662
|
}
|
|
31611
|
-
|
|
31612
|
-
|
|
31613
|
-
|
|
31614
|
-
|
|
31615
|
-
|
|
31616
|
-
libraryVersion: prepared.libraryVersion,
|
|
31617
|
-
engineVersion: prepared.engineVersion,
|
|
31618
|
-
studioUrl: studioLink(ctx, prepared.projectId, draft?.slug),
|
|
31619
|
-
...ctx.qualityLoop ? {
|
|
31620
|
-
creativeContract: args.creativeContract,
|
|
31621
|
-
qualityLoop: {
|
|
31622
|
-
enabled: true,
|
|
31623
|
-
next: 'Build the proof beat first, then call get_frames with reviewMode="proof" before completing the full film.'
|
|
31624
|
-
}
|
|
31625
|
-
} : {},
|
|
31626
|
-
next: ctx.qualityLoop ? "Read impractical://engine.d.ts, build the proof beat first, then review it before completing the film." : "Read impractical://engine.d.ts, then write App.tsx and direction.ts."
|
|
31627
|
-
});
|
|
31628
|
-
}
|
|
31629
|
-
};
|
|
31630
|
-
var listDraftFiles = {
|
|
31631
|
-
name: "list_draft_files",
|
|
31632
|
-
title: "List a draft's files",
|
|
31633
|
-
description: "List the source files of a draft with their sizes and the current revision. Cheap orientation \u2014 prefer this over reading every file. If notes.json is present, it holds the user's queued timecoded edit requests \u2014 read it before directing.",
|
|
31634
|
-
inputSchema: { draftId: external_exports.string().describe("The draft id.") },
|
|
31635
|
-
handler: async (args, ctx) => {
|
|
31636
|
-
const draft = await getDraft(ctx, String(args.draftId));
|
|
31637
|
-
return json2({
|
|
31638
|
-
draftId: draft.draftId,
|
|
31639
|
-
projectId: draft.projectId,
|
|
31640
|
-
slug: draft.slug,
|
|
31641
|
-
status: draft.status,
|
|
31642
|
-
revision: draft.revision,
|
|
31643
|
-
lockedByJobId: draft.lockedByJobId,
|
|
31644
|
-
files: Object.entries(draft.files).map(([path, content]) => ({ path, bytes: Buffer.byteLength(content, "utf8") })).sort((a, b) => a.path.localeCompare(b.path))
|
|
31663
|
+
const duration3 = Number(await page.evaluate(() => window.__duration)) || 0;
|
|
31664
|
+
const fps = Number(config2?.fps) || 30;
|
|
31665
|
+
const fontsFailed = await page.evaluate(async () => {
|
|
31666
|
+
await document.fonts.ready;
|
|
31667
|
+
return [...document.fonts].some((font) => font.status === "error");
|
|
31645
31668
|
});
|
|
31646
|
-
|
|
31647
|
-
|
|
31648
|
-
|
|
31649
|
-
name: "read_draft_file",
|
|
31650
|
-
title: "Read one draft file",
|
|
31651
|
-
description: "Read the contents of a single file in a draft. notes.json, when present, is the user's edit queue: [{ id, at, text, done, mentions? }] \u2014 timecoded requests written while watching. Address open items and set done: true on each one you handle.",
|
|
31652
|
-
inputSchema: {
|
|
31653
|
-
draftId: external_exports.string().describe("The draft id."),
|
|
31654
|
-
path: external_exports.string().describe("File path within the draft, e.g. 'direction.ts'."),
|
|
31655
|
-
startLine: external_exports.number().int().min(1).optional().describe("1-based first line to return \u2014 page large files instead of truncating."),
|
|
31656
|
-
lineCount: external_exports.number().int().min(1).optional().describe("Number of lines to return from startLine.")
|
|
31657
|
-
},
|
|
31658
|
-
handler: async (args, ctx) => {
|
|
31659
|
-
const draft = await getDraft(ctx, String(args.draftId));
|
|
31660
|
-
const path = String(args.path);
|
|
31661
|
-
const content = draft.files[path];
|
|
31662
|
-
if (content === void 0) {
|
|
31663
|
-
return fail(
|
|
31664
|
-
`No file "${path}" in draft ${args.draftId}. Available: ${Object.keys(draft.files).sort().join(", ")}`
|
|
31669
|
+
if (fontsFailed) {
|
|
31670
|
+
warnings.push(
|
|
31671
|
+
"Some web fonts failed to load, so typography in these frames is not final. Frame capture needs network access to fonts.googleapis.com."
|
|
31665
31672
|
);
|
|
31666
31673
|
}
|
|
31667
|
-
|
|
31668
|
-
|
|
31669
|
-
|
|
31670
|
-
|
|
31671
|
-
|
|
31672
|
-
|
|
31673
|
-
|
|
31674
|
-
|
|
31675
|
-
|
|
31674
|
+
return { context, page, duration: duration3, fps, revision: preview.artifactRevision, warnings, release };
|
|
31675
|
+
} catch (error51) {
|
|
31676
|
+
await release();
|
|
31677
|
+
throw error51;
|
|
31678
|
+
}
|
|
31679
|
+
}
|
|
31680
|
+
async function captureFrames(ctx, input) {
|
|
31681
|
+
const session = await openPreview(ctx, input.draftId);
|
|
31682
|
+
const { page, duration: duration3, warnings, revision } = session;
|
|
31683
|
+
try {
|
|
31684
|
+
const images = [];
|
|
31685
|
+
for (const time3 of input.times) {
|
|
31686
|
+
const clamped = duration3 > 0 ? Math.max(0, Math.min(time3, duration3)) : Math.max(0, time3);
|
|
31687
|
+
await renderSettledFrame(page, clamped);
|
|
31688
|
+
const frame = await page.screenshot({ type: FRAME_TYPE, quality: FRAME_QUALITY });
|
|
31689
|
+
images.push({ base64: frame.toString("base64"), mimeType: FRAME_MIME });
|
|
31676
31690
|
}
|
|
31691
|
+
return { images, warnings, revision, duration: duration3 };
|
|
31692
|
+
} finally {
|
|
31693
|
+
await session.release();
|
|
31694
|
+
}
|
|
31695
|
+
}
|
|
31696
|
+
var MEASURE_REPORT_CAP = 48;
|
|
31697
|
+
async function measureStage(ctx, draftId) {
|
|
31698
|
+
const session = await openPreview(ctx, draftId);
|
|
31699
|
+
try {
|
|
31700
|
+
await renderSettledFrame(session.page, 0);
|
|
31701
|
+
const probe = await session.page.evaluate(
|
|
31702
|
+
({ cap }) => {
|
|
31703
|
+
let tweenCount = 0;
|
|
31704
|
+
const countLeaves = (node) => {
|
|
31705
|
+
for (const child of node.getChildren?.(false, true, true) ?? []) {
|
|
31706
|
+
if (typeof child.getChildren === "function") countLeaves(child);
|
|
31707
|
+
else tweenCount += 1;
|
|
31708
|
+
}
|
|
31709
|
+
};
|
|
31710
|
+
const root = window.__timeline;
|
|
31711
|
+
if (root && typeof root.getChildren === "function") countLeaves(root);
|
|
31712
|
+
const width = window.innerWidth;
|
|
31713
|
+
const height = window.innerHeight;
|
|
31714
|
+
const seen = /* @__PURE__ */ new Map();
|
|
31715
|
+
const elements = [];
|
|
31716
|
+
let truncated = 0;
|
|
31717
|
+
for (const el of Array.from(document.querySelectorAll("*"))) {
|
|
31718
|
+
const attr = Array.from(el.attributes).find(
|
|
31719
|
+
(a) => a.name.startsWith("data-") && !a.name.startsWith("data-studio-")
|
|
31720
|
+
);
|
|
31721
|
+
if (!attr) continue;
|
|
31722
|
+
const base = attr.value ? `[${attr.name}="${attr.value}"]` : `[${attr.name}]`;
|
|
31723
|
+
const count = (seen.get(base) ?? 0) + 1;
|
|
31724
|
+
seen.set(base, count);
|
|
31725
|
+
if (elements.length >= cap) {
|
|
31726
|
+
truncated += 1;
|
|
31727
|
+
continue;
|
|
31728
|
+
}
|
|
31729
|
+
const sel = count > 1 ? `${base}~${count}` : base;
|
|
31730
|
+
const rect = el.getBoundingClientRect();
|
|
31731
|
+
const style = getComputedStyle(el);
|
|
31732
|
+
const hidden = rect.width * rect.height < 1 || style.display === "none" || style.visibility === "hidden" || Number(style.opacity) < 0.02;
|
|
31733
|
+
const offstage = !hidden && (rect.right <= 0 || rect.bottom <= 0 || rect.left >= width || rect.top >= height);
|
|
31734
|
+
elements.push({
|
|
31735
|
+
sel,
|
|
31736
|
+
x: rect.x,
|
|
31737
|
+
y: rect.y,
|
|
31738
|
+
w: rect.width,
|
|
31739
|
+
h: rect.height,
|
|
31740
|
+
...hidden ? { hidden: true } : {},
|
|
31741
|
+
...offstage ? { offstage: true } : {}
|
|
31742
|
+
});
|
|
31743
|
+
}
|
|
31744
|
+
return { tweenCount, elements, truncated };
|
|
31745
|
+
},
|
|
31746
|
+
{ cap: MEASURE_REPORT_CAP }
|
|
31747
|
+
);
|
|
31677
31748
|
return {
|
|
31678
|
-
|
|
31679
|
-
|
|
31749
|
+
revision: session.revision,
|
|
31750
|
+
duration: session.duration,
|
|
31751
|
+
tweenCount: probe.tweenCount,
|
|
31752
|
+
elements: probe.elements,
|
|
31753
|
+
truncated: probe.truncated
|
|
31680
31754
|
};
|
|
31755
|
+
} finally {
|
|
31756
|
+
await session.release();
|
|
31681
31757
|
}
|
|
31682
|
-
}
|
|
31683
|
-
var
|
|
31684
|
-
name: "
|
|
31685
|
-
title: "
|
|
31686
|
-
description: "
|
|
31687
|
-
inputSchema: {
|
|
31688
|
-
draftId: external_exports.string().describe("The draft id."),
|
|
31689
|
-
files: external_exports.record(external_exports.string(), external_exports.string()).describe("Map of file path to full file content."),
|
|
31690
|
-
deletedPaths: external_exports.array(external_exports.string()).optional().describe("Paths to remove from the draft.")
|
|
31691
|
-
},
|
|
31692
|
-
handler: async (args, ctx) => {
|
|
31693
|
-
const files = args.files ?? {};
|
|
31694
|
-
const deletedPaths = args.deletedPaths ?? [];
|
|
31695
|
-
return saveDraft(ctx, String(args.draftId), () => ({ files, deletedPaths }));
|
|
31696
|
-
}
|
|
31697
|
-
};
|
|
31698
|
-
var editDraftFile = {
|
|
31699
|
-
name: "edit_draft_file",
|
|
31700
|
-
title: "Edit one draft file",
|
|
31701
|
-
description: "Replace an exact string in one draft file. The read, the edit and the write happen in a single call against the current revision, so concurrent edits from the web tweak chat cannot clobber yours. Returns the committed revision and preview-build job id immediately; follow that id with job_status.",
|
|
31758
|
+
}
|
|
31759
|
+
var getFramesTool = {
|
|
31760
|
+
name: "get_frames",
|
|
31761
|
+
title: "See rendered frames",
|
|
31762
|
+
description: "Render frames of the draft at the given times (in seconds) and return them as images, so you can SEE your work instead of guessing. Runs on this machine against the cloud preview bundle \u2014 it needs Chrome installed. Ask for several times in one call: the frames are nearly free once the page is open. Always look at frames before telling the user a change is done.",
|
|
31702
31763
|
inputSchema: {
|
|
31703
31764
|
draftId: external_exports.string().describe("The draft id."),
|
|
31704
|
-
|
|
31705
|
-
|
|
31706
|
-
|
|
31707
|
-
|
|
31765
|
+
times: external_exports.array(external_exports.number().min(0)).min(1).max(12).describe("Timeline positions in seconds, e.g. [0, 1.5, 3]."),
|
|
31766
|
+
reviewMode: external_exports.enum(["layout", "proof", "final"]).optional().describe(
|
|
31767
|
+
"Opt-in quality-loop lens. proof judges the core transformation before the full film; final requires broad timeline coverage and produces export-eligible review evidence."
|
|
31768
|
+
)
|
|
31708
31769
|
},
|
|
31770
|
+
local: true,
|
|
31709
31771
|
handler: async (args, ctx) => {
|
|
31710
|
-
const
|
|
31711
|
-
|
|
31712
|
-
|
|
31713
|
-
|
|
31714
|
-
|
|
31715
|
-
|
|
31716
|
-
|
|
31717
|
-
|
|
31718
|
-
|
|
31719
|
-
|
|
31720
|
-
|
|
31721
|
-
|
|
31722
|
-
|
|
31723
|
-
|
|
31724
|
-
|
|
31725
|
-
|
|
31726
|
-
|
|
31727
|
-
|
|
31728
|
-
|
|
31729
|
-
|
|
31730
|
-
|
|
31772
|
+
const capture = await captureFrames(ctx, {
|
|
31773
|
+
draftId: String(args.draftId),
|
|
31774
|
+
times: args.times
|
|
31775
|
+
});
|
|
31776
|
+
ctx.sessionState?.lastCaptureByDraft.set(String(args.draftId), {
|
|
31777
|
+
revision: capture.revision,
|
|
31778
|
+
times: [...args.times]
|
|
31779
|
+
});
|
|
31780
|
+
const lines = [
|
|
31781
|
+
`Captured ${capture.images.length} frame(s) at revision ${capture.revision} (scene duration ${capture.duration.toFixed(2)}s).`,
|
|
31782
|
+
...capture.warnings.map((warning) => `WARNING: ${warning}`)
|
|
31783
|
+
];
|
|
31784
|
+
const reviewMode = args.reviewMode;
|
|
31785
|
+
if (ctx.qualityLoop) {
|
|
31786
|
+
if (!reviewMode) {
|
|
31787
|
+
lines.push(
|
|
31788
|
+
'QUALITY LOOP: choose reviewMode="proof" for the core proof beat or reviewMode="final" for the export review.'
|
|
31789
|
+
);
|
|
31790
|
+
} else {
|
|
31791
|
+
if (reviewMode === "final") {
|
|
31792
|
+
const coverageError = finalReviewCoverageError(args.times, capture.duration);
|
|
31793
|
+
const stalePreview = capture.warnings.some(
|
|
31794
|
+
(warning) => warning.startsWith("The preview bundle is at revision ")
|
|
31795
|
+
);
|
|
31796
|
+
if (stalePreview) {
|
|
31797
|
+
lines.push(
|
|
31798
|
+
"QUALITY REVIEW NOT RECORDED: these frames came from a stale preview bundle. Wait for the current revision to build, then review it again."
|
|
31799
|
+
);
|
|
31800
|
+
} else if (coverageError) {
|
|
31801
|
+
lines.push(`QUALITY REVIEW NOT RECORDED: ${coverageError}`);
|
|
31802
|
+
} else {
|
|
31803
|
+
recordFinalReviewCapture(ctx.qualityLoopState, String(args.draftId), {
|
|
31804
|
+
revision: capture.revision,
|
|
31805
|
+
duration: capture.duration,
|
|
31806
|
+
times: [...args.times]
|
|
31807
|
+
});
|
|
31808
|
+
lines.push(
|
|
31809
|
+
`FINAL REVIEW RECORDED for revision ${capture.revision}. Any later edit invalidates this evidence.`
|
|
31810
|
+
);
|
|
31811
|
+
}
|
|
31731
31812
|
}
|
|
31732
|
-
|
|
31733
|
-
return { files: { [path]: next }, deletedPaths: [] };
|
|
31813
|
+
lines.push(qualityReviewPrompt(reviewMode));
|
|
31734
31814
|
}
|
|
31735
|
-
|
|
31736
|
-
return
|
|
31737
|
-
}
|
|
31738
|
-
};
|
|
31739
|
-
var jobStatus = {
|
|
31740
|
-
name: "job_status",
|
|
31741
|
-
title: "Check job status",
|
|
31742
|
-
description: "Check Impractical Motion jobs (preview builds, exports). Set wait to poll until they finish.",
|
|
31743
|
-
inputSchema: {
|
|
31744
|
-
jobIds: external_exports.array(external_exports.string()).min(1).max(20).describe("Job ids to check."),
|
|
31745
|
-
wait: external_exports.boolean().optional().describe("Poll until every job reaches a terminal state."),
|
|
31746
|
-
waitSeconds: external_exports.number().optional().describe(`Polling budget when wait is true. Default ${JOB_WAIT_SECONDS}, capped at ${maxWaitSeconds()}s. When the budget runs out the call returns the job ids instead of failing \u2014 call it again to keep waiting.`)
|
|
31747
|
-
},
|
|
31748
|
-
handler: async (args, ctx, progress) => {
|
|
31749
|
-
const jobIds = args.jobIds;
|
|
31750
|
-
if (!args.wait) return json2(await ctx.invoke("job-status", { jobIds }));
|
|
31751
|
-
const budgetMs = waitBudgetMs(args.waitSeconds, JOB_WAIT_SECONDS);
|
|
31752
|
-
const now = ctx.now ?? Date.now;
|
|
31753
|
-
const started = now();
|
|
31754
|
-
const deadline = started + budgetMs;
|
|
31755
|
-
let rows = [];
|
|
31756
|
-
let consecutiveErrors = 0;
|
|
31757
|
-
let lastError = "";
|
|
31758
|
-
while (now() < deadline) {
|
|
31759
|
-
if (progress?.signal?.aborted) break;
|
|
31760
|
-
try {
|
|
31761
|
-
const result = await ctx.invoke("job-status", { jobIds });
|
|
31762
|
-
rows = result.jobs;
|
|
31763
|
-
consecutiveErrors = 0;
|
|
31764
|
-
if (rows.length && rows.every((row) => TERMINAL.has(row.status))) return json2(rows);
|
|
31765
|
-
} catch (error51) {
|
|
31766
|
-
lastError = error51 instanceof Error ? error51.message : String(error51);
|
|
31767
|
-
if (++consecutiveErrors >= POLL_ERROR_LIMIT) {
|
|
31768
|
-
return json2({ pollError: lastError, stillRunning: jobIds, jobs: rows });
|
|
31769
|
-
}
|
|
31770
|
-
}
|
|
31771
|
-
progress?.report?.({
|
|
31772
|
-
progress: Math.min(now() - started, budgetMs),
|
|
31773
|
-
total: budgetMs,
|
|
31774
|
-
message: rows.length ? rows.map((row) => describeJob(row.id, row, consecutiveErrors, lastError)).join("; ") : jobIds.join(", ")
|
|
31775
|
-
});
|
|
31776
|
-
const remainingMs = deadline - now();
|
|
31777
|
-
if (remainingMs > 0) await nap(ctx, Math.min(2e3, remainingMs));
|
|
31778
|
-
}
|
|
31779
|
-
return json2({
|
|
31780
|
-
timedOut: true,
|
|
31781
|
-
stillRunning: jobIds,
|
|
31782
|
-
jobs: rows,
|
|
31783
|
-
resumeWith: `job_status({ jobIds: ${JSON.stringify(jobIds)}, wait: true })`,
|
|
31784
|
-
note: "The jobs are still running in the cloud \u2014 this is a pause, not a failure. Call the command in resumeWith to keep following them." + clampNote(args.waitSeconds)
|
|
31785
|
-
});
|
|
31786
|
-
}
|
|
31787
|
-
};
|
|
31788
|
-
var exportVideo = {
|
|
31789
|
-
name: "export_video",
|
|
31790
|
-
title: "Export the final MP4",
|
|
31791
|
-
description: "Start the final MP4 render and return its job id immediately. Follow that id with job_status for the signed download URL. Only export once the user is happy with the frames.",
|
|
31792
|
-
inputSchema: {
|
|
31793
|
-
draftId: external_exports.string().describe("The draft id."),
|
|
31794
|
-
qualityReview: qualityReviewSchema.optional().describe(
|
|
31795
|
-
"Required only by the opt-in local quality loop: the exact reviewed revision, creative claim/proof, reference comparison, eight category scores, timestamp evidence, and blockers."
|
|
31796
|
-
)
|
|
31797
|
-
},
|
|
31798
|
-
handler: async (args, ctx) => {
|
|
31799
|
-
if (ctx.qualityLoop) {
|
|
31800
|
-
const capture = ctx.qualityLoopState?.finalReviewByDraft.get(String(args.draftId));
|
|
31801
|
-
const failure = qualityReviewFailure({
|
|
31802
|
-
draftRevision: capture?.revision ?? -1,
|
|
31803
|
-
capture,
|
|
31804
|
-
review: args.qualityReview
|
|
31805
|
-
}) ?? motionCoverageFailure({
|
|
31806
|
-
motionLab: ctx.motionLab === true,
|
|
31807
|
-
capture: ctx.qualityLoopState?.finalMotionReviewByDraft.get(String(args.draftId)),
|
|
31808
|
-
frameReviewRevision: capture?.revision ?? -1
|
|
31809
|
-
});
|
|
31810
|
-
if (failure) return fail(`Quality gate blocked export: ${failure}`);
|
|
31811
|
-
}
|
|
31812
|
-
const started = await ctx.invoke("start-export", { draftId: args.draftId });
|
|
31813
|
-
return json2({
|
|
31814
|
-
started: true,
|
|
31815
|
-
draftId: String(args.draftId),
|
|
31816
|
-
jobId: started.jobId,
|
|
31817
|
-
status: "queued",
|
|
31818
|
-
resumeWith: resumeWith(started.jobId),
|
|
31819
|
-
note: "Export started. Do not start another export. Follow this job with resumeWith to obtain the signed download URL."
|
|
31820
|
-
});
|
|
31821
|
-
}
|
|
31822
|
-
};
|
|
31823
|
-
var brandList = {
|
|
31824
|
-
name: "brand_list",
|
|
31825
|
-
title: "List brand kits",
|
|
31826
|
-
description: "List the brand kits saved in this workspace.",
|
|
31827
|
-
inputSchema: {},
|
|
31828
|
-
handler: async (_args, ctx) => json2(await ctx.invoke("brand-kits", { action: "list" }))
|
|
31829
|
-
};
|
|
31830
|
-
var brandGet = {
|
|
31831
|
-
name: "brand_get",
|
|
31832
|
-
title: "Get a brand kit",
|
|
31833
|
-
description: "Get one brand kit \u2014 its colors, fonts and logo. Treat these as law when directing.",
|
|
31834
|
-
inputSchema: { slug: external_exports.string().describe("Brand kit slug.") },
|
|
31835
|
-
handler: async (args, ctx) => json2(await ctx.invoke("brand-kits", { action: "get", slug: args.slug }))
|
|
31836
|
-
};
|
|
31837
|
-
var brandExtract = {
|
|
31838
|
-
name: "brand_extract",
|
|
31839
|
-
title: "Extract a brand kit from a URL",
|
|
31840
|
-
description: "Start extracting a brand kit (colors, fonts, logo) from a website and return the run id immediately. To observe an existing run, call this tool with jobId instead of url; that status mode never starts another extraction.",
|
|
31841
|
-
inputSchema: {
|
|
31842
|
-
url: external_exports.string().optional().describe("The brand website URL. Omit when continuing by jobId."),
|
|
31843
|
-
jobId: external_exports.string().optional().describe("Run id returned by a previous brand_extract call."),
|
|
31844
|
-
reextract: external_exports.boolean().optional().describe("Re-extract even if a kit already exists."),
|
|
31845
|
-
wait: external_exports.boolean().optional().describe("With jobId, poll until this extraction is terminal."),
|
|
31846
|
-
waitSeconds: external_exports.number().optional().describe(
|
|
31847
|
-
`With jobId and wait, poll for up to ${BRAND_WAIT_SECONDS}s by default, capped at ${maxWaitSeconds()}s.`
|
|
31848
|
-
)
|
|
31849
|
-
},
|
|
31850
|
-
handler: async (args, ctx, progress) => {
|
|
31851
|
-
const url2 = typeof args.url === "string" ? args.url : "";
|
|
31852
|
-
const jobId = typeof args.jobId === "string" ? args.jobId : "";
|
|
31853
|
-
if (url2 && jobId) return fail("Pass either url to start or jobId to continue, not both.");
|
|
31854
|
-
if (!url2 && !jobId) return fail("Pass url to start an extraction, or jobId to continue one.");
|
|
31855
|
-
if (url2) {
|
|
31856
|
-
const started = await ctx.invoke("brand-extraction", {
|
|
31857
|
-
action: "start",
|
|
31858
|
-
url: url2,
|
|
31859
|
-
...args.reextract ? { reextract: true } : {}
|
|
31860
|
-
});
|
|
31861
|
-
return json2({
|
|
31862
|
-
started: true,
|
|
31863
|
-
jobId: started.job,
|
|
31864
|
-
slug: started.slug,
|
|
31865
|
-
status: "queued",
|
|
31866
|
-
resumeWith: `brand_extract({ jobId: "${started.job}", wait: true })`,
|
|
31867
|
-
note: "Brand extraction started. Do not call brand_extract with the URL again. Continue this exact run with resumeWith, then pass the completed slug to create_video as brandSlug."
|
|
31868
|
-
});
|
|
31869
|
-
}
|
|
31870
|
-
const readStatus = () => ctx.invoke("brand-extraction", { action: "status", job: jobId });
|
|
31871
|
-
if (!args.wait) {
|
|
31872
|
-
const status = await readStatus();
|
|
31873
|
-
if (status.status === "error" || status.status === "failed" || status.error) {
|
|
31874
|
-
return fail(`Brand extraction failed${status.error ? `: ${status.error}` : ""}`);
|
|
31875
|
-
}
|
|
31876
|
-
return json2({ jobId, ...status });
|
|
31877
|
-
}
|
|
31878
|
-
const budgetMs = waitBudgetMs(args.waitSeconds, BRAND_WAIT_SECONDS);
|
|
31879
|
-
const startedAt = Date.now();
|
|
31880
|
-
const deadline = startedAt + budgetMs;
|
|
31881
|
-
let consecutiveErrors = 0;
|
|
31882
|
-
let lastError = "";
|
|
31883
|
-
let stage = "";
|
|
31884
|
-
let lastStatus = {};
|
|
31885
|
-
while (Date.now() < deadline) {
|
|
31886
|
-
if (progress?.signal?.aborted) break;
|
|
31887
|
-
try {
|
|
31888
|
-
const status = await readStatus();
|
|
31889
|
-
lastStatus = status;
|
|
31890
|
-
consecutiveErrors = 0;
|
|
31891
|
-
stage = status.stage ?? stage;
|
|
31892
|
-
if (status.status === "done") {
|
|
31893
|
-
return json2({ jobId, ...status });
|
|
31894
|
-
}
|
|
31895
|
-
if (status.status === "error" || status.status === "failed" || status.error) {
|
|
31896
|
-
return fail(`Brand extraction failed${status.error ? `: ${status.error}` : ""}`);
|
|
31897
|
-
}
|
|
31898
|
-
} catch (error51) {
|
|
31899
|
-
lastError = error51 instanceof Error ? error51.message : String(error51);
|
|
31900
|
-
if (++consecutiveErrors >= POLL_ERROR_LIMIT) break;
|
|
31901
|
-
}
|
|
31902
|
-
progress?.report?.({
|
|
31903
|
-
progress: Math.min(Date.now() - startedAt, budgetMs),
|
|
31904
|
-
total: budgetMs,
|
|
31905
|
-
message: `brand run ${jobId}: ${stage || "extracting"}`
|
|
31906
|
-
});
|
|
31907
|
-
await nap(ctx, 2e3);
|
|
31908
|
-
}
|
|
31909
|
-
return json2({
|
|
31910
|
-
timedOut: true,
|
|
31911
|
-
jobId,
|
|
31912
|
-
status: lastStatus,
|
|
31913
|
-
...lastError ? { pollError: lastError } : {},
|
|
31914
|
-
resumeWith: `brand_extract({ jobId: "${jobId}", wait: true })`,
|
|
31915
|
-
note: "The extraction is still running \u2014 this is a pause, not a failure. Continue the same run with resumeWith; never call brand_extract with the URL again for this run." + clampNote(args.waitSeconds)
|
|
31916
|
-
});
|
|
31917
|
-
}
|
|
31918
|
-
};
|
|
31919
|
-
var motionProfile = {
|
|
31920
|
-
name: "motion_profile",
|
|
31921
|
-
title: "Manage the personal motion profile",
|
|
31922
|
-
description: "Inspect and manage the user's learned motion preferences. Call with action 'get' to see the effective profile before directing. Use 'remember' ONLY when the user explicitly asks to remember a preference; use 'propose' to submit an inferred preference as a pending proposal for the user to confirm \u2014 never confirm an inferred preference yourself. 'confirm'/'reject' decide a pending proposal after asking the user; 'forget', 'pause', 'resume' and 'reset' manage stored preferences on the user's behalf.",
|
|
31923
|
-
inputSchema: {
|
|
31924
|
-
action: external_exports.enum(["get", "remember", "propose", "confirm", "reject", "forget", "pause", "resume", "reset"]).describe("The profile command to run."),
|
|
31925
|
-
rule: external_exports.record(external_exports.string(), external_exports.unknown()).optional().describe(
|
|
31926
|
-
"Structured preference for remember/propose, e.g. {kind:'trait',trait:'pacing',value:'fast'}, {kind:'library',itemType:'style',itemId:'optical',stance:'prefer'}, {kind:'freeform',text:'...'}, {kind:'duration',max:15}, or {kind:'example',draftId:'...'}."
|
|
31927
|
-
),
|
|
31928
|
-
eventId: external_exports.string().optional().describe("Pending proposal id for confirm/reject."),
|
|
31929
|
-
ruleKey: external_exports.string().optional().describe("Rule key to forget, from the get result."),
|
|
31930
|
-
scope: external_exports.enum(["workspace", "brand", "user", "user_brand"]).optional().describe("Target layer. Defaults to the personal layer; shared layers need admin."),
|
|
31931
|
-
brandSlug: external_exports.string().optional().describe("Brand slug for brand-scoped preferences."),
|
|
31932
|
-
contentType: external_exports.string().optional().describe("Content type this preference applies to."),
|
|
31933
|
-
draftId: external_exports.string().optional().describe("Draft that motivated a proposal.")
|
|
31934
|
-
},
|
|
31935
|
-
handler: async (args, ctx) => json2(await ctx.invoke("motion-profile", args))
|
|
31936
|
-
};
|
|
31937
|
-
var studioLinkTool = {
|
|
31938
|
-
name: "studio_link",
|
|
31939
|
-
title: "Get the studio URL",
|
|
31940
|
-
description: "Get the web studio URL where the user watches the cuts and tweaks them visually. Always give this to the user after a change lands.",
|
|
31941
|
-
inputSchema: {
|
|
31942
|
-
projectId: external_exports.string().describe("The project id."),
|
|
31943
|
-
slug: external_exports.string().optional().describe("Draft slug to open directly.")
|
|
31944
|
-
},
|
|
31945
|
-
handler: async (args, ctx) => ({
|
|
31946
|
-
text: studioLink(ctx, String(args.projectId), args.slug)
|
|
31947
|
-
})
|
|
31948
|
-
};
|
|
31949
|
-
var REMOTE_SAFE_TOOLS = [
|
|
31950
|
-
accountStatus,
|
|
31951
|
-
whoami,
|
|
31952
|
-
listProjects,
|
|
31953
|
-
getProject,
|
|
31954
|
-
createVideo,
|
|
31955
|
-
listDraftFiles,
|
|
31956
|
-
readDraftFile,
|
|
31957
|
-
writeDraftFiles,
|
|
31958
|
-
editDraftFile,
|
|
31959
|
-
jobStatus,
|
|
31960
|
-
exportVideo,
|
|
31961
|
-
brandList,
|
|
31962
|
-
brandGet,
|
|
31963
|
-
brandExtract,
|
|
31964
|
-
motionProfile,
|
|
31965
|
-
studioLinkTool
|
|
31966
|
-
];
|
|
31967
|
-
|
|
31968
|
-
// src/mcp/frames.ts
|
|
31969
|
-
import { chromium } from "playwright-core";
|
|
31970
|
-
var PREVIEW_ORIGIN = "https://preview.impractical.local";
|
|
31971
|
-
var PREVIEW_URL = `${PREVIEW_ORIGIN}/stage.html`;
|
|
31972
|
-
var FRAME_TYPE = "jpeg";
|
|
31973
|
-
var FRAME_QUALITY = 80;
|
|
31974
|
-
var FRAME_MIME = "image/jpeg";
|
|
31975
|
-
async function launchChromium(launcher = chromium) {
|
|
31976
|
-
if (process.env.CI) {
|
|
31977
|
-
try {
|
|
31978
|
-
return await launcher.launch({ headless: true });
|
|
31979
|
-
} catch (error51) {
|
|
31980
|
-
throw new Error(
|
|
31981
|
-
"CI requires the Playwright-managed Chromium pinned by cli/package-lock.json. Run `npx playwright-core install chromium`; system Chrome fallback is disabled in CI.",
|
|
31982
|
-
{ cause: error51 }
|
|
31983
|
-
);
|
|
31984
|
-
}
|
|
31985
|
-
}
|
|
31986
|
-
if (process.env.PLAYWRIGHT_CHANNEL === "chrome") {
|
|
31987
|
-
return launcher.launch({ channel: "chrome", headless: true });
|
|
31988
|
-
}
|
|
31989
|
-
try {
|
|
31990
|
-
return await launcher.launch({ headless: true });
|
|
31991
|
-
} catch {
|
|
31992
|
-
try {
|
|
31993
|
-
return await launcher.launch({ channel: "chrome", headless: true });
|
|
31994
|
-
} catch (error51) {
|
|
31995
|
-
throw new Error(
|
|
31996
|
-
`No usable Chromium found for frame capture. Install Google Chrome, or run \`npx playwright install chromium\`. If Chrome is installed but blocked by Gatekeeper, set PLAYWRIGHT_CHANNEL=chrome.
|
|
31997
|
-
Underlying error: ${error51 instanceof Error ? error51.message : String(error51)}`
|
|
31998
|
-
);
|
|
31999
|
-
}
|
|
32000
|
-
}
|
|
32001
|
-
}
|
|
32002
|
-
async function renderSettledFrame(page, time3) {
|
|
32003
|
-
await page.evaluate(
|
|
32004
|
-
(seconds) => (async () => {
|
|
32005
|
-
const stage = window;
|
|
32006
|
-
if (stage.__renderAt) stage.__renderAt(seconds);
|
|
32007
|
-
else stage.__seek(seconds);
|
|
32008
|
-
if (stage.__frameSettle) {
|
|
32009
|
-
await stage.__frameSettle();
|
|
32010
|
-
} else {
|
|
32011
|
-
await new Promise((resolve) => {
|
|
32012
|
-
const pending = [...document.querySelectorAll("video")].filter(
|
|
32013
|
-
(video) => video.currentSrc && !video.error && video.seeking
|
|
32014
|
-
);
|
|
32015
|
-
if (!pending.length) {
|
|
32016
|
-
resolve();
|
|
32017
|
-
return;
|
|
32018
|
-
}
|
|
32019
|
-
let finished = false;
|
|
32020
|
-
let remaining = pending.length;
|
|
32021
|
-
const finish = () => {
|
|
32022
|
-
if (finished) return;
|
|
32023
|
-
finished = true;
|
|
32024
|
-
clearTimeout(cap);
|
|
32025
|
-
resolve();
|
|
32026
|
-
};
|
|
32027
|
-
const cap = setTimeout(finish, 1e3);
|
|
32028
|
-
for (const video of pending) {
|
|
32029
|
-
video.addEventListener(
|
|
32030
|
-
"seeked",
|
|
32031
|
-
() => {
|
|
32032
|
-
remaining -= 1;
|
|
32033
|
-
if (remaining === 0) finish();
|
|
32034
|
-
},
|
|
32035
|
-
{ once: true }
|
|
32036
|
-
);
|
|
32037
|
-
}
|
|
32038
|
-
});
|
|
32039
|
-
}
|
|
32040
|
-
await new Promise(
|
|
32041
|
-
(resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
|
32042
|
-
);
|
|
32043
|
-
})(),
|
|
32044
|
-
time3
|
|
32045
|
-
);
|
|
32046
|
-
}
|
|
32047
|
-
async function captureReferenceFrames(input) {
|
|
32048
|
-
const browser = await launchChromium();
|
|
32049
|
-
try {
|
|
32050
|
-
const page = await browser.newPage({
|
|
32051
|
-
viewport: { width: 1920, height: 1080 },
|
|
32052
|
-
deviceScaleFactor: 1
|
|
32053
|
-
});
|
|
32054
|
-
const base = input.appUrl.replace(/\/$/, "");
|
|
32055
|
-
const url2 = `${base}/stage.html?scene=${encodeURIComponent(input.scene)}`;
|
|
32056
|
-
await page.goto(url2, { waitUntil: "load", timeout: 6e4 });
|
|
32057
|
-
await page.waitForFunction(() => window.__ready === true, void 0, {
|
|
32058
|
-
timeout: 6e4
|
|
32059
|
-
});
|
|
32060
|
-
const config2 = await page.evaluate(() => window.__config);
|
|
32061
|
-
if (config2?.width && config2?.height) {
|
|
32062
|
-
await page.setViewportSize({ width: config2.width, height: config2.height });
|
|
32063
|
-
}
|
|
32064
|
-
const duration3 = Number(await page.evaluate(() => window.__duration)) || 0;
|
|
32065
|
-
const count = Math.max(1, Math.min(input.count ?? 4, 6));
|
|
32066
|
-
const times = Array.from(
|
|
32067
|
-
{ length: count },
|
|
32068
|
-
(_, index) => duration3 > 0 ? index / Math.max(1, count - 1) * Math.max(0, duration3 - 0.05) : index
|
|
32069
|
-
);
|
|
32070
|
-
const images = [];
|
|
32071
|
-
for (const time3 of times) {
|
|
32072
|
-
await renderSettledFrame(page, time3);
|
|
32073
|
-
const frame = await page.screenshot({ type: FRAME_TYPE, quality: FRAME_QUALITY });
|
|
32074
|
-
images.push({ base64: frame.toString("base64"), mimeType: FRAME_MIME });
|
|
32075
|
-
}
|
|
32076
|
-
return { images, duration: duration3 };
|
|
32077
|
-
} finally {
|
|
32078
|
-
await browser.close().catch(() => void 0);
|
|
32079
|
-
}
|
|
32080
|
-
}
|
|
32081
|
-
function normalizeBundle(html) {
|
|
32082
|
-
return html.replace(
|
|
32083
|
-
"style-src 'unsafe-inline';",
|
|
32084
|
-
"style-src 'unsafe-inline' https://fonts.googleapis.com;"
|
|
32085
|
-
).replace("font-src data:;", "font-src data: https://fonts.gstatic.com;");
|
|
32086
|
-
}
|
|
32087
|
-
var INLINEABLE = /\.(png|jpe?g|gif|webp|svg|mp4|webm|mov|woff2)$/i;
|
|
32088
|
-
function skippedMedia(html, slug) {
|
|
32089
|
-
const pattern = new RegExp(`/scenes/${slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/[^"'\\s)]+`, "g");
|
|
32090
|
-
return [...new Set(html.match(pattern) ?? [])].filter((path) => INLINEABLE.test(path));
|
|
32091
|
-
}
|
|
32092
|
-
async function resolvePreview(ctx, draftId) {
|
|
32093
|
-
const draft = await ctx.invoke("get-draft", { draftId });
|
|
32094
|
-
const result = await ctx.invoke("list-projects", { projectId: draft.projectId });
|
|
32095
|
-
const row = result.project.drafts.find((entry) => entry.id === draftId);
|
|
32096
|
-
const preview = (row?.artifacts ?? []).find((artifact) => artifact.kind === "preview");
|
|
32097
|
-
if (!preview?.url) {
|
|
32098
|
-
throw new Error(
|
|
32099
|
-
`Draft ${draftId} has no preview bundle yet. Save App.tsx and direction.ts with write_draft_files, wait for the build to finish, then capture frames.`
|
|
32100
|
-
);
|
|
32101
|
-
}
|
|
32102
|
-
return {
|
|
32103
|
-
url: preview.url,
|
|
32104
|
-
slug: draft.slug,
|
|
32105
|
-
revision: draft.revision,
|
|
32106
|
-
artifactRevision: preview.source_revision
|
|
32107
|
-
};
|
|
32108
|
-
}
|
|
32109
|
-
async function openPreview(ctx, draftId) {
|
|
32110
|
-
const preview = await resolvePreview(ctx, draftId);
|
|
32111
|
-
const warnings = [];
|
|
32112
|
-
if (preview.artifactRevision !== preview.revision) {
|
|
32113
|
-
warnings.push(
|
|
32114
|
-
`The preview bundle is at revision ${preview.artifactRevision} but the draft is at ${preview.revision}. These frames show older code \u2014 save again and wait for the build.`
|
|
32115
|
-
);
|
|
32116
|
-
}
|
|
32117
|
-
const response = await fetch(preview.url);
|
|
32118
|
-
if (!response.ok) {
|
|
32119
|
-
throw new Error(
|
|
32120
|
-
`Preview download failed (${response.status}). Signed preview URLs expire after 15 minutes; retry to get a fresh one.`
|
|
32121
|
-
);
|
|
32122
|
-
}
|
|
32123
|
-
const html = normalizeBundle(await response.text());
|
|
32124
|
-
const skipped = skippedMedia(html, preview.slug);
|
|
32125
|
-
if (skipped.length) {
|
|
32126
|
-
warnings.push(
|
|
32127
|
-
`The preview omits ${skipped.length} asset(s) that were too large to inline (${skipped.join(", ")}). They render blank or black here but will be correct in the exported MP4.`
|
|
32128
|
-
);
|
|
32129
|
-
}
|
|
32130
|
-
const browser = await launchChromium();
|
|
32131
|
-
try {
|
|
32132
|
-
const page = await browser.newPage({
|
|
32133
|
-
viewport: { width: 1920, height: 1080 },
|
|
32134
|
-
deviceScaleFactor: 1
|
|
32135
|
-
});
|
|
32136
|
-
await page.route(
|
|
32137
|
-
`${PREVIEW_ORIGIN}/**`,
|
|
32138
|
-
(route) => route.fulfill({ status: 200, contentType: "text/html; charset=utf-8", body: html })
|
|
32139
|
-
);
|
|
32140
|
-
await page.goto(PREVIEW_URL, { waitUntil: "load", timeout: 6e4 });
|
|
32141
|
-
await page.waitForFunction(() => window.__ready === true, void 0, {
|
|
32142
|
-
timeout: 6e4
|
|
32143
|
-
});
|
|
32144
|
-
const config2 = await page.evaluate(() => window.__config);
|
|
32145
|
-
if (config2?.width && config2?.height) {
|
|
32146
|
-
await page.setViewportSize({ width: config2.width, height: config2.height });
|
|
32147
|
-
}
|
|
32148
|
-
const duration3 = Number(await page.evaluate(() => window.__duration)) || 0;
|
|
32149
|
-
const fps = Number(config2?.fps) || 30;
|
|
32150
|
-
const fontsFailed = await page.evaluate(async () => {
|
|
32151
|
-
await document.fonts.ready;
|
|
32152
|
-
return [...document.fonts].some((font) => font.status === "error");
|
|
32153
|
-
});
|
|
32154
|
-
if (fontsFailed) {
|
|
32155
|
-
warnings.push(
|
|
32156
|
-
"Some web fonts failed to load, so typography in these frames is not final. Frame capture needs network access to fonts.googleapis.com."
|
|
32157
|
-
);
|
|
32158
|
-
}
|
|
32159
|
-
return { browser, page, duration: duration3, fps, revision: preview.artifactRevision, warnings };
|
|
32160
|
-
} catch (error51) {
|
|
32161
|
-
await browser.close().catch(() => void 0);
|
|
32162
|
-
throw error51;
|
|
32163
|
-
}
|
|
32164
|
-
}
|
|
32165
|
-
async function captureFrames(ctx, input) {
|
|
32166
|
-
const session = await openPreview(ctx, input.draftId);
|
|
32167
|
-
const { page, duration: duration3, warnings, revision } = session;
|
|
32168
|
-
try {
|
|
32169
|
-
const images = [];
|
|
32170
|
-
for (const time3 of input.times) {
|
|
32171
|
-
const clamped = duration3 > 0 ? Math.max(0, Math.min(time3, duration3)) : Math.max(0, time3);
|
|
32172
|
-
await renderSettledFrame(page, clamped);
|
|
32173
|
-
const frame = await page.screenshot({ type: FRAME_TYPE, quality: FRAME_QUALITY });
|
|
32174
|
-
images.push({ base64: frame.toString("base64"), mimeType: FRAME_MIME });
|
|
32175
|
-
}
|
|
32176
|
-
return { images, warnings, revision, duration: duration3 };
|
|
32177
|
-
} finally {
|
|
32178
|
-
await session.browser.close().catch(() => void 0);
|
|
32179
|
-
}
|
|
32180
|
-
}
|
|
32181
|
-
var getFramesTool = {
|
|
32182
|
-
name: "get_frames",
|
|
32183
|
-
title: "See rendered frames",
|
|
32184
|
-
description: "Render frames of the draft at the given times (in seconds) and return them as images, so you can SEE your work instead of guessing. Runs on this machine against the cloud preview bundle \u2014 it needs Chrome installed. Ask for several times in one call: the frames are nearly free once the page is open. Always look at frames before telling the user a change is done.",
|
|
32185
|
-
inputSchema: {
|
|
32186
|
-
draftId: external_exports.string().describe("The draft id."),
|
|
32187
|
-
times: external_exports.array(external_exports.number().min(0)).min(1).max(12).describe("Timeline positions in seconds, e.g. [0, 1.5, 3]."),
|
|
32188
|
-
reviewMode: external_exports.enum(["layout", "proof", "final"]).optional().describe(
|
|
32189
|
-
"Opt-in quality-loop lens. proof judges the core transformation before the full film; final requires broad timeline coverage and produces export-eligible review evidence."
|
|
32190
|
-
)
|
|
32191
|
-
},
|
|
32192
|
-
local: true,
|
|
32193
|
-
handler: async (args, ctx) => {
|
|
32194
|
-
const capture = await captureFrames(ctx, {
|
|
32195
|
-
draftId: String(args.draftId),
|
|
32196
|
-
times: args.times
|
|
32197
|
-
});
|
|
32198
|
-
const lines = [
|
|
32199
|
-
`Captured ${capture.images.length} frame(s) at revision ${capture.revision} (scene duration ${capture.duration.toFixed(2)}s).`,
|
|
32200
|
-
...capture.warnings.map((warning) => `WARNING: ${warning}`)
|
|
32201
|
-
];
|
|
32202
|
-
const reviewMode = args.reviewMode;
|
|
32203
|
-
if (ctx.qualityLoop) {
|
|
32204
|
-
if (!reviewMode) {
|
|
32205
|
-
lines.push(
|
|
32206
|
-
'QUALITY LOOP: choose reviewMode="proof" for the core proof beat or reviewMode="final" for the export review.'
|
|
32207
|
-
);
|
|
32208
|
-
} else {
|
|
32209
|
-
if (reviewMode === "final") {
|
|
32210
|
-
const coverageError = finalReviewCoverageError(args.times, capture.duration);
|
|
32211
|
-
const stalePreview = capture.warnings.some(
|
|
32212
|
-
(warning) => warning.startsWith("The preview bundle is at revision ")
|
|
32213
|
-
);
|
|
32214
|
-
if (stalePreview) {
|
|
32215
|
-
lines.push(
|
|
32216
|
-
"QUALITY REVIEW NOT RECORDED: these frames came from a stale preview bundle. Wait for the current revision to build, then review it again."
|
|
32217
|
-
);
|
|
32218
|
-
} else if (coverageError) {
|
|
32219
|
-
lines.push(`QUALITY REVIEW NOT RECORDED: ${coverageError}`);
|
|
32220
|
-
} else {
|
|
32221
|
-
recordFinalReviewCapture(ctx.qualityLoopState, String(args.draftId), {
|
|
32222
|
-
revision: capture.revision,
|
|
32223
|
-
duration: capture.duration,
|
|
32224
|
-
times: [...args.times]
|
|
32225
|
-
});
|
|
32226
|
-
lines.push(
|
|
32227
|
-
`FINAL REVIEW RECORDED for revision ${capture.revision}. Any later edit invalidates this evidence.`
|
|
32228
|
-
);
|
|
32229
|
-
}
|
|
32230
|
-
}
|
|
32231
|
-
lines.push(qualityReviewPrompt(reviewMode));
|
|
32232
|
-
}
|
|
32233
|
-
}
|
|
32234
|
-
return { text: lines.join("\n"), images: capture.images };
|
|
31815
|
+
}
|
|
31816
|
+
return { text: lines.join("\n"), images: capture.images };
|
|
32235
31817
|
}
|
|
32236
31818
|
};
|
|
32237
31819
|
|
|
@@ -32847,6 +32429,7 @@ function motionLabEnabled(env = process.env) {
|
|
|
32847
32429
|
return !/^(0|false|off|no)$/i.test(env[MOTION_LAB_ENV]?.trim() ?? "");
|
|
32848
32430
|
}
|
|
32849
32431
|
var DEFAULT_EVERY_NTH_FRAME = 5;
|
|
32432
|
+
var SAMPLING_BASE_FPS = 30;
|
|
32850
32433
|
var MAX_SAMPLES = 48;
|
|
32851
32434
|
var REVIEW_MAX_SAMPLES = 160;
|
|
32852
32435
|
var QUICK_EVERY_NTH_FRAME = 10;
|
|
@@ -32856,17 +32439,20 @@ function defaultDetail(env = process.env) {
|
|
|
32856
32439
|
return value === "full" || value === "quick" ? value : void 0;
|
|
32857
32440
|
}
|
|
32858
32441
|
function motionPassPlan(args) {
|
|
32442
|
+
const fpsScale = Math.max(1, Math.round((Number(args.fps) || SAMPLING_BASE_FPS) / SAMPLING_BASE_FPS));
|
|
32443
|
+
const defaultNth = DEFAULT_EVERY_NTH_FRAME * fpsScale;
|
|
32444
|
+
const quickNth = QUICK_EVERY_NTH_FRAME * fpsScale;
|
|
32859
32445
|
const full = args.finalReview || (args.detail ?? defaultDetail()) === "full";
|
|
32860
32446
|
if (full) {
|
|
32861
32447
|
return {
|
|
32862
|
-
everyNthFrame: args.finalReview ? Math.min(Number(args.everyNthFrame ??
|
|
32448
|
+
everyNthFrame: args.finalReview ? Math.min(Number(args.everyNthFrame ?? defaultNth), defaultNth) : Number(args.everyNthFrame ?? defaultNth),
|
|
32863
32449
|
maxSamples: args.finalReview ? REVIEW_MAX_SAMPLES : MAX_SAMPLES,
|
|
32864
32450
|
includeSheets: true,
|
|
32865
32451
|
includeCatalog: true
|
|
32866
32452
|
};
|
|
32867
32453
|
}
|
|
32868
32454
|
return {
|
|
32869
|
-
everyNthFrame: Number(args.everyNthFrame ??
|
|
32455
|
+
everyNthFrame: Number(args.everyNthFrame ?? quickNth),
|
|
32870
32456
|
maxSamples: QUICK_MAX_SAMPLES,
|
|
32871
32457
|
includeSheets: false,
|
|
32872
32458
|
includeCatalog: false
|
|
@@ -32973,11 +32559,11 @@ function imperceptibleTweens(tweens, intervals, options = {}) {
|
|
|
32973
32559
|
const threshold = options.threshold ?? IMPERCEPTIBLE_THRESHOLD;
|
|
32974
32560
|
return tweens.filter((tween) => {
|
|
32975
32561
|
if (tween.end - tween.start < minDuration) return false;
|
|
32976
|
-
const
|
|
32562
|
+
const active2 = intervals.filter(
|
|
32977
32563
|
(interval) => interval.end > tween.start && interval.start < tween.end
|
|
32978
32564
|
);
|
|
32979
|
-
if (!
|
|
32980
|
-
const mean2 =
|
|
32565
|
+
if (!active2.length) return false;
|
|
32566
|
+
const mean2 = active2.reduce((sum, interval) => sum + interval.changed, 0) / active2.length;
|
|
32981
32567
|
return mean2 < threshold;
|
|
32982
32568
|
});
|
|
32983
32569
|
}
|
|
@@ -32999,11 +32585,11 @@ var FOCAL_GRID_ROWS = 6;
|
|
|
32999
32585
|
var FOCAL_CELL_THRESHOLD = 0.15;
|
|
33000
32586
|
var FOCAL_BUDGET = 2;
|
|
33001
32587
|
function focalClusters(cells, cols = FOCAL_GRID_COLS, rows = FOCAL_GRID_ROWS, threshold = FOCAL_CELL_THRESHOLD) {
|
|
33002
|
-
const
|
|
32588
|
+
const active2 = cells.map((cell) => cell >= threshold);
|
|
33003
32589
|
const seen = new Array(cells.length).fill(false);
|
|
33004
32590
|
let clusters = 0;
|
|
33005
32591
|
for (let start = 0; start < cells.length; start += 1) {
|
|
33006
|
-
if (!
|
|
32592
|
+
if (!active2[start] || seen[start]) continue;
|
|
33007
32593
|
clusters += 1;
|
|
33008
32594
|
const stack = [start];
|
|
33009
32595
|
seen[start] = true;
|
|
@@ -33014,7 +32600,7 @@ function focalClusters(cells, cols = FOCAL_GRID_COLS, rows = FOCAL_GRID_ROWS, th
|
|
|
33014
32600
|
for (const [nx, ny] of [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]]) {
|
|
33015
32601
|
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
|
|
33016
32602
|
const neighbour = ny * cols + nx;
|
|
33017
|
-
if (
|
|
32603
|
+
if (active2[neighbour] && !seen[neighbour]) {
|
|
33018
32604
|
seen[neighbour] = true;
|
|
33019
32605
|
stack.push(neighbour);
|
|
33020
32606
|
}
|
|
@@ -33428,9 +33014,9 @@ function displacementDebt(tweens, duration3, model = buildSceneModel(tweens, dur
|
|
|
33428
33014
|
const key = `${scene}\0${target}`;
|
|
33429
33015
|
const state = states.get(key) ?? { values: /* @__PURE__ */ new Map(), lastMove: 0, exited: false };
|
|
33430
33016
|
const alphaValue = tween.values?.autoAlpha ?? tween.values?.opacity;
|
|
33431
|
-
const
|
|
33017
|
+
const isEntrance2 = typeof alphaValue === "number" && alphaValue > 0.5;
|
|
33432
33018
|
for (const prop of tween.props) {
|
|
33433
|
-
if (tween.yoyo ||
|
|
33019
|
+
if (tween.yoyo || isEntrance2) break;
|
|
33434
33020
|
if (TRANSFORM_PROPS.has(prop) && typeof tween.values?.[prop] === "number") {
|
|
33435
33021
|
state.values.set(prop, tween.values[prop]);
|
|
33436
33022
|
state.lastMove = tween.end;
|
|
@@ -33619,6 +33205,60 @@ async function extractTimeline(page) {
|
|
|
33619
33205
|
return out;
|
|
33620
33206
|
});
|
|
33621
33207
|
}
|
|
33208
|
+
var FRAMING_ABANDONED_MAX_COVERAGE = 0.08;
|
|
33209
|
+
var FRAMING_ABANDONED_MIN_OFFSET = 0.25;
|
|
33210
|
+
var FRAMING_ABANDONED_MIN_SECONDS = 0.9;
|
|
33211
|
+
var FRAMING_ACTIVE_MEDIAN_DIFF = 0.05;
|
|
33212
|
+
var FRAMING_STILL_MOVE_MAX_DIFF = 0.012;
|
|
33213
|
+
var FRAMING_STILL_MOVE_MIN_SECONDS = 0.3;
|
|
33214
|
+
function framingFindings(input) {
|
|
33215
|
+
const { times, diffs, flatness, coverage, contentOffset, cameraSpans: cameraSpans2 } = input;
|
|
33216
|
+
const findings = [];
|
|
33217
|
+
const notes = [];
|
|
33218
|
+
const abandoned = (i) => coverage[i] <= FRAMING_ABANDONED_MAX_COVERAGE && contentOffset[i] >= FRAMING_ABANDONED_MIN_OFFSET;
|
|
33219
|
+
let runStart = -1;
|
|
33220
|
+
for (let i = 0; i <= times.length; i += 1) {
|
|
33221
|
+
const bad = i < times.length && abandoned(i);
|
|
33222
|
+
if (bad && runStart < 0) runStart = i;
|
|
33223
|
+
if (!bad && runStart >= 0) {
|
|
33224
|
+
const last = Math.min(i, times.length - 1);
|
|
33225
|
+
const seconds = times[last] - times[runStart];
|
|
33226
|
+
if (seconds >= FRAMING_ABANDONED_MIN_SECONDS) {
|
|
33227
|
+
const peak = Math.max(...flatness.slice(runStart, Math.max(runStart + 1, i)));
|
|
33228
|
+
const inside = [];
|
|
33229
|
+
for (let k = runStart; k <= i - 2 && k < diffs.length; k += 1) {
|
|
33230
|
+
inside.push(diffs[k]);
|
|
33231
|
+
}
|
|
33232
|
+
const sorted = [...inside].sort((a, b) => a - b);
|
|
33233
|
+
const median2 = sorted.length ? sorted[Math.floor(sorted.length / 2)] : 0;
|
|
33234
|
+
if (median2 >= FRAMING_ACTIVE_MEDIAN_DIFF) {
|
|
33235
|
+
notes.push(
|
|
33236
|
+
`HELD CLOSE-UP ${times[runStart].toFixed(2)}-${times[last].toFixed(2)}s: sparse framing but the content is alive (median change ${(median2 * 100).toFixed(1)}%/sample) \u2014 reads as an intentional close-up, not gated. Confirm it looks deliberate.`
|
|
33237
|
+
);
|
|
33238
|
+
} else {
|
|
33239
|
+
findings.push(
|
|
33240
|
+
`ABANDONED FRAME ${times[runStart].toFixed(2)}-${times[last].toFixed(2)}s: for ${seconds.toFixed(1)}s the frame is ${Math.round(peak * 100)}% one flat color with the remaining content pushed into a corner and nothing moving (median change ${(median2 * 100).toFixed(1)}%/sample). The camera is pointed at nothing \u2014 look at these frames, find what SHOULD be on screen here, and reframe on it.`
|
|
33241
|
+
);
|
|
33242
|
+
}
|
|
33243
|
+
}
|
|
33244
|
+
runStart = -1;
|
|
33245
|
+
}
|
|
33246
|
+
}
|
|
33247
|
+
for (const span of cameraSpans2) {
|
|
33248
|
+
if (span.end - span.start < FRAMING_STILL_MOVE_MIN_SECONDS) continue;
|
|
33249
|
+
const inside = [];
|
|
33250
|
+
for (let i = 0; i < diffs.length; i += 1) {
|
|
33251
|
+
const mid = (times[i] + times[i + 1]) / 2;
|
|
33252
|
+
if (mid >= span.start && mid <= span.end) inside.push(diffs[i]);
|
|
33253
|
+
}
|
|
33254
|
+
if (inside.length >= 2 && Math.max(...inside) < FRAMING_STILL_MOVE_MAX_DIFF) {
|
|
33255
|
+
findings.push(
|
|
33256
|
+
`CAMERA MOVE WITHOUT CHANGE ${span.start.toFixed(2)}-${span.end.toFixed(2)}s: this move leaves the frame visually identical (peak pixel change ${(Math.max(...inside) * 100).toFixed(1)}%). The shot before and after are the same - cut it, or make it a real reframe the viewer can feel.`
|
|
33257
|
+
);
|
|
33258
|
+
}
|
|
33259
|
+
}
|
|
33260
|
+
return { failures: findings, notes };
|
|
33261
|
+
}
|
|
33622
33262
|
async function compositeAndDiff(page, frames, options = {}) {
|
|
33623
33263
|
return page.evaluate(
|
|
33624
33264
|
async (input) => {
|
|
@@ -33642,6 +33282,9 @@ async function compositeAndDiff(page, frames, options = {}) {
|
|
|
33642
33282
|
let prev = null;
|
|
33643
33283
|
const diffs = [];
|
|
33644
33284
|
const cellDiffs = [];
|
|
33285
|
+
const flatness = [];
|
|
33286
|
+
const coverage = [];
|
|
33287
|
+
const contentOffset = [];
|
|
33645
33288
|
const gridCols = 8;
|
|
33646
33289
|
const gridRows = 6;
|
|
33647
33290
|
const cellW = dw / gridCols;
|
|
@@ -33649,6 +33292,40 @@ async function compositeAndDiff(page, frames, options = {}) {
|
|
|
33649
33292
|
for (const img of imgs) {
|
|
33650
33293
|
diffCtx.drawImage(img, 0, 0, dw, dh);
|
|
33651
33294
|
const data = diffCtx.getImageData(0, 0, dw, dh).data;
|
|
33295
|
+
{
|
|
33296
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
33297
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
33298
|
+
const key = data[i] >> 4 << 8 | data[i + 1] >> 4 << 4 | data[i + 2] >> 4;
|
|
33299
|
+
buckets.set(key, (buckets.get(key) ?? 0) + 1);
|
|
33300
|
+
}
|
|
33301
|
+
let dominantKey = -1;
|
|
33302
|
+
let dominant = 0;
|
|
33303
|
+
for (const [key, count2] of buckets) {
|
|
33304
|
+
if (count2 > dominant) {
|
|
33305
|
+
dominant = count2;
|
|
33306
|
+
dominantKey = key;
|
|
33307
|
+
}
|
|
33308
|
+
}
|
|
33309
|
+
flatness.push(dominant / (dw * dh));
|
|
33310
|
+
const dr = (dominantKey >> 8 & 15) << 4;
|
|
33311
|
+
const dg = (dominantKey >> 4 & 15) << 4;
|
|
33312
|
+
const db = (dominantKey & 15) << 4;
|
|
33313
|
+
let count = 0;
|
|
33314
|
+
let sumX = 0;
|
|
33315
|
+
let sumY = 0;
|
|
33316
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
33317
|
+
const delta = Math.abs(data[i] - dr) + Math.abs(data[i + 1] - dg) + Math.abs(data[i + 2] - db);
|
|
33318
|
+
if (delta > 90) {
|
|
33319
|
+
count += 1;
|
|
33320
|
+
sumX += i / 4 % dw;
|
|
33321
|
+
sumY = sumY + Math.floor(i / 4 / dw);
|
|
33322
|
+
}
|
|
33323
|
+
}
|
|
33324
|
+
coverage.push(count / (dw * dh));
|
|
33325
|
+
contentOffset.push(
|
|
33326
|
+
count ? Math.hypot(sumX / count / dw - 0.5, sumY / count / dh - 0.5) : 0
|
|
33327
|
+
);
|
|
33328
|
+
}
|
|
33652
33329
|
if (prev) {
|
|
33653
33330
|
let changed = 0;
|
|
33654
33331
|
const cells = new Array(gridCols * gridRows).fill(0);
|
|
@@ -33656,266 +33333,1809 @@ async function compositeAndDiff(page, frames, options = {}) {
|
|
|
33656
33333
|
const delta = Math.abs(data[i] - prev[i]) + Math.abs(data[i + 1] - prev[i + 1]) + Math.abs(data[i + 2] - prev[i + 2]);
|
|
33657
33334
|
if (delta > 60) {
|
|
33658
33335
|
changed += 1;
|
|
33659
|
-
const
|
|
33336
|
+
const px2 = i / 4 % dw;
|
|
33660
33337
|
const py = Math.floor(i / 4 / dw);
|
|
33661
|
-
cells[Math.floor(py / cellH) * gridCols + Math.floor(
|
|
33338
|
+
cells[Math.floor(py / cellH) * gridCols + Math.floor(px2 / cellW)] += 1;
|
|
33662
33339
|
}
|
|
33663
33340
|
}
|
|
33664
33341
|
diffs.push(changed / (dw * dh));
|
|
33665
33342
|
cellDiffs.push(cells.map((count) => count / (cellW * cellH)));
|
|
33666
33343
|
}
|
|
33667
|
-
prev = data.slice();
|
|
33344
|
+
prev = data.slice();
|
|
33345
|
+
}
|
|
33346
|
+
if (!input.includeSheets) return { sheets: [], diffs, cellDiffs, flatness, coverage, contentOffset };
|
|
33347
|
+
const ratio = imgs[0].naturalHeight / Math.max(1, imgs[0].naturalWidth);
|
|
33348
|
+
const tw = input.tileWidth;
|
|
33349
|
+
const th = Math.round(tw * ratio);
|
|
33350
|
+
const sheets = [];
|
|
33351
|
+
for (let s = 0; s * input.perSheet < imgs.length; s += 1) {
|
|
33352
|
+
const slice = imgs.slice(s * input.perSheet, (s + 1) * input.perSheet);
|
|
33353
|
+
const rows = Math.ceil(slice.length / input.columns);
|
|
33354
|
+
const canvas = document.createElement("canvas");
|
|
33355
|
+
canvas.width = input.columns * tw;
|
|
33356
|
+
canvas.height = rows * th;
|
|
33357
|
+
const ctx = canvas.getContext("2d");
|
|
33358
|
+
if (!ctx) throw new Error("2d canvas unavailable");
|
|
33359
|
+
ctx.fillStyle = "#000";
|
|
33360
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
33361
|
+
slice.forEach((img, i) => {
|
|
33362
|
+
const x = i % input.columns * tw;
|
|
33363
|
+
const y = Math.floor(i / input.columns) * th;
|
|
33364
|
+
ctx.drawImage(img, x, y, tw, th);
|
|
33365
|
+
const label = input.frames[s * input.perSheet + i].time.toFixed(2) + "s";
|
|
33366
|
+
ctx.font = "bold 16px monospace";
|
|
33367
|
+
const width = ctx.measureText(label).width + 10;
|
|
33368
|
+
ctx.fillStyle = "rgba(0,0,0,0.72)";
|
|
33369
|
+
ctx.fillRect(x, y, width, 22);
|
|
33370
|
+
ctx.fillStyle = "#ffd21f";
|
|
33371
|
+
ctx.fillText(label, x + 5, y + 16);
|
|
33372
|
+
});
|
|
33373
|
+
sheets.push(canvas.toDataURL("image/jpeg", 0.8).split(",")[1]);
|
|
33374
|
+
}
|
|
33375
|
+
return { sheets, diffs, cellDiffs, flatness, coverage, contentOffset };
|
|
33376
|
+
},
|
|
33377
|
+
{
|
|
33378
|
+
frames,
|
|
33379
|
+
columns: options.columns ?? 4,
|
|
33380
|
+
tileWidth: options.tileWidth ?? 480,
|
|
33381
|
+
perSheet: options.perSheet ?? 16,
|
|
33382
|
+
includeSheets: options.includeSheets ?? true
|
|
33383
|
+
}
|
|
33384
|
+
);
|
|
33385
|
+
}
|
|
33386
|
+
var getMotionTool = {
|
|
33387
|
+
name: "get_motion",
|
|
33388
|
+
title: "See and measure motion over an interval",
|
|
33389
|
+
description: "MOTION LAB (local experiment). Sample the draft densely across a time window and return (1) timestamp-labelled contact sheets \u2014 adjacent tiles are one sample apart, so the difference between tiles IS the motion \u2014 and (2) a measured report: % of pixels changing per interval, dead-air spans where nothing perceptible moves, and every tween the built timeline actually schedules in the window, flagging tweens that run without producing visible change. Static frames cannot show velocity, overlap, or dead air; this can. Call it over the full duration after every preview build, then narrow in on weak windows. Use get_frames for composition close-ups only.",
|
|
33390
|
+
inputSchema: {
|
|
33391
|
+
draftId: external_exports.string().describe("The draft id."),
|
|
33392
|
+
from: external_exports.number().min(0).optional().describe("Window start in seconds. Defaults to 0."),
|
|
33393
|
+
to: external_exports.number().min(0).optional().describe("Window end in seconds. Defaults to the scene duration."),
|
|
33394
|
+
everyNthFrame: external_exports.number().int().min(1).max(30).optional().describe(
|
|
33395
|
+
`Sample one frame in N. The default keeps 6 samples/second at the scene's fps \u2014 the density at which motion becomes readable across stills.`
|
|
33396
|
+
),
|
|
33397
|
+
detail: external_exports.enum(["quick", "full"]).optional().describe(
|
|
33398
|
+
'quick (default): numbers only \u2014 dead air, focal budget, text census \u2014 sparser sampling, NO contact-sheet images, no metric catalog. Cheap enough to run after every revision. full: dense sampling with contact sheets and the catalog, when you need to SEE motion mid-flight. Measure at most twice per revision; finish with reviewMode "final".'
|
|
33399
|
+
),
|
|
33400
|
+
reviewMode: external_exports.enum(["final"]).optional().describe(
|
|
33401
|
+
'Quality-loop lens: "final" forces a full-duration pass at one-frame-in-five density and records export-eligible motion-coverage evidence for the current revision.'
|
|
33402
|
+
)
|
|
33403
|
+
},
|
|
33404
|
+
local: true,
|
|
33405
|
+
// Metered as a frame capture: the deployed admission policy budgets
|
|
33406
|
+
// get_frames for exactly the routes this tool invokes (get-draft,
|
|
33407
|
+
// list-projects), and one motion pass is one capture.
|
|
33408
|
+
admitAs: "get_frames",
|
|
33409
|
+
handler: async (args, ctx, progress) => {
|
|
33410
|
+
const finalReview = args.reviewMode === "final";
|
|
33411
|
+
const session = await openPreview(ctx, String(args.draftId));
|
|
33412
|
+
try {
|
|
33413
|
+
const duration3 = session.duration;
|
|
33414
|
+
const from = finalReview ? 0 : Math.max(0, Math.min(Number(args.from ?? 0), duration3));
|
|
33415
|
+
const to = finalReview ? duration3 : Math.max(0, Math.min(Number(args.to ?? duration3), duration3));
|
|
33416
|
+
if (to <= from) {
|
|
33417
|
+
return {
|
|
33418
|
+
text: `get_motion needs from < to within 0\u2013${formatTime(duration3)}s (got ${from}\u2013${to}).`,
|
|
33419
|
+
isError: true
|
|
33420
|
+
};
|
|
33421
|
+
}
|
|
33422
|
+
const passPlan = motionPassPlan({
|
|
33423
|
+
finalReview,
|
|
33424
|
+
detail: args.detail ? String(args.detail) : void 0,
|
|
33425
|
+
everyNthFrame: args.everyNthFrame === void 0 ? void 0 : Number(args.everyNthFrame),
|
|
33426
|
+
fps: session.fps
|
|
33427
|
+
});
|
|
33428
|
+
const everyNthFrame = passPlan.everyNthFrame;
|
|
33429
|
+
const plan = sampleTimes({
|
|
33430
|
+
from,
|
|
33431
|
+
to,
|
|
33432
|
+
fps: session.fps,
|
|
33433
|
+
everyNthFrame,
|
|
33434
|
+
maxSamples: passPlan.maxSamples
|
|
33435
|
+
});
|
|
33436
|
+
const frames = [];
|
|
33437
|
+
const textSamples = [];
|
|
33438
|
+
const censusOn = textCensusEnabled();
|
|
33439
|
+
for (const [index, time3] of plan.times.entries()) {
|
|
33440
|
+
if (progress?.signal?.aborted) break;
|
|
33441
|
+
await renderSettledFrame(session.page, time3);
|
|
33442
|
+
const shot = await session.page.screenshot({ type: "jpeg", quality: 80 });
|
|
33443
|
+
frames.push({ time: time3, base64: shot.toString("base64") });
|
|
33444
|
+
if (censusOn) {
|
|
33445
|
+
try {
|
|
33446
|
+
textSamples.push({ time: time3, blocks: await sampleTextBlocks(session.page, CANDIDATE_MIN_PX) });
|
|
33447
|
+
} catch {
|
|
33448
|
+
}
|
|
33449
|
+
}
|
|
33450
|
+
progress?.report?.({
|
|
33451
|
+
progress: index + 1,
|
|
33452
|
+
total: plan.times.length,
|
|
33453
|
+
message: `sampled t=${formatTime(time3)}s`
|
|
33454
|
+
});
|
|
33455
|
+
}
|
|
33456
|
+
const tweensAll = await extractTimeline(session.page);
|
|
33457
|
+
const tweens = tweensAll ? tweensAll.filter((tween) => tween.end > from && tween.start < to) : null;
|
|
33458
|
+
const craftOn = craftLabEnabled();
|
|
33459
|
+
const engineTweens = await extractEngineTweens(session.page);
|
|
33460
|
+
const camSpans = cameraSpans(engineTweens);
|
|
33461
|
+
const sheetPage = await session.context.newPage();
|
|
33462
|
+
const { sheets, diffs, cellDiffs, flatness, coverage, contentOffset } = await compositeAndDiff(
|
|
33463
|
+
sheetPage,
|
|
33464
|
+
frames,
|
|
33465
|
+
{ includeSheets: passPlan.includeSheets }
|
|
33466
|
+
);
|
|
33467
|
+
const framingResult = framingFindings({
|
|
33468
|
+
times: plan.times,
|
|
33469
|
+
diffs,
|
|
33470
|
+
flatness,
|
|
33471
|
+
coverage,
|
|
33472
|
+
contentOffset,
|
|
33473
|
+
cameraSpans: camSpans.filter((span) => span.end > from && span.start < to)
|
|
33474
|
+
});
|
|
33475
|
+
const framing = framingResult.failures;
|
|
33476
|
+
let catalogSection = "";
|
|
33477
|
+
if (passPlan.includeCatalog && metricCatalogEnabled() && frames.length >= 2) {
|
|
33478
|
+
try {
|
|
33479
|
+
const features = await sampleCatalogFeatures(sheetPage, frames);
|
|
33480
|
+
const records = computeCatalogMetrics({
|
|
33481
|
+
features,
|
|
33482
|
+
dtSeconds: plan.stepSeconds,
|
|
33483
|
+
durationSeconds: to - from
|
|
33484
|
+
});
|
|
33485
|
+
catalogSection = formatCatalogReport(records);
|
|
33486
|
+
} catch (error51) {
|
|
33487
|
+
catalogSection = "CANONICAL METRICS unavailable this pass: " + (error51 instanceof Error ? error51.message : String(error51));
|
|
33488
|
+
}
|
|
33489
|
+
}
|
|
33490
|
+
const intervals = buildIntervals(
|
|
33491
|
+
frames.map((frame) => frame.time),
|
|
33492
|
+
diffs
|
|
33493
|
+
);
|
|
33494
|
+
const censusSection = censusOn && textSamples.length ? textCensusReport(textSamples) : "";
|
|
33495
|
+
let contractSection = "";
|
|
33496
|
+
if (textContractEnabled() && textSamples.length >= 2) {
|
|
33497
|
+
try {
|
|
33498
|
+
const dims = await session.page.evaluate(() => ({
|
|
33499
|
+
width: window.innerWidth,
|
|
33500
|
+
height: window.innerHeight
|
|
33501
|
+
}));
|
|
33502
|
+
contractSection = formatTextContractReport(
|
|
33503
|
+
analyzeTextContract({
|
|
33504
|
+
samples: textSamples,
|
|
33505
|
+
diffs,
|
|
33506
|
+
frameWidth: dims.width,
|
|
33507
|
+
frameHeight: dims.height,
|
|
33508
|
+
stepSeconds: plan.stepSeconds,
|
|
33509
|
+
lockedContent: ctx.qualityLoopState?.lockedContentByDraft.get(String(args.draftId)),
|
|
33510
|
+
inspectV2: inspectV2Enabled()
|
|
33511
|
+
})
|
|
33512
|
+
);
|
|
33513
|
+
} catch {
|
|
33514
|
+
}
|
|
33515
|
+
}
|
|
33516
|
+
let v2Section = "";
|
|
33517
|
+
if (inspectV2Enabled()) {
|
|
33518
|
+
const v2Lines = [];
|
|
33519
|
+
const envelope = envelopeLine(intervals);
|
|
33520
|
+
if (envelope) v2Lines.push(envelope);
|
|
33521
|
+
const beats = ctx.qualityLoopState?.beatsByDraft.get(String(args.draftId)) ?? [];
|
|
33522
|
+
v2Lines.push(...beatVerdicts(beats, intervals));
|
|
33523
|
+
v2Lines.push(
|
|
33524
|
+
...repairMenu({
|
|
33525
|
+
deadSpans: deadAirSpans(intervals),
|
|
33526
|
+
imperceptible: tweens ? imperceptibleTweens(tweens, intervals) : [],
|
|
33527
|
+
textSamples
|
|
33528
|
+
})
|
|
33529
|
+
);
|
|
33530
|
+
v2Section = v2Lines.join("\n");
|
|
33531
|
+
}
|
|
33532
|
+
let scoreSection = "";
|
|
33533
|
+
if (motionScoreEnabled() && tweensAll?.length) {
|
|
33534
|
+
try {
|
|
33535
|
+
scoreSection = motionScoreSection({
|
|
33536
|
+
tweens: tweensAll,
|
|
33537
|
+
beats: ctx.qualityLoopState?.beatsByDraft.get(String(args.draftId)) ?? [],
|
|
33538
|
+
duration: duration3
|
|
33539
|
+
});
|
|
33540
|
+
} catch {
|
|
33541
|
+
}
|
|
33542
|
+
}
|
|
33543
|
+
let splitFocus = splitFocusMoments(
|
|
33544
|
+
frames.map((frame) => frame.time),
|
|
33545
|
+
cellDiffs
|
|
33546
|
+
);
|
|
33547
|
+
let breathless = breathlessSpans(intervals);
|
|
33548
|
+
let cadence = tweens ? cadenceMoments(tweens) : void 0;
|
|
33549
|
+
let cameraExemptions = 0;
|
|
33550
|
+
if (craftOn && camSpans.length) {
|
|
33551
|
+
const before = breathless.length + splitFocus.length;
|
|
33552
|
+
breathless = breathless.filter((span) => cameraCoverageOf(span, camSpans) < 0.6);
|
|
33553
|
+
splitFocus = splitFocus.filter((moment) => cameraCoverageOf(moment, camSpans) < 0.6);
|
|
33554
|
+
cameraExemptions = before - breathless.length - splitFocus.length;
|
|
33555
|
+
cadence = tweens ? cadenceMoments(tweens.filter((tween) => !startsWithCamera(tween, camSpans))) : void 0;
|
|
33556
|
+
}
|
|
33557
|
+
let craftSection = "";
|
|
33558
|
+
let craftFindings = null;
|
|
33559
|
+
if (craftOn && tweensAll?.length) {
|
|
33560
|
+
try {
|
|
33561
|
+
const focalStarts = actionEvents(
|
|
33562
|
+
tweensAll.filter((tween) => !startsWithCamera(tween, camSpans))
|
|
33563
|
+
);
|
|
33564
|
+
craftFindings = computeCraftFindings({
|
|
33565
|
+
records: tweensAll,
|
|
33566
|
+
engine: engineTweens,
|
|
33567
|
+
duration: duration3,
|
|
33568
|
+
actionStarts: focalStarts
|
|
33569
|
+
});
|
|
33570
|
+
craftSection = craftReport(craftFindings);
|
|
33571
|
+
if (cameraExemptions > 0) {
|
|
33572
|
+
craftSection += `
|
|
33573
|
+
ROLE EXEMPTIONS \u2014 ${cameraExemptions} high-motion/split-focus span(s) attributed to camera moves and removed from the restraint verdicts above.`;
|
|
33574
|
+
}
|
|
33575
|
+
} catch {
|
|
33576
|
+
}
|
|
33577
|
+
}
|
|
33578
|
+
const report = motionReport({ from, to, plan, everyNthFrame, intervals, tweens, duration: duration3, splitFocus, breathless, cadence });
|
|
33579
|
+
const lines = [
|
|
33580
|
+
report,
|
|
33581
|
+
...passPlan.includeSheets ? [] : [
|
|
33582
|
+
"",
|
|
33583
|
+
'QUICK PASS \u2014 numbers only, no frames attached. Run detail: "full" when you need to SEE the motion, and reviewMode: "final" for the export-evidence review.'
|
|
33584
|
+
],
|
|
33585
|
+
...censusSection ? ["", censusSection] : [],
|
|
33586
|
+
...contractSection ? ["", contractSection] : [],
|
|
33587
|
+
...scoreSection ? ["", scoreSection] : [],
|
|
33588
|
+
...v2Section ? ["", v2Section] : [],
|
|
33589
|
+
...framing.length || framingResult.notes.length ? [
|
|
33590
|
+
"",
|
|
33591
|
+
"FRAMING (deterministic):",
|
|
33592
|
+
...framing.map((finding) => `- ${finding}`),
|
|
33593
|
+
...framingResult.notes.map((note) => `- ${note}`)
|
|
33594
|
+
] : [],
|
|
33595
|
+
...craftSection ? ["", craftSection] : [],
|
|
33596
|
+
...catalogSection ? ["", catalogSection] : [],
|
|
33597
|
+
...session.warnings.map((warning) => `WARNING: ${warning}`),
|
|
33598
|
+
"",
|
|
33599
|
+
"These are measurements, not style rules. Dead air and imperceptible tweens are time no one is directing \u2014 decide narratively what should own that time, change the direction, and measure again."
|
|
33600
|
+
];
|
|
33601
|
+
if (ctx.qualityLoop) {
|
|
33602
|
+
if (!finalReview) {
|
|
33603
|
+
lines.push(
|
|
33604
|
+
'QUALITY LOOP: this pass is not export evidence. Record motion coverage with get_motion({ draftId, reviewMode: "final" }) once the cut is complete.'
|
|
33605
|
+
);
|
|
33606
|
+
} else {
|
|
33607
|
+
const stalePreview = session.warnings.some(
|
|
33608
|
+
(warning) => warning.startsWith("The preview bundle is at revision ")
|
|
33609
|
+
);
|
|
33610
|
+
if (stalePreview) {
|
|
33611
|
+
lines.push(
|
|
33612
|
+
"MOTION REVIEW NOT RECORDED: these samples came from a stale preview bundle. Wait for the current revision to build, then review it again."
|
|
33613
|
+
);
|
|
33614
|
+
} else {
|
|
33615
|
+
let craftCapture;
|
|
33616
|
+
if (craftOn) {
|
|
33617
|
+
const beats = ctx.qualityLoopState?.beatsByDraft.get(String(args.draftId)) ?? [];
|
|
33618
|
+
const mismatches = beatVerdicts(beats, intervals).filter(
|
|
33619
|
+
(verdict) => verdict.startsWith("BEAT MISMATCH")
|
|
33620
|
+
).length;
|
|
33621
|
+
const dead = deadAirSpans(intervals);
|
|
33622
|
+
craftCapture = {
|
|
33623
|
+
cameraMoves: craftFindings?.cameraMoves ?? camSpans.length,
|
|
33624
|
+
deformations: craftFindings?.deformations.length ?? 0,
|
|
33625
|
+
beatMismatches: mismatches,
|
|
33626
|
+
deadAirSpans: dead.length,
|
|
33627
|
+
longestDeadAirSeconds: dead.reduce(
|
|
33628
|
+
(longest, span) => Math.max(longest, span.end - span.start),
|
|
33629
|
+
0
|
|
33630
|
+
),
|
|
33631
|
+
hardCutShare: craftFindings?.continuity.hardCutShare ?? null
|
|
33632
|
+
};
|
|
33633
|
+
}
|
|
33634
|
+
ctx.qualityLoopState?.finalMotionReviewByDraft.set(String(args.draftId), {
|
|
33635
|
+
revision: session.revision,
|
|
33636
|
+
duration: duration3,
|
|
33637
|
+
stepSeconds: plan.stepSeconds,
|
|
33638
|
+
samples: plan.times.length,
|
|
33639
|
+
craft: craftCapture,
|
|
33640
|
+
framingFailures: framing
|
|
33641
|
+
});
|
|
33642
|
+
lines.push(
|
|
33643
|
+
`FINAL MOTION REVIEW RECORDED for revision ${session.revision} \u2014 ${plan.times.length} samples, ${plan.stepSeconds.toFixed(3)}s apart` + (plan.coarsened ? " (coarsened below one-in-five by film length)" : "") + ". Any later edit invalidates this evidence."
|
|
33644
|
+
);
|
|
33645
|
+
lines.push(qualityReviewPrompt("final"));
|
|
33646
|
+
}
|
|
33647
|
+
}
|
|
33648
|
+
}
|
|
33649
|
+
return {
|
|
33650
|
+
text: lines.join("\n"),
|
|
33651
|
+
images: sheets.map((base643) => ({ base64: base643, mimeType: "image/jpeg" }))
|
|
33652
|
+
};
|
|
33653
|
+
} finally {
|
|
33654
|
+
await session.release();
|
|
33655
|
+
}
|
|
33656
|
+
}
|
|
33657
|
+
};
|
|
33658
|
+
function motionLabServerInstructions() {
|
|
33659
|
+
return `
|
|
33660
|
+
|
|
33661
|
+
MOTION LAB IS ENABLED \u2014 an opt-in local experiment:
|
|
33662
|
+
Static frames measure composition; they are blind to velocity, overlap, and dead air. After every successful preview build, call get_motion across the full duration BEFORE judging the cut, and again on any window you edit. Treat its output as measurements, not style rules: a dead-air span or a flagged imperceptible tween is time no one is directing \u2014 decide what should own that time, fix the direction, and measure again. A cut is not done while the report shows dead air you did not choose deliberately. get_frames remains the tool for composition close-ups.
|
|
33663
|
+
When the quality loop is also enabled, the final review is two-part and export requires both on the same revision: get_frames({ reviewMode: "final" }) for composition and legibility at native resolution, and get_motion({ draftId, reviewMode: "final" }) for full-duration motion coverage at one frame in five.`;
|
|
33664
|
+
}
|
|
33665
|
+
|
|
33666
|
+
// src/mcp/craft.ts
|
|
33667
|
+
var CRAFT_LAB_ENV = "IMPRACTICAL_CRAFT_LAB";
|
|
33668
|
+
function craftLabEnabled(env = process.env) {
|
|
33669
|
+
return /^(1|true|on|yes)$/i.test(env[CRAFT_LAB_ENV]?.trim() ?? "");
|
|
33670
|
+
}
|
|
33671
|
+
async function extractEngineTweens(page) {
|
|
33672
|
+
return page.evaluate(() => {
|
|
33673
|
+
const scene = window.__scene;
|
|
33674
|
+
if (!scene?.tweens) return null;
|
|
33675
|
+
return scene.tweens.map((tween) => ({
|
|
33676
|
+
kind: tween.kind,
|
|
33677
|
+
start: tween.start,
|
|
33678
|
+
end: tween.start + tween.total,
|
|
33679
|
+
ease: tween.ease || "default"
|
|
33680
|
+
}));
|
|
33681
|
+
});
|
|
33682
|
+
}
|
|
33683
|
+
var CAMERA_MIN_MOVE_SECONDS = 0.15;
|
|
33684
|
+
function cameraSpans(engine) {
|
|
33685
|
+
if (!engine) return [];
|
|
33686
|
+
return engine.filter((tween) => tween.kind === "camera" && tween.end - tween.start >= CAMERA_MIN_MOVE_SECONDS).map((tween) => ({ start: tween.start, end: tween.end })).sort((a, b) => a.start - b.start);
|
|
33687
|
+
}
|
|
33688
|
+
var overlaps = (a, b) => Math.max(0, Math.min(a.end, b.end) - Math.max(a.start, b.start));
|
|
33689
|
+
function startsWithCamera(tween, spans2, tolerance = 0.05) {
|
|
33690
|
+
return spans2.some((span) => tween.start >= span.start - tolerance && tween.start <= span.end + tolerance);
|
|
33691
|
+
}
|
|
33692
|
+
function cameraCoverageOf(span, spans2) {
|
|
33693
|
+
const length = span.end - span.start;
|
|
33694
|
+
if (length <= 0) return 0;
|
|
33695
|
+
let covered = 0;
|
|
33696
|
+
for (const cam of spans2) covered += overlaps(span, cam);
|
|
33697
|
+
return Math.min(1, covered / length);
|
|
33698
|
+
}
|
|
33699
|
+
var SLIDE_PROPS = /* @__PURE__ */ new Set(["x", "y", "xPercent", "yPercent", "left", "top", "right", "bottom"]);
|
|
33700
|
+
var SCALE_PROPS = /* @__PURE__ */ new Set(["scale", "scaleX", "scaleY"]);
|
|
33701
|
+
var ROTATE_PROPS = /* @__PURE__ */ new Set(["rotation", "rotationX", "rotationY", "rotationZ", "skewX", "skewY"]);
|
|
33702
|
+
var CLIP_PROPS = /* @__PURE__ */ new Set(["clipPath", "webkitClipPath", "maskImage", "maskPosition"]);
|
|
33703
|
+
var DRAW_PROPS = /* @__PURE__ */ new Set(["strokeDashoffset", "strokeDasharray", "drawSVG"]);
|
|
33704
|
+
var COLOR_PROPS = /* @__PURE__ */ new Set(["backgroundColor", "borderColor", "color", "fill", "stroke", "background"]);
|
|
33705
|
+
var FILTER_PROPS = /* @__PURE__ */ new Set(["filter", "backdropFilter", "opacity"]);
|
|
33706
|
+
var TEXT_PROPS = /* @__PURE__ */ new Set(["text", "textContent", "innerText"]);
|
|
33707
|
+
function moveFamily(record2) {
|
|
33708
|
+
const props = record2.props.filter((prop) => prop !== "transformOrigin" && prop !== "transformPerspective");
|
|
33709
|
+
const has = (set2) => props.some((prop) => set2.has(prop));
|
|
33710
|
+
const families = [];
|
|
33711
|
+
if (has(SLIDE_PROPS)) families.push("slide");
|
|
33712
|
+
if (has(SCALE_PROPS)) families.push("scale");
|
|
33713
|
+
if (has(ROTATE_PROPS)) families.push("rotate");
|
|
33714
|
+
if (has(CLIP_PROPS)) families.push("clip");
|
|
33715
|
+
if (has(DRAW_PROPS)) families.push("draw");
|
|
33716
|
+
if (has(COLOR_PROPS)) families.push("color");
|
|
33717
|
+
if (has(TEXT_PROPS)) families.push("text");
|
|
33718
|
+
if (families.length === 0 && has(FILTER_PROPS)) families.push("filter");
|
|
33719
|
+
if (families.length === 0 && props.some((prop) => prop === "autoAlpha" || prop === "alpha")) {
|
|
33720
|
+
return "fade";
|
|
33721
|
+
}
|
|
33722
|
+
if (families.length === 0) return "mixed";
|
|
33723
|
+
if (families.length === 1) return families[0];
|
|
33724
|
+
return "mixed";
|
|
33725
|
+
}
|
|
33726
|
+
function isEntrance(record2) {
|
|
33727
|
+
const alpha = record2.values?.autoAlpha ?? record2.values?.alpha ?? record2.values?.opacity;
|
|
33728
|
+
return typeof alpha === "number" && alpha > 0.5 && record2.end > record2.start;
|
|
33729
|
+
}
|
|
33730
|
+
var durationBucket = (record2) => Math.round((record2.end - record2.start) / 0.15);
|
|
33731
|
+
function entranceSignature(record2) {
|
|
33732
|
+
return `${moveFamily(record2)}|${record2.ease}|${durationBucket(record2)}`;
|
|
33733
|
+
}
|
|
33734
|
+
var DEFORMATION_MAX_RATIO = 3;
|
|
33735
|
+
function deformationViolations(records) {
|
|
33736
|
+
const state = /* @__PURE__ */ new Map();
|
|
33737
|
+
const out = [];
|
|
33738
|
+
const sorted = [...records].sort((a, b) => a.start - b.start);
|
|
33739
|
+
for (const record2 of sorted) {
|
|
33740
|
+
const sx = record2.values?.scaleX;
|
|
33741
|
+
const sy = record2.values?.scaleY;
|
|
33742
|
+
const uniform = record2.values?.scale;
|
|
33743
|
+
if (typeof sx !== "number" && typeof sy !== "number" && typeof uniform !== "number") continue;
|
|
33744
|
+
for (const target of record2.targets) {
|
|
33745
|
+
const previous = state.get(target) ?? { x: 1, y: 1 };
|
|
33746
|
+
const next = {
|
|
33747
|
+
x: typeof uniform === "number" ? uniform : typeof sx === "number" ? sx : previous.x,
|
|
33748
|
+
y: typeof uniform === "number" ? uniform : typeof sy === "number" ? sy : previous.y
|
|
33749
|
+
};
|
|
33750
|
+
state.set(target, next);
|
|
33751
|
+
if (next.x > 0 && next.y > 0) {
|
|
33752
|
+
const ratio = Math.max(next.x / next.y, next.y / next.x);
|
|
33753
|
+
if (ratio >= DEFORMATION_MAX_RATIO) {
|
|
33754
|
+
out.push({ target, time: record2.start, ratio: Number(ratio.toFixed(1)) });
|
|
33755
|
+
}
|
|
33756
|
+
}
|
|
33757
|
+
}
|
|
33758
|
+
}
|
|
33759
|
+
return out;
|
|
33760
|
+
}
|
|
33761
|
+
function rhythmStats(actionStarts) {
|
|
33762
|
+
const events = actionStarts.length;
|
|
33763
|
+
if (events < 3) return { events, meanInterval: null, cv: null, lag1: null };
|
|
33764
|
+
const intervals = [];
|
|
33765
|
+
for (let i = 1; i < actionStarts.length; i += 1) {
|
|
33766
|
+
intervals.push(actionStarts[i] - actionStarts[i - 1]);
|
|
33767
|
+
}
|
|
33768
|
+
const mean2 = intervals.reduce((sum, value) => sum + value, 0) / intervals.length;
|
|
33769
|
+
const variance = intervals.reduce((sum, value) => sum + (value - mean2) ** 2, 0) / intervals.length;
|
|
33770
|
+
const sd = Math.sqrt(variance);
|
|
33771
|
+
const cv = mean2 > 0 ? sd / mean2 : null;
|
|
33772
|
+
let lag1 = null;
|
|
33773
|
+
if (intervals.length >= 3 && sd > 0) {
|
|
33774
|
+
let cov = 0;
|
|
33775
|
+
for (let i = 1; i < intervals.length; i += 1) {
|
|
33776
|
+
cov += (intervals[i] - mean2) * (intervals[i - 1] - mean2);
|
|
33777
|
+
}
|
|
33778
|
+
lag1 = cov / ((intervals.length - 1) * variance);
|
|
33779
|
+
}
|
|
33780
|
+
return {
|
|
33781
|
+
events,
|
|
33782
|
+
meanInterval: Number(mean2.toFixed(2)),
|
|
33783
|
+
cv: cv === null ? null : Number(cv.toFixed(2)),
|
|
33784
|
+
lag1: lag1 === null ? null : Number(lag1.toFixed(2))
|
|
33785
|
+
};
|
|
33786
|
+
}
|
|
33787
|
+
var COVERING_FAMILIES = /* @__PURE__ */ new Set(["clip", "filter"]);
|
|
33788
|
+
var COVERING_KINDS = /* @__PURE__ */ new Set(["camera", "mask", "filter", "three"]);
|
|
33789
|
+
function continuityStats(records, engine) {
|
|
33790
|
+
const switches = sceneSwitches(records);
|
|
33791
|
+
if (!switches.length) return { switches: 0, hardCuts: 0, hardCutShare: null };
|
|
33792
|
+
const covering = [];
|
|
33793
|
+
for (const record2 of records) {
|
|
33794
|
+
if (COVERING_FAMILIES.has(moveFamily(record2)) && record2.end > record2.start) {
|
|
33795
|
+
covering.push({ start: record2.start, end: record2.end });
|
|
33796
|
+
}
|
|
33797
|
+
}
|
|
33798
|
+
for (const tween of engine ?? []) {
|
|
33799
|
+
if (COVERING_KINDS.has(tween.kind) && tween.end - tween.start >= CAMERA_MIN_MOVE_SECONDS) {
|
|
33800
|
+
covering.push({ start: tween.start, end: tween.end });
|
|
33801
|
+
}
|
|
33802
|
+
}
|
|
33803
|
+
let hardCuts = 0;
|
|
33804
|
+
for (const cut of switches) {
|
|
33805
|
+
const window2 = {
|
|
33806
|
+
start: cut.time - CUT_BOUNDARY_TOLERANCE_SECONDS,
|
|
33807
|
+
end: cut.time + CUT_BOUNDARY_TOLERANCE_SECONDS
|
|
33808
|
+
};
|
|
33809
|
+
const covered = covering.some((span) => overlaps(span, window2) > 0);
|
|
33810
|
+
if (!covered) hardCuts += 1;
|
|
33811
|
+
}
|
|
33812
|
+
return {
|
|
33813
|
+
switches: switches.length,
|
|
33814
|
+
hardCuts,
|
|
33815
|
+
hardCutShare: Number((hardCuts / switches.length).toFixed(2))
|
|
33816
|
+
};
|
|
33817
|
+
}
|
|
33818
|
+
function layeredMotionMoments(records, spans2) {
|
|
33819
|
+
const movers = records.filter(
|
|
33820
|
+
(record2) => record2.props.some((prop) => SLIDE_PROPS.has(prop)) && record2.end - record2.start >= 0.3 && !startsWithCamera(record2, spans2)
|
|
33821
|
+
);
|
|
33822
|
+
let moments = 0;
|
|
33823
|
+
for (let i = 0; i < movers.length; i += 1) {
|
|
33824
|
+
for (let j = i + 1; j < movers.length; j += 1) {
|
|
33825
|
+
const a = movers[i];
|
|
33826
|
+
const b = movers[j];
|
|
33827
|
+
if (a.targets[0] === b.targets[0]) continue;
|
|
33828
|
+
if (overlaps(a, b) < 0.4) continue;
|
|
33829
|
+
const ratio = (a.end - a.start) / (b.end - b.start);
|
|
33830
|
+
if (ratio > 0.2 && ratio < 0.8) moments += 1;
|
|
33831
|
+
else if (ratio > 1.25 && ratio < 5) moments += 1;
|
|
33832
|
+
}
|
|
33833
|
+
}
|
|
33834
|
+
return moments;
|
|
33835
|
+
}
|
|
33836
|
+
function computeCraftFindings(input) {
|
|
33837
|
+
const spans2 = cameraSpans(input.engine);
|
|
33838
|
+
const focal = input.records.filter((record2) => !startsWithCamera(record2, spans2));
|
|
33839
|
+
const familySet = /* @__PURE__ */ new Set();
|
|
33840
|
+
for (const record2 of focal) familySet.add(moveFamily(record2));
|
|
33841
|
+
const entrances = focal.filter(isEntrance);
|
|
33842
|
+
const signatureCounts = /* @__PURE__ */ new Map();
|
|
33843
|
+
for (const record2 of entrances) {
|
|
33844
|
+
const signature = entranceSignature(record2);
|
|
33845
|
+
signatureCounts.set(signature, (signatureCounts.get(signature) ?? 0) + 1);
|
|
33846
|
+
}
|
|
33847
|
+
let topSignature = null;
|
|
33848
|
+
let topCount = 0;
|
|
33849
|
+
for (const [signature, count] of signatureCounts) {
|
|
33850
|
+
if (count > topCount) {
|
|
33851
|
+
topCount = count;
|
|
33852
|
+
topSignature = signature;
|
|
33853
|
+
}
|
|
33854
|
+
}
|
|
33855
|
+
const eases = new Set(focal.map((record2) => record2.ease));
|
|
33856
|
+
const wholeFilm = { start: 0, end: input.duration };
|
|
33857
|
+
return {
|
|
33858
|
+
duration: input.duration,
|
|
33859
|
+
cameraMoves: spans2.length,
|
|
33860
|
+
cameraCoverage: Number(cameraCoverageOf(wholeFilm, spans2).toFixed(3)),
|
|
33861
|
+
layeredMoments: layeredMotionMoments(input.records, spans2),
|
|
33862
|
+
families: [...familySet].sort(),
|
|
33863
|
+
entrances: entrances.length,
|
|
33864
|
+
topEntranceSignature: topSignature,
|
|
33865
|
+
topEntranceShare: entrances.length >= 3 ? Number((topCount / entrances.length).toFixed(2)) : null,
|
|
33866
|
+
easeCount: eases.size,
|
|
33867
|
+
rhythm: rhythmStats(input.actionStarts),
|
|
33868
|
+
continuity: continuityStats(input.records, input.engine),
|
|
33869
|
+
deformations: deformationViolations(input.records)
|
|
33870
|
+
};
|
|
33871
|
+
}
|
|
33872
|
+
var CRAFT_BANDS = {
|
|
33873
|
+
cameraMovesPer30s: { low: 3, high: 9 },
|
|
33874
|
+
familiesMin: 5,
|
|
33875
|
+
topEntranceShareMax: 0.35,
|
|
33876
|
+
rhythmCv: { low: 0.3, high: 0.9 },
|
|
33877
|
+
hardCutShareMax: 0.4
|
|
33878
|
+
};
|
|
33879
|
+
var inBand = (value, low, high) => value < low ? `below band [${low}\u2013${high}]` : value > high ? `above band [${low}\u2013${high}]` : `in band [${low}\u2013${high}]`;
|
|
33880
|
+
function craftReport(findings) {
|
|
33881
|
+
const lines = [];
|
|
33882
|
+
const per30 = findings.duration > 0 ? findings.cameraMoves / findings.duration * 30 : 0;
|
|
33883
|
+
lines.push(
|
|
33884
|
+
"CRAFT ENVELOPE (craft-lab v1 \u2014 all bands PROVISIONAL until reference calibration; richness is band-scored, never maximized):"
|
|
33885
|
+
);
|
|
33886
|
+
lines.push(
|
|
33887
|
+
` DEPTH \u2014 camera: ${findings.cameraMoves} move(s) (${per30.toFixed(1)}/30s, ${inBand(per30, CRAFT_BANDS.cameraMovesPer30s.low, CRAFT_BANDS.cameraMovesPer30s.high)}), ${(findings.cameraCoverage * 100).toFixed(0)}% of runtime under camera motion; layered-motion moments: ${findings.layeredMoments}.` + (findings.cameraMoves === 0 ? " \u26A0 ZERO camera moves \u2014 a locked full-frame camera for the whole film reads as a slideshow." : "")
|
|
33888
|
+
);
|
|
33889
|
+
lines.push(
|
|
33890
|
+
` VOCABULARY \u2014 ${findings.families.length} move families (${findings.families.join(", ") || "none"}); ${findings.easeCount} distinct eases; ` + (findings.topEntranceShare !== null ? `top entrance signature ${(findings.topEntranceShare * 100).toFixed(0)}% of ${findings.entrances} entrances` + (findings.topEntranceShare > CRAFT_BANDS.topEntranceShareMax ? ` \u26A0 over the ${CRAFT_BANDS.topEntranceShareMax * 100}% repeat ceiling (${findings.topEntranceSignature})` : "") : "too few entrances to judge repetition")
|
|
33891
|
+
);
|
|
33892
|
+
const rhythm = findings.rhythm;
|
|
33893
|
+
lines.push(
|
|
33894
|
+
` RHYTHM \u2014 ${rhythm.events} focal actions` + (rhythm.cv !== null ? `, interval CV ${rhythm.cv} (${inBand(rhythm.cv, CRAFT_BANDS.rhythmCv.low, CRAFT_BANDS.rhythmCv.high)}; <${CRAFT_BANDS.rhythmCv.low} = metronomic, >${CRAFT_BANDS.rhythmCv.high} = erratic)` + (rhythm.lag1 !== null ? `, lag-1 autocorr ${rhythm.lag1}` : "") + "." : " \u2014 too few to measure rhythm.")
|
|
33895
|
+
);
|
|
33896
|
+
const continuity = findings.continuity;
|
|
33897
|
+
lines.push(
|
|
33898
|
+
` CONTINUITY \u2014 ${continuity.switches} scene changes, ${continuity.hardCuts} hard cut(s)` + (continuity.hardCutShare !== null ? ` (${(continuity.hardCutShare * 100).toFixed(0)}%` + (continuity.hardCutShare > CRAFT_BANDS.hardCutShareMax ? ` \u26A0 over the ${CRAFT_BANDS.hardCutShareMax * 100}% ceiling \u2014 carry more cuts with a camera move, mask, or transition` : "") + ")" : "") + "."
|
|
33899
|
+
);
|
|
33900
|
+
if (findings.deformations.length) {
|
|
33901
|
+
lines.push(
|
|
33902
|
+
` GEOMETRY \u2014 \u26A0 ${findings.deformations.length} deformation violation(s) (aspect distortion \u2265 ${DEFORMATION_MAX_RATIO}:1): ` + findings.deformations.slice(0, 3).map((violation) => `${violation.target} at ${violation.time.toFixed(2)}s (${violation.ratio}:1)`).join("; ") + ". Smeared elements are defects, not style."
|
|
33903
|
+
);
|
|
33904
|
+
} else {
|
|
33905
|
+
lines.push(" GEOMETRY \u2014 no deformation violations.");
|
|
33906
|
+
}
|
|
33907
|
+
return lines.join("\n");
|
|
33908
|
+
}
|
|
33909
|
+
var CAMERA_FLOOR_MIN_DURATION = 8;
|
|
33910
|
+
var DEAD_AIR_BLOCK_SECONDS = 2.5;
|
|
33911
|
+
function craftGateFailures(capture, duration3) {
|
|
33912
|
+
const failures = [];
|
|
33913
|
+
if (capture.beatMismatches > 0) {
|
|
33914
|
+
failures.push(
|
|
33915
|
+
`${capture.beatMismatches} unresolved BEAT MISMATCH finding(s) \u2014 declared energy the film does not deliver. Fix the beats or re-declare them honestly, then review again.`
|
|
33916
|
+
);
|
|
33917
|
+
}
|
|
33918
|
+
if (duration3 >= CAMERA_FLOOR_MIN_DURATION && capture.cameraMoves === 0) {
|
|
33919
|
+
failures.push(
|
|
33920
|
+
"Zero camera moves. The house floor is at least one real camera move per film (camera.move / camera.focus) \u2014 a locked full-frame camera reads as a slideshow."
|
|
33921
|
+
);
|
|
33922
|
+
}
|
|
33923
|
+
if (capture.deformations > 0) {
|
|
33924
|
+
failures.push(
|
|
33925
|
+
`${capture.deformations} geometry deformation violation(s) (aspect distortion \u2265 ${DEFORMATION_MAX_RATIO}:1). Fix the scaling before export.`
|
|
33926
|
+
);
|
|
33927
|
+
}
|
|
33928
|
+
if (capture.longestDeadAirSeconds > DEAD_AIR_BLOCK_SECONDS) {
|
|
33929
|
+
failures.push(
|
|
33930
|
+
`A ${capture.longestDeadAirSeconds.toFixed(1)}s dead-air span exceeds the ${DEAD_AIR_BLOCK_SECONDS}s ceiling. Rest is legitimate; abandonment is not \u2014 decide what owns that time.`
|
|
33931
|
+
);
|
|
33932
|
+
}
|
|
33933
|
+
return failures;
|
|
33934
|
+
}
|
|
33935
|
+
|
|
33936
|
+
// src/mcp/quality-loop.ts
|
|
33937
|
+
var QUALITY_LOOP_ENV = "IMPRACTICAL_QUALITY_LOOP";
|
|
33938
|
+
var meaningful = (label, minimum = 12) => external_exports.string().trim().min(minimum, `${label} must be specific enough to direct from.`);
|
|
33939
|
+
var creativeContractSchema = external_exports.object({
|
|
33940
|
+
audience: meaningful("audience", 3),
|
|
33941
|
+
singleIdea: meaningful("singleIdea"),
|
|
33942
|
+
visualProof: meaningful("visualProof"),
|
|
33943
|
+
referenceStrategy: meaningful("referenceStrategy"),
|
|
33944
|
+
productSource: meaningful("productSource"),
|
|
33945
|
+
opening: meaningful("opening"),
|
|
33946
|
+
proof: meaningful("proof"),
|
|
33947
|
+
payoff: meaningful("payoff")
|
|
33948
|
+
});
|
|
33949
|
+
var qualityScoresSchema = external_exports.object({
|
|
33950
|
+
narrativeClarity: external_exports.number().int().min(1).max(5),
|
|
33951
|
+
visualProof: external_exports.number().int().min(1).max(5),
|
|
33952
|
+
productAuthenticity: external_exports.number().int().min(1).max(5),
|
|
33953
|
+
composition: external_exports.number().int().min(1).max(5),
|
|
33954
|
+
legibility: external_exports.number().int().min(1).max(5),
|
|
33955
|
+
pacing: external_exports.number().int().min(1).max(5),
|
|
33956
|
+
referenceFidelity: external_exports.number().int().min(1).max(5),
|
|
33957
|
+
technicalIntegrity: external_exports.number().int().min(1).max(5)
|
|
33958
|
+
});
|
|
33959
|
+
var qualityReviewSchema = external_exports.object({
|
|
33960
|
+
reviewedRevision: external_exports.number().int().min(0),
|
|
33961
|
+
singleIdea: meaningful("singleIdea"),
|
|
33962
|
+
visualProof: meaningful("visualProof"),
|
|
33963
|
+
referenceComparison: meaningful("referenceComparison"),
|
|
33964
|
+
scores: qualityScoresSchema,
|
|
33965
|
+
timestampEvidence: external_exports.array(
|
|
33966
|
+
external_exports.object({
|
|
33967
|
+
time: external_exports.number().min(0),
|
|
33968
|
+
finding: meaningful("finding", 5)
|
|
33969
|
+
})
|
|
33970
|
+
).min(3),
|
|
33971
|
+
blockingIssues: external_exports.array(external_exports.string().trim().min(1))
|
|
33972
|
+
});
|
|
33973
|
+
var FRAME_RESOLVE_TOLERANCE_SECONDS = 0.6;
|
|
33974
|
+
var createQualityLoopState = () => ({
|
|
33975
|
+
finalReviewByDraft: /* @__PURE__ */ new Map(),
|
|
33976
|
+
finalMotionReviewByDraft: /* @__PURE__ */ new Map(),
|
|
33977
|
+
lockedContentByDraft: /* @__PURE__ */ new Map(),
|
|
33978
|
+
beatsByDraft: /* @__PURE__ */ new Map(),
|
|
33979
|
+
referenceLedger: null,
|
|
33980
|
+
scoreSignatures: /* @__PURE__ */ new Map()
|
|
33981
|
+
});
|
|
33982
|
+
function qualityLoopEnabled(env = process.env) {
|
|
33983
|
+
return !/^(0|false|off|no)$/i.test(env[QUALITY_LOOP_ENV]?.trim() ?? "");
|
|
33984
|
+
}
|
|
33985
|
+
var hasOpening = (times) => times.some((time3) => time3 <= 0.15);
|
|
33986
|
+
var hasEnding = (times, duration3) => duration3 <= 0 || times.some((time3) => time3 >= Math.max(0, duration3 - 0.25));
|
|
33987
|
+
function finalReviewCoverageError(times, duration3) {
|
|
33988
|
+
if (times.length < 8) return "Final review needs at least 8 representative frames.";
|
|
33989
|
+
if (!hasOpening(times)) return "Final review must include the opening frame at t=0.";
|
|
33990
|
+
if (!hasEnding(times, duration3)) {
|
|
33991
|
+
return `Final review must include a settled closing frame near t=${duration3.toFixed(2)}s.`;
|
|
33992
|
+
}
|
|
33993
|
+
return null;
|
|
33994
|
+
}
|
|
33995
|
+
function recordFinalReviewCapture(state, draftId, capture) {
|
|
33996
|
+
state?.finalReviewByDraft.set(draftId, capture);
|
|
33997
|
+
}
|
|
33998
|
+
function qualityReviewPrompt(mode, contract) {
|
|
33999
|
+
const contractNote = contract ? `
|
|
34000
|
+
Creative contract: ${contract.singleIdea} Visual proof: ${contract.visualProof}` : "";
|
|
34001
|
+
if (mode === "proof") {
|
|
34002
|
+
return "\nQUALITY LOOP \u2014 PROOF-SHOT REVIEW. Judge the visible pixels, not the effort or code. Answer with timestamped evidence: (1) can a stranger understand the product transformation, (2) is the real product large and legible, (3) is the result impressive without explanatory copy, (4) is reconstructed UI clearly disclosed rather than called exact, and (5) what must change before building the rest of the film. Do not describe the shot as merely clean." + contractNote;
|
|
34003
|
+
}
|
|
34004
|
+
if (mode === "final") {
|
|
34005
|
+
return "\nQUALITY LOOP \u2014 FINAL CRITIC REVIEW. Treat this as a fresh design-director pass. Score 1\u20135 with timestamped visible evidence: narrative clarity, visual proof, product authenticity, composition/hierarchy, legibility, pacing/variation, reference fidelity, and technical integrity. An empty opening, unreadable product UI, an unsupported marketing claim, stale/reconstructed UI presented as exact, any score below 3, or an average below 4 blocks export. Do not reward implementation effort and do not equate artifact-free with good." + contractNote;
|
|
34006
|
+
}
|
|
34007
|
+
return "\nQUALITY LOOP \u2014 LAYOUT REVIEW. Identify the primary subject at each timestamp and report clipping, unreadable text, weak occupancy, competing hierarchy, missing assets, or empty frames. Use visible evidence rather than saying the frames look clean.";
|
|
34008
|
+
}
|
|
34009
|
+
function motionCoverageFailure(input) {
|
|
34010
|
+
if (!input.motionLab) return null;
|
|
34011
|
+
if (!input.capture) {
|
|
34012
|
+
return 'The motion lab requires full-duration motion coverage: call get_motion({ draftId, reviewMode: "final" }) and resolve the dead air and imperceptible tweens it reports before exporting.';
|
|
34013
|
+
}
|
|
34014
|
+
if (input.capture.revision !== input.frameReviewRevision) {
|
|
34015
|
+
return `The motion review covered revision ${input.capture.revision}, but the final frame review covered revision ${input.frameReviewRevision}. Review the same revision with both.`;
|
|
34016
|
+
}
|
|
34017
|
+
return null;
|
|
34018
|
+
}
|
|
34019
|
+
function framingMotionFailure(capture) {
|
|
34020
|
+
if (!capture?.framingFailures?.length) return null;
|
|
34021
|
+
return "The final motion review found broken framing \u2014 fix these shots and review again:\n" + capture.framingFailures.map((finding) => `- ${finding}`).join("\n");
|
|
34022
|
+
}
|
|
34023
|
+
function craftMotionFailure(input) {
|
|
34024
|
+
if (!input.craftLab) return null;
|
|
34025
|
+
const craft = input.capture?.craft;
|
|
34026
|
+
if (!craft) return null;
|
|
34027
|
+
const failures = craftGateFailures(craft, input.capture?.duration ?? 0);
|
|
34028
|
+
if (!failures.length) return null;
|
|
34029
|
+
return `Craft floors block export: ${failures.join(" ")}`;
|
|
34030
|
+
}
|
|
34031
|
+
function qualityReviewFailure(input) {
|
|
34032
|
+
const { draftRevision, capture, review } = input;
|
|
34033
|
+
if (!capture) {
|
|
34034
|
+
return 'No eligible final review exists. Call get_frames with reviewMode="final" on at least 8 timestamps including t=0 and the settled closing frame.';
|
|
34035
|
+
}
|
|
34036
|
+
if (capture.revision !== draftRevision) {
|
|
34037
|
+
return `The final review covered revision ${capture.revision}, but the draft is now revision ${draftRevision}. Review the current revision again.`;
|
|
34038
|
+
}
|
|
34039
|
+
if (!review) {
|
|
34040
|
+
return "Attach the timestamped qualityReview scorecard required by the local quality loop.";
|
|
34041
|
+
}
|
|
34042
|
+
if (review.reviewedRevision !== draftRevision) {
|
|
34043
|
+
return `qualityReview says revision ${review.reviewedRevision}, but the current draft is revision ${draftRevision}.`;
|
|
34044
|
+
}
|
|
34045
|
+
if (review.blockingIssues.length) {
|
|
34046
|
+
return `The review still has blocking issues: ${review.blockingIssues.join("; ")}`;
|
|
34047
|
+
}
|
|
34048
|
+
const unresolved = capture.times.filter(
|
|
34049
|
+
(time3) => !review.timestampEvidence.some((entry) => Math.abs(entry.time - time3) <= FRAME_RESOLVE_TOLERANCE_SECONDS)
|
|
34050
|
+
);
|
|
34051
|
+
if (unresolved.length) {
|
|
34052
|
+
return `Unverified frames: the final capture rendered stills at ${capture.times.map((t) => t.toFixed(1)).join(", ")}s but timestampEvidence resolves nothing near t=${unresolved.map((t) => t.toFixed(1)).join(", ")}s. Look at each captured frame and record what you actually see there \u2014 every still must be marked with a finding before export. If your context was compacted since the capture, call get_frames again: summarized claims about frames you can no longer see do not count.`;
|
|
34053
|
+
}
|
|
34054
|
+
if (input.craftLab) {
|
|
34055
|
+
if (input.scoreSignatures && input.draftId) {
|
|
34056
|
+
const signature = JSON.stringify(review.scores);
|
|
34057
|
+
const previous = input.scoreSignatures.get(signature);
|
|
34058
|
+
if (previous && previous !== input.draftId) {
|
|
34059
|
+
return "This scorecard is identical to one already submitted for a different draft this session. Review THIS film with fresh eyes \u2014 identical numbers across different films are a rubber stamp, not a judgment.";
|
|
34060
|
+
}
|
|
34061
|
+
input.scoreSignatures.set(signature, input.draftId);
|
|
34062
|
+
}
|
|
34063
|
+
return null;
|
|
34064
|
+
}
|
|
34065
|
+
const scores = Object.entries(review.scores);
|
|
34066
|
+
const low = scores.filter(([, score]) => score < 3);
|
|
34067
|
+
if (low.length) {
|
|
34068
|
+
return `Scores below 3 block export: ${low.map(([name, score]) => `${name}=${score}`).join(", ")}.`;
|
|
34069
|
+
}
|
|
34070
|
+
const average = scores.reduce((sum, [, score]) => sum + score, 0) / scores.length;
|
|
34071
|
+
if (average < 4) return `Quality score average ${average.toFixed(2)} is below the 4.0 export floor.`;
|
|
34072
|
+
return null;
|
|
34073
|
+
}
|
|
34074
|
+
function qualityLoopServerInstructions() {
|
|
34075
|
+
return `
|
|
34076
|
+
|
|
34077
|
+
LOCAL QUALITY LOOP IS ENABLED \u2014 this is an opt-in experiment and it is mandatory for new videos:
|
|
34078
|
+
1. Before create_video, write and pass its creativeContract: audience, one memorable idea, one visible proof, how the reference will be translated rather than copied, the real product source, and opening/proof/payoff beats.
|
|
34079
|
+
2. Build the proof beat first. Call get_frames with reviewMode="proof" and judge whether the product transformation is understandable and impressive before completing the rest.
|
|
34080
|
+
3. After the complete cut, call get_frames with reviewMode="final" on at least 8 representative timestamps including t=0 and the settled closing frame. Score what is visibly present; "clean" is not a verdict. Every captured still is an open observation: your qualityReview's timestampEvidence must record a finding at each captured time, or export is blocked with the unresolved list. If your context is ever compacted, re-run get_frames before exporting \u2014 claims about frames you can no longer see do not count.
|
|
34081
|
+
4. Export only with a timestamped qualityReview for the exact reviewed revision. Any unresolved blocking issue, score below 3, or average below 4 must be fixed and reviewed again.
|
|
34082
|
+
This loop changes no scene schema or renderer behavior. It exists to stop technically valid but creatively weak cuts from self-approving.`;
|
|
34083
|
+
}
|
|
34084
|
+
|
|
34085
|
+
// src/mcp/phased-flow.ts
|
|
34086
|
+
var PHASED_FLOW_ENV = "IMPRACTICAL_PHASED_FLOW";
|
|
34087
|
+
function phasedFlowEnabled(env = process.env) {
|
|
34088
|
+
return !/^(0|false|off|no)$/i.test(env[PHASED_FLOW_ENV]?.trim() ?? "");
|
|
34089
|
+
}
|
|
34090
|
+
var meaningful2 = (label, minimum) => external_exports.string().trim().min(minimum, `${label} must be specific enough to direct from.`);
|
|
34091
|
+
var conceptSchema = external_exports.object({
|
|
34092
|
+
/** One sentence: product, user, and the change the viewer will believe. */
|
|
34093
|
+
premise: meaningful2("premise", 20),
|
|
34094
|
+
/** The thing the viewer follows across the whole film (an invoice, a request…). */
|
|
34095
|
+
recurringArtifact: meaningful2("recurringArtifact", 6),
|
|
34096
|
+
/** Evidence gathered BEFORE inventing UI: real code, the product site, images. */
|
|
34097
|
+
references: external_exports.array(
|
|
34098
|
+
external_exports.object({
|
|
34099
|
+
kind: external_exports.enum(["local-code", "website", "screenshot", "image-search"]),
|
|
34100
|
+
detail: meaningful2("reference detail", 5)
|
|
34101
|
+
})
|
|
34102
|
+
).min(1, "Gather at least one real reference before designing components."),
|
|
34103
|
+
/** Every beat names a MOVE — a beat with no move is not a beat. */
|
|
34104
|
+
beats: external_exports.array(
|
|
34105
|
+
external_exports.object({
|
|
34106
|
+
start: external_exports.number().min(0),
|
|
34107
|
+
end: external_exports.number().min(0),
|
|
34108
|
+
subject: meaningful2("beat subject", 3),
|
|
34109
|
+
move: meaningful2("beat move", 8),
|
|
34110
|
+
purpose: meaningful2("beat purpose", 5),
|
|
34111
|
+
/** Declared energy: rest is legitimate stillness; high must visibly move. */
|
|
34112
|
+
energy: external_exports.enum(["rest", "low", "high"]),
|
|
34113
|
+
/**
|
|
34114
|
+
* What happens to the elements ALREADY on stage when this beat starts:
|
|
34115
|
+
* exit (and how), transform, or deliberately stay. First beat may say
|
|
34116
|
+
* "empty stage". Entrances are cheap; exits are choreography, and a
|
|
34117
|
+
* beat that only describes what enters leaves the last scene's props
|
|
34118
|
+
* loitering in frame.
|
|
34119
|
+
*/
|
|
34120
|
+
outgoing: meaningful2("beat outgoing (what the elements already on stage do)", 6)
|
|
34121
|
+
})
|
|
34122
|
+
).min(4),
|
|
34123
|
+
/**
|
|
34124
|
+
* Every component the film will show, each answering: must this look 1:1
|
|
34125
|
+
* real? 'exact-product' = the actual product's UI; 'realistic-class' = a
|
|
34126
|
+
* real-world object class (a terminal, an invoice); 'stylized' = abstract/
|
|
34127
|
+
* editorial, no reference needed. Non-stylized components MUST name the
|
|
34128
|
+
* reference source that was pulled and viewed for them.
|
|
34129
|
+
*/
|
|
34130
|
+
componentInventory: external_exports.array(
|
|
34131
|
+
external_exports.object({
|
|
34132
|
+
name: meaningful2("component", 3),
|
|
34133
|
+
realism: external_exports.enum(["exact-product", "realistic-class", "stylized"]),
|
|
34134
|
+
referenceSource: external_exports.string().trim().min(4).optional()
|
|
34135
|
+
})
|
|
34136
|
+
).min(1)
|
|
34137
|
+
});
|
|
34138
|
+
var contentSchema = external_exports.object({
|
|
34139
|
+
/** Every editorial line the film will show, in viewing order. Locked. */
|
|
34140
|
+
lines: external_exports.array(
|
|
34141
|
+
external_exports.object({
|
|
34142
|
+
text: external_exports.string().trim().min(2).max(90),
|
|
34143
|
+
kind: external_exports.enum(["statement", "label", "endcard"])
|
|
34144
|
+
})
|
|
34145
|
+
).min(3)
|
|
34146
|
+
});
|
|
34147
|
+
function phasedGateFailure(args) {
|
|
34148
|
+
if (!args.concept || !args.content) {
|
|
34149
|
+
return "The phased flow requires BOTH `concept` and `content` before scaffolding. Phase 1 (concept): from the brief alone \u2014 premise, recurringArtifact, references (look for the product's real code or site FIRST), beats (each with start, end, subject, move, purpose, energy, and outgoing \u2014 what the elements already on stage do when the beat starts), componentInventory. Phase 2 (content): every editorial line the film will show, in viewing order, each marked statement/label/endcard, written so someone who has never heard of the product could repeat it. Do this BEFORE studying the engine or library. The lines are locked: the motion report diffs the rendered film against them.";
|
|
34150
|
+
}
|
|
34151
|
+
const concept = conceptSchema.safeParse(args.concept);
|
|
34152
|
+
if (!concept.success) {
|
|
34153
|
+
const issue2 = concept.error.issues[0];
|
|
34154
|
+
const at = issue2?.path?.length ? ` at ${issue2.path.join(".")}` : "";
|
|
34155
|
+
return `concept invalid${at}: ${issue2?.message ?? "schema mismatch"}`;
|
|
34156
|
+
}
|
|
34157
|
+
const content = contentSchema.safeParse(args.content);
|
|
34158
|
+
if (!content.success) {
|
|
34159
|
+
const issue2 = content.error.issues[0];
|
|
34160
|
+
const at = issue2?.path?.length ? ` at ${issue2.path.join(".")}` : "";
|
|
34161
|
+
return `content invalid${at}: ${issue2?.message ?? "schema mismatch"}`;
|
|
34162
|
+
}
|
|
34163
|
+
const missing = concept.data.componentInventory.filter(
|
|
34164
|
+
(component) => component.realism !== "stylized" && !component.referenceSource
|
|
34165
|
+
);
|
|
34166
|
+
if (missing.length) {
|
|
34167
|
+
return `concept invalid: these components are marked as needing realism but have no referenceSource: ${missing.map((component) => component.name).join(", ")}. For each, pull a real reference image (gather_references / your image search), VIEW it, and record its source \u2014 or mark the component stylized if it genuinely needs no real-world counterpart.`;
|
|
34168
|
+
}
|
|
34169
|
+
return null;
|
|
34170
|
+
}
|
|
34171
|
+
var CONTENT_DOCTRINE_ENV = "IMPRACTICAL_CONTENT_DOCTRINE";
|
|
34172
|
+
function contentDoctrineEnabled(env = process.env) {
|
|
34173
|
+
return !/^(0|false|off|no)$/i.test(env[CONTENT_DOCTRINE_ENV]?.trim() ?? "");
|
|
34174
|
+
}
|
|
34175
|
+
var contentDoctrine = () => contentDoctrineEnabled() ? ` Write every line so a smart 16-year-old who has never heard of the product gets it on first read \u2014 if they couldn't repeat it back in their own words, rewrite it. No insider jargon: name what the thing DOES in plain words, not its category ("your agent gets its own email address", never "agentic email infrastructure"). Say it, then show it: a statement makes ONE plain claim, and the very next thing the film does is prove that claim on camera. One thing to read at a time \u2014 never two lines competing for the same moment.` : " Write them plain enough for someone who has never heard of the product.";
|
|
34176
|
+
var contentTextRule = () => contentDoctrineEnabled() ? " That floor governs EDITORIAL lines \u2014 the film's own statements and labels \u2014 and it is not a ban on words inside the product you are showing. Recreate the product's own content as real, plausible words at whatever size the real interface uses. NEVER replace product content with grey placeholder bars or skeleton rectangles: a wireframe reads as a loading screen, and a film that claims the software works while showing an empty shell has proved nothing. Small real text is what makes an interface look real; the rule is that the viewer must never NEED to read it, not that it must be absent." : "";
|
|
34177
|
+
function phasedServerInstructions() {
|
|
34178
|
+
return `
|
|
34179
|
+
|
|
34180
|
+
PHASED FLOW IS ENABLED \u2014 work in three phases, in order:
|
|
34181
|
+
1. CONCEPT \u2014 from the brief alone, before engine_types or motion_context: FIRST study references, then call gather_references (required \u2014 create_video refuses without it). If you have native web/image search and an image-viewing tool, USE THEM: search real screenshots of the product in use and of each component class, download and VIEW them before designing anything. Hunt references of the ACTUAL SOFTWARE, not the marketing homepage \u2014 the product's real front-end code on this machine (pass it as appCode) \u2192 screenshots of the product IN USE (its app/editor/dashboard, found via its docs, help center, changelog, demo videos, review sites) \u2192 its site code \u2192 marketing site (styling tokens only) \u2192 real images of the component class \u2192 mockups; design components to match what the references actually show. Then ask of every component the film will show: MUST THIS LOOK 1:1 REAL? Product UI and real-world objects (a terminal, an invoice, a dashboard) do; abstract backdrops and statement cards do not. For each realism-required component, pull a real reference image, VIEW it, and record it as that component's referenceSource in the inventory. Then write the premise, the one recurring artifact the viewer follows, and beats where every beat names a MOVE, declares its energy (rest / low / high) \u2014 rest is legitimate stillness, high must visibly move; the motion report verdicts each beat against its declaration \u2014 and declares its OUTGOING: what the elements already on stage do when this beat starts. Nothing new enters without saying what the things still showing do \u2014 exit (and how), transform into the new thing, or deliberately stay. Entrances are cheap; exits are choreography. A beat that only describes what arrives leaves the last scene's props loitering in frame. The first beat may say "empty stage".
|
|
34182
|
+
2. CONTENT \u2014 write every editorial line of the film in viewing order (statement/label/endcard).${contentDoctrine()} Statements are the big lines the film is built around \u2014 10-15% of frame height each; no text anyone MUST read below 4% unless the user explicitly asks.${contentTextRule()} These lines are LOCKED once passed to create_video: the motion report flags any on-screen line that is not in the list, and any listed line the camera never shows.
|
|
34183
|
+
RUNTIME \u2014 when the user gives no duration, plan 20-30 seconds and EARN it with at least two distinct proof chapters (different capabilities or angles), never by stretching holds. A user-specified runtime always wins.
|
|
34184
|
+
3. CREATION \u2014 only now read engine_types and motion_context. Call install_kits on the new draft FIRST and import the primitives from './kit/*' \u2014 the cursor actor, showpieces, looks and sentence typography are already built and tuned; hand-rolling them produces cursors that teleport and clicks that fire early. Then cast the beats onto the library and kits, build, measure, revise, review, export.
|
|
34185
|
+
create_video requires the concept and content artifacts and will refuse without them.`;
|
|
34186
|
+
}
|
|
34187
|
+
|
|
34188
|
+
// src/mcp/preflight.ts
|
|
34189
|
+
var esbuildPromise = null;
|
|
34190
|
+
function loadEsbuild() {
|
|
34191
|
+
esbuildPromise ??= import("./main-BEONOBG5.js").then(
|
|
34192
|
+
(mod) => mod,
|
|
34193
|
+
() => null
|
|
34194
|
+
);
|
|
34195
|
+
return esbuildPromise;
|
|
34196
|
+
}
|
|
34197
|
+
var LOADERS = {
|
|
34198
|
+
".tsx": "tsx",
|
|
34199
|
+
".ts": "ts",
|
|
34200
|
+
".jsx": "jsx",
|
|
34201
|
+
".js": "js",
|
|
34202
|
+
".css": "css",
|
|
34203
|
+
".json": "json"
|
|
34204
|
+
};
|
|
34205
|
+
function resolveDraftPath(files, request) {
|
|
34206
|
+
const clean = request.replace(/^\.\//, "");
|
|
34207
|
+
const candidates = [
|
|
34208
|
+
clean,
|
|
34209
|
+
`${clean}.ts`,
|
|
34210
|
+
`${clean}.tsx`,
|
|
34211
|
+
`${clean}.js`,
|
|
34212
|
+
`${clean}.jsx`,
|
|
34213
|
+
`${clean}/index.ts`,
|
|
34214
|
+
`${clean}/index.tsx`
|
|
34215
|
+
];
|
|
34216
|
+
return candidates.find((candidate) => candidate in files) ?? null;
|
|
34217
|
+
}
|
|
34218
|
+
async function preflightDraft(files) {
|
|
34219
|
+
const assetErrors = [...assetReferenceErrors(files), ...unbundledFontErrors(files)];
|
|
34220
|
+
if (assetErrors.length) return assetErrors.join("\n");
|
|
34221
|
+
const esbuild = await loadEsbuild();
|
|
34222
|
+
if (!esbuild) return null;
|
|
34223
|
+
const entries = ["App.tsx", "direction.ts"].filter((entry) => entry in files);
|
|
34224
|
+
if (!entries.length) return null;
|
|
34225
|
+
try {
|
|
34226
|
+
await esbuild.build({
|
|
34227
|
+
entryPoints: entries.map((entry) => `draft:${entry}`),
|
|
34228
|
+
bundle: true,
|
|
34229
|
+
write: false,
|
|
34230
|
+
// write:false never touches disk; outdir is still required for multiple entries.
|
|
34231
|
+
outdir: "preflight-virtual",
|
|
34232
|
+
platform: "browser",
|
|
34233
|
+
format: "esm",
|
|
34234
|
+
logLevel: "silent",
|
|
34235
|
+
plugins: [
|
|
34236
|
+
{
|
|
34237
|
+
name: "draft-virtual-fs",
|
|
34238
|
+
setup(build) {
|
|
34239
|
+
build.onResolve({ filter: /^draft:/ }, (args) => ({
|
|
34240
|
+
path: args.path.slice("draft:".length),
|
|
34241
|
+
namespace: "draft"
|
|
34242
|
+
}));
|
|
34243
|
+
build.onResolve({ filter: /.*/ }, (args) => {
|
|
34244
|
+
if (args.namespace !== "draft") return void 0;
|
|
34245
|
+
if (args.path.startsWith("./")) {
|
|
34246
|
+
const dir = args.importer.includes("/") ? args.importer.slice(0, args.importer.lastIndexOf("/") + 1) : "";
|
|
34247
|
+
const joined = dir + args.path.slice(2);
|
|
34248
|
+
const resolved2 = resolveDraftPath(files, joined);
|
|
34249
|
+
if (resolved2) return { path: resolved2, namespace: "draft" };
|
|
34250
|
+
return {
|
|
34251
|
+
errors: [
|
|
34252
|
+
{
|
|
34253
|
+
text: `"${args.path}" (from ${args.importer}) does not match any draft file. Draft files: ${Object.keys(files).sort().join(", ")}`
|
|
34254
|
+
}
|
|
34255
|
+
]
|
|
34256
|
+
};
|
|
34257
|
+
}
|
|
34258
|
+
return { path: args.path, external: true };
|
|
34259
|
+
});
|
|
34260
|
+
build.onLoad({ filter: /.*/, namespace: "draft" }, (args) => {
|
|
34261
|
+
const ext = args.path.slice(args.path.lastIndexOf("."));
|
|
34262
|
+
return {
|
|
34263
|
+
contents: files[args.path],
|
|
34264
|
+
loader: LOADERS[ext] ?? "ts"
|
|
34265
|
+
};
|
|
34266
|
+
});
|
|
34267
|
+
}
|
|
34268
|
+
}
|
|
34269
|
+
]
|
|
34270
|
+
});
|
|
34271
|
+
return null;
|
|
34272
|
+
} catch (error51) {
|
|
34273
|
+
const message = error51 instanceof Error ? error51.message : String(error51);
|
|
34274
|
+
return message;
|
|
34275
|
+
}
|
|
34276
|
+
}
|
|
34277
|
+
var URL_REF_RE = /(?<![\w.$])(?<!new\s)url\(\s*(['"])([^'"\n]{1,300})\1\s*\)/gi;
|
|
34278
|
+
var FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?|#|$)/i;
|
|
34279
|
+
var ALLOWED_FONT_HOSTS = /* @__PURE__ */ new Set(["fonts.googleapis.com", "fonts.gstatic.com"]);
|
|
34280
|
+
function hostOf(reference) {
|
|
34281
|
+
try {
|
|
34282
|
+
return new URL(reference).hostname;
|
|
34283
|
+
} catch {
|
|
34284
|
+
return null;
|
|
34285
|
+
}
|
|
34286
|
+
}
|
|
34287
|
+
function assetReferenceErrors(files) {
|
|
34288
|
+
const errors = [];
|
|
34289
|
+
for (const [file2, content] of Object.entries(files)) {
|
|
34290
|
+
if (typeof content !== "string") continue;
|
|
34291
|
+
for (const match of content.matchAll(URL_REF_RE)) {
|
|
34292
|
+
const reference = match[2].trim();
|
|
34293
|
+
if (!reference || reference.startsWith("#")) continue;
|
|
34294
|
+
if (reference.includes("${")) continue;
|
|
34295
|
+
if (/^(data|blob):/i.test(reference)) continue;
|
|
34296
|
+
if (/^https?:/i.test(reference) || reference.startsWith("//")) {
|
|
34297
|
+
if (FONT_EXT_RE.test(reference)) {
|
|
34298
|
+
const host = hostOf(reference.startsWith("//") ? `https:${reference}` : reference);
|
|
34299
|
+
if (!host || !ALLOWED_FONT_HOSTS.has(host)) {
|
|
34300
|
+
errors.push(
|
|
34301
|
+
`${file2}: url('${reference}') loads a font from a host the render blocks. Use install_font to inline the family, or a fonts.googleapis.com stylesheet.`
|
|
34302
|
+
);
|
|
34303
|
+
}
|
|
34304
|
+
}
|
|
34305
|
+
continue;
|
|
34306
|
+
}
|
|
34307
|
+
if (reference.startsWith("/")) {
|
|
34308
|
+
if (!/^\/(scenes|brands)\//.test(reference)) {
|
|
34309
|
+
errors.push(
|
|
34310
|
+
`${file2}: url('${reference}') is an absolute path outside the workspace roots \u2014 nothing serves it at render time. Assets live under /scenes/<scene>/\u2026 or /brands/<slug>/\u2026 (see brand_get), or inline with install_font / data: URIs.`
|
|
34311
|
+
);
|
|
34312
|
+
}
|
|
34313
|
+
continue;
|
|
34314
|
+
}
|
|
34315
|
+
const clean = reference.replace(/[?#].*$/, "");
|
|
34316
|
+
if (!resolveDraftPath(files, clean) && !(clean in files)) {
|
|
34317
|
+
errors.push(
|
|
34318
|
+
`${file2}: url('${reference}') does not match any draft file, so the render will fail with a NetworkError (fonts report "Render font failed to load"). Drafts cannot hold binary assets \u2014 use install_font to inline an open-licensed font, reference the brand kit's real files by absolute path (/brands/<slug>/fonts/\u2026, from brand_get), or a data: URI.`
|
|
34319
|
+
);
|
|
34320
|
+
}
|
|
34321
|
+
}
|
|
34322
|
+
}
|
|
34323
|
+
return errors;
|
|
34324
|
+
}
|
|
34325
|
+
var BUNDLED_FAMILY_ALIASES = new Set(
|
|
34326
|
+
[
|
|
34327
|
+
"Space Grotesk",
|
|
34328
|
+
"Impractical Space Grotesk",
|
|
34329
|
+
"Geist",
|
|
34330
|
+
"Geist Variable",
|
|
34331
|
+
"Geist Mono",
|
|
34332
|
+
"Geist Mono Variable",
|
|
34333
|
+
"Inter",
|
|
34334
|
+
"Inter Variable"
|
|
34335
|
+
].map((family) => family.toLowerCase())
|
|
34336
|
+
);
|
|
34337
|
+
var PORTABLE_FAMILIES = /* @__PURE__ */ new Set([
|
|
34338
|
+
"-apple-system",
|
|
34339
|
+
"blinkmacsystemfont",
|
|
34340
|
+
"system-ui",
|
|
34341
|
+
"ui-sans-serif",
|
|
34342
|
+
"ui-serif",
|
|
34343
|
+
"ui-monospace",
|
|
34344
|
+
"ui-rounded",
|
|
34345
|
+
"sans-serif",
|
|
34346
|
+
"serif",
|
|
34347
|
+
"monospace",
|
|
34348
|
+
"cursive",
|
|
34349
|
+
"fantasy",
|
|
34350
|
+
"emoji",
|
|
34351
|
+
"math",
|
|
34352
|
+
"fangsong"
|
|
34353
|
+
]);
|
|
34354
|
+
var GENERIC_STACK_TAIL_RE = /(system-ui|sans-serif|serif|monospace|ui-monospace|ui-sans-serif|ui-serif)\s*$/i;
|
|
34355
|
+
var FONT_FACE_FAMILY_RE = /@font-face[^}]*?font-family:\s*['"]?([^'";}]+)/gi;
|
|
34356
|
+
var CSS_FONT_FAMILY_RE = /font-family:\s*([^;}\n]+)/gi;
|
|
34357
|
+
var JS_FONT_FAMILY_RE = /fontFamily:\s*(['"`])([^'"`\n]+)\1/g;
|
|
34358
|
+
var STACK_LITERAL_RE = /(['"`])([A-Za-z][\w '.-]*(?:,\s*[\w '.-]+)+)\1/g;
|
|
34359
|
+
function primaryFamily(stack) {
|
|
34360
|
+
return (stack.split(",")[0] ?? "").trim().replace(/^['"]|['"]$/g, "");
|
|
34361
|
+
}
|
|
34362
|
+
function unbundledFontErrors(files) {
|
|
34363
|
+
const declared = /* @__PURE__ */ new Set();
|
|
34364
|
+
for (const content of Object.values(files)) {
|
|
34365
|
+
if (typeof content !== "string") continue;
|
|
34366
|
+
for (const match of content.matchAll(FONT_FACE_FAMILY_RE)) {
|
|
34367
|
+
declared.add(primaryFamily(match[1]).toLowerCase());
|
|
34368
|
+
}
|
|
34369
|
+
}
|
|
34370
|
+
const resolvable = (family) => {
|
|
34371
|
+
const normalized = family.toLowerCase();
|
|
34372
|
+
return !normalized || normalized.includes("${") || BUNDLED_FAMILY_ALIASES.has(normalized) || PORTABLE_FAMILIES.has(normalized) || declared.has(normalized);
|
|
34373
|
+
};
|
|
34374
|
+
const errors = [];
|
|
34375
|
+
const flagged = /* @__PURE__ */ new Set();
|
|
34376
|
+
const flag = (file2, family) => {
|
|
34377
|
+
const key = `${file2}\0${family.toLowerCase()}`;
|
|
34378
|
+
if (flagged.has(key)) return;
|
|
34379
|
+
flagged.add(key);
|
|
34380
|
+
errors.push(
|
|
34381
|
+
`${file2}: font-family leads with "${family}", which is neither bundled (Space Grotesk, Geist, Geist Mono, Inter), declared via @font-face in the draft, nor a portable generic. The render guard will throw and the stage will never reach ready \u2014 no frame can show you this. Use a bundled family, or install_font to inline an open-licensed face, and say so if you substituted.`
|
|
34382
|
+
);
|
|
34383
|
+
};
|
|
34384
|
+
for (const [file2, content] of Object.entries(files)) {
|
|
34385
|
+
if (typeof content !== "string") continue;
|
|
34386
|
+
if (file2.endsWith(".json") || file2.endsWith(".md")) continue;
|
|
34387
|
+
const stacks = [];
|
|
34388
|
+
if (file2.endsWith(".css")) {
|
|
34389
|
+
for (const match of content.matchAll(CSS_FONT_FAMILY_RE)) stacks.push(match[1]);
|
|
34390
|
+
} else {
|
|
34391
|
+
for (const match of content.matchAll(JS_FONT_FAMILY_RE)) stacks.push(match[2]);
|
|
34392
|
+
for (const match of content.matchAll(STACK_LITERAL_RE)) {
|
|
34393
|
+
if (GENERIC_STACK_TAIL_RE.test(match[2])) stacks.push(match[2]);
|
|
34394
|
+
}
|
|
34395
|
+
for (const match of content.matchAll(CSS_FONT_FAMILY_RE)) stacks.push(match[1]);
|
|
34396
|
+
}
|
|
34397
|
+
for (const stack of stacks) {
|
|
34398
|
+
const family = primaryFamily(stack);
|
|
34399
|
+
if (!resolvable(family)) flag(file2, family);
|
|
34400
|
+
}
|
|
34401
|
+
}
|
|
34402
|
+
return errors;
|
|
34403
|
+
}
|
|
34404
|
+
|
|
34405
|
+
// src/mcp/tools.ts
|
|
34406
|
+
var createSessionState = () => ({
|
|
34407
|
+
lastCaptureByDraft: /* @__PURE__ */ new Map(),
|
|
34408
|
+
measuredByDraft: /* @__PURE__ */ new Map()
|
|
34409
|
+
});
|
|
34410
|
+
var json2 = (value) => ({ text: JSON.stringify(value, null, 2) });
|
|
34411
|
+
var fail = (text) => ({ text, isError: true });
|
|
34412
|
+
var nap = (ctx, ms) => ctx.sleep ? ctx.sleep(ms) : new Promise((resolve) => setTimeout(resolve, ms));
|
|
34413
|
+
var TERMINAL = /* @__PURE__ */ new Set(["completed", "failed", "canceled"]);
|
|
34414
|
+
var POLL_ERROR_LIMIT = 5;
|
|
34415
|
+
var JOB_WAIT_SECONDS = 300;
|
|
34416
|
+
var BRAND_WAIT_SECONDS = 300;
|
|
34417
|
+
var MAX_WAIT_SECONDS = 540;
|
|
34418
|
+
var maxWaitSeconds = () => {
|
|
34419
|
+
const override = Number(process.env.IMPRACTICAL_MAX_WAIT_SECONDS);
|
|
34420
|
+
return Number.isFinite(override) && override > 0 ? override : MAX_WAIT_SECONDS;
|
|
34421
|
+
};
|
|
34422
|
+
function waitBudgetMs(requested, fallbackSeconds) {
|
|
34423
|
+
const asked = Number(requested);
|
|
34424
|
+
const seconds = Number.isFinite(asked) && asked > 0 ? asked : fallbackSeconds;
|
|
34425
|
+
return Math.min(seconds, maxWaitSeconds()) * 1e3;
|
|
34426
|
+
}
|
|
34427
|
+
var waitWasClamped = (requested) => {
|
|
34428
|
+
const asked = Number(requested);
|
|
34429
|
+
return Number.isFinite(asked) && asked > maxWaitSeconds();
|
|
34430
|
+
};
|
|
34431
|
+
var clampNote = (requested) => waitWasClamped(requested) ? ` Waiting was capped at ${maxWaitSeconds()}s (you asked for ${Number(requested)}s) because MCP clients drop a request that stays open too long, which would report this job as failed while it is still running.` : "";
|
|
34432
|
+
function describeJob(jobId, row, consecutiveErrors, lastError) {
|
|
34433
|
+
if (consecutiveErrors) return `${jobId}: status check failed (${lastError}), retrying`;
|
|
34434
|
+
const stage = row?.stage ? ` \u2014 ${row.stage}` : "";
|
|
34435
|
+
const pct = typeof row?.progress === "number" ? ` ${Math.round(row.progress * 100)}%` : "";
|
|
34436
|
+
return `${jobId}: ${row?.status ?? "queued"}${stage}${pct}`;
|
|
34437
|
+
}
|
|
34438
|
+
var resumeWith = (jobId) => `job_status({ jobIds: ["${jobId}"], wait: true })`;
|
|
34439
|
+
var px = (value) => String(Math.round(value));
|
|
34440
|
+
var describeRect = (el) => `${el.sel} ${px(el.w)}\xD7${px(el.h)}@(${px(el.x)},${px(el.y)})`;
|
|
34441
|
+
var listSome = (sels, cap = 6) => sels.slice(0, cap).join(", ") + (sels.length > cap ? ` +${sels.length - cap} more` : "");
|
|
34442
|
+
function renderMeasured(draftId, measured, previous) {
|
|
34443
|
+
const placed = measured.elements.filter((el) => !el.hidden && !el.offstage);
|
|
34444
|
+
const hidden = measured.elements.filter((el) => el.hidden || el.offstage);
|
|
34445
|
+
const lines = [
|
|
34446
|
+
`MEASURED ${draftId} @ revision ${measured.revision}: duration ${measured.duration.toFixed(2)}s, ${measured.tweenCount} tween(s), ${placed.length} data-attributed element(s) placed at t=0` + (measured.truncated ? ` (+${measured.truncated} beyond the reporting cap)` : "") + ". This is what the build produced, not what the code intended."
|
|
34447
|
+
];
|
|
34448
|
+
if (placed.length) {
|
|
34449
|
+
lines.push(" " + placed.slice(0, 12).map(describeRect).join(" \xB7 ") + (placed.length > 12 ? ` \xB7 +${placed.length - 12} more` : ""));
|
|
34450
|
+
}
|
|
34451
|
+
if (hidden.length) {
|
|
34452
|
+
lines.push(` not visible at t=0: ${listSome(hidden.map((el) => el.sel))}`);
|
|
34453
|
+
}
|
|
34454
|
+
if (previous) {
|
|
34455
|
+
const visible = (stage) => new Map(stage.elements.filter((el) => !el.hidden && !el.offstage).map((el) => [el.sel, el]));
|
|
34456
|
+
const before = visible(previous);
|
|
34457
|
+
const after = visible(measured);
|
|
34458
|
+
const appeared = [...after.keys()].filter((sel) => !before.has(sel));
|
|
34459
|
+
const gone = [...before.keys()].filter((sel) => !after.has(sel));
|
|
34460
|
+
const moved = [...after.entries()].filter(([sel, el]) => {
|
|
34461
|
+
const prior = before.get(sel);
|
|
34462
|
+
return prior && (Math.abs(prior.x - el.x) + Math.abs(prior.y - el.y) > 1.5 || Math.abs(prior.w - el.w) + Math.abs(prior.h - el.h) > 1.5);
|
|
34463
|
+
}).map(([sel]) => sel);
|
|
34464
|
+
const deltas = [
|
|
34465
|
+
appeared.length ? `appeared: ${listSome(appeared)}` : "",
|
|
34466
|
+
gone.length ? `gone: ${listSome(gone)}` : "",
|
|
34467
|
+
moved.length ? `moved/resized: ${listSome(moved)}` : ""
|
|
34468
|
+
].filter(Boolean);
|
|
34469
|
+
lines.push(` vs revision ${previous.revision}: ${deltas.length ? deltas.join("; ") : "no layout change at t=0"}`);
|
|
34470
|
+
}
|
|
34471
|
+
return lines.join("\n");
|
|
34472
|
+
}
|
|
34473
|
+
async function measuredBlock(ctx, rows) {
|
|
34474
|
+
if (!ctx.measurePreview) return "";
|
|
34475
|
+
const byDraft = /* @__PURE__ */ new Map();
|
|
34476
|
+
for (const row of rows) {
|
|
34477
|
+
if (row.status !== "completed" || row.master || !row.draft_id) continue;
|
|
34478
|
+
byDraft.set(row.draft_id, row);
|
|
34479
|
+
}
|
|
34480
|
+
const parts = [];
|
|
34481
|
+
for (const [draftId, row] of byDraft) {
|
|
34482
|
+
const previous = ctx.sessionState?.measuredByDraft.get(draftId);
|
|
34483
|
+
if (previous && row.revision != null && previous.revision === row.revision) continue;
|
|
34484
|
+
try {
|
|
34485
|
+
const measured = await ctx.measurePreview(ctx, draftId);
|
|
34486
|
+
if (previous && previous.revision === measured.revision) continue;
|
|
34487
|
+
ctx.sessionState?.measuredByDraft.set(draftId, measured);
|
|
34488
|
+
parts.push(renderMeasured(draftId, measured, previous));
|
|
34489
|
+
} catch (error51) {
|
|
34490
|
+
parts.push(
|
|
34491
|
+
`MEASUREMENT UNAVAILABLE for ${draftId}: ${error51 instanceof Error ? error51.message : String(error51)} \u2014 use get_frames to verify instead.`
|
|
34492
|
+
);
|
|
34493
|
+
}
|
|
34494
|
+
}
|
|
34495
|
+
return parts.length ? "\n\n" + parts.join("\n") : "";
|
|
34496
|
+
}
|
|
34497
|
+
var getDraft = (ctx, draftId) => ctx.invoke("get-draft", { draftId });
|
|
34498
|
+
var studioLink = (ctx, projectId, slug) => {
|
|
34499
|
+
const base = (ctx.appUrl ?? "https://motion.impractical.ai").replace(/\/$/, "");
|
|
34500
|
+
return `${base}/projects/${projectId}/studio${slug ? `?scene=${slug}` : ""}`;
|
|
34501
|
+
};
|
|
34502
|
+
async function saveDraft(ctx, draftId, apply) {
|
|
34503
|
+
let conflicts = 0;
|
|
34504
|
+
while (true) {
|
|
34505
|
+
const draft = await getDraft(ctx, draftId);
|
|
34506
|
+
if (draft.lockedByJobId) {
|
|
34507
|
+
return fail(
|
|
34508
|
+
`Draft ${draftId} is locked by job ${draft.lockedByJobId}. Wait for it with job_status({ jobIds: ["${draft.lockedByJobId}"], wait: true }), then retry.`
|
|
34509
|
+
);
|
|
34510
|
+
}
|
|
34511
|
+
const change = apply(draft);
|
|
34512
|
+
const changedCount = Object.keys(change.files).length;
|
|
34513
|
+
if (!changedCount && !change.deletedPaths.length) {
|
|
34514
|
+
return { text: `No change \u2014 the draft already matches at revision ${draft.revision}.` };
|
|
34515
|
+
}
|
|
34516
|
+
const merged = { ...draft.files, ...change.files };
|
|
34517
|
+
for (const deleted of change.deletedPaths) delete merged[deleted];
|
|
34518
|
+
const preflightError = await preflightDraft(merged);
|
|
34519
|
+
if (preflightError) {
|
|
34520
|
+
return fail(
|
|
34521
|
+
"Local build preflight failed \u2014 nothing was sent to the cloud, fix and save again:\n" + preflightError
|
|
34522
|
+
);
|
|
34523
|
+
}
|
|
34524
|
+
try {
|
|
34525
|
+
const saved = await ctx.invoke("save-draft", {
|
|
34526
|
+
draftId,
|
|
34527
|
+
expectedRevision: draft.revision,
|
|
34528
|
+
files: change.files,
|
|
34529
|
+
deletedPaths: change.deletedPaths
|
|
34530
|
+
});
|
|
34531
|
+
ctx.qualityLoopState?.finalReviewByDraft.delete(draftId);
|
|
34532
|
+
ctx.qualityLoopState?.finalMotionReviewByDraft.delete(draftId);
|
|
34533
|
+
const staleNotes = [];
|
|
34534
|
+
const changedPaths = Object.keys(change.files);
|
|
34535
|
+
if (changedPaths.length) {
|
|
34536
|
+
staleNotes.push(
|
|
34537
|
+
`All prior reads of ${changedPaths.join(", ")} are stale as of revision ${saved.revision} \u2014 re-read before further exact-string edits to them.`
|
|
34538
|
+
);
|
|
34539
|
+
}
|
|
34540
|
+
const capture = ctx.sessionState?.lastCaptureByDraft.get(draftId);
|
|
34541
|
+
if (capture) {
|
|
34542
|
+
staleNotes.push(
|
|
34543
|
+
`The ${capture.times.length} frame(s) you captured at revision ${capture.revision} no longer show this draft \u2014 call get_frames again before judging this change.`
|
|
34544
|
+
);
|
|
34545
|
+
}
|
|
34546
|
+
return reportBuildStart(draftId, saved, changedCount, change.deletedPaths.length, staleNotes);
|
|
34547
|
+
} catch (error51) {
|
|
34548
|
+
const status = error51.status;
|
|
34549
|
+
const message = error51 instanceof Error ? error51.message : String(error51);
|
|
34550
|
+
if (status === 409 && conflicts < 1 && !/locked by an active job/i.test(message)) {
|
|
34551
|
+
conflicts += 1;
|
|
34552
|
+
continue;
|
|
34553
|
+
}
|
|
34554
|
+
if (status === 409) {
|
|
34555
|
+
return fail(
|
|
34556
|
+
`Save rejected (409): ${message}. The draft changed underneath this edit \u2014 re-read the file and re-apply your change on top of the new content.`
|
|
34557
|
+
);
|
|
34558
|
+
}
|
|
34559
|
+
throw error51;
|
|
34560
|
+
}
|
|
34561
|
+
}
|
|
34562
|
+
}
|
|
34563
|
+
function reportBuildStart(draftId, saved, changed, deleted, staleNotes = []) {
|
|
34564
|
+
const summary = `Saved ${changed} file(s)` + (deleted ? `, deleted ${deleted}` : "") + ` as revision ${saved.revision}.`;
|
|
34565
|
+
if (!saved.jobId) {
|
|
34566
|
+
return json2({
|
|
34567
|
+
committed: true,
|
|
34568
|
+
draftId,
|
|
34569
|
+
revision: saved.revision,
|
|
34570
|
+
jobId: null,
|
|
34571
|
+
previewQueued: false,
|
|
34572
|
+
summary,
|
|
34573
|
+
...staleNotes.length ? { stale: staleNotes } : {},
|
|
34574
|
+
note: "No preview build was queued \u2014 a draft needs both App.tsx and direction.ts before it can render."
|
|
34575
|
+
});
|
|
34576
|
+
}
|
|
34577
|
+
return json2({
|
|
34578
|
+
committed: true,
|
|
34579
|
+
draftId,
|
|
34580
|
+
revision: saved.revision,
|
|
34581
|
+
jobId: saved.jobId,
|
|
34582
|
+
previewQueued: true,
|
|
34583
|
+
status: "queued",
|
|
34584
|
+
summary,
|
|
34585
|
+
...staleNotes.length ? { stale: staleNotes } : {},
|
|
34586
|
+
resumeWith: resumeWith(saved.jobId),
|
|
34587
|
+
note: "The save committed. Do not re-apply it. Follow the preview build with resumeWith; if a transport timeout ever hides a mutation reply, read the changed file before retrying."
|
|
34588
|
+
});
|
|
34589
|
+
}
|
|
34590
|
+
var accountStatus = {
|
|
34591
|
+
name: "account_status",
|
|
34592
|
+
title: "Show account, plan, and MCP usage",
|
|
34593
|
+
description: "Show the connected workspace, current role, plan, weekly MCP usage, reset time, seats, and billing state. Run this once at the start of a session to confirm the connection works.",
|
|
34594
|
+
inputSchema: {},
|
|
34595
|
+
handler: async (_args, ctx) => json2(await ctx.invoke("workspace-bootstrap"))
|
|
34596
|
+
};
|
|
34597
|
+
var whoami = {
|
|
34598
|
+
...accountStatus,
|
|
34599
|
+
name: "whoami",
|
|
34600
|
+
title: "Show the connected workspace (legacy alias)",
|
|
34601
|
+
description: "Deprecated alias for account_status. Use account_status to show the connected workspace, plan, weekly MCP usage, reset time, seats, and billing state."
|
|
34602
|
+
};
|
|
34603
|
+
var upgradeTool = {
|
|
34604
|
+
name: "upgrade",
|
|
34605
|
+
title: "Explain Pro and get the billing link",
|
|
34606
|
+
description: "Explain what Pro costs and unlocks for this workspace, and return the authenticated billing URL. Only a workspace admin can buy \u2014 checking out subscribes the whole workspace. Set open=true only after the user explicitly asks to open it in their browser.",
|
|
34607
|
+
inputSchema: {
|
|
34608
|
+
open: external_exports.boolean().optional().describe("Open billing in the user's browser. Defaults to false.")
|
|
34609
|
+
},
|
|
34610
|
+
local: true,
|
|
34611
|
+
handler: async (args, ctx) => {
|
|
34612
|
+
const url2 = `${(ctx.appUrl ?? "https://motion.impractical.ai").replace(/\/$/, "")}/account/billing`;
|
|
34613
|
+
const account = await ctx.invoke("workspace-bootstrap").catch(() => null);
|
|
34614
|
+
const role = account?.role === "admin" ? "admin" : "member";
|
|
34615
|
+
const canPurchase = role === "admin";
|
|
34616
|
+
const plan = account?.plan ?? "free";
|
|
34617
|
+
const seats = Math.max(1, account?.billing?.seatCount ?? 1);
|
|
34618
|
+
const limit = account?.quota?.limit ?? FREE_WEEKLY_LIMIT;
|
|
34619
|
+
const remaining = account?.quota?.remaining ?? 0;
|
|
34620
|
+
const opened = args.open === true ? ctx.openExternal?.(url2) ?? false : false;
|
|
34621
|
+
return json2({
|
|
34622
|
+
plan,
|
|
34623
|
+
role,
|
|
34624
|
+
canPurchase,
|
|
34625
|
+
price: { amountUsd: 50, per: "user / month" },
|
|
34626
|
+
billedFor: `${seats} seat${seats === 1 ? "" : "s"} \u2014 every admin and member is a paid seat`,
|
|
34627
|
+
unlocks: [
|
|
34628
|
+
"Unlimited videos and exports \u2014 no weekly ceiling",
|
|
34629
|
+
"Iterate on one video as many times as it takes",
|
|
34630
|
+
"A dedicated allowance per person, not shared across workspaces"
|
|
34631
|
+
],
|
|
34632
|
+
// Exact, because we meter calls. How many videos that is depends on how
|
|
34633
|
+
// hard they iterate, so do not turn it into a per-user video count.
|
|
34634
|
+
freePlanNow: limit >= 1e6 ? "This workspace is already on Pro." : `${remaining} of ${limit} free calls left this week (${FREE_WEEKLY_VIDEO_ESTIMATE}).`,
|
|
34635
|
+
url: url2,
|
|
34636
|
+
opened,
|
|
34637
|
+
instructions: "Summarise the value in one or two sentences, then give the URL. Do not open a browser unless asked. " + (canPurchase ? "Checking out subscribes the whole workspace \u2014 say so before they act on it." : "Only a workspace admin can approve the purchase, so tell this user to ask an admin to upgrade.")
|
|
34638
|
+
});
|
|
34639
|
+
}
|
|
34640
|
+
};
|
|
34641
|
+
var listProjects = {
|
|
34642
|
+
name: "list_projects",
|
|
34643
|
+
title: "List video projects",
|
|
34644
|
+
description: "List video projects in the connected workspace, newest first. Large workspaces: pass limit/offset to page instead of relying on a truncated dump, and nameFilter to search.",
|
|
34645
|
+
inputSchema: {
|
|
34646
|
+
limit: external_exports.number().int().min(1).max(200).optional().describe("Return at most this many projects (default: all)."),
|
|
34647
|
+
offset: external_exports.number().int().min(0).optional().describe("Skip this many projects, for paging."),
|
|
34648
|
+
nameFilter: external_exports.string().optional().describe("Case-insensitive substring match on the project name/slug.")
|
|
34649
|
+
},
|
|
34650
|
+
handler: async (args, ctx) => {
|
|
34651
|
+
const result = await ctx.invoke("list-projects", {});
|
|
34652
|
+
let projects = result.projects ?? [];
|
|
34653
|
+
const total = projects.length;
|
|
34654
|
+
const filter = typeof args.nameFilter === "string" ? args.nameFilter.trim().toLowerCase() : "";
|
|
34655
|
+
if (filter) {
|
|
34656
|
+
projects = projects.filter(
|
|
34657
|
+
(project) => `${project.name ?? ""} ${project.slug ?? ""}`.toLowerCase().includes(filter)
|
|
34658
|
+
);
|
|
34659
|
+
}
|
|
34660
|
+
const matched = projects.length;
|
|
34661
|
+
const offset = typeof args.offset === "number" ? args.offset : 0;
|
|
34662
|
+
const limit = typeof args.limit === "number" ? args.limit : projects.length;
|
|
34663
|
+
projects = projects.slice(offset, offset + limit);
|
|
34664
|
+
return json2({
|
|
34665
|
+
total,
|
|
34666
|
+
matched,
|
|
34667
|
+
offset,
|
|
34668
|
+
returned: projects.length,
|
|
34669
|
+
hasMore: offset + projects.length < matched,
|
|
34670
|
+
projects
|
|
34671
|
+
});
|
|
34672
|
+
}
|
|
34673
|
+
};
|
|
34674
|
+
var getProject = {
|
|
34675
|
+
name: "get_project",
|
|
34676
|
+
title: "Get one project",
|
|
34677
|
+
description: "Get one project with its drafts (each draft is one cut of the video) and recent jobs. Use this to find the draftId you need for the editing tools.",
|
|
34678
|
+
inputSchema: { projectId: external_exports.string().describe("The project id.") },
|
|
34679
|
+
handler: async (args, ctx) => json2(await ctx.invoke("list-projects", { projectId: args.projectId }))
|
|
34680
|
+
};
|
|
34681
|
+
var createVideo = {
|
|
34682
|
+
name: "create_video",
|
|
34683
|
+
title: "Create a video project",
|
|
34684
|
+
description: "Create a new video project and an empty draft for you to direct. This does NOT run Impractical's own director \u2014 it scaffolds App.tsx, direction.ts and brand.json and hands them to you. Read the `impractical://engine.d.ts` resource, then write the scene yourself with edit_draft_file / write_draft_files.",
|
|
34685
|
+
inputSchema: {
|
|
34686
|
+
valueProp: external_exports.string().min(1).describe("One or two sentences on what ships and why it matters."),
|
|
34687
|
+
name: external_exports.string().optional().describe("Human-readable project name. Defaults to the slug."),
|
|
34688
|
+
slug: external_exports.string().optional().describe("URL-safe project slug. Generated when omitted."),
|
|
34689
|
+
vibe: external_exports.string().optional().describe("Tone, e.g. 'confident'. Defaults to 'confident'."),
|
|
34690
|
+
brandSlug: external_exports.string().optional().describe("Brand kit slug from brand_list. Its colors and fonts become brand.json."),
|
|
34691
|
+
appCode: external_exports.string().optional().describe("The product's front-end code, used as the starting point for App.tsx."),
|
|
34692
|
+
templateId: external_exports.string().optional().describe("Template id recommended by motion_context. Defaults to a blank direction."),
|
|
34693
|
+
templateParams: external_exports.record(external_exports.string(), external_exports.string()).optional().describe("Parameters for templateId, using the schema returned by motion_context."),
|
|
34694
|
+
motionContextId: external_exports.string().optional().describe(
|
|
34695
|
+
"contextId from the motion_context result. Pins the draft to the personalized recommendations that produced it. Omit to resolve a fresh context."
|
|
34696
|
+
),
|
|
34697
|
+
creativeContract: creativeContractSchema.optional().describe(
|
|
34698
|
+
"Required only when the local quality-loop experiment is enabled: the audience, one memorable idea, visible proof, reference strategy, product source, and opening/proof/payoff."
|
|
34699
|
+
),
|
|
34700
|
+
concept: conceptSchema.optional().describe(
|
|
34701
|
+
"Required only when the phased flow is enabled: premise, recurringArtifact, references, beats (each with a MOVE), and componentInventory \u2014 authored from the brief alone."
|
|
34702
|
+
),
|
|
34703
|
+
content: contentSchema.optional().describe(
|
|
34704
|
+
"Required only when the phased flow is enabled: every editorial line in viewing order (claim/label/endcard). Locked \u2014 the motion report diffs the film against these lines."
|
|
34705
|
+
)
|
|
34706
|
+
},
|
|
34707
|
+
handler: async (args, ctx) => {
|
|
34708
|
+
if (ctx.qualityLoop && !args.creativeContract) {
|
|
34709
|
+
return fail(
|
|
34710
|
+
"The local quality loop requires creativeContract before scaffolding. Name the audience, singleIdea, visualProof, referenceStrategy, productSource, opening, proof, and payoff. This is a planning gate only; it does not change the scene schema."
|
|
34711
|
+
);
|
|
34712
|
+
}
|
|
34713
|
+
if (ctx.phasedFlow) {
|
|
34714
|
+
const gate = phasedGateFailure({ concept: args.concept, content: args.content });
|
|
34715
|
+
if (gate) return fail(gate);
|
|
34716
|
+
if (!ctx.qualityLoopState?.referenceLedger) {
|
|
34717
|
+
return fail(
|
|
34718
|
+
"The phased flow requires gather_references BEFORE create_video: record the real references this film is designed against (or an honest noneFound after exhausting the ladder: local code -> product site -> screenshots -> component-class images)."
|
|
34719
|
+
);
|
|
34720
|
+
}
|
|
34721
|
+
}
|
|
34722
|
+
let brand = null;
|
|
34723
|
+
if (args.brandSlug) {
|
|
34724
|
+
const result = await ctx.invoke("brand-kits", {
|
|
34725
|
+
action: "get",
|
|
34726
|
+
slug: args.brandSlug
|
|
34727
|
+
});
|
|
34728
|
+
brand = result.brand;
|
|
34729
|
+
}
|
|
34730
|
+
const valueProp = String(args.valueProp).trim();
|
|
34731
|
+
const slug = args.slug ?? `${valueProp.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().split(/\s+/).slice(0, 4).join("-") || "video"}-${Math.floor(Math.random() * 65536).toString(16).padStart(4, "0")}`;
|
|
34732
|
+
const prepared = await ctx.invoke("prepare-generation", {
|
|
34733
|
+
name: (args.name ?? slug).replace(/[-_]/g, " "),
|
|
34734
|
+
slug,
|
|
34735
|
+
valueProp,
|
|
34736
|
+
vibe: args.vibe ?? "confident",
|
|
34737
|
+
directorIds: ["d1"],
|
|
34738
|
+
append: false,
|
|
34739
|
+
appCode: args.appCode,
|
|
34740
|
+
brand,
|
|
34741
|
+
templateId: args.templateId,
|
|
34742
|
+
templateParams: args.templateParams,
|
|
34743
|
+
...args.motionContextId ? { motionContextId: args.motionContextId } : {}
|
|
34744
|
+
});
|
|
34745
|
+
const draft = prepared.drafts[0];
|
|
34746
|
+
if (ctx.phasedFlow && draft?.id && args.content) {
|
|
34747
|
+
const parsedContent = contentSchema.safeParse(args.content);
|
|
34748
|
+
if (parsedContent.success) {
|
|
34749
|
+
ctx.qualityLoopState?.lockedContentByDraft.set(draft.id, parsedContent.data.lines);
|
|
34750
|
+
}
|
|
34751
|
+
const parsedConcept = conceptSchema.safeParse(args.concept);
|
|
34752
|
+
if (parsedConcept.success) {
|
|
34753
|
+
ctx.qualityLoopState?.beatsByDraft.set(
|
|
34754
|
+
draft.id,
|
|
34755
|
+
parsedConcept.data.beats.map((beat) => ({
|
|
34756
|
+
start: beat.start,
|
|
34757
|
+
end: beat.end,
|
|
34758
|
+
subject: beat.subject,
|
|
34759
|
+
energy: beat.energy
|
|
34760
|
+
}))
|
|
34761
|
+
);
|
|
34762
|
+
}
|
|
34763
|
+
}
|
|
34764
|
+
return json2({
|
|
34765
|
+
projectId: prepared.projectId,
|
|
34766
|
+
draftId: draft?.id,
|
|
34767
|
+
slug: draft?.slug,
|
|
34768
|
+
starterFiles: prepared.starterFiles,
|
|
34769
|
+
libraryVersion: prepared.libraryVersion,
|
|
34770
|
+
engineVersion: prepared.engineVersion,
|
|
34771
|
+
studioUrl: studioLink(ctx, prepared.projectId, draft?.slug),
|
|
34772
|
+
...ctx.qualityLoop ? {
|
|
34773
|
+
creativeContract: args.creativeContract,
|
|
34774
|
+
qualityLoop: {
|
|
34775
|
+
enabled: true,
|
|
34776
|
+
next: 'Build the proof beat first, then call get_frames with reviewMode="proof" before completing the full film.'
|
|
34777
|
+
}
|
|
34778
|
+
} : {},
|
|
34779
|
+
next: ctx.qualityLoop ? "Read impractical://engine.d.ts, build the proof beat first, then review it before completing the film." : "Read impractical://engine.d.ts, then write App.tsx and direction.ts."
|
|
34780
|
+
});
|
|
34781
|
+
}
|
|
34782
|
+
};
|
|
34783
|
+
var listDraftFiles = {
|
|
34784
|
+
name: "list_draft_files",
|
|
34785
|
+
title: "List a draft's files",
|
|
34786
|
+
description: "List the source files of a draft with their sizes and the current revision. Cheap orientation \u2014 prefer this over reading every file. If notes.json is present, it holds the user's queued timecoded edit requests \u2014 read it before directing.",
|
|
34787
|
+
inputSchema: { draftId: external_exports.string().describe("The draft id.") },
|
|
34788
|
+
handler: async (args, ctx) => {
|
|
34789
|
+
const draft = await getDraft(ctx, String(args.draftId));
|
|
34790
|
+
return json2({
|
|
34791
|
+
draftId: draft.draftId,
|
|
34792
|
+
projectId: draft.projectId,
|
|
34793
|
+
slug: draft.slug,
|
|
34794
|
+
status: draft.status,
|
|
34795
|
+
revision: draft.revision,
|
|
34796
|
+
staleNote: `Any file contents read before revision ${draft.revision} are stale \u2014 re-read before editing them.`,
|
|
34797
|
+
lockedByJobId: draft.lockedByJobId,
|
|
34798
|
+
files: Object.entries(draft.files).map(([path, content]) => ({ path, bytes: Buffer.byteLength(content, "utf8") })).sort((a, b) => a.path.localeCompare(b.path))
|
|
34799
|
+
});
|
|
34800
|
+
}
|
|
34801
|
+
};
|
|
34802
|
+
var readDraftFile = {
|
|
34803
|
+
name: "read_draft_file",
|
|
34804
|
+
title: "Read one draft file",
|
|
34805
|
+
description: "Read the contents of a single file in a draft. notes.json, when present, is the user's edit queue: [{ id, at, text, done, mentions? }] \u2014 timecoded requests written while watching. Address open items and set done: true on each one you handle.",
|
|
34806
|
+
inputSchema: {
|
|
34807
|
+
draftId: external_exports.string().describe("The draft id."),
|
|
34808
|
+
path: external_exports.string().describe("File path within the draft, e.g. 'direction.ts'."),
|
|
34809
|
+
startLine: external_exports.number().int().min(1).optional().describe("1-based first line to return \u2014 page large files instead of truncating."),
|
|
34810
|
+
lineCount: external_exports.number().int().min(1).optional().describe("Number of lines to return from startLine.")
|
|
34811
|
+
},
|
|
34812
|
+
handler: async (args, ctx) => {
|
|
34813
|
+
const draft = await getDraft(ctx, String(args.draftId));
|
|
34814
|
+
const path = String(args.path);
|
|
34815
|
+
const content = draft.files[path];
|
|
34816
|
+
if (content === void 0) {
|
|
34817
|
+
return fail(
|
|
34818
|
+
`No file "${path}" in draft ${args.draftId}. Available: ${Object.keys(draft.files).sort().join(", ")}`
|
|
34819
|
+
);
|
|
34820
|
+
}
|
|
34821
|
+
const stamp = `[${path} @ revision ${draft.revision} \u2014 earlier copies of this file in your context are stale if this revision is newer]`;
|
|
34822
|
+
const lines = content.split("\n");
|
|
34823
|
+
const startLine = typeof args.startLine === "number" ? Math.max(1, args.startLine) : 1;
|
|
34824
|
+
const lineCount = typeof args.lineCount === "number" ? Math.max(1, args.lineCount) : lines.length;
|
|
34825
|
+
if (startLine === 1 && lineCount >= lines.length) return { text: `${stamp}
|
|
34826
|
+
${content}` };
|
|
34827
|
+
const slice = lines.slice(startLine - 1, startLine - 1 + lineCount);
|
|
34828
|
+
if (slice.length === 0) {
|
|
34829
|
+
return {
|
|
34830
|
+
text: `[no lines returned: startLine ${startLine} is beyond ${lines.length} lines \u2014 page with startLine/lineCount]`
|
|
34831
|
+
};
|
|
34832
|
+
}
|
|
34833
|
+
return {
|
|
34834
|
+
text: `[lines ${startLine}-${startLine - 1 + slice.length} of ${lines.length}, ${path} @ revision ${draft.revision} \u2014 page with startLine/lineCount]
|
|
34835
|
+
` + slice.join("\n")
|
|
34836
|
+
};
|
|
34837
|
+
}
|
|
34838
|
+
};
|
|
34839
|
+
var writeDraftFiles = {
|
|
34840
|
+
name: "write_draft_files",
|
|
34841
|
+
title: "Write draft files",
|
|
34842
|
+
description: "Create or overwrite whole files in a draft, and optionally delete others. Returns the committed revision and preview-build job id immediately; follow that id with job_status. Prefer edit_draft_file for small changes. Only .ts/.tsx/.js/.jsx/.css/.json paths sync, max 100 files per draft. Never edit project.json.",
|
|
34843
|
+
inputSchema: {
|
|
34844
|
+
draftId: external_exports.string().describe("The draft id."),
|
|
34845
|
+
files: external_exports.record(external_exports.string(), external_exports.string()).describe("Map of file path to full file content."),
|
|
34846
|
+
deletedPaths: external_exports.array(external_exports.string()).optional().describe("Paths to remove from the draft.")
|
|
34847
|
+
},
|
|
34848
|
+
handler: async (args, ctx) => {
|
|
34849
|
+
const files = args.files ?? {};
|
|
34850
|
+
const deletedPaths = args.deletedPaths ?? [];
|
|
34851
|
+
return saveDraft(ctx, String(args.draftId), () => ({ files, deletedPaths }));
|
|
34852
|
+
}
|
|
34853
|
+
};
|
|
34854
|
+
var editDraftFile = {
|
|
34855
|
+
name: "edit_draft_file",
|
|
34856
|
+
title: "Edit one draft file",
|
|
34857
|
+
description: "Replace an exact string in one draft file. The read, the edit and the write happen in a single call against the current revision, so concurrent edits from the web tweak chat cannot clobber yours. Returns the committed revision and preview-build job id immediately; follow that id with job_status.",
|
|
34858
|
+
inputSchema: {
|
|
34859
|
+
draftId: external_exports.string().describe("The draft id."),
|
|
34860
|
+
path: external_exports.string().describe("File path within the draft, e.g. 'direction.ts'."),
|
|
34861
|
+
oldString: external_exports.string().describe("Exact text to replace. Must appear in the file."),
|
|
34862
|
+
newString: external_exports.string().describe("Replacement text."),
|
|
34863
|
+
replaceAll: external_exports.boolean().optional().describe("Replace every occurrence. Default false, which requires a unique match.")
|
|
34864
|
+
},
|
|
34865
|
+
handler: async (args, ctx) => {
|
|
34866
|
+
const path = String(args.path);
|
|
34867
|
+
const oldString = String(args.oldString);
|
|
34868
|
+
const newString = String(args.newString);
|
|
34869
|
+
let problem = null;
|
|
34870
|
+
const output = await saveDraft(
|
|
34871
|
+
ctx,
|
|
34872
|
+
String(args.draftId),
|
|
34873
|
+
(draft) => {
|
|
34874
|
+
const content = draft.files[path];
|
|
34875
|
+
if (content === void 0) {
|
|
34876
|
+
problem = `No file "${path}" in this draft. Available: ${Object.keys(draft.files).sort().join(", ")}`;
|
|
34877
|
+
return { files: {}, deletedPaths: [] };
|
|
34878
|
+
}
|
|
34879
|
+
const occurrences = content.split(oldString).length - 1;
|
|
34880
|
+
if (occurrences === 0) {
|
|
34881
|
+
problem = `oldString was not found in ${path} at revision ${draft.revision}. Either the match is inexact, or your copy of this file is stale \u2014 if you last read it at an earlier revision, read_draft_file again and re-apply the edit against the current text.`;
|
|
34882
|
+
return { files: {}, deletedPaths: [] };
|
|
34883
|
+
}
|
|
34884
|
+
if (occurrences > 1 && !args.replaceAll) {
|
|
34885
|
+
problem = `oldString appears ${occurrences} times in ${path} at revision ${draft.revision}. Add more surrounding context to make it unique, or pass replaceAll: true.`;
|
|
34886
|
+
return { files: {}, deletedPaths: [] };
|
|
34887
|
+
}
|
|
34888
|
+
const next = args.replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
|
|
34889
|
+
return { files: { [path]: next }, deletedPaths: [] };
|
|
33668
34890
|
}
|
|
33669
|
-
|
|
33670
|
-
|
|
33671
|
-
|
|
33672
|
-
|
|
33673
|
-
|
|
33674
|
-
|
|
33675
|
-
|
|
33676
|
-
|
|
33677
|
-
|
|
33678
|
-
|
|
33679
|
-
|
|
33680
|
-
|
|
33681
|
-
|
|
33682
|
-
|
|
33683
|
-
|
|
33684
|
-
|
|
33685
|
-
|
|
33686
|
-
|
|
33687
|
-
|
|
33688
|
-
|
|
33689
|
-
|
|
33690
|
-
|
|
33691
|
-
|
|
33692
|
-
|
|
33693
|
-
|
|
33694
|
-
|
|
33695
|
-
|
|
33696
|
-
|
|
34891
|
+
);
|
|
34892
|
+
return problem ? fail(problem) : output;
|
|
34893
|
+
}
|
|
34894
|
+
};
|
|
34895
|
+
var jobStatus = {
|
|
34896
|
+
name: "job_status",
|
|
34897
|
+
title: "Check job status",
|
|
34898
|
+
description: "Check Impractical Motion jobs (preview builds, exports). Set wait to poll until they finish.",
|
|
34899
|
+
inputSchema: {
|
|
34900
|
+
jobIds: external_exports.array(external_exports.string()).min(1).max(20).describe("Job ids to check."),
|
|
34901
|
+
wait: external_exports.boolean().optional().describe("Poll until every job reaches a terminal state."),
|
|
34902
|
+
waitSeconds: external_exports.number().optional().describe(`Polling budget when wait is true. Default ${JOB_WAIT_SECONDS}, capped at ${maxWaitSeconds()}s. When the budget runs out the call returns the job ids instead of failing \u2014 call it again to keep waiting.`)
|
|
34903
|
+
},
|
|
34904
|
+
handler: async (args, ctx, progress) => {
|
|
34905
|
+
const jobIds = args.jobIds;
|
|
34906
|
+
if (!args.wait) {
|
|
34907
|
+
const result = await ctx.invoke("job-status", { jobIds });
|
|
34908
|
+
const measured = await measuredBlock(ctx, result.jobs ?? []);
|
|
34909
|
+
return { text: json2(result).text + measured };
|
|
34910
|
+
}
|
|
34911
|
+
const budgetMs = waitBudgetMs(args.waitSeconds, JOB_WAIT_SECONDS);
|
|
34912
|
+
const now = ctx.now ?? Date.now;
|
|
34913
|
+
const started = now();
|
|
34914
|
+
const deadline = started + budgetMs;
|
|
34915
|
+
let rows = [];
|
|
34916
|
+
let consecutiveErrors = 0;
|
|
34917
|
+
let lastError = "";
|
|
34918
|
+
while (now() < deadline) {
|
|
34919
|
+
if (progress?.signal?.aborted) break;
|
|
34920
|
+
try {
|
|
34921
|
+
const result = await ctx.invoke("job-status", { jobIds });
|
|
34922
|
+
rows = result.jobs;
|
|
34923
|
+
consecutiveErrors = 0;
|
|
34924
|
+
if (rows.length && rows.every((row) => TERMINAL.has(row.status))) {
|
|
34925
|
+
const measured = await measuredBlock(ctx, rows);
|
|
34926
|
+
return { text: json2(rows).text + measured };
|
|
34927
|
+
}
|
|
34928
|
+
} catch (error51) {
|
|
34929
|
+
lastError = error51 instanceof Error ? error51.message : String(error51);
|
|
34930
|
+
if (++consecutiveErrors >= POLL_ERROR_LIMIT) {
|
|
34931
|
+
return json2({ pollError: lastError, stillRunning: jobIds, jobs: rows });
|
|
34932
|
+
}
|
|
33697
34933
|
}
|
|
33698
|
-
|
|
33699
|
-
|
|
33700
|
-
|
|
33701
|
-
|
|
33702
|
-
|
|
33703
|
-
|
|
33704
|
-
|
|
33705
|
-
includeSheets: options.includeSheets ?? true
|
|
34934
|
+
progress?.report?.({
|
|
34935
|
+
progress: Math.min(now() - started, budgetMs),
|
|
34936
|
+
total: budgetMs,
|
|
34937
|
+
message: rows.length ? rows.map((row) => describeJob(row.id, row, consecutiveErrors, lastError)).join("; ") : jobIds.join(", ")
|
|
34938
|
+
});
|
|
34939
|
+
const remainingMs = deadline - now();
|
|
34940
|
+
if (remainingMs > 0) await nap(ctx, Math.min(2e3, remainingMs));
|
|
33706
34941
|
}
|
|
33707
|
-
|
|
33708
|
-
|
|
33709
|
-
|
|
33710
|
-
|
|
33711
|
-
|
|
33712
|
-
|
|
34942
|
+
return json2({
|
|
34943
|
+
timedOut: true,
|
|
34944
|
+
stillRunning: jobIds,
|
|
34945
|
+
jobs: rows,
|
|
34946
|
+
resumeWith: `job_status({ jobIds: ${JSON.stringify(jobIds)}, wait: true })`,
|
|
34947
|
+
note: "The jobs are still running in the cloud \u2014 this is a pause, not a failure. Call the command in resumeWith to keep following them." + clampNote(args.waitSeconds)
|
|
34948
|
+
});
|
|
34949
|
+
}
|
|
34950
|
+
};
|
|
34951
|
+
var exportVideo = {
|
|
34952
|
+
name: "export_video",
|
|
34953
|
+
title: "Export the final MP4",
|
|
34954
|
+
description: "Start the final MP4 render and return its job id immediately. Follow that id with job_status for the signed download URL. Only export once the user is happy with the frames.",
|
|
33713
34955
|
inputSchema: {
|
|
33714
34956
|
draftId: external_exports.string().describe("The draft id."),
|
|
33715
|
-
|
|
33716
|
-
|
|
33717
|
-
|
|
33718
|
-
|
|
33719
|
-
|
|
33720
|
-
|
|
33721
|
-
|
|
33722
|
-
|
|
33723
|
-
|
|
33724
|
-
|
|
34957
|
+
qualityReview: qualityReviewSchema.optional().describe(
|
|
34958
|
+
"Required only by the opt-in local quality loop: the exact reviewed revision, creative claim/proof, reference comparison, eight category scores, timestamp evidence, and blockers."
|
|
34959
|
+
)
|
|
34960
|
+
},
|
|
34961
|
+
handler: async (args, ctx) => {
|
|
34962
|
+
if (ctx.qualityLoop) {
|
|
34963
|
+
const capture = ctx.qualityLoopState?.finalReviewByDraft.get(String(args.draftId));
|
|
34964
|
+
const failure = qualityReviewFailure({
|
|
34965
|
+
draftRevision: capture?.revision ?? -1,
|
|
34966
|
+
capture,
|
|
34967
|
+
review: args.qualityReview,
|
|
34968
|
+
craftLab: ctx.craftLab === true,
|
|
34969
|
+
scoreSignatures: ctx.qualityLoopState?.scoreSignatures,
|
|
34970
|
+
draftId: String(args.draftId)
|
|
34971
|
+
}) ?? motionCoverageFailure({
|
|
34972
|
+
motionLab: ctx.motionLab === true,
|
|
34973
|
+
capture: ctx.qualityLoopState?.finalMotionReviewByDraft.get(String(args.draftId)),
|
|
34974
|
+
frameReviewRevision: capture?.revision ?? -1
|
|
34975
|
+
}) ?? craftMotionFailure({
|
|
34976
|
+
craftLab: ctx.craftLab === true,
|
|
34977
|
+
capture: ctx.qualityLoopState?.finalMotionReviewByDraft.get(String(args.draftId))
|
|
34978
|
+
}) ?? framingMotionFailure(ctx.qualityLoopState?.finalMotionReviewByDraft.get(String(args.draftId)));
|
|
34979
|
+
if (failure) return fail(`Quality gate blocked export: ${failure}`);
|
|
34980
|
+
}
|
|
34981
|
+
const started = await ctx.invoke("start-export", { draftId: args.draftId });
|
|
34982
|
+
return json2({
|
|
34983
|
+
started: true,
|
|
34984
|
+
draftId: String(args.draftId),
|
|
34985
|
+
jobId: started.jobId,
|
|
34986
|
+
status: "queued",
|
|
34987
|
+
resumeWith: resumeWith(started.jobId),
|
|
34988
|
+
note: "Export started. Do not start another export. Follow this job with resumeWith to obtain the signed download URL."
|
|
34989
|
+
});
|
|
34990
|
+
}
|
|
34991
|
+
};
|
|
34992
|
+
var brandList = {
|
|
34993
|
+
name: "brand_list",
|
|
34994
|
+
title: "List brand kits",
|
|
34995
|
+
description: "List the brand kits saved in this workspace.",
|
|
34996
|
+
inputSchema: {},
|
|
34997
|
+
handler: async (_args, ctx) => json2(await ctx.invoke("brand-kits", { action: "list" }))
|
|
34998
|
+
};
|
|
34999
|
+
var brandGet = {
|
|
35000
|
+
name: "brand_get",
|
|
35001
|
+
title: "Get a brand kit",
|
|
35002
|
+
description: "Get one brand kit \u2014 its colors, fonts and logo. Treat these as law when directing.",
|
|
35003
|
+
inputSchema: { slug: external_exports.string().describe("Brand kit slug.") },
|
|
35004
|
+
handler: async (args, ctx) => json2(await ctx.invoke("brand-kits", { action: "get", slug: args.slug }))
|
|
35005
|
+
};
|
|
35006
|
+
var brandExtract = {
|
|
35007
|
+
name: "brand_extract",
|
|
35008
|
+
title: "Extract a brand kit from a URL",
|
|
35009
|
+
description: "Start extracting a brand kit (colors, fonts, logo) from a website and return the run id immediately. To observe an existing run, call this tool with jobId instead of url; that status mode never starts another extraction.",
|
|
35010
|
+
inputSchema: {
|
|
35011
|
+
url: external_exports.string().optional().describe("The brand website URL. Omit when continuing by jobId."),
|
|
35012
|
+
jobId: external_exports.string().optional().describe("Run id returned by a previous brand_extract call."),
|
|
35013
|
+
reextract: external_exports.boolean().optional().describe("Re-extract even if a kit already exists."),
|
|
35014
|
+
wait: external_exports.boolean().optional().describe("With jobId, poll until this extraction is terminal."),
|
|
35015
|
+
waitSeconds: external_exports.number().optional().describe(
|
|
35016
|
+
`With jobId and wait, poll for up to ${BRAND_WAIT_SECONDS}s by default, capped at ${maxWaitSeconds()}s.`
|
|
33725
35017
|
)
|
|
33726
35018
|
},
|
|
33727
|
-
local: true,
|
|
33728
|
-
// Metered as a frame capture: the deployed admission policy budgets
|
|
33729
|
-
// get_frames for exactly the routes this tool invokes (get-draft,
|
|
33730
|
-
// list-projects), and one motion pass is one capture.
|
|
33731
|
-
admitAs: "get_frames",
|
|
33732
35019
|
handler: async (args, ctx, progress) => {
|
|
33733
|
-
const
|
|
33734
|
-
const
|
|
33735
|
-
|
|
33736
|
-
|
|
33737
|
-
|
|
33738
|
-
const
|
|
33739
|
-
|
|
33740
|
-
|
|
33741
|
-
|
|
33742
|
-
isError: true
|
|
33743
|
-
};
|
|
33744
|
-
}
|
|
33745
|
-
const passPlan = motionPassPlan({
|
|
33746
|
-
finalReview,
|
|
33747
|
-
detail: args.detail ? String(args.detail) : void 0,
|
|
33748
|
-
everyNthFrame: args.everyNthFrame === void 0 ? void 0 : Number(args.everyNthFrame)
|
|
33749
|
-
});
|
|
33750
|
-
const everyNthFrame = passPlan.everyNthFrame;
|
|
33751
|
-
const plan = sampleTimes({
|
|
33752
|
-
from,
|
|
33753
|
-
to,
|
|
33754
|
-
fps: session.fps,
|
|
33755
|
-
everyNthFrame,
|
|
33756
|
-
maxSamples: passPlan.maxSamples
|
|
35020
|
+
const url2 = typeof args.url === "string" ? args.url : "";
|
|
35021
|
+
const jobId = typeof args.jobId === "string" ? args.jobId : "";
|
|
35022
|
+
if (url2 && jobId) return fail("Pass either url to start or jobId to continue, not both.");
|
|
35023
|
+
if (!url2 && !jobId) return fail("Pass url to start an extraction, or jobId to continue one.");
|
|
35024
|
+
if (url2) {
|
|
35025
|
+
const started = await ctx.invoke("brand-extraction", {
|
|
35026
|
+
action: "start",
|
|
35027
|
+
url: url2,
|
|
35028
|
+
...args.reextract ? { reextract: true } : {}
|
|
33757
35029
|
});
|
|
33758
|
-
|
|
33759
|
-
|
|
33760
|
-
|
|
33761
|
-
|
|
33762
|
-
|
|
33763
|
-
|
|
33764
|
-
|
|
33765
|
-
frames.push({ time: time3, base64: shot.toString("base64") });
|
|
33766
|
-
if (censusOn) {
|
|
33767
|
-
try {
|
|
33768
|
-
textSamples.push({ time: time3, blocks: await sampleTextBlocks(session.page, CANDIDATE_MIN_PX) });
|
|
33769
|
-
} catch {
|
|
33770
|
-
}
|
|
33771
|
-
}
|
|
33772
|
-
progress?.report?.({
|
|
33773
|
-
progress: index + 1,
|
|
33774
|
-
total: plan.times.length,
|
|
33775
|
-
message: `sampled t=${formatTime(time3)}s`
|
|
33776
|
-
});
|
|
33777
|
-
}
|
|
33778
|
-
const tweensAll = await extractTimeline(session.page);
|
|
33779
|
-
const tweens = tweensAll ? tweensAll.filter((tween) => tween.end > from && tween.start < to) : null;
|
|
33780
|
-
const sheetPage = await session.browser.newPage();
|
|
33781
|
-
const { sheets, diffs, cellDiffs } = await compositeAndDiff(sheetPage, frames, {
|
|
33782
|
-
includeSheets: passPlan.includeSheets
|
|
35030
|
+
return json2({
|
|
35031
|
+
started: true,
|
|
35032
|
+
jobId: started.job,
|
|
35033
|
+
slug: started.slug,
|
|
35034
|
+
status: "queued",
|
|
35035
|
+
resumeWith: `brand_extract({ jobId: "${started.job}", wait: true })`,
|
|
35036
|
+
note: "Brand extraction started. Do not call brand_extract with the URL again. Continue this exact run with resumeWith, then pass the completed slug to create_video as brandSlug."
|
|
33783
35037
|
});
|
|
33784
|
-
|
|
33785
|
-
|
|
33786
|
-
|
|
33787
|
-
|
|
33788
|
-
|
|
33789
|
-
|
|
33790
|
-
dtSeconds: plan.stepSeconds,
|
|
33791
|
-
durationSeconds: to - from
|
|
33792
|
-
});
|
|
33793
|
-
catalogSection = formatCatalogReport(records);
|
|
33794
|
-
} catch (error51) {
|
|
33795
|
-
catalogSection = "CANONICAL METRICS unavailable this pass: " + (error51 instanceof Error ? error51.message : String(error51));
|
|
33796
|
-
}
|
|
33797
|
-
}
|
|
33798
|
-
const intervals = buildIntervals(
|
|
33799
|
-
frames.map((frame) => frame.time),
|
|
33800
|
-
diffs
|
|
33801
|
-
);
|
|
33802
|
-
const censusSection = censusOn && textSamples.length ? textCensusReport(textSamples) : "";
|
|
33803
|
-
let contractSection = "";
|
|
33804
|
-
if (textContractEnabled() && textSamples.length >= 2) {
|
|
33805
|
-
try {
|
|
33806
|
-
const dims = await session.page.evaluate(() => ({
|
|
33807
|
-
width: window.innerWidth,
|
|
33808
|
-
height: window.innerHeight
|
|
33809
|
-
}));
|
|
33810
|
-
contractSection = formatTextContractReport(
|
|
33811
|
-
analyzeTextContract({
|
|
33812
|
-
samples: textSamples,
|
|
33813
|
-
diffs,
|
|
33814
|
-
frameWidth: dims.width,
|
|
33815
|
-
frameHeight: dims.height,
|
|
33816
|
-
stepSeconds: plan.stepSeconds,
|
|
33817
|
-
lockedContent: ctx.qualityLoopState?.lockedContentByDraft.get(String(args.draftId)),
|
|
33818
|
-
inspectV2: inspectV2Enabled()
|
|
33819
|
-
})
|
|
33820
|
-
);
|
|
33821
|
-
} catch {
|
|
33822
|
-
}
|
|
33823
|
-
}
|
|
33824
|
-
let v2Section = "";
|
|
33825
|
-
if (inspectV2Enabled()) {
|
|
33826
|
-
const v2Lines = [];
|
|
33827
|
-
const envelope = envelopeLine(intervals);
|
|
33828
|
-
if (envelope) v2Lines.push(envelope);
|
|
33829
|
-
const beats = ctx.qualityLoopState?.beatsByDraft.get(String(args.draftId)) ?? [];
|
|
33830
|
-
v2Lines.push(...beatVerdicts(beats, intervals));
|
|
33831
|
-
v2Lines.push(
|
|
33832
|
-
...repairMenu({
|
|
33833
|
-
deadSpans: deadAirSpans(intervals),
|
|
33834
|
-
imperceptible: tweens ? imperceptibleTweens(tweens, intervals) : [],
|
|
33835
|
-
textSamples
|
|
33836
|
-
})
|
|
33837
|
-
);
|
|
33838
|
-
v2Section = v2Lines.join("\n");
|
|
35038
|
+
}
|
|
35039
|
+
const readStatus = () => ctx.invoke("brand-extraction", { action: "status", job: jobId });
|
|
35040
|
+
if (!args.wait) {
|
|
35041
|
+
const status = await readStatus();
|
|
35042
|
+
if (status.status === "error" || status.status === "failed" || status.error) {
|
|
35043
|
+
return fail(`Brand extraction failed${status.error ? `: ${status.error}` : ""}`);
|
|
33839
35044
|
}
|
|
33840
|
-
|
|
33841
|
-
|
|
33842
|
-
|
|
33843
|
-
|
|
33844
|
-
|
|
33845
|
-
|
|
33846
|
-
|
|
33847
|
-
|
|
33848
|
-
|
|
35045
|
+
return json2({ jobId, ...status });
|
|
35046
|
+
}
|
|
35047
|
+
const budgetMs = waitBudgetMs(args.waitSeconds, BRAND_WAIT_SECONDS);
|
|
35048
|
+
const startedAt = Date.now();
|
|
35049
|
+
const deadline = startedAt + budgetMs;
|
|
35050
|
+
let consecutiveErrors = 0;
|
|
35051
|
+
let lastError = "";
|
|
35052
|
+
let stage = "";
|
|
35053
|
+
let lastStatus = {};
|
|
35054
|
+
while (Date.now() < deadline) {
|
|
35055
|
+
if (progress?.signal?.aborted) break;
|
|
35056
|
+
try {
|
|
35057
|
+
const status = await readStatus();
|
|
35058
|
+
lastStatus = status;
|
|
35059
|
+
consecutiveErrors = 0;
|
|
35060
|
+
stage = status.stage ?? stage;
|
|
35061
|
+
if (status.status === "done") {
|
|
35062
|
+
return json2({ jobId, ...status });
|
|
33849
35063
|
}
|
|
33850
|
-
|
|
33851
|
-
|
|
33852
|
-
frames.map((frame) => frame.time),
|
|
33853
|
-
cellDiffs
|
|
33854
|
-
);
|
|
33855
|
-
const breathless = breathlessSpans(intervals);
|
|
33856
|
-
const cadence = tweens ? cadenceMoments(tweens) : void 0;
|
|
33857
|
-
const report = motionReport({ from, to, plan, everyNthFrame, intervals, tweens, duration: duration3, splitFocus, breathless, cadence });
|
|
33858
|
-
const lines = [
|
|
33859
|
-
report,
|
|
33860
|
-
...passPlan.includeSheets ? [] : [
|
|
33861
|
-
"",
|
|
33862
|
-
'QUICK PASS \u2014 numbers only, no frames attached. Run detail: "full" when you need to SEE the motion, and reviewMode: "final" for the export-evidence review.'
|
|
33863
|
-
],
|
|
33864
|
-
...censusSection ? ["", censusSection] : [],
|
|
33865
|
-
...contractSection ? ["", contractSection] : [],
|
|
33866
|
-
...scoreSection ? ["", scoreSection] : [],
|
|
33867
|
-
...v2Section ? ["", v2Section] : [],
|
|
33868
|
-
...catalogSection ? ["", catalogSection] : [],
|
|
33869
|
-
...session.warnings.map((warning) => `WARNING: ${warning}`),
|
|
33870
|
-
"",
|
|
33871
|
-
"These are measurements, not style rules. Dead air and imperceptible tweens are time no one is directing \u2014 decide narratively what should own that time, change the direction, and measure again."
|
|
33872
|
-
];
|
|
33873
|
-
if (ctx.qualityLoop) {
|
|
33874
|
-
if (!finalReview) {
|
|
33875
|
-
lines.push(
|
|
33876
|
-
'QUALITY LOOP: this pass is not export evidence. Record motion coverage with get_motion({ draftId, reviewMode: "final" }) once the cut is complete.'
|
|
33877
|
-
);
|
|
33878
|
-
} else {
|
|
33879
|
-
const stalePreview = session.warnings.some(
|
|
33880
|
-
(warning) => warning.startsWith("The preview bundle is at revision ")
|
|
33881
|
-
);
|
|
33882
|
-
if (stalePreview) {
|
|
33883
|
-
lines.push(
|
|
33884
|
-
"MOTION REVIEW NOT RECORDED: these samples came from a stale preview bundle. Wait for the current revision to build, then review it again."
|
|
33885
|
-
);
|
|
33886
|
-
} else {
|
|
33887
|
-
ctx.qualityLoopState?.finalMotionReviewByDraft.set(String(args.draftId), {
|
|
33888
|
-
revision: session.revision,
|
|
33889
|
-
duration: duration3,
|
|
33890
|
-
stepSeconds: plan.stepSeconds,
|
|
33891
|
-
samples: plan.times.length
|
|
33892
|
-
});
|
|
33893
|
-
lines.push(
|
|
33894
|
-
`FINAL MOTION REVIEW RECORDED for revision ${session.revision} \u2014 ${plan.times.length} samples, ${plan.stepSeconds.toFixed(3)}s apart` + (plan.coarsened ? " (coarsened below one-in-five by film length)" : "") + ". Any later edit invalidates this evidence."
|
|
33895
|
-
);
|
|
33896
|
-
lines.push(qualityReviewPrompt("final"));
|
|
33897
|
-
}
|
|
35064
|
+
if (status.status === "error" || status.status === "failed" || status.error) {
|
|
35065
|
+
return fail(`Brand extraction failed${status.error ? `: ${status.error}` : ""}`);
|
|
33898
35066
|
}
|
|
35067
|
+
} catch (error51) {
|
|
35068
|
+
lastError = error51 instanceof Error ? error51.message : String(error51);
|
|
35069
|
+
if (++consecutiveErrors >= POLL_ERROR_LIMIT) break;
|
|
33899
35070
|
}
|
|
33900
|
-
|
|
33901
|
-
|
|
33902
|
-
|
|
33903
|
-
|
|
33904
|
-
|
|
33905
|
-
await
|
|
35071
|
+
progress?.report?.({
|
|
35072
|
+
progress: Math.min(Date.now() - startedAt, budgetMs),
|
|
35073
|
+
total: budgetMs,
|
|
35074
|
+
message: `brand run ${jobId}: ${stage || "extracting"}`
|
|
35075
|
+
});
|
|
35076
|
+
await nap(ctx, 2e3);
|
|
33906
35077
|
}
|
|
35078
|
+
return json2({
|
|
35079
|
+
timedOut: true,
|
|
35080
|
+
jobId,
|
|
35081
|
+
status: lastStatus,
|
|
35082
|
+
...lastError ? { pollError: lastError } : {},
|
|
35083
|
+
resumeWith: `brand_extract({ jobId: "${jobId}", wait: true })`,
|
|
35084
|
+
note: "The extraction is still running \u2014 this is a pause, not a failure. Continue the same run with resumeWith; never call brand_extract with the URL again for this run." + clampNote(args.waitSeconds)
|
|
35085
|
+
});
|
|
33907
35086
|
}
|
|
33908
35087
|
};
|
|
33909
|
-
|
|
33910
|
-
|
|
33911
|
-
|
|
33912
|
-
|
|
33913
|
-
|
|
33914
|
-
|
|
33915
|
-
|
|
35088
|
+
var motionProfile = {
|
|
35089
|
+
name: "motion_profile",
|
|
35090
|
+
title: "Manage the personal motion profile",
|
|
35091
|
+
description: "Inspect and manage the user's learned motion preferences. Call with action 'get' to see the effective profile before directing. Use 'remember' ONLY when the user explicitly asks to remember a preference; use 'propose' to submit an inferred preference as a pending proposal for the user to confirm \u2014 never confirm an inferred preference yourself. 'confirm'/'reject' decide a pending proposal after asking the user; 'forget', 'pause', 'resume' and 'reset' manage stored preferences on the user's behalf.",
|
|
35092
|
+
inputSchema: {
|
|
35093
|
+
action: external_exports.enum(["get", "remember", "propose", "confirm", "reject", "forget", "pause", "resume", "reset"]).describe("The profile command to run."),
|
|
35094
|
+
rule: external_exports.record(external_exports.string(), external_exports.unknown()).optional().describe(
|
|
35095
|
+
"Structured preference for remember/propose, e.g. {kind:'trait',trait:'pacing',value:'fast'}, {kind:'library',itemType:'style',itemId:'optical',stance:'prefer'}, {kind:'freeform',text:'...'}, {kind:'duration',max:15}, or {kind:'example',draftId:'...'}."
|
|
35096
|
+
),
|
|
35097
|
+
eventId: external_exports.string().optional().describe("Pending proposal id for confirm/reject."),
|
|
35098
|
+
ruleKey: external_exports.string().optional().describe("Rule key to forget, from the get result."),
|
|
35099
|
+
scope: external_exports.enum(["workspace", "brand", "user", "user_brand"]).optional().describe("Target layer. Defaults to the personal layer; shared layers need admin."),
|
|
35100
|
+
brandSlug: external_exports.string().optional().describe("Brand slug for brand-scoped preferences."),
|
|
35101
|
+
contentType: external_exports.string().optional().describe("Content type this preference applies to."),
|
|
35102
|
+
draftId: external_exports.string().optional().describe("Draft that motivated a proposal.")
|
|
35103
|
+
},
|
|
35104
|
+
handler: async (args, ctx) => json2(await ctx.invoke("motion-profile", args))
|
|
35105
|
+
};
|
|
35106
|
+
var studioLinkTool = {
|
|
35107
|
+
name: "studio_link",
|
|
35108
|
+
title: "Get the studio URL",
|
|
35109
|
+
description: "Get the web studio URL where the user watches the cuts and tweaks them visually. Always give this to the user after a change lands.",
|
|
35110
|
+
inputSchema: {
|
|
35111
|
+
projectId: external_exports.string().describe("The project id."),
|
|
35112
|
+
slug: external_exports.string().optional().describe("Draft slug to open directly.")
|
|
35113
|
+
},
|
|
35114
|
+
handler: async (args, ctx) => ({
|
|
35115
|
+
text: studioLink(ctx, String(args.projectId), args.slug)
|
|
35116
|
+
})
|
|
35117
|
+
};
|
|
35118
|
+
var REMOTE_SAFE_TOOLS = [
|
|
35119
|
+
accountStatus,
|
|
35120
|
+
whoami,
|
|
35121
|
+
listProjects,
|
|
35122
|
+
getProject,
|
|
35123
|
+
createVideo,
|
|
35124
|
+
listDraftFiles,
|
|
35125
|
+
readDraftFile,
|
|
35126
|
+
writeDraftFiles,
|
|
35127
|
+
editDraftFile,
|
|
35128
|
+
jobStatus,
|
|
35129
|
+
exportVideo,
|
|
35130
|
+
brandList,
|
|
35131
|
+
brandGet,
|
|
35132
|
+
brandExtract,
|
|
35133
|
+
motionProfile,
|
|
35134
|
+
studioLinkTool
|
|
35135
|
+
];
|
|
33916
35136
|
|
|
33917
35137
|
// src/mcp/gather-references.ts
|
|
33918
|
-
import { existsSync, statSync } from "node:fs";
|
|
35138
|
+
import { existsSync as existsSync2, statSync } from "node:fs";
|
|
33919
35139
|
var referenceEntry = external_exports.object({
|
|
33920
35140
|
kind: external_exports.enum(["local-code", "product-ui-screenshot", "site-code", "website", "screenshot", "image-to-use", "component-class-image", "mockup"]),
|
|
33921
35141
|
/** URL or local file path. */
|
|
@@ -33948,7 +35168,7 @@ async function captureSource(source) {
|
|
|
33948
35168
|
}
|
|
33949
35169
|
}
|
|
33950
35170
|
try {
|
|
33951
|
-
return { verified:
|
|
35171
|
+
return { verified: existsSync2(source) && statSync(source).size > 0 };
|
|
33952
35172
|
} catch {
|
|
33953
35173
|
return { verified: false };
|
|
33954
35174
|
}
|
|
@@ -33995,33 +35215,47 @@ var gatherReferencesTool = {
|
|
|
33995
35215
|
};
|
|
33996
35216
|
|
|
33997
35217
|
// src/mcp/install-kits.ts
|
|
33998
|
-
|
|
33999
|
-
|
|
34000
|
-
|
|
34001
|
-
|
|
34002
|
-
"
|
|
34003
|
-
|
|
34004
|
-
|
|
34005
|
-
|
|
34006
|
-
|
|
34007
|
-
|
|
34008
|
-
|
|
34009
|
-
|
|
34010
|
-
|
|
34011
|
-
|
|
34012
|
-
|
|
34013
|
-
|
|
34014
|
-
|
|
34015
|
-
|
|
34016
|
-
|
|
34017
|
-
|
|
34018
|
-
)
|
|
34019
|
-
|
|
35218
|
+
var kitSourceShape = external_exports.object({
|
|
35219
|
+
kits: external_exports.array(external_exports.object({ path: external_exports.string().min(1), content: external_exports.string().min(1) })).min(1)
|
|
35220
|
+
});
|
|
35221
|
+
async function fetchKitSources(ctx) {
|
|
35222
|
+
const response = await ctx.invoke("motion-library", { kitsOnly: true });
|
|
35223
|
+
const parsed = kitSourceShape.safeParse(response);
|
|
35224
|
+
if (!parsed.success) {
|
|
35225
|
+
throw new Error(
|
|
35226
|
+
"The motion library did not return kit sources \u2014 the server may predate kit serving. Retry after updating, or continue without install_kits."
|
|
35227
|
+
);
|
|
35228
|
+
}
|
|
35229
|
+
return parsed.data.kits.filter((kit) => /^kit\/[a-z]+\.ts$/.test(kit.path));
|
|
35230
|
+
}
|
|
35231
|
+
function kitCatalog(sources) {
|
|
35232
|
+
const lines = [];
|
|
35233
|
+
for (const file2 of sources) {
|
|
35234
|
+
const match = /^kit\/([\w-]+)\.ts$/.exec(file2.path);
|
|
35235
|
+
if (!match) continue;
|
|
35236
|
+
const name = match[1];
|
|
35237
|
+
const header = [];
|
|
35238
|
+
for (const raw of file2.content.split("\n")) {
|
|
35239
|
+
const line = raw.trim();
|
|
35240
|
+
if (!line.startsWith("//")) {
|
|
35241
|
+
if (header.length) break;
|
|
35242
|
+
continue;
|
|
35243
|
+
}
|
|
35244
|
+
const text = line.replace(/^\/\/\s*/, "").replace(/[─]+/g, "").trim();
|
|
35245
|
+
if (text) header.push(text);
|
|
35246
|
+
if (header.length >= 2) break;
|
|
35247
|
+
}
|
|
35248
|
+
const exports = [...file2.content.matchAll(/^export (?:const|function) (\w+)/gm)].map((m) => m[1]).filter((n) => !n.startsWith("_"));
|
|
35249
|
+
lines.push(
|
|
35250
|
+
`- kit/${name}: ${header.join(" ") || "(no header)"}` + (exports.length ? ` [exports: ${exports.slice(0, 8).join(", ")}${exports.length > 8 ? ", \u2026" : ""}]` : "")
|
|
35251
|
+
);
|
|
35252
|
+
}
|
|
35253
|
+
return lines.join("\n");
|
|
34020
35254
|
}
|
|
34021
35255
|
var installKitsTool = {
|
|
34022
35256
|
name: "install_kits",
|
|
34023
35257
|
title: "Install the interaction and showpiece kits into a draft",
|
|
34024
|
-
description: "Write the house kits into the draft under kit/ so direction.ts can import them instead of hand-rolling primitives. Call this ONCE right after create_video, before writing your own files. Provides: kit/actions (a STATEFUL cursor actor \u2014 travels from where it is, decelerates into targets, presses with squash, types with a real caret; plus scroll and ui.set state changes), kit/ensemble (fountain, confetti, assemble, scatter, dropIn showpieces with closed-form ballistics), kit/looks (hero angle-zoom, tilt, dropIn entrances), kit/sentence (append/swap/stress/morphWord/chip typography), kit/morphkit, kit/highlight, kit/impact, kit/streamkit. Import as e.g. `import { createCursorKit } from './kit/actions'`. NEVER hand-roll a cursor, click, confetti burst, or fountain when the kit already does it \u2014 hand-rolled cursors teleport and click before they arrive.",
|
|
35258
|
+
description: "Write the house kits into the draft under kit/ so direction.ts can import them instead of hand-rolling primitives. Call this ONCE right after create_video, before writing your own files. Provides: kit/actions (a STATEFUL cursor actor \u2014 travels from where it is, decelerates into targets, presses with squash, types with a real caret; plus scroll and ui.set state changes), kit/ensemble (fountain, confetti, assemble, scatter, dropIn showpieces with closed-form ballistics), kit/looks (hero angle-zoom, tilt, dropIn entrances), kit/sentence (append/swap/stress/morphWord/chip typography), kit/morphkit (morph one element INTO another \u2014 box/radius/parts crossfade with matched sub-parts, plus chip-to-fullbleed expand: THE transformation verb), kit/highlight (draw attention: glow, underline, ring), kit/impact (hits and emphasis), kit/streamkit (live-data motion), kit/editorial (frame-authored cuts, settles, arrivals, cursor actions, depth fixes, and cadence), and kit/depthkit (the depth world: layered parallax rig under the camera, quickTilt product framing, frost glass, elevation-light lift, whip zoom and rack focus \u2014 the default stage for product beats). Import as e.g. `import { createCursorKit } from './kit/actions'`. NEVER hand-roll a cursor, click, confetti burst, or fountain when the kit already does it \u2014 hand-rolled cursors teleport and click before they arrive.",
|
|
34025
35259
|
inputSchema: {
|
|
34026
35260
|
draftId: external_exports.string().describe("The draft to install the kits into.")
|
|
34027
35261
|
},
|
|
@@ -34029,7 +35263,7 @@ var installKitsTool = {
|
|
|
34029
35263
|
// This is a draft write in route terms; meter it as one.
|
|
34030
35264
|
admitAs: "write_draft_files",
|
|
34031
35265
|
handler: async (args, ctx) => {
|
|
34032
|
-
const sources =
|
|
35266
|
+
const sources = await fetchKitSources(ctx);
|
|
34033
35267
|
const files = Object.fromEntries(sources.map((file2) => [file2.path, file2.content]));
|
|
34034
35268
|
const output = await saveDraft(ctx, String(args.draftId), () => ({
|
|
34035
35269
|
files,
|
|
@@ -34037,8 +35271,8 @@ var installKitsTool = {
|
|
|
34037
35271
|
}));
|
|
34038
35272
|
if (output.isError) return output;
|
|
34039
35273
|
return {
|
|
34040
|
-
text: `Installed ${sources.length} kit files under kit/. Import from './kit
|
|
34041
|
-
` + output.text
|
|
35274
|
+
text: `Installed ${sources.length} kit files under kit/. Import from './kit/<name>' in direction.ts; NEVER hand-roll what a kit provides. What arrived:
|
|
35275
|
+
` + kitCatalog(sources) + "\n" + output.text
|
|
34042
35276
|
};
|
|
34043
35277
|
}
|
|
34044
35278
|
};
|
|
@@ -34387,23 +35621,22 @@ function parseLocalMotionLibrary(source, filePath = "local motion library") {
|
|
|
34387
35621
|
}
|
|
34388
35622
|
return data;
|
|
34389
35623
|
}
|
|
34390
|
-
var replaceSelectedItems = (selected,
|
|
35624
|
+
var replaceSelectedItems = (selected, local2) => {
|
|
34391
35625
|
if (!selected?.length) return [];
|
|
34392
|
-
const byId = new Map(
|
|
35626
|
+
const byId = new Map(local2.map((item) => [item.id, item]));
|
|
34393
35627
|
return selected.map((item) => byId.get(item.id) ?? item);
|
|
34394
35628
|
};
|
|
34395
|
-
function overlayLocalMotionLibrary(remote,
|
|
35629
|
+
function overlayLocalMotionLibrary(remote, local2, filePath, detail) {
|
|
34396
35630
|
const productionLibraryVersion = remote.libraryVersion;
|
|
34397
35631
|
return {
|
|
34398
35632
|
...remote,
|
|
34399
|
-
schemaVersion:
|
|
34400
|
-
libraryVersion:
|
|
34401
|
-
engineVersion:
|
|
34402
|
-
catalog:
|
|
34403
|
-
guidance:
|
|
34404
|
-
styles: replaceSelectedItems(remote.styles,
|
|
34405
|
-
templates: replaceSelectedItems(remote.templates,
|
|
34406
|
-
sharedReferences: detail === "full" ? local.sharedReferences : void 0,
|
|
35633
|
+
schemaVersion: local2.schemaVersion,
|
|
35634
|
+
libraryVersion: local2.version,
|
|
35635
|
+
engineVersion: local2.engineVersion,
|
|
35636
|
+
catalog: local2.catalog,
|
|
35637
|
+
guidance: local2.guidance,
|
|
35638
|
+
styles: replaceSelectedItems(remote.styles, local2.styles),
|
|
35639
|
+
templates: replaceSelectedItems(remote.templates, local2.templates),
|
|
34407
35640
|
localLibraryOverride: {
|
|
34408
35641
|
enabled: true,
|
|
34409
35642
|
source: filePath,
|
|
@@ -34644,7 +35877,7 @@ ${formatUsageNotice(admission.warning)}`;
|
|
|
34644
35877
|
}
|
|
34645
35878
|
var ENGINE_DTS_URI = "impractical://engine.d.ts";
|
|
34646
35879
|
function readEngineTypes() {
|
|
34647
|
-
return readFileSync2(
|
|
35880
|
+
return readFileSync2(fileURLToPath(new URL("../assets/engine.d.ts", import.meta.url)), "utf8");
|
|
34648
35881
|
}
|
|
34649
35882
|
function registerEngineResource(server) {
|
|
34650
35883
|
server.registerResource(
|
|
@@ -34661,15 +35894,41 @@ function registerEngineResource(server) {
|
|
|
34661
35894
|
);
|
|
34662
35895
|
}
|
|
34663
35896
|
function buildServerInstructions(ctx) {
|
|
34664
|
-
return
|
|
34665
|
-
|
|
34666
|
-
|
|
34667
|
-
|
|
35897
|
+
return (
|
|
35898
|
+
// Which BUILD is answering, as opposed to which release. Several agents
|
|
35899
|
+
// on one machine routinely run different builds of this CLI — a
|
|
35900
|
+
// published npx copy, a shared build dir, somebody's worktree — and the
|
|
35901
|
+
// version alone cannot separate them, which is how a stale dist goes
|
|
35902
|
+
// unnoticed. It lives here and not in serverInfo.version because that
|
|
35903
|
+
// field means "the published release" and two test suites assert it.
|
|
35904
|
+
`Impractical Motion CLI build: ${PACKAGE_VERSION}+${BUILD_COMMIT}
|
|
35905
|
+
Impractical Motion makes studio-quality motion-graphics videos. Draft source lives in the cloud \u2014 there is no working copy on disk, so edit through these tools rather than writing files.
|
|
35906
|
+
|
|
35907
|
+
THE USER'S EDITS COME FIRST. While watching a draft in the Studio the user queues timecoded edit requests into its notes.json ("Edits for agent"). Before directing an existing draft, read_draft_file notes.json (it may not exist \u2014 that is fine); address the open items, and mark each one you have handled by setting its \`done\` to true with edit_draft_file, leaving the rest of the entry intact. The Studio shows the user what you resolved.
|
|
35908
|
+
|
|
35909
|
+
You are the director. Call engine_types first to learn the API, then motion_context to study the current house library and production references, then create_video, then write App.tsx (the stage markup) and direction.ts (the GSAP choreography) with edit_draft_file / write_draft_files. Every save returns its committed revision and preview-build job id immediately. Follow that id with job_status and fix any build error it returns. Call get_frames to SEE the result before telling the user it is done, then call export_video, follow its returned job id with job_status, and hand the user the studio_link.
|
|
35910
|
+
|
|
35911
|
+
JOBS OUTLIVE TOOL CALLS. Job-producing mutations return their durable job id BEFORE any wait; resume that exact id with job_status({ jobIds: [...], wait: true }). Never start a second export or re-apply an edit after a committed revision. If the MCP client kills a call and shows only a timeout, assume the acknowledgment may have been lost: DO NOT blindly retry a mutating or metered tool. Verify state first. For a write/edit, use list_draft_files and read_draft_file to confirm the intended source and revision. For an export or other project mutation, use get_project/list_projects to check whether it already started or completed. brand_extract returns a durable run handle; continue that exact jobId in status mode and never re-extract blindly. If its start acknowledgement is lost, check brand_list/brand_get before starting again. Retry only when those reads prove the mutation did not commit; otherwise continue from the observed state.
|
|
35912
|
+
|
|
35913
|
+
THE MOTION FLOOR \u2014 these hold even if you read nothing else:
|
|
35914
|
+
1. Beat out the story before writing code: every beat needs a time, a visible subject, and a narrative purpose. An intentional reading hold is a valid beat.
|
|
35915
|
+
2. Text enters on RISE \u2014 text.reveal(el, 'rise') on promo-out, lines ~0.2s apart. Do NOT default type to 'blur': word-by-word de-blur reads as out-of-focus and a staggered blur turns to mush. Blur is for whole-frame moves \u2014 fx.spike(el, 'blur') alongside a whip or dive, where it hides the cut. Never animate blur above 20px.
|
|
35916
|
+
3. Every ease is a named house curve \u2014 ease-out-* for entrances, ease-in-out-* for travel and ALL camera moves, ease-in-* for exits and dives only. Never leave an ease unspecified, and never put an ease-in-* on an entrance.
|
|
35917
|
+
4. Motion is decisive: entrances 0.4-0.6s, exits shorter, line staggers ~0.2s apart. Longer movement needs a narrative reason; important states may hold still to be read.
|
|
35918
|
+
5. One primary and at most one supporting motion at a time. Keep backgrounds stable unless their movement is explicitly authored. Cuts are HARD (tl.set), never crossfades.
|
|
35919
|
+
6. Determinism: no Date.now, Math.random, setTimeout, or CSS transitions. The exporter renders by seeking, so wall-clock motion shimmers or vanishes.
|
|
35920
|
+
7. Never claim a video is done without calling get_frames and judging the visible story, hierarchy, proof, legibility, reference fidelity, and technical integrity \u2014 artifact-free is not the same as good.
|
|
35921
|
+
` + // Item 8 is the content doctrine, which is still under experiment: it ships
|
|
35922
|
+
// only when IMPRACTICAL_CONTENT_DOCTRINE is set, so the same switch governs
|
|
35923
|
+
// the doctrine here and in the phased flow's phase 2.
|
|
35924
|
+
(contentDoctrineEnabled() ? "8. CONTENT: pretend the viewer has never heard of this product. Figure out the one thing they should walk away knowing, and say it in as few words as possible. If a smart 16-year-old could not repeat a line back in their own words, the line means nothing \u2014 cut it. No buzzwords, no vague taglines. Say a thing, then show it happening \u2014 in that order, one after the other, never both at once. Give the viewer exactly one thing to read at a time: no little label above a headline, no subtitle under it." : "") + "\n9. ICONS: always use real icons from the icon library, never typographic or ASCII stand-ins. lucide-react is available in every draft \u2014 `import { ArrowRight, Check } from 'lucide-react'`. An arrow is <ArrowRight/>, not \u2192 or ->; a tick is <Check/>, not \u2713; a chevron is <ChevronRight/>, not >; a play control is <Play/>, not \u25B6. Glyphs inherit the text font, so they land on the baseline at the wrong weight and optical size and read as a placeholder, and they cannot take a stroke width or animate as their own element. The same goes for emoji standing in for a UI affordance.\n10. FOCAL BUDGET: one focal point at a time. At most ONE region of the frame changes at once (a full-frame camera move is one change; a subject plus its direct reaction is the limit). A claim and the product may NEVER share the same space: while a statement is up, the product is fully cleared or fully hidden \u2014 dimming it under the words is not separation. Never stack anything on top of a dashboard while the dashboard is the subject. An element that finished its job exits before the next enters; the motion report now measures split focus and stacked text, and the film is not done while either fires.\n11. COMPONENT REHEARSAL: build and judge the hero components BEFORE choreographing the film. For each component that must look real (the dashboard, the inbox, the editor): build it, render it with get_frames in each state the beats will put it through, compare it against the reference you studied, and iterate until it holds up as a still. Only then write the timeline. A component that cannot survive a paused frame will not survive motion, and choreographing a broken component wastes every revision that follows.\n12. SCALE TRUTH: a product element never exceeds its native proportion. When a toast, button, or label must be readable, push the CAMERA to it so the whole UI scales together \u2014 never blow one element up inside otherwise-small chrome; an oversized toast over tiny UI text reads as a fake. The same for pointers: a cursor or caret is sized relative to the UI it operates on (match the product's zoom factor; at 67% zoom the cursor is 67% size), a caret pins to its text baseline, and a cursor tip lands ON its target, never near it. The census measures inflated elements; the film is not done while it fires.\n13. PACING: slow down. Stillness is a tool, not a defect \u2014 the dead-air check finds UNOWNED time, not quiet time. After every claim and every proof, HOLD: the viewer needs settled time to focus before the next change. The motion report flags nonstop motion (TOO FAST) exactly like it flags dead air, and the film is not done while either fires. THE CADENCE: one action at a time, about one every 1\u20131.5 seconds \u2014 a click, a panel opening, a card landing each count as one action. Let each settle before the next. The kit and camera defaults are already tuned to this; never override their durations to be FASTER. The report measures action frequency and flags stretches that pack more.\n14. CUTS: a scene change lands ONLY on a declared beat boundary \u2014 never mid-beat, never mid-read. A transition is either a real camera move (the house dolly) or a hard cut covered by the incoming element; a 0.1\u20130.3s squash-collapse is neither. Vary how scenes leave \u2014 the same exit mechanism back to back reads as a loop, and a scene alive under ~2 seconds never registered. The motion score measures all of this.\n15. THE GO-THROUGH: to dive into a button/card/color until it takes over the frame and becomes the next scene, ALWAYS use transition.through(target, { swap, land }) \u2014 never hand-roll it with a big camera.move scale plus a timed tl.set swap. Hand-rolled dives aim at build-time positions and miss; through() measures the target at the dive's start time, computes the exact cover scale, and guarantees the cut lands under full cover." + (ctx.qualityLoop ? qualityLoopServerInstructions() : "") + (ctx.motionLab ? motionLabServerInstructions() : "") + (ctx.craftLab ? "\n\nCRAFT LAB IS ENABLED \u2014 get_motion additionally prints a CRAFT ENVELOPE (depth, vocabulary, rhythm, continuity, geometry) band-scored against the house references. Camera motion is exempt from the restraint verdicts. Export enforces objective craft floors: at least one camera move, zero unresolved beat mismatches, zero geometry deformations, no dead-air span over 2.5s. Self-scores are recorded but do not gate; identical scorecards across drafts are rejected." : "") + (ctx.phasedFlow ? phasedServerInstructions() : "")
|
|
35925
|
+
);
|
|
34668
35926
|
}
|
|
34669
35927
|
function createServer(ctx) {
|
|
34670
35928
|
const qualityLoopState = ctx.qualityLoop ? ctx.qualityLoopState ?? createQualityLoopState() : void 0;
|
|
34671
35929
|
const validatedCtx = {
|
|
34672
35930
|
...ctx,
|
|
35931
|
+
sessionState: ctx.sessionState ?? createSessionState(),
|
|
34673
35932
|
qualityLoopState,
|
|
34674
35933
|
invoke: async (name, body = {}) => validateApiResponse(name, body, await ctx.invoke(name, body))
|
|
34675
35934
|
};
|
|
@@ -34711,7 +35970,11 @@ async function runStdioServer() {
|
|
|
34711
35970
|
appUrl: config2.appUrl,
|
|
34712
35971
|
qualityLoop: qualityLoopEnabled(),
|
|
34713
35972
|
motionLab: motionLabEnabled(),
|
|
34714
|
-
|
|
35973
|
+
craftLab: craftLabEnabled(),
|
|
35974
|
+
phasedFlow: phasedFlowEnabled(),
|
|
35975
|
+
// stdio runs on the user's machine, so completed preview builds can be
|
|
35976
|
+
// measured in the local browser and answered with what the frame became.
|
|
35977
|
+
measurePreview: measureStage
|
|
34715
35978
|
});
|
|
34716
35979
|
await server.connect(new StdioServerTransport());
|
|
34717
35980
|
const served = TOOLS.length + 2 + (motionLabEnabled() ? 1 : 0) + (phasedFlowEnabled() ? 1 : 0);
|