@writepanda/cli 1.222.0 → 1.225.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,6 +54,13 @@ pandastudio caption.set-template --templateId=bold
54
54
  pandastudio export.start --id=$ID
55
55
  ```
56
56
 
57
+ Review items: the user can pin notes, drawings and picked graphics on the editor preview and send them to the agent. Read and close them with:
58
+
59
+ ```bash
60
+ pandastudio review.list --json # open project; each item has imagePath (frame crop)
61
+ pandastudio review.resolve --id=$ID --annotationIds='["ITEM_ID"]'
62
+ ```
63
+
57
64
  Same Tier-3 PixiJS render pipeline the in-app Export Video button uses (v1.24+ — convergence release). Reuses an open editor window if it's already on the target project, otherwise spawns a hidden one for the duration of the render. Identical output to a UI-driven export.
58
65
 
59
66
  ## For AI agents (Claude Code, Claude Desktop)
@@ -111,3 +118,9 @@ English, Parakeet's European set and the Whisper languages. Provider is
111
118
  `local|apple|deepgram|elevenlabs`. Never use cloud or apple with `auto`; choose a language first.
112
119
  Cloud needs a connected key and user consent to upload audio. Surface job `warnings`
113
120
  when a network/provider failure caused a local fallback.
121
+
122
+ ### Close a timeline gap
123
+
124
+ `pandastudio project.close-gap --id=PROJECT --startMs=2500 --endMs=4000 --lane=media:0 --json`
125
+
126
+ The timeline's right-click "Close gap". Times are edited milliseconds. Omit `lane` (or use `main`) to ripple-delete the range from the video; inserted pauses inside it are shortened or removed rather than cutting footage, and anchored regions follow. Use `media[:index]`, `audio[:index]` or `effects[:index]` to close an empty span of that sub-lane: every later item in the lane shifts left. Sub-lanes are zero based and packed by start time, matching the editor; speed changes never move. A range that is not empty fails and changes nothing. `expectedRevision` is supported. MCP: `project_close_gap`.
@@ -40,7 +40,7 @@ for (const stream of [process.stdout, process.stderr]) {
40
40
  });
41
41
  }
42
42
 
43
- const VERSION = "1.222.0";
43
+ const VERSION = "1.225.0";
44
44
 
45
45
  function configDir() {
46
46
  // PANDASTUDIO_CONFIG_DIR mirrors the override the main process
@@ -69,6 +69,17 @@ USAGE
69
69
  pandastudio transcript.transcribe --id=ID --force=true --language=tamil --provider=deepgram
70
70
  pandastudio transcript.detect-language --id=ID --clipId=CLIP
71
71
  pandastudio --version Print CLI version
72
+ pandastudio brand.list-assets --json List workspace brand media
73
+ pandastudio brand.add-asset --path=/absolute/logo.png --role=logo
74
+ pandastudio project.add-brand-asset --id=<project> --assetId=<asset> --atMs=0
75
+ pandastudio brand.remove-asset --id=<asset>
76
+ pandastudio motion.variables --source=@generatedFrom.json Typed variables of a graphic
77
+ pandastudio motion.generate --templateId=creator-card --variables='{"headline":"Client A"}'
78
+ pandastudio project.update-motion-graphic --id=ID --overlayId=OVERLAY --variables='{"headline":"New"}'
79
+ pandastudio project.update-motion-graphic --id=ID --overlayId=OVERLAY --preset="Client A lower third"
80
+ pandastudio motion.save-preset --source=@generatedFrom.json --name="Client A lower third" --variables=@values.json
81
+ pandastudio motion.list-presets --templateId=creator-card
82
+ pandastudio motion.delete-preset --templateId=creator-card --name="Client A lower third"
72
83
  pandastudio commands List every available verb.noun
73
84
 
74
85
  OPTIONS
@@ -389,22 +400,6 @@ async function fetchJson(port, pathSuffix, opts = {}) {
389
400
  return { status: res.status, body: parsed };
390
401
  }
391
402
 
392
- async function probeHealth(port, timeoutMs) {
393
- // Single short probe with a small AbortController; used as a
394
- // liveness check before auto-launching, then in a poll loop after
395
- // launch to wait for readiness.
396
- const ctrl = new AbortController();
397
- const t = setTimeout(() => ctrl.abort(), timeoutMs);
398
- try {
399
- const res = await fetch(`http://127.0.0.1:${port}/v1/health`, { signal: ctrl.signal });
400
- clearTimeout(t);
401
- return res.ok;
402
- } catch {
403
- clearTimeout(t);
404
- return false;
405
- }
406
- }
407
-
408
403
  /**
409
404
  * One health probe: "up" (answered), "busy" (the socket is open but no answer
410
405
  * within `timeoutMs`: the app's main process is stalled, e.g. creating an
@@ -509,7 +504,61 @@ function appLaunchEnv(env) {
509
504
  return out;
510
505
  }
511
506
 
512
- async function autoLaunchAndWait(opts, log) {
507
+ // Launch guard (mirrors ensureApp in packages/mcp/bin/appConnection.mjs; this
508
+ // script ships as one standalone file). Launching whenever the port file's
509
+ // server didn't answer started a NEW PandaStudio while one was busy, booting,
510
+ // or had its token/port files replaced (Jared, Windows 2.1.15: "it restarted
511
+ // with every request"). Launch only when nothing says PandaStudio is running,
512
+ // and at most once per LAUNCH_DEBOUNCE_MS from any process on the machine.
513
+ const DEFAULT_AUTOMATION_PORT = 7878;
514
+ const INSTANCE_STALE_MS = 20_000;
515
+ const LAUNCH_DEBOUNCE_MS = 90_000;
516
+
517
+ function pidAlive(pid) {
518
+ if (!Number.isInteger(pid) || pid <= 0) return false;
519
+ try {
520
+ process.kill(pid, 0);
521
+ return true;
522
+ } catch (err) {
523
+ return err?.code === "EPERM";
524
+ }
525
+ }
526
+
527
+ /** instance.json names a live PandaStudio with a fresh heartbeat. */
528
+ async function appInstanceAlive() {
529
+ try {
530
+ const raw = await fs.readFile(path.join(configDir(), "instance.json"), "utf-8");
531
+ const rec = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
532
+ const beat = Number(rec?.heartbeatAt);
533
+ if (!Number.isFinite(beat) || Date.now() - beat > INSTANCE_STALE_MS) return false;
534
+ return pidAlive(rec.pid);
535
+ } catch {
536
+ return false;
537
+ }
538
+ }
539
+
540
+ async function lastLaunchAgeMs() {
541
+ try {
542
+ const at = Number(
543
+ (await fs.readFile(path.join(configDir(), "launch-requested"), "utf-8")).trim(),
544
+ );
545
+ if (!Number.isFinite(at) || at > Date.now()) return null;
546
+ return Date.now() - at;
547
+ } catch {
548
+ return null;
549
+ }
550
+ }
551
+
552
+ async function recordLaunch() {
553
+ try {
554
+ await fs.mkdir(configDir(), { recursive: true });
555
+ await fs.writeFile(path.join(configDir(), "launch-requested"), String(Date.now()), "utf-8");
556
+ } catch {
557
+ /* best-effort */
558
+ }
559
+ }
560
+
561
+ function launchApp(opts, log) {
513
562
  const bin = findInstalledApp(opts.appOverride);
514
563
  log(`auto-launching ${bin}`);
515
564
  try {
@@ -521,31 +570,76 @@ async function autoLaunchAndWait(opts, log) {
521
570
  // program's first window too).
522
571
  windowsHide: false,
523
572
  });
573
+ child.on("error", () => {
574
+ /* reported by the readiness wait */
575
+ });
524
576
  child.unref();
525
577
  } catch (err) {
526
578
  throw new Error(`failed to launch PandaStudio (${bin}): ${err?.message ?? err}`);
527
579
  }
580
+ }
528
581
 
529
- const deadline = Date.now() + opts.timeoutSeconds * 1000;
530
- let credsKnown = null;
531
- while (Date.now() < deadline) {
532
- // Re-read credentials each iteration — the app writes them only
533
- // after binding, so the file may not exist yet.
582
+ async function autoLaunchAndWait(opts, log) {
583
+ const started = Date.now();
584
+ const deadline = started + opts.timeoutSeconds * 1000;
585
+ let launched = false;
586
+ let last = { state: null, alive: false, preferred: null };
587
+ for (;;) {
588
+ // Re-read credentials each round: the app writes them only after
589
+ // binding, and its heartbeat rewrites them if they drifted.
590
+ let creds = null;
591
+ let state = null;
534
592
  try {
535
- credsKnown = await readCredentials(opts);
536
- if (await probeHealth(credsKnown.port, 2000)) {
537
- log(
538
- `ready after ${Math.round((Date.now() - (deadline - opts.timeoutSeconds * 1000)) / 1000)}s`,
539
- );
540
- return credsKnown;
541
- }
593
+ creds = await readCredentials(opts);
594
+ state = await probeHealthState(creds.port, 1500);
542
595
  } catch {
543
596
  /* token/port not written yet */
544
597
  }
598
+ if (state === "up") {
599
+ if (launched) log(`ready after ${Math.round((Date.now() - started) / 1000)}s`);
600
+ return creds;
601
+ }
602
+ const alive = await appInstanceAlive();
603
+ let preferred = null;
604
+ if (!alive && state !== "busy" && creds?.port !== DEFAULT_AUTOMATION_PORT) {
605
+ preferred = await probeHealthState(DEFAULT_AUTOMATION_PORT, 1000);
606
+ }
607
+ last = { state, alive, preferred };
608
+ const age = launched ? 0 : await lastLaunchAgeMs();
609
+ const mayLaunch =
610
+ state !== "busy" &&
611
+ !alive &&
612
+ preferred !== "up" &&
613
+ preferred !== "busy" &&
614
+ (age === null || age >= LAUNCH_DEBOUNCE_MS);
615
+ if (mayLaunch) {
616
+ await recordLaunch();
617
+ launched = true;
618
+ launchApp(opts, log);
619
+ } else if (!launched && Date.now() - started < 600) {
620
+ log("PandaStudio is already running; waiting for it to answer (not launching another)");
621
+ }
622
+ if (Date.now() >= deadline) break;
545
623
  await new Promise((r) => setTimeout(r, 500));
546
624
  }
625
+ const secs = opts.timeoutSeconds;
626
+ if (last.state === "busy") {
627
+ throw new Error(
628
+ `PandaStudio is running but busy: it didn't answer for ${secs}s. Try again in a moment; nothing was relaunched.`,
629
+ );
630
+ }
631
+ if (last.alive) {
632
+ throw new Error(
633
+ `PandaStudio is running but its automation server didn't answer within ${secs}s. In PandaStudio open Settings → Automation and make sure Local automation is on.`,
634
+ );
635
+ }
636
+ if (last.preferred === "up") {
637
+ throw new Error(
638
+ `A PandaStudio is answering on 127.0.0.1:${DEFAULT_AUTOMATION_PORT} but its access files in ${configDir()} are missing or stale. Quit PandaStudio completely and open it again.`,
639
+ );
640
+ }
547
641
  throw new Error(
548
- `PandaStudio did not become ready within ${opts.timeoutSeconds}s. Open PandaStudio, go to Settings → Automation and turn on Local automation.`,
642
+ `PandaStudio did not become ready within ${secs}s. Open PandaStudio, go to Settings → Automation and turn on Local automation.`,
549
643
  );
550
644
  }
551
645
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writepanda/cli",
3
- "version": "1.222.0",
3
+ "version": "1.225.0",
4
4
  "description": "Drive PandaStudio (a desktop video editor for YouTube creators) from the command line. Talks to a localhost HTTP API the desktop app exposes.",
5
5
  "keywords": [
6
6
  "pandastudio",