mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): snapshot reads https video in the Desktop app instead of failing at ffprobe (#6699)
* fix(cli): snapshot reads https video in the Desktop app instead of failing at ffprobe * fix(cli): give a remote clip download the same 30 s a stalled host had before
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { remoteVideoInput } from "./snapshot.js";
|
||||
|
||||
describe("remoteVideoInput", () => {
|
||||
const url = "https://cdn.example.com/clip.mp4";
|
||||
|
||||
it("hands FFmpeg a local copy of a remote clip", async () => {
|
||||
const download = vi.fn(
|
||||
async (_url: string, _dir: string, _timeoutMs?: number) => "/cache/clip.mp4",
|
||||
);
|
||||
|
||||
await expect(remoteVideoInput(url, download)).resolves.toBe("/cache/clip.mp4");
|
||||
expect(download).toHaveBeenCalledWith(url, expect.any(String), expect.any(Number));
|
||||
});
|
||||
|
||||
it("gives a stalled host the same 30 s FFmpeg would have", async () => {
|
||||
const download = vi.fn(
|
||||
async (_url: string, _dir: string, _timeoutMs?: number) => "/cache/clip.mp4",
|
||||
);
|
||||
|
||||
await remoteVideoInput(url, download);
|
||||
expect(download.mock.calls[0]).toEqual([url, expect.any(String), 30_000]);
|
||||
});
|
||||
|
||||
it("keeps the URL when the clip cannot be downloaded", async () => {
|
||||
const download = vi.fn(
|
||||
async (_url: string, _dir: string, _timeoutMs?: number): Promise<string> =>
|
||||
Promise.reject(new Error("not a public https URL")),
|
||||
);
|
||||
|
||||
await expect(remoteVideoInput(url, download)).resolves.toBe(url);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
buildZeroDurationDiagnostic,
|
||||
downloadToTemp,
|
||||
evaluateHfDiagnostic,
|
||||
extractMediaMetadata,
|
||||
isHdrColorSpace,
|
||||
@@ -383,6 +384,17 @@ const processStderr = (error: unknown) =>
|
||||
? error.stderr
|
||||
: "";
|
||||
|
||||
const REMOTE_VIDEO_DIR = join(tmpdir(), "hyperframes-snapshot-media");
|
||||
|
||||
/** A local copy of a remote clip, since an FFmpeg built without network protocols (the Desktop app's) reads no URL. */
|
||||
export async function remoteVideoInput(url: string, download = downloadToTemp): Promise<string> {
|
||||
try {
|
||||
return await download(url, REMOTE_VIDEO_DIR, FFMPEG_EXTRACT_TIMEOUT_MS);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/** Keyframe seek plus the decoded-time threshold that selects the frame containing `time`, or null. */
|
||||
export async function probeContainingSourceFrameSeek(
|
||||
videoPath: string,
|
||||
@@ -1065,6 +1077,12 @@ async function captureSnapshots(
|
||||
);
|
||||
}
|
||||
const alphaDecoderCache = new Map<string, Promise<boolean>>();
|
||||
const remoteCopies = new Map<string, Promise<string>>();
|
||||
const localCopy = (href: string): Promise<string> => {
|
||||
const copy = remoteCopies.get(href) ?? remoteVideoInput(href);
|
||||
remoteCopies.set(href, copy);
|
||||
return copy;
|
||||
};
|
||||
const shouldUseVp9AlphaDecoder = (filePath: string): Promise<boolean> => {
|
||||
if (!extractMediaMetadata) return Promise.resolve(false);
|
||||
const cached = alphaDecoderCache.get(filePath);
|
||||
@@ -1172,7 +1190,8 @@ async function captureSnapshots(
|
||||
ffmpegInput = candidate;
|
||||
inputIsLocal = true;
|
||||
} else if (url.protocol === "http:" || url.protocol === "https:") {
|
||||
ffmpegInput = url.href;
|
||||
ffmpegInput = await localCopy(url.href);
|
||||
inputIsLocal = ffmpegInput !== url.href;
|
||||
}
|
||||
} catch {
|
||||
/* unresolvable src (e.g. blob:, data:) — skip */
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,109 +1,3 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><style data-hyperframes-text-rendering="true">html,body,*{text-rendering:geometricPrecision}</style>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=640, height=360">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 640px;
|
||||
height: 360px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #f5fafd;
|
||||
}
|
||||
|
||||
#root {
|
||||
position: relative;
|
||||
width: 640px;
|
||||
height: 360px;
|
||||
overflow: hidden;
|
||||
background: #f5fafd;
|
||||
}
|
||||
|
||||
.scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#scene-a {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
#scene-b {
|
||||
background: #f5fafd;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: absolute;
|
||||
inset: 22px 42px 48px;
|
||||
border: 3px solid #333b44;
|
||||
border-radius: 8px;
|
||||
background: #fffdfa;
|
||||
}
|
||||
|
||||
.panel::before {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 34px;
|
||||
width: 170px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: #333b44;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.panel::after {
|
||||
position: absolute;
|
||||
top: 84px;
|
||||
left: 34px;
|
||||
width: 310px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: #9aa7b5;
|
||||
box-shadow:
|
||||
0 24px 0 #c3ccd7,
|
||||
0 48px 0 #d6dde7;
|
||||
content: "";
|
||||
}
|
||||
|
||||
#leaky-band {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
height: 31px;
|
||||
background: #daebff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" data-composition-id="main" data-root="true" data-width="640" data-height="360" data-start="0" data-duration="9">
|
||||
<section id="scene-a" class="scene clip" data-start="0" data-duration="6">
|
||||
<div class="panel"></div>
|
||||
<div id="leaky-band" class="clip" data-start="0" data-duration="9"></div>
|
||||
</section>
|
||||
<section id="scene-b" class="scene clip" data-start="6" data-duration="3">
|
||||
<div class="panel"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
<script>window.__timelines = window.__timelines || {};
|
||||
window.__timelines.main = {
|
||||
duration: function () {
|
||||
return 9;
|
||||
},
|
||||
totalDuration: function () {
|
||||
return 9;
|
||||
},
|
||||
pause: function () {},
|
||||
seek: function () {},
|
||||
};</script></body>
|
||||
</html>
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:93e349ab3cf09c15b5289d73bf5276a223e45a00463cafc634e3be1cca116377
|
||||
size 2515
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,106 +1,3 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><style data-hyperframes-text-rendering="true">html,body,*{text-rendering:geometricPrecision}</style>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: #000;
|
||||
}
|
||||
[data-composition-id="webgl-video-texture-test"] {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
#gl {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
}
|
||||
/* Texture-source video: sampled into WebGL only, parked off-canvas. */
|
||||
#vid {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
opacity: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" data-composition-id="webgl-video-texture-test" data-width="1280" data-height="720" data-start="0" data-duration="2">
|
||||
<video id="vid" class="clip" data-start="0" data-duration="2" data-track-index="0" src="clip.mp4" muted playsinline data-end="2" data-has-audio="false"></video>
|
||||
<canvas id="gl"></canvas>
|
||||
|
||||
|
||||
</div>
|
||||
<script>// WebGL2 textured fullscreen triangle sampling the source video element.
|
||||
// The handler uploads that video as a texture on every hf-seek — the
|
||||
// natural author pattern. In headless render, Chrome can't supply decoded
|
||||
// video frames to WebGL, so the core video-texture patch substitutes the
|
||||
// engine's injected, decoded render-frame image, and the post-injection
|
||||
// GPU reseek re-runs this handler. With the fix, render reproduces the
|
||||
// preview snapshot (PSNR high); without it the canvas is black (PSNR
|
||||
// collapses). NB: avoid writing literal tag syntax in comments here — the
|
||||
// compiler's media-attribute injection scans the raw HTML for it.
|
||||
(function () {
|
||||
var canvas = document.getElementById("gl");
|
||||
canvas.width = 1280;
|
||||
canvas.height = 720;
|
||||
var gl = canvas.getContext("webgl2", { preserveDrawingBuffer: true });
|
||||
if (!gl) return;
|
||||
|
||||
var VS =
|
||||
"#version 300 es\n" +
|
||||
"in vec2 p; out vec2 uv;\n" +
|
||||
"void main(){ uv = vec2(p.x*0.5+0.5, 0.5-p.y*0.5); gl_Position = vec4(p,0.0,1.0); }";
|
||||
var FS =
|
||||
"#version 300 es\n" +
|
||||
"precision highp float; in vec2 uv; uniform sampler2D tex; out vec4 c;\n" +
|
||||
"void main(){ c = texture(tex, uv); }";
|
||||
|
||||
function sh(type, src) {
|
||||
var s = gl.createShader(type);
|
||||
gl.shaderSource(s, src);
|
||||
gl.compileShader(s);
|
||||
return s;
|
||||
}
|
||||
var prog = gl.createProgram();
|
||||
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS));
|
||||
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS));
|
||||
gl.linkProgram(prog);
|
||||
gl.useProgram(prog);
|
||||
|
||||
var buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
||||
var loc = gl.getAttribLocation(prog, "p");
|
||||
gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
var tex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
var video = document.getElementById("vid");
|
||||
|
||||
function render() {
|
||||
if (video && video.readyState >= 2) {
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video);
|
||||
}
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
}
|
||||
|
||||
window.addEventListener("hf-seek", function () {
|
||||
render();
|
||||
});
|
||||
render();
|
||||
})();</script></body>
|
||||
</html>
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:253e2b94f1babe8290f5f1c20e51c0d79d8b21cf1af4fecfa6a0d22733d9298d
|
||||
size 4110
|
||||
|
||||
Reference in New Issue
Block a user