feat: base functionality

This commit is contained in:
Esa Kataja
2025-08-22 23:58:30 +03:00
parent d6e6270237
commit 4631524510
6 changed files with 668 additions and 6 deletions
+144
View File
@@ -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(" ");
}
+107 -4
View File
@@ -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<OMDBMovieInfo | OMDBSeriesInfo> {
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 <video_file>");
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();
}
+226
View File
@@ -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[];
}
+137
View File
@@ -0,0 +1,137 @@
import {
AudioTrack,
parsedAudioStream,
parsedMetadata,
parsedSubtitleStream,
parsedVideoInfo,
SubtitlesTrack,
VideoInfo,
VideoTrack,
} from "./types.ts";
const months: Record<string, string> = {
"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,
};
}
}