clipwise 0.10.0 → 0.10.1

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
@@ -3978,6 +3978,7 @@ __export(runner_exports, {
3978
3978
  });
3979
3979
  import { chromium as chromium2 } from "playwright";
3980
3980
  import { createServer } from "http";
3981
+ import { createHash } from "crypto";
3981
3982
  import { execSync } from "child_process";
3982
3983
  import { readFile as readFile4, writeFile as writeFile2, mkdtemp, rm as rm2 } from "fs/promises";
3983
3984
  import { existsSync as existsSync2 } from "fs";
@@ -4119,21 +4120,21 @@ async function recordFootageTake(scenario, scene, selectors) {
4119
4120
  const recorder = new ClipwiseRecorder();
4120
4121
  const session = await recorder.record(takeScenario);
4121
4122
  const renderer = new CanvasRenderer(takeScenario.effects, segmentOutput(scenario), scene.steps);
4122
- const composed = [];
4123
- for await (const f of renderer.composeStream(session.frames)) composed.push(f);
4124
- const frames = await Promise.all(
4125
- composed.map(
4126
- (f) => f.rawInfo ? sharp10(f.buffer, {
4127
- raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
4128
- }).png().toBuffer() : Promise.resolve(f.buffer)
4129
- )
4130
- );
4123
+ const framesDir = await mkdtemp(join2(tmpdir2(), `clipwise-footage-${scene.id}-`));
4124
+ let count = 0;
4125
+ for await (const f of renderer.composeStream(session.frames)) {
4126
+ const png = f.rawInfo ? await sharp10(f.buffer, {
4127
+ raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
4128
+ }).png().toBuffer() : f.buffer;
4129
+ await writeFile2(join2(framesDir, `${count}.png`), png);
4130
+ count++;
4131
+ }
4131
4132
  const anchors = [];
4132
4133
  for (let k = 0; k < scene.steps.length; k++) {
4133
4134
  const idx = session.frames.findIndex((f) => (f.stepIndex ?? 0) >= k);
4134
4135
  anchors.push(Math.max(0, idx) / scenario.output.fps);
4135
4136
  }
4136
- return { frames, anchors, boxes };
4137
+ return { framesDir, count, anchors, boxes };
4137
4138
  }
4138
4139
  function fitCardW(cw, ch, maxW = 940, maxH = 540) {
4139
4140
  return Math.round(Math.min(maxW, maxH * cw / ch));
@@ -4193,7 +4194,7 @@ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
4193
4194
  label: scene.label ?? "",
4194
4195
  caption: scene.caption ?? "",
4195
4196
  base: serverBase,
4196
- count: take.frames.length,
4197
+ count: take.count,
4197
4198
  fps: scenario.output.fps,
4198
4199
  start,
4199
4200
  rate: scene.rate,
@@ -4251,9 +4252,14 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
4251
4252
  const m = req.url?.match(/^\/([\w-]+)\/(\d+)\.png$/);
4252
4253
  const take = m ? takes.get(m[1]) : void 0;
4253
4254
  if (take) {
4254
- const idx = Math.min(take.frames.length - 1, parseInt(m[2], 10));
4255
- res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" });
4256
- res.end(take.frames[idx]);
4255
+ const idx = Math.min(take.count - 1, parseInt(m[2], 10));
4256
+ readFile4(join2(take.framesDir, `${idx}.png`)).then((png) => {
4257
+ res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" });
4258
+ res.end(png);
4259
+ }).catch(() => {
4260
+ res.writeHead(404);
4261
+ res.end();
4262
+ });
4257
4263
  } else {
4258
4264
  res.writeHead(404);
4259
4265
  res.end();
@@ -4311,24 +4317,37 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
4311
4317
  const outPath = join2(tmp, "timeline.mp4");
4312
4318
  let audioInput = "";
4313
4319
  let audioMap = "";
4320
+ const totalSec = totalMs / 1e3;
4314
4321
  if (scenario.audio) {
4315
4322
  const a = scenario.audio;
4316
- const audioPath = isAbsolute2(a.file) ? a.file : resolve2(scenarioDir, a.file);
4317
- const totalSec = totalMs / 1e3;
4323
+ let audioPath;
4324
+ if (/^https?:\/\//.test(a.file)) {
4325
+ const hash = createHash("sha256").update(a.file).digest("hex").slice(0, 16);
4326
+ audioPath = join2(tmpdir2(), `clipwise-audio-${hash}${a.file.match(/\.\w{2,4}$/)?.[0] ?? ".mp3"}`);
4327
+ if (!existsSync2(audioPath)) {
4328
+ execSync(`curl -sL -o "${audioPath}" "${a.file}"`, { stdio: ["ignore", "ignore", "pipe"] });
4329
+ }
4330
+ } else {
4331
+ audioPath = isAbsolute2(a.file) ? a.file : resolve2(scenarioDir, a.file);
4332
+ }
4318
4333
  const af = [];
4319
4334
  if (a.volume !== 1) af.push(`volume=${a.volume}`);
4320
4335
  if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
4321
4336
  if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
4322
- audioInput = `-i "${audioPath}" `;
4323
- audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k -shortest `;
4337
+ audioInput = `-stream_loop -1 -i "${audioPath}" `;
4338
+ audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k `;
4324
4339
  }
4325
4340
  execSync(
4326
- `ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]" -map "[v]" ${audioMap}-c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
4341
+ `ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]" -map "[v]" ${audioMap}-t ${totalSec.toFixed(3)} -c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
4327
4342
  { stdio: ["ignore", "ignore", "pipe"] }
4328
4343
  );
4329
4344
  const buffer = await readFile4(outPath);
4330
4345
  await rm2(tmp, { recursive: true, force: true }).catch(() => {
4331
4346
  });
4347
+ for (const take of takes.values()) {
4348
+ await rm2(take.framesDir, { recursive: true, force: true }).catch(() => {
4349
+ });
4350
+ }
4332
4351
  return buffer;
4333
4352
  }
4334
4353
  var init_runner = __esm({
@@ -4566,7 +4585,7 @@ import { homedir } from "os";
4566
4585
  var program = new Command();
4567
4586
  program.name("clipwise").description(
4568
4587
  "Playwright-based cinematic screen recorder for product demos"
4569
- ).version("0.10.0");
4588
+ ).version("0.10.1");
4570
4589
  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(
4571
4590
  "-f, --format <format>",
4572
4591
  "Output format (gif|mp4|png-sequence)"
package/dist/index.js CHANGED
@@ -3524,6 +3524,7 @@ var StreamingSession = class extends EventEmitter {
3524
3524
  // src/scenes/runner.ts
3525
3525
  import { chromium as chromium2 } from "playwright";
3526
3526
  import { createServer } from "http";
3527
+ import { createHash } from "crypto";
3527
3528
  import { execSync } from "child_process";
3528
3529
  import { readFile as readFile3, writeFile as writeFile2, mkdtemp, rm as rm2 } from "fs/promises";
3529
3530
  import { existsSync as existsSync2 } from "fs";
@@ -3665,21 +3666,21 @@ async function recordFootageTake(scenario, scene, selectors) {
3665
3666
  const recorder = new ClipwiseRecorder();
3666
3667
  const session = await recorder.record(takeScenario);
3667
3668
  const renderer = new CanvasRenderer(takeScenario.effects, segmentOutput(scenario), scene.steps);
3668
- const composed = [];
3669
- for await (const f of renderer.composeStream(session.frames)) composed.push(f);
3670
- const frames = await Promise.all(
3671
- composed.map(
3672
- (f) => f.rawInfo ? sharp10(f.buffer, {
3673
- raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
3674
- }).png().toBuffer() : Promise.resolve(f.buffer)
3675
- )
3676
- );
3669
+ const framesDir = await mkdtemp(join2(tmpdir2(), `clipwise-footage-${scene.id}-`));
3670
+ let count = 0;
3671
+ for await (const f of renderer.composeStream(session.frames)) {
3672
+ const png = f.rawInfo ? await sharp10(f.buffer, {
3673
+ raw: { width: f.rawInfo.width, height: f.rawInfo.height, channels: f.rawInfo.channels }
3674
+ }).png().toBuffer() : f.buffer;
3675
+ await writeFile2(join2(framesDir, `${count}.png`), png);
3676
+ count++;
3677
+ }
3677
3678
  const anchors = [];
3678
3679
  for (let k = 0; k < scene.steps.length; k++) {
3679
3680
  const idx = session.frames.findIndex((f) => (f.stepIndex ?? 0) >= k);
3680
3681
  anchors.push(Math.max(0, idx) / scenario.output.fps);
3681
3682
  }
3682
- return { frames, anchors, boxes };
3683
+ return { framesDir, count, anchors, boxes };
3683
3684
  }
3684
3685
  function fitCardW(cw, ch, maxW = 940, maxH = 540) {
3685
3686
  return Math.round(Math.min(maxW, maxH * cw / ch));
@@ -3739,7 +3740,7 @@ function vignetteProps(scene, take, serverBase, scenario, brand, durMs) {
3739
3740
  label: scene.label ?? "",
3740
3741
  caption: scene.caption ?? "",
3741
3742
  base: serverBase,
3742
- count: take.frames.length,
3743
+ count: take.count,
3743
3744
  fps: scenario.output.fps,
3744
3745
  start,
3745
3746
  rate: scene.rate,
@@ -3797,9 +3798,14 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3797
3798
  const m = req.url?.match(/^\/([\w-]+)\/(\d+)\.png$/);
3798
3799
  const take = m ? takes.get(m[1]) : void 0;
3799
3800
  if (take) {
3800
- const idx = Math.min(take.frames.length - 1, parseInt(m[2], 10));
3801
- res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" });
3802
- res.end(take.frames[idx]);
3801
+ const idx = Math.min(take.count - 1, parseInt(m[2], 10));
3802
+ readFile3(join2(take.framesDir, `${idx}.png`)).then((png) => {
3803
+ res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" });
3804
+ res.end(png);
3805
+ }).catch(() => {
3806
+ res.writeHead(404);
3807
+ res.end();
3808
+ });
3803
3809
  } else {
3804
3810
  res.writeHead(404);
3805
3811
  res.end();
@@ -3857,24 +3863,37 @@ async function renderScenesTimeline(scenario, scenarioDir, onProgress) {
3857
3863
  const outPath = join2(tmp, "timeline.mp4");
3858
3864
  let audioInput = "";
3859
3865
  let audioMap = "";
3866
+ const totalSec = totalMs / 1e3;
3860
3867
  if (scenario.audio) {
3861
3868
  const a = scenario.audio;
3862
- const audioPath = isAbsolute(a.file) ? a.file : resolve(scenarioDir, a.file);
3863
- const totalSec = totalMs / 1e3;
3869
+ let audioPath;
3870
+ if (/^https?:\/\//.test(a.file)) {
3871
+ const hash = createHash("sha256").update(a.file).digest("hex").slice(0, 16);
3872
+ audioPath = join2(tmpdir2(), `clipwise-audio-${hash}${a.file.match(/\.\w{2,4}$/)?.[0] ?? ".mp3"}`);
3873
+ if (!existsSync2(audioPath)) {
3874
+ execSync(`curl -sL -o "${audioPath}" "${a.file}"`, { stdio: ["ignore", "ignore", "pipe"] });
3875
+ }
3876
+ } else {
3877
+ audioPath = isAbsolute(a.file) ? a.file : resolve(scenarioDir, a.file);
3878
+ }
3864
3879
  const af = [];
3865
3880
  if (a.volume !== 1) af.push(`volume=${a.volume}`);
3866
3881
  if (a.fadeIn > 0) af.push(`afade=t=in:d=${a.fadeIn / 1e3}`);
3867
3882
  if (a.fadeOut > 0) af.push(`afade=t=out:st=${Math.max(0, totalSec - a.fadeOut / 1e3)}:d=${a.fadeOut / 1e3}`);
3868
- audioInput = `-i "${audioPath}" `;
3869
- audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k -shortest `;
3883
+ audioInput = `-stream_loop -1 -i "${audioPath}" `;
3884
+ audioMap = `-map ${segments.length}:a ${af.length ? `-af "${af.join(",")}" ` : ""}-c:a aac -b:a 192k `;
3870
3885
  }
3871
3886
  execSync(
3872
- `ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]" -map "[v]" ${audioMap}-c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
3887
+ `ffmpeg -y ${segments.map((s) => `-i "${s.path}"`).join(" ")} ${audioInput}-filter_complex "${filters};${concatInputs}concat=n=${segments.length}:v=1:a=0[v]" -map "[v]" ${audioMap}-t ${totalSec.toFixed(3)} -c:v libx264 -crf 16 -preset slow -movflags +faststart "${outPath}"`,
3873
3888
  { stdio: ["ignore", "ignore", "pipe"] }
3874
3889
  );
3875
3890
  const buffer = await readFile3(outPath);
3876
3891
  await rm2(tmp, { recursive: true, force: true }).catch(() => {
3877
3892
  });
3893
+ for (const take of takes.values()) {
3894
+ await rm2(take.framesDir, { recursive: true, force: true }).catch(() => {
3895
+ });
3896
+ }
3878
3897
  return buffer;
3879
3898
  }
3880
3899
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clipwise",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
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",
@@ -400,7 +400,10 @@ scenes:
400
400
  7. **Sensitive data**: `prepare.mask: [".email", ".amount"]` blurs elements at record time
401
401
  (follows scrolling — never ask the user to fake their data)
402
402
  8. **Music**: `audio: { file: bgm.mp3, bpm: 122, fadeOut: 1500 }` muxes BGM into the final
403
- video AND snaps every scene cut onto the beat grid (beat-synced cuts)
403
+ video AND snaps every scene cut onto the beat grid (beat-synced cuts).
404
+ `file:` also accepts a URL (downloaded+cached on the user's machine — use license-free
405
+ sources like Mixkit, e.g. `https://assets.mixkit.co/music/132/132.mp3`, ~120bpm).
406
+ Track shorter than the video loops automatically; video length is always authoritative
404
407
 
405
408
  ## Critical Rules
406
409