From 39239f0d2f46c522a52fa5d1cf4493b15a13f30a Mon Sep 17 00:00:00 2001 From: florian <> Date: Mon, 29 Apr 2024 10:09:59 +0200 Subject: [PATCH] Initial commit --- .dockerignore | 4 + .env.example | 5 ++ .gitignore | 3 + .nvmrc | 1 + .prettierrc | 3 + Dockerfile | 9 +++ LICENSE | 21 +++++ README.md | 85 ++++++++++++++++++++ package.json | 49 ++++++++++++ src/const.ts | 4 + src/debug.ts | 4 + src/env.ts | 17 ++++ src/helpers/array.ts | 3 + src/helpers/blossom.ts | 63 +++++++++++++++ src/helpers/dvm.ts | 31 ++++++++ src/helpers/ffmpeg.ts | 112 +++++++++++++++++++++++++++ src/helpers/filesystem.ts | 13 ++++ src/index.ts | 158 ++++++++++++++++++++++++++++++++++++++ src/pool.ts | 8 ++ tsconfig.json | 12 +++ 20 files changed, 605 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 .prettierrc create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 package.json create mode 100644 src/const.ts create mode 100644 src/debug.ts create mode 100644 src/env.ts create mode 100644 src/helpers/array.ts create mode 100644 src/helpers/blossom.ts create mode 100644 src/helpers/dvm.ts create mode 100644 src/helpers/ffmpeg.ts create mode 100644 src/helpers/filesystem.ts create mode 100644 src/index.ts create mode 100644 src/pool.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..60fc638 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.env +build +node_modules +.vscode diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..95b3d2c --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ + +NOSTR_PRIVATE_KEY="" +NOSTR_RELAYS="," + +NODE_ENV=development diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a735ebe --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +build +.env diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..963354f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "printWidth": 120 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6080ae7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM node:20-alpine + +WORKDIR /app +COPY . /app/ + +RUN yarn install +RUN yarn build + +ENTRYPOINT [ "node", "build/index.js" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c30a8f4 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cee29ab --- /dev/null +++ b/README.md @@ -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" + ] + ] +} +``` + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..8919814 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/const.ts b/src/const.ts new file mode 100644 index 0000000..ec2d535 --- /dev/null +++ b/src/const.ts @@ -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; diff --git a/src/debug.ts b/src/debug.ts new file mode 100644 index 0000000..c648527 --- /dev/null +++ b/src/debug.ts @@ -0,0 +1,4 @@ +import debug from "debug"; + +export const logger = debug("dvm"); +debug.enable("dvm,dvm:*"); diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..b0f2f77 --- /dev/null +++ b/src/env.ts @@ -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 }; + + diff --git a/src/helpers/array.ts b/src/helpers/array.ts new file mode 100644 index 0000000..1eb573e --- /dev/null +++ b/src/helpers/array.ts @@ -0,0 +1,3 @@ +export function unique(arr: T[]): T[] { + return Array.from(new Set(arr)); +} diff --git a/src/helpers/blossom.ts b/src/helpers/blossom.ts new file mode 100644 index 0000000..dd9b7fd --- /dev/null +++ b/src/helpers/blossom.ts @@ -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 { + 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(`${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}`); + } + } \ No newline at end of file diff --git a/src/helpers/dvm.ts b/src/helpers/dvm.ts new file mode 100644 index 0000000..139302c --- /dev/null +++ b/src/helpers/dvm.ts @@ -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; +} diff --git a/src/helpers/ffmpeg.ts b/src/helpers/ffmpeg.ts new file mode 100644 index 0000000..b0a0c7b --- /dev/null +++ b/src/helpers/ffmpeg.ts @@ -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 { + 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 { + 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}`); + } +} diff --git a/src/helpers/filesystem.ts b/src/helpers/filesystem.ts new file mode 100644 index 0000000..9f6ba09 --- /dev/null +++ b/src/helpers/filesystem.ts @@ -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; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..69007ee --- /dev/null +++ b/src/index.ts @@ -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 { + 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 { + 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(); +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(); + +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); diff --git a/src/pool.ts b/src/pool.ts new file mode 100644 index 0000000..3991f35 --- /dev/null +++ b/src/pool.ts @@ -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(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..131a3aa --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "target": "ESNext", + "moduleResolution": "NodeNext", + "module": "NodeNext", + "outDir": "build", + "removeComments": false, + "skipLibCheck": true, + "strict": true + } +}