@sanohiro/casty 0.5.6 → 0.5.8

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/bin/casty.js CHANGED
@@ -2,9 +2,17 @@
2
2
  // casty - TTY web browser using raw CDP and Kitty graphics protocol
3
3
 
4
4
  import { execFileSync } from 'node:child_process';
5
+ import { readFileSync } from 'node:fs';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { dirname, join } from 'node:path';
7
8
 
9
+ // --version / -v
10
+ if (process.argv[2] === '--version' || process.argv[2] === '-v') {
11
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
12
+ console.log(`casty ${pkg.version}`);
13
+ process.exit(0);
14
+ }
15
+
8
16
  // Ensure Chrome is installed (skip if launched from bin/casty shell script)
9
17
  if (!process.env.CASTY_ENSURE_CHROME) {
10
18
  const __bin = dirname(fileURLToPath(import.meta.url));
@@ -19,7 +27,7 @@ if (!process.env.CASTY_ENSURE_CHROME) {
19
27
  }
20
28
 
21
29
  import { startBrowser, setupPage, startScreencast, stopScreencast } from '../lib/browser.js';
22
- import { sendFrame, resetFrameCache, clearScreen, hideCursor, showCursor, cleanup as cleanupTmp, transport } from '../lib/kitty.js';
30
+ import { sendFrame, resetFrameCache, clearScreen, hideCursor, showCursor, cleanup as cleanupTmp, transport, setDisplaySize, disableDedup } from '../lib/kitty.js';
23
31
  import { enableMouse, disableMouse, startInputHandling } from '../lib/input.js';
24
32
  import { loadKeyBindings } from '../lib/keys.js';
25
33
  import { loadConfig } from '../lib/config.js';
@@ -89,10 +97,14 @@ async function getTermInfo({ keepAlive = false } = {}) {
89
97
 
90
98
  const pixelSize = await queryTermPixelSize({ keepAlive });
91
99
  if (pixelSize) {
92
- const cellWidth = pixelSize.width / cols;
93
- const cellHeight = pixelSize.height / rows;
100
+ // Align to cell boundaries: floor cell size, then multiply back
101
+ // This ensures image pixels == display pixels (no GPU interpolation blur)
102
+ const cellWidth = Math.floor(pixelSize.width / cols);
103
+ const cellHeight = Math.floor(pixelSize.height / rows);
104
+ const width = cellWidth * cols;
105
+ const height = cellHeight * rows;
94
106
  const zoom = calcZoom(cellWidth);
95
- return { cols, rows, width: pixelSize.width, height: pixelSize.height, cellWidth, cellHeight, zoom };
107
+ return { cols, rows, width, height, cellWidth, cellHeight, zoom };
96
108
  }
97
109
 
98
110
  const cellWidth = parseInt(process.env.CASTY_CELL_WIDTH) || 10;
@@ -114,8 +126,9 @@ async function main() {
114
126
  const browser = await browserP;
115
127
 
116
128
  // Reserve line 1 for URL bar, use the rest for browser display
117
- const barHeight = Math.round(term.cellHeight);
129
+ const barHeight = term.cellHeight;
118
130
  const viewHeight = term.height - barHeight;
131
+ setDisplaySize(term.cols, term.rows - 1);
119
132
 
120
133
  // Phase 2: CDP connection + page setup
121
134
  const { client, cssWidth, cssHeight } = await setupPage(browser, { ...term, height: viewHeight });
@@ -215,25 +228,64 @@ async function main() {
215
228
  clearTimeout(resizeTimer);
216
229
  resizeTimer = setTimeout(handleResize, 150);
217
230
  });
231
+ // Direct screenshot — bypasses screencast's capturing flag
232
+ const screenshotOpts = { format: screenshotFormat, optimizeForSpeed: true, captureBeyondViewport: false };
233
+ if (screenshotFormat === 'jpeg') screenshotOpts.quality = 85;
234
+ async function directCapture() {
235
+ try {
236
+ const { data } = await client.send('Page.captureScreenshot', screenshotOpts);
237
+ if (data) onFrame(data);
238
+ } catch {}
239
+ }
240
+
218
241
  async function handleResize() {
219
242
  if (resizing) { pendingResize = true; return; }
220
243
  resizing = true;
221
244
  try {
245
+ // Stop old screencast FIRST to prevent stale frames
246
+ await stopScreencast(client, screencastCleanup);
247
+
222
248
  const t = await getTermInfo({ keepAlive: true });
223
- const vh = t.height - Math.round(t.cellHeight);
249
+ const vh = t.height - t.cellHeight;
224
250
  const cw = Math.round(t.width / t.zoom);
225
251
  const ch = Math.round(vh / t.zoom);
252
+ setDisplaySize(t.cols, t.rows - 1);
226
253
  console.error(`casty: resize ${cw}x${ch} (dev:${t.width}x${vh}) zoom:${t.zoom.toFixed(2)}`);
227
254
 
228
255
  urlBar.updateCellSize(t.cellWidth, t.cellHeight);
229
256
  clearScreen();
230
257
  resetFrameCache();
258
+ disableDedup(3000); // Force re-send for 3s (bcon may not display first frame)
231
259
 
232
- await stopScreencast(client, screencastCleanup);
233
260
  await client.send('Emulation.setDeviceMetricsOverride', {
234
261
  width: cw, height: ch, deviceScaleFactor: t.zoom, mobile: false,
235
262
  });
236
263
 
264
+ // Wait for Chrome to finish re-rendering by watching for a screencast frame
265
+ await new Promise(resolve => {
266
+ const onFirstFrame = ({ sessionId }) => {
267
+ client.send('Page.screencastFrameAck', { sessionId }).catch(() => {});
268
+ client.removeListener('Page.screencastFrame', onFirstFrame);
269
+ resolve();
270
+ };
271
+ client.on('Page.screencastFrame', onFirstFrame);
272
+ client.send('Page.startScreencast', {
273
+ format: 'jpeg', quality: 10,
274
+ maxWidth: Math.round(cw / 4), maxHeight: Math.round(ch / 4),
275
+ everyNthFrame: 1,
276
+ }).catch(() => resolve());
277
+ setTimeout(() => {
278
+ client.removeListener('Page.screencastFrame', onFirstFrame);
279
+ resolve();
280
+ }, 2000);
281
+ });
282
+ await client.send('Page.stopScreencast').catch(() => {});
283
+
284
+ // Capture hi-res frame (Chrome has finished rendering)
285
+ await directCapture();
286
+ urlBar.render();
287
+
288
+ // Restart screencast for ongoing change detection
237
289
  ({ forceCapture, cleanup: screencastCleanup } = await startScreencast(client, {
238
290
  width: cw,
239
291
  height: ch,
package/lib/kitty.js CHANGED
@@ -29,6 +29,16 @@ import { loadConfig } from './config.js';
29
29
  const tmpFile = join(tmpdir(), `casty-frame-${process.pid}.png`);
30
30
  const tmpPathB64 = Buffer.from(tmpFile).toString('base64');
31
31
 
32
+ // Display size in cells (set by caller, used for c=/r= parameters)
33
+ let _cols = 0;
34
+ let _rows = 0;
35
+
36
+ // Set display size (cols = terminal columns, rows = display rows excluding URL bar)
37
+ export function setDisplaySize(cols, rows) {
38
+ _cols = cols;
39
+ _rows = rows;
40
+ }
41
+
32
42
  // Detect transfer mode
33
43
  function detectTransport() {
34
44
  const config = loadConfig();
@@ -74,24 +84,39 @@ export function cleanup() {
74
84
 
75
85
  // Frame deduplication — skip identical consecutive frames
76
86
  let lastFrameData = '';
87
+ let _dedupDisabled = false;
88
+ let _dedupTimer = null;
89
+
90
+ // Temporarily disable dedup (e.g. after resize, bcon needs re-send)
91
+ export function disableDedup(ms = 3000) {
92
+ _dedupDisabled = true;
93
+ clearTimeout(_dedupTimer);
94
+ _dedupTimer = setTimeout(() => { _dedupDisabled = false; }, ms);
95
+ }
77
96
 
78
97
  // File transfer mode (fast: sends only path)
79
98
  // Prepends cursor-home to batch into a single write
80
99
  function sendFrameFile(base64Data) {
81
- if (base64Data.length === lastFrameData.length && base64Data === lastFrameData) return;
100
+ if (!_dedupDisabled && base64Data.length === lastFrameData.length && base64Data === lastFrameData) {
101
+ return;
102
+ }
82
103
  lastFrameData = base64Data;
83
104
  writeFileSync(tmpFile, Buffer.from(base64Data, 'base64'));
84
- process.stdout.write(`${CURSOR_HOME}\x1b_Ga=T,f=100,t=f,q=2,C=1,i=1;${tmpPathB64}\x1b\\`);
105
+ const crFile = _cols && _rows ? `,c=${_cols},r=${_rows}` : '';
106
+ process.stdout.write(`${CURSOR_HOME}\x1b_Ga=T,f=100,t=f,q=2,C=1,i=1${crFile};${tmpPathB64}\x1b\\`);
85
107
  }
86
108
 
87
109
  // Inline mode (4096B chunked, PNG only)
88
110
  // Prepends cursor-home and batches all chunks into a single stdout.write
89
111
  function sendFrameInline(pngBase64) {
90
- if (pngBase64.length === lastFrameData.length && pngBase64 === lastFrameData) return;
112
+ if (!_dedupDisabled && pngBase64.length === lastFrameData.length && pngBase64 === lastFrameData) {
113
+ return;
114
+ }
91
115
  lastFrameData = pngBase64;
92
116
  const CHUNK = 4096;
117
+ const crInline = _cols && _rows ? `,c=${_cols},r=${_rows}` : '';
93
118
  if (pngBase64.length <= CHUNK) {
94
- process.stdout.write(`${CURSOR_HOME}\x1b_Ga=T,f=100,q=2,C=1,i=1;${pngBase64}\x1b\\`);
119
+ process.stdout.write(`${CURSOR_HOME}\x1b_Ga=T,f=100,q=2,C=1,i=1${crInline};${pngBase64}\x1b\\`);
95
120
  return;
96
121
  }
97
122
  const parts = [CURSOR_HOME];
@@ -100,7 +125,7 @@ function sendFrameInline(pngBase64) {
100
125
  const chunk = pngBase64.slice(i, i + CHUNK);
101
126
  const more = i + CHUNK < pngBase64.length ? 1 : 0;
102
127
  if (i === 0) {
103
- parts.push(`\x1b_Ga=T,f=100,q=2,C=1,i=1,m=${more};${chunk}\x1b\\`);
128
+ parts.push(`\x1b_Ga=T,f=100,q=2,C=1,i=1${crInline},m=${more};${chunk}\x1b\\`);
104
129
  } else {
105
130
  parts.push(`\x1b_Gm=${more};${chunk}\x1b\\`);
106
131
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanohiro/casty",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "TTY web browser using raw CDP and Kitty graphics protocol",
5
5
  "main": "bin/casty.js",
6
6
  "bin": {