@impractical-ai/motion 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/assets/engine.d.ts +755 -3
- package/assets/impractical/SKILL.md +53 -6
- package/assets/kits/actions.ts +803 -0
- package/assets/kits/ensemble.ts +483 -0
- package/assets/kits/highlight.ts +374 -0
- package/assets/kits/impact.ts +103 -0
- package/assets/kits/kits.ts +343 -0
- package/assets/kits/looks.ts +935 -0
- package/assets/kits/morphkit.ts +438 -0
- package/assets/kits/reveal.ts +1364 -0
- package/assets/kits/sentence.ts +218 -0
- package/assets/kits/streamkit.ts +195 -0
- package/dist/{api-C7KJDYN4.js → api-DR3NJ7TI.js} +3 -2
- package/dist/chunk-77HVPD4G.js +43 -0
- package/dist/{chunk-BUHNO2W2.js → chunk-M7SHFM25.js} +2 -2
- package/dist/{chunk-KITRCE33.js → chunk-MQKW7NFI.js} +1 -1
- package/dist/{chunk-LJ4YH3YJ.js → chunk-Z74SLKEU.js} +2 -34
- package/dist/{commands-P5CDHPDP.js → commands-R3LJU2BW.js} +4 -3
- package/dist/index.js +6 -5
- package/dist/main-BEONOBG5.js +2218 -0
- package/dist/{stdio-GHDI4S7P.js → stdio-XUVQKDCB.js} +2723 -165
- package/package.json +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
openBrowser
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-M7SHFM25.js";
|
|
5
5
|
import {
|
|
6
6
|
ApiError,
|
|
7
7
|
admitMcpCall,
|
|
@@ -11,15 +11,17 @@ import {
|
|
|
11
11
|
saveConfig,
|
|
12
12
|
validateApiResponse,
|
|
13
13
|
withAdmissionReceipt
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-MQKW7NFI.js";
|
|
15
15
|
import {
|
|
16
16
|
PACKAGE_VERSION,
|
|
17
17
|
PRODUCTION_APP_URL,
|
|
18
|
-
PRODUCTION_SUPABASE_URL
|
|
18
|
+
PRODUCTION_SUPABASE_URL
|
|
19
|
+
} from "./chunk-Z74SLKEU.js";
|
|
20
|
+
import {
|
|
19
21
|
__commonJS,
|
|
20
22
|
__export,
|
|
21
23
|
__toESM
|
|
22
|
-
} from "./chunk-
|
|
24
|
+
} from "./chunk-77HVPD4G.js";
|
|
23
25
|
|
|
24
26
|
// node_modules/ajv/dist/compile/codegen/code.js
|
|
25
27
|
var require_code = __commonJS({
|
|
@@ -378,7 +380,7 @@ var require_codegen = __commonJS({
|
|
|
378
380
|
AND: new code_1._Code("&&"),
|
|
379
381
|
ADD: new code_1._Code("+")
|
|
380
382
|
};
|
|
381
|
-
var
|
|
383
|
+
var Node2 = class {
|
|
382
384
|
optimizeNodes() {
|
|
383
385
|
return this;
|
|
384
386
|
}
|
|
@@ -386,7 +388,7 @@ var require_codegen = __commonJS({
|
|
|
386
388
|
return this;
|
|
387
389
|
}
|
|
388
390
|
};
|
|
389
|
-
var Def = class extends
|
|
391
|
+
var Def = class extends Node2 {
|
|
390
392
|
constructor(varKind, name, rhs) {
|
|
391
393
|
super();
|
|
392
394
|
this.varKind = varKind;
|
|
@@ -409,7 +411,7 @@ var require_codegen = __commonJS({
|
|
|
409
411
|
return this.rhs instanceof code_1._CodeOrName ? this.rhs.names : {};
|
|
410
412
|
}
|
|
411
413
|
};
|
|
412
|
-
var Assign = class extends
|
|
414
|
+
var Assign = class extends Node2 {
|
|
413
415
|
constructor(lhs, rhs, sideEffects) {
|
|
414
416
|
super();
|
|
415
417
|
this.lhs = lhs;
|
|
@@ -439,7 +441,7 @@ var require_codegen = __commonJS({
|
|
|
439
441
|
return `${this.lhs} ${this.op}= ${this.rhs};` + _n;
|
|
440
442
|
}
|
|
441
443
|
};
|
|
442
|
-
var Label = class extends
|
|
444
|
+
var Label = class extends Node2 {
|
|
443
445
|
constructor(label) {
|
|
444
446
|
super();
|
|
445
447
|
this.label = label;
|
|
@@ -449,7 +451,7 @@ var require_codegen = __commonJS({
|
|
|
449
451
|
return `${this.label}:` + _n;
|
|
450
452
|
}
|
|
451
453
|
};
|
|
452
|
-
var Break = class extends
|
|
454
|
+
var Break = class extends Node2 {
|
|
453
455
|
constructor(label) {
|
|
454
456
|
super();
|
|
455
457
|
this.label = label;
|
|
@@ -460,7 +462,7 @@ var require_codegen = __commonJS({
|
|
|
460
462
|
return `break${label};` + _n;
|
|
461
463
|
}
|
|
462
464
|
};
|
|
463
|
-
var Throw = class extends
|
|
465
|
+
var Throw = class extends Node2 {
|
|
464
466
|
constructor(error51) {
|
|
465
467
|
super();
|
|
466
468
|
this.error = error51;
|
|
@@ -472,7 +474,7 @@ var require_codegen = __commonJS({
|
|
|
472
474
|
return this.error.names;
|
|
473
475
|
}
|
|
474
476
|
};
|
|
475
|
-
var AnyCode = class extends
|
|
477
|
+
var AnyCode = class extends Node2 {
|
|
476
478
|
constructor(code) {
|
|
477
479
|
super();
|
|
478
480
|
this.code = code;
|
|
@@ -491,7 +493,7 @@ var require_codegen = __commonJS({
|
|
|
491
493
|
return this.code instanceof code_1._CodeOrName ? this.code.names : {};
|
|
492
494
|
}
|
|
493
495
|
};
|
|
494
|
-
var ParentNode = class extends
|
|
496
|
+
var ParentNode = class extends Node2 {
|
|
495
497
|
constructor(nodes = []) {
|
|
496
498
|
super();
|
|
497
499
|
this.nodes = nodes;
|
|
@@ -2223,8 +2225,8 @@ var require_resolve = __commonJS({
|
|
|
2223
2225
|
}
|
|
2224
2226
|
return count;
|
|
2225
2227
|
}
|
|
2226
|
-
function getFullPath(resolver, id = "",
|
|
2227
|
-
if (
|
|
2228
|
+
function getFullPath(resolver, id = "", normalize2) {
|
|
2229
|
+
if (normalize2 !== false)
|
|
2228
2230
|
id = normalizeId(id);
|
|
2229
2231
|
const p = resolver.parse(id);
|
|
2230
2232
|
return _getFullPath(resolver, p);
|
|
@@ -3620,7 +3622,7 @@ var require_fast_uri = __commonJS({
|
|
|
3620
3622
|
"use strict";
|
|
3621
3623
|
var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, escapePreservingEscapes, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require_utils();
|
|
3622
3624
|
var { SCHEMES, getSchemeHandler } = require_schemes();
|
|
3623
|
-
function
|
|
3625
|
+
function normalize2(uri, options) {
|
|
3624
3626
|
if (typeof uri === "string") {
|
|
3625
3627
|
uri = /** @type {T} */
|
|
3626
3628
|
normalizeString(uri, options);
|
|
@@ -3913,7 +3915,7 @@ var require_fast_uri = __commonJS({
|
|
|
3913
3915
|
}
|
|
3914
3916
|
var fastUri = {
|
|
3915
3917
|
SCHEMES,
|
|
3916
|
-
normalize,
|
|
3918
|
+
normalize: normalize2,
|
|
3917
3919
|
resolve,
|
|
3918
3920
|
resolveComponent,
|
|
3919
3921
|
equal,
|
|
@@ -6904,8 +6906,8 @@ var require_dist = __commonJS({
|
|
|
6904
6906
|
});
|
|
6905
6907
|
|
|
6906
6908
|
// src/mcp/stdio.ts
|
|
6907
|
-
import { readFileSync } from "node:fs";
|
|
6908
|
-
import { fileURLToPath } from "node:url";
|
|
6909
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
6910
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
6909
6911
|
|
|
6910
6912
|
// node_modules/zod/v3/helpers/util.js
|
|
6911
6913
|
var util;
|
|
@@ -31010,6 +31012,315 @@ var UNMETERED_MCP_TOOLS = /* @__PURE__ */ new Set([
|
|
|
31010
31012
|
var encoder = new TextEncoder();
|
|
31011
31013
|
var decoder = new TextDecoder();
|
|
31012
31014
|
|
|
31015
|
+
// src/mcp/quality-loop.ts
|
|
31016
|
+
var QUALITY_LOOP_ENV = "IMPRACTICAL_QUALITY_LOOP";
|
|
31017
|
+
var meaningful = (label, minimum = 12) => external_exports.string().trim().min(minimum, `${label} must be specific enough to direct from.`);
|
|
31018
|
+
var creativeContractSchema = external_exports.object({
|
|
31019
|
+
audience: meaningful("audience", 3),
|
|
31020
|
+
singleIdea: meaningful("singleIdea"),
|
|
31021
|
+
visualProof: meaningful("visualProof"),
|
|
31022
|
+
referenceStrategy: meaningful("referenceStrategy"),
|
|
31023
|
+
productSource: meaningful("productSource"),
|
|
31024
|
+
opening: meaningful("opening"),
|
|
31025
|
+
proof: meaningful("proof"),
|
|
31026
|
+
payoff: meaningful("payoff")
|
|
31027
|
+
});
|
|
31028
|
+
var qualityScoresSchema = external_exports.object({
|
|
31029
|
+
narrativeClarity: external_exports.number().int().min(1).max(5),
|
|
31030
|
+
visualProof: external_exports.number().int().min(1).max(5),
|
|
31031
|
+
productAuthenticity: external_exports.number().int().min(1).max(5),
|
|
31032
|
+
composition: external_exports.number().int().min(1).max(5),
|
|
31033
|
+
legibility: external_exports.number().int().min(1).max(5),
|
|
31034
|
+
pacing: external_exports.number().int().min(1).max(5),
|
|
31035
|
+
referenceFidelity: external_exports.number().int().min(1).max(5),
|
|
31036
|
+
technicalIntegrity: external_exports.number().int().min(1).max(5)
|
|
31037
|
+
});
|
|
31038
|
+
var qualityReviewSchema = external_exports.object({
|
|
31039
|
+
reviewedRevision: external_exports.number().int().min(0),
|
|
31040
|
+
singleIdea: meaningful("singleIdea"),
|
|
31041
|
+
visualProof: meaningful("visualProof"),
|
|
31042
|
+
referenceComparison: meaningful("referenceComparison"),
|
|
31043
|
+
scores: qualityScoresSchema,
|
|
31044
|
+
timestampEvidence: external_exports.array(
|
|
31045
|
+
external_exports.object({
|
|
31046
|
+
time: external_exports.number().min(0),
|
|
31047
|
+
finding: meaningful("finding", 5)
|
|
31048
|
+
})
|
|
31049
|
+
).min(3),
|
|
31050
|
+
blockingIssues: external_exports.array(external_exports.string().trim().min(1))
|
|
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() ?? "");
|
|
31061
|
+
}
|
|
31062
|
+
var hasOpening = (times) => times.some((time3) => time3 <= 0.15);
|
|
31063
|
+
var hasEnding = (times, duration3) => duration3 <= 0 || times.some((time3) => time3 >= Math.max(0, duration3 - 0.25));
|
|
31064
|
+
function finalReviewCoverageError(times, duration3) {
|
|
31065
|
+
if (times.length < 8) return "Final review needs at least 8 representative frames.";
|
|
31066
|
+
if (!hasOpening(times)) return "Final review must include the opening frame at t=0.";
|
|
31067
|
+
if (!hasEnding(times, duration3)) {
|
|
31068
|
+
return `Final review must include a settled closing frame near t=${duration3.toFixed(2)}s.`;
|
|
31069
|
+
}
|
|
31070
|
+
return null;
|
|
31071
|
+
}
|
|
31072
|
+
function recordFinalReviewCapture(state, draftId, capture) {
|
|
31073
|
+
state?.finalReviewByDraft.set(draftId, capture);
|
|
31074
|
+
}
|
|
31075
|
+
function qualityReviewPrompt(mode, contract) {
|
|
31076
|
+
const contractNote = contract ? `
|
|
31077
|
+
Creative contract: ${contract.singleIdea} Visual proof: ${contract.visualProof}` : "";
|
|
31078
|
+
if (mode === "proof") {
|
|
31079
|
+
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;
|
|
31080
|
+
}
|
|
31081
|
+
if (mode === "final") {
|
|
31082
|
+
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;
|
|
31083
|
+
}
|
|
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
|
+
}
|
|
31086
|
+
function motionCoverageFailure(input) {
|
|
31087
|
+
if (!input.motionLab) return null;
|
|
31088
|
+
if (!input.capture) {
|
|
31089
|
+
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.';
|
|
31090
|
+
}
|
|
31091
|
+
if (input.capture.revision !== input.frameReviewRevision) {
|
|
31092
|
+
return `The motion review covered revision ${input.capture.revision}, but the final frame review covered revision ${input.frameReviewRevision}. Review the same revision with both.`;
|
|
31093
|
+
}
|
|
31094
|
+
return null;
|
|
31095
|
+
}
|
|
31096
|
+
function qualityReviewFailure(input) {
|
|
31097
|
+
const { draftRevision, capture, review } = input;
|
|
31098
|
+
if (!capture) {
|
|
31099
|
+
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.';
|
|
31100
|
+
}
|
|
31101
|
+
if (capture.revision !== draftRevision) {
|
|
31102
|
+
return `The final review covered revision ${capture.revision}, but the draft is now revision ${draftRevision}. Review the current revision again.`;
|
|
31103
|
+
}
|
|
31104
|
+
if (!review) {
|
|
31105
|
+
return "Attach the timestamped qualityReview scorecard required by the local quality loop.";
|
|
31106
|
+
}
|
|
31107
|
+
if (review.reviewedRevision !== draftRevision) {
|
|
31108
|
+
return `qualityReview says revision ${review.reviewedRevision}, but the current draft is revision ${draftRevision}.`;
|
|
31109
|
+
}
|
|
31110
|
+
if (review.blockingIssues.length) {
|
|
31111
|
+
return `The review still has blocking issues: ${review.blockingIssues.join("; ")}`;
|
|
31112
|
+
}
|
|
31113
|
+
const scores = Object.entries(review.scores);
|
|
31114
|
+
const low = scores.filter(([, score]) => score < 3);
|
|
31115
|
+
if (low.length) {
|
|
31116
|
+
return `Scores below 3 block export: ${low.map(([name, score]) => `${name}=${score}`).join(", ")}.`;
|
|
31117
|
+
}
|
|
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
|
+
}
|
|
31122
|
+
function qualityLoopServerInstructions() {
|
|
31123
|
+
return `
|
|
31124
|
+
|
|
31125
|
+
LOCAL QUALITY LOOP IS ENABLED \u2014 this is an opt-in experiment and it is mandatory for new videos:
|
|
31126
|
+
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.
|
|
31127
|
+
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.
|
|
31128
|
+
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.
|
|
31129
|
+
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.
|
|
31130
|
+
This loop changes no scene schema or renderer behavior. It exists to stop technically valid but creatively weak cuts from self-approving.`;
|
|
31131
|
+
}
|
|
31132
|
+
|
|
31133
|
+
// src/mcp/phased-flow.ts
|
|
31134
|
+
var PHASED_FLOW_ENV = "IMPRACTICAL_PHASED_FLOW";
|
|
31135
|
+
function phasedFlowEnabled(env = process.env) {
|
|
31136
|
+
return !/^(0|false|off|no)$/i.test(env[PHASED_FLOW_ENV]?.trim() ?? "");
|
|
31137
|
+
}
|
|
31138
|
+
var meaningful2 = (label, minimum) => external_exports.string().trim().min(minimum, `${label} must be specific enough to direct from.`);
|
|
31139
|
+
var conceptSchema = external_exports.object({
|
|
31140
|
+
/** One sentence: product, user, and the change the viewer will believe. */
|
|
31141
|
+
premise: meaningful2("premise", 20),
|
|
31142
|
+
/** The thing the viewer follows across the whole film (an invoice, a request…). */
|
|
31143
|
+
recurringArtifact: meaningful2("recurringArtifact", 6),
|
|
31144
|
+
/** Evidence gathered BEFORE inventing UI: real code, the product site, images. */
|
|
31145
|
+
references: external_exports.array(
|
|
31146
|
+
external_exports.object({
|
|
31147
|
+
kind: external_exports.enum(["local-code", "website", "screenshot", "image-search"]),
|
|
31148
|
+
detail: meaningful2("reference detail", 5)
|
|
31149
|
+
})
|
|
31150
|
+
).min(1, "Gather at least one real reference before designing components."),
|
|
31151
|
+
/** Every beat names a MOVE — a beat with no move is not a beat. */
|
|
31152
|
+
beats: external_exports.array(
|
|
31153
|
+
external_exports.object({
|
|
31154
|
+
start: external_exports.number().min(0),
|
|
31155
|
+
end: external_exports.number().min(0),
|
|
31156
|
+
subject: meaningful2("beat subject", 3),
|
|
31157
|
+
move: meaningful2("beat move", 8),
|
|
31158
|
+
purpose: meaningful2("beat purpose", 5),
|
|
31159
|
+
/** Declared energy: rest is legitimate stillness; high must visibly move. */
|
|
31160
|
+
energy: external_exports.enum(["rest", "low", "high"]),
|
|
31161
|
+
/**
|
|
31162
|
+
* What happens to the elements ALREADY on stage when this beat starts:
|
|
31163
|
+
* exit (and how), transform, or deliberately stay. First beat may say
|
|
31164
|
+
* "empty stage". Entrances are cheap; exits are choreography, and a
|
|
31165
|
+
* beat that only describes what enters leaves the last scene's props
|
|
31166
|
+
* loitering in frame.
|
|
31167
|
+
*/
|
|
31168
|
+
outgoing: meaningful2("beat outgoing (what the elements already on stage do)", 6)
|
|
31169
|
+
})
|
|
31170
|
+
).min(4),
|
|
31171
|
+
/**
|
|
31172
|
+
* Every component the film will show, each answering: must this look 1:1
|
|
31173
|
+
* real? 'exact-product' = the actual product's UI; 'realistic-class' = a
|
|
31174
|
+
* real-world object class (a terminal, an invoice); 'stylized' = abstract/
|
|
31175
|
+
* editorial, no reference needed. Non-stylized components MUST name the
|
|
31176
|
+
* reference source that was pulled and viewed for them.
|
|
31177
|
+
*/
|
|
31178
|
+
componentInventory: external_exports.array(
|
|
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.";
|
|
31198
|
+
}
|
|
31199
|
+
const concept = conceptSchema.safeParse(args.concept);
|
|
31200
|
+
if (!concept.success) {
|
|
31201
|
+
const issue2 = concept.error.issues[0];
|
|
31202
|
+
const at = issue2?.path?.length ? ` at ${issue2.path.join(".")}` : "";
|
|
31203
|
+
return `concept invalid${at}: ${issue2?.message ?? "schema mismatch"}`;
|
|
31204
|
+
}
|
|
31205
|
+
const content = contentSchema.safeParse(args.content);
|
|
31206
|
+
if (!content.success) {
|
|
31207
|
+
const issue2 = content.error.issues[0];
|
|
31208
|
+
const at = issue2?.path?.length ? ` at ${issue2.path.join(".")}` : "";
|
|
31209
|
+
return `content invalid${at}: ${issue2?.message ?? "schema mismatch"}`;
|
|
31210
|
+
}
|
|
31211
|
+
const missing = concept.data.componentInventory.filter(
|
|
31212
|
+
(component) => component.realism !== "stylized" && !component.referenceSource
|
|
31213
|
+
);
|
|
31214
|
+
if (missing.length) {
|
|
31215
|
+
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.`;
|
|
31216
|
+
}
|
|
31217
|
+
return null;
|
|
31218
|
+
}
|
|
31219
|
+
var CONTENT_DOCTRINE_ENV = "IMPRACTICAL_CONTENT_DOCTRINE";
|
|
31220
|
+
function contentDoctrineEnabled(env = process.env) {
|
|
31221
|
+
return !/^(0|false|off|no)$/i.test(env[CONTENT_DOCTRINE_ENV]?.trim() ?? "");
|
|
31222
|
+
}
|
|
31223
|
+
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.";
|
|
31224
|
+
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." : "";
|
|
31225
|
+
function phasedServerInstructions() {
|
|
31226
|
+
return `
|
|
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.`;
|
|
31234
|
+
}
|
|
31235
|
+
|
|
31236
|
+
// src/mcp/preflight.ts
|
|
31237
|
+
var esbuildPromise = null;
|
|
31238
|
+
function loadEsbuild() {
|
|
31239
|
+
esbuildPromise ??= import("./main-BEONOBG5.js").then(
|
|
31240
|
+
(mod) => mod,
|
|
31241
|
+
() => null
|
|
31242
|
+
);
|
|
31243
|
+
return esbuildPromise;
|
|
31244
|
+
}
|
|
31245
|
+
var LOADERS = {
|
|
31246
|
+
".tsx": "tsx",
|
|
31247
|
+
".ts": "ts",
|
|
31248
|
+
".jsx": "jsx",
|
|
31249
|
+
".js": "js",
|
|
31250
|
+
".css": "css",
|
|
31251
|
+
".json": "json"
|
|
31252
|
+
};
|
|
31253
|
+
function resolveDraftPath(files, request) {
|
|
31254
|
+
const clean = request.replace(/^\.\//, "");
|
|
31255
|
+
const candidates = [
|
|
31256
|
+
clean,
|
|
31257
|
+
`${clean}.ts`,
|
|
31258
|
+
`${clean}.tsx`,
|
|
31259
|
+
`${clean}.js`,
|
|
31260
|
+
`${clean}.jsx`,
|
|
31261
|
+
`${clean}/index.ts`,
|
|
31262
|
+
`${clean}/index.tsx`
|
|
31263
|
+
];
|
|
31264
|
+
return candidates.find((candidate) => candidate in files) ?? null;
|
|
31265
|
+
}
|
|
31266
|
+
async function preflightDraft(files) {
|
|
31267
|
+
const esbuild = await loadEsbuild();
|
|
31268
|
+
if (!esbuild) return null;
|
|
31269
|
+
const entries = ["App.tsx", "direction.ts"].filter((entry) => entry in files);
|
|
31270
|
+
if (!entries.length) return null;
|
|
31271
|
+
try {
|
|
31272
|
+
await esbuild.build({
|
|
31273
|
+
entryPoints: entries.map((entry) => `draft:${entry}`),
|
|
31274
|
+
bundle: true,
|
|
31275
|
+
write: false,
|
|
31276
|
+
// write:false never touches disk; outdir is still required for multiple entries.
|
|
31277
|
+
outdir: "preflight-virtual",
|
|
31278
|
+
platform: "browser",
|
|
31279
|
+
format: "esm",
|
|
31280
|
+
logLevel: "silent",
|
|
31281
|
+
plugins: [
|
|
31282
|
+
{
|
|
31283
|
+
name: "draft-virtual-fs",
|
|
31284
|
+
setup(build) {
|
|
31285
|
+
build.onResolve({ filter: /^draft:/ }, (args) => ({
|
|
31286
|
+
path: args.path.slice("draft:".length),
|
|
31287
|
+
namespace: "draft"
|
|
31288
|
+
}));
|
|
31289
|
+
build.onResolve({ filter: /.*/ }, (args) => {
|
|
31290
|
+
if (args.namespace !== "draft") return void 0;
|
|
31291
|
+
if (args.path.startsWith("./")) {
|
|
31292
|
+
const dir = args.importer.includes("/") ? args.importer.slice(0, args.importer.lastIndexOf("/") + 1) : "";
|
|
31293
|
+
const joined = dir + args.path.slice(2);
|
|
31294
|
+
const resolved = resolveDraftPath(files, joined);
|
|
31295
|
+
if (resolved) return { path: resolved, namespace: "draft" };
|
|
31296
|
+
return {
|
|
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
|
+
]
|
|
31316
|
+
});
|
|
31317
|
+
return null;
|
|
31318
|
+
} catch (error51) {
|
|
31319
|
+
const message = error51 instanceof Error ? error51.message : String(error51);
|
|
31320
|
+
return message;
|
|
31321
|
+
}
|
|
31322
|
+
}
|
|
31323
|
+
|
|
31013
31324
|
// src/mcp/tools.ts
|
|
31014
31325
|
var json2 = (value) => ({ text: JSON.stringify(value, null, 2) });
|
|
31015
31326
|
var fail = (text) => ({ text, isError: true });
|
|
@@ -31059,6 +31370,14 @@ async function saveDraft(ctx, draftId, apply) {
|
|
|
31059
31370
|
if (!changedCount && !change.deletedPaths.length) {
|
|
31060
31371
|
return { text: `No change \u2014 the draft already matches at revision ${draft.revision}.` };
|
|
31061
31372
|
}
|
|
31373
|
+
const merged = { ...draft.files, ...change.files };
|
|
31374
|
+
for (const deleted of change.deletedPaths) delete merged[deleted];
|
|
31375
|
+
const preflightError = await preflightDraft(merged);
|
|
31376
|
+
if (preflightError) {
|
|
31377
|
+
return fail(
|
|
31378
|
+
"Local build preflight failed \u2014 nothing was sent to the cloud, fix and save again:\n" + preflightError
|
|
31379
|
+
);
|
|
31380
|
+
}
|
|
31062
31381
|
try {
|
|
31063
31382
|
const saved = await ctx.invoke("save-draft", {
|
|
31064
31383
|
draftId,
|
|
@@ -31066,6 +31385,8 @@ async function saveDraft(ctx, draftId, apply) {
|
|
|
31066
31385
|
files: change.files,
|
|
31067
31386
|
deletedPaths: change.deletedPaths
|
|
31068
31387
|
});
|
|
31388
|
+
ctx.qualityLoopState?.finalReviewByDraft.delete(draftId);
|
|
31389
|
+
ctx.qualityLoopState?.finalMotionReviewByDraft.delete(draftId);
|
|
31069
31390
|
return reportBuildStart(
|
|
31070
31391
|
draftId,
|
|
31071
31392
|
saved,
|
|
@@ -31219,9 +31540,32 @@ var createVideo = {
|
|
|
31219
31540
|
templateParams: external_exports.record(external_exports.string(), external_exports.string()).optional().describe("Parameters for templateId, using the schema returned by motion_context."),
|
|
31220
31541
|
motionContextId: external_exports.string().optional().describe(
|
|
31221
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."
|
|
31222
31552
|
)
|
|
31223
31553
|
},
|
|
31224
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
|
+
}
|
|
31568
|
+
}
|
|
31225
31569
|
let brand = null;
|
|
31226
31570
|
if (args.brandSlug) {
|
|
31227
31571
|
const result = await ctx.invoke("brand-kits", {
|
|
@@ -31246,6 +31590,24 @@ var createVideo = {
|
|
|
31246
31590
|
...args.motionContextId ? { motionContextId: args.motionContextId } : {}
|
|
31247
31591
|
});
|
|
31248
31592
|
const draft = prepared.drafts[0];
|
|
31593
|
+
if (ctx.phasedFlow && draft?.id && args.content) {
|
|
31594
|
+
const parsedContent = contentSchema.safeParse(args.content);
|
|
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
|
+
}
|
|
31610
|
+
}
|
|
31249
31611
|
return json2({
|
|
31250
31612
|
projectId: prepared.projectId,
|
|
31251
31613
|
draftId: draft?.id,
|
|
@@ -31254,7 +31616,14 @@ var createVideo = {
|
|
|
31254
31616
|
libraryVersion: prepared.libraryVersion,
|
|
31255
31617
|
engineVersion: prepared.engineVersion,
|
|
31256
31618
|
studioUrl: studioLink(ctx, prepared.projectId, draft?.slug),
|
|
31257
|
-
|
|
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."
|
|
31258
31627
|
});
|
|
31259
31628
|
}
|
|
31260
31629
|
};
|
|
@@ -31421,9 +31790,25 @@ var exportVideo = {
|
|
|
31421
31790
|
title: "Export the final MP4",
|
|
31422
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.",
|
|
31423
31792
|
inputSchema: {
|
|
31424
|
-
draftId: external_exports.string().describe("The draft id.")
|
|
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
|
+
)
|
|
31425
31797
|
},
|
|
31426
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
|
+
}
|
|
31427
31812
|
const started = await ctx.invoke("start-export", { draftId: args.draftId });
|
|
31428
31813
|
return json2({
|
|
31429
31814
|
started: true,
|
|
@@ -31614,6 +31999,51 @@ Underlying error: ${error51 instanceof Error ? error51.message : String(error51)
|
|
|
31614
31999
|
}
|
|
31615
32000
|
}
|
|
31616
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
|
+
}
|
|
31617
32047
|
async function captureReferenceFrames(input) {
|
|
31618
32048
|
const browser = await launchChromium();
|
|
31619
32049
|
try {
|
|
@@ -31639,14 +32069,7 @@ async function captureReferenceFrames(input) {
|
|
|
31639
32069
|
);
|
|
31640
32070
|
const images = [];
|
|
31641
32071
|
for (const time3 of times) {
|
|
31642
|
-
await page
|
|
31643
|
-
(seconds) => new Promise((resolve) => {
|
|
31644
|
-
;
|
|
31645
|
-
window.__seek(seconds);
|
|
31646
|
-
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
31647
|
-
}),
|
|
31648
|
-
time3
|
|
31649
|
-
);
|
|
32072
|
+
await renderSettledFrame(page, time3);
|
|
31650
32073
|
const frame = await page.screenshot({ type: FRAME_TYPE, quality: FRAME_QUALITY });
|
|
31651
32074
|
images.push({ base64: frame.toString("base64"), mimeType: FRAME_MIME });
|
|
31652
32075
|
}
|
|
@@ -31683,8 +32106,8 @@ async function resolvePreview(ctx, draftId) {
|
|
|
31683
32106
|
artifactRevision: preview.source_revision
|
|
31684
32107
|
};
|
|
31685
32108
|
}
|
|
31686
|
-
async function
|
|
31687
|
-
const preview = await resolvePreview(ctx,
|
|
32109
|
+
async function openPreview(ctx, draftId) {
|
|
32110
|
+
const preview = await resolvePreview(ctx, draftId);
|
|
31688
32111
|
const warnings = [];
|
|
31689
32112
|
if (preview.artifactRevision !== preview.revision) {
|
|
31690
32113
|
warnings.push(
|
|
@@ -31723,6 +32146,7 @@ async function captureFrames(ctx, input) {
|
|
|
31723
32146
|
await page.setViewportSize({ width: config2.width, height: config2.height });
|
|
31724
32147
|
}
|
|
31725
32148
|
const duration3 = Number(await page.evaluate(() => window.__duration)) || 0;
|
|
32149
|
+
const fps = Number(config2?.fps) || 30;
|
|
31726
32150
|
const fontsFailed = await page.evaluate(async () => {
|
|
31727
32151
|
await document.fonts.ready;
|
|
31728
32152
|
return [...document.fonts].some((font) => font.status === "error");
|
|
@@ -31732,41 +32156,26 @@ async function captureFrames(ctx, input) {
|
|
|
31732
32156
|
"Some web fonts failed to load, so typography in these frames is not final. Frame capture needs network access to fonts.googleapis.com."
|
|
31733
32157
|
);
|
|
31734
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 {
|
|
31735
32169
|
const images = [];
|
|
31736
32170
|
for (const time3 of input.times) {
|
|
31737
32171
|
const clamped = duration3 > 0 ? Math.max(0, Math.min(time3, duration3)) : Math.max(0, time3);
|
|
31738
|
-
await page
|
|
31739
|
-
(seconds) => new Promise((resolve) => {
|
|
31740
|
-
;
|
|
31741
|
-
window.__seek(seconds);
|
|
31742
|
-
const finish = () => requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
31743
|
-
const pending = [...document.querySelectorAll("video")].filter(
|
|
31744
|
-
(video) => video.currentSrc && !video.error && video.seeking
|
|
31745
|
-
);
|
|
31746
|
-
if (!pending.length) return finish();
|
|
31747
|
-
let remaining = pending.length;
|
|
31748
|
-
const cap = setTimeout(finish, 1e3);
|
|
31749
|
-
for (const video of pending) {
|
|
31750
|
-
video.addEventListener(
|
|
31751
|
-
"seeked",
|
|
31752
|
-
() => {
|
|
31753
|
-
if (--remaining === 0) {
|
|
31754
|
-
clearTimeout(cap);
|
|
31755
|
-
finish();
|
|
31756
|
-
}
|
|
31757
|
-
},
|
|
31758
|
-
{ once: true }
|
|
31759
|
-
);
|
|
31760
|
-
}
|
|
31761
|
-
}),
|
|
31762
|
-
clamped
|
|
31763
|
-
);
|
|
32172
|
+
await renderSettledFrame(page, clamped);
|
|
31764
32173
|
const frame = await page.screenshot({ type: FRAME_TYPE, quality: FRAME_QUALITY });
|
|
31765
32174
|
images.push({ base64: frame.toString("base64"), mimeType: FRAME_MIME });
|
|
31766
32175
|
}
|
|
31767
|
-
return { images, warnings, revision
|
|
32176
|
+
return { images, warnings, revision, duration: duration3 };
|
|
31768
32177
|
} finally {
|
|
31769
|
-
await browser.close().catch(() => void 0);
|
|
32178
|
+
await session.browser.close().catch(() => void 0);
|
|
31770
32179
|
}
|
|
31771
32180
|
}
|
|
31772
32181
|
var getFramesTool = {
|
|
@@ -31775,7 +32184,10 @@ var getFramesTool = {
|
|
|
31775
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.",
|
|
31776
32185
|
inputSchema: {
|
|
31777
32186
|
draftId: external_exports.string().describe("The draft id."),
|
|
31778
|
-
times: external_exports.array(external_exports.number().min(0)).min(1).max(12).describe("Timeline positions in seconds, e.g. [0, 1.5, 3].")
|
|
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
|
+
)
|
|
31779
32191
|
},
|
|
31780
32192
|
local: true,
|
|
31781
32193
|
handler: async (args, ctx) => {
|
|
@@ -31787,127 +32199,2251 @@ var getFramesTool = {
|
|
|
31787
32199
|
`Captured ${capture.images.length} frame(s) at revision ${capture.revision} (scene duration ${capture.duration.toFixed(2)}s).`,
|
|
31788
32200
|
...capture.warnings.map((warning) => `WARNING: ${warning}`)
|
|
31789
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
|
+
}
|
|
31790
32234
|
return { text: lines.join("\n"), images: capture.images };
|
|
31791
32235
|
}
|
|
31792
32236
|
};
|
|
31793
32237
|
|
|
31794
|
-
// src/mcp/
|
|
31795
|
-
var
|
|
31796
|
-
|
|
31797
|
-
|
|
31798
|
-
|
|
32238
|
+
// src/mcp/metric-catalog.ts
|
|
32239
|
+
var CATALOG_VERSION = "inspect-video-metrics/catalog-v1";
|
|
32240
|
+
var STATIC_THRESHOLD = 5e-3;
|
|
32241
|
+
var ACTIVE_THRESHOLD = 0.01;
|
|
32242
|
+
var ANALYSIS_WIDTH = 192;
|
|
32243
|
+
var FINAL_DISTINCT_THRESHOLD = 0.03;
|
|
32244
|
+
function metricCatalogEnabled(env = process.env) {
|
|
32245
|
+
return !/^(0|false|off|no)$/i.test(env.IMPRACTICAL_METRIC_CATALOG?.trim() ?? "");
|
|
32246
|
+
}
|
|
32247
|
+
var mean = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length;
|
|
32248
|
+
function percentile(xs, p) {
|
|
32249
|
+
const sorted = [...xs].sort((a, b) => a - b);
|
|
32250
|
+
const pos = (sorted.length - 1) * p;
|
|
32251
|
+
const lo = Math.floor(pos);
|
|
32252
|
+
const hi = Math.ceil(pos);
|
|
32253
|
+
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
|
|
32254
|
+
}
|
|
32255
|
+
var median = (xs) => percentile(xs, 0.5);
|
|
32256
|
+
var mad = (xs) => {
|
|
32257
|
+
const m = median(xs);
|
|
32258
|
+
return median(xs.map((x) => Math.abs(x - m)));
|
|
32259
|
+
};
|
|
32260
|
+
function runs(xs, holds) {
|
|
32261
|
+
const out = [];
|
|
32262
|
+
let current = 0;
|
|
32263
|
+
for (const x of xs) {
|
|
32264
|
+
if (holds(x)) current += 1;
|
|
32265
|
+
else if (current) {
|
|
32266
|
+
out.push(current);
|
|
32267
|
+
current = 0;
|
|
32268
|
+
}
|
|
32269
|
+
}
|
|
32270
|
+
if (current) out.push(current);
|
|
32271
|
+
return out;
|
|
32272
|
+
}
|
|
32273
|
+
function peakIndices(m) {
|
|
32274
|
+
if (m.length < 3) return [];
|
|
32275
|
+
const threshold = median(m) + 1.5 * mad(m);
|
|
32276
|
+
const out = [];
|
|
32277
|
+
for (let i = 1; i < m.length - 1; i += 1) {
|
|
32278
|
+
if (m[i] > m[i - 1] && m[i] >= m[i + 1] && m[i] > threshold) out.push(i);
|
|
32279
|
+
}
|
|
32280
|
+
return out;
|
|
32281
|
+
}
|
|
32282
|
+
var UNIMPLEMENTED = [
|
|
32283
|
+
{ name: "edge_density_median", label: "Structural edge density", unit: "fraction of pixels" },
|
|
32284
|
+
{ name: "edge_density_iqr", label: "Structural density variation", unit: "fraction of pixels" },
|
|
32285
|
+
{ name: "text_like_occupancy_median", label: "Text-like screen occupancy", unit: "fraction of pixels" },
|
|
32286
|
+
{ name: "text_like_border_contact_share", label: "Text-like border contact", unit: "fraction of components" },
|
|
32287
|
+
{ name: "text_like_safe_margin_p05", label: "Text-like safe margin", unit: "fraction of shorter dimension" },
|
|
32288
|
+
{ name: "composition_balance_mean", label: "High-frequency composition balance", unit: "[0,1] proxy" },
|
|
32289
|
+
{ name: "saliency_center_offset_mean", label: "Saliency-center offset", unit: "fraction of frame diagonal" },
|
|
32290
|
+
{ name: "saliency_center_speed_mean_per_s", label: "Saliency-center travel speed", unit: "diagonal fractions per second" },
|
|
32291
|
+
{ name: "global_translation_mean_frame_fraction_per_s", label: "Mean global translation speed", unit: "frame-width fractions per second" },
|
|
32292
|
+
{ name: "global_translation_p90_frame_fraction_per_s", label: "High global translation speed", unit: "frame-width fractions per second" },
|
|
32293
|
+
{ name: "global_translation_response_median", label: "Global-translation fit response", unit: "phase-correlation response" },
|
|
32294
|
+
{ name: "residual_motion_ratio_median", label: "Residual motion after translation", unit: "ratio" },
|
|
32295
|
+
{ name: "motion_edge_alignment_median", label: "Motion-boundary edge alignment", unit: "fraction of boundary pixels" },
|
|
32296
|
+
{ name: "edge_turnover_mean", label: "Structural edge turnover", unit: "Jaccard distance" },
|
|
32297
|
+
{ name: "layer_turnover_event_rate_per_minute", label: "Layer-turnover proxy rate", unit: "events per minute" }
|
|
32298
|
+
];
|
|
32299
|
+
function computeCatalogMetrics(input) {
|
|
32300
|
+
const { m, cells, centroids, width, height, finalStateDistinctiveness } = input.features;
|
|
32301
|
+
const dt = Math.max(input.dtSeconds, 1e-6);
|
|
32302
|
+
const duration3 = Math.max(input.durationSeconds, 1e-6);
|
|
32303
|
+
const minutes = Math.max(duration3 / 60, 1e-9);
|
|
32304
|
+
const diagonal = Math.hypot(width, height);
|
|
32305
|
+
const provisional = (name, label, unit, value) => ({
|
|
32306
|
+
name,
|
|
32307
|
+
label,
|
|
32308
|
+
unit,
|
|
32309
|
+
value,
|
|
32310
|
+
status: "PROVISIONAL"
|
|
31799
32311
|
});
|
|
31800
|
-
const
|
|
31801
|
-
const
|
|
31802
|
-
const
|
|
31803
|
-
|
|
31804
|
-
|
|
32312
|
+
const accel = m.length >= 2 ? mean(Array.from({ length: m.length - 1 }, (_, i) => Math.abs((m[i + 1] - m[i]) / dt))) : 0;
|
|
32313
|
+
const jerk = m.length >= 3 ? mean(Array.from({ length: m.length - 2 }, (_, i) => Math.abs((m[i + 2] - 2 * m[i + 1] + m[i]) / (dt * dt)))) : 0;
|
|
32314
|
+
const staticRuns = runs(m, (x) => x <= STATIC_THRESHOLD);
|
|
32315
|
+
const peaks = peakIndices(m);
|
|
32316
|
+
const peakGaps = peaks.slice(1).map((p, i) => (p - peaks[i]) * dt);
|
|
32317
|
+
const gapCv = peakGaps.length >= 2 ? Math.sqrt(mean(peakGaps.map((g) => (g - mean(peakGaps)) ** 2))) / mean(peakGaps) : 0;
|
|
32318
|
+
const second = m.length >= 3 ? Array.from({ length: m.length - 2 }, (_, i) => Math.abs(m[i + 2] - 2 * m[i + 1] + m[i])) : [];
|
|
32319
|
+
const smoothness = second.length ? 1 / (1 + percentile(second, 0.9) / (median(m) + 1e-6)) : 1;
|
|
32320
|
+
const cutThreshold = Math.max(0.08, median(m) + 4 * mad(m));
|
|
32321
|
+
const hardCuts = m.filter((x) => x > cutThreshold).length;
|
|
32322
|
+
const LOG16 = Math.log(16);
|
|
32323
|
+
const perInterval = cells.map((cell) => {
|
|
32324
|
+
const total = cell.reduce((s, x) => s + x, 0);
|
|
32325
|
+
if (total <= 0) return { concentration: 0, largest: 0 };
|
|
32326
|
+
const shares = cell.map((x) => x / total);
|
|
32327
|
+
const entropy = -shares.reduce((s, p) => p > 0 ? s + p * Math.log(p) : s, 0);
|
|
32328
|
+
return { concentration: 1 - entropy / LOG16, largest: Math.max(...shares) };
|
|
32329
|
+
});
|
|
32330
|
+
const centroidOffsets = centroids.map(
|
|
32331
|
+
(c) => Math.hypot(c.x - width / 2, c.y - height / 2) / diagonal
|
|
31805
32332
|
);
|
|
31806
|
-
const
|
|
31807
|
-
|
|
31808
|
-
|
|
31809
|
-
|
|
31810
|
-
|
|
31811
|
-
|
|
31812
|
-
|
|
31813
|
-
|
|
31814
|
-
|
|
31815
|
-
|
|
31816
|
-
|
|
31817
|
-
|
|
31818
|
-
|
|
31819
|
-
|
|
31820
|
-
|
|
31821
|
-
|
|
31822
|
-
|
|
31823
|
-
|
|
31824
|
-
|
|
31825
|
-
|
|
31826
|
-
|
|
31827
|
-
|
|
31828
|
-
|
|
31829
|
-
|
|
31830
|
-
|
|
31831
|
-
|
|
31832
|
-
|
|
31833
|
-
|
|
32333
|
+
const centroidSpeeds = centroids.slice(1).map(
|
|
32334
|
+
(c, i) => Math.hypot(c.x - centroids[i].x, c.y - centroids[i].y) / diagonal / dt
|
|
32335
|
+
);
|
|
32336
|
+
let trailing = 0;
|
|
32337
|
+
for (let i = m.length - 1; i >= 0 && m[i] <= STATIC_THRESHOLD; i -= 1) trailing += 1;
|
|
32338
|
+
const terminalShare = trailing * dt / duration3;
|
|
32339
|
+
const peakIndex = m.indexOf(Math.max(...m));
|
|
32340
|
+
const postPeakRuns = runs(m.slice(peakIndex + 1), (x) => x <= STATIC_THRESHOLD);
|
|
32341
|
+
const implemented = [
|
|
32342
|
+
provisional("motion_magnitude_mean", "Mean frame-to-frame motion", "normalized change per interval", mean(m)),
|
|
32343
|
+
provisional("motion_magnitude_median", "Median frame-to-frame motion", "normalized change per interval", median(m)),
|
|
32344
|
+
provisional("motion_magnitude_p90", "High-motion magnitude", "normalized change per interval", percentile(m, 0.9)),
|
|
32345
|
+
provisional("motion_acceleration_abs_mean_per_s", "Mean absolute motion acceleration", "change per second", accel),
|
|
32346
|
+
provisional("motion_jerk_abs_mean_per_s2", "Mean absolute motion jerk", "change per second squared", jerk),
|
|
32347
|
+
provisional("static_exposure_share", "Static exposure", "fraction of intervals", mean(m.map((x) => x <= STATIC_THRESHOLD ? 1 : 0))),
|
|
32348
|
+
provisional("active_interval_share", "Active-motion exposure", "fraction of intervals", mean(m.map((x) => x >= ACTIVE_THRESHOLD ? 1 : 0))),
|
|
32349
|
+
provisional("longest_static_hold_share", "Longest static hold", "fraction of duration", staticRuns.length ? Math.max(...staticRuns) * dt / duration3 : 0),
|
|
32350
|
+
provisional("motion_peak_rate_per_minute", "Motion peak rate", "peaks per minute", peaks.length / minutes),
|
|
32351
|
+
provisional("cadence_gap_cv", "Cadence-gap variability", "dimensionless", gapCv),
|
|
32352
|
+
provisional("transition_smoothness_index", "Transition smoothness proxy", "[0,1] proxy", smoothness),
|
|
32353
|
+
provisional("hard_cut_rate_per_minute", "Hard-cut proxy rate", "events per minute", hardCuts / minutes),
|
|
32354
|
+
provisional("motion_concentration_median", "Spatial motion concentration", "[0,1] entropy concentration", median(perInterval.map((c) => c.concentration))),
|
|
32355
|
+
provisional("largest_motion_cell_share_median", "Largest local motion share", "fraction of energy", median(perInterval.map((c) => c.largest))),
|
|
32356
|
+
provisional("motion_centroid_offset_mean", "Motion-center offset", "fraction of diagonal", mean(centroidOffsets)),
|
|
32357
|
+
provisional("motion_centroid_speed_mean_per_s", "Motion-center travel speed", "diagonal fractions per second", centroidSpeeds.length ? mean(centroidSpeeds) : 0)
|
|
32358
|
+
];
|
|
32359
|
+
const tail = [
|
|
32360
|
+
provisional("terminal_static_hold_share", "Terminal static hold", "fraction of duration", terminalShare),
|
|
32361
|
+
provisional("post_peak_longest_static_hold_share", "Longest hold after maximum motion", "fraction of duration", postPeakRuns.length ? Math.max(...postPeakRuns) * dt / duration3 : 0),
|
|
32362
|
+
provisional("final_state_distinctiveness", "Final-state visual distinctiveness", "mean absolute change", finalStateDistinctiveness),
|
|
32363
|
+
provisional("proof_hold_proxy_share", "Distinct final hold proxy", "fraction of duration", finalStateDistinctiveness >= FINAL_DISTINCT_THRESHOLD ? terminalShare : 0)
|
|
32364
|
+
];
|
|
32365
|
+
const nulls = UNIMPLEMENTED.map((metric) => ({
|
|
32366
|
+
...metric,
|
|
32367
|
+
value: null,
|
|
32368
|
+
status: "NULL",
|
|
32369
|
+
nullReason: "MISSING_INPUT"
|
|
32370
|
+
}));
|
|
32371
|
+
return [...implemented, ...nulls, ...tail];
|
|
31834
32372
|
}
|
|
31835
|
-
|
|
31836
|
-
|
|
31837
|
-
|
|
31838
|
-
|
|
31839
|
-
|
|
31840
|
-
|
|
31841
|
-
}
|
|
31842
|
-
|
|
31843
|
-
|
|
31844
|
-
|
|
31845
|
-
|
|
31846
|
-
|
|
31847
|
-
|
|
31848
|
-
|
|
31849
|
-
|
|
31850
|
-
|
|
31851
|
-
|
|
31852
|
-
|
|
31853
|
-
|
|
31854
|
-
|
|
31855
|
-
|
|
31856
|
-
|
|
31857
|
-
|
|
31858
|
-
|
|
31859
|
-
|
|
32373
|
+
function formatCatalogReport(records) {
|
|
32374
|
+
const lines = [];
|
|
32375
|
+
lines.push(`CANONICAL METRICS (${CATALOG_VERSION}, preview stage \u2014 all values PROVISIONAL):`);
|
|
32376
|
+
for (const record2 of records) {
|
|
32377
|
+
if (record2.value === null) continue;
|
|
32378
|
+
const value = Math.abs(record2.value) >= 100 ? record2.value.toFixed(1) : record2.value.toFixed(4);
|
|
32379
|
+
lines.push(` ${record2.name} = ${value} (${record2.label}; ${record2.unit})`);
|
|
32380
|
+
}
|
|
32381
|
+
const missing = records.filter((record2) => record2.value === null);
|
|
32382
|
+
lines.push(
|
|
32383
|
+
` ${missing.length} metrics null MISSING_INPUT (edge/text/saliency/translation families not ported yet).`
|
|
32384
|
+
);
|
|
32385
|
+
lines.push(
|
|
32386
|
+
" These are descriptive proxies, not grades \u2014 never optimize them directly; use them to find where to look."
|
|
32387
|
+
);
|
|
32388
|
+
return lines.join("\n");
|
|
32389
|
+
}
|
|
32390
|
+
async function sampleCatalogFeatures(page, frames) {
|
|
32391
|
+
return page.evaluate(
|
|
32392
|
+
async (input) => {
|
|
32393
|
+
const imgs = [];
|
|
32394
|
+
for (const [index, base643] of input.frames.entries()) {
|
|
32395
|
+
const img = new Image();
|
|
32396
|
+
await new Promise((resolve, reject) => {
|
|
32397
|
+
img.onload = () => resolve();
|
|
32398
|
+
img.onerror = () => reject(new Error(`catalog frame ${index} failed to load`));
|
|
32399
|
+
img.src = "data:image/jpeg;base64," + base643;
|
|
32400
|
+
});
|
|
32401
|
+
imgs.push(img);
|
|
32402
|
+
}
|
|
32403
|
+
const w = input.analysisWidth;
|
|
32404
|
+
const h = Math.max(1, Math.round(w * imgs[0].naturalHeight / Math.max(1, imgs[0].naturalWidth)));
|
|
32405
|
+
const canvas = document.createElement("canvas");
|
|
32406
|
+
canvas.width = w;
|
|
32407
|
+
canvas.height = h;
|
|
32408
|
+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
32409
|
+
if (!ctx) throw new Error("2d canvas unavailable");
|
|
32410
|
+
const grays = [];
|
|
32411
|
+
for (const img of imgs) {
|
|
32412
|
+
ctx.drawImage(img, 0, 0, w, h);
|
|
32413
|
+
const data = ctx.getImageData(0, 0, w, h).data;
|
|
32414
|
+
const gray = new Float32Array(w * h);
|
|
32415
|
+
for (let i = 0; i < gray.length; i += 1) {
|
|
32416
|
+
const o = i * 4;
|
|
32417
|
+
gray[i] = (0.299 * data[o] + 0.587 * data[o + 1] + 0.114 * data[o + 2]) / 255;
|
|
32418
|
+
}
|
|
32419
|
+
grays.push(gray);
|
|
32420
|
+
}
|
|
32421
|
+
const m = [];
|
|
32422
|
+
const cells = [];
|
|
32423
|
+
const centroids = [];
|
|
32424
|
+
for (let f = 1; f < grays.length; f += 1) {
|
|
32425
|
+
const a = grays[f - 1];
|
|
32426
|
+
const b = grays[f];
|
|
32427
|
+
let sum = 0;
|
|
32428
|
+
const cell = new Array(16).fill(0);
|
|
32429
|
+
let cx = 0;
|
|
32430
|
+
let cy = 0;
|
|
32431
|
+
for (let y = 0; y < h; y += 1) {
|
|
32432
|
+
const row = Math.min(3, Math.floor(y * 4 / h)) * 4;
|
|
32433
|
+
for (let x = 0; x < w; x += 1) {
|
|
32434
|
+
const d = Math.abs(b[y * w + x] - a[y * w + x]);
|
|
32435
|
+
sum += d;
|
|
32436
|
+
cell[row + Math.min(3, Math.floor(x * 4 / w))] += d;
|
|
32437
|
+
cx += d * x;
|
|
32438
|
+
cy += d * y;
|
|
32439
|
+
}
|
|
32440
|
+
}
|
|
32441
|
+
m.push(sum / (w * h));
|
|
32442
|
+
cells.push(cell);
|
|
32443
|
+
centroids.push(sum > 0 ? { x: cx / sum, y: cy / sum } : { x: w / 2, y: h / 2 });
|
|
31860
32444
|
}
|
|
31861
|
-
|
|
31862
|
-
|
|
32445
|
+
const avgOf = (list) => {
|
|
32446
|
+
const out = new Float32Array(w * h);
|
|
32447
|
+
for (const gray of list) for (let i = 0; i < out.length; i += 1) out[i] += gray[i] / list.length;
|
|
32448
|
+
return out;
|
|
32449
|
+
};
|
|
32450
|
+
const first = avgOf(grays.slice(0, Math.min(3, grays.length)));
|
|
32451
|
+
const last = avgOf(grays.slice(-Math.min(3, grays.length)));
|
|
32452
|
+
let distinct = 0;
|
|
32453
|
+
for (let i = 0; i < first.length; i += 1) distinct += Math.abs(last[i] - first[i]);
|
|
31863
32454
|
return {
|
|
31864
|
-
|
|
31865
|
-
|
|
32455
|
+
m,
|
|
32456
|
+
cells,
|
|
32457
|
+
centroids,
|
|
32458
|
+
width: w,
|
|
32459
|
+
height: h,
|
|
32460
|
+
finalStateDistinctiveness: distinct / (w * h)
|
|
31866
32461
|
};
|
|
32462
|
+
},
|
|
32463
|
+
{ frames: frames.map((frame) => frame.base64), analysisWidth: ANALYSIS_WIDTH }
|
|
32464
|
+
);
|
|
32465
|
+
}
|
|
32466
|
+
|
|
32467
|
+
// src/mcp/text-census.ts
|
|
32468
|
+
function textCensusEnabled(env = process.env) {
|
|
32469
|
+
return !/^(0|false|off|no)$/i.test(env.IMPRACTICAL_TEXT_CENSUS?.trim() ?? "");
|
|
32470
|
+
}
|
|
32471
|
+
var READING_TARGET_MIN_PX = 30;
|
|
32472
|
+
var CANDIDATE_MIN_PX = 11;
|
|
32473
|
+
var isReadingTarget = (block) => block.fontSize >= READING_TARGET_MIN_PX && block.text.trim().length >= 3;
|
|
32474
|
+
function eyebrowPairs(blocks) {
|
|
32475
|
+
const out = [];
|
|
32476
|
+
for (const small of blocks) {
|
|
32477
|
+
if (small.fontSize < CANDIDATE_MIN_PX) continue;
|
|
32478
|
+
for (const large of blocks) {
|
|
32479
|
+
if (large === small) continue;
|
|
32480
|
+
if (large.fontSize < READING_TARGET_MIN_PX) continue;
|
|
32481
|
+
if (small.fontSize > large.fontSize * 0.55) continue;
|
|
32482
|
+
const gap = large.y - (small.y + small.height);
|
|
32483
|
+
if (gap < -small.height * 0.5 || gap > small.height * 2) continue;
|
|
32484
|
+
const overlap = Math.min(small.x + small.width, large.x + large.width) - Math.max(small.x, large.x);
|
|
32485
|
+
if (overlap < Math.min(small.width, large.width) * 0.3) continue;
|
|
32486
|
+
out.push({ small, large });
|
|
32487
|
+
}
|
|
32488
|
+
}
|
|
32489
|
+
return out;
|
|
32490
|
+
}
|
|
32491
|
+
function spans(samples, holds) {
|
|
32492
|
+
const out = [];
|
|
32493
|
+
let open = null;
|
|
32494
|
+
for (const sample of samples) {
|
|
32495
|
+
if (holds(sample)) {
|
|
32496
|
+
if (open) open.end = sample.time;
|
|
32497
|
+
else open = { start: sample.time, end: sample.time, example: sample };
|
|
32498
|
+
} else if (open) {
|
|
32499
|
+
out.push(open);
|
|
32500
|
+
open = null;
|
|
32501
|
+
}
|
|
32502
|
+
}
|
|
32503
|
+
if (open) out.push(open);
|
|
32504
|
+
return out;
|
|
32505
|
+
}
|
|
32506
|
+
var snippet = (block) => `"${block.text.trim().slice(0, 30)}${block.text.trim().length > 30 ? "\u2026" : ""}"`;
|
|
32507
|
+
function textCensusReport(rawSamples) {
|
|
32508
|
+
if (!rawSamples.length) return "";
|
|
32509
|
+
const samples = rawSamples.map((sample) => ({
|
|
32510
|
+
time: sample.time,
|
|
32511
|
+
blocks: sample.blocks.filter((block) => block.inViewport)
|
|
32512
|
+
}));
|
|
32513
|
+
const lines = [];
|
|
32514
|
+
const maxTargets = Math.max(...samples.map((s) => s.blocks.filter(isReadingTarget).length));
|
|
32515
|
+
lines.push(
|
|
32516
|
+
`TEXT CENSUS (visible text \u2265${READING_TARGET_MIN_PX}px counts as a reading target; proxy, not OCR):`
|
|
32517
|
+
);
|
|
32518
|
+
lines.push(` max simultaneous reading targets: ${maxTargets}`);
|
|
32519
|
+
const multi = spans(samples, (s) => s.blocks.filter(isReadingTarget).length >= 2);
|
|
32520
|
+
if (multi.length) {
|
|
32521
|
+
lines.push(" \u22652 simultaneous reading targets \u2014 the viewer has more than one thing to read:");
|
|
32522
|
+
for (const span of multi) {
|
|
32523
|
+
const targets = span.example.blocks.filter(isReadingTarget);
|
|
32524
|
+
lines.push(
|
|
32525
|
+
` ${span.start.toFixed(2)}\u2013${span.end.toFixed(2)}s: ` + targets.slice(0, 3).map(snippet).join(" + ")
|
|
32526
|
+
);
|
|
31867
32527
|
}
|
|
31868
|
-
|
|
31869
|
-
|
|
31870
|
-
|
|
32528
|
+
}
|
|
32529
|
+
const eyebrow = spans(samples, (s) => eyebrowPairs(s.blocks).length > 0);
|
|
32530
|
+
if (eyebrow.length) {
|
|
32531
|
+
lines.push(" eyebrow/subline stacks (small text riding a larger block):");
|
|
32532
|
+
for (const span of eyebrow) {
|
|
32533
|
+
const pair = eyebrowPairs(span.example.blocks)[0];
|
|
32534
|
+
lines.push(
|
|
32535
|
+
` ${span.start.toFixed(2)}\u2013${span.end.toFixed(2)}s: ${snippet(pair.small)} stacked on ${snippet(pair.large)}`
|
|
31871
32536
|
);
|
|
31872
|
-
return {
|
|
31873
|
-
text: `Connected to workspace ${snapshot.orgId}` + (snapshot.hasAccess ? "." : " \u2014 but it has no active access; check billing in the web app.")
|
|
31874
|
-
};
|
|
31875
|
-
} catch (error51) {
|
|
31876
|
-
const message = error51 instanceof Error ? error51.message : String(error51);
|
|
31877
|
-
return {
|
|
31878
|
-
text: `Credential saved, but the workspace check failed: ${message}`,
|
|
31879
|
-
isError: true
|
|
31880
|
-
};
|
|
31881
32537
|
}
|
|
31882
32538
|
}
|
|
31883
|
-
|
|
31884
|
-
|
|
31885
|
-
|
|
31886
|
-
|
|
31887
|
-
|
|
31888
|
-
|
|
31889
|
-
|
|
31890
|
-
|
|
31891
|
-
|
|
31892
|
-
|
|
31893
|
-
|
|
31894
|
-
|
|
31895
|
-
|
|
31896
|
-
|
|
31897
|
-
|
|
31898
|
-
|
|
31899
|
-
|
|
31900
|
-
|
|
32539
|
+
if (!multi.length && !eyebrow.length) {
|
|
32540
|
+
lines.push(" clean: one reading target at a time, no eyebrow stacks.");
|
|
32541
|
+
} else {
|
|
32542
|
+
lines.push(
|
|
32543
|
+
" One reading target at a time. Text either performs (kinetic emphasis in the type) or states a claim the very next moment proves on screen \u2014 never a caption running beside something else."
|
|
32544
|
+
);
|
|
32545
|
+
}
|
|
32546
|
+
return lines.join("\n");
|
|
32547
|
+
}
|
|
32548
|
+
async function sampleTextBlocks(page, minFontPx) {
|
|
32549
|
+
return page.evaluate((minPx) => {
|
|
32550
|
+
const out = [];
|
|
32551
|
+
const viewportWidth = window.innerWidth;
|
|
32552
|
+
const viewportHeight = window.innerHeight;
|
|
32553
|
+
for (const el of Array.from(document.body.querySelectorAll("*"))) {
|
|
32554
|
+
const direct = Array.from(el.childNodes).filter((node) => node.nodeType === Node.TEXT_NODE).map((node) => node.textContent ?? "").join("").trim();
|
|
32555
|
+
if (!direct) continue;
|
|
32556
|
+
const style = getComputedStyle(el);
|
|
32557
|
+
const fontSize = parseFloat(style.fontSize);
|
|
32558
|
+
if (!Number.isFinite(fontSize) || fontSize < minPx) continue;
|
|
32559
|
+
if (style.visibility === "hidden" || style.display === "none") continue;
|
|
32560
|
+
if (parseFloat(style.opacity) < 0.05) continue;
|
|
32561
|
+
const rect = el.getBoundingClientRect();
|
|
32562
|
+
if (rect.width < 2 || rect.height < 2) continue;
|
|
32563
|
+
const inViewport = !(rect.bottom < 0 || rect.right < 0 || rect.top > viewportHeight || rect.left > viewportWidth);
|
|
32564
|
+
const margin = 0.02 * Math.min(viewportWidth, viewportHeight);
|
|
32565
|
+
const fullyInFrame = rect.left >= margin && rect.top >= margin && rect.right <= viewportWidth - margin && rect.bottom <= viewportHeight - margin;
|
|
32566
|
+
let selector;
|
|
32567
|
+
for (const attr of Array.from(el.attributes)) {
|
|
32568
|
+
if (attr.name.startsWith("data-") && !attr.name.startsWith("data-studio-")) {
|
|
32569
|
+
selector = "[" + attr.name + "]";
|
|
32570
|
+
break;
|
|
32571
|
+
}
|
|
32572
|
+
}
|
|
32573
|
+
out.push({
|
|
32574
|
+
text: direct.slice(0, 40),
|
|
32575
|
+
fontSize,
|
|
32576
|
+
x: rect.x,
|
|
32577
|
+
y: rect.y,
|
|
32578
|
+
width: rect.width,
|
|
32579
|
+
height: rect.height,
|
|
32580
|
+
inViewport,
|
|
32581
|
+
fullyInFrame,
|
|
32582
|
+
selector
|
|
32583
|
+
});
|
|
32584
|
+
}
|
|
32585
|
+
return out;
|
|
32586
|
+
}, minFontPx);
|
|
32587
|
+
}
|
|
32588
|
+
function textContractEnabled(env = process.env) {
|
|
32589
|
+
return !/^(0|false|off|no)$/i.test(env.IMPRACTICAL_TEXT_CONTRACT?.trim() ?? "");
|
|
32590
|
+
}
|
|
32591
|
+
function inspectV2Enabled(env = process.env) {
|
|
32592
|
+
return !/^(0|false|off|no)$/i.test(env.IMPRACTICAL_INSPECT_V2?.trim() ?? "");
|
|
32593
|
+
}
|
|
32594
|
+
var STATEMENT_MIN_FRACTION = 0.1;
|
|
32595
|
+
var PROOF_TEXT_MIN_FRACTION = 0.025;
|
|
32596
|
+
var EDITORIAL_MIN_FONT_PX = 24;
|
|
32597
|
+
var WITNESS_SCALE_FRACTION = 0.04;
|
|
32598
|
+
var MIN_WITNESS_SECONDS = 0.9;
|
|
32599
|
+
var STATEMENT_HOLD_SECONDS = 1.2;
|
|
32600
|
+
var RIDING_DIFF_THRESHOLD = 0.05;
|
|
32601
|
+
var normalize = (text) => text.trim().replace(/\s+/g, " ");
|
|
32602
|
+
var settledBetween = (a, b, frameDiagonal) => Math.hypot(a.x + a.width / 2 - (b.x + b.width / 2), a.y + a.height / 2 - (b.y + b.height / 2)) < frameDiagonal * 0.01;
|
|
32603
|
+
var ICON_GLYPHS = /^[\s\u2190-\u21FF\u27F0-\u27FF\u2794-\u27BF\u2713\u2714\u2705\u2717\u2718\u2716\u274C\u25B6\u25C0\u25B2\u25BC\u2605\u2606\u2B50]+$|^\s*(->|=>|<-|<=|\[x\]|\[ \])\s*$/;
|
|
32604
|
+
function analyzeTextContract(input) {
|
|
32605
|
+
const { samples, diffs, frameWidth, frameHeight, stepSeconds } = input;
|
|
32606
|
+
const diagonal = Math.hypot(frameWidth, frameHeight);
|
|
32607
|
+
const findings = [];
|
|
32608
|
+
const lines = /* @__PURE__ */ new Map();
|
|
32609
|
+
let previous = /* @__PURE__ */ new Map();
|
|
32610
|
+
for (const [index, sample] of samples.entries()) {
|
|
32611
|
+
const current = /* @__PURE__ */ new Map();
|
|
32612
|
+
for (const block of sample.blocks) {
|
|
32613
|
+
if (block.fontSize < EDITORIAL_MIN_FONT_PX) continue;
|
|
32614
|
+
const key = normalize(block.text);
|
|
32615
|
+
if (key.length < 3) continue;
|
|
32616
|
+
current.set(key, block);
|
|
32617
|
+
let stat = lines.get(key);
|
|
32618
|
+
if (!stat) {
|
|
32619
|
+
stat = { text: key, witnessedSeconds: 0, everInViewport: false, everFullyInFrame: false, maxHeightFraction: 0, firstSeen: sample.time, ridingRun: 0, ridingFlagged: false };
|
|
32620
|
+
lines.set(key, stat);
|
|
32621
|
+
}
|
|
32622
|
+
if (block.inViewport) {
|
|
32623
|
+
stat.maxHeightFraction = Math.max(stat.maxHeightFraction, block.height / frameHeight);
|
|
32624
|
+
}
|
|
32625
|
+
const prior = previous.get(key);
|
|
32626
|
+
const settled = prior ? settledBetween(prior, block, diagonal) : false;
|
|
32627
|
+
const atScale = block.height >= frameHeight * WITNESS_SCALE_FRACTION;
|
|
32628
|
+
if (block.inViewport) {
|
|
32629
|
+
stat.everInViewport = true;
|
|
32630
|
+
if (block.fullyInFrame) stat.everFullyInFrame = true;
|
|
32631
|
+
if (settled && atScale && block.fullyInFrame) stat.witnessedSeconds += stepSeconds;
|
|
32632
|
+
}
|
|
32633
|
+
const acting = index < diffs.length && diffs[index] >= RIDING_DIFF_THRESHOLD;
|
|
32634
|
+
if (block.inViewport && settled && atScale && acting) {
|
|
32635
|
+
stat.ridingRun += 1;
|
|
32636
|
+
if (stat.ridingRun >= 3 && !stat.ridingFlagged) {
|
|
32637
|
+
stat.ridingFlagged = true;
|
|
32638
|
+
findings.push({
|
|
32639
|
+
kind: "riding",
|
|
32640
|
+
detail: `"${stat.text.slice(0, 30)}" rests in frame while the product is acting around ${sample.time.toFixed(2)}s \u2014 text and action are competing; give the claim its own moment, or let the product push it out.`
|
|
32641
|
+
});
|
|
32642
|
+
}
|
|
32643
|
+
} else {
|
|
32644
|
+
stat.ridingRun = 0;
|
|
32645
|
+
}
|
|
32646
|
+
}
|
|
32647
|
+
previous = current;
|
|
32648
|
+
}
|
|
32649
|
+
const lockedLines = input.lockedContent?.map((line) => line.text);
|
|
32650
|
+
if (lockedLines?.length) {
|
|
32651
|
+
const locked = lockedLines.map(normalize);
|
|
32652
|
+
const matches = (observed) => locked.some((line) => line.startsWith(observed) || observed.startsWith(line));
|
|
32653
|
+
for (const stat of lines.values()) {
|
|
32654
|
+
if (stat.maxHeightFraction < WITNESS_SCALE_FRACTION) continue;
|
|
32655
|
+
if (!matches(stat.text)) {
|
|
32656
|
+
findings.push({
|
|
32657
|
+
kind: "unauthorized",
|
|
32658
|
+
detail: `"${stat.text.slice(0, 30)}" appears on screen at editorial scale but is NOT in the locked content \u2014 execution may not invent lines the viewer is asked to read. Remove it, shrink it into product texture, or revise the content artifact deliberately.`
|
|
32659
|
+
});
|
|
32660
|
+
}
|
|
32661
|
+
}
|
|
32662
|
+
for (const line of locked) {
|
|
32663
|
+
const seen = [...lines.values()].some(
|
|
32664
|
+
(stat) => line.startsWith(stat.text) || stat.text.startsWith(line)
|
|
32665
|
+
);
|
|
32666
|
+
if (!seen) {
|
|
32667
|
+
findings.push({
|
|
32668
|
+
kind: "missing",
|
|
32669
|
+
detail: `Locked line "${line.slice(0, 30)}" never appears in the film \u2014 a planned beat is silent.`
|
|
32670
|
+
});
|
|
32671
|
+
}
|
|
32672
|
+
}
|
|
32673
|
+
}
|
|
32674
|
+
if (samples.length) {
|
|
32675
|
+
const openers = samples[0].blocks.filter(
|
|
32676
|
+
(block) => block.inViewport && block.fontSize >= EDITORIAL_MIN_FONT_PX && normalize(block.text).length >= 3
|
|
32677
|
+
);
|
|
32678
|
+
const firstLocked = lockedLines?.length ? normalize(lockedLines[0]) : null;
|
|
32679
|
+
for (const [index, block] of openers.entries()) {
|
|
32680
|
+
const text = normalize(block.text);
|
|
32681
|
+
const isDeclaredOpening = firstLocked ? firstLocked.startsWith(text) || text.startsWith(firstLocked) : index === 0 && openers.length === 1;
|
|
32682
|
+
if (!isDeclaredOpening && (firstLocked || openers.length > 1)) {
|
|
32683
|
+
findings.push({
|
|
32684
|
+
kind: "frame0-leak",
|
|
32685
|
+
detail: `"${text.slice(0, 30)}" is visible at frame 0 but is not the film's opening \u2014 a later element is leaking its initial state. Hide it statically (App.tsx or gsap.set in setup); a from() tween does not hide anything on a cold seek.`
|
|
32686
|
+
});
|
|
32687
|
+
}
|
|
32688
|
+
}
|
|
32689
|
+
}
|
|
32690
|
+
const INFLATED_RATIO = 2.5;
|
|
32691
|
+
const lockedLabels = (input.lockedContent ?? []).filter((line) => line.kind === "label").map((line) => normalize(line.text));
|
|
32692
|
+
if (lockedLabels.length) {
|
|
32693
|
+
const inflatedSeen = /* @__PURE__ */ new Set();
|
|
32694
|
+
for (const sample of samples) {
|
|
32695
|
+
const productSizes = sample.blocks.filter(
|
|
32696
|
+
(block) => block.inViewport && block.fontSize >= CANDIDATE_MIN_PX && block.height < frameHeight * WITNESS_SCALE_FRACTION
|
|
32697
|
+
).map((block) => block.fontSize).sort((a, b) => a - b);
|
|
32698
|
+
if (productSizes.length < 5) continue;
|
|
32699
|
+
const nativeScale = productSizes[Math.floor(productSizes.length / 2)];
|
|
32700
|
+
for (const block of sample.blocks) {
|
|
32701
|
+
if (!block.inViewport || block.height < frameHeight * WITNESS_SCALE_FRACTION) continue;
|
|
32702
|
+
const key = normalize(block.text);
|
|
32703
|
+
if (!key || inflatedSeen.has(key)) continue;
|
|
32704
|
+
const isLabel = lockedLabels.some(
|
|
32705
|
+
(line) => line.startsWith(key) || key.startsWith(line)
|
|
32706
|
+
);
|
|
32707
|
+
if (!isLabel) continue;
|
|
32708
|
+
if (block.fontSize > nativeScale * INFLATED_RATIO) {
|
|
32709
|
+
inflatedSeen.add(key);
|
|
32710
|
+
findings.push({
|
|
32711
|
+
kind: "inflated",
|
|
32712
|
+
detail: `"${block.text.slice(0, 40)}" renders at ${Math.round(block.fontSize)}px inside UI whose native type scale is ~${Math.round(nativeScale)}px (${(block.fontSize / nativeScale).toFixed(1)}x) at ${sample.time.toFixed(2)}s. A product element never exceeds its native proportion \u2014 make it readable by pushing the CAMERA to it (the whole UI scales together), or restyle it as a true product toast/control at native scale.`
|
|
32713
|
+
});
|
|
32714
|
+
}
|
|
32715
|
+
}
|
|
32716
|
+
}
|
|
32717
|
+
}
|
|
32718
|
+
const editorialLines = input.lockedContent?.map((line) => normalize(line.text));
|
|
32719
|
+
const isEditorial = (observed) => !editorialLines?.length || editorialLines.some((line) => line.startsWith(observed) || observed.startsWith(line));
|
|
32720
|
+
const glyphSeen = /* @__PURE__ */ new Set();
|
|
32721
|
+
for (const sample of samples) {
|
|
32722
|
+
for (const block of sample.blocks) {
|
|
32723
|
+
const text = block.text.trim();
|
|
32724
|
+
if (!text || block.fontSize < CANDIDATE_MIN_PX) continue;
|
|
32725
|
+
if (!ICON_GLYPHS.test(text) || glyphSeen.has(text)) continue;
|
|
32726
|
+
glyphSeen.add(text);
|
|
32727
|
+
findings.push({
|
|
32728
|
+
kind: "glyph-icon",
|
|
32729
|
+
detail: `"${text}" is a text glyph doing an icon's job. Use the real icon from lucide-react (import { ArrowRight, Check } from 'lucide-react') \u2014 a glyph inherits the text font, so it sits on the baseline at the wrong weight and optical size, and it cannot take a stroke width or animate as its own element.`
|
|
32730
|
+
});
|
|
32731
|
+
}
|
|
32732
|
+
}
|
|
32733
|
+
const stackedSeen = /* @__PURE__ */ new Set();
|
|
32734
|
+
for (const sample of samples) {
|
|
32735
|
+
for (const editorial of sample.blocks) {
|
|
32736
|
+
if (!editorial.inViewport) continue;
|
|
32737
|
+
if (editorial.height < frameHeight * WITNESS_SCALE_FRACTION) continue;
|
|
32738
|
+
const key = normalize(editorial.text);
|
|
32739
|
+
if (!key || stackedSeen.has(key)) continue;
|
|
32740
|
+
const margin = frameHeight * 0.02;
|
|
32741
|
+
const overlapped = sample.blocks.filter((other) => {
|
|
32742
|
+
if (other === editorial || !other.inViewport) return false;
|
|
32743
|
+
if (normalize(other.text) === key) return false;
|
|
32744
|
+
return editorial.x - margin < other.x + other.width && other.x < editorial.x + editorial.width + margin && editorial.y - margin < other.y + other.height && other.y < editorial.y + editorial.height + margin;
|
|
32745
|
+
});
|
|
32746
|
+
if (overlapped.length) {
|
|
32747
|
+
stackedSeen.add(key);
|
|
32748
|
+
findings.push({
|
|
32749
|
+
kind: "stacked",
|
|
32750
|
+
detail: `"${editorial.text.slice(0, 40)}" is stacked on or crowding other readable text at ${sample.time.toFixed(2)}s (e.g. "${overlapped[0].text.slice(0, 30)}"${overlapped.length > 1 ? ` and ${overlapped.length - 1} more` : ""}). A claim and the product may never share the same space \u2014 dimming the product under the words is not separation. Clear the product fully (or hide it) while the claim is up, then bring it back for the proof.`
|
|
32751
|
+
});
|
|
32752
|
+
}
|
|
32753
|
+
}
|
|
32754
|
+
}
|
|
32755
|
+
for (const stat of lines.values()) {
|
|
32756
|
+
if (!stat.everInViewport) {
|
|
32757
|
+
findings.push({
|
|
32758
|
+
kind: "off-camera",
|
|
32759
|
+
detail: `"${stat.text.slice(0, 30)}" (from ${stat.firstSeen.toFixed(2)}s) exists on the stage but the camera NEVER shows it \u2014 the viewer never sees this line. If it explains an action, that action happens unexplained.`
|
|
32760
|
+
});
|
|
32761
|
+
} else if (stat.maxHeightFraction < WITNESS_SCALE_FRACTION && isEditorial(stat.text)) {
|
|
32762
|
+
findings.push({
|
|
32763
|
+
kind: "too-small",
|
|
32764
|
+
detail: `"${stat.text.slice(0, 30)}" peaks at ${(stat.maxHeightFraction * 100).toFixed(1)}% of frame height \u2014 below the ${(WITNESS_SCALE_FRACTION * 100).toFixed(0)}% readable floor (statements are 10-15% per line). This is an editorial line the viewer is asked to read: make it bigger or push the camera to it. Do NOT solve it by deleting the words \u2014 product content is allowed to be small.`
|
|
32765
|
+
});
|
|
32766
|
+
} else if (!stat.everFullyInFrame) {
|
|
32767
|
+
findings.push({
|
|
32768
|
+
kind: "clipped",
|
|
32769
|
+
detail: `"${stat.text.slice(0, 30)}" only ever appears partially cut off by the frame edge \u2014 the viewer cannot read a clipped line. Reframe the camera or reposition the text so it sits fully inside the frame with a margin.`
|
|
32770
|
+
});
|
|
32771
|
+
} else if (stat.witnessedSeconds < MIN_WITNESS_SECONDS) {
|
|
32772
|
+
findings.push({
|
|
32773
|
+
kind: "unwitnessed",
|
|
32774
|
+
detail: `"${stat.text.slice(0, 30)}" is on camera settled and readable for only ${stat.witnessedSeconds.toFixed(2)}s (needs ~${MIN_WITNESS_SECONDS}s) \u2014 the viewer cannot read it. Give it a real held moment before its action.`
|
|
32775
|
+
});
|
|
32776
|
+
} else {
|
|
32777
|
+
const isStatement = (input.lockedContent ?? []).some(
|
|
32778
|
+
(line) => line.kind === "statement" && (normalize(line.text).startsWith(stat.text) || stat.text.startsWith(normalize(line.text)))
|
|
32779
|
+
);
|
|
32780
|
+
if (isStatement && stat.witnessedSeconds < STATEMENT_HOLD_SECONDS) {
|
|
32781
|
+
findings.push({
|
|
32782
|
+
kind: "rushed",
|
|
32783
|
+
detail: `"${stat.text.slice(0, 40)}" holds for only ${stat.witnessedSeconds.toFixed(2)}s \u2014 slow down, the viewer won't be able to focus. A statement needs at least ${STATEMENT_HOLD_SECONDS}s of settled, undisturbed time before the next change.`
|
|
32784
|
+
});
|
|
32785
|
+
}
|
|
32786
|
+
}
|
|
32787
|
+
}
|
|
32788
|
+
if (input.inspectV2) {
|
|
32789
|
+
if (input.lockedContent?.length) {
|
|
32790
|
+
const bigKinds = input.lockedContent.filter((line) => line.kind !== "label");
|
|
32791
|
+
for (const line of bigKinds) {
|
|
32792
|
+
const key = normalize(line.text);
|
|
32793
|
+
const stat = [...lines.values()].find(
|
|
32794
|
+
(candidate) => key.startsWith(candidate.text) || candidate.text.startsWith(key)
|
|
32795
|
+
);
|
|
32796
|
+
if (stat && stat.everFullyInFrame && stat.maxHeightFraction < STATEMENT_MIN_FRACTION) {
|
|
32797
|
+
findings.push({
|
|
32798
|
+
kind: "statement-small",
|
|
32799
|
+
detail: `"${key.slice(0, 30)}" is a ${line.kind} but peaks at ${(stat.maxHeightFraction * 100).toFixed(1)}% of frame height \u2014 statements are 10-15% per line. Make it the biggest thing on screen.`
|
|
32800
|
+
});
|
|
32801
|
+
}
|
|
32802
|
+
}
|
|
32803
|
+
}
|
|
32804
|
+
let runStart = -1;
|
|
32805
|
+
let runMax = 0;
|
|
32806
|
+
const closeRun = (endTime) => {
|
|
32807
|
+
if (runStart >= 0 && endTime - runStart >= 1.5 && runMax > 0 && runMax < PROOF_TEXT_MIN_FRACTION) {
|
|
32808
|
+
findings.push({
|
|
32809
|
+
kind: "proof-text-small",
|
|
32810
|
+
detail: `proof window ${runStart.toFixed(2)}-${endTime.toFixed(2)}s: product text peaks at ${(runMax * 100).toFixed(1)}% rendered \u2014 if the viewer must read it, zoom until it clears ${(PROOF_TEXT_MIN_FRACTION * 100).toFixed(1)}%.`
|
|
32811
|
+
});
|
|
32812
|
+
}
|
|
32813
|
+
runStart = -1;
|
|
32814
|
+
runMax = 0;
|
|
32815
|
+
};
|
|
32816
|
+
for (const sample of samples) {
|
|
32817
|
+
const visible = sample.blocks.filter((block) => block.inViewport);
|
|
32818
|
+
const editorial = visible.some((block) => block.fontSize >= EDITORIAL_MIN_FONT_PX);
|
|
32819
|
+
const product = visible.filter((block) => block.fontSize < EDITORIAL_MIN_FONT_PX);
|
|
32820
|
+
if (!editorial && product.length) {
|
|
32821
|
+
if (runStart < 0) runStart = sample.time;
|
|
32822
|
+
runMax = Math.max(runMax, ...product.map((block) => block.height / frameHeight));
|
|
32823
|
+
} else {
|
|
32824
|
+
closeRun(sample.time);
|
|
32825
|
+
}
|
|
32826
|
+
}
|
|
32827
|
+
closeRun(samples.length ? samples[samples.length - 1].time : 0);
|
|
32828
|
+
}
|
|
32829
|
+
return findings;
|
|
32830
|
+
}
|
|
32831
|
+
function formatTextContractReport(findings) {
|
|
32832
|
+
const lines = ["TEXT CONTRACT (witness + baton checks on editorial copy):"];
|
|
32833
|
+
if (!findings.length) {
|
|
32834
|
+
lines.push(" clean: every line is witnessed on camera, and no text rests in frame while the product acts.");
|
|
32835
|
+
return lines.join("\n");
|
|
32836
|
+
}
|
|
32837
|
+
for (const finding of findings) lines.push(` [${finding.kind}] ${finding.detail}`);
|
|
32838
|
+
lines.push(
|
|
32839
|
+
" Rule of thumb: if the camera did not see a line, it did not happen. A claim gets the frame to itself; the product gets the frame to itself; they overlap only while one is entering, leaving, or dimmed to the background."
|
|
32840
|
+
);
|
|
32841
|
+
return lines.join("\n");
|
|
32842
|
+
}
|
|
32843
|
+
|
|
32844
|
+
// src/mcp/motion.ts
|
|
32845
|
+
var MOTION_LAB_ENV = "IMPRACTICAL_MOTION_LAB";
|
|
32846
|
+
function motionLabEnabled(env = process.env) {
|
|
32847
|
+
return !/^(0|false|off|no)$/i.test(env[MOTION_LAB_ENV]?.trim() ?? "");
|
|
32848
|
+
}
|
|
32849
|
+
var DEFAULT_EVERY_NTH_FRAME = 5;
|
|
32850
|
+
var MAX_SAMPLES = 48;
|
|
32851
|
+
var REVIEW_MAX_SAMPLES = 160;
|
|
32852
|
+
var QUICK_EVERY_NTH_FRAME = 10;
|
|
32853
|
+
var QUICK_MAX_SAMPLES = 24;
|
|
32854
|
+
function defaultDetail(env = process.env) {
|
|
32855
|
+
const value = env.IMPRACTICAL_MOTION_DETAIL?.trim().toLowerCase();
|
|
32856
|
+
return value === "full" || value === "quick" ? value : void 0;
|
|
32857
|
+
}
|
|
32858
|
+
function motionPassPlan(args) {
|
|
32859
|
+
const full = args.finalReview || (args.detail ?? defaultDetail()) === "full";
|
|
32860
|
+
if (full) {
|
|
32861
|
+
return {
|
|
32862
|
+
everyNthFrame: args.finalReview ? Math.min(Number(args.everyNthFrame ?? DEFAULT_EVERY_NTH_FRAME), DEFAULT_EVERY_NTH_FRAME) : Number(args.everyNthFrame ?? DEFAULT_EVERY_NTH_FRAME),
|
|
32863
|
+
maxSamples: args.finalReview ? REVIEW_MAX_SAMPLES : MAX_SAMPLES,
|
|
32864
|
+
includeSheets: true,
|
|
32865
|
+
includeCatalog: true
|
|
32866
|
+
};
|
|
32867
|
+
}
|
|
32868
|
+
return {
|
|
32869
|
+
everyNthFrame: Number(args.everyNthFrame ?? QUICK_EVERY_NTH_FRAME),
|
|
32870
|
+
maxSamples: QUICK_MAX_SAMPLES,
|
|
32871
|
+
includeSheets: false,
|
|
32872
|
+
includeCatalog: false
|
|
32873
|
+
};
|
|
32874
|
+
}
|
|
32875
|
+
var DEAD_THRESHOLD = 5e-3;
|
|
32876
|
+
var IMPERCEPTIBLE_THRESHOLD = 0.01;
|
|
32877
|
+
function sampleTimes(input) {
|
|
32878
|
+
const maxSamples = Math.max(2, input.maxSamples ?? MAX_SAMPLES);
|
|
32879
|
+
const span = Math.max(0, input.to - input.from);
|
|
32880
|
+
const round = (value) => Math.round(value * 1e3) / 1e3;
|
|
32881
|
+
if (span === 0) return { times: [round(input.from)], stepSeconds: 0, coarsened: false };
|
|
32882
|
+
const stepFrames = Math.max(1, Math.round(input.everyNthFrame));
|
|
32883
|
+
const step = stepFrames / Math.max(1, input.fps);
|
|
32884
|
+
const naturalCount = Math.floor(span / step + 1e-9) + 1;
|
|
32885
|
+
if (naturalCount > maxSamples) {
|
|
32886
|
+
const coarseStep = span / (maxSamples - 1);
|
|
32887
|
+
return {
|
|
32888
|
+
times: Array.from({ length: maxSamples }, (_, i) => round(input.from + i * coarseStep)),
|
|
32889
|
+
stepSeconds: coarseStep,
|
|
32890
|
+
coarsened: true
|
|
32891
|
+
};
|
|
32892
|
+
}
|
|
32893
|
+
const times = Array.from({ length: naturalCount }, (_, i) => round(input.from + i * step));
|
|
32894
|
+
if (times[times.length - 1] < round(input.to)) times.push(round(input.to));
|
|
32895
|
+
return { times, stepSeconds: step, coarsened: false };
|
|
32896
|
+
}
|
|
32897
|
+
function buildIntervals(times, diffs) {
|
|
32898
|
+
return diffs.map((changed, i) => ({ start: times[i], end: times[i + 1], changed }));
|
|
32899
|
+
}
|
|
32900
|
+
var SETTLE_THRESHOLD = 0.02;
|
|
32901
|
+
var BREATHLESS_MAX_RUN_SECONDS = 3.5;
|
|
32902
|
+
var ACTION_CLUSTER_SECONDS = 0.2;
|
|
32903
|
+
var ACTION_MAX_TWEEN_SECONDS = 3;
|
|
32904
|
+
var ACTION_WINDOW_SECONDS = 6;
|
|
32905
|
+
var ACTION_WINDOW_MAX = 5;
|
|
32906
|
+
function actionEvents(tweens) {
|
|
32907
|
+
const starts = tweens.filter((tween) => tween.end - tween.start <= ACTION_MAX_TWEEN_SECONDS).map((tween) => tween.start).sort((a, b) => a - b);
|
|
32908
|
+
const events = [];
|
|
32909
|
+
for (const start of starts) {
|
|
32910
|
+
if (!events.length || start - events[events.length - 1] > ACTION_CLUSTER_SECONDS) {
|
|
32911
|
+
events.push(start);
|
|
32912
|
+
}
|
|
32913
|
+
}
|
|
32914
|
+
return events;
|
|
32915
|
+
}
|
|
32916
|
+
function cadenceMoments(tweens) {
|
|
32917
|
+
const events = actionEvents(tweens);
|
|
32918
|
+
const moments = [];
|
|
32919
|
+
for (let i = 0; i < events.length; i += 1) {
|
|
32920
|
+
let j = i;
|
|
32921
|
+
while (j + 1 < events.length && events[j + 1] - events[i] <= ACTION_WINDOW_SECONDS) j += 1;
|
|
32922
|
+
const actions = j - i + 1;
|
|
32923
|
+
if (actions > ACTION_WINDOW_MAX) {
|
|
32924
|
+
const last = moments[moments.length - 1];
|
|
32925
|
+
if (last && events[i] <= last.end) {
|
|
32926
|
+
last.end = events[j];
|
|
32927
|
+
last.actions = Math.max(last.actions, actions);
|
|
32928
|
+
} else {
|
|
32929
|
+
moments.push({ start: events[i], end: events[j], actions });
|
|
32930
|
+
}
|
|
32931
|
+
}
|
|
32932
|
+
}
|
|
32933
|
+
return moments;
|
|
32934
|
+
}
|
|
32935
|
+
function breathlessSpans(intervals, options = {}) {
|
|
32936
|
+
const settle = options.settle ?? SETTLE_THRESHOLD;
|
|
32937
|
+
const maxRun = options.maxRun ?? BREATHLESS_MAX_RUN_SECONDS;
|
|
32938
|
+
const spans2 = [];
|
|
32939
|
+
let runStart = null;
|
|
32940
|
+
for (const interval of intervals) {
|
|
32941
|
+
if (interval.changed >= settle) {
|
|
32942
|
+
runStart ??= interval.start;
|
|
32943
|
+
if (interval.end - runStart > maxRun) {
|
|
32944
|
+
const last = spans2[spans2.length - 1];
|
|
32945
|
+
if (last && last.start === runStart) last.end = interval.end;
|
|
32946
|
+
else spans2.push({ start: runStart, end: interval.end });
|
|
32947
|
+
}
|
|
32948
|
+
} else {
|
|
32949
|
+
runStart = null;
|
|
32950
|
+
}
|
|
32951
|
+
}
|
|
32952
|
+
return spans2;
|
|
32953
|
+
}
|
|
32954
|
+
function deadAirSpans(intervals, options = {}) {
|
|
32955
|
+
const threshold = options.threshold ?? DEAD_THRESHOLD;
|
|
32956
|
+
const minSpan = options.minSpanSeconds ?? 0.6;
|
|
32957
|
+
const spans2 = [];
|
|
32958
|
+
let open = null;
|
|
32959
|
+
for (const interval of intervals) {
|
|
32960
|
+
if (interval.changed < threshold) {
|
|
32961
|
+
if (open) open.end = interval.end;
|
|
32962
|
+
else open = { start: interval.start, end: interval.end };
|
|
32963
|
+
} else if (open) {
|
|
32964
|
+
spans2.push(open);
|
|
32965
|
+
open = null;
|
|
32966
|
+
}
|
|
32967
|
+
}
|
|
32968
|
+
if (open) spans2.push(open);
|
|
32969
|
+
return spans2.filter((span) => span.end - span.start >= minSpan);
|
|
32970
|
+
}
|
|
32971
|
+
function imperceptibleTweens(tweens, intervals, options = {}) {
|
|
32972
|
+
const minDuration = options.minDurationSeconds ?? 0.5;
|
|
32973
|
+
const threshold = options.threshold ?? IMPERCEPTIBLE_THRESHOLD;
|
|
32974
|
+
return tweens.filter((tween) => {
|
|
32975
|
+
if (tween.end - tween.start < minDuration) return false;
|
|
32976
|
+
const active = intervals.filter(
|
|
32977
|
+
(interval) => interval.end > tween.start && interval.start < tween.end
|
|
32978
|
+
);
|
|
32979
|
+
if (!active.length) return false;
|
|
32980
|
+
const mean2 = active.reduce((sum, interval) => sum + interval.changed, 0) / active.length;
|
|
32981
|
+
return mean2 < threshold;
|
|
32982
|
+
});
|
|
32983
|
+
}
|
|
32984
|
+
var formatTime = (value) => value.toFixed(2);
|
|
32985
|
+
var bar = (changed) => {
|
|
32986
|
+
const filled = Math.round(Math.min(changed / 0.25, 1) * 10);
|
|
32987
|
+
return "\u2588".repeat(filled) + "\u2591".repeat(10 - filled);
|
|
32988
|
+
};
|
|
32989
|
+
var describeTween = (tween) => {
|
|
32990
|
+
const targets = tween.targets.slice(0, 3).join(", ") + (tween.targets.length > 3 ? ", \u2026" : "");
|
|
32991
|
+
const props = tween.props.slice(0, 4).join(",") + (tween.props.length > 4 ? ",\u2026" : "");
|
|
32992
|
+
if (tween.end - tween.start < 0.02) {
|
|
32993
|
+
return `${formatTime(tween.start)}s SET ${targets} ${props} (hard cut)`;
|
|
32994
|
+
}
|
|
32995
|
+
return `${formatTime(tween.start)}\u2192${formatTime(tween.end)}s ${targets} ${props} ease=${tween.ease}`;
|
|
32996
|
+
};
|
|
32997
|
+
var FOCAL_GRID_COLS = 8;
|
|
32998
|
+
var FOCAL_GRID_ROWS = 6;
|
|
32999
|
+
var FOCAL_CELL_THRESHOLD = 0.15;
|
|
33000
|
+
var FOCAL_BUDGET = 2;
|
|
33001
|
+
function focalClusters(cells, cols = FOCAL_GRID_COLS, rows = FOCAL_GRID_ROWS, threshold = FOCAL_CELL_THRESHOLD) {
|
|
33002
|
+
const active = cells.map((cell) => cell >= threshold);
|
|
33003
|
+
const seen = new Array(cells.length).fill(false);
|
|
33004
|
+
let clusters = 0;
|
|
33005
|
+
for (let start = 0; start < cells.length; start += 1) {
|
|
33006
|
+
if (!active[start] || seen[start]) continue;
|
|
33007
|
+
clusters += 1;
|
|
33008
|
+
const stack = [start];
|
|
33009
|
+
seen[start] = true;
|
|
33010
|
+
while (stack.length) {
|
|
33011
|
+
const index = stack.pop();
|
|
33012
|
+
const x = index % cols;
|
|
33013
|
+
const y = Math.floor(index / cols);
|
|
33014
|
+
for (const [nx, ny] of [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]]) {
|
|
33015
|
+
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
|
|
33016
|
+
const neighbour = ny * cols + nx;
|
|
33017
|
+
if (active[neighbour] && !seen[neighbour]) {
|
|
33018
|
+
seen[neighbour] = true;
|
|
33019
|
+
stack.push(neighbour);
|
|
33020
|
+
}
|
|
33021
|
+
}
|
|
33022
|
+
}
|
|
33023
|
+
}
|
|
33024
|
+
return clusters;
|
|
33025
|
+
}
|
|
33026
|
+
function splitFocusMoments(times, cellDiffs, budget = FOCAL_BUDGET) {
|
|
33027
|
+
const moments = [];
|
|
33028
|
+
for (const [i, cells] of cellDiffs.entries()) {
|
|
33029
|
+
const clusters = focalClusters(cells);
|
|
33030
|
+
if (clusters > budget) {
|
|
33031
|
+
moments.push({ start: times[i], end: times[i + 1], clusters });
|
|
33032
|
+
}
|
|
33033
|
+
}
|
|
33034
|
+
return moments;
|
|
33035
|
+
}
|
|
33036
|
+
function motionReport(input) {
|
|
33037
|
+
const { intervals, tweens, plan } = input;
|
|
33038
|
+
const lines = [];
|
|
33039
|
+
lines.push(
|
|
33040
|
+
`MOTION ANALYSIS ${formatTime(input.from)}\u2013${formatTime(input.to)}s of ${formatTime(input.duration)}s \u2014 ${plan.times.length} samples, ${plan.stepSeconds.toFixed(3)}s apart` + (plan.coarsened ? ` (coarsened to fit ${plan.times.length} samples \u2014 analyze a narrower window for every-${input.everyNthFrame}-frames density)` : ` (every ${input.everyNthFrame} frames)`) + "."
|
|
33041
|
+
);
|
|
33042
|
+
lines.push(
|
|
33043
|
+
"Read the contact sheets like a director: adjacent tiles are one step apart, so the difference between them IS the motion. A row of near-identical tiles is a freeze."
|
|
33044
|
+
);
|
|
33045
|
+
lines.push("");
|
|
33046
|
+
lines.push("Pixel change per interval (fraction of the frame that moved; <0.5% reads as frozen):");
|
|
33047
|
+
for (const interval of intervals) {
|
|
33048
|
+
lines.push(
|
|
33049
|
+
` ${formatTime(interval.start)}\u2013${formatTime(interval.end)} ${bar(interval.changed)} ${(interval.changed * 100).toFixed(1)}%`
|
|
33050
|
+
);
|
|
33051
|
+
}
|
|
33052
|
+
const dead = deadAirSpans(intervals);
|
|
33053
|
+
lines.push("");
|
|
33054
|
+
if (dead.length) {
|
|
33055
|
+
lines.push("DEAD AIR \u2014 nothing perceptible moves. Decide what should own this time:");
|
|
33056
|
+
for (const span of dead) {
|
|
33057
|
+
lines.push(
|
|
33058
|
+
` ${formatTime(span.start)}\u2013${formatTime(span.end)}s (${(span.end - span.start).toFixed(2)}s)`
|
|
33059
|
+
);
|
|
33060
|
+
}
|
|
33061
|
+
} else {
|
|
33062
|
+
lines.push("DEAD AIR: none in this window.");
|
|
33063
|
+
}
|
|
33064
|
+
if (input.breathless) {
|
|
33065
|
+
lines.push("");
|
|
33066
|
+
if (input.breathless.length) {
|
|
33067
|
+
lines.push(
|
|
33068
|
+
"TOO FAST \u2014 slow down, the viewer won't be able to focus. These spans never stop moving; nothing holds long enough to be taken in. Let each thing land and sit before the next change:"
|
|
33069
|
+
);
|
|
33070
|
+
for (const span of input.breathless.slice(0, 6)) {
|
|
33071
|
+
lines.push(
|
|
33072
|
+
` ${formatTime(span.start)}\u2013${formatTime(span.end)}s (${(span.end - span.start).toFixed(1)}s of nonstop change)`
|
|
33073
|
+
);
|
|
33074
|
+
}
|
|
33075
|
+
} else {
|
|
33076
|
+
lines.push("PACING: motion settles regularly \u2014 the viewer gets time to focus.");
|
|
33077
|
+
}
|
|
33078
|
+
}
|
|
33079
|
+
if (input.cadence) {
|
|
33080
|
+
lines.push("");
|
|
33081
|
+
if (input.cadence.length) {
|
|
33082
|
+
lines.push(
|
|
33083
|
+
"ACTIONS TOO FREQUENT \u2014 slow down, the viewer won't be able to focus. The house cadence is about one action every 1\u20131.5 seconds; these stretches pack them faster:"
|
|
33084
|
+
);
|
|
33085
|
+
for (const moment of input.cadence.slice(0, 6)) {
|
|
33086
|
+
lines.push(
|
|
33087
|
+
` ${formatTime(moment.start)}\u2013${formatTime(moment.end)}s: ${moment.actions} actions in ${(moment.end - moment.start).toFixed(1)}s`
|
|
33088
|
+
);
|
|
33089
|
+
}
|
|
33090
|
+
} else {
|
|
33091
|
+
lines.push("ACTION CADENCE: about one action at a time \u2014 the viewer can follow.");
|
|
33092
|
+
}
|
|
33093
|
+
}
|
|
33094
|
+
if (input.splitFocus) {
|
|
33095
|
+
lines.push("");
|
|
33096
|
+
if (input.splitFocus.length) {
|
|
33097
|
+
lines.push(
|
|
33098
|
+
`FOCAL BUDGET BLOWN \u2014 more than ${FOCAL_BUDGET} separate regions of the frame are moving at once. The eye can follow one focal point; everything else moving beside it is noise. Let one change finish (or hold) before the next begins:`
|
|
33099
|
+
);
|
|
33100
|
+
for (const moment of input.splitFocus.slice(0, 8)) {
|
|
33101
|
+
lines.push(
|
|
33102
|
+
` ${formatTime(moment.start)}\u2013${formatTime(moment.end)} ${moment.clusters} regions moving simultaneously`
|
|
33103
|
+
);
|
|
33104
|
+
}
|
|
33105
|
+
if (input.splitFocus.length > 8) {
|
|
33106
|
+
lines.push(` \u2026and ${input.splitFocus.length - 8} more intervals over budget`);
|
|
33107
|
+
}
|
|
33108
|
+
} else {
|
|
33109
|
+
lines.push("FOCAL BUDGET: respected \u2014 at most one focal change at a time in this window.");
|
|
33110
|
+
}
|
|
33111
|
+
}
|
|
33112
|
+
lines.push("");
|
|
33113
|
+
if (tweens === null) {
|
|
33114
|
+
lines.push(
|
|
33115
|
+
"TIMELINE: introspection unavailable \u2014 this preview bundle predates window.__timeline. Save the draft again to rebuild with a current bundle."
|
|
33116
|
+
);
|
|
33117
|
+
} else if (!tweens.length) {
|
|
33118
|
+
lines.push("TIMELINE: no tweens found in this window.");
|
|
33119
|
+
} else {
|
|
33120
|
+
const flagged = new Set(imperceptibleTweens(tweens, intervals));
|
|
33121
|
+
lines.push("TIMELINE \u2014 the tweens actually scheduled in this window (absolute seconds):");
|
|
33122
|
+
for (const tween of [...tweens].sort((a, b) => a.start - b.start)) {
|
|
33123
|
+
const flag = flagged.has(tween) ? " \u26A0 runs but changes almost no pixels \u2014 likely an imperceptible magnitude; make it visibly larger or give the time to something else" : "";
|
|
33124
|
+
lines.push(` ${describeTween(tween)}${flag}`);
|
|
33125
|
+
}
|
|
33126
|
+
const covered = intervals.filter(
|
|
33127
|
+
(interval) => tweens.some((tween) => tween.end > interval.start && tween.start < interval.end)
|
|
33128
|
+
).length;
|
|
33129
|
+
const perceptible = intervals.filter((interval) => interval.changed >= DEAD_THRESHOLD).length;
|
|
33130
|
+
lines.push("");
|
|
33131
|
+
lines.push(
|
|
33132
|
+
`Coverage: tweens are active in ${Math.round(covered / Math.max(1, intervals.length) * 100)}% of intervals; perceptible motion occupies ${Math.round(perceptible / Math.max(1, intervals.length) * 100)}%.`
|
|
33133
|
+
);
|
|
33134
|
+
}
|
|
33135
|
+
return lines.join("\n");
|
|
33136
|
+
}
|
|
33137
|
+
var REFERENCE_STATIC_BAND = { low: 0.35, median: 0.45, high: 0.55 };
|
|
33138
|
+
function envelopeLine(intervals) {
|
|
33139
|
+
if (!intervals.length) return "";
|
|
33140
|
+
const still = intervals.filter((interval) => interval.changed < DEAD_THRESHOLD).length / intervals.length;
|
|
33141
|
+
const delta = still - REFERENCE_STATIC_BAND.median;
|
|
33142
|
+
return `REFERENCE ENVELOPE: reference launch films run ~${Math.round(REFERENCE_STATIC_BAND.low * 100)}-${Math.round(REFERENCE_STATIC_BAND.high * 100)}% static (median ~${Math.round(REFERENCE_STATIC_BAND.median * 100)}%); this window: ${(still * 100).toFixed(1)}%` + (delta > 0.1 ? ` \u2014 ${Math.round(delta * 100)} points stiller than the reference norm.` : " \u2014 inside the norm.");
|
|
33143
|
+
}
|
|
33144
|
+
function repairMenu(input) {
|
|
33145
|
+
const out = [];
|
|
33146
|
+
for (const span of input.deadSpans) {
|
|
33147
|
+
const sample = input.textSamples?.find((candidate) => candidate.time >= span.start && candidate.time <= span.end);
|
|
33148
|
+
const anchor = sample?.blocks.find((block) => block.inViewport && block.selector)?.selector;
|
|
33149
|
+
out.push(
|
|
33150
|
+
`REPAIR dead air ${span.start.toFixed(2)}-${span.end.toFixed(2)}s: either trim the hold (pull the next cut to ~${(span.start + 0.9).toFixed(1)}s), or give it hold-life` + (anchor ? ` \u2014 e.g. a slow camera push toward ${anchor} (ease-in-out-cubic, the full span)` : "") + ". A reading hold may rest ~1s; longer needs visible life."
|
|
33151
|
+
);
|
|
33152
|
+
}
|
|
33153
|
+
for (const tween of input.imperceptible) {
|
|
33154
|
+
out.push(
|
|
33155
|
+
`REPAIR imperceptible tween ${tween.start.toFixed(2)}-${tween.end.toFixed(2)}s on ${tween.targets[0] ?? "target"} (${tween.props.slice(0, 3).join(",")}): the change is below what a viewer can see \u2014 multiply the distance roughly 5-10x, or delete it and hold clean.`
|
|
33156
|
+
);
|
|
33157
|
+
}
|
|
33158
|
+
return out;
|
|
33159
|
+
}
|
|
33160
|
+
function beatVerdicts(beats, intervals) {
|
|
33161
|
+
const out = [];
|
|
33162
|
+
for (const beat of beats) {
|
|
33163
|
+
const inside = intervals.filter((interval) => interval.end > beat.start && interval.start < beat.end);
|
|
33164
|
+
if (!inside.length) continue;
|
|
33165
|
+
const activeShare = inside.filter((interval) => interval.changed >= IMPERCEPTIBLE_THRESHOLD).length / inside.length;
|
|
33166
|
+
const label = `${beat.start.toFixed(1)}-${beat.end.toFixed(1)}s "${beat.subject.slice(0, 24)}"`;
|
|
33167
|
+
if (beat.energy === "high" && activeShare < 0.35) {
|
|
33168
|
+
out.push(
|
|
33169
|
+
`BEAT MISMATCH ${label}: declared HIGH energy but only ${(activeShare * 100).toFixed(0)}% of it moves \u2014 the declared move is not visible. Make it move or re-declare the beat honestly.`
|
|
33170
|
+
);
|
|
33171
|
+
} else if (beat.energy === "low" && activeShare < 0.15) {
|
|
33172
|
+
out.push(`BEAT MISMATCH ${label}: declared LOW energy but is essentially frozen (${(activeShare * 100).toFixed(0)}% active).`);
|
|
33173
|
+
} else if (beat.energy === "rest" && activeShare > 0.6) {
|
|
33174
|
+
out.push(`BEAT NOTE ${label}: declared REST but ${(activeShare * 100).toFixed(0)}% of it moves \u2014 fine if intended, distracting if not.`);
|
|
33175
|
+
}
|
|
33176
|
+
const length = beat.end - beat.start;
|
|
33177
|
+
if (length > 0 && length < 1.5) {
|
|
33178
|
+
out.push(
|
|
33179
|
+
`BEAT TOO SHORT ${label}: only ${length.toFixed(1)}s \u2014 too quick for the viewer to take in. Slow down: give it more time or fold it into the neighbouring beat.`
|
|
33180
|
+
);
|
|
33181
|
+
}
|
|
33182
|
+
}
|
|
33183
|
+
return out;
|
|
33184
|
+
}
|
|
33185
|
+
var MOTION_SCORE_ENV = "IMPRACTICAL_MOTION_SCORE";
|
|
33186
|
+
function motionScoreEnabled(env = process.env) {
|
|
33187
|
+
return !/^(0|false|off|no)$/i.test(env[MOTION_SCORE_ENV]?.trim() ?? "");
|
|
33188
|
+
}
|
|
33189
|
+
var SCENE_SWITCH_CLUSTER_SECONDS = 0.3;
|
|
33190
|
+
var CUT_BOUNDARY_TOLERANCE_SECONDS = 0.35;
|
|
33191
|
+
var SCENE_MIN_SECONDS = 2;
|
|
33192
|
+
var isSceneRootToggle = (tween) => {
|
|
33193
|
+
if (!tween.scene || !tween.targets.includes("[data-scene]")) return null;
|
|
33194
|
+
const value = tween.values?.autoAlpha ?? tween.values?.opacity;
|
|
33195
|
+
return typeof value === "number" ? value : null;
|
|
33196
|
+
};
|
|
33197
|
+
function sceneSwitches(tweens) {
|
|
33198
|
+
const events = [];
|
|
33199
|
+
for (const tween of tweens) {
|
|
33200
|
+
const value = isSceneRootToggle(tween);
|
|
33201
|
+
if (value === null || !tween.scene) continue;
|
|
33202
|
+
events.push({ time: tween.end, scene: tween.scene, visible: value > 0.5 });
|
|
33203
|
+
}
|
|
33204
|
+
events.sort((a, b) => a.time - b.time);
|
|
33205
|
+
const switches = [];
|
|
33206
|
+
for (const event of events) {
|
|
33207
|
+
const current = switches[switches.length - 1];
|
|
33208
|
+
if (current && event.time - current.time <= SCENE_SWITCH_CLUSTER_SECONDS) {
|
|
33209
|
+
if (event.visible) current.shown.push(event.scene);
|
|
33210
|
+
else current.hidden.push(event.scene);
|
|
33211
|
+
} else {
|
|
33212
|
+
switches.push({
|
|
33213
|
+
time: event.time,
|
|
33214
|
+
hidden: event.visible ? [] : [event.scene],
|
|
33215
|
+
shown: event.visible ? [event.scene] : []
|
|
33216
|
+
});
|
|
33217
|
+
}
|
|
33218
|
+
}
|
|
33219
|
+
return switches;
|
|
33220
|
+
}
|
|
33221
|
+
function sceneSpans(tweens, duration3) {
|
|
33222
|
+
const shownAt = /* @__PURE__ */ new Map();
|
|
33223
|
+
const spans2 = [];
|
|
33224
|
+
const events = tweens.map((tween) => ({ tween, value: isSceneRootToggle(tween) })).filter((entry) => entry.value !== null).sort((a, b) => a.tween.end - b.tween.end);
|
|
33225
|
+
for (const { tween, value } of events) {
|
|
33226
|
+
if (value > 0.5) {
|
|
33227
|
+
if (!shownAt.has(tween.scene)) shownAt.set(tween.scene, tween.end);
|
|
33228
|
+
} else {
|
|
33229
|
+
const start = shownAt.get(tween.scene);
|
|
33230
|
+
if (start !== void 0) {
|
|
33231
|
+
spans2.push({ scene: tween.scene, start, end: tween.end });
|
|
33232
|
+
shownAt.delete(tween.scene);
|
|
33233
|
+
}
|
|
33234
|
+
}
|
|
33235
|
+
}
|
|
33236
|
+
for (const [scene, start] of shownAt) spans2.push({ scene, start, end: duration3 });
|
|
33237
|
+
return spans2.sort((a, b) => a.start - b.start);
|
|
33238
|
+
}
|
|
33239
|
+
var INSTANT_SECONDS = 0.05;
|
|
33240
|
+
var GENERIC_MIN_TOTAL_SECONDS = 2;
|
|
33241
|
+
var GENERIC_MIN_LONGEST_SECONDS = 1.2;
|
|
33242
|
+
var GENERIC_MAX_OVERLAP_SHARE = 0.4;
|
|
33243
|
+
var instantAlpha = (tween) => {
|
|
33244
|
+
if (tween.end - tween.start > INSTANT_SECONDS) return null;
|
|
33245
|
+
const value = tween.values?.autoAlpha ?? tween.values?.opacity;
|
|
33246
|
+
return typeof value === "number" ? value : null;
|
|
33247
|
+
};
|
|
33248
|
+
function buildSceneModel(tweens, duration3) {
|
|
33249
|
+
const declared = sceneSwitches(tweens);
|
|
33250
|
+
if (declared.length) {
|
|
33251
|
+
return {
|
|
33252
|
+
spans: sceneSpans(tweens, duration3),
|
|
33253
|
+
switches: declared,
|
|
33254
|
+
rootIds: /* @__PURE__ */ new Set(),
|
|
33255
|
+
sceneOf: (tween) => tween.scene ?? null
|
|
33256
|
+
};
|
|
33257
|
+
}
|
|
33258
|
+
const eventsByTarget = /* @__PURE__ */ new Map();
|
|
33259
|
+
for (const tween of tweens) {
|
|
33260
|
+
const value = instantAlpha(tween);
|
|
33261
|
+
const target = tween.targets[0];
|
|
33262
|
+
if (value === null || !target || target.startsWith("[data-cursor")) continue;
|
|
33263
|
+
const list = eventsByTarget.get(target) ?? [];
|
|
33264
|
+
list.push({ time: tween.end, visible: value > 0.5 });
|
|
33265
|
+
eventsByTarget.set(target, list);
|
|
33266
|
+
}
|
|
33267
|
+
const candidates = [];
|
|
33268
|
+
for (const [target, events2] of eventsByTarget) {
|
|
33269
|
+
events2.sort((a, b) => a.time - b.time);
|
|
33270
|
+
const spans3 = [];
|
|
33271
|
+
let shownAt = null;
|
|
33272
|
+
for (const event of events2) {
|
|
33273
|
+
if (event.visible) shownAt ??= event.time;
|
|
33274
|
+
else if (shownAt !== null) {
|
|
33275
|
+
spans3.push({ start: shownAt, end: event.time });
|
|
33276
|
+
shownAt = null;
|
|
33277
|
+
}
|
|
33278
|
+
}
|
|
33279
|
+
if (shownAt !== null) spans3.push({ start: shownAt, end: duration3 });
|
|
33280
|
+
if (!spans3.length) continue;
|
|
33281
|
+
const total = spans3.reduce((sum, span) => sum + (span.end - span.start), 0);
|
|
33282
|
+
const longest = Math.max(...spans3.map((span) => span.end - span.start));
|
|
33283
|
+
if (total < GENERIC_MIN_TOTAL_SECONDS || longest < GENERIC_MIN_LONGEST_SECONDS) continue;
|
|
33284
|
+
candidates.push({ target, spans: spans3, total });
|
|
33285
|
+
}
|
|
33286
|
+
candidates.sort((a, b) => b.total - a.total);
|
|
33287
|
+
const accepted = [];
|
|
33288
|
+
const overlap = (a, b) => Math.max(0, Math.min(a.end, b.end) - Math.max(a.start, b.start));
|
|
33289
|
+
for (const candidate of candidates) {
|
|
33290
|
+
let overlapping = 0;
|
|
33291
|
+
for (const span of candidate.spans) {
|
|
33292
|
+
for (const other of accepted) {
|
|
33293
|
+
for (const otherSpan of other.spans) overlapping += overlap(span, otherSpan);
|
|
33294
|
+
}
|
|
33295
|
+
}
|
|
33296
|
+
if (overlapping / candidate.total <= GENERIC_MAX_OVERLAP_SHARE) accepted.push(candidate);
|
|
33297
|
+
}
|
|
33298
|
+
const rootIds = new Set(accepted.map((candidate) => candidate.target));
|
|
33299
|
+
const spans2 = accepted.flatMap((candidate) => candidate.spans.map((span) => ({ scene: candidate.target, ...span }))).sort((a, b) => a.start - b.start);
|
|
33300
|
+
const events = [];
|
|
33301
|
+
for (const candidate of accepted) {
|
|
33302
|
+
for (const event of eventsByTarget.get(candidate.target) ?? []) {
|
|
33303
|
+
events.push({ time: event.time, scene: candidate.target, visible: event.visible });
|
|
33304
|
+
}
|
|
33305
|
+
}
|
|
33306
|
+
events.sort((a, b) => a.time - b.time);
|
|
33307
|
+
const switches = [];
|
|
33308
|
+
for (const event of events) {
|
|
33309
|
+
const current = switches[switches.length - 1];
|
|
33310
|
+
if (current && event.time - current.time <= 0.3) {
|
|
33311
|
+
if (event.visible) current.shown.push(event.scene);
|
|
33312
|
+
else current.hidden.push(event.scene);
|
|
33313
|
+
} else {
|
|
33314
|
+
switches.push({
|
|
33315
|
+
time: event.time,
|
|
33316
|
+
hidden: event.visible ? [] : [event.scene],
|
|
33317
|
+
shown: event.visible ? [event.scene] : []
|
|
33318
|
+
});
|
|
33319
|
+
}
|
|
33320
|
+
}
|
|
33321
|
+
const sceneOf = (tween) => {
|
|
33322
|
+
if (rootIds.has(tween.targets[0] ?? "")) return null;
|
|
33323
|
+
const mid = (tween.start + tween.end) / 2;
|
|
33324
|
+
for (const span of spans2) if (mid >= span.start && mid <= span.end) return span.scene;
|
|
33325
|
+
return null;
|
|
33326
|
+
};
|
|
33327
|
+
return { spans: spans2, switches, rootIds, sceneOf };
|
|
33328
|
+
}
|
|
33329
|
+
var EXIT_GLYPH = {
|
|
33330
|
+
squash: "S",
|
|
33331
|
+
wipe: "W",
|
|
33332
|
+
slide: "L",
|
|
33333
|
+
fade: "F",
|
|
33334
|
+
camera: "C",
|
|
33335
|
+
hard: "\xB7"
|
|
33336
|
+
};
|
|
33337
|
+
var TRANSFORM_PROPS = /* @__PURE__ */ new Set(["x", "y", "xPercent", "yPercent", "scale", "scaleX", "scaleY", "rotation"]);
|
|
33338
|
+
function classifyExit(tweens, swtch, model) {
|
|
33339
|
+
const sceneOf = (tween) => tween.scene ?? (model ? model.sceneOf(tween) : null);
|
|
33340
|
+
const window2 = tweens.filter((tween) => {
|
|
33341
|
+
if (tween.end <= swtch.time - 0.9 || tween.end > swtch.time + 0.15) return false;
|
|
33342
|
+
if (tween.end - tween.start <= 0.01) return false;
|
|
33343
|
+
if (isSceneRootToggle(tween) !== null) return false;
|
|
33344
|
+
if (model?.rootIds.has(tween.targets[0] ?? "")) return false;
|
|
33345
|
+
const scene = sceneOf(tween);
|
|
33346
|
+
return scene !== null && swtch.hidden.includes(scene);
|
|
33347
|
+
});
|
|
33348
|
+
const has = (predicate) => window2.some(predicate);
|
|
33349
|
+
if (has(
|
|
33350
|
+
(tween) => ["scale", "scaleX", "scaleY"].some(
|
|
33351
|
+
(prop) => typeof tween.values?.[prop] === "number" && tween.values[prop] <= 0.15
|
|
33352
|
+
)
|
|
33353
|
+
)) {
|
|
33354
|
+
return "squash";
|
|
33355
|
+
}
|
|
33356
|
+
if (has((tween) => tween.props.some((prop) => prop.toLowerCase().includes("clippath")))) return "wipe";
|
|
33357
|
+
if (has(
|
|
33358
|
+
(tween) => ["x", "y"].some((prop) => typeof tween.values?.[prop] === "number" && Math.abs(tween.values[prop]) >= 120) || ["xPercent", "yPercent"].some(
|
|
33359
|
+
(prop) => typeof tween.values?.[prop] === "number" && Math.abs(tween.values[prop]) >= 60
|
|
33360
|
+
)
|
|
33361
|
+
)) {
|
|
33362
|
+
return "slide";
|
|
33363
|
+
}
|
|
33364
|
+
if (has((tween) => {
|
|
33365
|
+
const value = tween.values?.autoAlpha ?? tween.values?.opacity;
|
|
33366
|
+
return typeof value === "number" && value <= 0.1;
|
|
33367
|
+
})) {
|
|
33368
|
+
return "fade";
|
|
33369
|
+
}
|
|
33370
|
+
const cameraMoves = tweens.some(
|
|
33371
|
+
(tween) => tween.scene === null && (!model || model.sceneOf(tween) === null) && tween.props.some((prop) => TRANSFORM_PROPS.has(prop)) && tween.start < swtch.time + 0.4 && tween.end > swtch.time - 0.4
|
|
33372
|
+
);
|
|
33373
|
+
return cameraMoves ? "camera" : "hard";
|
|
33374
|
+
}
|
|
33375
|
+
function exitProfile(tweens, model) {
|
|
33376
|
+
const switches = model ? model.switches : sceneSwitches(tweens);
|
|
33377
|
+
return switches.filter((swtch) => swtch.hidden.length > 0).map((swtch) => ({ time: swtch.time, mechanism: classifyExit(tweens, swtch, model) }));
|
|
33378
|
+
}
|
|
33379
|
+
function exitMonotonyFlags(exits) {
|
|
33380
|
+
const flags = [];
|
|
33381
|
+
let runStart = 0;
|
|
33382
|
+
for (let i = 1; i <= exits.length; i += 1) {
|
|
33383
|
+
if (i === exits.length || exits[i].mechanism !== exits[runStart].mechanism) {
|
|
33384
|
+
const runLength = i - runStart;
|
|
33385
|
+
if (runLength >= 3 && exits[runStart].mechanism !== "camera") {
|
|
33386
|
+
const times = exits.slice(runStart, i).map((exit) => `${exit.time.toFixed(1)}s`).join(", ");
|
|
33387
|
+
flags.push(
|
|
33388
|
+
`EXIT MONOTONY: ${runLength} consecutive '${exits[runStart].mechanism}' exits (${times}) \u2014 the same leave-the-stage move back to back reads as a loop.`
|
|
33389
|
+
);
|
|
33390
|
+
}
|
|
33391
|
+
runStart = i;
|
|
33392
|
+
}
|
|
33393
|
+
}
|
|
33394
|
+
if (exits.length >= 4) {
|
|
33395
|
+
const counts = /* @__PURE__ */ new Map();
|
|
33396
|
+
for (const exit of exits) counts.set(exit.mechanism, (counts.get(exit.mechanism) ?? 0) + 1);
|
|
33397
|
+
const [topMechanism, topCount] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
33398
|
+
if (topCount / exits.length > 0.5 && topMechanism !== "camera") {
|
|
33399
|
+
flags.push(
|
|
33400
|
+
`EXIT MONOTONY: '${topMechanism}' is ${topCount} of ${exits.length} exits \u2014 one mechanism is carrying the whole film.`
|
|
33401
|
+
);
|
|
33402
|
+
}
|
|
33403
|
+
}
|
|
33404
|
+
return flags;
|
|
33405
|
+
}
|
|
33406
|
+
function cutAlignment(cutTimes, beats) {
|
|
33407
|
+
const boundaries = [...new Set(beats.flatMap((beat) => [beat.start, beat.end]))].sort((a, b) => a - b);
|
|
33408
|
+
return cutTimes.map((time3) => {
|
|
33409
|
+
if (!boundaries.length) return { time: time3, aligned: true, nearest: null };
|
|
33410
|
+
const nearest = boundaries.reduce(
|
|
33411
|
+
(best, boundary) => Math.abs(boundary - time3) < Math.abs(best - time3) ? boundary : best
|
|
33412
|
+
);
|
|
33413
|
+
return { time: time3, aligned: Math.abs(nearest - time3) <= CUT_BOUNDARY_TOLERANCE_SECONDS, nearest };
|
|
33414
|
+
});
|
|
33415
|
+
}
|
|
33416
|
+
var DEBT_MIN_SECONDS = 1.2;
|
|
33417
|
+
function displacementDebt(tweens, duration3, model = buildSceneModel(tweens, duration3)) {
|
|
33418
|
+
const spans2 = model.spans;
|
|
33419
|
+
const endOfScene = /* @__PURE__ */ new Map();
|
|
33420
|
+
for (const span of spans2) endOfScene.set(span.scene, Math.max(endOfScene.get(span.scene) ?? 0, span.end));
|
|
33421
|
+
const states = /* @__PURE__ */ new Map();
|
|
33422
|
+
for (const tween of [...tweens].sort((a, b) => a.end - b.end)) {
|
|
33423
|
+
if (isSceneRootToggle(tween) !== null) continue;
|
|
33424
|
+
const scene = tween.scene ?? model.sceneOf(tween);
|
|
33425
|
+
if (!scene) continue;
|
|
33426
|
+
const target = tween.targets[0];
|
|
33427
|
+
if (!target || target.startsWith("[data-cursor") || model.rootIds.has(target)) continue;
|
|
33428
|
+
const key = `${scene}\0${target}`;
|
|
33429
|
+
const state = states.get(key) ?? { values: /* @__PURE__ */ new Map(), lastMove: 0, exited: false };
|
|
33430
|
+
const alphaValue = tween.values?.autoAlpha ?? tween.values?.opacity;
|
|
33431
|
+
const isEntrance = typeof alphaValue === "number" && alphaValue > 0.5;
|
|
33432
|
+
for (const prop of tween.props) {
|
|
33433
|
+
if (tween.yoyo || isEntrance) break;
|
|
33434
|
+
if (TRANSFORM_PROPS.has(prop) && typeof tween.values?.[prop] === "number") {
|
|
33435
|
+
state.values.set(prop, tween.values[prop]);
|
|
33436
|
+
state.lastMove = tween.end;
|
|
33437
|
+
}
|
|
33438
|
+
}
|
|
33439
|
+
const alpha = tween.values?.autoAlpha ?? tween.values?.opacity;
|
|
33440
|
+
if (typeof alpha === "number" && alpha <= 0.1) state.exited = true;
|
|
33441
|
+
if (typeof alpha === "number" && alpha > 0.5) state.exited = false;
|
|
33442
|
+
for (const prop of ["scale", "scaleX", "scaleY"]) {
|
|
33443
|
+
const value = tween.values?.[prop];
|
|
33444
|
+
if (typeof value === "number" && value <= 0.05 && !tween.yoyo) state.exited = true;
|
|
33445
|
+
}
|
|
33446
|
+
states.set(key, state);
|
|
33447
|
+
}
|
|
33448
|
+
const debts = [];
|
|
33449
|
+
for (const [key, state] of states) {
|
|
33450
|
+
if (state.exited) continue;
|
|
33451
|
+
const [scene, target] = key.split("\0");
|
|
33452
|
+
const sceneEnd = endOfScene.get(scene) ?? duration3;
|
|
33453
|
+
if (sceneEnd - state.lastMove < DEBT_MIN_SECONDS) continue;
|
|
33454
|
+
const off = [...state.values.entries()].filter(([prop, value]) => {
|
|
33455
|
+
if (prop === "x" || prop === "y") return Math.abs(value) >= 8;
|
|
33456
|
+
if (prop === "xPercent" || prop === "yPercent") return Math.abs(value) >= 8;
|
|
33457
|
+
if (prop === "rotation") return Math.abs(value) >= 2;
|
|
33458
|
+
return Math.abs(value - 1) >= 0.03;
|
|
33459
|
+
});
|
|
33460
|
+
if (!off.length) continue;
|
|
33461
|
+
debts.push({
|
|
33462
|
+
scene,
|
|
33463
|
+
target,
|
|
33464
|
+
props: off.map(([prop, value]) => `${prop} ${value}`),
|
|
33465
|
+
movedAt: state.lastMove,
|
|
33466
|
+
sceneEnd
|
|
33467
|
+
});
|
|
33468
|
+
}
|
|
33469
|
+
return debts.sort((a, b) => a.movedAt - b.movedAt);
|
|
33470
|
+
}
|
|
33471
|
+
function motionScoreSection(input) {
|
|
33472
|
+
const { tweens, beats, duration: duration3 } = input;
|
|
33473
|
+
const model = buildSceneModel(tweens, duration3);
|
|
33474
|
+
const switches = model.switches.filter((swtch) => swtch.time > 0.5 || swtch.hidden.length > 0);
|
|
33475
|
+
if (!switches.length) return "";
|
|
33476
|
+
const lines = ["MOTION SCORE \u2014 scene changes, exits, and leftovers (whole film):"];
|
|
33477
|
+
const cuts = cutAlignment(switches.map((swtch) => swtch.time), beats);
|
|
33478
|
+
const secondsPerChar = duration3 > 120 ? 2 : 1;
|
|
33479
|
+
const chars = Math.max(1, Math.ceil(duration3 / secondsPerChar));
|
|
33480
|
+
if (beats.length) {
|
|
33481
|
+
const beatRow = Array.from({ length: chars }, () => ".");
|
|
33482
|
+
for (const beat of beats.flatMap((entry) => [entry.start, entry.end])) {
|
|
33483
|
+
const index = Math.min(chars - 1, Math.floor(beat / secondsPerChar));
|
|
33484
|
+
beatRow[index] = "|";
|
|
33485
|
+
}
|
|
33486
|
+
const cutRow = Array.from({ length: chars }, () => ".");
|
|
33487
|
+
for (const cut of cuts) {
|
|
33488
|
+
const index = Math.min(chars - 1, Math.floor(cut.time / secondsPerChar));
|
|
33489
|
+
cutRow[index] = cut.aligned ? "#" : "x";
|
|
33490
|
+
}
|
|
33491
|
+
lines.push(` beats ${beatRow.join("")}`);
|
|
33492
|
+
lines.push(` cuts ${cutRow.join("")}`);
|
|
33493
|
+
lines.push(
|
|
33494
|
+
` | declared beat boundary \xB7 # scene change on a boundary \xB7 x mid-beat (${secondsPerChar}s per column)`
|
|
33495
|
+
);
|
|
33496
|
+
}
|
|
33497
|
+
lines.push(` SCENE CHANGES: ${switches.length} in ${duration3.toFixed(1)}s`);
|
|
33498
|
+
for (const cut of cuts) {
|
|
33499
|
+
if (!cut.aligned && cut.nearest !== null) {
|
|
33500
|
+
lines.push(
|
|
33501
|
+
` CUT AT A RANDOM POINT: ${cut.time.toFixed(1)}s is mid-beat \u2014 the nearest declared boundary is ${cut.nearest.toFixed(1)}s. Cuts land on beat boundaries.`
|
|
33502
|
+
);
|
|
33503
|
+
}
|
|
33504
|
+
}
|
|
33505
|
+
for (const span of model.spans) {
|
|
33506
|
+
const life = span.end - span.start;
|
|
33507
|
+
if (life < SCENE_MIN_SECONDS) {
|
|
33508
|
+
lines.push(
|
|
33509
|
+
` SCENE TOO SHORT: "${span.scene}" lived ${life.toFixed(1)}s (${span.start.toFixed(1)}\u2013${span.end.toFixed(1)}s) \u2014 shorter than a viewer can register.`
|
|
33510
|
+
);
|
|
33511
|
+
}
|
|
33512
|
+
}
|
|
33513
|
+
const exits = exitProfile(tweens, model);
|
|
33514
|
+
if (exits.length) {
|
|
33515
|
+
const counts = /* @__PURE__ */ new Map();
|
|
33516
|
+
for (const exit of exits) counts.set(exit.mechanism, (counts.get(exit.mechanism) ?? 0) + 1);
|
|
33517
|
+
const histogram = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([mechanism, count]) => `${mechanism} ${count}`).join(" \xB7 ");
|
|
33518
|
+
lines.push(` EXITS [ ${exits.map((exit) => EXIT_GLYPH[exit.mechanism]).join(" ")} ] ${histogram}`);
|
|
33519
|
+
lines.push(...exitMonotonyFlags(exits).map((flag) => ` ${flag}`));
|
|
33520
|
+
}
|
|
33521
|
+
const debts = displacementDebt(tweens, duration3, model);
|
|
33522
|
+
if (debts.length) {
|
|
33523
|
+
lines.push(" DISPLACEMENT DEBT \u2014 still off their layout position when their scene ends:");
|
|
33524
|
+
for (const debt of debts.slice(0, 6)) {
|
|
33525
|
+
lines.push(
|
|
33526
|
+
` ${debt.target} (scene "${debt.scene}"): ${debt.props.join(", ")} from ${debt.movedAt.toFixed(1)}s until the scene ends at ${debt.sceneEnd.toFixed(1)}s (${(debt.sceneEnd - debt.movedAt).toFixed(1)}s)`
|
|
33527
|
+
);
|
|
33528
|
+
}
|
|
33529
|
+
if (debts.length > 6) lines.push(` \u2026and ${debts.length - 6} more`);
|
|
33530
|
+
}
|
|
33531
|
+
return lines.join("\n");
|
|
33532
|
+
}
|
|
33533
|
+
async function extractTimeline(page) {
|
|
33534
|
+
return page.evaluate(() => {
|
|
33535
|
+
const root = window.__timeline;
|
|
33536
|
+
if (!root || typeof root.getChildren !== "function") return null;
|
|
33537
|
+
const CONTROL = /* @__PURE__ */ new Set([
|
|
33538
|
+
"duration",
|
|
33539
|
+
"delay",
|
|
33540
|
+
"ease",
|
|
33541
|
+
"stagger",
|
|
33542
|
+
"repeat",
|
|
33543
|
+
"repeatDelay",
|
|
33544
|
+
"yoyo",
|
|
33545
|
+
"yoyoEase",
|
|
33546
|
+
"paused",
|
|
33547
|
+
"overwrite",
|
|
33548
|
+
"immediateRender",
|
|
33549
|
+
"onComplete",
|
|
33550
|
+
"onStart",
|
|
33551
|
+
"onUpdate",
|
|
33552
|
+
"onRepeat",
|
|
33553
|
+
"onReverseComplete",
|
|
33554
|
+
"callbackScope",
|
|
33555
|
+
"data",
|
|
33556
|
+
"id",
|
|
33557
|
+
"runBackwards",
|
|
33558
|
+
"lazy",
|
|
33559
|
+
"startAt",
|
|
33560
|
+
"parent",
|
|
33561
|
+
"defaults",
|
|
33562
|
+
"autoRemoveChildren",
|
|
33563
|
+
"smoothChildTiming",
|
|
33564
|
+
"onCompleteParams",
|
|
33565
|
+
"onStartParams",
|
|
33566
|
+
"onUpdateParams",
|
|
33567
|
+
"keyframes"
|
|
33568
|
+
]);
|
|
33569
|
+
const describe3 = (target) => {
|
|
33570
|
+
if (target instanceof Element) {
|
|
33571
|
+
for (const attr of Array.from(target.attributes)) {
|
|
33572
|
+
if (attr.name.startsWith("data-") && !attr.name.startsWith("data-studio-")) {
|
|
33573
|
+
return `[${attr.name}]`;
|
|
33574
|
+
}
|
|
33575
|
+
}
|
|
33576
|
+
if (target.id) return `${target.tagName.toLowerCase()}#${target.id}`;
|
|
33577
|
+
const cls = target.classList[0];
|
|
33578
|
+
return cls ? `${target.tagName.toLowerCase()}.${cls}` : target.tagName.toLowerCase();
|
|
33579
|
+
}
|
|
33580
|
+
if (target && typeof target === "object") {
|
|
33581
|
+
return `(${target.constructor?.name ?? "object"})`;
|
|
33582
|
+
}
|
|
33583
|
+
return "(value)";
|
|
33584
|
+
};
|
|
33585
|
+
const out = [];
|
|
33586
|
+
const walk = (node, offset) => {
|
|
33587
|
+
for (const child of node.getChildren?.(false, true, true) ?? []) {
|
|
33588
|
+
const start = offset + child.startTime();
|
|
33589
|
+
if (typeof child.getChildren === "function") {
|
|
33590
|
+
walk(child, start);
|
|
33591
|
+
continue;
|
|
33592
|
+
}
|
|
33593
|
+
const vars = child.vars ?? {};
|
|
33594
|
+
const props = Object.keys(vars).filter(
|
|
33595
|
+
(key) => !CONTROL.has(key) && typeof vars[key] !== "function"
|
|
33596
|
+
);
|
|
33597
|
+
const values = {};
|
|
33598
|
+
for (const key of props) {
|
|
33599
|
+
const value = vars[key];
|
|
33600
|
+
if (typeof value === "number" || typeof value === "string") values[key] = value;
|
|
33601
|
+
}
|
|
33602
|
+
const targets = child.targets?.() ?? [];
|
|
33603
|
+
const firstElement = targets.find((target) => target instanceof Element);
|
|
33604
|
+
const scene = firstElement?.closest("[data-scene]")?.getAttribute("data-scene") ?? null;
|
|
33605
|
+
const easeRaw = vars.ease;
|
|
33606
|
+
out.push({
|
|
33607
|
+
start,
|
|
33608
|
+
end: start + child.totalDuration(),
|
|
33609
|
+
targets: [...new Set(targets.map(describe3))],
|
|
33610
|
+
props,
|
|
33611
|
+
ease: typeof easeRaw === "string" ? easeRaw : easeRaw ? "custom" : "default",
|
|
33612
|
+
values,
|
|
33613
|
+
scene,
|
|
33614
|
+
yoyo: Boolean(vars.yoyo)
|
|
33615
|
+
});
|
|
33616
|
+
}
|
|
33617
|
+
};
|
|
33618
|
+
walk(root, 0);
|
|
33619
|
+
return out;
|
|
33620
|
+
});
|
|
33621
|
+
}
|
|
33622
|
+
async function compositeAndDiff(page, frames, options = {}) {
|
|
33623
|
+
return page.evaluate(
|
|
33624
|
+
async (input) => {
|
|
33625
|
+
const imgs = [];
|
|
33626
|
+
for (const [index, frame] of input.frames.entries()) {
|
|
33627
|
+
const img = new Image();
|
|
33628
|
+
await new Promise((resolve, reject) => {
|
|
33629
|
+
img.onload = () => resolve();
|
|
33630
|
+
img.onerror = () => reject(new Error(`frame ${index} failed to load`));
|
|
33631
|
+
img.src = "data:image/jpeg;base64," + frame.base64;
|
|
33632
|
+
});
|
|
33633
|
+
imgs.push(img);
|
|
33634
|
+
}
|
|
33635
|
+
const dw = 160;
|
|
33636
|
+
const dh = 90;
|
|
33637
|
+
const diffCanvas = document.createElement("canvas");
|
|
33638
|
+
diffCanvas.width = dw;
|
|
33639
|
+
diffCanvas.height = dh;
|
|
33640
|
+
const diffCtx = diffCanvas.getContext("2d", { willReadFrequently: true });
|
|
33641
|
+
if (!diffCtx) throw new Error("2d canvas unavailable");
|
|
33642
|
+
let prev = null;
|
|
33643
|
+
const diffs = [];
|
|
33644
|
+
const cellDiffs = [];
|
|
33645
|
+
const gridCols = 8;
|
|
33646
|
+
const gridRows = 6;
|
|
33647
|
+
const cellW = dw / gridCols;
|
|
33648
|
+
const cellH = dh / gridRows;
|
|
33649
|
+
for (const img of imgs) {
|
|
33650
|
+
diffCtx.drawImage(img, 0, 0, dw, dh);
|
|
33651
|
+
const data = diffCtx.getImageData(0, 0, dw, dh).data;
|
|
33652
|
+
if (prev) {
|
|
33653
|
+
let changed = 0;
|
|
33654
|
+
const cells = new Array(gridCols * gridRows).fill(0);
|
|
33655
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
33656
|
+
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
|
+
if (delta > 60) {
|
|
33658
|
+
changed += 1;
|
|
33659
|
+
const px = i / 4 % dw;
|
|
33660
|
+
const py = Math.floor(i / 4 / dw);
|
|
33661
|
+
cells[Math.floor(py / cellH) * gridCols + Math.floor(px / cellW)] += 1;
|
|
33662
|
+
}
|
|
33663
|
+
}
|
|
33664
|
+
diffs.push(changed / (dw * dh));
|
|
33665
|
+
cellDiffs.push(cells.map((count) => count / (cellW * cellH)));
|
|
33666
|
+
}
|
|
33667
|
+
prev = data.slice();
|
|
33668
|
+
}
|
|
33669
|
+
if (!input.includeSheets) return { sheets: [], diffs, cellDiffs };
|
|
33670
|
+
const ratio = imgs[0].naturalHeight / Math.max(1, imgs[0].naturalWidth);
|
|
33671
|
+
const tw = input.tileWidth;
|
|
33672
|
+
const th = Math.round(tw * ratio);
|
|
33673
|
+
const sheets = [];
|
|
33674
|
+
for (let s = 0; s * input.perSheet < imgs.length; s += 1) {
|
|
33675
|
+
const slice = imgs.slice(s * input.perSheet, (s + 1) * input.perSheet);
|
|
33676
|
+
const rows = Math.ceil(slice.length / input.columns);
|
|
33677
|
+
const canvas = document.createElement("canvas");
|
|
33678
|
+
canvas.width = input.columns * tw;
|
|
33679
|
+
canvas.height = rows * th;
|
|
33680
|
+
const ctx = canvas.getContext("2d");
|
|
33681
|
+
if (!ctx) throw new Error("2d canvas unavailable");
|
|
33682
|
+
ctx.fillStyle = "#000";
|
|
33683
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
33684
|
+
slice.forEach((img, i) => {
|
|
33685
|
+
const x = i % input.columns * tw;
|
|
33686
|
+
const y = Math.floor(i / input.columns) * th;
|
|
33687
|
+
ctx.drawImage(img, x, y, tw, th);
|
|
33688
|
+
const label = input.frames[s * input.perSheet + i].time.toFixed(2) + "s";
|
|
33689
|
+
ctx.font = "bold 16px monospace";
|
|
33690
|
+
const width = ctx.measureText(label).width + 10;
|
|
33691
|
+
ctx.fillStyle = "rgba(0,0,0,0.72)";
|
|
33692
|
+
ctx.fillRect(x, y, width, 22);
|
|
33693
|
+
ctx.fillStyle = "#ffd21f";
|
|
33694
|
+
ctx.fillText(label, x + 5, y + 16);
|
|
33695
|
+
});
|
|
33696
|
+
sheets.push(canvas.toDataURL("image/jpeg", 0.8).split(",")[1]);
|
|
33697
|
+
}
|
|
33698
|
+
return { sheets, diffs, cellDiffs };
|
|
33699
|
+
},
|
|
33700
|
+
{
|
|
33701
|
+
frames,
|
|
33702
|
+
columns: options.columns ?? 4,
|
|
33703
|
+
tileWidth: options.tileWidth ?? 480,
|
|
33704
|
+
perSheet: options.perSheet ?? 16,
|
|
33705
|
+
includeSheets: options.includeSheets ?? true
|
|
33706
|
+
}
|
|
33707
|
+
);
|
|
33708
|
+
}
|
|
33709
|
+
var getMotionTool = {
|
|
33710
|
+
name: "get_motion",
|
|
33711
|
+
title: "See and measure motion over an interval",
|
|
33712
|
+
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.",
|
|
33713
|
+
inputSchema: {
|
|
33714
|
+
draftId: external_exports.string().describe("The draft id."),
|
|
33715
|
+
from: external_exports.number().min(0).optional().describe("Window start in seconds. Defaults to 0."),
|
|
33716
|
+
to: external_exports.number().min(0).optional().describe("Window end in seconds. Defaults to the scene duration."),
|
|
33717
|
+
everyNthFrame: external_exports.number().int().min(1).max(30).optional().describe(
|
|
33718
|
+
`Sample one frame in N. Default ${DEFAULT_EVERY_NTH_FRAME} (6 samples/second at 30fps) \u2014 the density at which motion becomes readable across stills.`
|
|
33719
|
+
),
|
|
33720
|
+
detail: external_exports.enum(["quick", "full"]).optional().describe(
|
|
33721
|
+
'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".'
|
|
33722
|
+
),
|
|
33723
|
+
reviewMode: external_exports.enum(["final"]).optional().describe(
|
|
33724
|
+
'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.'
|
|
33725
|
+
)
|
|
33726
|
+
},
|
|
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
|
+
handler: async (args, ctx, progress) => {
|
|
33733
|
+
const finalReview = args.reviewMode === "final";
|
|
33734
|
+
const session = await openPreview(ctx, String(args.draftId));
|
|
33735
|
+
try {
|
|
33736
|
+
const duration3 = session.duration;
|
|
33737
|
+
const from = finalReview ? 0 : Math.max(0, Math.min(Number(args.from ?? 0), duration3));
|
|
33738
|
+
const to = finalReview ? duration3 : Math.max(0, Math.min(Number(args.to ?? duration3), duration3));
|
|
33739
|
+
if (to <= from) {
|
|
33740
|
+
return {
|
|
33741
|
+
text: `get_motion needs from < to within 0\u2013${formatTime(duration3)}s (got ${from}\u2013${to}).`,
|
|
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
|
|
33757
|
+
});
|
|
33758
|
+
const frames = [];
|
|
33759
|
+
const textSamples = [];
|
|
33760
|
+
const censusOn = textCensusEnabled();
|
|
33761
|
+
for (const [index, time3] of plan.times.entries()) {
|
|
33762
|
+
if (progress?.signal?.aborted) break;
|
|
33763
|
+
await renderSettledFrame(session.page, time3);
|
|
33764
|
+
const shot = await session.page.screenshot({ type: "jpeg", quality: 80 });
|
|
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
|
|
33783
|
+
});
|
|
33784
|
+
let catalogSection = "";
|
|
33785
|
+
if (passPlan.includeCatalog && metricCatalogEnabled() && frames.length >= 2) {
|
|
33786
|
+
try {
|
|
33787
|
+
const features = await sampleCatalogFeatures(sheetPage, frames);
|
|
33788
|
+
const records = computeCatalogMetrics({
|
|
33789
|
+
features,
|
|
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");
|
|
33839
|
+
}
|
|
33840
|
+
let scoreSection = "";
|
|
33841
|
+
if (motionScoreEnabled() && tweensAll?.length) {
|
|
33842
|
+
try {
|
|
33843
|
+
scoreSection = motionScoreSection({
|
|
33844
|
+
tweens: tweensAll,
|
|
33845
|
+
beats: ctx.qualityLoopState?.beatsByDraft.get(String(args.draftId)) ?? [],
|
|
33846
|
+
duration: duration3
|
|
33847
|
+
});
|
|
33848
|
+
} catch {
|
|
33849
|
+
}
|
|
33850
|
+
}
|
|
33851
|
+
const splitFocus = splitFocusMoments(
|
|
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
|
+
}
|
|
33898
|
+
}
|
|
33899
|
+
}
|
|
33900
|
+
return {
|
|
33901
|
+
text: lines.join("\n"),
|
|
33902
|
+
images: sheets.map((base643) => ({ base64: base643, mimeType: "image/jpeg" }))
|
|
33903
|
+
};
|
|
33904
|
+
} finally {
|
|
33905
|
+
await session.browser.close().catch(() => void 0);
|
|
33906
|
+
}
|
|
33907
|
+
}
|
|
33908
|
+
};
|
|
33909
|
+
function motionLabServerInstructions() {
|
|
33910
|
+
return `
|
|
33911
|
+
|
|
33912
|
+
MOTION LAB IS ENABLED \u2014 an opt-in local experiment:
|
|
33913
|
+
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.
|
|
33914
|
+
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.`;
|
|
33915
|
+
}
|
|
33916
|
+
|
|
33917
|
+
// src/mcp/gather-references.ts
|
|
33918
|
+
import { existsSync, statSync } from "node:fs";
|
|
33919
|
+
var referenceEntry = external_exports.object({
|
|
33920
|
+
kind: external_exports.enum(["local-code", "product-ui-screenshot", "site-code", "website", "screenshot", "image-to-use", "component-class-image", "mockup"]),
|
|
33921
|
+
/** URL or local file path. */
|
|
33922
|
+
source: external_exports.string().trim().min(4),
|
|
33923
|
+
/** What this reference will inform, e.g. "the terminal styling". */
|
|
33924
|
+
informs: external_exports.string().trim().min(5)
|
|
33925
|
+
});
|
|
33926
|
+
var gatherReferencesInput = {
|
|
33927
|
+
references: external_exports.array(referenceEntry).optional().describe(
|
|
33928
|
+
"The references found. URLs are fetched to verify they exist; local paths are checked on disk."
|
|
33929
|
+
),
|
|
33930
|
+
noneFound: external_exports.object({ searched: external_exports.array(external_exports.string().trim().min(3)).min(2) }).optional().describe(
|
|
33931
|
+
'Only when the fallback ladder is exhausted: what was searched (local code, product site, component-class images). The film proceeds marked "designed from convention".'
|
|
33932
|
+
)
|
|
33933
|
+
};
|
|
33934
|
+
async function captureSource(source) {
|
|
33935
|
+
if (/^https?:\/\//i.test(source)) {
|
|
33936
|
+
let browser = null;
|
|
33937
|
+
try {
|
|
33938
|
+
browser = await launchChromium();
|
|
33939
|
+
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
|
33940
|
+
await page.goto(source, { waitUntil: "load", timeout: 2e4 });
|
|
33941
|
+
await page.waitForTimeout(1200);
|
|
33942
|
+
const shot = await page.screenshot({ type: "jpeg", quality: 75 });
|
|
33943
|
+
return { verified: true, image: { base64: shot.toString("base64"), mimeType: "image/jpeg" } };
|
|
33944
|
+
} catch {
|
|
33945
|
+
return { verified: false };
|
|
33946
|
+
} finally {
|
|
33947
|
+
await browser?.close().catch(() => void 0);
|
|
33948
|
+
}
|
|
33949
|
+
}
|
|
33950
|
+
try {
|
|
33951
|
+
return { verified: existsSync(source) && statSync(source).size > 0 };
|
|
33952
|
+
} catch {
|
|
33953
|
+
return { verified: false };
|
|
33954
|
+
}
|
|
33955
|
+
}
|
|
33956
|
+
var gatherReferencesTool = {
|
|
33957
|
+
name: "gather_references",
|
|
33958
|
+
title: "Record and verify the film's design references",
|
|
33959
|
+
description: `PHASED FLOW step 1b \u2014 REQUIRED before create_video when the phased flow is enabled. FIRST STUDY, THEN RECORD: if your environment has web/image search and an image-viewing tool (e.g. view_image), use them BEFORE calling this \u2014 search for real screenshots of the product in use and real examples of each component class you will build, download and VIEW them so their pixels are in your context. Then record here what you studied. The references that matter are of the ACTUAL SOFTWARE \u2014 what the product looks like in use (its app, editor, dashboard, the real screens users see) \u2014 NOT the marketing homepage. Fallback ladder: the product's real front-end code on this machine (best \u2014 also pass it as appCode) \u2192 product-ui-screenshots from its own docs, help center, changelog, demo videos, or review sites \u2192 the product's site code \u2192 the marketing site (weakest; styling tokens only) \u2192 images of the component CLASS (a real terminal, a real dashboard) \u2192 mockups. Each URL is fetched to verify it is RENDERED and its screenshot returned to you \u2014 study those pixels. Style components to match what the references actually show. Only when the ladder is genuinely exhausted, call with noneFound listing what you searched \u2014 the film then proceeds marked "designed from convention". Rights note: searched images are styling references ONLY; image-to-use entries (placed in the film) must come from the product's own assets or press kit, never search thumbnails.`,
|
|
33960
|
+
inputSchema: gatherReferencesInput,
|
|
33961
|
+
local: true,
|
|
33962
|
+
// No cloud routes are invoked; alias to an unmetered name so admission is skipped.
|
|
33963
|
+
admitAs: "studio_link",
|
|
33964
|
+
handler: async (args, ctx) => {
|
|
33965
|
+
const references = args.references ?? [];
|
|
33966
|
+
const noneFound = args.noneFound;
|
|
33967
|
+
if (!references.length && !noneFound) {
|
|
33968
|
+
return {
|
|
33969
|
+
text: "Pass references (each with kind, source, informs) or \u2014 only after genuinely exhausting the fallback ladder \u2014 noneFound with what you searched.",
|
|
33970
|
+
isError: true
|
|
33971
|
+
};
|
|
33972
|
+
}
|
|
33973
|
+
const entries = [];
|
|
33974
|
+
const images = [];
|
|
33975
|
+
for (const reference of references.slice(0, 6)) {
|
|
33976
|
+
const captured = await captureSource(reference.source);
|
|
33977
|
+
entries.push({ ...reference, verified: captured.verified });
|
|
33978
|
+
if (captured.image) images.push(captured.image);
|
|
33979
|
+
}
|
|
33980
|
+
const ledger = { entries, ...noneFound ? { noneFound } : {} };
|
|
33981
|
+
if (ctx.qualityLoopState) ctx.qualityLoopState.referenceLedger = ledger;
|
|
33982
|
+
process.stderr.write("gather_references ledger: " + JSON.stringify(ledger) + "\n");
|
|
33983
|
+
const verified = entries.filter((entry) => entry.verified).length;
|
|
33984
|
+
const lines = [
|
|
33985
|
+
`Reference ledger recorded: ${entries.length} reference(s), ${verified} verified.`,
|
|
33986
|
+
...entries.map(
|
|
33987
|
+
(entry) => ` [${entry.verified ? "ok" : "UNVERIFIED"}] ${entry.kind}: ${entry.source} \u2192 informs ${entry.informs}`
|
|
33988
|
+
),
|
|
33989
|
+
...noneFound ? [` none-found declared; searched: ${noneFound.searched.join("; ")}`] : [],
|
|
33990
|
+
...entries.length && !verified && !noneFound ? ["WARNING: nothing verified \u2014 fix the sources or declare noneFound honestly."] : [],
|
|
33991
|
+
images.length ? "The screenshots above ARE your references \u2014 design the components to match what they actually show, not what you imagine the product looks like." : "Design to what the references actually show. The final review judges fidelity against this ledger."
|
|
33992
|
+
];
|
|
33993
|
+
return { text: lines.join("\n"), images };
|
|
33994
|
+
}
|
|
33995
|
+
};
|
|
33996
|
+
|
|
33997
|
+
// src/mcp/install-kits.ts
|
|
33998
|
+
import { readFileSync } from "node:fs";
|
|
33999
|
+
import { fileURLToPath } from "node:url";
|
|
34000
|
+
var KIT_FILES = [
|
|
34001
|
+
"kits",
|
|
34002
|
+
"reveal",
|
|
34003
|
+
"actions",
|
|
34004
|
+
"ensemble",
|
|
34005
|
+
"looks",
|
|
34006
|
+
"sentence",
|
|
34007
|
+
"morphkit",
|
|
34008
|
+
"highlight",
|
|
34009
|
+
"impact",
|
|
34010
|
+
"streamkit"
|
|
34011
|
+
];
|
|
34012
|
+
function readKitSources() {
|
|
34013
|
+
return KIT_FILES.map((name) => ({
|
|
34014
|
+
path: `kit/${name}.ts`,
|
|
34015
|
+
content: readFileSync(
|
|
34016
|
+
fileURLToPath(new URL(`../assets/kits/${name}.ts`, import.meta.url)),
|
|
34017
|
+
"utf8"
|
|
34018
|
+
)
|
|
34019
|
+
}));
|
|
34020
|
+
}
|
|
34021
|
+
var installKitsTool = {
|
|
34022
|
+
name: "install_kits",
|
|
34023
|
+
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.",
|
|
34025
|
+
inputSchema: {
|
|
34026
|
+
draftId: external_exports.string().describe("The draft to install the kits into.")
|
|
34027
|
+
},
|
|
34028
|
+
local: true,
|
|
34029
|
+
// This is a draft write in route terms; meter it as one.
|
|
34030
|
+
admitAs: "write_draft_files",
|
|
34031
|
+
handler: async (args, ctx) => {
|
|
34032
|
+
const sources = readKitSources();
|
|
34033
|
+
const files = Object.fromEntries(sources.map((file2) => [file2.path, file2.content]));
|
|
34034
|
+
const output = await saveDraft(ctx, String(args.draftId), () => ({
|
|
34035
|
+
files,
|
|
34036
|
+
deletedPaths: []
|
|
34037
|
+
}));
|
|
34038
|
+
if (output.isError) return output;
|
|
34039
|
+
return {
|
|
34040
|
+
text: `Installed ${sources.length} kit files under kit/. Import from './kit/actions', './kit/ensemble', './kit/looks', './kit/sentence' etc. in direction.ts. NEVER hand-roll what they provide.
|
|
34041
|
+
` + output.text
|
|
34042
|
+
};
|
|
34043
|
+
}
|
|
34044
|
+
};
|
|
34045
|
+
|
|
34046
|
+
// src/mcp/install-font.ts
|
|
34047
|
+
var FONTSOURCE_CDN = "https://cdn.jsdelivr.net/npm/@fontsource";
|
|
34048
|
+
var MAX_WOFF2_BYTES = 3e5;
|
|
34049
|
+
var MAX_TOTAL_BYTES = 9e5;
|
|
34050
|
+
function fontsourceSlug(family) {
|
|
34051
|
+
return family.trim().toLowerCase().replace(/['’]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
34052
|
+
}
|
|
34053
|
+
function fontsourceUrl(family, weight, italic = false) {
|
|
34054
|
+
const slug = fontsourceSlug(family);
|
|
34055
|
+
return `${FONTSOURCE_CDN}/${slug}/files/${slug}-latin-${weight}-${italic ? "italic" : "normal"}.woff2`;
|
|
34056
|
+
}
|
|
34057
|
+
var MARKER = (family, weight, italic) => `/* install_font: ${family} ${weight}${italic ? " italic" : ""} */`;
|
|
34058
|
+
function fontFaceCss(family, entries) {
|
|
34059
|
+
return entries.map(
|
|
34060
|
+
(entry) => `${MARKER(family, entry.weight, entry.italic)}
|
|
34061
|
+
@font-face {
|
|
34062
|
+
font-family: '${family}';
|
|
34063
|
+
font-style: ${entry.italic ? "italic" : "normal"};
|
|
34064
|
+
font-weight: ${entry.weight};
|
|
34065
|
+
font-display: block;
|
|
34066
|
+
src: url(data:font/woff2;base64,${entry.base64}) format('woff2');
|
|
34067
|
+
}`
|
|
34068
|
+
).join("\n");
|
|
34069
|
+
}
|
|
34070
|
+
function mergeFontCss(existing, family, entries) {
|
|
34071
|
+
const fresh = entries.filter((entry) => !existing.includes(MARKER(family, entry.weight, entry.italic)));
|
|
34072
|
+
if (!fresh.length) return { css: existing, added: 0, skipped: entries.length };
|
|
34073
|
+
const block = fontFaceCss(family, fresh);
|
|
34074
|
+
const css = existing ? `${existing.replace(/\s*$/, "")}
|
|
34075
|
+
|
|
34076
|
+
${block}
|
|
34077
|
+
` : `${block}
|
|
34078
|
+
`;
|
|
34079
|
+
return { css, added: fresh.length, skipped: entries.length - fresh.length };
|
|
34080
|
+
}
|
|
34081
|
+
function notFoundMessage(family) {
|
|
34082
|
+
return `"${family}" is not on Fontsource, which carries open-licensed fonts only \u2014 this usually means it is a commercial face that cannot legally be auto-installed. Either use the closest bundled or open alternative and note the substitution, or ask the user to supply licensed font files. Do NOT fake it with an unbundled system family: the render guard will refuse, and rightly.`;
|
|
34083
|
+
}
|
|
34084
|
+
var installFontTool = {
|
|
34085
|
+
name: "install_font",
|
|
34086
|
+
title: "Package an open-licensed font into a draft",
|
|
34087
|
+
description: "Download an open-licensed font family (Fontsource: Google Fonts and friends) and inline it into the draft's styles.css as @font-face data URIs, so the render font guard passes and previews, exports, and every machine render the same glyphs. Use when the brand calls for a font outside the bundled set. Commercial faces are refused with an explanation \u2014 substitute openly and say so, never fake with a system family. Returns the exact font-family value to use. If styles.css was created fresh, import it from App.tsx.",
|
|
34088
|
+
inputSchema: {
|
|
34089
|
+
draftId: external_exports.string().describe("The draft to install the font into."),
|
|
34090
|
+
family: external_exports.string().min(2).describe('Font family name, e.g. "Space Grotesk".'),
|
|
34091
|
+
weights: external_exports.array(external_exports.number().int().min(100).max(900)).min(1).max(6).optional().describe("Weights to install (default [400, 700])."),
|
|
34092
|
+
italic: external_exports.boolean().optional().describe("Also install italic styles (default false).")
|
|
34093
|
+
},
|
|
34094
|
+
local: true,
|
|
34095
|
+
// Route-wise this is a draft write; meter it as one.
|
|
34096
|
+
admitAs: "write_draft_files",
|
|
31901
34097
|
handler: async (args, ctx) => {
|
|
31902
|
-
const
|
|
34098
|
+
const family = String(args.family).trim();
|
|
34099
|
+
const weights = [...new Set(args.weights ?? [400, 700])].sort(
|
|
34100
|
+
(a, b) => a - b
|
|
34101
|
+
);
|
|
34102
|
+
const styles = args.italic ? [false, true] : [false];
|
|
34103
|
+
const entries = [];
|
|
34104
|
+
let total = 0;
|
|
34105
|
+
for (const italic of styles) {
|
|
34106
|
+
for (const weight of weights) {
|
|
34107
|
+
const url2 = fontsourceUrl(family, weight, italic);
|
|
34108
|
+
let response;
|
|
34109
|
+
try {
|
|
34110
|
+
response = await fetch(url2);
|
|
34111
|
+
} catch (error51) {
|
|
34112
|
+
return {
|
|
34113
|
+
isError: true,
|
|
34114
|
+
text: `Could not reach the font CDN (${error51 instanceof Error ? error51.message : String(error51)}). Check network and retry.`
|
|
34115
|
+
};
|
|
34116
|
+
}
|
|
34117
|
+
if (response.status === 404) {
|
|
34118
|
+
if (!entries.length && weight === weights[0] && !italic) {
|
|
34119
|
+
return { isError: true, text: notFoundMessage(family) };
|
|
34120
|
+
}
|
|
34121
|
+
return {
|
|
34122
|
+
isError: true,
|
|
34123
|
+
text: `"${family}" exists but has no ${italic ? "italic " : ""}${weight} file on Fontsource. Retry with the weights it actually ships (most families carry 400/500/600/700).`
|
|
34124
|
+
};
|
|
34125
|
+
}
|
|
34126
|
+
if (!response.ok) {
|
|
34127
|
+
return { isError: true, text: `Font CDN returned ${response.status} for ${url2}.` };
|
|
34128
|
+
}
|
|
34129
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
34130
|
+
if (bytes.length > MAX_WOFF2_BYTES) {
|
|
34131
|
+
return {
|
|
34132
|
+
isError: true,
|
|
34133
|
+
text: `${family} ${weight} is ${Math.round(bytes.length / 1024)}KB \u2014 over the ${Math.round(MAX_WOFF2_BYTES / 1024)}KB per-file ceiling.`
|
|
34134
|
+
};
|
|
34135
|
+
}
|
|
34136
|
+
total += bytes.length;
|
|
34137
|
+
if (total > MAX_TOTAL_BYTES) {
|
|
34138
|
+
return {
|
|
34139
|
+
isError: true,
|
|
34140
|
+
text: `Requested weights total over ${Math.round(MAX_TOTAL_BYTES / 1024)}KB \u2014 install fewer weights.`
|
|
34141
|
+
};
|
|
34142
|
+
}
|
|
34143
|
+
entries.push({ weight, italic, base64: bytes.toString("base64") });
|
|
34144
|
+
}
|
|
34145
|
+
}
|
|
34146
|
+
let created = false;
|
|
34147
|
+
let mergeResult = { added: 0, skipped: 0 };
|
|
34148
|
+
const output = await saveDraft(ctx, String(args.draftId), (draft) => {
|
|
34149
|
+
const existing = draft.files["styles.css"] ?? "";
|
|
34150
|
+
created = !("styles.css" in draft.files);
|
|
34151
|
+
const merged = mergeFontCss(existing, family, entries);
|
|
34152
|
+
mergeResult = { added: merged.added, skipped: merged.skipped };
|
|
34153
|
+
const files = merged.added ? { "styles.css": merged.css } : {};
|
|
34154
|
+
return { files, deletedPaths: [] };
|
|
34155
|
+
});
|
|
34156
|
+
if (output.isError) return output;
|
|
34157
|
+
const kb = Math.round(total / 1024);
|
|
34158
|
+
return {
|
|
34159
|
+
text: `Installed "${family}" (${mergeResult.added} face${mergeResult.added === 1 ? "" : "s"}` + (mergeResult.skipped ? `, ${mergeResult.skipped} already present` : "") + `, ~${kb}KB inlined) into styles.css. Use font-family: '${family}'. ` + (created ? "styles.css was CREATED \u2014 add `import './styles.css'` to App.tsx or the fonts never load. " : "") + output.text
|
|
34160
|
+
};
|
|
34161
|
+
}
|
|
34162
|
+
};
|
|
34163
|
+
|
|
34164
|
+
// src/mcp/open-studio.ts
|
|
34165
|
+
import { execFile } from "node:child_process";
|
|
34166
|
+
import { promisify } from "node:util";
|
|
34167
|
+
var run = promisify(execFile);
|
|
34168
|
+
var CHROMIUM_BROWSERS = ["Google Chrome", "Arc", "Brave Browser", "Microsoft Edge"];
|
|
34169
|
+
async function osascript(script) {
|
|
34170
|
+
const { stdout } = await run("osascript", ["-e", script], { timeout: 4e3 });
|
|
34171
|
+
return stdout.trim();
|
|
34172
|
+
}
|
|
34173
|
+
async function appRunning(app) {
|
|
34174
|
+
try {
|
|
34175
|
+
return await osascript(`tell application "System Events" to (name of processes) contains "${app}"`) === "true";
|
|
34176
|
+
} catch {
|
|
34177
|
+
return false;
|
|
34178
|
+
}
|
|
34179
|
+
}
|
|
34180
|
+
async function focusChromiumTab(app, needle) {
|
|
34181
|
+
const script = `
|
|
34182
|
+
tell application "${app}"
|
|
34183
|
+
set wIndex to 0
|
|
34184
|
+
repeat with w in windows
|
|
34185
|
+
set wIndex to wIndex + 1
|
|
34186
|
+
set tIndex to 0
|
|
34187
|
+
repeat with t in tabs of w
|
|
34188
|
+
set tIndex to tIndex + 1
|
|
34189
|
+
if (URL of t as text) contains "${needle}" then
|
|
34190
|
+
set active tab index of window wIndex to tIndex
|
|
34191
|
+
set index of window wIndex to 1
|
|
34192
|
+
activate
|
|
34193
|
+
return URL of t as text
|
|
34194
|
+
end if
|
|
34195
|
+
end repeat
|
|
34196
|
+
end repeat
|
|
34197
|
+
end tell
|
|
34198
|
+
return ""`;
|
|
34199
|
+
try {
|
|
34200
|
+
const url2 = await osascript(script);
|
|
34201
|
+
return url2 || null;
|
|
34202
|
+
} catch {
|
|
34203
|
+
return null;
|
|
34204
|
+
}
|
|
34205
|
+
}
|
|
34206
|
+
async function focusSafariTab(needle) {
|
|
34207
|
+
const script = `
|
|
34208
|
+
tell application "Safari"
|
|
34209
|
+
repeat with w in windows
|
|
34210
|
+
repeat with t in tabs of w
|
|
34211
|
+
if (URL of t as text) contains "${needle}" then
|
|
34212
|
+
tell w to set current tab to t
|
|
34213
|
+
set index of w to 1
|
|
34214
|
+
activate
|
|
34215
|
+
return URL of t as text
|
|
34216
|
+
end if
|
|
34217
|
+
end repeat
|
|
34218
|
+
end repeat
|
|
34219
|
+
end tell
|
|
34220
|
+
return ""`;
|
|
34221
|
+
try {
|
|
34222
|
+
const url2 = await osascript(script);
|
|
34223
|
+
return url2 || null;
|
|
34224
|
+
} catch {
|
|
34225
|
+
return null;
|
|
34226
|
+
}
|
|
34227
|
+
}
|
|
34228
|
+
async function findOpenStudioTab(needle) {
|
|
34229
|
+
if (process.platform !== "darwin") return null;
|
|
34230
|
+
for (const app of CHROMIUM_BROWSERS) {
|
|
34231
|
+
if (!await appRunning(app)) continue;
|
|
34232
|
+
const url2 = await focusChromiumTab(app, needle);
|
|
34233
|
+
if (url2) return { browser: app, url: url2 };
|
|
34234
|
+
}
|
|
34235
|
+
if (await appRunning("Safari")) {
|
|
34236
|
+
const url2 = await focusSafariTab(needle);
|
|
34237
|
+
if (url2) return { browser: "Safari", url: url2 };
|
|
34238
|
+
}
|
|
34239
|
+
return null;
|
|
34240
|
+
}
|
|
34241
|
+
async function openInBrowser(url2) {
|
|
34242
|
+
if (process.platform === "darwin") await run("open", [url2]);
|
|
34243
|
+
else if (process.platform === "win32") await run("cmd", ["/c", "start", "", url2]);
|
|
34244
|
+
else await run("xdg-open", [url2]);
|
|
34245
|
+
}
|
|
34246
|
+
var openStudioTool = {
|
|
34247
|
+
name: "open_studio",
|
|
34248
|
+
title: "Open the studio in the browser",
|
|
34249
|
+
description: "Open the web studio at a project (optionally a specific draft) in the user's browser. Checks first whether a studio tab for that project is already open and focuses it instead of opening a duplicate. Use when the user asks to see or pull up a project.",
|
|
34250
|
+
inputSchema: {
|
|
34251
|
+
projectId: external_exports.string().describe("The project id."),
|
|
34252
|
+
slug: external_exports.string().optional().describe("Draft slug to open directly.")
|
|
34253
|
+
},
|
|
34254
|
+
handler: async (args, ctx) => {
|
|
34255
|
+
const url2 = studioLink(ctx, String(args.projectId), args.slug);
|
|
34256
|
+
const needle = `/projects/${String(args.projectId)}`;
|
|
34257
|
+
try {
|
|
34258
|
+
const existing = await findOpenStudioTab(needle);
|
|
34259
|
+
if (existing) {
|
|
34260
|
+
return {
|
|
34261
|
+
text: `Already open \u2014 focused the existing studio tab in ${existing.browser} (${existing.url}).` + (args.slug && !existing.url.includes(`scene=${args.slug}`) ? ` It is on a different draft; the user can switch cuts there, or open ${url2} for ${args.slug} directly.` : "")
|
|
34262
|
+
};
|
|
34263
|
+
}
|
|
34264
|
+
await openInBrowser(url2);
|
|
34265
|
+
return { text: `Opened the studio in the user's browser: ${url2}` };
|
|
34266
|
+
} catch (cause) {
|
|
34267
|
+
return {
|
|
34268
|
+
text: `Could not open a browser here (${cause instanceof Error ? cause.message : String(cause)}). Give the user the link instead: ${url2}`
|
|
34269
|
+
};
|
|
34270
|
+
}
|
|
34271
|
+
}
|
|
34272
|
+
};
|
|
34273
|
+
|
|
34274
|
+
// src/mcp/connect.ts
|
|
34275
|
+
var CONNECT_WAIT_SECONDS = 300;
|
|
34276
|
+
async function connectWorkspace(input, deps) {
|
|
34277
|
+
const grant = await deps.request(deps.supabaseUrl, "cli-device-auth", {
|
|
34278
|
+
action: "start"
|
|
34279
|
+
});
|
|
34280
|
+
const browserOpened = deps.open(grant.verificationUri);
|
|
34281
|
+
const expiry = Date.parse(grant.expiresAt);
|
|
34282
|
+
const deadline = Math.min(
|
|
34283
|
+
deps.now() + input.waitMs,
|
|
34284
|
+
Number.isNaN(expiry) ? Infinity : expiry
|
|
34285
|
+
);
|
|
34286
|
+
const intervalMs = Math.max(1, grant.intervalSeconds || 2) * 1e3;
|
|
34287
|
+
while (deps.now() < deadline) {
|
|
34288
|
+
let poll;
|
|
34289
|
+
try {
|
|
34290
|
+
poll = await deps.request(deps.supabaseUrl, "cli-device-auth", {
|
|
34291
|
+
action: "poll",
|
|
34292
|
+
deviceCode: grant.deviceCode
|
|
34293
|
+
});
|
|
34294
|
+
} catch (error51) {
|
|
34295
|
+
if (error51 instanceof ApiError && error51.status === 410) break;
|
|
34296
|
+
throw error51;
|
|
34297
|
+
}
|
|
34298
|
+
if (poll.status === "authorized") {
|
|
34299
|
+
deps.persist({
|
|
34300
|
+
apiKey: poll.apiKey,
|
|
34301
|
+
supabaseUrl: deps.supabaseUrl,
|
|
34302
|
+
appUrl: deps.appUrl
|
|
34303
|
+
});
|
|
34304
|
+
return { status: "connected", userCode: grant.userCode, browserOpened };
|
|
34305
|
+
}
|
|
34306
|
+
await deps.sleep(intervalMs);
|
|
34307
|
+
}
|
|
34308
|
+
return {
|
|
34309
|
+
status: "timeout",
|
|
34310
|
+
userCode: grant.userCode,
|
|
34311
|
+
verificationUri: grant.verificationUri,
|
|
34312
|
+
browserOpened
|
|
34313
|
+
};
|
|
34314
|
+
}
|
|
34315
|
+
var connectTool = {
|
|
34316
|
+
name: "connect",
|
|
34317
|
+
title: "Connect this workspace",
|
|
34318
|
+
description: "Authenticate Impractical Motion for this machine. Opens the user's browser to approve the connection, waits for them to confirm, and stores the credential locally. TELL THE USER YOU ARE ABOUT TO OPEN THEIR BROWSER BEFORE CALLING THIS \u2014 the call blocks until they approve. Use it whenever a tool reports that the workspace is not connected.",
|
|
34319
|
+
inputSchema: {
|
|
34320
|
+
waitSeconds: external_exports.number().optional().describe(`How long to wait for browser approval. Default ${CONNECT_WAIT_SECONDS}.`)
|
|
34321
|
+
},
|
|
34322
|
+
local: true,
|
|
34323
|
+
handler: async (args, ctx) => {
|
|
34324
|
+
const stored = loadConfig();
|
|
34325
|
+
const supabaseUrl = stored.supabaseUrl ?? PRODUCTION_SUPABASE_URL;
|
|
34326
|
+
const appUrl = stored.appUrl ?? PRODUCTION_APP_URL;
|
|
34327
|
+
const result = await connectWorkspace(
|
|
34328
|
+
// Capped like every other wait: a browser approval that outlives the MCP
|
|
34329
|
+
// client's request deadline reports a failure the user then repeats,
|
|
34330
|
+
// burning a second device grant on an approval that already worked.
|
|
34331
|
+
{ waitMs: waitBudgetMs(args.waitSeconds, CONNECT_WAIT_SECONDS) },
|
|
34332
|
+
{
|
|
34333
|
+
request: (url2, name, body) => invokeAt(url2, name, body),
|
|
34334
|
+
open: openBrowser,
|
|
34335
|
+
persist: saveConfig,
|
|
34336
|
+
sleep: (ms) => ctx.sleep ? ctx.sleep(ms) : new Promise((r) => setTimeout(r, ms)),
|
|
34337
|
+
now: () => Date.now(),
|
|
34338
|
+
supabaseUrl,
|
|
34339
|
+
appUrl
|
|
34340
|
+
}
|
|
34341
|
+
);
|
|
34342
|
+
if (result.status === "timeout") {
|
|
34343
|
+
return {
|
|
34344
|
+
text: `Nobody approved the connection in time. Ask the user to open ${result.verificationUri} and confirm code ${result.userCode}, then call connect again.`,
|
|
34345
|
+
isError: true
|
|
34346
|
+
};
|
|
34347
|
+
}
|
|
34348
|
+
try {
|
|
34349
|
+
const snapshot = await ctx.invoke(
|
|
34350
|
+
"workspace-bootstrap"
|
|
34351
|
+
);
|
|
34352
|
+
return {
|
|
34353
|
+
text: `Connected to workspace ${snapshot.orgId}` + (snapshot.hasAccess ? "." : " \u2014 but it has no active access; check billing in the web app.")
|
|
34354
|
+
};
|
|
34355
|
+
} catch (error51) {
|
|
34356
|
+
const message = error51 instanceof Error ? error51.message : String(error51);
|
|
34357
|
+
return {
|
|
34358
|
+
text: `Credential saved, but the workspace check failed: ${message}`,
|
|
34359
|
+
isError: true
|
|
34360
|
+
};
|
|
34361
|
+
}
|
|
34362
|
+
}
|
|
34363
|
+
};
|
|
34364
|
+
var isNotConnected = (message) => /Not connected|Not configured/i.test(message);
|
|
34365
|
+
|
|
34366
|
+
// src/mcp/motion-context.ts
|
|
34367
|
+
import { readFile } from "node:fs/promises";
|
|
34368
|
+
var LOCAL_MOTION_LIBRARY_ENV = "IMPRACTICAL_MOTION_LIBRARY_FILE";
|
|
34369
|
+
function parseLocalMotionLibrary(source, filePath = "local motion library") {
|
|
34370
|
+
const prefix = "export const MOTION_LIBRARY_DATA = ";
|
|
34371
|
+
const start = source.indexOf(prefix);
|
|
34372
|
+
const end = source.lastIndexOf(" as const");
|
|
34373
|
+
if (start < 0 || end < 0 || end <= start + prefix.length) {
|
|
34374
|
+
throw new Error(`${filePath} is not a generated motion-library data module`);
|
|
34375
|
+
}
|
|
34376
|
+
let value;
|
|
34377
|
+
try {
|
|
34378
|
+
value = JSON.parse(source.slice(start + prefix.length, end).trim());
|
|
34379
|
+
} catch (error51) {
|
|
34380
|
+
throw new Error(
|
|
34381
|
+
`Could not parse ${filePath}: ${error51 instanceof Error ? error51.message : String(error51)}`
|
|
34382
|
+
);
|
|
34383
|
+
}
|
|
34384
|
+
const data = value;
|
|
34385
|
+
if (typeof data.version !== "string" || typeof data.engineVersion !== "string" || !Array.isArray(data.styles) || !Array.isArray(data.templates)) {
|
|
34386
|
+
throw new Error(`${filePath} is missing required generated motion-library fields`);
|
|
34387
|
+
}
|
|
34388
|
+
return data;
|
|
34389
|
+
}
|
|
34390
|
+
var replaceSelectedItems = (selected, local) => {
|
|
34391
|
+
if (!selected?.length) return [];
|
|
34392
|
+
const byId = new Map(local.map((item) => [item.id, item]));
|
|
34393
|
+
return selected.map((item) => byId.get(item.id) ?? item);
|
|
34394
|
+
};
|
|
34395
|
+
function overlayLocalMotionLibrary(remote, local, filePath, detail) {
|
|
34396
|
+
const productionLibraryVersion = remote.libraryVersion;
|
|
34397
|
+
return {
|
|
34398
|
+
...remote,
|
|
34399
|
+
schemaVersion: local.schemaVersion,
|
|
34400
|
+
libraryVersion: local.version,
|
|
34401
|
+
engineVersion: local.engineVersion,
|
|
34402
|
+
catalog: local.catalog,
|
|
34403
|
+
guidance: local.guidance,
|
|
34404
|
+
styles: replaceSelectedItems(remote.styles, local.styles),
|
|
34405
|
+
templates: replaceSelectedItems(remote.templates, local.templates),
|
|
34406
|
+
sharedReferences: detail === "full" ? local.sharedReferences : void 0,
|
|
34407
|
+
localLibraryOverride: {
|
|
34408
|
+
enabled: true,
|
|
34409
|
+
source: filePath,
|
|
34410
|
+
productionLibraryVersion
|
|
34411
|
+
},
|
|
34412
|
+
note: `LOCAL TEST OVERRIDE: guidance and selected references come from ${filePath}. Production currently returned library ${productionLibraryVersion}. No production deployment was used.`
|
|
34413
|
+
};
|
|
34414
|
+
}
|
|
34415
|
+
async function applyLocalMotionLibraryOverride(remote, detail) {
|
|
34416
|
+
const filePath = process.env[LOCAL_MOTION_LIBRARY_ENV]?.trim();
|
|
34417
|
+
if (!filePath) return remote;
|
|
34418
|
+
const source = await readFile(filePath, "utf8");
|
|
34419
|
+
return overlayLocalMotionLibrary(remote, parseLocalMotionLibrary(source, filePath), filePath, detail);
|
|
34420
|
+
}
|
|
34421
|
+
var motionContextTool = {
|
|
34422
|
+
name: "motion_context",
|
|
34423
|
+
title: "Study the motion library",
|
|
34424
|
+
description: `Retrieve the current versioned house skills, relevant templates, selected production reference sources, and rendered style frames for this brief. CALL THIS after engine_types and before create_video. The production library is fetched live; local development may explicitly override its guidance with ${LOCAL_MOTION_LIBRARY_ENV}.`,
|
|
34425
|
+
inputSchema: {
|
|
34426
|
+
brief: external_exports.string().min(1).describe("What the video should communicate and how it should feel."),
|
|
34427
|
+
styleId: external_exports.string().optional().describe("Optional style id from a previous motion_context result."),
|
|
34428
|
+
templateId: external_exports.string().optional().describe("Optional template id to study in detail."),
|
|
34429
|
+
brandSlug: external_exports.string().optional().describe("Brand kit slug when this video is for a known brand. Personalizes the result."),
|
|
34430
|
+
contentType: external_exports.string().optional().describe("Kind of video, e.g. 'launch-promo', 'logo-sting', 'product-demo'."),
|
|
34431
|
+
detail: external_exports.enum(["focused", "full"]).optional().describe("Focused returns selected references. Full also exposes all shared source references."),
|
|
34432
|
+
includeReferenceFrames: external_exports.boolean().optional().describe("Render four frames from the best matching production reference. Default true.")
|
|
34433
|
+
},
|
|
34434
|
+
local: true,
|
|
34435
|
+
handler: async (args, ctx) => {
|
|
34436
|
+
const detail = args.detail === "full" ? "full" : "focused";
|
|
34437
|
+
const remoteResult = await ctx.invoke("motion-library", {
|
|
31903
34438
|
brief: args.brief,
|
|
31904
34439
|
styleId: args.styleId,
|
|
31905
34440
|
templateId: args.templateId,
|
|
31906
34441
|
brandSlug: args.brandSlug,
|
|
31907
34442
|
contentType: args.contentType,
|
|
31908
|
-
detail
|
|
34443
|
+
detail,
|
|
31909
34444
|
limit: 2
|
|
31910
34445
|
});
|
|
34446
|
+
const result = await applyLocalMotionLibraryOverride(remoteResult, detail);
|
|
31911
34447
|
const images = [];
|
|
31912
34448
|
const notes = [];
|
|
31913
34449
|
if (args.includeReferenceFrames !== false && result.styles[0]?.scene) {
|
|
@@ -31966,7 +34502,8 @@ var TOOLS = [
|
|
|
31966
34502
|
...REMOTE_SAFE_TOOLS,
|
|
31967
34503
|
getFramesTool,
|
|
31968
34504
|
engineTypesTool,
|
|
31969
|
-
motionContextTool
|
|
34505
|
+
motionContextTool,
|
|
34506
|
+
openStudioTool
|
|
31970
34507
|
];
|
|
31971
34508
|
function toContent(output) {
|
|
31972
34509
|
const content = [{ type: "text", text: output.text }];
|
|
@@ -32075,7 +34612,8 @@ function register(server, tool, ctx) {
|
|
|
32075
34612
|
},
|
|
32076
34613
|
async (args, extra) => {
|
|
32077
34614
|
try {
|
|
32078
|
-
const
|
|
34615
|
+
const admissionName = tool.admitAs ?? tool.name;
|
|
34616
|
+
const admission = ctx.admit && !UNMETERED_MCP_TOOLS.has(admissionName) ? await ctx.admit(admissionName) : null;
|
|
32079
34617
|
const execute = () => tool.handler(args ?? {}, ctx, toolProgress(extra));
|
|
32080
34618
|
const output = admission?.receipt && ctx.withAdmission ? await ctx.withAdmission(admission.receipt, execute) : await execute();
|
|
32081
34619
|
if (admission?.warning) output.text += `
|
|
@@ -32106,7 +34644,7 @@ ${formatUsageNotice(admission.warning)}`;
|
|
|
32106
34644
|
}
|
|
32107
34645
|
var ENGINE_DTS_URI = "impractical://engine.d.ts";
|
|
32108
34646
|
function readEngineTypes() {
|
|
32109
|
-
return
|
|
34647
|
+
return readFileSync2(fileURLToPath2(new URL("../assets/engine.d.ts", import.meta.url)), "utf8");
|
|
32110
34648
|
}
|
|
32111
34649
|
function registerEngineResource(server) {
|
|
32112
34650
|
server.registerResource(
|
|
@@ -32122,9 +34660,17 @@ function registerEngineResource(server) {
|
|
|
32122
34660
|
})
|
|
32123
34661
|
);
|
|
32124
34662
|
}
|
|
34663
|
+
function buildServerInstructions(ctx) {
|
|
34664
|
+
return "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.\n\nTHE 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.\n\nYou 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.\n\nJOBS 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.\n\nTHE MOTION FLOOR \u2014 these hold even if you read nothing else:\n1. 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.\n2. 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.\n3. 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.\n4. 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.\n5. 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.\n6. Determinism: no Date.now, Math.random, setTimeout, or CSS transitions. The exporter renders by seeking, so wall-clock motion shimmers or vanishes.\n7. 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.\n" + // Item 8 is the content doctrine, which is still under experiment: it ships
|
|
34665
|
+
// only when IMPRACTICAL_CONTENT_DOCTRINE is set, so the same switch governs
|
|
34666
|
+
// the doctrine here and in the phased flow's phase 2.
|
|
34667
|
+
(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." + (ctx.qualityLoop ? qualityLoopServerInstructions() : "") + (ctx.motionLab ? motionLabServerInstructions() : "") + (ctx.phasedFlow ? phasedServerInstructions() : "");
|
|
34668
|
+
}
|
|
32125
34669
|
function createServer(ctx) {
|
|
34670
|
+
const qualityLoopState = ctx.qualityLoop ? ctx.qualityLoopState ?? createQualityLoopState() : void 0;
|
|
32126
34671
|
const validatedCtx = {
|
|
32127
34672
|
...ctx,
|
|
34673
|
+
qualityLoopState,
|
|
32128
34674
|
invoke: async (name, body = {}) => validateApiResponse(name, body, await ctx.invoke(name, body))
|
|
32129
34675
|
};
|
|
32130
34676
|
const server = new McpServer(
|
|
@@ -32136,10 +34682,17 @@ function createServer(ctx) {
|
|
|
32136
34682
|
// agent that skips motion_context still cannot produce slop by default.
|
|
32137
34683
|
// The full language (ease table, blur doctrine, camera grammar, beat
|
|
32138
34684
|
// sheets) ships live in motion_context's `guidance.motionDirection`.
|
|
32139
|
-
instructions:
|
|
34685
|
+
instructions: buildServerInstructions(ctx)
|
|
32140
34686
|
}
|
|
32141
34687
|
);
|
|
32142
|
-
|
|
34688
|
+
const tools = [
|
|
34689
|
+
...TOOLS,
|
|
34690
|
+
installKitsTool,
|
|
34691
|
+
installFontTool,
|
|
34692
|
+
...ctx.motionLab ? [getMotionTool] : [],
|
|
34693
|
+
...ctx.phasedFlow ? [gatherReferencesTool] : []
|
|
34694
|
+
];
|
|
34695
|
+
for (const tool of tools) register(server, tool, validatedCtx);
|
|
32143
34696
|
registerEngineResource(server);
|
|
32144
34697
|
return server;
|
|
32145
34698
|
}
|
|
@@ -32155,14 +34708,19 @@ async function runStdioServer() {
|
|
|
32155
34708
|
admit: admitMcpCall,
|
|
32156
34709
|
withAdmission: withAdmissionReceipt,
|
|
32157
34710
|
openExternal: openBrowser,
|
|
32158
|
-
appUrl: config2.appUrl
|
|
34711
|
+
appUrl: config2.appUrl,
|
|
34712
|
+
qualityLoop: qualityLoopEnabled(),
|
|
34713
|
+
motionLab: motionLabEnabled(),
|
|
34714
|
+
phasedFlow: phasedFlowEnabled()
|
|
32159
34715
|
});
|
|
32160
34716
|
await server.connect(new StdioServerTransport());
|
|
32161
|
-
|
|
34717
|
+
const served = TOOLS.length + 2 + (motionLabEnabled() ? 1 : 0) + (phasedFlowEnabled() ? 1 : 0);
|
|
34718
|
+
process.stderr.write(`impractical mcp: serving ${served} tools over stdio
|
|
32162
34719
|
`);
|
|
32163
34720
|
}
|
|
32164
34721
|
export {
|
|
32165
34722
|
ENGINE_DTS_URI,
|
|
34723
|
+
buildServerInstructions,
|
|
32166
34724
|
createServer,
|
|
32167
34725
|
runStdioServer
|
|
32168
34726
|
};
|