video-stream-snap
Version:
Capture the first frame from video streams (HLS/RTMP/FLV)
333 lines (329 loc) • 11.2 kB
JavaScript
;
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
default: () => index_default
});
module.exports = __toCommonJS(index_exports);
// src/utils.ts
var createVideoElement = () => {
const video = document.createElement("video");
video.crossOrigin = "anonymous";
video.muted = true;
video.playsInline = true;
video.preload = "auto";
video.style.cssText = `
position: fixed;
opacity: 0;
pointer-events: none;
top: -1000px;
`;
return video;
};
var isBlackFrame = (ctx, threshold = 20) => {
const { width, height } = ctx.canvas;
try {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
let sum = 0;
for (let i = 0; i < data.length; i += 4) {
sum += data[i] + data[i + 1] + data[i + 2];
}
const avg = sum / (width * height * 3);
return avg < threshold;
} catch (e) {
throw new Error("Canvas\u88AB\u6C61\u67D3\uFF0C\u8BF7\u786E\u4FDD\u89C6\u9891\u670D\u52A1\u5668\u542F\u7528CORS");
}
};
var detectStreamType = (url) => {
const lowerUrl = url.toLowerCase();
if (lowerUrl.includes(".m3u8")) return "hls";
if (lowerUrl.includes(".flv")) return "flv";
if (lowerUrl.includes(".mpd")) return "dash";
return "normal";
};
var checkCORS = async (url) => {
try {
const res = await fetch(url, { method: "HEAD", mode: "no-cors" });
return res.ok || false;
} catch {
return false;
}
};
// src/index.ts
var import_flv = __toESM(require("flv.js"));
var import_hls = __toESM(require("hls.js"));
var dashjs = __toESM(require("dashjs"));
if (typeof window !== "undefined") {
window.flvjs = import_flv.default;
window.Hls = import_hls.default;
window.dashjs = dashjs;
}
var StreamSnap = class {
static ensureCrossOrigin(video) {
if (!video.crossOrigin) {
video.crossOrigin = "anonymous";
video.src = "";
}
}
static async capture(streamUrl, options = {}) {
const {
timeout = 8e3,
quality = 0.8,
mimeType = "image/jpeg",
skipBlackFrame = false,
videoElement: existingVideo
} = options;
const video = existingVideo || createVideoElement();
this.ensureCrossOrigin(video);
if (!existingVideo) {
document.body.appendChild(video);
}
try {
const isCORSEnabled = await checkCORS(streamUrl);
if (!isCORSEnabled) {
console.warn("CORS not enabled on server, attempting anyway...");
}
const streamType = detectStreamType(streamUrl);
switch (streamType) {
case "flv":
if (typeof window.flvjs !== "undefined") {
return this.captureWithFLVJS(video, streamUrl, { timeout, quality, mimeType, skipBlackFrame });
}
break;
case "hls":
if (typeof window.Hls !== "undefined") {
return this.captureWithHLS(video, streamUrl, { timeout, quality, mimeType, skipBlackFrame });
}
break;
case "dash":
if (typeof window.dashjs !== "undefined") {
return this.captureWithDASH(video, streamUrl, { timeout, quality, mimeType, skipBlackFrame });
}
break;
}
return this.captureNormal(video, streamUrl, { timeout, quality, mimeType, skipBlackFrame });
} finally {
if (!existingVideo && video.parentNode) {
video.parentNode.removeChild(video);
}
}
}
static async captureNormal(video, url, options) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error("StreamSnap timeout"));
}, options.timeout);
const cleanup = () => {
clearTimeout(timer);
video.removeEventListener("loadeddata", onLoadedData);
video.removeEventListener("error", onError);
video.src = "";
};
const onError = (e) => {
cleanup();
reject(new Error(`Video error: ${e.message}`));
};
const onLoadedData = () => {
try {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Could not get canvas context");
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
if (!options.skipBlackFrame && isBlackFrame(ctx)) {
throw new Error("Black frame detected");
}
resolve(canvas.toDataURL(options.mimeType, options.quality));
} catch (err) {
reject(err);
} finally {
cleanup();
}
};
video.addEventListener("error", onError);
video.addEventListener("loadeddata", onLoadedData);
video.src = url;
video.load();
});
}
static async captureWithFLVJS(video, url, options) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error("FLVJS timeout"));
}, options.timeout);
const flvPlayer = window.flvjs.createPlayer({
type: "flv",
url,
isLive: true,
cors: true
});
flvPlayer.attachMediaElement(video);
flvPlayer.load();
const cleanup = () => {
clearTimeout(timer);
video.removeEventListener("loadeddata", onLoadedData);
video.removeEventListener("error", onError);
flvPlayer.destroy();
};
const onError = (e) => {
cleanup();
reject(new Error(`FLV error: ${e.message}`));
};
const onLoadedData = () => {
try {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Could not get canvas context");
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
if (!options.skipBlackFrame && isBlackFrame(ctx)) {
throw new Error("Black frame detected");
}
resolve(canvas.toDataURL(options.mimeType, options.quality));
} catch (err) {
reject(err);
} finally {
cleanup();
}
};
video.addEventListener("error", onError);
video.addEventListener("loadeddata", onLoadedData);
flvPlayer.play();
});
}
static async captureWithHLS(video, url, options) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error("HLS timeout"));
}, options.timeout);
const hls = new window.Hls({
enableWorker: false,
xhrSetup: (xhr) => {
xhr.withCredentials = false;
}
});
hls.loadSource(url);
hls.attachMedia(video);
const cleanup = () => {
clearTimeout(timer);
video.removeEventListener("loadeddata", onLoadedData);
video.removeEventListener("error", onError);
hls.destroy();
};
const onError = (e) => {
cleanup();
reject(new Error(`HLS error: ${e.message}`));
};
const onLoadedData = () => {
try {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Could not get canvas context");
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
if (!options.skipBlackFrame && isBlackFrame(ctx)) {
throw new Error("Black frame detected");
}
resolve(canvas.toDataURL(options.mimeType, options.quality));
} catch (err) {
reject(err);
} finally {
cleanup();
}
};
video.addEventListener("error", onError);
video.addEventListener("loadeddata", onLoadedData);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(() => {
video.muted = true;
video.play();
});
});
});
}
static async captureWithDASH(video, url, options) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error("DASH timeout"));
}, options.timeout);
const player = window.dashjs.MediaPlayer().create();
player.extend(
"RequestModifier",
() => (request) => {
request.withCredentials = false;
return request;
},
true
// 第三个参数 override 设为 true
);
player.initialize(video, url, false);
const cleanup = () => {
clearTimeout(timer);
video.removeEventListener("loadeddata", onLoadedData);
video.removeEventListener("error", onError);
player.reset();
};
const onError = (e) => {
cleanup();
reject(new Error(`DASH error: ${e.message}`));
};
const onLoadedData = () => {
try {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Could not get canvas context");
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
if (!options.skipBlackFrame && isBlackFrame(ctx)) {
throw new Error("Black frame detected");
}
resolve(canvas.toDataURL(options.mimeType, options.quality));
} catch (err) {
reject(err);
} finally {
cleanup();
}
};
video.addEventListener("error", onError);
video.addEventListener("loadeddata", onLoadedData);
player.play();
});
}
};
var index_default = StreamSnap;