@hyperframes/engine 0.8.224 → 0.8.225

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.
@@ -5,7 +5,7 @@ import { renameWithRetry } from "@hyperframes/core/rename-retry";
5
5
  import { removeScratch } from "@hyperframes/core/remove-scratch";
6
6
  import { join, dirname, isAbsolute, relative } from "path";
7
7
  import { parseHTML } from "linkedom";
8
- import { extractAudioMetadata, ffmpegSeekSeconds } from "../utils/ffprobe.js";
8
+ import { extractAudioMetadata, ffmpegSeekSeconds, formatFfmpegSeconds, } from "../utils/ffprobe.js";
9
9
  import { isNotMediaPayload } from "../utils/notMediaPayload.js";
10
10
  import { urlInputArgs } from "../utils/urlInput.js";
11
11
  import { clampAudioGain, clipGain } from "@hyperframes/core/audio-gain";
@@ -550,7 +550,7 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
550
550
  "-stream_loop",
551
551
  "-1",
552
552
  "-t",
553
- String(sourceDuration),
553
+ formatFfmpegSeconds(sourceDuration),
554
554
  "-i",
555
555
  cyclePath,
556
556
  "-vn",
@@ -560,7 +560,7 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
560
560
  "48000",
561
561
  ...outputArgs,
562
562
  "-t",
563
- String(duration),
563
+ formatFfmpegSeconds(duration),
564
564
  "-y",
565
565
  outputPath,
566
566
  ], { signal, timeout: ffmpegProcessTimeout });
@@ -598,14 +598,19 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
598
598
  ...(readsUrl ? FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE : []),
599
599
  ...urlInputArgs(readFrom),
600
600
  ...(inputSeek
601
- ? ["-ss", String(ffmpegStart - warmUp), "-t", String(sourceDuration + warmUp)]
601
+ ? [
602
+ "-ss",
603
+ formatFfmpegSeconds(ffmpegStart - warmUp),
604
+ "-t",
605
+ formatFfmpegSeconds(sourceDuration + warmUp),
606
+ ]
602
607
  : []),
603
608
  "-i",
604
609
  readFrom,
605
610
  ];
606
611
  args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", ...outputArgs);
607
612
  if (normalizedPlaybackRate !== 1 || options?.padToDuration)
608
- args.push("-t", String(duration));
613
+ args.push("-t", formatFfmpegSeconds(duration));
609
614
  args.push("-y", outputPath);
610
615
  const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
611
616
  if (signal?.aborted) {
@@ -660,7 +665,7 @@ async function generateSilence(outputPath, duration, signal, config) {
660
665
  "-i",
661
666
  "anullsrc=r=48000:cl=stereo",
662
667
  "-t",
663
- String(duration),
668
+ formatFfmpegSeconds(duration),
664
669
  "-acodec",
665
670
  "pcm_s16le",
666
671
  "-y",
@@ -761,7 +766,7 @@ async function mixAudioTracksFlat(tracks, outputPath, totalDuration, signal, con
761
766
  "-ar",
762
767
  "48000",
763
768
  "-t",
764
- String(totalDuration),
769
+ formatFfmpegSeconds(totalDuration),
765
770
  "-y",
766
771
  outputPath,
767
772
  ];
@@ -923,7 +928,7 @@ async function mixGroupMembersFlat(memberTracks, outputPath, totalDuration, sign
923
928
  "-ar",
924
929
  "48000",
925
930
  "-t",
926
- String(totalDuration),
931
+ formatFfmpegSeconds(totalDuration),
927
932
  "-y",
928
933
  outputPath,
929
934
  ];
@@ -313,6 +313,7 @@ export interface TimelineExtractionWindow {
313
313
  }
314
314
  type TimelineWindowVideo = Pick<VideoElement, "start" | "end" | "mediaStart"> & Partial<Pick<VideoElement, "playbackRate">> & Partial<Pick<VideoElement, "loop">>;
315
315
  export declare function isVideoPastTimelineEnd(video: Pick<VideoElement, "start">, timelineEnd: number): boolean;
316
+ export declare function alignClipStartToFirstOutputFrame(video: VideoElement, fps: number): void;
316
317
  /** Move a clip to its extraction window; a rate lane is shifted so it keeps integrating from the new origin. */
317
318
  export declare function rebaseVideoToWindow(video: VideoElement, window: TimelineExtractionWindow): void;
318
319
  /**
@@ -18,7 +18,7 @@ import { alphaDecoderFor } from "@hyperframes/parsers/media-alpha";
18
18
  import { MEDIA_RENDER_ID_ATTR, isAudibleVideoElement, fpsToFfmpegArg, fpsToNumber, MEDIA_DURATION_CLAMP_EPSILON_SECONDS, normalizeRateSpec, readElementRateSpec, shiftRateLane, sourceTimeAt, timeAtSourceTime, exportClipWindow, hasClipStarted, isInClipWindow, mediaElementTime, parseStrictFiniteTimingNumber, readMediaStart, toFps, firstFrameColourArgs, hdrToSdrToneMapFilter, parseFirstFrameColour, } from "@hyperframes/core";
19
19
  import { resolveReferencedStart } from "./referenceResolver.js";
20
20
  import { isKnownInactiveTimelineWindow } from "./mediaTimelineWindow.js";
21
- import { extractFinalVideoFrameTimestamp, extractMediaMetadata, ffmpegSeekSeconds, keyframeGapSeconds, mediaFileIdentity, readMetadataCache, readStoredFinalFrame, resolvePlayableVideoDuration, videoStartOnMediaTimeline, writeMetadataCache, } from "../utils/ffprobe.js";
21
+ import { extractFinalVideoFrameTimestamp, extractMediaMetadata, ffmpegSeekSeconds, formatFfmpegSeconds, keyframeGapSeconds, mediaFileIdentity, readMetadataCache, readStoredFinalFrame, resolvePlayableVideoDuration, videoStartOnMediaTimeline, writeMetadataCache, } from "../utils/ffprobe.js";
22
22
  import { inputAlphaOpaqueWarning, probeInputAlphaPlane } from "../utils/alphaPlaneProbe.js";
23
23
  import { redactUrlQueriesInError, urlInputArgs } from "../utils/urlInput.js";
24
24
  import { analyzeCompositionHdr, isHdrColorSpace as isHdrColorSpaceUtil, } from "../utils/hdr.js";
@@ -593,12 +593,12 @@ async function runSegmentedExtraction(job) {
593
593
  ...decodeArgs,
594
594
  "-noaccurate_seek",
595
595
  "-ss",
596
- String(job.ffmpegSeek(seek)),
596
+ formatFfmpegSeconds(job.ffmpegSeek(seek)),
597
597
  "-i",
598
598
  videoPath,
599
599
  // Read one frame past the segment and keep exactly its own frames.
600
600
  "-t",
601
- String((frames + 1) / fps),
601
+ formatFfmpegSeconds((frames + 1) / fps),
602
602
  "-frames:v",
603
603
  String(frames),
604
604
  "-start_number",
@@ -709,13 +709,13 @@ outputDirOverride) {
709
709
  // sample. This is intentionally reserved for the one-frame path: input
710
710
  // seeking is faster, but valid unindexed transports (notably MPEG-TS with
711
711
  // a negative timestamp base) can seek to EOF and emit zero frames.
712
- args.push("-i", videoPath, "-ss", String(Math.max(0, ffmpegSeek(startTime))), "-frames:v", "1");
712
+ args.push("-i", videoPath, "-ss", formatFfmpegSeconds(Math.max(0, ffmpegSeek(startTime))), "-frames:v", "1");
713
713
  }
714
714
  else {
715
715
  // An accurate seek drops the frame on screen at startTime; the fps sampling below needs it.
716
716
  if (sampleAtOutputFps)
717
717
  args.push("-noaccurate_seek");
718
- args.push("-ss", String(ffmpegSeek(startTime)), "-i", videoPath, "-t", String(duration));
718
+ args.push("-ss", formatFfmpegSeconds(ffmpegSeek(startTime)), "-i", videoPath, "-t", formatFfmpegSeconds(duration));
719
719
  }
720
720
  const vfFilters = [];
721
721
  if (keepsHdrForChrome) {
@@ -917,14 +917,17 @@ const FINAL_FRAME_LOGICAL_DURATION_SECONDS = 1e-6;
917
917
  export function isVideoPastTimelineEnd(video, timelineEnd) {
918
918
  return video.start >= timelineEnd;
919
919
  }
920
- function alignClipStartToFirstOutputFrame(video, fps) {
920
+ export function alignClipStartToFirstOutputFrame(video, fps) {
921
921
  if (video.loop || !Number.isFinite(video.start) || video.start < 0 || !(fps > 0))
922
922
  return;
923
923
  const position = exportClipWindow(video.start, Infinity, fps).start * fps;
924
924
  const firstFrame = Math.abs(position - Math.round(position)) < 1e-6 ? Math.round(position) : Math.ceil(position);
925
925
  const shift = firstFrame / fps - video.start;
926
- if (shift === 0)
926
+ // Float dust (a sum landing a hair off the grid) snaps the start; it never reaches mediaStart.
927
+ if (Math.abs(shift) < 1e-9) {
928
+ video.start = firstFrame / fps;
927
929
  return;
930
+ }
928
931
  const playbackRate = normalizeRateSpec(video.playbackRate);
929
932
  video.mediaStart += sourceTimeAt(playbackRate, shift);
930
933
  video.playbackRate = shiftRateLane(playbackRate, shift);
@@ -128,6 +128,7 @@ export declare function readProbeJson(filePath: string, signal?: AbortSignal): P
128
128
  /** What a site stores beside a video: its ffprobe JSON and the final-frame facts a plan would otherwise probe for. */
129
129
  export declare function buildProbeFactsText(filePath: string, signal?: AbortSignal): Promise<string>;
130
130
  export declare function videoStartOnMediaTimeline(metadata: Pick<VideoMetadata, "videoStreamLeadSeconds" | "mediaTimeOffsetSeconds">): number;
131
+ export declare function formatFfmpegSeconds(seconds: number): string;
131
132
  export declare function ffmpegSeekSeconds(metadata: Pick<VideoMetadata, "mediaTimeOffsetSeconds">, mediaTime: number): number;
132
133
  export declare function resolvePlayableVideoDuration(metadata: VideoMetadata): number;
133
134
  /**
@@ -336,6 +336,10 @@ export async function buildProbeFactsText(filePath, signal) {
336
336
  export function videoStartOnMediaTimeline(metadata) {
337
337
  return (metadata.videoStreamLeadSeconds ?? 0) + (metadata.mediaTimeOffsetSeconds ?? 0);
338
338
  }
339
+ export function formatFfmpegSeconds(seconds) {
340
+ const text = String(seconds);
341
+ return text.includes("e") ? String(+seconds.toFixed(6)) : text;
342
+ }
339
343
  export function ffmpegSeekSeconds(metadata, mediaTime) {
340
344
  return mediaTime - (metadata.mediaTimeOffsetSeconds ?? 0);
341
345
  }
@@ -860,7 +864,7 @@ async function extractFinalPacketDuration(filePath, window, signal) {
860
864
  "-select_streams",
861
865
  "v:0",
862
866
  "-read_intervals",
863
- `${intervalStart}%${streamEnd}`,
867
+ `${formatFfmpegSeconds(intervalStart)}%${formatFfmpegSeconds(streamEnd)}`,
864
868
  "-show_entries",
865
869
  "packet=pts_time,duration_time",
866
870
  "-of",
@@ -939,7 +943,8 @@ export async function extractFinalVideoFrameTimestamp(filePath, metadata, signal
939
943
  });
940
944
  return parseFinalTimestamp(stdout);
941
945
  };
942
- const timestamp = (await probe(`${intervalStart}%${streamEnd}`)) ?? (await probe( /* full scan */));
946
+ const timestamp = (await probe(`${formatFfmpegSeconds(intervalStart)}%${formatFfmpegSeconds(streamEnd)}`)) ??
947
+ (await probe( /* full scan */));
943
948
  if (timestamp === undefined) {
944
949
  throw new Error("[FFmpeg] ffprobe found no decodable final video frame");
945
950
  }
@@ -1114,7 +1119,7 @@ export async function analyzeKeyframeIntervals(filePath, metadata) {
1114
1119
  export async function keyframeGapSeconds(filePath, seekSeconds, signal) {
1115
1120
  const stdout = await runFfprobe(filePath, [
1116
1121
  ...["-select_streams", "v:0", "-show_entries", "packet=pts_time", "-of", "csv=p=0"],
1117
- ...["-read_intervals", `${seekSeconds}%+#1`],
1122
+ ...["-read_intervals", `${formatFfmpegSeconds(seekSeconds)}%+#1`],
1118
1123
  ], signal);
1119
1124
  const keyframe = parseFloat(stdout.split("\n")[0] ?? "");
1120
1125
  return Number.isFinite(keyframe) ? Math.max(0, seekSeconds - keyframe) : 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.224",
3
+ "version": "0.8.225",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -59,8 +59,8 @@
59
59
  "puppeteer": "~25.11.0",
60
60
  "puppeteer-core": "^25.11.0",
61
61
  "undici": "7.25.0",
62
- "@hyperframes/core": "^0.8.224",
63
- "@hyperframes/parsers": "^0.8.224"
62
+ "@hyperframes/core": "^0.8.225",
63
+ "@hyperframes/parsers": "^0.8.225"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^25.0.10",