@impractical-ai/motion 0.1.9 → 0.1.11

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 CHANGED
@@ -34,6 +34,58 @@ Setup writes the API credential to `~/.impractical/config.json` with mode `0600`
34
34
  variables `IMPRACTICAL_API_KEY`, `IMPRACTICAL_SUPABASE_URL`, and `IMPRACTICAL_APP_URL` take
35
35
  precedence over that file.
36
36
 
37
+ ## Start from a shared video
38
+
39
+ When the sender includes an editable project, the public video page has an
40
+ **Edit this video** action. Its single command both connects your agent and
41
+ copies the project into your workspace:
42
+
43
+ ```sh
44
+ npx --yes @impractical-ai/motion@0.1.11 setup --agent --from https://impractical.ai/v/SHARE_TOKEN
45
+ ```
46
+
47
+ Use the real URL copied from the page. Sign in when prompted, then restart your
48
+ coding agent. The command prints your new project URL and a prompt containing
49
+ its project ID. Drafts stay in your Impractical workspace and are edited through
50
+ MCP; no source is executed or written into your current directory by this command.
51
+
52
+ Already connected, or retrying an interrupted import:
53
+
54
+ ```sh
55
+ npx --yes @impractical-ai/motion@0.1.11 clone https://impractical.ai/v/SHARE_TOKEN
56
+ ```
57
+
58
+ Repeating the command returns the same copy for that share and workspace. The
59
+ copy is independent of the sender's project. An MP4-only share cannot be cloned.
60
+
61
+ ## Choose who can edit a shared video
62
+
63
+ Each link is **view only by default**. Upload without `--project` to share just
64
+ the video and its public brand information; the page will hide **Edit this video**
65
+ and the API will reject project-copy requests for that link:
66
+
67
+ ```sh
68
+ npx --yes @impractical-ai/motion@0.1.11 share ./video.mp4 --title "For Acme" --references ./public-brand.json
69
+ ```
70
+
71
+ `--project` explicitly lets recipients copy **all editable drafts and assets**
72
+ from that project, so use a project you intend to share:
73
+
74
+ ```sh
75
+ npx --yes @impractical-ai/motion@0.1.11 share ./video.mp4 --title "For Acme" --references ./public-brand.json --project PROJECT_ID
76
+ ```
77
+
78
+ Enable cloning on an existing video share, or disable future clones:
79
+
80
+ ```sh
81
+ npx --yes @impractical-ai/motion@0.1.11 share allow-editing SHARE_ID --project PROJECT_ID
82
+ npx --yes @impractical-ai/motion@0.1.11 share view-only SHARE_ID
83
+ ```
84
+
85
+ Detaching the source or revoking the video does not remove copies already owned
86
+ by recipients. The existing upload API accepts the same opt-in as `projectId`.
87
+ The shorter `share attach` and `share detach` aliases also remain supported.
88
+
37
89
  ## MCP server
38
90
 
39
91
  The installer configures supported clients to run:
@@ -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
- /** Takeover color. Default: the target's effective background (first
46
- * non-transparent backgroundColor walking up from the target), read at
47
- * build time. */
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 target exactly covers the frame at the cut.
54
- * Default 1.15, so a few px of layout drift can never expose an edge. */
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 an element until its color takes
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
- * the target, measures the target in its state AT THE DIVE'S START TIME
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 target's color at the cut so the
108
- * takeover is guaranteed to the pixel. Deterministic: every value is
109
- * resolved once at build; the swap is `tl.set`s, never callbacks.
110
- * Appends at the timeline's end when no position is given. */
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' = expo-out flight (~90% of the change in the first ~15% of the
1111
- * time, long soft landing). 'spring' = the reference bounce: expo-out
1112
- * flight, then a LIGHT damped sine rides the same path — calibrated from
1113
- * the dnyxstudios AE expression (amp .04·v, freq 1.8Hz, decay e^-3t,
1114
- * eased envelope). One motion, no delayed settle, ~one visible overshoot. */
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 — center of the A→B crossfade along the flight. */
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
- /** Width of the crossfade as a fraction of the flight (default 0.3 —
1128
- * tight; each side shows only near its natural shape). */
1129
- crossfade?: number
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
- /** Part choreography (default 'stagger'): the target's direct children
1133
- * arrive individually inside the landing box; the source's children are
1134
- * absorbed as it leaves. `false` = containers only. */
1135
- parts?: 'stagger' | false
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 two sanctioned exceptions: a slow ambient drift
1429
- * (`duration: 2–5, ease: 'none'`) and an instant reframe behind a
1430
- * covering element (`duration: 0.01`). Anything between 0.05–0.4s is
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?, duration?,
1489
- * outDuration? }, position?)` is the GO-THROUGH: dive INTO an element
1490
- * until its color takes over the frame, world-swap under the cover, pull
1491
- * out into the new world. Never hand-roll a dive (a magic-scale
1492
- * camera.move + a hand-timed swap): through() computes the exact cover
1493
- * scale, measures the target at the dive's own start time, and guarantees
1494
- * full coverage at the cut. All return `{ duration }` (full span including
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'`. FLIP box + interpolated rounded-rect clip-path silhouette +
1664
- * content-clone crossfade (`handoff` sets where). Author the TARGET hidden
1665
- * (`autoAlpha: 0`); the morph reveals it at arrival. `fromPath`/`toPath`
1666
- * accept real SVG paths for logo endcards. Export-worker safe. */
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 carry a slow
1768
- // linear scale drift (~4%/s, ease 'none'), pops that need bounce use
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-KI5E2464.js";
17
- import "./chunk-H5TR3QJN.js";
16
+ } from "./chunk-VTHAZQEV.js";
17
+ import "./chunk-HGA2O3JM.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.9",
6
+ version: "0.1.11",
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 = "fd0fa24" ? "fd0fa24" : "dev";
59
+ var BUILD_COMMIT = "9f06a02" ? "9f06a02" : "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-KI5E2464.js";
9
+ } from "./chunk-VTHAZQEV.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-H5TR3QJN.js";
16
+ } from "./chunk-HGA2O3JM.js";
17
17
 
18
18
  // src/onboarding.ts
19
19
  import {
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  PRODUCTION_APP_URL,
4
4
  PRODUCTION_SUPABASE_URL
5
- } from "./chunk-H5TR3QJN.js";
5
+ } from "./chunk-HGA2O3JM.js";
6
6
 
7
7
  // src/config.ts
8
8
  import {
@@ -141,6 +141,9 @@ function bodyRecord(body) {
141
141
  function validateApiResponse(name, body, data) {
142
142
  if (!isRecord(data)) throw new ApiContractError(name, "a JSON object");
143
143
  const input = bodyRecord(body);
144
+ if (name === "video-shares" && input.action === "clone" && (typeof data.projectId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(data.projectId) || typeof data.alreadyForked !== "boolean")) {
145
+ throw new ApiContractError(name, "a projectId and alreadyForked flag");
146
+ }
144
147
  if (name === "workspace-bootstrap") {
145
148
  const billing = data.billing;
146
149
  if (typeof data.orgId !== "string" || typeof data.plan !== "string" || typeof data.hasAccess !== "boolean" || !isRecord(billing) || typeof billing.status !== "string") {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  setup
4
- } from "./chunk-NG3Q3BYW.js";
4
+ } from "./chunk-SN6SLPIC.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-KI5E2464.js";
15
- import "./chunk-H5TR3QJN.js";
14
+ } from "./chunk-VTHAZQEV.js";
15
+ import "./chunk-HGA2O3JM.js";
16
16
  import "./chunk-77HVPD4G.js";
17
17
 
18
18
  // src/commands.ts
@@ -1204,6 +1204,133 @@ function buildInitialFiles(input) {
1204
1204
  // ../src/shared/video-models.ts
1205
1205
  var DEFAULT_OPENROUTER_VIDEO_MODEL = "anthropic/claude-opus-5";
1206
1206
 
1207
+ // ../src/shared/video-clone-command.ts
1208
+ var TOKEN = /^[A-Za-z0-9_-]{43}$/;
1209
+ var SHARE_HOSTS = /* @__PURE__ */ new Set(["impractical.ai", "www.impractical.ai", "impractical-video-shares.vercel.app"]);
1210
+ function videoShareToken(source) {
1211
+ if (TOKEN.test(source)) return source;
1212
+ try {
1213
+ const url = new URL(source);
1214
+ const token = url.pathname.match(/^\/v\/([A-Za-z0-9_-]{43})\/?$/)?.[1];
1215
+ if (url.protocol === "https:" && SHARE_HOSTS.has(url.host) && !url.username && !url.password && !url.search && !url.hash && token) return token;
1216
+ } catch {
1217
+ }
1218
+ throw new Error("Use an Impractical video share URL: https://impractical.ai/v/<token>");
1219
+ }
1220
+
1221
+ // src/clone-video-project.ts
1222
+ async function cloneVideoProject(source, json = false) {
1223
+ const token = videoShareToken(source);
1224
+ const result = await invoke("video-shares", { action: "clone", token });
1225
+ const url = studioLink(requireConfig().appUrl, result.projectId);
1226
+ if (json) {
1227
+ console.log(JSON.stringify({ ...result, url }, null, 2));
1228
+ return;
1229
+ }
1230
+ console.log(result.alreadyForked ? "Your editable copy is ready." : "Copied the project into your workspace.");
1231
+ console.log(`Open: ${url}`);
1232
+ console.log(`Project: ${result.projectId}`);
1233
+ console.log("After restarting your agent, paste:");
1234
+ console.log(`Use Impractical to open project ${result.projectId} and help me iterate on this video.`);
1235
+ }
1236
+
1237
+ // src/video-sharing.ts
1238
+ import { openAsBlob } from "node:fs";
1239
+ import { readFile, stat } from "node:fs/promises";
1240
+ import { basename as basename2, extname as extname2 } from "node:path";
1241
+ async function shareVideo(rest, flags) {
1242
+ const config = requireConfig();
1243
+ const request = async (body) => {
1244
+ const response = await fetch(
1245
+ `${config.supabaseUrl.replace(/\/$/, "")}/functions/v1/video-shares`,
1246
+ {
1247
+ method: body ? "POST" : "GET",
1248
+ redirect: "error",
1249
+ signal: AbortSignal.timeout(45e3),
1250
+ headers: { authorization: `Bearer ${config.apiKey}`, "content-type": "application/json" },
1251
+ body: body ? JSON.stringify(body) : void 0
1252
+ }
1253
+ );
1254
+ const data = await response.json().catch(() => ({}));
1255
+ if (!response.ok) throw new Error(data.error || `Video sharing failed (${response.status})`);
1256
+ return data;
1257
+ };
1258
+ if (rest[0] === "list") {
1259
+ console.log(JSON.stringify(await request(), null, 2));
1260
+ return;
1261
+ }
1262
+ if (rest[0] === "revoke") {
1263
+ if (!rest[1]) throw new Error("Usage: impractical share revoke <share-id>");
1264
+ await request({ action: "revoke", id: rest[1] });
1265
+ console.log("Share link revoked. Already-open videos may remain playable for up to 5 minutes.");
1266
+ return;
1267
+ }
1268
+ const allowEditing = rest[0] === "allow-editing" || rest[0] === "attach";
1269
+ const viewOnly = rest[0] === "view-only" || rest[0] === "detach";
1270
+ if (allowEditing || viewOnly) {
1271
+ if (!rest[1] || allowEditing && typeof flags.project !== "string") {
1272
+ throw new Error("Usage: impractical share allow-editing <share-id> --project <id> | share view-only <share-id>");
1273
+ }
1274
+ await request({ action: "attach-project", id: rest[1], projectId: viewOnly ? null : flags.project });
1275
+ console.log(allowEditing ? "Editing allowed for this link. Recipients can make their own editable copy." : "This link is now view only. Future project copies are disabled; existing recipient copies remain theirs.");
1276
+ return;
1277
+ }
1278
+ const filePath = rest[0];
1279
+ if (!filePath || extname2(filePath).toLowerCase() !== ".mp4") {
1280
+ throw new Error(
1281
+ 'Usage: impractical share ./video.mp4 [--title "A video for Acme"] [--message "..."] [--references ./public-brand.json] [--project <id>] [--expires-in 7] [--json]'
1282
+ );
1283
+ }
1284
+ const info = await stat(filePath);
1285
+ if (!info.isFile() || info.size < 16 || info.size > 3e8) {
1286
+ throw new Error("Choose an MP4 up to 300 MB");
1287
+ }
1288
+ const days = flags["expires-in"] === void 0 ? null : Number(flags["expires-in"]);
1289
+ if (days !== null && (!Number.isFinite(days) || days <= 0 || days > 3650)) {
1290
+ throw new Error("--expires-in must be a number of days between 0 and 3650");
1291
+ }
1292
+ const file = await openAsBlob(filePath, { type: "video/mp4" });
1293
+ const header = new Uint8Array(await file.slice(0, 16).arrayBuffer());
1294
+ if (String.fromCharCode(...header.slice(4, 8)) !== "ftyp") {
1295
+ throw new Error("This file does not have an MP4 header");
1296
+ }
1297
+ let references;
1298
+ if (typeof flags.references === "string") {
1299
+ const metadataFile = await stat(flags.references);
1300
+ if (!metadataFile.isFile() || metadataFile.size > 4e6) throw new Error("Public references must be a JSON file up to 4 MB");
1301
+ references = JSON.parse(await readFile(flags.references, "utf8"));
1302
+ }
1303
+ const prepared = await request({
1304
+ action: "prepare",
1305
+ bytes: info.size,
1306
+ title: typeof flags.title === "string" ? flags.title : basename2(filePath, extname2(filePath)).slice(0, 160),
1307
+ message: typeof flags.message === "string" ? flags.message : "",
1308
+ expiresAt: days === null ? null : new Date(Date.now() + days * 864e5).toISOString(),
1309
+ ...references === void 0 ? {} : { references },
1310
+ ...typeof flags.project === "string" ? { projectId: flags.project } : {}
1311
+ });
1312
+ try {
1313
+ const form = new FormData();
1314
+ form.append("cacheControl", "0");
1315
+ form.append("", file, basename2(filePath));
1316
+ const response = await fetch(prepared.upload.signedUrl, {
1317
+ method: "PUT",
1318
+ body: form,
1319
+ headers: { "x-upsert": "false" },
1320
+ redirect: "error",
1321
+ signal: AbortSignal.timeout(15 * 6e4)
1322
+ });
1323
+ await response.body?.cancel();
1324
+ if (!response.ok) throw new Error(`MP4 upload failed (${response.status}). Please try again`);
1325
+ const share = await request({ action: "complete", id: prepared.id });
1326
+ console.log(flags.json ? JSON.stringify(share, null, 2) : share.url);
1327
+ } catch (error) {
1328
+ await request({ action: "revoke", id: prepared.id }).catch(() => {
1329
+ });
1330
+ throw error;
1331
+ }
1332
+ }
1333
+
1207
1334
  // src/commands.ts
1208
1335
  var flagString = (flags, name) => {
1209
1336
  const value = flags[name];
@@ -1218,6 +1345,8 @@ var flagList = (flags, name) => {
1218
1345
  var flagBool = (flags, name) => flags[name] === true;
1219
1346
  var out = (line) => console.log(line);
1220
1347
  async function setup2(flags) {
1348
+ const source = flagString(flags, "from");
1349
+ if (source !== void 0) videoShareToken(source);
1221
1350
  const claude = flagBool(flags, "claude");
1222
1351
  const agent = flagBool(flags, "agent");
1223
1352
  await setup({
@@ -1227,6 +1356,11 @@ async function setup2(flags) {
1227
1356
  openBrowser: !flagBool(flags, "no-open"),
1228
1357
  log: out
1229
1358
  });
1359
+ if (source !== void 0) await cloneVideoProject(source);
1360
+ }
1361
+ async function clone(positional, flags) {
1362
+ if (positional.length !== 1) throw new Error("Usage: impractical clone <video-share-url> [--json]");
1363
+ await cloneVideoProject(positional[0], flagBool(flags, "json"));
1230
1364
  }
1231
1365
  async function login(flags) {
1232
1366
  const patch = {};
@@ -1522,12 +1656,14 @@ function validateMeteredCommand(command, positional, flags) {
1522
1656
  }
1523
1657
  export {
1524
1658
  brand,
1659
+ clone,
1525
1660
  exportDraft,
1526
1661
  generate,
1527
1662
  keys,
1528
1663
  link,
1529
1664
  login,
1530
1665
  setup2 as setup,
1666
+ shareVideo,
1531
1667
  status,
1532
1668
  upload,
1533
1669
  validateMeteredCommand,
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-H5TR3QJN.js";
4
+ } from "./chunk-HGA2O3JM.js";
5
5
  import "./chunk-77HVPD4G.js";
6
6
 
7
7
  // src/cli-args.ts
@@ -17,6 +17,7 @@ var CLI_OPTIONS = {
17
17
  brand: { type: "string" },
18
18
  asset: { type: "string", multiple: true },
19
19
  project: { type: "string" },
20
+ from: { type: "string" },
20
21
  feedback: { type: "string" },
21
22
  model: { type: "string" },
22
23
  slug: { type: "string" },
@@ -26,6 +27,11 @@ var CLI_OPTIONS = {
26
27
  out: { type: "string" },
27
28
  draft: { type: "string" },
28
29
  id: { type: "string" },
30
+ title: { type: "string" },
31
+ message: { type: "string" },
32
+ references: { type: "string" },
33
+ "expires-in": { type: "string" },
34
+ json: { type: "boolean" },
29
35
  wait: { type: "boolean" },
30
36
  agent: { type: "boolean" },
31
37
  claude: { type: "boolean" },
@@ -48,6 +54,7 @@ var HELP = `impractical \u2014 Impractical Motion from your terminal (and your c
48
54
  Setup
49
55
  setup --claude Sign in and install the Claude plugin + MCP
50
56
  setup --agent Sign in and install MCP for detected coding agents
57
+ --from <video-share-url> Also copy the shared project into your workspace
51
58
  login --key imk_... Manual credential override for development
52
59
  whoami Show org and plan status
53
60
  keys [list|revoke <id>] List or revoke agent API keys
@@ -66,6 +73,14 @@ Edit drafts
66
73
  Editing happens through the MCP server (see "Agents" above) \u2014 draft source
67
74
  lives in the cloud, so there is no working copy to pull or push.
68
75
  link <projectId> [slug] Print the studio tweak-UI URL
76
+ clone <video-share-url> Add an editable copy to your connected workspace
77
+
78
+ Share videos
79
+ share <file.mp4> [--title "..."] [--references brand.json] [--project <id>]
80
+ Omit --project for a view-only link (the default)
81
+ share allow-editing <share-id> --project <id> Let recipients copy and edit
82
+ share view-only <share-id> Hide editing and block future project copies
83
+ share list | share revoke <share-id>
69
84
 
70
85
  Assets & brands
71
86
  upload <file> --project <id> --draft <id>
@@ -97,6 +112,10 @@ async function runCli(args, deps) {
97
112
  switch (command) {
98
113
  case "setup":
99
114
  return commands.setup(flags);
115
+ case "clone":
116
+ return commands.clone(rest, flags);
117
+ case "share":
118
+ return commands.shareVideo(rest, flags);
100
119
  case "motion":
101
120
  if (rest[0] === "wizard") return commands.setup(flags);
102
121
  break;
@@ -140,11 +159,11 @@ async function runCli(args, deps) {
140
159
  // src/index.ts
141
160
  runCli(process.argv.slice(2), {
142
161
  writeLine: (line) => console.log(line),
143
- loadCommands: () => import("./commands-56WH3ZVJ.js"),
144
- loadApi: () => import("./api-NVZQJBVP.js"),
145
- loadMcp: () => import("./stdio-MJ4LTXME.js")
162
+ loadCommands: () => import("./commands-EDRH2XMY.js"),
163
+ loadApi: () => import("./api-FMMG3W6J.js"),
164
+ loadMcp: () => import("./stdio-VBPVT2KS.js")
146
165
  }).catch(async (error) => {
147
- const { ApiError } = await import("./api-NVZQJBVP.js");
166
+ const { ApiError } = await import("./api-FMMG3W6J.js");
148
167
  if (error instanceof ApiError) {
149
168
  console.error(`Error (${error.status} from ${error.fn}): ${error.message}`);
150
169
  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-NG3Q3BYW.js";
4
+ } from "./chunk-SN6SLPIC.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-KI5E2464.js";
14
+ } from "./chunk-VTHAZQEV.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-H5TR3QJN.js";
20
+ } from "./chunk-HGA2O3JM.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
- var STATE_DIR = () => join(homedir(), ".impractical");
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(tmpdir(), "impractical-frames-"));
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("DEAD AIR \u2014 nothing perceptible moves. Decide what should own this time:");
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 (${(span.end - span.start).toFixed(2)}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 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" : "";
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((interval) => interval.changed >= DEAD_THRESHOLD).length;
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 still = intervals.filter((interval) => interval.changed < DEAD_THRESHOLD).length / intervals.length;
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 + 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."
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 cut is not done while the report shows dead air you did not choose deliberately. get_frames remains the tool for composition close-ups.
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 resolve the dead air and imperceptible tweens it reports before exporting.';
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 result = await ctx.invoke("list-projects", {});
34652
- let projects = result.projects ?? [];
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("URL-safe project slug. Generated when omitted."),
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." : "") + "\n9. ICONS: always use real icons from the icon library, never typographic or ASCII stand-ins. lucide-react is available in every draft \u2014 `import { ArrowRight, Check } from 'lucide-react'`. An arrow is <ArrowRight/>, not \u2192 or ->; a tick is <Check/>, not \u2713; a chevron is <ChevronRight/>, not >; a play control is <Play/>, not \u25B6. Glyphs inherit the text font, so they land on the baseline at the wrong weight and optical size and read as a placeholder, and they cannot take a stroke width or animate as their own element. The same goes for emoji standing in for a UI affordance.\n10. FOCAL BUDGET: one focal point at a time. At most ONE region of the frame changes at once (a full-frame camera move is one change; a subject plus its direct reaction is the limit). A claim and the product may NEVER share the same space: while a statement is up, the product is fully cleared or fully hidden \u2014 dimming it under the words is not separation. Never stack anything on top of a dashboard while the dashboard is the subject. An element that finished its job exits before the next enters; the motion report now measures split focus and stacked text, and the film is not done while either fires.\n11. COMPONENT REHEARSAL: build and judge the hero components BEFORE choreographing the film. For each component that must look real (the dashboard, the inbox, the editor): build it, render it with get_frames in each state the beats will put it through, compare it against the reference you studied, and iterate until it holds up as a still. Only then write the timeline. A component that cannot survive a paused frame will not survive motion, and choreographing a broken component wastes every revision that follows.\n12. SCALE TRUTH: a product element never exceeds its native proportion. When a toast, button, or label must be readable, push the CAMERA to it so the whole UI scales together \u2014 never blow one element up inside otherwise-small chrome; an oversized toast over tiny UI text reads as a fake. The same for pointers: a cursor or caret is sized relative to the UI it operates on (match the product's zoom factor; at 67% zoom the cursor is 67% size), a caret pins to its text baseline, and a cursor tip lands ON its target, never near it. The census measures inflated elements; the film is not done while it fires.\n13. PACING: slow down. Stillness is a tool, not a defect \u2014 the dead-air check finds UNOWNED time, not quiet time. After every claim and every proof, HOLD: the viewer needs settled time to focus before the next change. The motion report flags nonstop motion (TOO FAST) exactly like it flags dead air, and the film is not done while either fires. THE CADENCE: one action at a time, about one every 1\u20131.5 seconds \u2014 a click, a panel opening, a card landing each count as one action. Let each settle before the next. The kit and camera defaults are already tuned to this; never override their durations to be FASTER. The report measures action frequency and flags stretches that pack more.\n14. CUTS: a scene change lands ONLY on a declared beat boundary \u2014 never mid-beat, never mid-read. A transition is either a real camera move (the house dolly) or a hard cut covered by the incoming element; a 0.1\u20130.3s squash-collapse is neither. Vary how scenes leave \u2014 the same exit mechanism back to back reads as a loop, and a scene alive under ~2 seconds never registered. The motion score measures all of this.\n15. THE GO-THROUGH: to dive into a button/card/color until it takes over the frame and becomes the next scene, ALWAYS use transition.through(target, { swap, land }) \u2014 never hand-roll it with a big camera.move scale plus a timed tl.set swap. Hand-rolled dives aim at build-time positions and miss; through() measures the target at the dive's start time, computes the exact cover scale, and guarantees the cut lands under full cover." + (ctx.qualityLoop ? qualityLoopServerInstructions() : "") + (ctx.motionLab ? motionLabServerInstructions() : "") + (ctx.craftLab ? "\n\nCRAFT LAB IS ENABLED \u2014 get_motion additionally prints a CRAFT ENVELOPE (depth, vocabulary, rhythm, continuity, geometry) band-scored against the house references. Camera motion is exempt from the restraint verdicts. Export enforces objective craft floors: at least one camera move, zero unresolved beat mismatches, zero geometry deformations, no dead-air span over 2.5s. Self-scores are recorded but do not gate; identical scorecards across drafts are rejected." : "") + (ctx.phasedFlow ? phasedServerInstructions() : "")
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.9",
3
+ "version": "0.1.11",
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",