Initial commit
This commit is contained in:
commit
39239f0d2f
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
|
.vscode
|
5
.env.example
Normal file
5
.env.example
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
NOSTR_PRIVATE_KEY="<hex key>"
|
||||||
|
NOSTR_RELAYS="<relay url>,<relay url>"
|
||||||
|
|
||||||
|
NODE_ENV=development
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
build
|
||||||
|
.env
|
3
.prettierrc
Normal file
3
.prettierrc
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120
|
||||||
|
}
|
9
Dockerfile
Normal file
9
Dockerfile
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . /app/
|
||||||
|
|
||||||
|
RUN yarn install
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
ENTRYPOINT [ "node", "build/index.js" ]
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2023 hzrd149
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
85
README.md
Normal file
85
README.md
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
# Video Thumbnail Creation DVM
|
||||||
|
|
||||||
|
A [Data Vending Machine](https://www.data-vending-machines.org/) that uses [ffmpeg](https://ffmpeg.org/) to extract metadata and create thumbnails for video files. [notes](https://github.com/nostr-protocol/nostr)
|
||||||
|
|
||||||
|
|
||||||
|
## Example Input
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 5204,
|
||||||
|
"content": "",
|
||||||
|
"tags": [
|
||||||
|
[ "i", "https://cdn.satellite.earth/4050ff8c96b295ded9de688fb8a06aa7e2879413281f4dd9b0b6547b4a18819d.mp4", "url", "ws://localhost:4869" ],
|
||||||
|
[ "output", "image/jpeg" ],
|
||||||
|
[ "relays", "ws://localhost:4869" ]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Output
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "",
|
||||||
|
"kind": 6204,
|
||||||
|
"tags": [
|
||||||
|
[
|
||||||
|
"request",
|
||||||
|
"{\"id\":\"1badb978c9e6cc67e2d1bd4949a65a4da0aaabfc2d01e7e886cd83d7683dd2de\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1714336010,\"kind\":5204,\"tags\":[[\"i\",\"https://cdn.satellite.earth/4050ff8c96b295ded9de688fb8a06aa7e2879413281f4dd9b0b6547b4a18819d.mp4\",\"url\",\"ws://localhost:4869\"],[\"output\",\"image/jpeg\"],[\"relays\",\"ws://localhost:4869\"]],\"content\":\"\",\"sig\":\"8d1e56f91f721d127260f4313c1fe307e790632a0c2d855bf4c02ec9f38c4cef651b7adcd2acff4f0d8719136943f55de4314f76119dbe4d119d22a4daa6e7a9\"}"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"e",
|
||||||
|
"1badb978c9e6cc67e2d1bd4949a65a4da0aaabfc2d01e7e886cd83d7683dd2de"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"p",
|
||||||
|
"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"i",
|
||||||
|
"https://cdn.satellite.earth/4050ff8c96b295ded9de688fb8a06aa7e2879413281f4dd9b0b6547b4a18819d.mp4",
|
||||||
|
"url",
|
||||||
|
"ws://localhost:4869"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"dim",
|
||||||
|
"1280x720"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"duration",
|
||||||
|
"341"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"size",
|
||||||
|
"62340416"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"thumb",
|
||||||
|
"https://media-server.slidestr.net/9a0179ae3f604a561c571312d8ac4c41ce7ecccfea603f4fc37457849d148083"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"x",
|
||||||
|
"9a0179ae3f604a561c571312d8ac4c41ce7ecccfea603f4fc37457849d148083"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"thumb",
|
||||||
|
"https://media-server.slidestr.net/69aaa1e0051beff35791fe21190807e87f581817e1db1a79c8e0cf6420d935a6"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"x",
|
||||||
|
"69aaa1e0051beff35791fe21190807e87f581817e1db1a79c8e0cf6420d935a6"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"thumb",
|
||||||
|
"https://media-server.slidestr.net/6c31e9917b3edb95b48d56885167645f1d55ea588842bda238ca8198f3e53e67"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"x",
|
||||||
|
"6c31e9917b3edb95b48d56885167645f1d55ea588842bda238ca8198f3e53e67"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
49
package.json
Normal file
49
package.json
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "dvm-video-thumbnails",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./build/index.js",
|
||||||
|
"typings": "./build/index.d.ts",
|
||||||
|
"bin": "./build/index.js",
|
||||||
|
"license": "MIT",
|
||||||
|
"files": [
|
||||||
|
"build",
|
||||||
|
"views",
|
||||||
|
"public"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "rm -rf build && tsc",
|
||||||
|
"dev": "node development.mjs",
|
||||||
|
"start": "node build/index.js",
|
||||||
|
"format": "prettier -w . --ignore-path .gitignore",
|
||||||
|
"prepare": "tsc",
|
||||||
|
"support": "npx @getalby/pkgzap-cli"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "^1.4.0",
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"blossom-client-sdk": "^0.4.0",
|
||||||
|
"dayjs": "^1.11.10",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"get-file-object-from-local-path": "^1.0.2",
|
||||||
|
"lowdb": "^7.0.1",
|
||||||
|
"nanoid": "^5.0.7",
|
||||||
|
"nostr-tools": "^2.5.0",
|
||||||
|
"promisify": "^0.0.3",
|
||||||
|
"ws": "^8.16.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/debug": "^4.1.12",
|
||||||
|
"@types/node": "^20.12.7",
|
||||||
|
"@types/ws": "^8.5.10",
|
||||||
|
"nodemon": "^3.1.0",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"shelljs": "^0.8.5",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "lightning",
|
||||||
|
"url": "lightning:hzrd149@getalby.com"
|
||||||
|
}
|
||||||
|
}
|
4
src/const.ts
Normal file
4
src/const.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export const DVM_STATUS_KIND = 7000;
|
||||||
|
|
||||||
|
export const DVM_VIDEO_THUMB_REQUEST_KIND = 5204;
|
||||||
|
export const DVM_VIDEO_THUMB_RESULT_KIND = 6204;
|
4
src/debug.ts
Normal file
4
src/debug.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import debug from "debug";
|
||||||
|
|
||||||
|
export const logger = debug("dvm");
|
||||||
|
debug.enable("dvm,dvm:*");
|
17
src/env.ts
Normal file
17
src/env.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { hexToBytes } from "@noble/hashes/utils";
|
||||||
|
|
||||||
|
if (!process.env.NOSTR_PRIVATE_KEY) throw new Error("Missing NOSTR_PRIVATE_KEY");
|
||||||
|
const NOSTR_PRIVATE_KEY = hexToBytes(process.env.NOSTR_PRIVATE_KEY);
|
||||||
|
|
||||||
|
// lnbits
|
||||||
|
const LNBITS_URL = process.env.LNBITS_URL;
|
||||||
|
const LNBITS_ADMIN_KEY = process.env.LNBITS_ADMIN_KEY;
|
||||||
|
|
||||||
|
// nostr
|
||||||
|
const NOSTR_RELAYS = process.env.NOSTR_RELAYS?.split(",") ?? [];
|
||||||
|
if (NOSTR_RELAYS.length === 0) throw new Error("Missing NOSTR_RELAYS");
|
||||||
|
|
||||||
|
export { NOSTR_PRIVATE_KEY, LNBITS_URL, LNBITS_ADMIN_KEY, NOSTR_RELAYS };
|
||||||
|
|
||||||
|
|
3
src/helpers/array.ts
Normal file
3
src/helpers/array.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function unique<T>(arr: T[]): T[] {
|
||||||
|
return Array.from(new Set(arr));
|
||||||
|
}
|
63
src/helpers/blossom.ts
Normal file
63
src/helpers/blossom.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { Signer } from "blossom-client-sdk";
|
||||||
|
import { EventTemplate, finalizeEvent } from "nostr-tools";
|
||||||
|
import { NOSTR_PRIVATE_KEY } from "../env.js";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { getFileSizeSync } from "./filesystem.js";
|
||||||
|
import { createReadStream } from "fs";
|
||||||
|
import axios from "axios";
|
||||||
|
import debug from "debug";
|
||||||
|
|
||||||
|
const logger = debug("dvm:blossom");
|
||||||
|
|
||||||
|
type BlobDescriptor = {
|
||||||
|
created: number;
|
||||||
|
type?: string;
|
||||||
|
sha256: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const signer: Signer = async (event: EventTemplate) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const verifiedEvent = finalizeEvent(event, NOSTR_PRIVATE_KEY);
|
||||||
|
resolve(verifiedEvent);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function uploadFile(filePath: string, server: string): Promise<BlobDescriptor> {
|
||||||
|
try {
|
||||||
|
const oneHour = () => dayjs().unix() + 60 * 60;
|
||||||
|
const authEvent = await signer({
|
||||||
|
created_at: dayjs().unix(),
|
||||||
|
kind: 24242,
|
||||||
|
content: "Upload thumbail",
|
||||||
|
tags: [
|
||||||
|
["t", "upload"],
|
||||||
|
// ["name", ], unknown
|
||||||
|
["size", String(getFileSizeSync(filePath))],
|
||||||
|
["expiration", String(oneHour)],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a read stream for the thumbnail file
|
||||||
|
const thumbnailStream = createReadStream(filePath);
|
||||||
|
|
||||||
|
// Upload thumbnail stream using axios
|
||||||
|
const blob = await axios.put<BlobDescriptor>(`${server}/upload`, thumbnailStream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "image/jpeg", // Adjust content type as needed <--- TODO adjust for png
|
||||||
|
authorization: "Nostr " + btoa(JSON.stringify(authEvent)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger(`File ${filePath} uploaded successfully.`);
|
||||||
|
return blob.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to upload thumbnail ${filePath}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
31
src/helpers/dvm.ts
Normal file
31
src/helpers/dvm.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { Event } from "nostr-tools";
|
||||||
|
|
||||||
|
export function getInputTag(e: Event) {
|
||||||
|
const tag = e.tags.find((t) => t[0] === "i");
|
||||||
|
if (!tag) throw new Error("Missing tag");
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInput(e: Event) {
|
||||||
|
const tag = getInputTag(e);
|
||||||
|
const [_, value, type, relay, marker] = tag;
|
||||||
|
if (!value) throw new Error("Missing input value");
|
||||||
|
if (!type) throw new Error("Missing input type");
|
||||||
|
return { value, type, relay, marker };
|
||||||
|
}
|
||||||
|
export function getRelays(event: Event) {
|
||||||
|
return event.tags.find((t) => t[0] === "relays")?.slice(1) ?? [];
|
||||||
|
}
|
||||||
|
export function getOutputType(event: Event): string | undefined {
|
||||||
|
return event.tags.find((t) => t[0] === "output")?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInputParams(e: Event, k: string) {
|
||||||
|
return e.tags.filter((t) => t[0] === "param" && t[1] === k).map((t) => t[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInputParam(e: Event, k: string) {
|
||||||
|
const value = getInputParams(e, k)[0];
|
||||||
|
if (value === undefined) throw new Error(`Missing ${k} param`);
|
||||||
|
return value;
|
||||||
|
}
|
112
src/helpers/ffmpeg.ts
Normal file
112
src/helpers/ffmpeg.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { exec } from "child_process";
|
||||||
|
import { mkdirSync } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
type MetaData = {
|
||||||
|
streams: {
|
||||||
|
index: number;
|
||||||
|
codec_name: string;
|
||||||
|
codec_long_name: string;
|
||||||
|
profile?: string;
|
||||||
|
codec_type: string;
|
||||||
|
codec_tag_string: string;
|
||||||
|
codec_tag: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
coded_width?: number;
|
||||||
|
coded_height?: number;
|
||||||
|
closed_captions: number;
|
||||||
|
film_grain: number;
|
||||||
|
has_b_frames: number;
|
||||||
|
sample_aspect_ratio?: string;
|
||||||
|
display_aspect_ratio?: string;
|
||||||
|
pix_fmt?: string;
|
||||||
|
is_avc?: string;
|
||||||
|
duration: string;
|
||||||
|
bit_rate: string;
|
||||||
|
bits_per_raw_sample: string;
|
||||||
|
}[];
|
||||||
|
format: {
|
||||||
|
filename: string;
|
||||||
|
nb_streams: number;
|
||||||
|
nb_programs: number;
|
||||||
|
format_name: string;
|
||||||
|
format_long_name: string;
|
||||||
|
start_time: string;
|
||||||
|
duration: string;
|
||||||
|
size: string;
|
||||||
|
bit_rate: string;
|
||||||
|
probe_score: number;
|
||||||
|
tags: {
|
||||||
|
major_brand: string;
|
||||||
|
minor_version: string;
|
||||||
|
compatible_brands: string;
|
||||||
|
creation_time: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
export async function extractVideoMetadata(videoUrl: string): Promise<MetaData> {
|
||||||
|
try {
|
||||||
|
// Construct the command to extract metadata using ffprobe
|
||||||
|
const command = `ffprobe -v quiet -print_format json -show_format -show_streams "${videoUrl}"`;
|
||||||
|
|
||||||
|
// Execute the command
|
||||||
|
const { stdout, stderr } = await execAsync(command);
|
||||||
|
|
||||||
|
// Check for any errors
|
||||||
|
if (stderr) {
|
||||||
|
throw new Error(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON output
|
||||||
|
const metadata = JSON.parse(stdout);
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to extract video metadata: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThumbnailContent = {
|
||||||
|
thumbnailPaths: string[];
|
||||||
|
tempDir: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function extractThumbnails(
|
||||||
|
videoUrl: string,
|
||||||
|
numFrames: number = 1,
|
||||||
|
outputFormat: 'jpg'|'png' = "jpg",
|
||||||
|
options: string = "",
|
||||||
|
): Promise<ThumbnailContent> {
|
||||||
|
try {
|
||||||
|
// Create a temporary directory with a random name
|
||||||
|
const tempDir = path.join(process.cwd(), "temp" + Math.random().toString(36).substring(2));
|
||||||
|
mkdirSync(tempDir);
|
||||||
|
|
||||||
|
// Construct the command to extract thumbnails using ffmpeg
|
||||||
|
const filenameTemplate = "thumbnail%02d." + outputFormat;
|
||||||
|
const command = `ffmpeg -v error -i "${videoUrl}" -vf "thumbnail" -frames:v ${numFrames} -ss 00:00:01 -vf fps=1/4 ${options} "${path.join(tempDir, filenameTemplate)}"`;
|
||||||
|
|
||||||
|
// Execute the command
|
||||||
|
const { stdout, stderr } = await execAsync(command);
|
||||||
|
|
||||||
|
// Check for any errors
|
||||||
|
if (stderr) {
|
||||||
|
throw new Error(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate array of thumbnail file paths
|
||||||
|
const thumbnailPaths: string[] = [];
|
||||||
|
for (let i = 1; i <= numFrames; i++) {
|
||||||
|
thumbnailPaths.push(path.join(tempDir, `thumbnail${i.toString().padStart(2, "0")}.${outputFormat}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { thumbnailPaths, tempDir };
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to extract thumbnails: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
13
src/helpers/filesystem.ts
Normal file
13
src/helpers/filesystem.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { statSync } from "fs";
|
||||||
|
|
||||||
|
export function getFileSizeSync(filePath: string): number {
|
||||||
|
// Get file stats synchronously
|
||||||
|
const stats = statSync(filePath);
|
||||||
|
|
||||||
|
// Check if the path points to a file
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
throw new Error("Provided path is not a file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats.size;
|
||||||
|
}
|
158
src/index.ts
Normal file
158
src/index.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { NostrEvent, Subscription, Filter, finalizeEvent } from "nostr-tools";
|
||||||
|
import { NOSTR_PRIVATE_KEY, NOSTR_RELAYS } from "./env.js";
|
||||||
|
import { getInput, getInputTag, getOutputType, getRelays } from "./helpers/dvm.js";
|
||||||
|
import { unique } from "./helpers/array.js";
|
||||||
|
import { pool } from "./pool.js";
|
||||||
|
import { logger } from "./debug.js";
|
||||||
|
import { DVM_VIDEO_THUMB_REQUEST_KIND, DVM_VIDEO_THUMB_RESULT_KIND } from "./const.js";
|
||||||
|
import { extractThumbnails, extractVideoMetadata } from "./helpers/ffmpeg.js";
|
||||||
|
import { uploadFile } from "./helpers/blossom.js";
|
||||||
|
import { rmSync } from "fs";
|
||||||
|
|
||||||
|
type JobContext = {
|
||||||
|
request: NostrEvent;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function shouldAcceptJob(request: NostrEvent): Promise<JobContext> {
|
||||||
|
const input = getInput(request);
|
||||||
|
const output = getOutputType(request);
|
||||||
|
// const lang = getInputParam(request, "language");
|
||||||
|
|
||||||
|
// if (output !== "text/plain") throw new Error(`Unsupported output type ${output}`);
|
||||||
|
|
||||||
|
// TODO add sanity checks for URL
|
||||||
|
|
||||||
|
if (input.type === "url") {
|
||||||
|
return { url: input.value, request };
|
||||||
|
} else throw new Error(`Unknown input type ${input.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retreiveMetaData(url: string): Promise<string[][]> {
|
||||||
|
const resultTags: string[][] = [];
|
||||||
|
|
||||||
|
const metaData = await extractVideoMetadata(url);
|
||||||
|
|
||||||
|
const duration = metaData.format.duration.split(".")[0];
|
||||||
|
const size = metaData.format.size;
|
||||||
|
const videoStreamIndex = metaData.streams.findIndex((ms) => (ms.codec_type = "video"));
|
||||||
|
const width = metaData.streams[videoStreamIndex].width;
|
||||||
|
const height = metaData.streams[videoStreamIndex].height;
|
||||||
|
|
||||||
|
if (width && height) {
|
||||||
|
resultTags.push(["dim", `${width}x${height}`]);
|
||||||
|
}
|
||||||
|
if (duration) {
|
||||||
|
resultTags.push(["duration", duration]);
|
||||||
|
}
|
||||||
|
if (size) {
|
||||||
|
resultTags.push(["size", size]);
|
||||||
|
}
|
||||||
|
logger(`Video duration: ${duration}s, size: ${size}, dimensions: ${width}x${height}`);
|
||||||
|
|
||||||
|
return resultTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doWork(context: JobContext) {
|
||||||
|
logger(`Starting work for ${context.request.id}`);
|
||||||
|
const startTime = dayjs().unix();
|
||||||
|
|
||||||
|
logger(`creating thumb for URL ${context.url}`);
|
||||||
|
const server = "https://media-server.slidestr.net"; // TODO add env variable for this
|
||||||
|
|
||||||
|
const resultTags = await retreiveMetaData(context.url);
|
||||||
|
|
||||||
|
const thumbnailContent = await extractThumbnails(context.url, 3, 'jpg'); // TODO add DVM param for these
|
||||||
|
|
||||||
|
for (const tp of thumbnailContent.thumbnailPaths) {
|
||||||
|
const blob = await uploadFile(tp, server);
|
||||||
|
logger(`Uplaoaded thumbnail file: ${blob.url}`);
|
||||||
|
resultTags.push(["thumb", blob.url]);
|
||||||
|
resultTags.push(["x", blob.sha256]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: DVM_VIDEO_THUMB_RESULT_KIND,
|
||||||
|
tags: [
|
||||||
|
["request", JSON.stringify(context.request)],
|
||||||
|
["e", context.request.id],
|
||||||
|
["p", context.request.pubkey],
|
||||||
|
getInputTag(context.request),
|
||||||
|
...resultTags,
|
||||||
|
],
|
||||||
|
content: "", // JSON.stringify(metaData),
|
||||||
|
created_at: dayjs().unix(),
|
||||||
|
},
|
||||||
|
NOSTR_PRIVATE_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
rmSync(thumbnailContent.tempDir, { recursive: true }); // TODO also remove this when an error occurs
|
||||||
|
|
||||||
|
const endTime = dayjs().unix();
|
||||||
|
|
||||||
|
// TODO add DVM error events for exeptions
|
||||||
|
|
||||||
|
logger(`${`Finished work for ${context.request.id} in ` + (endTime - startTime)} seconds`);
|
||||||
|
await Promise.all(
|
||||||
|
pool.publish(unique([...getRelays(context.request), ...NOSTR_RELAYS]), result).map((p) => p.catch((e) => {})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
async function handleEvent(event: NostrEvent) {
|
||||||
|
if (event.kind === DVM_VIDEO_THUMB_REQUEST_KIND && !seen.has(event.id)) {
|
||||||
|
try {
|
||||||
|
seen.add(event.id);
|
||||||
|
const context = await shouldAcceptJob(event);
|
||||||
|
try {
|
||||||
|
await doWork(context);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
logger(`Failed to process request ${event.id} because`, e.message);
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
logger(`Skipped request ${event.id} because`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptions = new Map<string, Subscription>();
|
||||||
|
|
||||||
|
const filters: Filter[] = [{ kinds: [DVM_VIDEO_THUMB_REQUEST_KIND], since: dayjs().unix() - 99000 }];
|
||||||
|
async function ensureSubscriptions() {
|
||||||
|
for (const url of NOSTR_RELAYS) {
|
||||||
|
const existing = subscriptions.get(url);
|
||||||
|
|
||||||
|
if (!existing || existing.closed) {
|
||||||
|
subscriptions.delete(url);
|
||||||
|
const relay = await pool.ensureRelay(url);
|
||||||
|
const sub = relay.subscribe(filters, {
|
||||||
|
onevent: handleEvent,
|
||||||
|
onclose: () => {
|
||||||
|
logger("Subscription to", url, "closed");
|
||||||
|
if (subscriptions.get(url) === sub) subscriptions.delete(url);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger("Subscribed to", url);
|
||||||
|
subscriptions.set(url, sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureSubscriptions();
|
||||||
|
setInterval(ensureSubscriptions, 30_000);
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
process.exit();
|
||||||
|
}
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
process.once("SIGUSR2", shutdown);
|
8
src/pool.ts
Normal file
8
src/pool.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import WebSocket from "ws";
|
||||||
|
import { SimplePool, useWebSocketImplementation } from "nostr-tools";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
global.WebSocket = WebSocket;
|
||||||
|
useWebSocketImplementation(WebSocket);
|
||||||
|
|
||||||
|
export const pool = new SimplePool();
|
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ESNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"outDir": "build",
|
||||||
|
"removeComments": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user