feat: base functionality
This commit is contained in:
@@ -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
@@ -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
@@ -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[];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user