diff --git a/deno.json b/deno.json index 5b320c2..f1fb7ea 100644 --- a/deno.json +++ b/deno.json @@ -1,8 +1,13 @@ { "tasks": { - "dev": "deno run --watch main.ts" + "dev": "deno run --watch main.ts", + "run": "deno run --allow-read src/main.ts", + "build": "deno compile --allow-read --allow-write --allow-run --allow-env --allow-net --output ./videnc ./src/main.ts" }, "imports": { - "@std/assert": "jsr:@std/assert@1" + "@std/assert": "jsr:@std/assert@1", + "@std/cli": "jsr:@std/cli@^1.0.21", + "@std/fs": "jsr:@std/fs@^1.0.19", + "@std/path": "jsr:@std/path@^1.1.2" } } diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..1dbf0d5 --- /dev/null +++ b/deno.lock @@ -0,0 +1,47 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@1": "1.0.14", + "jsr:@std/cli@^1.0.21": "1.0.21", + "jsr:@std/fs@^1.0.19": "1.0.19", + "jsr:@std/internal@^1.0.10": "1.0.10", + "jsr:@std/internal@^1.0.9": "1.0.10", + "jsr:@std/path@^1.1.1": "1.1.2", + "jsr:@std/path@^1.1.2": "1.1.2" + }, + "jsr": { + "@std/assert@1.0.14": { + "integrity": "68d0d4a43b365abc927f45a9b85c639ea18a9fab96ad92281e493e4ed84abaa4", + "dependencies": [ + "jsr:@std/internal@^1.0.10" + ] + }, + "@std/cli@1.0.21": { + "integrity": "cd25b050bdf6282e321854e3822bee624f07aca7636a3a76d95f77a3a919ca2a" + }, + "@std/fs@1.0.19": { + "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06", + "dependencies": [ + "jsr:@std/internal@^1.0.9", + "jsr:@std/path@^1.1.1" + ] + }, + "@std/internal@1.0.10": { + "integrity": "e3be62ce42cab0e177c27698e5d9800122f67b766a0bea6ca4867886cbde8cf7" + }, + "@std/path@1.1.2": { + "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", + "dependencies": [ + "jsr:@std/internal@^1.0.10" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@1", + "jsr:@std/cli@^1.0.21", + "jsr:@std/fs@^1.0.19", + "jsr:@std/path@^1.1.2" + ] + } +} diff --git a/src/commandBuilder.ts b/src/commandBuilder.ts new file mode 100644 index 0000000..5fc0f4d --- /dev/null +++ b/src/commandBuilder.ts @@ -0,0 +1,144 @@ +import { parsedVideoInfo } from "./types.ts"; +import { basename, dirname } from "@std/path"; + +function escapeFilename(filename: string): string { + return filename.replace(/[^a-zA-Z0-9]/g, ""); +} + +export function encodingCommandBuilder(videoInfo: parsedVideoInfo): string { + let videoName: string | null = null; + const videoPath = dirname(videoInfo.filename); + if (videoInfo.metadata) { + videoName = videoInfo.metadata.title; + } + const args = [ + "ffmpeg \\\n", + "-v", + "quiet \\\n", + "-hide_banner \\\n", + "-i", + `"${videoInfo.filename}" \\\n`, + "-map_metadata", + "-1 \\\n", + "-map", + "0:v \\\n", + "-c:v", + "libsvtav1 \\\n", + "-pix_fmt", + "yuv420p10le \\\n", + ]; + if (videoInfo.MPix < 1) { + args.push("-crf", "29 \\\n"); + } else { + args.push("-crf", "25 \\\n"); + } + args.push( + "-svtav1-params", + "tune=0:film-grain=5:scd=1:film-grain-denoise=1 \\\n", + ); + args.push("-preset", "2 \\\n"); + + if (videoInfo.isAnamorphic || videoInfo.isInterlaced) { + const filter_string: string[] = []; + if (videoInfo.isInterlaced) { + filter_string.push("bwdif=mode=0"); + } + if (videoInfo.isAnamorphic) { + filter_string.push( + "scale=iw*sar:ih:lanczos,setsar=1,scale=-2:ih:lanczos", + ); + } + args.push( + "-vf", + `${filter_string.join(",")} \\\n`, + ); + } + + args.push("-g", `${Math.round(videoInfo.fps * 5)} \\\n`); + + if (videoInfo.metadata) { + args.push("-metadata", `title="${videoInfo.metadata.title}" \\\n`); + args.push( + "-metadata", + `date_released="${videoInfo.metadata.date_released}" \\\n`, + ); + args.push("-metadata", `imdb_id="${videoInfo.metadata.imdb_id}" \\\n`); + args.push( + "-metadata", + `original_media_type="${videoInfo.metadata.original_media_type}" \\\n`, + ); + } + + for (const [index, audioStream] of videoInfo.audioStreams?.entries() ?? []) { + args.push( + "-map", + `0:a:${index}`, + ); + args.push( + `-c:a:${index}`, + "libopus", + ); + if (audioStream.isSideloaded) { + args.push( + `-channel_layout:a:${index}`, + `${audioStream.channel_layout}`, + ); + } + args.push( + `-metadata:s:a:${index}`, + `language=${audioStream.language} \\\n`, + ); + } + + for ( + const [index, subtitleStream] of videoInfo.subtitleStreams?.entries() ?? [] + ) { + args.push( + "-map", + `0:s:${index}`, + ); + args.push( + `-c:s:${index}`, + "copy", + ); + args.push( + `-metadata:s:s:${index}`, + `language=${subtitleStream.language}`, + ); + if (subtitleStream.language === "fin") { + args.push( + `-disposition:s:${index}`, + `default \\\n`, + ); + } else { + args.push( + `-disposition:s:${index}`, + `-1 \\\n`, + ); + } + } + + if (videoName) { + args.push( + `"${videoPath}/${ + escapeFilename(videoName) + }.${videoInfo.metadata?.imdb_id}.mkv"`, + ); + } else { + args.push( + `"${videoPath}/${ + basename(videoInfo.filename.replace(".mkv", "")) + }.av1.mkv"`, + ); + } + + const trimmedArray = args.map((item) => { + // Only trim if it doesn't end with newline + if (item.endsWith("\\\n")) { + return item; // Keep as is + } + return item.trim(); + }); + + return trimmedArray.join(" "); +} diff --git a/src/main.ts b/src/main.ts index 292ce5f..a355d1c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,111 @@ -export function add(a: number, b: number): number { - return a + b; +import { parseArgs } from "@std/cli"; +import { exists } from "@std/fs"; +import { resolve } from "@std/path"; +import { + OMDBMovieInfo, + OMDBSeriesInfo, + parsedVideoInfo, + VideoInfo, +} from "./types.ts"; +import { VideoParser } from "./videoParser.ts"; +import { encodingCommandBuilder } from "./commandBuilder.ts"; + +function analyzeVideo(videoFile: string): VideoInfo { + const cmd = "ffprobe"; + const args = [ + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + "-show_streams", + videoFile, + ]; + const process = new Deno.Command(cmd, { args }); + const { stdout, stderr } = process.outputSync(); + const videoInfo = JSON.parse(new TextDecoder().decode(stdout)); + return videoInfo; } -// Learn more at https://docs.deno.com/runtime/manual/examples/module_metadata#concepts +function buildVideoInfo( + videoInfo: VideoInfo, + movieInfo?: OMDBMovieInfo | OMDBSeriesInfo, +): parsedVideoInfo { + const parser = new VideoParser(videoInfo); + if (movieInfo) { + parser.metadata = { + title: movieInfo.Title, + date_released: movieInfo.Released, + imdb_id: movieInfo.imdbID, + }; + } + return parser.parse(); +} + +async function fetchMovieInfo( + imdbID: string, +): Promise { + const omdbAPIKey = Deno.env.get("OMDB_API_KEY"); + const response = await fetch( + `https://www.omdbapi.com/?i=${imdbID}&apikey=${omdbAPIKey}`, + ); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const movieInfo = await response.json(); + if (movieInfo.Response === "False") { + throw new Error(movieInfo.Error); + } + return movieInfo; +} + +async function writeShellScript(command: string) { + if (await exists("./encode.sh")) { + let counter = 1; + while (true) { + if (await exists(`./encode_${counter}.sh`)) { + counter++; + } else { + break; + } + } + Deno.writeTextFileSync(`./encode_${counter}.sh`, command); + console.log(`Created ./encode_${counter}.sh`); + return; + } + const script = `#!/bin/bash\n${command}`; + Deno.writeTextFileSync("./encode.sh", script); +} + +async function main() { + // Parse arguments + const args = parseArgs(Deno.args); + if (!args.i) { + console.log("Usage: deno run main.ts -i "); + Deno.exit(1); + } + // Check if file exists + const videoFile: string = String(args.i); + if (!await exists(videoFile)) { + console.log("File does not exist"); + Deno.exit(1); + } + // Analyze video + const videoInfo = analyzeVideo(videoFile); + videoInfo.format.filename = resolve(videoFile); + //const imdbID = "tt0108333"; + const imdbID = prompt("Enter IMDb ID. Leave empty to skip"); + let jsonVideoInfo; + if (imdbID) { + const movieInfo = await fetchMovieInfo(imdbID); + jsonVideoInfo = buildVideoInfo(videoInfo, movieInfo); + } else { + jsonVideoInfo = buildVideoInfo(videoInfo); + } + const command = encodingCommandBuilder(jsonVideoInfo); + await writeShellScript(command); +} + if (import.meta.main) { - console.log("Add 2 + 3 =", add(2, 3)); + main(); } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..426c712 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,226 @@ +export interface Disposition { + default: boolean; + dub: boolean; + original: boolean; + comment: boolean; + lyrics: boolean; + karaoke: boolean; + forced: boolean; + hearing_impaired: boolean; + visual_impaired: boolean; + clean_effects: boolean; + attached_pic: boolean; + timed_thumbnails: boolean; + non_diegetic: boolean; + captions: boolean; + descriptions: boolean; + metadata: boolean; + dependent: boolean; + still_image: boolean; + multilayer: boolean; +} + +export interface VideoTrack { + 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; + level: number; + color_range: string; + chroma_location: string; + field_order: string; + refs: number; + r_frame_rate: string; + avg_frame_rate: string; + time_base: string; + start_pts: number; + start_time: string; + extradata_size: number; + disposition: Disposition; + tags: { + language: string; + }; +} + +export interface AudioTrack { + index: number; + codec_name: string; + codec_long_name: string; + profile: string; + codec_type: string; + codec_tag_string: string; + codec_tag: string; + channels: number; + channel_layout: string; + sample_rate: number; + bits_per_sample: number; + bits_per_raw_sample: number; + block_align: number; + frame_size: number; + initial_padding: number; + final_padding: number; + bit_rate: number; + max_bit_rate: number; + bits_per_frame: number; + id: number; + disposition: Disposition; + tags: { + language: string; + }; +} + +export interface SubtitlesTrack { + 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; + level: number; + color_range: string; + chroma_location: string; + field_order: string; + refs: number; + r_frame_rate: string; + avg_frame_rate: string; + time_base: string; + start_pts: number; + start_time: string; + extradata_size: number; + disposition: Disposition; + tags: { + language: string; + }; +} + +export interface VideoInfo { + format: { + filename: string; + nb_streams: number; + nb_programs: number; + nb_stream_groups: number; + format_name: string; + format_long_name: string; + start_time: string; + duration: string; + size: string; + bit_rate: string; + probe_score: number; + tags: { + encoder: string; + creation_time: string; + }; + }; + streams: (VideoTrack | AudioTrack | SubtitlesTrack)[]; +} + +export interface OMDBMovieInfo { + Title: string; + Year: string; + Rated: string; + Released: string; + Runtime: string; + Genre: string; + Director: string; + Writer: string; + Actors: string; + Plot: string; + Language: string; + Country: string; + Awards: string; + Poster: string; + Ratings: { Source: string; Value: string }[]; + Metascore: string; + imdbRating: string; + imdbVotes: string; + imdbID: string; + Type: string; + DVD: string; + BoxOffice: string; + Production: string; + Website: string; + Response: string; +} + +export interface OMDBSeriesInfo { + Title: string; + Year: string; + Rated: string; + Released: string; + Runtime: string; + Genre: string; + Director: string; + Writer: string; + Actors: string; + Plot: string; + Language: string; + Country: string; + Awards: string; + Poster: string; + Ratings: { Source: string; Value: string }[]; + Metascore: string; + imdbRating: string; + imdbVotes: string; + imdbID: string; + Type: string; + totalSeasons: string; + Response: string; +} + +export interface parsedAudioStream { + index: number; + language: string; + isSideloaded: boolean; + channels: number; + channel_layout: string; +} + +export interface parsedSubtitleStream { + index: number; + language: string; + codec: string; +} + +export interface parsedMetadata { + title: string; + date_released: string; + imdb_id: string; + original_media_type?: string; + season_number?: number; + episode_number?: number; +} + +export interface parsedVideoInfo { + filename: string; + metadata?: parsedMetadata; + MPix: number; + fps: number; + isAnamorphic: boolean; + isInterlaced: boolean; + audioStreams?: parsedAudioStream[]; + subtitleStreams?: parsedSubtitleStream[]; +} diff --git a/src/videoParser.ts b/src/videoParser.ts new file mode 100644 index 0000000..6717e36 --- /dev/null +++ b/src/videoParser.ts @@ -0,0 +1,137 @@ +import { + AudioTrack, + parsedAudioStream, + parsedMetadata, + parsedSubtitleStream, + parsedVideoInfo, + SubtitlesTrack, + VideoInfo, + VideoTrack, +} from "./types.ts"; + +const months: Record = { + "Jan": "01", + "Feb": "02", + "Mar": "03", + "Apr": "04", + "May": "05", + "Jun": "06", + "Jul": "07", + "Aug": "08", + "Sep": "09", + "Oct": "10", + "Nov": "11", + "Dec": "12", +}; + +export class VideoParser { + public metadata?: parsedMetadata; + + constructor( + private videoInfo: VideoInfo, + metadata?: parsedMetadata, + ) { + if (metadata) { + this.metadata = metadata; + } + } + + private parseFps(fps: string): number { + const [numerator, denominator] = fps.split("/"); + const result = Number(numerator) / Number(denominator); + return Math.round(result * 100) / 100; + } + + private parseDate(date: string): string { + const [day, month, year] = date.split(" "); + const convertedDate = `${year}-${months[month]}-${day}`; + return convertedDate; + } + + private parseMPix(): number { + const videoStream = this.videoInfo.streams[0] as VideoTrack; + return videoStream.width * videoStream.height / 1000000; + } + + getFps() { + for (const stream of this.videoInfo.streams) { + if (stream.codec_type === "video") { + const videoStream = stream as VideoTrack; + return this.parseFps(videoStream.avg_frame_rate); + } + } + return 0; + } + + isAudioSideloaded(index: number): boolean { + const audioStream = this.videoInfo.streams[index] as AudioTrack; + if (audioStream.channel_layout.endsWith("(side)")) { + return true; + } + return false; + } + + isAnamorphic(index: number = 0): boolean { + const videoStream = this.videoInfo.streams[index] as VideoTrack; + if (videoStream.sample_aspect_ratio !== "1:1") { + return true; + } + return false; + } + + isInterlaced(index: number = 0): boolean { + const videoStream = this.videoInfo.streams[index] as VideoTrack; + if (videoStream.field_order !== "progressive") { + return true; + } + return false; + } + + parse(): parsedVideoInfo { + const fps = this.getFps(); + const MPix = this.parseMPix(); + const isAnamorphic = this.isAnamorphic(); + const isInterlaced = this.isInterlaced(); + const audioStreams: parsedAudioStream[] = this.videoInfo.streams.filter(( + s, + ) => s.codec_type === "audio").map((s) => { + const audioStream = s as AudioTrack; + return { + language: audioStream.tags.language, + isSideloaded: this.isAudioSideloaded(this.videoInfo.streams.indexOf(s)), + channels: audioStream.channels, + channel_layout: audioStream.channel_layout.replace("(side)", ""), + index: this.videoInfo.streams.indexOf(s), + }; + }); + const subtitleStreams: parsedSubtitleStream[] = this.videoInfo.streams + .filter((s) => s.codec_type === "subtitle").map((s) => { + const subtitleStream = s as SubtitlesTrack; + return { + language: subtitleStream.tags.language, + index: this.videoInfo.streams.indexOf(s), + codec: subtitleStream.codec_name, + }; + }); + if (this.metadata) { + this.metadata.date_released = this.parseDate( + this.metadata.date_released, + ); + if (MPix < 1) { + this.metadata.original_media_type = "DVD"; + } else { + this.metadata.original_media_type = "BluRay"; + } + } + return { + filename: this.videoInfo.format.filename, + metadata: this.metadata, + fps, + MPix, + isAnamorphic, + isInterlaced, + audioStreams, + subtitleStreams, + }; + } +}