commit 39239f0d2f46c522a52fa5d1cf4493b15a13f30a Author: florian <> Date: Mon Apr 29 10:09:59 2024 +0200 Initial commit 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 + } +}