@higgsfield/video-editor-mcp 1.0.2 → 1.0.4

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.
@@ -36814,9 +36814,17 @@ function invert(m) {
36814
36814
  return [m[3] * id, -m[1] * id, -m[2] * id, m[0] * id, (m[2] * m[5] - m[3] * m[4]) * id, (m[1] * m[4] - m[0] * m[5]) * id];
36815
36815
  }
36816
36816
  var sceneFps = (scene) => scene?.get(SceneConfig)?.fps ?? DEFAULT_FPS;
36817
+ // Capture selection scopes composition and pixels, but root timing stays in the
36818
+ // ordinary editor's stage clock, independent of the selected scene's own fps.
36819
+ var captureSceneIds = new WeakMap();
36820
+ function stageFps(world) {
36821
+ const stage = findStage(world);
36822
+ return sceneFps(stage ? childrenOf(stage).find((e) => e.get(Node).tag === "scene") : undefined);
36823
+ }
36817
36824
  function rootScenes(world) {
36818
36825
  const stage = findStage(world);
36819
- return stage ? childrenOf(stage).filter((e) => e.get(Node).tag === "scene") : [];
36826
+ const sceneId = captureSceneIds.get(world);
36827
+ return stage ? childrenOf(stage).filter((e) => e.get(Node).tag === "scene" && (sceneId === undefined || e.get(Node).key === sceneId || e.get(Node).id === sceneId)) : [];
36820
36828
  }
36821
36829
  function span(world, entity, fps) {
36822
36830
  const synced = syncedPlacement(world, entity, fps, SYNC_CLOCK);
@@ -36855,7 +36863,7 @@ function span(world, entity, fps) {
36855
36863
  }
36856
36864
  function fpsUnder(world, parent) {
36857
36865
  for (let e = parent; e; e = parentOf(e)) if (e.get(Node).tag === "scene") return sceneFps(e);
36858
- return sceneFps(rootScenes(world)[0]);
36866
+ return stageFps(world);
36859
36867
  }
36860
36868
  var SYNC_CLOCK = { span, fpsUnder, speed: mediaSpeed };
36861
36869
  function sourceInOf(world, entity, fps) {
@@ -36869,7 +36877,7 @@ function localTime(world, entity, time) {
36869
36877
  const chain = [];
36870
36878
  for (let e = entity; e && e.get(Node).tag !== "stage"; e = parentOf(e)) chain.unshift(e);
36871
36879
  let clock = time;
36872
- let fps = sceneFps(rootScenes(world)[0]);
36880
+ let fps = stageFps(world);
36873
36881
  for (const e of chain) {
36874
36882
  const start = span(world, e, fps).start;
36875
36883
  const tag2 = e.get(Node).tag;
@@ -36885,7 +36893,8 @@ function composition(world) {
36885
36893
  const config = scene?.get(SceneConfig);
36886
36894
  const fps = sceneFps(scene);
36887
36895
  let duration = 0;
36888
- for (const s of scenes) duration = Math.max(duration, span(world, s, fps).end);
36896
+ const rootFps = stageFps(world);
36897
+ for (const s of scenes) duration = Math.max(duration, span(world, s, rootFps).end);
36889
36898
  return {
36890
36899
  scene,
36891
36900
  width: config?.width ?? DEFAULT_SCENE.width,
@@ -37040,11 +37049,14 @@ function textPivot(entity, text) {
37040
37049
  const fallback = text.x0 === 0 ? 0 : align === "center" ? 0.5 : 1;
37041
37050
  return [text.x0 + num3(resolved(entity, "anchorX"), fallback) * text.width, num3(resolved(entity, "anchorY"), 0) * text.height];
37042
37051
  }
37043
- function evaluate(world, time) {
37052
+ function evaluate(world, time, onlyScene) {
37044
37053
  const comp = composition(world);
37054
+ const sceneConfig = onlyScene?.get(SceneConfig);
37055
+ const sourceFps = stageFps(world);
37056
+ const sourceEnd = onlyScene ? span(world, onlyScene, sourceFps).end : comp.duration;
37045
37057
  const stage = findStage(world);
37046
37058
  if (!stage) return comp;
37047
- const root = { time, length: comp.duration, fps: comp.fps, matrix: [1, 0, 0, 1, 0, 0], opacity: 1, width: comp.width, height: comp.height, baseWidth: comp.width, baseHeight: comp.height, active: true };
37059
+ const root = { time, length: sourceEnd, fps: sourceFps, matrix: [1, 0, 0, 1, 0, 0], opacity: 1, width: sceneConfig?.width ?? comp.width, height: sceneConfig?.height ?? comp.height, baseWidth: sceneConfig?.width ?? comp.width, baseHeight: sceneConfig?.height ?? comp.height, active: true };
37048
37060
  const timeWalk = (entity, frame, held = false) => {
37049
37061
  const next = timeSystem(world, entity, frame, held);
37050
37062
  const crossings = entity.get(Crossings);
@@ -37054,7 +37066,10 @@ function evaluate(world, time) {
37054
37066
  return;
37055
37067
  }
37056
37068
  const crossing = new Set(crossings?.active.flatMap((c) => [c.left, c.right]));
37057
- for (const child of childrenOf(entity)) timeWalk(child, next, crossing.has(child));
37069
+ for (const child of childrenOf(entity)) {
37070
+ if (entity === stage && onlyScene && child !== onlyScene) deactivate(child);
37071
+ else timeWalk(child, next, crossing.has(child));
37072
+ }
37058
37073
  };
37059
37074
  timeWalk(stage, root);
37060
37075
  motionSystem(world);
@@ -37070,9 +37085,11 @@ function evaluate(world, time) {
37070
37085
  transformWalk(stage, root);
37071
37086
  return comp;
37072
37087
  }
37073
- function clips(world) {
37088
+ function clips(world, onlyScene) {
37074
37089
  const out = [];
37075
37090
  const comp = composition(world);
37091
+ const rootFps = stageFps(world);
37092
+ if (onlyScene) comp.duration = span(world, onlyScene, rootFps).end;
37076
37093
  const visit = (entity, origin, fps, depth3, scene) => {
37077
37094
  const node = entity.get(Node);
37078
37095
  if (node.tag === "textRange" || node.tag === "shaderPaint") return;
@@ -37086,15 +37103,15 @@ function clips(world) {
37086
37103
  visit(child, clock, isScene ? sceneFps(entity) : fps, depth3 + 1, isScene ? node.key : scene);
37087
37104
  }
37088
37105
  };
37089
- for (const scene of rootScenes(world)) visit(scene, 0, comp.fps, 0, void 0);
37106
+ for (const scene of onlyScene ? [onlyScene] : rootScenes(world)) visit(scene, 0, rootFps, 0, void 0);
37090
37107
  return out;
37091
37108
  }
37092
37109
  function hiddenInTree(entity) {
37093
37110
  for (let e = entity; e; e = parentOf(e)) if (e.get(Visibility)?.hidden === true) return true;
37094
37111
  return false;
37095
37112
  }
37096
- function audioClips(world) {
37097
- const spans = new Map(clips(world).map((c) => [c.key, c]));
37113
+ function audioClips(world, onlyScene) {
37114
+ const spans = new Map(clips(world, onlyScene).map((c) => [c.key, c]));
37098
37115
  const fps = composition(world).fps;
37099
37116
  const out = [];
37100
37117
  for (const e of world.query(Media, Node)) {
@@ -37108,7 +37125,7 @@ function audioClips(world) {
37108
37125
  src: media.src,
37109
37126
  start: clip.start,
37110
37127
  end: clip.end,
37111
- sourceIn: sourceInOf(world, e, fps),
37128
+ sourceIn: sourceInOf(world, e, onlyScene ? fpsUnder(world, e) : fps),
37112
37129
  volume: media.volume ?? 1,
37113
37130
  fadeIn: Math.max(0, media.fadeIn ?? 0),
37114
37131
  fadeOut: Math.max(0, media.fadeOut ?? 0),
@@ -38585,7 +38602,7 @@ function drawFrame(ctx, world, comp, media, opts) {
38585
38602
  ctx.clearRect(0, 0, opts.origin ? ctx.canvas.width : comp.width * s, opts.origin ? ctx.canvas.height : comp.height * s);
38586
38603
  const stage = findStage(world);
38587
38604
  if (!stage) return;
38588
- childrenOf(stage).forEach((scene) => drawEntity(ctx, scene, media, opts, true));
38605
+ childrenOf(stage).filter((scene) => !opts.scene || scene === opts.scene).forEach((scene) => drawEntity(ctx, scene, media, opts, true));
38589
38606
  ctx.setTransform(1, 0, 0, 1, 0, 0);
38590
38607
  ctx.globalAlpha = 1;
38591
38608
  }
@@ -40621,6 +40638,17 @@ function mountComponent(component, world, mode = "live", options = {}) {
40621
40638
  var HOLD_TIMEOUT_MS = 3e4;
40622
40639
  var relativeMedia = (src) => src.replace(/^\.\//, "");
40623
40640
  var fontsInUse = (world) => [...usedFonts(world), ...htmlFonts(world)];
40641
+ function captureMediaEntities(world, includes) {
40642
+ const needed = new Set(world.query(Media).filter(includes));
40643
+ const targets = new Map(syncLinks(world).filter((link) => !link.problem && link.target).map((link) => [link.entity, link.target]));
40644
+ // Only load timing dependencies of the selected scene, not every sibling scene.
40645
+ // Set iteration includes additions; cycles are bounded by the finite entity set.
40646
+ for (const entity of needed) {
40647
+ const target = targets.get(entity);
40648
+ if (target) needed.add(target);
40649
+ }
40650
+ return needed;
40651
+ }
40624
40652
  var Session = class {
40625
40653
  world = createProjectWorld();
40626
40654
  mount;
@@ -40632,6 +40660,18 @@ var Session = class {
40632
40660
  mediaUrl;
40633
40661
  options;
40634
40662
  comp;
40663
+ captureScene;
40664
+ captureIncludes(entity) {
40665
+ if (!this.captureScene) return true;
40666
+ for (let e = entity; e; e = parentOf(e)) if (e === this.captureScene) return true;
40667
+ return false;
40668
+ }
40669
+ captureFonts() {
40670
+ return fontsInUse({ query: (...traits) => this.world.query(...traits).filter((entity) => this.captureIncludes(entity)) });
40671
+ }
40672
+ evaluateCapture(time) {
40673
+ return evaluate(this.world, time, this.captureScene);
40674
+ }
40635
40675
  /** The `@inspect` consts the last load evaluated, with the values it used. */
40636
40676
  inspect = [];
40637
40677
  constructor(options) {
@@ -40662,19 +40702,24 @@ var Session = class {
40662
40702
  const component = evaluateProject(modules, { ...externals, ...this.packages(packages, externals) });
40663
40703
  const t1 = performance.now();
40664
40704
  const world = createProjectWorld();
40705
+ if (this.options.sceneId !== undefined) captureSceneIds.set(world, this.options.sceneId);
40665
40706
  const mount = mountComponent(component, world, this.options.mode === "capture" ? "capture" : "live", { resolveUrl: this.mediaUrl });
40666
40707
  const t2 = performance.now();
40667
40708
  this.mount?.dispose();
40668
40709
  this.world.destroy();
40669
40710
  this.world = world;
40670
40711
  this.mount = mount;
40712
+ if (this.options.sceneId !== undefined) {
40713
+ this.captureScene = rootScenes(world).find((scene) => scene.get(Node).key === this.options.sceneId || scene.get(Node).id === this.options.sceneId);
40714
+ if (!this.captureScene) throw new Error(`unknown scene: ${this.options.sceneId}`);
40715
+ }
40671
40716
  this.inspect = inspect.entries;
40672
40717
  const faces = await fontsLoaded;
40673
40718
  this.projectFamilies = new Set(faces.flatMap((f) => f ? [f.family.replace(/^["']|["']$/g, "").toLowerCase()] : []));
40674
40719
  this.googleFonts.retryFailed();
40675
- await this.googleFonts.load(fontsInUse(world), this.projectFamilies);
40720
+ await this.googleFonts.load(this.captureFonts(), this.projectFamilies);
40676
40721
  await this.resolveMedia(this.options.mode === "capture");
40677
- this.comp = evaluate(world, 0);
40722
+ this.comp = this.evaluateCapture(0);
40678
40723
  return {
40679
40724
  composition: { width: this.comp.width, height: this.comp.height, fps: this.comp.fps, duration: this.comp.duration, frames: this.comp.frames },
40680
40725
  clips: clips(world),
@@ -40686,28 +40731,29 @@ var Session = class {
40686
40731
  /** Opens every video and audio source to learn its duration; waits for them when `wait` (capture), else updates later. */
40687
40732
  async resolveMedia(wait2) {
40688
40733
  const durations = this.world.get(MediaInfo).durations;
40689
- const work = this.world.query(Media).filter((e) => (e.get(Node).tag === "video" || e.get(Node).tag === "audio") && e.get(Media).src).map((e) => {
40734
+ const needed = captureMediaEntities(this.world, (entity) => this.captureIncludes(entity));
40735
+ const work = [...needed].filter((e) => (e.get(Node).tag === "video" || e.get(Node).tag === "audio") && e.get(Media).src).map((e) => {
40690
40736
  const src = e.get(Media).src;
40691
40737
  const handle = e.get(Node).tag === "audio" ? this.media.audio(src) : this.media.video(src);
40692
40738
  return handle.ready.then((facts) => {
40693
40739
  durations.set(src, facts.duration);
40694
40740
  });
40695
40741
  });
40696
- for (const e of this.world.query(Captions)) {
40742
+ for (const e of this.world.query(Captions).filter((entity) => this.captureIncludes(entity))) {
40697
40743
  const src = e.get(Captions).src;
40698
40744
  if (src) work.push(this.media.transcript(src).ready.then((t) => void durations.set(src, transcriptLength(t))));
40699
40745
  }
40700
- work.push(this.resolveSyncs());
40746
+ work.push(this.resolveSyncs(needed));
40701
40747
  if (wait2) await Promise.all(work);
40702
40748
  else await Promise.race([Promise.allSettled(work), new Promise((r) => setTimeout(r, 0))]);
40703
40749
  }
40704
40750
  /** Asks the server to measure every `syncTo` pair (it caches them) so the runtime can place those elements. */
40705
- async resolveSyncs() {
40751
+ async resolveSyncs(needed) {
40706
40752
  const world = this.world;
40707
40753
  const offsets = world.get(MediaInfo).offsets;
40708
40754
  const url2 = this.options.syncUrl ?? `${new URL(this.options.mediaBase, globalThis.location?.href).origin}/__higgsedit/sync`;
40709
40755
  await Promise.all(
40710
- syncLinks(world).map(async (link) => {
40756
+ syncLinks(world).filter((link) => needed.has(link.entity)).map(async (link) => {
40711
40757
  if (link.problem || !link.src || !link.targetSrc) {
40712
40758
  console.warn(`syncTo on ${link.key}: ${link.problem ?? "nothing to align"}; it keeps its default placement`);
40713
40759
  return;
@@ -40739,22 +40785,21 @@ var Session = class {
40739
40785
  }
40740
40786
  /** Writes the tick for frame `f` (capture mode: a pure function of `f`). */
40741
40787
  tick(frame, opts = {}) {
40742
- const fps = this.comp?.fps ?? 30;
40788
+ const fps = stageFps(this.world);
40743
40789
  const time = opts.time ?? frame / fps;
40744
40790
  batch(() => this.mount?.tick({ time, frame, delta: opts.delta ?? 1 / fps, playing: opts.playing ?? true }));
40745
- this.comp = evaluate(this.world, time);
40791
+ this.comp = this.evaluateCapture(time);
40746
40792
  return this.comp;
40747
40793
  }
40748
40794
  /**
40749
40795
  * Frame barrier: holds registered since the tick, exact media for active elements, and
40750
40796
  * fonts; re-checked until no new work appears.
40751
40797
  */
40752
- async settle(frame) {
40798
+ async settle(frame, time = frame / stageFps(this.world)) {
40753
40799
  const document2 = this.mount?.document;
40754
- const fps = this.comp?.fps ?? 30;
40755
40800
  for (let round = 0; round < 50; round++) {
40756
40801
  const holds = document2 ? [...document2.holds] : [];
40757
- const work = Promise.all([...holds, mediaReady(this.world, this.media), this.googleFonts.load(fontsInUse(this.world), this.projectFamilies), globalThis.document?.fonts?.ready]);
40802
+ const work = Promise.all([...holds, mediaReady(this.world, this.media), this.googleFonts.load(this.captureFonts(), this.projectFamilies), globalThis.document?.fonts?.ready]);
40758
40803
  let timer;
40759
40804
  const timeout = new Promise((_, reject) => {
40760
40805
  timer = setTimeout(() => reject(new Error(`frame ${frame}: hold() did not settle within ${HOLD_TIMEOUT_MS / 1e3} s`)), HOLD_TIMEOUT_MS);
@@ -40764,7 +40809,7 @@ var Session = class {
40764
40809
  } finally {
40765
40810
  clearTimeout(timer);
40766
40811
  }
40767
- this.comp = evaluate(this.world, frame / fps);
40812
+ this.comp = this.evaluateCapture(time);
40768
40813
  if (!document2 || document2.holds.size === 0) {
40769
40814
  await mediaReady(this.world, this.media);
40770
40815
  return;
@@ -40779,7 +40824,7 @@ var Session = class {
40779
40824
  await this.settle(frame);
40780
40825
  drawFrame(ctx, this.world, this.comp, this.media, { mode: "capture", scale });
40781
40826
  } catch (error) {
40782
- throw withFrame(error, frame, this.comp?.fps ?? 30);
40827
+ throw withFrame(error, frame, stageFps(this.world));
40783
40828
  }
40784
40829
  }
40785
40830
  /** Live mode: draw whatever media is ready now. */
@@ -40802,8 +40847,23 @@ var session;
40802
40847
  var canvas;
40803
40848
  var depth2 = 8;
40804
40849
  var encoder;
40850
+ var outputComposition;
40851
+ var outputOrigin = 0;
40852
+ async function renderOutputFrame(ctx, frame) {
40853
+ if (!outputComposition) return session.renderFrame(ctx, frame);
40854
+ const time = outputOrigin + frame / outputComposition.fps;
40855
+ const authoredFrame = Math.round(time * stageFps(session.world));
40856
+ session.tick(authoredFrame, { time });
40857
+ await session.settle(authoredFrame, time);
40858
+ const source = session.captureScene?.get(SceneConfig) ?? session.comp;
40859
+ const sourceWidth = source.width ?? session.comp.width;
40860
+ const sourceHeight = source.height ?? session.comp.height;
40861
+ const scale = Math.min(outputComposition.width / sourceWidth, outputComposition.height / sourceHeight);
40862
+ const origin = { x: -(outputComposition.width / scale - sourceWidth) / 2, y: -(outputComposition.height / scale - sourceHeight) / 2 };
40863
+ drawFrame(ctx, session.world, session.comp, session.media, { mode: "capture", scale, origin, scene: session.captureScene });
40864
+ }
40805
40865
  function context() {
40806
- const comp = session.comp;
40866
+ const comp = outputComposition ?? session.comp;
40807
40867
  if (!canvas || canvas.width !== comp.width || canvas.height !== comp.height) {
40808
40868
  canvas = new OffscreenCanvas(comp.width, comp.height);
40809
40869
  }
@@ -40817,22 +40877,36 @@ function read(ctx) {
40817
40877
  return image.data;
40818
40878
  }
40819
40879
  var api = {
40820
- async load({ modules, fonts, packages, mediaBase, depth: d, hostFrames }) {
40880
+ async load({ modules, fonts, packages, mediaBase, depth: d, hostFrames, spec, sceneId }) {
40821
40881
  depth2 = d;
40822
40882
  session = new Session({
40823
40883
  mediaBase,
40824
40884
  mode: "capture",
40885
+ sceneId,
40825
40886
  ...hostFrames ? { makeVideo: (src) => new HostFrameVideo(src, hostFrames) } : {}
40826
40887
  });
40827
- return session.load(modules, fonts, void 0, packages);
40888
+ const info = await session.load(modules, fonts, void 0, packages);
40889
+ outputComposition = undefined;
40890
+ outputOrigin = 0;
40891
+ if (spec || sceneId !== undefined) {
40892
+ const scene = session.captureScene;
40893
+ const timing = scene ? span(session.world, scene, stageFps(session.world)) : { start: 0, end: info.composition.duration };
40894
+ const config = scene?.get(SceneConfig);
40895
+ outputOrigin = timing.start;
40896
+ const fps = spec?.video.fps ?? config?.fps ?? info.composition.fps;
40897
+ const duration = timing.end - timing.start;
40898
+ outputComposition = { width: spec?.video.width ?? config?.width ?? info.composition.width, height: spec?.video.height ?? config?.height ?? info.composition.height, fps, duration, frames: Math.max(1, Math.round(duration * fps)) };
40899
+ return { ...info, composition: outputComposition, sourceFps: config?.fps ?? info.composition.fps };
40900
+ }
40901
+ return info;
40828
40902
  },
40829
40903
  async png(frame) {
40830
40904
  const ctx = context();
40831
- await session.renderFrame(ctx, frame);
40905
+ await renderOutputFrame(ctx, frame);
40832
40906
  return toBase64(await canvas.convertToBlob({ type: "image/png" }));
40833
40907
  },
40834
40908
  async sheet(frames, { columns, width }) {
40835
- const comp = session.comp;
40909
+ const comp = outputComposition ?? session.comp;
40836
40910
  const thumbW = width;
40837
40911
  const thumbH = Math.round(width * comp.height / comp.width);
40838
40912
  const label = 28;
@@ -40843,7 +40917,7 @@ var api = {
40843
40917
  out.fillRect(0, 0, sheet.width, sheet.height);
40844
40918
  for (const [i, frame] of frames.entries()) {
40845
40919
  const ctx = context();
40846
- await session.renderFrame(ctx, frame);
40920
+ await renderOutputFrame(ctx, frame);
40847
40921
  const x = i % columns * thumbW;
40848
40922
  const y = Math.floor(i / columns) * (thumbH + label);
40849
40923
  out.drawImage(canvas, x, y, thumbW, thumbH);
@@ -40854,12 +40928,12 @@ var api = {
40854
40928
  }
40855
40929
  return toBase64(await sheet.convertToBlob({ type: "image/png" }));
40856
40930
  },
40857
- audio: () => audioClips(session.world),
40931
+ audio: () => audioClips(session.world, session.captureScene).map((clip) => ({ ...clip, start: clip.start - outputOrigin, end: clip.end - outputOrigin })),
40858
40932
  fontMisses: () => session.fontMisses(),
40859
40933
  async raw(frame, url2) {
40860
40934
  const ctx = context();
40861
40935
  const t0 = performance.now();
40862
- await session.renderFrame(ctx, frame);
40936
+ await renderOutputFrame(ctx, frame);
40863
40937
  const t1 = performance.now();
40864
40938
  const data = read(ctx);
40865
40939
  const t2 = performance.now();
@@ -40869,17 +40943,17 @@ var api = {
40869
40943
  },
40870
40944
  async digest(frame) {
40871
40945
  const ctx = context();
40872
- await session.renderFrame(ctx, frame);
40946
+ await renderOutputFrame(ctx, frame);
40873
40947
  const data = read(ctx);
40874
40948
  const hash = await crypto.subtle.digest("SHA-256", data);
40875
40949
  return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
40876
40950
  },
40877
40951
  async encodeCheck({ codec, bitrate }) {
40878
- const comp = session.comp;
40952
+ const comp = outputComposition ?? session.comp;
40879
40953
  return await encodeProblem({ depth: depth2, codec, width: comp.width, height: comp.height, fps: comp.fps, bitrate }) ?? null;
40880
40954
  },
40881
40955
  async encodeStart({ url: url2, codec, quantizer, bitrate }) {
40882
- const comp = session.comp;
40956
+ const comp = outputComposition ?? session.comp;
40883
40957
  context();
40884
40958
  encoder = await createWebCodecsEncoder(canvas, {
40885
40959
  depth: depth2,
@@ -40898,7 +40972,7 @@ var api = {
40898
40972
  async encodeFrame(frame, index) {
40899
40973
  const ctx = context();
40900
40974
  const t0 = performance.now();
40901
- await session.renderFrame(ctx, frame);
40975
+ await renderOutputFrame(ctx, frame);
40902
40976
  const t1 = performance.now();
40903
40977
  await encoder.add(index);
40904
40978
  return { renderMs: t1 - t0, encodeMs: performance.now() - t1 };
@@ -152,7 +152,7 @@ var FontLoadError = class extends Error {
152
152
  super(`${fonts}; the frame would draw in a fallback font.`);
153
153
  }
154
154
  };
155
- async function openCapture(dir, depth, allowFontFallback = false) {
155
+ async function openCapture(dir, depth, allowFontFallback = false, options = {}) {
156
156
  const decoders = /* @__PURE__ */ new Map();
157
157
  const sinks = /* @__PURE__ */ new Map();
158
158
  let sinkId = 0;
@@ -203,20 +203,30 @@ async function openCapture(dir, depth, allowFontFallback = false) {
203
203
  }
204
204
  ];
205
205
  const server = await startServer(dir, handlers);
206
- const browser = await launch();
207
- const page = await capturePage(browser, server.origin);
208
- const modules = compileProject(readSources(dir));
209
- const fonts = fontFiles(dir);
210
- const packages = (await bundlePackages(dir)).code;
211
- const info = await page.evaluate(
212
- ({ modules: modules2, fonts: fonts2, packages: packages2, origin, depth: depth2 }) => globalThis.higgsedit.load({ modules: modules2, fonts: fonts2, packages: packages2, mediaBase: `${origin}/project`, depth: depth2, ...depth2 === 10 ? { hostFrames: `${origin}/__higgsedit/media` } : {} }),
213
- { modules, fonts, packages, origin: server.origin, depth }
214
- );
206
+ let browser;
207
+ let page;
208
+ let info;
209
+ try {
210
+ browser = await launch();
211
+ page = await capturePage(browser, server.origin);
212
+ const modules = compileProject(readSources(dir));
213
+ const fonts = fontFiles(dir);
214
+ const packages = (await bundlePackages(dir)).code;
215
+ info = await page.evaluate(
216
+ ({ modules, fonts, packages, origin, depth, options }) => globalThis.higgsedit.load({ modules, fonts, packages, mediaBase: `${origin}/project`, depth, ...options, ...depth === 10 ? { hostFrames: `${origin}/__higgsedit/media` } : {} }),
217
+ { modules, fonts, packages, origin: server.origin, depth, options }
218
+ );
219
+ } catch (error) {
220
+ for (const decoder of decoders.values()) decoder.close();
221
+ try { await browser?.close(); } finally { await server.close(); }
222
+ throw error;
223
+ }
215
224
  return {
216
225
  page,
217
226
  browser,
218
227
  server,
219
228
  composition: info.composition,
229
+ sourceFps: info.sourceFps ?? info.composition.fps,
220
230
  decoders,
221
231
  sink(onBody) {
222
232
  const id = String(++sinkId);
@@ -231,8 +241,7 @@ async function openCapture(dir, depth, allowFontFallback = false) {
231
241
  },
232
242
  async close() {
233
243
  for (const d of decoders.values()) d.close();
234
- await browser.close();
235
- await server.close();
244
+ try { await browser.close(); } finally { await server.close(); }
236
245
  }
237
246
  };
238
247
  }
@@ -251,15 +260,16 @@ var api = (page) => ({
251
260
  });
252
261
 
253
262
  // src/render.ts
254
- var toFrame = (value, fps, fallback) => {
263
+ var toFrame = (value, fps, fallback, sourceFps = fps) => {
255
264
  if (value === void 0) return fallback;
256
- const seconds = parseTime(/^-?\d+(\.\d+)?$/.test(value) ? Number(value) : value, fps);
265
+ const seconds = parseTime(/^-?\d+(\.\d+)?$/.test(value) ? Number(value) : value, sourceFps);
257
266
  if (seconds === void 0) throw new Error(`cannot read time "${value}"`);
258
267
  return Math.round(seconds * fps);
259
268
  };
260
269
  var FFMPEG_CODECS = { x264: "libx264", x265: "libx265", videotoolbox: "hevc_videotoolbox" };
261
270
  function videoArgs(opts, encoder) {
262
271
  const tags = ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv"];
272
+ const quality = opts.spec?.video.bitrate !== undefined ? ["-b:v", String(opts.spec.video.bitrate)] : ["-crf", String(opts.crf ?? 18)];
263
273
  if (encoder === "videotoolbox") {
264
274
  return ["-c:v", "hevc_videotoolbox", "-profile:v", opts.depth === 10 ? "main10" : "main", "-q:v", "70", "-tag:v", "hvc1", ...tags];
265
275
  }
@@ -269,8 +279,7 @@ function videoArgs(opts, encoder) {
269
279
  "libx265",
270
280
  "-preset",
271
281
  opts.preset ?? "fast",
272
- "-crf",
273
- String(opts.crf ?? 18),
282
+ ...quality,
274
283
  "-profile:v",
275
284
  opts.depth === 10 ? "main10" : "main",
276
285
  "-tag:v",
@@ -280,7 +289,7 @@ function videoArgs(opts, encoder) {
280
289
  ...tags
281
290
  ];
282
291
  }
283
- return ["-c:v", "libx264", "-preset", opts.preset ?? "medium", "-crf", String(opts.crf ?? 18), ...tags];
292
+ return ["-c:v", "libx264", "-preset", opts.preset ?? "medium", ...quality, ...tags];
284
293
  }
285
294
  function gainFilter(clip, from) {
286
295
  const len = clip.end - clip.start;
@@ -309,7 +318,7 @@ function rateFilter(clip) {
309
318
  async function render(dir, opts) {
310
319
  const log = opts.log ?? (() => {
311
320
  });
312
- const session = await openCapture(dir, opts.depth, opts.allowFontFallback);
321
+ const session = await openCapture(dir, opts.depth, opts.allowFontFallback, { spec: opts.spec, sceneId: opts.sceneId });
313
322
  try {
314
323
  return await renderWith(session, dir, opts, log);
315
324
  } finally {
@@ -338,6 +347,10 @@ async function audioMix(session, dir, span) {
338
347
  return { audio, inputs, filters };
339
348
  }
340
349
  var AUDIO_CODEC = ["-c:a", "aac", "-b:a", "192k"];
350
+ function audioArgs(opts) {
351
+ const audio = opts.spec?.audio;
352
+ return audio ? ["-c:a", "aac", "-b:a", String(audio.bitrate ?? 192000), "-ar", String(audio.sampleRate), "-ac", String(audio.channels)] : AUDIO_CODEC;
353
+ }
341
354
  function cancelled() {
342
355
  const error = new Error("render cancelled");
343
356
  error.name = "RenderCancelledError";
@@ -352,14 +365,14 @@ function progress(opts, log, started, done, count) {
352
365
  }
353
366
  async function renderWith(session, dir, opts, log) {
354
367
  const { width, height, fps, frames: total } = session.composition;
355
- const first = toFrame(opts.from, fps, 0);
356
- const last = Math.min(total, toFrame(opts.to, fps, total));
368
+ const first = Math.max(0, toFrame(opts.from, fps, 0, session.sourceFps));
369
+ const last = Math.min(total, toFrame(opts.to, fps, total, session.sourceFps));
357
370
  if (last <= first) throw new Error(`nothing to render between frame ${first} and ${last}`);
358
371
  const span = { first, last, count: last - first, t0: first / fps, duration: (last - first) / fps };
359
372
  const encoder = await pickEncoder(session, opts, log);
360
373
  const out = resolve(opts.out);
361
374
  mkdirSync(dirname(out), { recursive: true });
362
- const mix = await audioMix(session, dir, span);
375
+ const mix = opts.spec?.audio === null ? { audio: [], inputs: [], filters: [] } : await audioMix(session, dir, span);
363
376
  const started = performance.now();
364
377
  const { codec, breakdown } = await (encoder === "webcodecs" ? encodeInPage(session, opts, out, span, mix, log, started) : encodeWithFfmpeg(session, opts, encoder, out, span, mix, log, started)).catch((error) => {
365
378
  rmSync(out, { force: true });
@@ -388,11 +401,13 @@ async function pickEncoder(session, opts, log) {
388
401
  const encoder = opts.encoder ?? (opts.depth === 10 ? "x265" : "webcodecs");
389
402
  if (encoder !== "webcodecs") return encoder;
390
403
  const { width, height, fps } = session.composition;
391
- const problem = await api(session.page).encodeCheck({ codec: opts.webcodecsCodec ?? "avc", bitrate: fallbackBitrate(width, height, fps) });
404
+ const codec = opts.spec?.video.codec ?? opts.webcodecsCodec ?? "avc";
405
+ const problem = await api(session.page).encodeCheck({ codec, bitrate: opts.spec?.video.bitrate ?? fallbackBitrate(width, height, fps) });
392
406
  if (!problem) return encoder;
393
407
  if (!opts.encoder) {
394
- log(`${problem}; encoding with x264`);
395
- return "x264";
408
+ const fallback = codec === "hevc" ? "x265" : "x264";
409
+ log(`${problem}; encoding with ${fallback}`);
410
+ return fallback;
396
411
  }
397
412
  throw new Error(`${problem}; ${opts.depth === 10 ? "render --depth 10 without --encoder webcodecs (ffmpeg x265)" : "render without --encoder webcodecs"}`);
398
413
  }
@@ -401,7 +416,7 @@ async function encodeWithFfmpeg(session, opts, codec, out, span, mix, log, start
401
416
  const pixFmt = opts.depth === 10 ? "rgbaf16le" : "rgba";
402
417
  const outFmt = opts.depth === 10 ? "yuv420p10le" : "yuv420p";
403
418
  const filters = [`[0:v]scale=out_color_matrix=bt709:out_range=limited:flags=accurate_rnd+full_chroma_int,format=${outFmt},setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv[v]`, ...mix.filters];
404
- const maps = ["-map", "[v]", ...mix.audio.length ? ["-map", "[a]", ...AUDIO_CODEC] : []];
419
+ const maps = ["-map", "[v]", ...mix.audio.length ? ["-map", "[a]", ...audioArgs(opts)] : []];
405
420
  const encoder = ffmpeg(
406
421
  [
407
422
  "-y",
@@ -476,7 +491,7 @@ async function encodeInPage(session, opts, out, span, mix, log, started) {
476
491
  const sink = session.sink(async (position, body) => {
477
492
  await file.write(body, 0, body.length, position);
478
493
  });
479
- await page.encodeStart({ url: sink, codec: opts.webcodecsCodec ?? "avc", quantizer: opts.crf ?? 18, bitrate: fallbackBitrate(width, height, fps) });
494
+ await page.encodeStart({ url: sink, codec: opts.spec?.video.codec ?? opts.webcodecsCodec ?? "avc", ...opts.spec?.video.bitrate === undefined ? { quantizer: opts.crf ?? 18 } : {}, bitrate: opts.spec?.video.bitrate ?? fallbackBitrate(width, height, fps) });
480
495
  for (let f = span.first; f < span.last; f++) {
481
496
  if (opts.signal?.aborted) {
482
497
  await page.encodeCancel();
@@ -489,13 +504,16 @@ async function encodeInPage(session, opts, out, span, mix, log, started) {
489
504
  progress(opts, log, started, f - span.first + 1, span.count);
490
505
  }
491
506
  ({ codec } = await page.encodeEnd());
507
+ } catch (error) {
508
+ await page.encodeCancel().catch(() => {});
509
+ throw error;
492
510
  } finally {
493
511
  await file.close();
494
512
  }
495
513
  const hevc = !codec.startsWith("avc1");
496
514
  const retag = `${hevc ? "hevc" : "h264"}_metadata=colour_primaries=1`;
497
515
  const tag = hevc ? ["-tag:v", "hvc1"] : [];
498
- const audio = mix.audio.length ? ["-filter_complex", mix.filters.join(";"), "-map", "[a]", ...AUDIO_CODEC] : [];
516
+ const audio = mix.audio.length ? ["-filter_complex", mix.filters.join(";"), "-map", "[a]", ...audioArgs(opts)] : [];
499
517
  const muxer = ffmpeg(["-y", "-i", video, ...mix.inputs, "-map", "0:v", "-c:v", "copy", "-bsf:v", retag, ...tag, "-color_primaries", "bt709", ...audio, "-t", span.duration.toFixed(6), "-movflags", "+faststart", out]);
500
518
  let stderr = "";
501
519
  muxer.stderr.on("data", (d) => stderr += d.toString());