@impractical-ai/motion 0.1.9 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/engine.d.ts +107 -40
- package/dist/{api-NVZQJBVP.js → api-4WE4LYPB.js} +2 -2
- package/dist/{chunk-H5TR3QJN.js → chunk-6FVZ6IWR.js} +2 -2
- package/dist/{chunk-KI5E2464.js → chunk-CZANULCU.js} +1 -1
- package/dist/{chunk-NG3Q3BYW.js → chunk-WB7FRXAG.js} +2 -2
- package/dist/{commands-56WH3ZVJ.js → commands-YJFMKK2K.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/{stdio-MJ4LTXME.js → stdio-S7VGBKNS.js} +242 -27
- package/package.json +1 -1
package/assets/engine.d.ts
CHANGED
|
@@ -41,17 +41,44 @@ export type TransitionResult = {
|
|
|
41
41
|
root: HTMLElement
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** What the camera dives through. Every mode computes its own geometry —
|
|
45
|
+
* never hand-tune margins or wrap glyphs in spans to fake these. */
|
|
46
|
+
export type ThroughVia =
|
|
47
|
+
| 'surface' // the element's face: its rect covers the frame (default)
|
|
48
|
+
| 'glyph' // one character's COUNTER (the enclosed hole) covers the frame
|
|
49
|
+
| 'word' // one word's rect covers the frame
|
|
50
|
+
| 'anchor'
|
|
51
|
+
|
|
44
52
|
export type ThroughOpts = {
|
|
45
|
-
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
53
|
+
/** Dive mode. Default 'surface'. 'glyph' needs `char` (or `charIndex`),
|
|
54
|
+
* 'word' needs `word`; both address plain text — no wrapper spans.
|
|
55
|
+
* 'anchor' dives toward the element and, at the cut, match-frames the
|
|
56
|
+
* element matching the SAME selector in the post-swap world (its twin),
|
|
57
|
+
* so the subject persists while everything around it changes. */
|
|
58
|
+
via?: ThroughVia
|
|
59
|
+
/** glyph mode: the character to dive through — first occurrence in the
|
|
60
|
+
* target's text. The dive passes through its enclosed counter (o, e, a,
|
|
61
|
+
* 0…); characters without a hole throw with a diagnostic. */
|
|
62
|
+
char?: string
|
|
63
|
+
/** glyph mode: 0-based index into the target's text, instead of `char`. */
|
|
64
|
+
charIndex?: number
|
|
65
|
+
/** word mode: the word to dive through — first occurrence. */
|
|
66
|
+
word?: string
|
|
67
|
+
/** Takeover color. Defaults: surface/anchor — the target's effective
|
|
68
|
+
* background (first non-transparent backgroundColor walking up from the
|
|
69
|
+
* target); glyph/word — the surface BEHIND the text (the walk starts at
|
|
70
|
+
* the text element's parent), i.e. what actually shows through the
|
|
71
|
+
* counters. */
|
|
48
72
|
color?: string
|
|
49
73
|
/** Dive duration in seconds. Default 0.5. */
|
|
50
74
|
duration?: number
|
|
51
75
|
/** Dive ease. Default 'power3.in' (accelerates into the surface). */
|
|
52
76
|
ease?: string
|
|
53
|
-
/** Cover overshoot: 1.0 = the
|
|
54
|
-
*
|
|
77
|
+
/** Cover overshoot: 1.0 = the dive geometry (element rect, glyph counter,
|
|
78
|
+
* or word rect per `via`) exactly covers the frame at the cut. Default
|
|
79
|
+
* 1.15, so a few px of drift can never expose an edge. Override only for
|
|
80
|
+
* taste — every mode measures its own geometry, so no mode NEEDS a
|
|
81
|
+
* hand-tuned margin. */
|
|
55
82
|
margin?: number
|
|
56
83
|
/** The world swap, applied as seek-safe `tl.set`s at the exact cut moment
|
|
57
84
|
* while the frame is fully covered: `hide` gets autoAlpha 0, `show` gets
|
|
@@ -98,16 +125,24 @@ export type TransitionKit = {
|
|
|
98
125
|
opts?: TransitionOpts,
|
|
99
126
|
position?: gsap.Position
|
|
100
127
|
) => TransitionResult
|
|
101
|
-
/** The go-through: dive the camera INTO
|
|
128
|
+
/** The go-through: dive the camera INTO something until its color takes
|
|
102
129
|
* over the whole frame, cut worlds under the cover, then pull out into the
|
|
103
130
|
* new world. NEVER hand-roll this (a magic-scale zoom + a hand-timed
|
|
104
131
|
* `tl.set` swap + a re-aim) — through() computes the exact cover scale for
|
|
105
|
-
*
|
|
132
|
+
* its dive geometry, measures it in its state AT THE DIVE'S START TIME
|
|
106
133
|
* (entrances, moves, and tilts since build are all accounted for), and
|
|
107
|
-
* snaps a full-frame overlay in the
|
|
108
|
-
* takeover is guaranteed to the pixel.
|
|
109
|
-
*
|
|
110
|
-
*
|
|
134
|
+
* snaps a full-frame overlay in the right color at the cut so the
|
|
135
|
+
* takeover is guaranteed to the pixel. Four dive modes via `opts.via`:
|
|
136
|
+
* 'surface' (default — the element's face), 'glyph' (through one
|
|
137
|
+
* character's enclosed counter: `{ via: 'glyph', char: 'o' }` on plain
|
|
138
|
+
* text, no wrapper span — the hole is measured from the actual glyph
|
|
139
|
+
* outline, so the margin is computed, never tuned), 'word' (`{ via:
|
|
140
|
+
* 'word', word: 'world' }` — the word's rect), and 'anchor' (match-cut:
|
|
141
|
+
* dive toward the element, swap the world at the cut, and land on the
|
|
142
|
+
* element matching the same selector in the new world, so the subject
|
|
143
|
+
* persists while everything around it changes). Deterministic: every
|
|
144
|
+
* value is resolved once at build; the swap is `tl.set`s, never
|
|
145
|
+
* callbacks. Appends at the timeline's end when no position is given. */
|
|
111
146
|
through: (
|
|
112
147
|
target: string | Element,
|
|
113
148
|
opts?: ThroughOpts,
|
|
@@ -1107,11 +1142,16 @@ export type SentenceHandle = {
|
|
|
1107
1142
|
// ─── Morph — any element becomes any other element ───────────────────────
|
|
1108
1143
|
|
|
1109
1144
|
export type MorphOpts = {
|
|
1110
|
-
/** 'apple' =
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1113
|
-
* the
|
|
1114
|
-
* eased envelope). One motion, no
|
|
1145
|
+
/** 'apple' = a firm in-out flight that keeps the deformation READABLE —
|
|
1146
|
+
* the shape change is the subject, so it may not be over in three frames.
|
|
1147
|
+
* 'spring' = the same flight with the reference bounce: a LIGHT damped
|
|
1148
|
+
* sine riding the same path, calibrated from the dnyxstudios AE expression
|
|
1149
|
+
* (amp .04·v, freq 1.8Hz, decay e^-3t, eased envelope). One motion, no
|
|
1150
|
+
* delayed settle, ~one visible overshoot.
|
|
1151
|
+
*
|
|
1152
|
+
* Both are `ease-in-out-*` by design. A morph is travel WITHIN the frame,
|
|
1153
|
+
* and the house table reserves `ease-out-*` for entrances; a front-loaded
|
|
1154
|
+
* curve finishes the transformation before the viewer can look at it. */
|
|
1115
1155
|
feel?: 'apple' | 'spring'
|
|
1116
1156
|
/** Tune the 'spring' bounce. `amp` = first overshoot as a fraction of the
|
|
1117
1157
|
* travel (default 0.025 — light), `freq` Hz (default 1.8), `decay` per
|
|
@@ -1122,17 +1162,26 @@ export type MorphOpts = {
|
|
|
1122
1162
|
/** Flight curve — ANY GSAP ease works: house names, 'elastic.out(1, 0.4)',
|
|
1123
1163
|
* 'back.out(1.8)'… Overshoots extrapolate past the target (kept renderable). */
|
|
1124
1164
|
ease?: string
|
|
1125
|
-
/** 0..1 —
|
|
1165
|
+
/** 0..1 in EASED space — where along the SHAPE CHANGE the surface swaps
|
|
1166
|
+
* from A to B. Not a fade midpoint: at this instant both sides hold the
|
|
1167
|
+
* same box and the same chrome, so the swap is a match-frame cut. 0.5
|
|
1168
|
+
* means "when the shape is halfway between the two," which is what the
|
|
1169
|
+
* viewer actually perceives. */
|
|
1126
1170
|
handoff?: number
|
|
1127
|
-
/**
|
|
1128
|
-
*
|
|
1129
|
-
|
|
1171
|
+
/** Fraction of the flight over which unmatched content departs and
|
|
1172
|
+
* arrives (default 0.42). Content displaces along the travel vector and
|
|
1173
|
+
* must be clear of the seam — that emptiness is what makes the cut
|
|
1174
|
+
* invisible, so widening this past ~0.5 will start to show the swap. */
|
|
1175
|
+
content?: number
|
|
1130
1176
|
/** Arc lift in px (0 disables). Default: auto from travel distance. */
|
|
1131
1177
|
arc?: number
|
|
1132
|
-
/**
|
|
1133
|
-
* arrive
|
|
1134
|
-
*
|
|
1135
|
-
|
|
1178
|
+
/** Content choreography (default 'displace'): unmatched children leave and
|
|
1179
|
+
* arrive by moving ALONG the travel vector — replacement reads as motion,
|
|
1180
|
+
* and both sides are clear of the seam before the surfaces swap.
|
|
1181
|
+
* `false` = containers only; children hold still and stay counter-scaled,
|
|
1182
|
+
* which is right when the two sides share a background image or the
|
|
1183
|
+
* content is meant to look continuous. */
|
|
1184
|
+
parts?: 'displace' | false
|
|
1136
1185
|
/** Sub-component matching (default 'auto'): elements tagged
|
|
1137
1186
|
* `data-morph="name"` on BOTH sides (any depth) morph INTO each other —
|
|
1138
1187
|
* each pair rides its own interpolated box inside the flight, so the
|
|
@@ -1425,9 +1474,12 @@ export type DirectionContext = {
|
|
|
1425
1474
|
* a long dwell at both ends and a confident glide through the middle.
|
|
1426
1475
|
* These defaults ARE the recommended camera physics: do NOT pass ease
|
|
1427
1476
|
* or duration here unless the user explicitly asks for a different
|
|
1428
|
-
* camera feel. The
|
|
1429
|
-
* (`duration:
|
|
1430
|
-
*
|
|
1477
|
+
* camera feel. The one sanctioned exception: an instant reframe behind
|
|
1478
|
+
* a covering element (`duration: 0.01`). A slow linear drift
|
|
1479
|
+
* (`duration: 2–5, ease: 'none'`) is NOT one — sub-pixel motion on the
|
|
1480
|
+
* whole stage re-rasterizes every glyph each frame and renders as
|
|
1481
|
+
* micro-flicker; the motion lab flags it as DRIFT and export blocks.
|
|
1482
|
+
* Hold the camera still instead. Anything between 0.05–0.4s is
|
|
1431
1483
|
* neither a move nor a cut — avoid it. `ease-in-out-cubic` is for
|
|
1432
1484
|
* ELEMENT travel, never the camera. */
|
|
1433
1485
|
move: (
|
|
@@ -1485,13 +1537,20 @@ export type DirectionContext = {
|
|
|
1485
1537
|
* 'down', duration?, cell?, at? }`. `out` with no position appends at the
|
|
1486
1538
|
* clip's end — the standard way to end a clip for a cut; `in` at position
|
|
1487
1539
|
* 0 starts the clip covered and reveals it.
|
|
1488
|
-
* `transition.through(target, { swap?, land?, color?,
|
|
1489
|
-
* outDuration? }, position?)` is the GO-THROUGH: dive INTO
|
|
1490
|
-
* until its color takes over the frame, world-swap under the
|
|
1491
|
-
* out into the new world.
|
|
1492
|
-
*
|
|
1493
|
-
*
|
|
1494
|
-
*
|
|
1540
|
+
* `transition.through(target, { via?, char?, word?, swap?, land?, color?,
|
|
1541
|
+
* duration?, outDuration? }, position?)` is the GO-THROUGH: dive INTO
|
|
1542
|
+
* something until its color takes over the frame, world-swap under the
|
|
1543
|
+
* cover, pull out into the new world. `via` picks the dive geometry:
|
|
1544
|
+
* 'surface' (default, the element's face), 'glyph' (`{ via: 'glyph',
|
|
1545
|
+
* char: 'o' }` — through the character's enclosed counter, measured from
|
|
1546
|
+
* the real glyph outline on plain text: no wrapper spans, no hand-tuned
|
|
1547
|
+
* margins), 'word' (`{ via: 'word', word: 'world' }` — the word's rect),
|
|
1548
|
+
* 'anchor' (match-cut: the subject persists across the cut — the same
|
|
1549
|
+
* selector's twin in the new world is the landing — while everything
|
|
1550
|
+
* around it changes). Never hand-roll a dive (a magic-scale camera.move +
|
|
1551
|
+
* a hand-timed swap): through() computes the exact cover scale, measures
|
|
1552
|
+
* at the dive's own start time, and guarantees full coverage at the cut.
|
|
1553
|
+
* All return `{ duration }` (full span including
|
|
1495
1554
|
* stagger) for sequencing, and accept `(target, effect, opts?, position?)`
|
|
1496
1555
|
* with opts `{ by?: 'chars'|'words'|'lines', duration?, stagger?, ease?,
|
|
1497
1556
|
* from?: 'start'|'center'|'end'|'edges' }`. */
|
|
@@ -1660,10 +1719,17 @@ export type DirectionContext = {
|
|
|
1660
1719
|
* eases the container instead of live reflow. */
|
|
1661
1720
|
sentence: SentenceKit['sentence']
|
|
1662
1721
|
/** Universal shape morph: any element becomes any other element, a rect, or
|
|
1663
|
-
* `'frame'`.
|
|
1664
|
-
*
|
|
1665
|
-
*
|
|
1666
|
-
*
|
|
1722
|
+
* `'frame'`. Both real elements ride ONE interpolated box (position, size,
|
|
1723
|
+
* corner radius, arc lift) while the source's chrome is driven onto the
|
|
1724
|
+
* target's — at `handoff` the two are the same picture, so the surfaces
|
|
1725
|
+
* HARD-CUT and never crossfade. Matched sub-components (`data-morph="name"`
|
|
1726
|
+
* on both sides) travel; unmatched content displaces along the travel
|
|
1727
|
+
* vector; children are counter-scaled so nothing stretches.
|
|
1728
|
+
*
|
|
1729
|
+
* Author the TARGET hidden (`autoAlpha: 0`); the morph reveals it at
|
|
1730
|
+
* arrival. Every visual state is a pure function of eased progress, so it
|
|
1731
|
+
* is export-worker safe. For MANY-to-many structure — recursing into
|
|
1732
|
+
* sub-components, N→1 gathers, 1→N splits — install `kit/morphtree`. */
|
|
1667
1733
|
morph: MorphKit['morph']
|
|
1668
1734
|
/** The chip⇄full-bleed round trip: grows the element to cover the frame,
|
|
1669
1735
|
* holds, returns home (`back: false` to stay). Scale IS the transition —
|
|
@@ -1764,8 +1830,9 @@ export type BuiltScene = {
|
|
|
1764
1830
|
// promo-in ease-in-quart (.895,.03,.685,.22) — exits, dives
|
|
1765
1831
|
//
|
|
1766
1832
|
// Rule of thumb from the reference promos: entrances ~0.4–0.6s on promo-out
|
|
1767
|
-
// (visually settled inside 0.15s), line staggers ~0.2s, holds
|
|
1768
|
-
// linear scale drift
|
|
1833
|
+
// (visually settled inside 0.15s), line staggers ~0.2s, holds are held STILL
|
|
1834
|
+
// (a slow linear scale drift re-rasterizes glyphs every frame and renders as
|
|
1835
|
+
// micro-flicker — never add one), pops that need bounce use
|
|
1769
1836
|
// back.out(1.8–2), and background/color cuts are HARD (no crossfade).
|
|
1770
1837
|
|
|
1771
1838
|
/** Registered ease names usable anywhere GSAP accepts an ease. */
|
|
@@ -13,8 +13,8 @@ import {
|
|
|
13
13
|
waitForJobs,
|
|
14
14
|
withAdmissionReceipt,
|
|
15
15
|
withMeteredCall
|
|
16
|
-
} from "./chunk-
|
|
17
|
-
import "./chunk-
|
|
16
|
+
} from "./chunk-CZANULCU.js";
|
|
17
|
+
import "./chunk-6FVZ6IWR.js";
|
|
18
18
|
import "./chunk-77HVPD4G.js";
|
|
19
19
|
export {
|
|
20
20
|
ApiContractError,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// package.json
|
|
4
4
|
var package_default = {
|
|
5
5
|
name: "@impractical-ai/motion",
|
|
6
|
-
version: "0.1.
|
|
6
|
+
version: "0.1.10",
|
|
7
7
|
private: false,
|
|
8
8
|
description: "Create and refine Impractical Motion videos from coding agents and the terminal.",
|
|
9
9
|
homepage: "https://github.com/Openpod/impractical-motion#readme",
|
|
@@ -56,7 +56,7 @@ var PRODUCTION_APP_URL = "https://motion.impractical.ai";
|
|
|
56
56
|
var PRODUCTION_SUPABASE_URL = "https://uaxjweednprtbhbceify.supabase.co";
|
|
57
57
|
var PACKAGE_NAME = "@impractical-ai/motion";
|
|
58
58
|
var PACKAGE_VERSION = package_default.version;
|
|
59
|
-
var BUILD_COMMIT = "
|
|
59
|
+
var BUILD_COMMIT = "779568a" ? "779568a" : "dev";
|
|
60
60
|
var BUILD_ID = `${PACKAGE_VERSION}+${BUILD_COMMIT}`;
|
|
61
61
|
var PACKAGE_COMMAND = `npx --yes ${PACKAGE_NAME}`;
|
|
62
62
|
|
|
@@ -6,14 +6,14 @@ import {
|
|
|
6
6
|
loadConfig,
|
|
7
7
|
recoverCorruptConfig,
|
|
8
8
|
saveConfig
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-CZANULCU.js";
|
|
10
10
|
import {
|
|
11
11
|
PACKAGE_COMMAND,
|
|
12
12
|
PACKAGE_NAME,
|
|
13
13
|
PACKAGE_VERSION,
|
|
14
14
|
PRODUCTION_APP_URL,
|
|
15
15
|
PRODUCTION_SUPABASE_URL
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-6FVZ6IWR.js";
|
|
17
17
|
|
|
18
18
|
// src/onboarding.ts
|
|
19
19
|
import {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
setup
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-WB7FRXAG.js";
|
|
5
5
|
import {
|
|
6
6
|
invoke,
|
|
7
7
|
invokeAt,
|
|
@@ -11,8 +11,8 @@ import {
|
|
|
11
11
|
saveConfig,
|
|
12
12
|
studioLink,
|
|
13
13
|
waitForJobs
|
|
14
|
-
} from "./chunk-
|
|
15
|
-
import "./chunk-
|
|
14
|
+
} from "./chunk-CZANULCU.js";
|
|
15
|
+
import "./chunk-6FVZ6IWR.js";
|
|
16
16
|
import "./chunk-77HVPD4G.js";
|
|
17
17
|
|
|
18
18
|
// src/commands.ts
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
PACKAGE_VERSION
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-6FVZ6IWR.js";
|
|
5
5
|
import "./chunk-77HVPD4G.js";
|
|
6
6
|
|
|
7
7
|
// src/cli-args.ts
|
|
@@ -140,11 +140,11 @@ async function runCli(args, deps) {
|
|
|
140
140
|
// src/index.ts
|
|
141
141
|
runCli(process.argv.slice(2), {
|
|
142
142
|
writeLine: (line) => console.log(line),
|
|
143
|
-
loadCommands: () => import("./commands-
|
|
144
|
-
loadApi: () => import("./api-
|
|
145
|
-
loadMcp: () => import("./stdio-
|
|
143
|
+
loadCommands: () => import("./commands-YJFMKK2K.js"),
|
|
144
|
+
loadApi: () => import("./api-4WE4LYPB.js"),
|
|
145
|
+
loadMcp: () => import("./stdio-S7VGBKNS.js")
|
|
146
146
|
}).catch(async (error) => {
|
|
147
|
-
const { ApiError } = await import("./api-
|
|
147
|
+
const { ApiError } = await import("./api-4WE4LYPB.js");
|
|
148
148
|
if (error instanceof ApiError) {
|
|
149
149
|
console.error(`Error (${error.status} from ${error.fn}): ${error.message}`);
|
|
150
150
|
if (error.status === 429) console.error("Weekly MCP allowance reached. Upgrade or wait for Monday UTC.");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
openBrowser
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-WB7FRXAG.js";
|
|
5
5
|
import {
|
|
6
6
|
ApiError,
|
|
7
7
|
admitMcpCall,
|
|
@@ -11,13 +11,13 @@ import {
|
|
|
11
11
|
saveConfig,
|
|
12
12
|
validateApiResponse,
|
|
13
13
|
withAdmissionReceipt
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-CZANULCU.js";
|
|
15
15
|
import {
|
|
16
16
|
BUILD_COMMIT,
|
|
17
17
|
PACKAGE_VERSION,
|
|
18
18
|
PRODUCTION_APP_URL,
|
|
19
19
|
PRODUCTION_SUPABASE_URL
|
|
20
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-6FVZ6IWR.js";
|
|
21
21
|
import {
|
|
22
22
|
__commonJS,
|
|
23
23
|
__export,
|
|
@@ -31037,11 +31037,24 @@ import { join } from "node:path";
|
|
|
31037
31037
|
import { chromium } from "playwright-core";
|
|
31038
31038
|
var PROTOCOL = 3;
|
|
31039
31039
|
var MAX_AGE_MS = 12 * 60 * 60 * 1e3;
|
|
31040
|
-
|
|
31040
|
+
function framesHome() {
|
|
31041
|
+
const override = process.env.IMPRACTICAL_FRAMES_HOME?.trim();
|
|
31042
|
+
return override ? override : join(homedir(), ".impractical");
|
|
31043
|
+
}
|
|
31044
|
+
function usingDefaultFramesHome() {
|
|
31045
|
+
return framesHome() === join(homedir(), ".impractical");
|
|
31046
|
+
}
|
|
31047
|
+
var STATE_DIR = () => framesHome();
|
|
31041
31048
|
var ADVERT = () => join(STATE_DIR(), "frames-daemon.json");
|
|
31042
31049
|
var LOCK = () => join(STATE_DIR(), "frames-daemon.lock");
|
|
31043
31050
|
var CLIENTS = () => join(STATE_DIR(), "frames-clients");
|
|
31044
31051
|
var BINARIES = () => join(STATE_DIR(), "frames-binaries.json");
|
|
31052
|
+
function profileRoot() {
|
|
31053
|
+
if (usingDefaultFramesHome()) return tmpdir();
|
|
31054
|
+
const root = join(framesHome(), "tmp");
|
|
31055
|
+
mkdirSync(root, { recursive: true });
|
|
31056
|
+
return root;
|
|
31057
|
+
}
|
|
31045
31058
|
function alive(pid) {
|
|
31046
31059
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
31047
31060
|
try {
|
|
@@ -31198,7 +31211,7 @@ async function waitForPort(userDataDir, timeoutMs = 3e4) {
|
|
|
31198
31211
|
}
|
|
31199
31212
|
async function spawnDaemon(launcher, executable) {
|
|
31200
31213
|
mkdirSync(STATE_DIR(), { recursive: true });
|
|
31201
|
-
const userDataDir = mkdtempSync(join(
|
|
31214
|
+
const userDataDir = mkdtempSync(join(profileRoot(), "impractical-frames-"));
|
|
31202
31215
|
const child = spawn(executable, browserArgs(executable, 0, userDataDir), {
|
|
31203
31216
|
detached: true,
|
|
31204
31217
|
stdio: "ignore"
|
|
@@ -32567,6 +32580,125 @@ function imperceptibleTweens(tweens, intervals, options = {}) {
|
|
|
32567
32580
|
return mean2 < threshold;
|
|
32568
32581
|
});
|
|
32569
32582
|
}
|
|
32583
|
+
var DRIFT_MIN_SECONDS = 1.2;
|
|
32584
|
+
var DRIFT_EASES = /* @__PURE__ */ new Set([
|
|
32585
|
+
"none",
|
|
32586
|
+
"linear",
|
|
32587
|
+
"power0",
|
|
32588
|
+
"power0.none",
|
|
32589
|
+
"power0.in",
|
|
32590
|
+
"power0.out",
|
|
32591
|
+
"power0.inOut"
|
|
32592
|
+
]);
|
|
32593
|
+
var DRIFT_MAX_SCALE_DELTA = 0.15;
|
|
32594
|
+
var DRIFT_MAX_TRANSLATE = { px: 60, vw: 3.5, vh: 6, "%": 6 };
|
|
32595
|
+
var DRIFT_SCALE_PROPS = /* @__PURE__ */ new Set(["scale", "scaleX", "scaleY"]);
|
|
32596
|
+
var DRIFT_TRANSLATE_PROPS = /* @__PURE__ */ new Set(["x", "y", "xPercent", "yPercent"]);
|
|
32597
|
+
var DRIFT_ROTATE_PROPS = /* @__PURE__ */ new Set(["rotation", "rotationZ"]);
|
|
32598
|
+
var DRIFT_PASSIVE_PROPS = /* @__PURE__ */ new Set(["transformOrigin", "force3D", "willChange"]);
|
|
32599
|
+
var parseMagnitude = (value) => {
|
|
32600
|
+
if (typeof value === "number") return { amount: value, unit: "px" };
|
|
32601
|
+
if (typeof value !== "string") return null;
|
|
32602
|
+
const match = /^\s*([+-]=)?\s*(-?\d*\.?\d+)\s*([a-z%]*)\s*$/i.exec(value);
|
|
32603
|
+
if (!match) return null;
|
|
32604
|
+
return { amount: Number(match[2]), unit: (match[3] || "px").toLowerCase() };
|
|
32605
|
+
};
|
|
32606
|
+
function isDriftTween(tween) {
|
|
32607
|
+
if (tween.end - tween.start < DRIFT_MIN_SECONDS) return false;
|
|
32608
|
+
if (!DRIFT_EASES.has(tween.ease)) return false;
|
|
32609
|
+
if (tween.yoyo) return false;
|
|
32610
|
+
const active2 = tween.props.filter((prop) => !DRIFT_PASSIVE_PROPS.has(prop));
|
|
32611
|
+
if (!active2.length) return false;
|
|
32612
|
+
for (const prop of active2) {
|
|
32613
|
+
const value = tween.values?.[prop];
|
|
32614
|
+
if (DRIFT_SCALE_PROPS.has(prop)) {
|
|
32615
|
+
const parsed = parseMagnitude(value);
|
|
32616
|
+
if (parsed && Math.abs(parsed.amount - 1) > DRIFT_MAX_SCALE_DELTA) return false;
|
|
32617
|
+
} else if (DRIFT_TRANSLATE_PROPS.has(prop)) {
|
|
32618
|
+
const parsed = parseMagnitude(value);
|
|
32619
|
+
const ceiling = parsed ? DRIFT_MAX_TRANSLATE[parsed.unit] ?? DRIFT_MAX_TRANSLATE.px : null;
|
|
32620
|
+
if (parsed && ceiling !== null && Math.abs(parsed.amount) > ceiling) return false;
|
|
32621
|
+
} else if (DRIFT_ROTATE_PROPS.has(prop)) {
|
|
32622
|
+
const parsed = parseMagnitude(value);
|
|
32623
|
+
if (parsed && Math.abs(parsed.amount) > 15) return false;
|
|
32624
|
+
} else {
|
|
32625
|
+
return false;
|
|
32626
|
+
}
|
|
32627
|
+
}
|
|
32628
|
+
return true;
|
|
32629
|
+
}
|
|
32630
|
+
var coincides = (a, b, tolerance = 0.06) => Math.abs(a.start - b.start) <= tolerance && Math.abs(a.end - b.end) <= tolerance;
|
|
32631
|
+
function driftFindings(input) {
|
|
32632
|
+
const cameraDrifts = (input.engine ?? []).filter(
|
|
32633
|
+
(tween) => tween.kind === "camera" && tween.end - tween.start >= DRIFT_MIN_SECONDS && DRIFT_EASES.has(tween.ease)
|
|
32634
|
+
);
|
|
32635
|
+
const stageLike = (tween) => tween.targets.some((target) => /^[a-z][a-z0-9]*$/i.test(target)) || ["scale", "x", "y"].every((prop) => tween.props.includes(prop));
|
|
32636
|
+
const out = [];
|
|
32637
|
+
for (const tween of input.tweens ?? []) {
|
|
32638
|
+
if (!isDriftTween(tween)) continue;
|
|
32639
|
+
const camera = stageLike(tween) && cameraDrifts.some((span) => coincides(tween, span));
|
|
32640
|
+
out.push({ tween, target: camera ? "camera" : tween.text ? "text" : "media" });
|
|
32641
|
+
}
|
|
32642
|
+
for (const span of cameraDrifts) {
|
|
32643
|
+
if (out.some((finding) => finding.target === "camera" && coincides(finding.tween, span))) continue;
|
|
32644
|
+
out.push({
|
|
32645
|
+
tween: { start: span.start, end: span.end, targets: ["camera"], props: ["scale", "x", "y"], ease: span.ease },
|
|
32646
|
+
target: "camera"
|
|
32647
|
+
});
|
|
32648
|
+
}
|
|
32649
|
+
return out.sort((a, b) => a.tween.start - b.tween.start);
|
|
32650
|
+
}
|
|
32651
|
+
var describeDrift = (finding) => {
|
|
32652
|
+
const { tween } = finding;
|
|
32653
|
+
const props = tween.props.filter((prop) => !DRIFT_PASSIVE_PROPS.has(prop));
|
|
32654
|
+
const kind = props.some((prop) => DRIFT_SCALE_PROPS.has(prop)) ? "scale" : props.some((prop) => DRIFT_ROTATE_PROPS.has(prop)) ? "rotation" : "translate";
|
|
32655
|
+
const where = finding.target === "camera" ? "the camera" : finding.target === "text" ? `text (${tween.targets[0] ?? "target"})` : tween.targets[0] ?? "target";
|
|
32656
|
+
return `DRIFT ${formatTime(tween.start)}-${formatTime(tween.end)}s on ${where}: a ${(tween.end - tween.start).toFixed(1)}s linear (ease=${tween.ease}) sub-pixel ${kind}`;
|
|
32657
|
+
};
|
|
32658
|
+
function driftFailures(findings) {
|
|
32659
|
+
return findings.filter((finding) => finding.target !== "media").map(
|
|
32660
|
+
(finding) => `${describeDrift(finding)} re-rasterizes every glyph each frame and renders as micro-flicker in the export. Delete it and hold clean \u2014 the dead-air line it prevents is not a defect.`
|
|
32661
|
+
);
|
|
32662
|
+
}
|
|
32663
|
+
var DRIFT_GATE_ENV = "IMPRACTICAL_DRIFT_GATE";
|
|
32664
|
+
function driftGateEnabled(env = process.env) {
|
|
32665
|
+
return !/^(0|false|off|no)$/i.test(env[DRIFT_GATE_ENV]?.trim() ?? "");
|
|
32666
|
+
}
|
|
32667
|
+
var SHIMMER_MAX_CHANGE = 0.12;
|
|
32668
|
+
var SHIMMER_MIN_SPREAD = 0.4;
|
|
32669
|
+
var SHIMMER_MAX_TOP_SHARE = 0.5;
|
|
32670
|
+
var SHIMMER_TOP_CELLS = 5;
|
|
32671
|
+
var SHIMMER_MIN_SECONDS = 0.75;
|
|
32672
|
+
function isShimmerInterval(interval, cells) {
|
|
32673
|
+
if (!cells?.length) return false;
|
|
32674
|
+
if (interval.changed < DEAD_THRESHOLD || interval.changed >= SHIMMER_MAX_CHANGE) return false;
|
|
32675
|
+
const touched = cells.filter((cell) => cell > 0).length / cells.length;
|
|
32676
|
+
if (touched < SHIMMER_MIN_SPREAD) return false;
|
|
32677
|
+
const total = cells.reduce((sum, cell) => sum + cell, 0);
|
|
32678
|
+
if (total <= 0) return false;
|
|
32679
|
+
const top = [...cells].sort((a, b) => b - a).slice(0, SHIMMER_TOP_CELLS).reduce((sum, cell) => sum + cell, 0);
|
|
32680
|
+
return top / total <= SHIMMER_MAX_TOP_SHARE;
|
|
32681
|
+
}
|
|
32682
|
+
function shimmerSpans(intervals, cellDiffs) {
|
|
32683
|
+
const spans2 = [];
|
|
32684
|
+
let open = null;
|
|
32685
|
+
intervals.forEach((interval, i) => {
|
|
32686
|
+
if (isShimmerInterval(interval, cellDiffs[i])) {
|
|
32687
|
+
if (open) {
|
|
32688
|
+
open.end = interval.end;
|
|
32689
|
+
open.peak = Math.max(open.peak, interval.changed);
|
|
32690
|
+
} else {
|
|
32691
|
+
open = { start: interval.start, end: interval.end, peak: interval.changed };
|
|
32692
|
+
}
|
|
32693
|
+
} else if (open) {
|
|
32694
|
+
spans2.push(open);
|
|
32695
|
+
open = null;
|
|
32696
|
+
}
|
|
32697
|
+
});
|
|
32698
|
+
if (open) spans2.push(open);
|
|
32699
|
+
return spans2.filter((span) => span.end - span.start >= SHIMMER_MIN_SECONDS);
|
|
32700
|
+
}
|
|
32701
|
+
var READING_HOLD_MAX_SECONDS = 1.5;
|
|
32570
32702
|
var formatTime = (value) => value.toFixed(2);
|
|
32571
32703
|
var bar = (changed) => {
|
|
32572
32704
|
const filled = Math.round(Math.min(changed / 0.25, 1) * 10);
|
|
@@ -32621,6 +32753,10 @@ function splitFocusMoments(times, cellDiffs, budget = FOCAL_BUDGET) {
|
|
|
32621
32753
|
}
|
|
32622
32754
|
function motionReport(input) {
|
|
32623
32755
|
const { intervals, tweens, plan } = input;
|
|
32756
|
+
const shimmer = input.shimmer ?? [];
|
|
32757
|
+
const inShimmer = (interval) => shimmer.some((span) => interval.start >= span.start - 1e-9 && interval.end <= span.end + 1e-9);
|
|
32758
|
+
const drifts = input.drifts ?? [];
|
|
32759
|
+
const driftByTween = new Map(drifts.map((finding) => [finding.tween, finding]));
|
|
32624
32760
|
const lines = [];
|
|
32625
32761
|
lines.push(
|
|
32626
32762
|
`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)`) + "."
|
|
@@ -32638,15 +32774,45 @@ function motionReport(input) {
|
|
|
32638
32774
|
const dead = deadAirSpans(intervals);
|
|
32639
32775
|
lines.push("");
|
|
32640
32776
|
if (dead.length) {
|
|
32641
|
-
lines.push(
|
|
32777
|
+
lines.push(
|
|
32778
|
+
`DEAD AIR \u2014 nothing perceptible moves. A reading hold (up to ${READING_HOLD_MAX_SECONDS}s) is a legitimate choice and needs no fix; longer is time no one is directing. Never "fix" a hold with a slow drift \u2014 see DRIFT below:`
|
|
32779
|
+
);
|
|
32642
32780
|
for (const span of dead) {
|
|
32781
|
+
const length = span.end - span.start;
|
|
32643
32782
|
lines.push(
|
|
32644
|
-
` ${formatTime(span.start)}\u2013${formatTime(span.end)}s (${
|
|
32783
|
+
` ${formatTime(span.start)}\u2013${formatTime(span.end)}s (${length.toFixed(2)}s)` + (length <= READING_HOLD_MAX_SECONDS ? " \u2014 reading hold, fine" : "")
|
|
32645
32784
|
);
|
|
32646
32785
|
}
|
|
32647
32786
|
} else {
|
|
32648
32787
|
lines.push("DEAD AIR: none in this window.");
|
|
32649
32788
|
}
|
|
32789
|
+
if (shimmer.length) {
|
|
32790
|
+
lines.push("");
|
|
32791
|
+
lines.push(
|
|
32792
|
+
"SHIMMER \u2014 low, diffuse, whole-frame change with no focal region. Nothing is moving; the frame is re-rasterizing (a sub-pixel scale or translate on the camera or on text). This renders as micro-flicker in the export, not as hold-life, and is counted as STATIC:"
|
|
32793
|
+
);
|
|
32794
|
+
for (const span of shimmer.slice(0, 8)) {
|
|
32795
|
+
lines.push(
|
|
32796
|
+
` ${formatTime(span.start)}\u2013${formatTime(span.end)}s (${(span.end - span.start).toFixed(2)}s, peak ${(span.peak * 100).toFixed(1)}%)`
|
|
32797
|
+
);
|
|
32798
|
+
}
|
|
32799
|
+
}
|
|
32800
|
+
const flicker = drifts.filter((finding) => finding.target !== "media");
|
|
32801
|
+
const mediaDrifts = drifts.filter((finding) => finding.target === "media");
|
|
32802
|
+
if (flicker.length) {
|
|
32803
|
+
lines.push("");
|
|
32804
|
+
lines.push(
|
|
32805
|
+
`DRIFT = FLICKER \u2014 ${flicker.length} slow linear transform(s) on the camera or on text. A sub-pixel scale/translate across a hold re-rasterizes every glyph each frame: invisible as motion, visible as shimmer in the MP4. These block export. Delete them and hold clean; if a hold needs life, move something without text (a cover image, a gradient plate) with a move that starts and settles:`
|
|
32806
|
+
);
|
|
32807
|
+
for (const finding of flicker) lines.push(` ${describeDrift(finding)}`);
|
|
32808
|
+
}
|
|
32809
|
+
if (mediaDrifts.length) {
|
|
32810
|
+
lines.push("");
|
|
32811
|
+
lines.push(
|
|
32812
|
+
`DRIFT (allowed) \u2014 ${mediaDrifts.length} slow linear transform(s) on non-text elements. Fine on a photo or a plate; confirm the target really carries no glyphs:`
|
|
32813
|
+
);
|
|
32814
|
+
for (const finding of mediaDrifts) lines.push(` ${describeDrift(finding)}`);
|
|
32815
|
+
}
|
|
32650
32816
|
if (input.breathless) {
|
|
32651
32817
|
lines.push("");
|
|
32652
32818
|
if (input.breathless.length) {
|
|
@@ -32706,39 +32872,56 @@ function motionReport(input) {
|
|
|
32706
32872
|
const flagged = new Set(imperceptibleTweens(tweens, intervals));
|
|
32707
32873
|
lines.push("TIMELINE \u2014 the tweens actually scheduled in this window (absolute seconds):");
|
|
32708
32874
|
for (const tween of [...tweens].sort((a, b) => a.start - b.start)) {
|
|
32709
|
-
const
|
|
32875
|
+
const drift = driftByTween.get(tween);
|
|
32876
|
+
const flag = drift ? drift.target === "media" ? " \xB7 slow linear drift on a non-text element (allowed)" : " \u26A0 DRIFT \u2014 linear sub-pixel transform on the camera/text; renders as flicker, not motion. Delete it and hold clean." : flagged.has(tween) ? " \u26A0 runs but changes almost no pixels \u2014 likely an imperceptible magnitude; make it visibly larger or delete it and hold clean (never a slow drift)" : "";
|
|
32710
32877
|
lines.push(` ${describeTween(tween)}${flag}`);
|
|
32711
32878
|
}
|
|
32712
32879
|
const covered = intervals.filter(
|
|
32713
32880
|
(interval) => tweens.some((tween) => tween.end > interval.start && tween.start < interval.end)
|
|
32714
32881
|
).length;
|
|
32715
|
-
const perceptible = intervals.filter(
|
|
32882
|
+
const perceptible = intervals.filter(
|
|
32883
|
+
(interval) => interval.changed >= DEAD_THRESHOLD && !inShimmer(interval)
|
|
32884
|
+
).length;
|
|
32885
|
+
const shimmering = intervals.filter(
|
|
32886
|
+
(interval) => interval.changed >= DEAD_THRESHOLD && inShimmer(interval)
|
|
32887
|
+
).length;
|
|
32716
32888
|
lines.push("");
|
|
32717
32889
|
lines.push(
|
|
32718
|
-
`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)}
|
|
32890
|
+
`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)}%` + (shimmering ? ` (a further ${Math.round(shimmering / Math.max(1, intervals.length) * 100)}% is shimmer, counted as static).` : ".")
|
|
32719
32891
|
);
|
|
32720
32892
|
}
|
|
32721
32893
|
return lines.join("\n");
|
|
32722
32894
|
}
|
|
32723
32895
|
var REFERENCE_STATIC_BAND = { low: 0.35, median: 0.45, high: 0.55 };
|
|
32724
|
-
function envelopeLine(intervals) {
|
|
32896
|
+
function envelopeLine(intervals, shimmer = []) {
|
|
32725
32897
|
if (!intervals.length) return "";
|
|
32726
|
-
const
|
|
32898
|
+
const inShimmer = (interval) => shimmer.some((span) => interval.start >= span.start - 1e-9 && interval.end <= span.end + 1e-9);
|
|
32899
|
+
const still = intervals.filter((interval) => interval.changed < DEAD_THRESHOLD || inShimmer(interval)).length / intervals.length;
|
|
32727
32900
|
const delta = still - REFERENCE_STATIC_BAND.median;
|
|
32728
32901
|
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.");
|
|
32729
32902
|
}
|
|
32730
32903
|
function repairMenu(input) {
|
|
32731
32904
|
const out = [];
|
|
32905
|
+
const driftTweens = new Set((input.drifts ?? []).map((finding) => finding.tween));
|
|
32906
|
+
for (const finding of input.drifts ?? []) {
|
|
32907
|
+
if (finding.target === "media") continue;
|
|
32908
|
+
out.push(
|
|
32909
|
+
`REPAIR ${describeDrift(finding)}: delete it. It is not hold-life \u2014 it is glyph flicker. A held frame is the correct state for a hold; if the beat genuinely needs motion, give it a discrete event (a click, a reveal, a state change) or move a non-text element with a move that starts and settles.`
|
|
32910
|
+
);
|
|
32911
|
+
}
|
|
32732
32912
|
for (const span of input.deadSpans) {
|
|
32913
|
+
const length = span.end - span.start;
|
|
32914
|
+
if (length <= READING_HOLD_MAX_SECONDS) continue;
|
|
32733
32915
|
const sample = input.textSamples?.find((candidate) => candidate.time >= span.start && candidate.time <= span.end);
|
|
32734
32916
|
const anchor = sample?.blocks.find((block) => block.inViewport && block.selector)?.selector;
|
|
32735
32917
|
out.push(
|
|
32736
|
-
`REPAIR dead air ${span.start.toFixed(2)}-${span.end.toFixed(2)}s: either trim the hold (pull the next cut to ~${(span.start +
|
|
32918
|
+
`REPAIR dead air ${span.start.toFixed(2)}-${span.end.toFixed(2)}s (${length.toFixed(1)}s): either trim the hold (pull the next cut to ~${(span.start + READING_HOLD_MAX_SECONDS).toFixed(1)}s), or add a discrete event` + (anchor ? ` \u2014 a click, reveal, or state change on/near ${anchor}` : " \u2014 a click, a reveal, a state change") + `. Do NOT add a slow scale/translate on the camera or on text to fill it: that measures as motion and renders as flicker. A reading hold under ${READING_HOLD_MAX_SECONDS}s is fine as it is.`
|
|
32737
32919
|
);
|
|
32738
32920
|
}
|
|
32739
32921
|
for (const tween of input.imperceptible) {
|
|
32922
|
+
if (driftTweens.has(tween)) continue;
|
|
32740
32923
|
out.push(
|
|
32741
|
-
`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.`
|
|
32924
|
+
`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 (never stretch it into a slow drift).`
|
|
32742
32925
|
);
|
|
32743
32926
|
}
|
|
32744
32927
|
return out;
|
|
@@ -33197,7 +33380,8 @@ async function extractTimeline(page) {
|
|
|
33197
33380
|
ease: typeof easeRaw === "string" ? easeRaw : easeRaw ? "custom" : "default",
|
|
33198
33381
|
values,
|
|
33199
33382
|
scene,
|
|
33200
|
-
yoyo: Boolean(vars.yoyo)
|
|
33383
|
+
yoyo: Boolean(vars.yoyo),
|
|
33384
|
+
text: Boolean(firstElement && (firstElement.textContent ?? "").trim().length > 0)
|
|
33201
33385
|
});
|
|
33202
33386
|
}
|
|
33203
33387
|
};
|
|
@@ -33491,6 +33675,8 @@ var getMotionTool = {
|
|
|
33491
33675
|
frames.map((frame) => frame.time),
|
|
33492
33676
|
diffs
|
|
33493
33677
|
);
|
|
33678
|
+
const drifts = driftFindings({ tweens, engine: engineTweens });
|
|
33679
|
+
const shimmer = shimmerSpans(intervals, cellDiffs);
|
|
33494
33680
|
const censusSection = censusOn && textSamples.length ? textCensusReport(textSamples) : "";
|
|
33495
33681
|
let contractSection = "";
|
|
33496
33682
|
if (textContractEnabled() && textSamples.length >= 2) {
|
|
@@ -33516,7 +33702,7 @@ var getMotionTool = {
|
|
|
33516
33702
|
let v2Section = "";
|
|
33517
33703
|
if (inspectV2Enabled()) {
|
|
33518
33704
|
const v2Lines = [];
|
|
33519
|
-
const envelope = envelopeLine(intervals);
|
|
33705
|
+
const envelope = envelopeLine(intervals, shimmer);
|
|
33520
33706
|
if (envelope) v2Lines.push(envelope);
|
|
33521
33707
|
const beats = ctx.qualityLoopState?.beatsByDraft.get(String(args.draftId)) ?? [];
|
|
33522
33708
|
v2Lines.push(...beatVerdicts(beats, intervals));
|
|
@@ -33524,6 +33710,7 @@ var getMotionTool = {
|
|
|
33524
33710
|
...repairMenu({
|
|
33525
33711
|
deadSpans: deadAirSpans(intervals),
|
|
33526
33712
|
imperceptible: tweens ? imperceptibleTweens(tweens, intervals) : [],
|
|
33713
|
+
drifts,
|
|
33527
33714
|
textSamples
|
|
33528
33715
|
})
|
|
33529
33716
|
);
|
|
@@ -33543,6 +33730,8 @@ var getMotionTool = {
|
|
|
33543
33730
|
let splitFocus = splitFocusMoments(
|
|
33544
33731
|
frames.map((frame) => frame.time),
|
|
33545
33732
|
cellDiffs
|
|
33733
|
+
).filter(
|
|
33734
|
+
(moment) => !shimmer.some((span) => moment.start >= span.start - 1e-9 && moment.end <= span.end + 1e-9)
|
|
33546
33735
|
);
|
|
33547
33736
|
let breathless = breathlessSpans(intervals);
|
|
33548
33737
|
let cadence = tweens ? cadenceMoments(tweens) : void 0;
|
|
@@ -33575,7 +33764,7 @@ var getMotionTool = {
|
|
|
33575
33764
|
} catch {
|
|
33576
33765
|
}
|
|
33577
33766
|
}
|
|
33578
|
-
const report = motionReport({ from, to, plan, everyNthFrame, intervals, tweens, duration: duration3, splitFocus, breathless, cadence });
|
|
33767
|
+
const report = motionReport({ from, to, plan, everyNthFrame, intervals, tweens, duration: duration3, splitFocus, breathless, cadence, drifts, shimmer });
|
|
33579
33768
|
const lines = [
|
|
33580
33769
|
report,
|
|
33581
33770
|
...passPlan.includeSheets ? [] : [
|
|
@@ -33596,7 +33785,7 @@ var getMotionTool = {
|
|
|
33596
33785
|
...catalogSection ? ["", catalogSection] : [],
|
|
33597
33786
|
...session.warnings.map((warning) => `WARNING: ${warning}`),
|
|
33598
33787
|
"",
|
|
33599
|
-
"These are measurements, not style rules. Dead air and imperceptible tweens are time no one is directing \u2014 decide narratively what should own that time, change the direction, and measure again."
|
|
33788
|
+
"These are measurements, not style rules. Dead air longer than a reading hold and imperceptible tweens are time no one is directing \u2014 decide narratively what should own that time, change the direction, and measure again. A reading hold is allowed to be still: never fill it with a slow drift on the camera or on text, which measures as motion and renders as flicker."
|
|
33600
33789
|
];
|
|
33601
33790
|
if (ctx.qualityLoop) {
|
|
33602
33791
|
if (!finalReview) {
|
|
@@ -33637,7 +33826,8 @@ var getMotionTool = {
|
|
|
33637
33826
|
stepSeconds: plan.stepSeconds,
|
|
33638
33827
|
samples: plan.times.length,
|
|
33639
33828
|
craft: craftCapture,
|
|
33640
|
-
framingFailures: framing
|
|
33829
|
+
framingFailures: framing,
|
|
33830
|
+
driftFailures: driftGateEnabled() ? driftFailures(drifts) : []
|
|
33641
33831
|
});
|
|
33642
33832
|
lines.push(
|
|
33643
33833
|
`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."
|
|
@@ -33659,7 +33849,7 @@ function motionLabServerInstructions() {
|
|
|
33659
33849
|
return `
|
|
33660
33850
|
|
|
33661
33851
|
MOTION LAB IS ENABLED \u2014 an opt-in local experiment:
|
|
33662
|
-
Static frames measure composition; they are blind to velocity, overlap, and dead air. After every successful preview build, call get_motion across the full duration BEFORE judging the cut, and again on any window you edit. Treat its output as measurements, not style rules: a dead-air span or a flagged imperceptible tween is time no one is directing \u2014 decide what should own that time, fix the direction, and measure again. A
|
|
33852
|
+
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 longer than a reading hold (~1.5s) 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 reading hold is allowed to be completely still. NEVER quiet a dead-air line with a slow linear scale or translate on the camera or on any element carrying text: it measures as motion and renders as micro-flicker in the export, and the report's DRIFT finding blocks export. get_frames remains the tool for composition close-ups.
|
|
33663
33853
|
When the quality loop is also enabled, the final review is two-part and export requires both on the same revision: get_frames({ reviewMode: "final" }) for composition and legibility at native resolution, and get_motion({ draftId, reviewMode: "final" }) for full-duration motion coverage at one frame in five.`;
|
|
33664
33854
|
}
|
|
33665
33855
|
|
|
@@ -34009,7 +34199,7 @@ Creative contract: ${contract.singleIdea} Visual proof: ${contract.visualProof}`
|
|
|
34009
34199
|
function motionCoverageFailure(input) {
|
|
34010
34200
|
if (!input.motionLab) return null;
|
|
34011
34201
|
if (!input.capture) {
|
|
34012
|
-
return 'The motion lab requires full-duration motion coverage: call get_motion({ draftId, reviewMode: "final" }) and
|
|
34202
|
+
return 'The motion lab requires full-duration motion coverage: call get_motion({ draftId, reviewMode: "final" }) and read the dead-air, drift, and imperceptible-tween findings it reports before exporting. A reading hold may stay still; a drift must go.';
|
|
34013
34203
|
}
|
|
34014
34204
|
if (input.capture.revision !== input.frameReviewRevision) {
|
|
34015
34205
|
return `The motion review covered revision ${input.capture.revision}, but the final frame review covered revision ${input.frameReviewRevision}. Review the same revision with both.`;
|
|
@@ -34020,6 +34210,10 @@ function framingMotionFailure(capture) {
|
|
|
34020
34210
|
if (!capture?.framingFailures?.length) return null;
|
|
34021
34211
|
return "The final motion review found broken framing \u2014 fix these shots and review again:\n" + capture.framingFailures.map((finding) => `- ${finding}`).join("\n");
|
|
34022
34212
|
}
|
|
34213
|
+
function driftMotionFailure(capture) {
|
|
34214
|
+
if (!capture?.driftFailures?.length) return null;
|
|
34215
|
+
return "The final motion review found drift that renders as flicker \u2014 delete these tweens and hold clean, then review again (a still reading hold is the correct state, not a defect):\n" + capture.driftFailures.map((finding) => `- ${finding}`).join("\n");
|
|
34216
|
+
}
|
|
34023
34217
|
function craftMotionFailure(input) {
|
|
34024
34218
|
if (!input.craftLab) return null;
|
|
34025
34219
|
const craft = input.capture?.craft;
|
|
@@ -34641,15 +34835,26 @@ var upgradeTool = {
|
|
|
34641
34835
|
var listProjects = {
|
|
34642
34836
|
name: "list_projects",
|
|
34643
34837
|
title: "List video projects",
|
|
34644
|
-
description: "List video projects in the connected workspace, newest first. Large workspaces: pass limit/offset to page instead of relying on a truncated dump, and nameFilter to search.",
|
|
34838
|
+
description: "List video projects in the connected workspace, newest first. Large workspaces: pass limit/offset to page instead of relying on a truncated dump, and nameFilter to search. The tool pages through the server list internally, so totals describe the whole workspace (up to 2,000 projects).",
|
|
34645
34839
|
inputSchema: {
|
|
34646
34840
|
limit: external_exports.number().int().min(1).max(200).optional().describe("Return at most this many projects (default: all)."),
|
|
34647
34841
|
offset: external_exports.number().int().min(0).optional().describe("Skip this many projects, for paging."),
|
|
34648
34842
|
nameFilter: external_exports.string().optional().describe("Case-insensitive substring match on the project name/slug.")
|
|
34649
34843
|
},
|
|
34650
34844
|
handler: async (args, ctx) => {
|
|
34651
|
-
const
|
|
34652
|
-
let
|
|
34845
|
+
const fetched = [];
|
|
34846
|
+
let serverOffset = 0;
|
|
34847
|
+
for (let pageCount = 0; serverOffset !== null && pageCount < 20; pageCount++) {
|
|
34848
|
+
const result = await ctx.invoke("list-projects", {
|
|
34849
|
+
offset: serverOffset,
|
|
34850
|
+
limit: 100
|
|
34851
|
+
});
|
|
34852
|
+
const rows = result.projects ?? [];
|
|
34853
|
+
fetched.push(...rows);
|
|
34854
|
+
serverOffset = rows.length > 0 && typeof result.nextOffset === "number" ? result.nextOffset : null;
|
|
34855
|
+
}
|
|
34856
|
+
const truncated = serverOffset !== null;
|
|
34857
|
+
let projects = fetched;
|
|
34653
34858
|
const total = projects.length;
|
|
34654
34859
|
const filter = typeof args.nameFilter === "string" ? args.nameFilter.trim().toLowerCase() : "";
|
|
34655
34860
|
if (filter) {
|
|
@@ -34667,6 +34872,7 @@ var listProjects = {
|
|
|
34667
34872
|
offset,
|
|
34668
34873
|
returned: projects.length,
|
|
34669
34874
|
hasMore: offset + projects.length < matched,
|
|
34875
|
+
...truncated ? { note: "Workspace exceeds 2,000 projects; totals reflect the first 2,000." } : {},
|
|
34670
34876
|
projects
|
|
34671
34877
|
});
|
|
34672
34878
|
}
|
|
@@ -34685,7 +34891,9 @@ var createVideo = {
|
|
|
34685
34891
|
inputSchema: {
|
|
34686
34892
|
valueProp: external_exports.string().min(1).describe("One or two sentences on what ships and why it matters."),
|
|
34687
34893
|
name: external_exports.string().optional().describe("Human-readable project name. Defaults to the slug."),
|
|
34688
|
-
slug: external_exports.string().optional().describe(
|
|
34894
|
+
slug: external_exports.string().optional().describe(
|
|
34895
|
+
"URL-safe project slug. Generated when omitted (recommended). An explicit slug that collides with a different existing project is rejected with a 409 \u2014 it never attaches to that project; pass projectId instead to edit an existing project."
|
|
34896
|
+
),
|
|
34689
34897
|
vibe: external_exports.string().optional().describe("Tone, e.g. 'confident'. Defaults to 'confident'."),
|
|
34690
34898
|
brandSlug: external_exports.string().optional().describe("Brand kit slug from brand_list. Its colors and fonts become brand.json."),
|
|
34691
34899
|
appCode: external_exports.string().optional().describe("The product's front-end code, used as the starting point for App.tsx."),
|
|
@@ -34975,7 +35183,7 @@ var exportVideo = {
|
|
|
34975
35183
|
}) ?? craftMotionFailure({
|
|
34976
35184
|
craftLab: ctx.craftLab === true,
|
|
34977
35185
|
capture: ctx.qualityLoopState?.finalMotionReviewByDraft.get(String(args.draftId))
|
|
34978
|
-
}) ?? framingMotionFailure(ctx.qualityLoopState?.finalMotionReviewByDraft.get(String(args.draftId)));
|
|
35186
|
+
}) ?? framingMotionFailure(ctx.qualityLoopState?.finalMotionReviewByDraft.get(String(args.draftId))) ?? driftMotionFailure(ctx.qualityLoopState?.finalMotionReviewByDraft.get(String(args.draftId)));
|
|
34979
35187
|
if (failure) return fail(`Quality gate blocked export: ${failure}`);
|
|
34980
35188
|
}
|
|
34981
35189
|
const started = await ctx.invoke("start-export", { draftId: args.draftId });
|
|
@@ -35921,7 +36129,14 @@ THE MOTION FLOOR \u2014 these hold even if you read nothing else:
|
|
|
35921
36129
|
` + // Item 8 is the content doctrine, which is still under experiment: it ships
|
|
35922
36130
|
// only when IMPRACTICAL_CONTENT_DOCTRINE is set, so the same switch governs
|
|
35923
36131
|
// the doctrine here and in the phased flow's phase 2.
|
|
35924
|
-
(contentDoctrineEnabled() ? "8. CONTENT: pretend the viewer has never heard of this product. Figure out the one thing they should walk away knowing, and say it in as few words as possible. If a smart 16-year-old could not repeat a line back in their own words, the line means nothing \u2014 cut it. No buzzwords, no vague taglines. Say a thing, then show it happening \u2014 in that order, one after the other, never both at once. Give the viewer exactly one thing to read at a time: no little label above a headline, no subtitle under it." : "") +
|
|
36132
|
+
(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." : "") + `
|
|
36133
|
+
9. 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.
|
|
36134
|
+
10. 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.
|
|
36135
|
+
11. 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.
|
|
36136
|
+
12. 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.
|
|
36137
|
+
13. 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.
|
|
36138
|
+
14. 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.
|
|
36139
|
+
15. THE GO-THROUGH: to dive into a button/card/color until it takes over the frame and becomes the next scene, ALWAYS use transition.through(target, { swap, land }) \u2014 never hand-roll it with a big camera.move scale plus a timed tl.set swap. Hand-rolled dives aim at build-time positions and miss; through() measures the target at the dive's start time, computes the exact cover scale, and guarantees the cut lands under full cover. It has modes: { via: "glyph", char: "o" } dives through a character's enclosed counter on PLAIN text (never wrap glyphs in spans or tune margins by hand \u2014 the hole is measured from the glyph outline), { via: "word", word: "..." } dives through a word, and { via: "anchor" } is the match-cut: the subject persists across the cut while the world changes around it.` + (ctx.qualityLoop ? qualityLoopServerInstructions() : "") + (ctx.motionLab ? motionLabServerInstructions() : "") + (ctx.craftLab ? "\n\nCRAFT LAB IS ENABLED \u2014 get_motion additionally prints a CRAFT ENVELOPE (depth, vocabulary, rhythm, continuity, geometry) band-scored against the house references. Camera motion is exempt from the restraint verdicts. Export enforces objective craft floors: at least one camera move, zero unresolved beat mismatches, zero geometry deformations, no dead-air span over 2.5s. Self-scores are recorded but do not gate; identical scorecards across drafts are rejected." : "") + (ctx.phasedFlow ? phasedServerInstructions() : "")
|
|
35925
36140
|
);
|
|
35926
36141
|
}
|
|
35927
36142
|
function createServer(ctx) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@impractical-ai/motion",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Create and refine Impractical Motion videos from coding agents and the terminal.",
|
|
6
6
|
"homepage": "https://github.com/Openpod/impractical-motion#readme",
|