clipwise 0.11.1 → 0.12.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/dist/cli/index.js CHANGED
@@ -789,6 +789,9 @@ var init_recorder = __esm({
789
789
  frameChannel = null;
790
790
  channelIndex = 0;
791
791
  // sequential index for channel-pushed frames
792
+ /** low-memory 채널 모드 — 버퍼는 스트림으로만 내보내고 내부에는 메타데이터만
793
+ * 보관한다. done의 frames는 빈 screenshot + sourceIndex(원본 참조)를 갖는다. */
794
+ channelLowMemory = false;
792
795
  /**
793
796
  * Launch the browser and create a page with the scenario viewport.
794
797
  */
@@ -834,6 +837,7 @@ var init_recorder = __esm({
834
837
  this.dedupStats = { received: 0, stored: 0, skipped: 0 };
835
838
  this.frameChannel = null;
836
839
  this.channelIndex = 0;
840
+ this.channelLowMemory = false;
837
841
  }
838
842
  /**
839
843
  * Start CDP screencast for continuous frame capture.
@@ -867,6 +871,9 @@ var init_recorder = __esm({
867
871
  this.channelIndex++
868
872
  );
869
873
  this.frameChannel.push(frame);
874
+ if (this.channelLowMemory) {
875
+ rawFrame.buffer = Buffer.alloc(0);
876
+ }
870
877
  }
871
878
  }
872
879
  await this.cdpClient.send("Page.screencastFrameAck", {
@@ -1000,12 +1007,13 @@ var init_recorder = __esm({
1000
1007
  * Use this with CanvasRenderer.composeStreamOnline() to overlap recording
1001
1008
  * time with composition time — total wall-clock ≈ max(recordingMs, composeMs).
1002
1009
  */
1003
- recordToChannel(scenario) {
1010
+ recordToChannel(scenario, options) {
1004
1011
  const channel = new FrameChannel();
1005
1012
  const done = (async () => {
1006
1013
  try {
1007
1014
  await this.init(scenario);
1008
1015
  this.frameChannel = channel;
1016
+ this.channelLowMemory = options?.lowMemory ?? false;
1009
1017
  const startTime = Date.now();
1010
1018
  if (scenario.steps.length > 0) {
1011
1019
  const s0 = scenario.steps[0];
@@ -1562,7 +1570,9 @@ var init_recorder = __esm({
1562
1570
  frames.length,
1563
1571
  Math.round(recordingDurationMs / 1e3 * this.targetFps)
1564
1572
  );
1565
- if (targetFrameCount <= frames.length) return frames;
1573
+ if (targetFrameCount <= frames.length) {
1574
+ return frames.map((f, idx) => ({ ...f, sourceIndex: idx }));
1575
+ }
1566
1576
  const startTime = frames[0].timestamp;
1567
1577
  const endTime = frames[frames.length - 1].timestamp;
1568
1578
  const duration = Math.max(1, endTime - startTime);
@@ -1588,6 +1598,7 @@ var init_recorder = __esm({
1588
1598
  resampled.push({
1589
1599
  index: i,
1590
1600
  screenshot: frames[nearestIdx].screenshot,
1601
+ sourceIndex: nearestIdx,
1591
1602
  timestamp: targetTimestamp,
1592
1603
  cursorPosition: cursorPos,
1593
1604
  clickPosition: clickEvent?.position ?? null,
@@ -2824,7 +2835,7 @@ var init_canvas_renderer = __esm({
2824
2835
  init_transition();
2825
2836
  MIN_FRAMES_PER_WORKER = 4;
2826
2837
  cachedWorkerUrl = null;
2827
- CanvasRenderer = class {
2838
+ CanvasRenderer = class _CanvasRenderer {
2828
2839
  constructor(effects, output, steps) {
2829
2840
  this.effects = effects;
2830
2841
  this.output = output;
@@ -3219,25 +3230,28 @@ var init_canvas_renderer = __esm({
3219
3230
  * Uses a notify-on-progress pattern (same as streamWithWorkers) extended
3220
3231
  * with an intake coroutine that feeds the growing frames[] buffer.
3221
3232
  */
3233
+ static EMPTY_SCREENSHOT = Buffer.alloc(0);
3222
3234
  async *streamOnlineWithWorkers(source, workerCount) {
3223
3235
  const transitionFrames = this.effects.zoom.enabled ? Math.round(this.output.fps * (this.effects.zoom.duration / 1e3)) : 0;
3224
3236
  const trailLength = this.effects.cursor.trailLength;
3225
3237
  const frames = [];
3226
3238
  let sourceComplete = false;
3227
3239
  let workerError = null;
3228
- let notify = null;
3240
+ let waiters = [];
3229
3241
  const trigger = () => {
3230
- notify?.();
3231
- notify = null;
3242
+ const ws = waiters;
3243
+ waiters = [];
3244
+ for (const w of ws) w();
3232
3245
  };
3233
3246
  const waitForProgress = () => new Promise((r) => {
3234
- notify = r;
3247
+ waiters.push(r);
3235
3248
  });
3236
3249
  const completed = /* @__PURE__ */ new Map();
3237
3250
  const idleWorkers = [];
3238
3251
  let nextToDispatch = 0;
3239
3252
  let nextToYield = 0;
3240
- const canDispatch = (i) => i < frames.length && (sourceComplete || frames.length > i + transitionFrames);
3253
+ const MAX_BACKLOG = workerCount * 3;
3254
+ const canDispatch = (i) => i < frames.length && (sourceComplete || frames.length > i + transitionFrames) && i - nextToYield < MAX_BACKLOG;
3241
3255
  const effectiveScale = resolveZoomScale(
3242
3256
  this.effects.zoom.scale,
3243
3257
  this.effects.zoom.intensity
@@ -3277,6 +3291,7 @@ var init_canvas_renderer = __esm({
3277
3291
  output: this.output,
3278
3292
  context: computeContext(i)
3279
3293
  });
3294
+ frames[i] = { ...frames[i], screenshot: _CanvasRenderer.EMPTY_SCREENSHOT };
3280
3295
  } else {
3281
3296
  idleWorkers.push(worker);
3282
3297
  }
@@ -3312,11 +3327,15 @@ var init_canvas_renderer = __esm({
3312
3327
  });
3313
3328
  idleWorkers.push(worker);
3314
3329
  }
3330
+ const INTAKE_AHEAD = Math.max(MAX_BACKLOG, transitionFrames + workerCount) + 16;
3315
3331
  const intakeTask = (async () => {
3316
3332
  for await (const frame of source) {
3317
3333
  frames.push(frame);
3318
3334
  dispatchToIdle();
3319
3335
  trigger();
3336
+ while (!workerError && frames.length - nextToYield > INTAKE_AHEAD) {
3337
+ await waitForProgress();
3338
+ }
3320
3339
  }
3321
3340
  sourceComplete = true;
3322
3341
  dispatchToIdle();
@@ -3332,6 +3351,8 @@ var init_canvas_renderer = __esm({
3332
3351
  const frame = completed.get(nextToYield);
3333
3352
  completed.delete(nextToYield);
3334
3353
  nextToYield++;
3354
+ dispatchToIdle();
3355
+ trigger();
3335
3356
  yield frame;
3336
3357
  continue;
3337
3358
  }
@@ -4128,21 +4149,34 @@ async function recordFootageTake(scenario, scene, selectors) {
4128
4149
  effects: footageEffects(scenario.effects)
4129
4150
  };
4130
4151
  const recorder = new ClipwiseRecorder();
4131
- const session = await recorder.record(takeScenario);
4152
+ const handle = recorder.recordToChannel(takeScenario, { lowMemory: true });
4153
+ const rawDir = await mkdtemp(join2(tmpdir2(), `clipwise-raw-${scene.id}-`));
4154
+ for await (const frame of handle.frameStream) {
4155
+ await writeFile2(join2(rawDir, `${frame.index}.png`), frame.screenshot);
4156
+ }
4157
+ const session = await handle.done;
4132
4158
  const renderer = new CanvasRenderer(
4133
4159
  takeScenario.effects,
4134
4160
  segmentOutput(scenario, scenario.viewport.width, scenario.viewport.height),
4135
4161
  scene.steps
4136
4162
  );
4163
+ const planStream = (async function* () {
4164
+ for (const f of session.frames) {
4165
+ yield { ...f, screenshot: await readFile4(join2(rawDir, `${f.sourceIndex ?? f.index}.png`)) };
4166
+ }
4167
+ })();
4137
4168
  const framesDir = await mkdtemp(join2(tmpdir2(), `clipwise-footage-${scene.id}-`));
4138
4169
  let count = 0;
4139
- for await (const f of renderer.composeStream(session.frames)) {
4170
+ const composed = renderer.canStreamOnline() ? renderer.composeStreamOnline(planStream) : renderer.composeStream(session.frames);
4171
+ for await (const f of composed) {
4140
4172
  const png = f.rawInfo ? await sharp10(f.buffer, {
4141
4173
  raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
4142
4174
  }).png().toBuffer() : f.buffer;
4143
4175
  await writeFile2(join2(framesDir, `${count}.png`), png);
4144
4176
  count++;
4145
4177
  }
4178
+ await rm2(rawDir, { recursive: true, force: true }).catch(() => {
4179
+ });
4146
4180
  const anchors = [];
4147
4181
  for (let k = 0; k < scene.steps.length; k++) {
4148
4182
  const idx = session.frames.findIndex((f) => (f.stepIndex ?? 0) >= k);
@@ -4642,7 +4676,7 @@ import { homedir } from "os";
4642
4676
  var program = new Command();
4643
4677
  program.name("clipwise").description(
4644
4678
  "Playwright-based cinematic screen recorder for product demos"
4645
- ).version("0.11.1");
4679
+ ).version("0.12.0");
4646
4680
  program.command("record").description("Record a demo from a YAML scenario file").argument("<scenario>", "Path to YAML scenario file").option("-o, --output <dir>", "Output directory (default: scenario outputDir or .clipwise/output)").option(
4647
4681
  "-f, --format <format>",
4648
4682
  "Output format (gif|mp4|png-sequence)"
package/dist/index.d.ts CHANGED
@@ -9889,6 +9889,11 @@ interface CapturedFrame {
9889
9889
  isWaitingPhase?: boolean;
9890
9890
  /** Speed multiplier for this frame when in a smartWait phase. */
9891
9891
  displaySpeed?: number;
9892
+ /**
9893
+ * low-memory 스트리밍 녹화에서 이 프레임이 참조하는 원본(채널) 프레임 인덱스.
9894
+ * screenshot이 빈 버퍼일 때, 소비자가 디스크에 받아둔 원본을 이 인덱스로 찾는다.
9895
+ */
9896
+ sourceIndex?: number;
9892
9897
  }
9893
9898
  interface ComposedFrame {
9894
9899
  index: number;
@@ -9967,6 +9972,9 @@ declare class ClipwiseRecorder {
9967
9972
  private dedupStats;
9968
9973
  private frameChannel;
9969
9974
  private channelIndex;
9975
+ /** low-memory 채널 모드 — 버퍼는 스트림으로만 내보내고 내부에는 메타데이터만
9976
+ * 보관한다. done의 frames는 빈 screenshot + sourceIndex(원본 참조)를 갖는다. */
9977
+ private channelLowMemory;
9970
9978
  /**
9971
9979
  * Launch the browser and create a page with the scenario viewport.
9972
9980
  */
@@ -9997,7 +10005,9 @@ declare class ClipwiseRecorder {
9997
10005
  * Use this with CanvasRenderer.composeStreamOnline() to overlap recording
9998
10006
  * time with composition time — total wall-clock ≈ max(recordingMs, composeMs).
9999
10007
  */
10000
- recordToChannel(scenario: Scenario): RecordingHandle;
10008
+ recordToChannel(scenario: Scenario, options?: {
10009
+ lowMemory?: boolean;
10010
+ }): RecordingHandle;
10001
10011
  /**
10002
10012
  * Build a single CapturedFrame from a RawFrame in real-time.
10003
10013
  * Used by recordToChannel() to emit frames as they arrive.
@@ -10292,6 +10302,7 @@ declare class CanvasRenderer {
10292
10302
  * Uses a notify-on-progress pattern (same as streamWithWorkers) extended
10293
10303
  * with an intake coroutine that feeds the growing frames[] buffer.
10294
10304
  */
10305
+ private static readonly EMPTY_SCREENSHOT;
10295
10306
  private streamOnlineWithWorkers;
10296
10307
  /**
10297
10308
  * Stream frame composition — yields ComposedFrames as workers finish,
package/dist/index.js CHANGED
@@ -247,6 +247,9 @@ var ClipwiseRecorder = class {
247
247
  frameChannel = null;
248
248
  channelIndex = 0;
249
249
  // sequential index for channel-pushed frames
250
+ /** low-memory 채널 모드 — 버퍼는 스트림으로만 내보내고 내부에는 메타데이터만
251
+ * 보관한다. done의 frames는 빈 screenshot + sourceIndex(원본 참조)를 갖는다. */
252
+ channelLowMemory = false;
250
253
  /**
251
254
  * Launch the browser and create a page with the scenario viewport.
252
255
  */
@@ -292,6 +295,7 @@ var ClipwiseRecorder = class {
292
295
  this.dedupStats = { received: 0, stored: 0, skipped: 0 };
293
296
  this.frameChannel = null;
294
297
  this.channelIndex = 0;
298
+ this.channelLowMemory = false;
295
299
  }
296
300
  /**
297
301
  * Start CDP screencast for continuous frame capture.
@@ -325,6 +329,9 @@ var ClipwiseRecorder = class {
325
329
  this.channelIndex++
326
330
  );
327
331
  this.frameChannel.push(frame);
332
+ if (this.channelLowMemory) {
333
+ rawFrame.buffer = Buffer.alloc(0);
334
+ }
328
335
  }
329
336
  }
330
337
  await this.cdpClient.send("Page.screencastFrameAck", {
@@ -458,12 +465,13 @@ var ClipwiseRecorder = class {
458
465
  * Use this with CanvasRenderer.composeStreamOnline() to overlap recording
459
466
  * time with composition time — total wall-clock ≈ max(recordingMs, composeMs).
460
467
  */
461
- recordToChannel(scenario) {
468
+ recordToChannel(scenario, options) {
462
469
  const channel = new FrameChannel();
463
470
  const done = (async () => {
464
471
  try {
465
472
  await this.init(scenario);
466
473
  this.frameChannel = channel;
474
+ this.channelLowMemory = options?.lowMemory ?? false;
467
475
  const startTime = Date.now();
468
476
  if (scenario.steps.length > 0) {
469
477
  const s0 = scenario.steps[0];
@@ -1020,7 +1028,9 @@ var ClipwiseRecorder = class {
1020
1028
  frames.length,
1021
1029
  Math.round(recordingDurationMs / 1e3 * this.targetFps)
1022
1030
  );
1023
- if (targetFrameCount <= frames.length) return frames;
1031
+ if (targetFrameCount <= frames.length) {
1032
+ return frames.map((f, idx) => ({ ...f, sourceIndex: idx }));
1033
+ }
1024
1034
  const startTime = frames[0].timestamp;
1025
1035
  const endTime = frames[frames.length - 1].timestamp;
1026
1036
  const duration = Math.max(1, endTime - startTime);
@@ -1046,6 +1056,7 @@ var ClipwiseRecorder = class {
1046
1056
  resampled.push({
1047
1057
  index: i,
1048
1058
  screenshot: frames[nearestIdx].screenshot,
1059
+ sourceIndex: nearestIdx,
1049
1060
  timestamp: targetTimestamp,
1050
1061
  cursorPosition: cursorPos,
1051
1062
  clickPosition: clickEvent?.position ?? null,
@@ -2300,7 +2311,7 @@ function getWorkerUrl() {
2300
2311
  cachedWorkerUrl = candidates[1];
2301
2312
  return cachedWorkerUrl;
2302
2313
  }
2303
- var CanvasRenderer = class {
2314
+ var CanvasRenderer = class _CanvasRenderer {
2304
2315
  constructor(effects, output, steps) {
2305
2316
  this.effects = effects;
2306
2317
  this.output = output;
@@ -2695,25 +2706,28 @@ var CanvasRenderer = class {
2695
2706
  * Uses a notify-on-progress pattern (same as streamWithWorkers) extended
2696
2707
  * with an intake coroutine that feeds the growing frames[] buffer.
2697
2708
  */
2709
+ static EMPTY_SCREENSHOT = Buffer.alloc(0);
2698
2710
  async *streamOnlineWithWorkers(source, workerCount) {
2699
2711
  const transitionFrames = this.effects.zoom.enabled ? Math.round(this.output.fps * (this.effects.zoom.duration / 1e3)) : 0;
2700
2712
  const trailLength = this.effects.cursor.trailLength;
2701
2713
  const frames = [];
2702
2714
  let sourceComplete = false;
2703
2715
  let workerError = null;
2704
- let notify = null;
2716
+ let waiters = [];
2705
2717
  const trigger = () => {
2706
- notify?.();
2707
- notify = null;
2718
+ const ws = waiters;
2719
+ waiters = [];
2720
+ for (const w of ws) w();
2708
2721
  };
2709
2722
  const waitForProgress = () => new Promise((r) => {
2710
- notify = r;
2723
+ waiters.push(r);
2711
2724
  });
2712
2725
  const completed = /* @__PURE__ */ new Map();
2713
2726
  const idleWorkers = [];
2714
2727
  let nextToDispatch = 0;
2715
2728
  let nextToYield = 0;
2716
- const canDispatch = (i) => i < frames.length && (sourceComplete || frames.length > i + transitionFrames);
2729
+ const MAX_BACKLOG = workerCount * 3;
2730
+ const canDispatch = (i) => i < frames.length && (sourceComplete || frames.length > i + transitionFrames) && i - nextToYield < MAX_BACKLOG;
2717
2731
  const effectiveScale = resolveZoomScale(
2718
2732
  this.effects.zoom.scale,
2719
2733
  this.effects.zoom.intensity
@@ -2753,6 +2767,7 @@ var CanvasRenderer = class {
2753
2767
  output: this.output,
2754
2768
  context: computeContext(i)
2755
2769
  });
2770
+ frames[i] = { ...frames[i], screenshot: _CanvasRenderer.EMPTY_SCREENSHOT };
2756
2771
  } else {
2757
2772
  idleWorkers.push(worker);
2758
2773
  }
@@ -2788,11 +2803,15 @@ var CanvasRenderer = class {
2788
2803
  });
2789
2804
  idleWorkers.push(worker);
2790
2805
  }
2806
+ const INTAKE_AHEAD = Math.max(MAX_BACKLOG, transitionFrames + workerCount) + 16;
2791
2807
  const intakeTask = (async () => {
2792
2808
  for await (const frame of source) {
2793
2809
  frames.push(frame);
2794
2810
  dispatchToIdle();
2795
2811
  trigger();
2812
+ while (!workerError && frames.length - nextToYield > INTAKE_AHEAD) {
2813
+ await waitForProgress();
2814
+ }
2796
2815
  }
2797
2816
  sourceComplete = true;
2798
2817
  dispatchToIdle();
@@ -2808,6 +2827,8 @@ var CanvasRenderer = class {
2808
2827
  const frame = completed.get(nextToYield);
2809
2828
  completed.delete(nextToYield);
2810
2829
  nextToYield++;
2830
+ dispatchToIdle();
2831
+ trigger();
2811
2832
  yield frame;
2812
2833
  continue;
2813
2834
  }
@@ -3669,21 +3690,34 @@ async function recordFootageTake(scenario, scene, selectors) {
3669
3690
  effects: footageEffects(scenario.effects)
3670
3691
  };
3671
3692
  const recorder = new ClipwiseRecorder();
3672
- const session = await recorder.record(takeScenario);
3693
+ const handle = recorder.recordToChannel(takeScenario, { lowMemory: true });
3694
+ const rawDir = await mkdtemp(join2(tmpdir2(), `clipwise-raw-${scene.id}-`));
3695
+ for await (const frame of handle.frameStream) {
3696
+ await writeFile2(join2(rawDir, `${frame.index}.png`), frame.screenshot);
3697
+ }
3698
+ const session = await handle.done;
3673
3699
  const renderer = new CanvasRenderer(
3674
3700
  takeScenario.effects,
3675
3701
  segmentOutput(scenario, scenario.viewport.width, scenario.viewport.height),
3676
3702
  scene.steps
3677
3703
  );
3704
+ const planStream = (async function* () {
3705
+ for (const f of session.frames) {
3706
+ yield { ...f, screenshot: await readFile3(join2(rawDir, `${f.sourceIndex ?? f.index}.png`)) };
3707
+ }
3708
+ })();
3678
3709
  const framesDir = await mkdtemp(join2(tmpdir2(), `clipwise-footage-${scene.id}-`));
3679
3710
  let count = 0;
3680
- for await (const f of renderer.composeStream(session.frames)) {
3711
+ const composed = renderer.canStreamOnline() ? renderer.composeStreamOnline(planStream) : renderer.composeStream(session.frames);
3712
+ for await (const f of composed) {
3681
3713
  const png = f.rawInfo ? await sharp10(f.buffer, {
3682
3714
  raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
3683
3715
  }).png().toBuffer() : f.buffer;
3684
3716
  await writeFile2(join2(framesDir, `${count}.png`), png);
3685
3717
  count++;
3686
3718
  }
3719
+ await rm2(rawDir, { recursive: true, force: true }).catch(() => {
3720
+ });
3687
3721
  const anchors = [];
3688
3722
  for (let k = 0; k < scene.steps.length; k++) {
3689
3723
  const idx = session.frames.findIndex((f) => (f.stepIndex ?? 0) >= k);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clipwise",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Scriptable cinematic screen recorder for product demos — YAML in, polished MP4 out",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",