mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
feat: add support for loudnorm audio filter
Allows for better loudness normalization vs straight volume adjustment using the ffmpeg loudnorm filter. The i, lra, and tp values are all configurable in the advanced transcode settings.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -27,8 +27,8 @@
|
|||||||
"@semantic-release/changelog": "^6.0.3",
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
"@types/node": "22.10.7",
|
"@types/node": "22.10.7",
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.21.0",
|
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||||
"@typescript-eslint/parser": "^8.21.0",
|
"@typescript-eslint/parser": "catalog:",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"esbuild": "^0.21.5",
|
"esbuild": "^0.21.5",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
@@ -62,9 +62,8 @@
|
|||||||
"kysely": "patches/kysely.patch"
|
"kysely": "patches/kysely.patch"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"eslint": "9.39.2",
|
"eslint": "catalog:",
|
||||||
"@types/node": "22.10.7",
|
"@types/node": "22.10.7"
|
||||||
"typescript": "5.9.3"
|
|
||||||
},
|
},
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@swc/core",
|
"@swc/core",
|
||||||
|
|||||||
1183
pnpm-lock.yaml
generated
1183
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,13 @@ packages:
|
|||||||
- shared
|
- shared
|
||||||
|
|
||||||
catalog:
|
catalog:
|
||||||
|
'@typescript-eslint/eslint-plugin': ^8.55.0
|
||||||
|
'@typescript-eslint/parser': ^8.55.0
|
||||||
dayjs: ^1.11.14
|
dayjs: ^1.11.14
|
||||||
eslint: 9.17.0
|
eslint: 9.39.2
|
||||||
lodash-es: ^4.17.21
|
lodash-es: ^4.17.21
|
||||||
random-js: 2.1.0
|
random-js: 2.1.0
|
||||||
typescript: 5.7.3
|
typescript: 5.9.3
|
||||||
zod: ^4.1.5
|
zod: ^4.3.6
|
||||||
|
|
||||||
enablePrePostScripts: true
|
enablePrePostScripts: true
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"test:watch": "vitest --typecheck.tsconfig tsconfig.test.json --watch",
|
"test:watch": "vitest --typecheck.tsconfig tsconfig.test.json --watch",
|
||||||
"test": "vitest --typecheck.tsconfig tsconfig.test.json --run",
|
"test": "vitest --typecheck.tsconfig tsconfig.test.json --run",
|
||||||
"tunarr": "dotenv -e .env.development -- tsx src/index.ts",
|
"tunarr": "dotenv -e .env.development -- tsx src/index.ts",
|
||||||
"typecheck": "cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc -p tsconfig.build.json --noEmit"
|
"typecheck": "cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc -p tsconfig.build.json --noEmit --diagnostics"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cospired/i18n-iso-languages": "^4.2.0",
|
"@cospired/i18n-iso-languages": "^4.2.0",
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
"pino": "^9.9.1",
|
"pino": "^9.9.1",
|
||||||
"pino-pretty": "^11.3.0",
|
"pino-pretty": "^11.3.0",
|
||||||
"pino-roll": "^1.3.0",
|
"pino-roll": "^1.3.0",
|
||||||
"random-js": "2.1.0",
|
"random-js": "catalog:",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"retry": "^0.13.1",
|
"retry": "^0.13.1",
|
||||||
"sonic-boom": "4.2.0",
|
"sonic-boom": "4.2.0",
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"yargs": "^17.7.2",
|
"yargs": "^17.7.2",
|
||||||
"zod": "^4.1.5"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "^9.9.0",
|
"@faker-js/faker": "^9.9.0",
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"tsx": "^4.20.5",
|
"tsx": "^4.20.5",
|
||||||
"typed-emitter": "^2.1.0",
|
"typed-emitter": "^2.1.0",
|
||||||
"typescript": "5.7.3",
|
"typescript": "catalog:",
|
||||||
"typescript-eslint": "^8.41.0",
|
"typescript-eslint": "^8.41.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
|
|||||||
'/transcode_configs',
|
'/transcode_configs',
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
tags: ['Settings'],
|
tags: ['Settings', 'Transcode Configs'],
|
||||||
response: {
|
response: {
|
||||||
200: z.array(TranscodeConfigSchema),
|
200: z.array(TranscodeConfigSchema),
|
||||||
},
|
},
|
||||||
@@ -185,7 +185,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
|
|||||||
'/transcode_configs/:id',
|
'/transcode_configs/:id',
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
tags: ['Settings'],
|
tags: ['Settings', 'Transcode Configs'],
|
||||||
params: z.object({
|
params: z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
}),
|
}),
|
||||||
@@ -211,6 +211,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
|
|||||||
'/transcode_configs/:id/copy',
|
'/transcode_configs/:id/copy',
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
|
tags: ['Settings', 'Transcode Configs'],
|
||||||
params: z.object({
|
params: z.object({
|
||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
}),
|
}),
|
||||||
@@ -244,7 +245,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
|
|||||||
'/transcode_configs',
|
'/transcode_configs',
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
tags: ['Settings'],
|
tags: ['Settings', 'Transcode Configs'],
|
||||||
body: TranscodeConfigSchema.omit({
|
body: TranscodeConfigSchema.omit({
|
||||||
id: true,
|
id: true,
|
||||||
}),
|
}),
|
||||||
@@ -265,7 +266,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
|
|||||||
'/transcode_configs/:id',
|
'/transcode_configs/:id',
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
tags: ['Settings'],
|
tags: ['Settings', 'Transcode Configs'],
|
||||||
body: TranscodeConfigSchema,
|
body: TranscodeConfigSchema,
|
||||||
params: IdPathParamSchema,
|
params: IdPathParamSchema,
|
||||||
response: {
|
response: {
|
||||||
@@ -286,7 +287,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
|
|||||||
'/transcode_configs/:id',
|
'/transcode_configs/:id',
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
tags: ['Settings'],
|
tags: ['Settings', 'Transcode Configs'],
|
||||||
params: IdPathParamSchema,
|
params: IdPathParamSchema,
|
||||||
response: {
|
response: {
|
||||||
200: z.void(),
|
200: z.void(),
|
||||||
|
|||||||
@@ -196,78 +196,6 @@ export class TranscodeConfigDB implements ITranscodeConfigDB {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
.execute();
|
.execute();
|
||||||
});
|
});
|
||||||
// const numConfigs = await tx
|
|
||||||
// .selectFrom('transcodeConfig')
|
|
||||||
// .select((eb) => eb.fn.count<number>('uuid').as('count'))
|
|
||||||
// .executeTakeFirst()
|
|
||||||
// .then((res) => res?.count ?? 0);
|
|
||||||
|
|
||||||
// // If there are no configs (should be impossible) create a default, assign it to all channels
|
|
||||||
// // and move on.
|
|
||||||
// if (numConfigs === 0) {
|
|
||||||
// const { uuid: newDefaultConfigId } =
|
|
||||||
// await this.insertDefaultConfiguration(tx);
|
|
||||||
// await tx
|
|
||||||
// .updateTable('channel')
|
|
||||||
// .set('transcodeConfigId', newDefaultConfigId)
|
|
||||||
// .execute();
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const configToDelete = await tx
|
|
||||||
// .selectFrom('transcodeConfig')
|
|
||||||
// .where('uuid', '=', id)
|
|
||||||
// .selectAll()
|
|
||||||
// .limit(1)
|
|
||||||
// .executeTakeFirst();
|
|
||||||
|
|
||||||
// if (!configToDelete) {
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // If this is the last config, we'll need a new one and will have to assign it
|
|
||||||
// if (numConfigs === 1) {
|
|
||||||
// const { uuid: newDefaultConfigId } =
|
|
||||||
// await this.insertDefaultConfiguration(tx);
|
|
||||||
// await tx
|
|
||||||
// .updateTable('channel')
|
|
||||||
// .set('transcodeConfigId', newDefaultConfigId)
|
|
||||||
// .execute();
|
|
||||||
// await tx
|
|
||||||
// .deleteFrom('transcodeConfig')
|
|
||||||
// .where('uuid', '=', id)
|
|
||||||
// .limit(1)
|
|
||||||
// .execute();
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // We're deleting the default config. Pick a random one to make the new default. Not great!
|
|
||||||
// if (configToDelete.isDefault) {
|
|
||||||
// const newDefaultConfig = await tx
|
|
||||||
// .selectFrom('transcodeConfig')
|
|
||||||
// .where('uuid', '!=', id)
|
|
||||||
// .where('isDefault', '=', 0)
|
|
||||||
// .select('uuid')
|
|
||||||
// .limit(1)
|
|
||||||
// .executeTakeFirstOrThrow();
|
|
||||||
// await tx
|
|
||||||
// .updateTable('transcodeConfig')
|
|
||||||
// .set('isDefault', 1)
|
|
||||||
// .where('uuid', '=', newDefaultConfig.uuid)
|
|
||||||
// .limit(1)
|
|
||||||
// .execute();
|
|
||||||
// await tx
|
|
||||||
// .updateTable('channel')
|
|
||||||
// .set('transcodeConfigId', newDefaultConfig.uuid)
|
|
||||||
// .execute();
|
|
||||||
// }
|
|
||||||
|
|
||||||
// await tx
|
|
||||||
// .deleteFrom('transcodeConfig')
|
|
||||||
// .where('uuid', '=', id)
|
|
||||||
// .limit(1)
|
|
||||||
// .execute();
|
|
||||||
// });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async insertDefaultConfiguration(db: DrizzleDBAccess = this.drizzle) {
|
private async insertDefaultConfiguration(db: DrizzleDBAccess = this.drizzle) {
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import type { TranscodeConfig } from '@tunarr/types';
|
import type { TranscodeConfig } from '@tunarr/types';
|
||||||
import { numberToBoolean } from '../../util/sqliteUtil.ts';
|
import type { TranscodeConfigOrm } from '../schema/TranscodeConfig.ts';
|
||||||
import type {
|
|
||||||
TranscodeConfig as TranscodeConfigDAO,
|
|
||||||
TranscodeConfigOrm,
|
|
||||||
} from '../schema/TranscodeConfig.ts';
|
|
||||||
|
|
||||||
export function transcodeConfigOrmToDto(
|
export function transcodeConfigOrmToDto(
|
||||||
config: TranscodeConfigOrm,
|
config: TranscodeConfigOrm,
|
||||||
@@ -11,13 +7,6 @@ export function transcodeConfigOrmToDto(
|
|||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
id: config.uuid,
|
id: config.uuid,
|
||||||
// disableChannelOverlay: numberToBoolean(config.disableChannelOverlay),
|
|
||||||
// normalizeFrameRate: numberToBoolean(config.normalizeFrameRate),
|
|
||||||
// deinterlaceVideo: numberToBoolean(config.deinterlaceVideo),
|
|
||||||
// isDefault: numberToBoolean(config.isDefault),
|
|
||||||
// disableHardwareDecoder: numberToBoolean(config.disableHardwareDecoder),
|
|
||||||
// disableHardwareEncoding: numberToBoolean(config.disableHardwareEncoding),
|
|
||||||
// disableHardwareFilters: numberToBoolean(config.disableHardwareFilters),
|
|
||||||
disableChannelOverlay: config.disableChannelOverlay ?? false,
|
disableChannelOverlay: config.disableChannelOverlay ?? false,
|
||||||
normalizeFrameRate: config.normalizeFrameRate ?? false,
|
normalizeFrameRate: config.normalizeFrameRate ?? false,
|
||||||
deinterlaceVideo: config.deinterlaceVideo ?? false,
|
deinterlaceVideo: config.deinterlaceVideo ?? false,
|
||||||
@@ -27,19 +16,3 @@ export function transcodeConfigOrmToDto(
|
|||||||
disableHardwareFilters: config.disableHardwareFilters ?? false,
|
disableHardwareFilters: config.disableHardwareFilters ?? false,
|
||||||
} satisfies TranscodeConfig;
|
} satisfies TranscodeConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function legacyTranscodeConfigToDto(
|
|
||||||
config: TranscodeConfigDAO,
|
|
||||||
): TranscodeConfig {
|
|
||||||
return {
|
|
||||||
...config,
|
|
||||||
id: config.uuid,
|
|
||||||
disableChannelOverlay: numberToBoolean(config.disableChannelOverlay),
|
|
||||||
normalizeFrameRate: numberToBoolean(config.normalizeFrameRate),
|
|
||||||
deinterlaceVideo: numberToBoolean(config.deinterlaceVideo),
|
|
||||||
isDefault: numberToBoolean(config.isDefault),
|
|
||||||
disableHardwareDecoder: numberToBoolean(config.disableHardwareDecoder),
|
|
||||||
disableHardwareEncoding: numberToBoolean(config.disableHardwareEncoding),
|
|
||||||
disableHardwareFilters: numberToBoolean(config.disableHardwareFilters),
|
|
||||||
} satisfies TranscodeConfig;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Resolution, TupleToUnion } from '@tunarr/types';
|
import { type Resolution, type TupleToUnion } from '@tunarr/types';
|
||||||
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
|
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
|
||||||
import { inArray } from 'drizzle-orm';
|
import { inArray } from 'drizzle-orm';
|
||||||
import { check, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
import { check, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||||
@@ -146,6 +146,7 @@ export const TranscodeConfig = sqliteTable(
|
|||||||
audioBufferSize: integer().notNull(),
|
audioBufferSize: integer().notNull(),
|
||||||
audioSampleRate: integer().notNull(),
|
audioSampleRate: integer().notNull(),
|
||||||
audioVolumePercent: integer().notNull().default(100), // Default 100
|
audioVolumePercent: integer().notNull().default(100), // Default 100
|
||||||
|
// audioLoudnormConfig: text({ mode: 'json' }).$type<LoudnormConfig>(),
|
||||||
|
|
||||||
normalizeFrameRate: integer({ mode: 'boolean' }).default(false),
|
normalizeFrameRate: integer({ mode: 'boolean' }).default(false),
|
||||||
deinterlaceVideo: integer({ mode: 'boolean' }).default(true),
|
deinterlaceVideo: integer({ mode: 'boolean' }).default(true),
|
||||||
|
|||||||
@@ -54,10 +54,6 @@ import {
|
|||||||
ProgramExternalId,
|
ProgramExternalId,
|
||||||
ProgramExternalIdRelations,
|
ProgramExternalIdRelations,
|
||||||
} from './ProgramExternalId.ts';
|
} from './ProgramExternalId.ts';
|
||||||
import {
|
|
||||||
ProgramPlayHistory,
|
|
||||||
ProgramPlayHistoryRelations,
|
|
||||||
} from './ProgramPlayHistory.ts';
|
|
||||||
import {
|
import {
|
||||||
ProgramGrouping,
|
ProgramGrouping,
|
||||||
ProgramGroupingRelations,
|
ProgramGroupingRelations,
|
||||||
@@ -74,6 +70,10 @@ import {
|
|||||||
ProgramMediaStream,
|
ProgramMediaStream,
|
||||||
ProgramMediaStreamRelations,
|
ProgramMediaStreamRelations,
|
||||||
} from './ProgramMediaStream.ts';
|
} from './ProgramMediaStream.ts';
|
||||||
|
import {
|
||||||
|
ProgramPlayHistory,
|
||||||
|
ProgramPlayHistoryRelations,
|
||||||
|
} from './ProgramPlayHistory.ts';
|
||||||
import {
|
import {
|
||||||
ProgramSubtitles,
|
ProgramSubtitles,
|
||||||
ProgramSubtitlesRelations,
|
ProgramSubtitlesRelations,
|
||||||
|
|||||||
@@ -385,6 +385,8 @@ export class FfmpegStreamFactory extends IFFMPEG {
|
|||||||
// Check if audio and video are coming from same location
|
// Check if audio and video are coming from same location
|
||||||
audioDuration:
|
audioDuration:
|
||||||
streamMode === 'hls_direct' ? null : duration.asMilliseconds(),
|
streamMode === 'hls_direct' ? null : duration.asMilliseconds(),
|
||||||
|
normalizeLoudness: false, // !!this.transcodeConfig.audioLoudnormConfig,
|
||||||
|
// loudnormConfig: this.transcodeConfig.audioLoudnormConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
let audioInput: AudioInputSource;
|
let audioInput: AudioInputSource;
|
||||||
@@ -411,7 +413,11 @@ export class FfmpegStreamFactory extends IFFMPEG {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let watermarkSource: Nullable<WatermarkInputSource> = null;
|
let watermarkSource: Nullable<WatermarkInputSource> = null;
|
||||||
if (streamMode !== ChannelStreamModes.HlsDirect && watermark?.enabled) {
|
if (
|
||||||
|
streamMode !== ChannelStreamModes.HlsDirect &&
|
||||||
|
streamMode !== ChannelStreamModes.HlsDirectV2 &&
|
||||||
|
watermark?.enabled
|
||||||
|
) {
|
||||||
const watermarkUrl = watermark.url ?? makeLocalUrl('/images/tunarr.png');
|
const watermarkUrl = watermark.url ?? makeLocalUrl('/images/tunarr.png');
|
||||||
watermarkSource = new WatermarkInputSource(
|
watermarkSource = new WatermarkInputSource(
|
||||||
new HttpStreamSource(watermarkUrl),
|
new HttpStreamSource(watermarkUrl),
|
||||||
|
|||||||
19
server/src/ffmpeg/builder/filter/LoudnormFilter.ts
Normal file
19
server/src/ffmpeg/builder/filter/LoudnormFilter.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { LoudnormConfig } from '@tunarr/types';
|
||||||
|
import { isDefined } from '../../../util/index.ts';
|
||||||
|
import { FilterOption } from './FilterOption.ts';
|
||||||
|
|
||||||
|
export class LoudnormFilter extends FilterOption {
|
||||||
|
constructor(
|
||||||
|
private loudnormConfig: LoudnormConfig,
|
||||||
|
private sampleRate: number,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get filter(): string {
|
||||||
|
const gain = isDefined(this.loudnormConfig.offsetGain)
|
||||||
|
? `:offset=${this.loudnormConfig.offsetGain}`
|
||||||
|
: '';
|
||||||
|
return `loudnorm=I=${this.loudnormConfig.i}:LRA=${this.loudnormConfig.lra}:TP=${this.loudnormConfig.tp}${gain},aresample=${this.sampleRate}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FileStreamSource } from '../../../stream/types.ts';
|
import { FileStreamSource } from '../../../stream/types.ts';
|
||||||
import { EmptyFfmpegCapabilities } from '../capabilities/FfmpegCapabilities.ts';
|
import { EmptyFfmpegCapabilities } from '../capabilities/FfmpegCapabilities.ts';
|
||||||
import { AudioVolumeFilter } from '../filter/AudioVolumeFilter.ts';
|
import { AudioVolumeFilter } from '../filter/AudioVolumeFilter.ts';
|
||||||
|
import { LoudnormFilter } from '../filter/LoudnormFilter.ts';
|
||||||
import { PixelFormatYuv420P } from '../format/PixelFormat.ts';
|
import { PixelFormatYuv420P } from '../format/PixelFormat.ts';
|
||||||
import { AudioInputSource } from '../input/AudioInputSource.ts';
|
import { AudioInputSource } from '../input/AudioInputSource.ts';
|
||||||
import { VideoInputSource } from '../input/VideoInputSource.ts';
|
import { VideoInputSource } from '../input/VideoInputSource.ts';
|
||||||
@@ -127,4 +128,269 @@ describe('BasePipelineBuilder', () => {
|
|||||||
|
|
||||||
expect(volumeFilter).toBeUndefined();
|
expect(volumeFilter).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('add loudnorm filter when loudnormConfig is set', () => {
|
||||||
|
const audio = AudioInputSource.withStream(
|
||||||
|
new FileStreamSource('/path/to/song.flac'),
|
||||||
|
AudioStream.create({
|
||||||
|
channels: 2,
|
||||||
|
codec: 'flac',
|
||||||
|
index: 0,
|
||||||
|
}),
|
||||||
|
AudioState.create({
|
||||||
|
audioBitrate: 192,
|
||||||
|
audioBufferSize: 192 * 2,
|
||||||
|
audioChannels: 2,
|
||||||
|
loudnormConfig: { i: -24, lra: 7, tp: -2 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pipeline = new NoopPipelineBuilder(
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
EmptyFfmpegCapabilities,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = pipeline.build(state, frameState, DefaultPipelineOptions);
|
||||||
|
|
||||||
|
const loudnormFilter = result.inputs.audioInput?.filterSteps.find(
|
||||||
|
(step) => step instanceof LoudnormFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loudnormFilter).toBeDefined();
|
||||||
|
expect(loudnormFilter?.filter).toEqual(
|
||||||
|
'loudnorm=I=-24:LRA=7:TP=-2,aresample=48000',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add loudnorm filter with custom offset gain', () => {
|
||||||
|
const audio = AudioInputSource.withStream(
|
||||||
|
new FileStreamSource('/path/to/song.flac'),
|
||||||
|
AudioStream.create({
|
||||||
|
channels: 2,
|
||||||
|
codec: 'flac',
|
||||||
|
index: 0,
|
||||||
|
}),
|
||||||
|
AudioState.create({
|
||||||
|
audioBitrate: 192,
|
||||||
|
audioBufferSize: 192 * 2,
|
||||||
|
audioChannels: 2,
|
||||||
|
loudnormConfig: { i: -16, lra: 11, tp: -1, offsetGain: 3 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pipeline = new NoopPipelineBuilder(
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
EmptyFfmpegCapabilities,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = pipeline.build(state, frameState, DefaultPipelineOptions);
|
||||||
|
|
||||||
|
const loudnormFilter = result.inputs.audioInput?.filterSteps.find(
|
||||||
|
(step) => step instanceof LoudnormFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loudnormFilter).toBeDefined();
|
||||||
|
expect(loudnormFilter?.filter).toEqual(
|
||||||
|
'loudnorm=I=-16:LRA=11:TP=-1:offset=3,aresample=48000',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('use custom sample rate in loudnorm filter when audioSampleRate is set', () => {
|
||||||
|
const audio = AudioInputSource.withStream(
|
||||||
|
new FileStreamSource('/path/to/song.flac'),
|
||||||
|
AudioStream.create({
|
||||||
|
channels: 2,
|
||||||
|
codec: 'flac',
|
||||||
|
index: 0,
|
||||||
|
}),
|
||||||
|
AudioState.create({
|
||||||
|
audioBitrate: 192,
|
||||||
|
audioBufferSize: 192 * 2,
|
||||||
|
audioChannels: 2,
|
||||||
|
audioSampleRate: 44100,
|
||||||
|
loudnormConfig: { i: -24, lra: 7, tp: -2 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pipeline = new NoopPipelineBuilder(
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
EmptyFfmpegCapabilities,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = pipeline.build(state, frameState, DefaultPipelineOptions);
|
||||||
|
|
||||||
|
const loudnormFilter = result.inputs.audioInput?.filterSteps.find(
|
||||||
|
(step) => step instanceof LoudnormFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loudnormFilter).toBeDefined();
|
||||||
|
expect(loudnormFilter?.filter).toEqual(
|
||||||
|
'loudnorm=I=-24:LRA=7:TP=-2,aresample=44100',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('do not add loudnorm filter when audio encoder is copy', () => {
|
||||||
|
const audio = AudioInputSource.withStream(
|
||||||
|
new FileStreamSource('/path/to/song.flac'),
|
||||||
|
AudioStream.create({
|
||||||
|
channels: 2,
|
||||||
|
codec: 'flac',
|
||||||
|
index: 0,
|
||||||
|
}),
|
||||||
|
AudioState.create({
|
||||||
|
audioEncoder: 'copy',
|
||||||
|
loudnormConfig: { i: -24, lra: 7, tp: -2 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pipeline = new NoopPipelineBuilder(
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
EmptyFfmpegCapabilities,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = pipeline.build(state, frameState, DefaultPipelineOptions);
|
||||||
|
|
||||||
|
const loudnormFilter = result.inputs.audioInput?.filterSteps.find(
|
||||||
|
(step) => step instanceof LoudnormFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loudnormFilter).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ desc: 'i too low', config: { i: -70.1, lra: 7, tp: -2 } },
|
||||||
|
{ desc: 'i too high', config: { i: -4.9, lra: 7, tp: -2 } },
|
||||||
|
{ desc: 'lra too low', config: { i: -24, lra: 0.9, tp: -2 } },
|
||||||
|
{ desc: 'lra too high', config: { i: -24, lra: 50.1, tp: -2 } },
|
||||||
|
{ desc: 'tp too low', config: { i: -24, lra: 7, tp: -9.1 } },
|
||||||
|
{ desc: 'tp too high', config: { i: -24, lra: 7, tp: 0.1 } },
|
||||||
|
])(
|
||||||
|
'do not add loudnorm filter when $desc',
|
||||||
|
({ config }) => {
|
||||||
|
const audio = AudioInputSource.withStream(
|
||||||
|
new FileStreamSource('/path/to/song.flac'),
|
||||||
|
AudioStream.create({
|
||||||
|
channels: 2,
|
||||||
|
codec: 'flac',
|
||||||
|
index: 0,
|
||||||
|
}),
|
||||||
|
AudioState.create({
|
||||||
|
audioBitrate: 192,
|
||||||
|
audioBufferSize: 192 * 2,
|
||||||
|
audioChannels: 2,
|
||||||
|
loudnormConfig: config,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pipeline = new NoopPipelineBuilder(
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
EmptyFfmpegCapabilities,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = pipeline.build(state, frameState, DefaultPipelineOptions);
|
||||||
|
|
||||||
|
const loudnormFilter = result.inputs.audioInput?.filterSteps.find(
|
||||||
|
(step) => step instanceof LoudnormFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loudnormFilter).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ desc: 'i at lower bound', config: { i: -70, lra: 7, tp: -2 } },
|
||||||
|
{ desc: 'i at upper bound', config: { i: -5, lra: 7, tp: -2 } },
|
||||||
|
{ desc: 'lra at lower bound', config: { i: -24, lra: 1, tp: -2 } },
|
||||||
|
{ desc: 'lra at upper bound', config: { i: -24, lra: 50, tp: -2 } },
|
||||||
|
{ desc: 'tp at lower bound', config: { i: -24, lra: 7, tp: -9 } },
|
||||||
|
{ desc: 'tp at upper bound', config: { i: -24, lra: 7, tp: 0 } },
|
||||||
|
])(
|
||||||
|
'add loudnorm filter when $desc',
|
||||||
|
({ config }) => {
|
||||||
|
const audio = AudioInputSource.withStream(
|
||||||
|
new FileStreamSource('/path/to/song.flac'),
|
||||||
|
AudioStream.create({
|
||||||
|
channels: 2,
|
||||||
|
codec: 'flac',
|
||||||
|
index: 0,
|
||||||
|
}),
|
||||||
|
AudioState.create({
|
||||||
|
audioBitrate: 192,
|
||||||
|
audioBufferSize: 192 * 2,
|
||||||
|
audioChannels: 2,
|
||||||
|
loudnormConfig: config,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pipeline = new NoopPipelineBuilder(
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
EmptyFfmpegCapabilities,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = pipeline.build(state, frameState, DefaultPipelineOptions);
|
||||||
|
|
||||||
|
const loudnormFilter = result.inputs.audioInput?.filterSteps.find(
|
||||||
|
(step) => step instanceof LoudnormFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loudnormFilter).toBeDefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('do not add loudnorm filter when loudnormConfig is not set', () => {
|
||||||
|
const audio = AudioInputSource.withStream(
|
||||||
|
new FileStreamSource('/path/to/song.flac'),
|
||||||
|
AudioStream.create({
|
||||||
|
channels: 2,
|
||||||
|
codec: 'flac',
|
||||||
|
index: 0,
|
||||||
|
}),
|
||||||
|
AudioState.create({
|
||||||
|
audioBitrate: 192,
|
||||||
|
audioBufferSize: 192 * 2,
|
||||||
|
audioChannels: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pipeline = new NoopPipelineBuilder(
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
EmptyFfmpegCapabilities,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = pipeline.build(state, frameState, DefaultPipelineOptions);
|
||||||
|
|
||||||
|
const loudnormFilter = result.inputs.audioInput?.filterSteps.find(
|
||||||
|
(step) => step instanceof LoudnormFilter,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loudnormFilter).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import { Mpeg2VideoEncoder } from '../encoder/Mpeg2VideoEncoder.ts';
|
|||||||
import { RawVideoEncoder } from '../encoder/RawVideoEncoder.ts';
|
import { RawVideoEncoder } from '../encoder/RawVideoEncoder.ts';
|
||||||
import { AudioVolumeFilter } from '../filter/AudioVolumeFilter.ts';
|
import { AudioVolumeFilter } from '../filter/AudioVolumeFilter.ts';
|
||||||
import type { FilterOption } from '../filter/FilterOption.ts';
|
import type { FilterOption } from '../filter/FilterOption.ts';
|
||||||
|
import { LoudnormFilter } from '../filter/LoudnormFilter.ts';
|
||||||
import { StreamSeekFilter } from '../filter/StreamSeekFilter.ts';
|
import { StreamSeekFilter } from '../filter/StreamSeekFilter.ts';
|
||||||
import type { SubtitlesInputSource } from '../input/SubtitlesInputSource.ts';
|
import type { SubtitlesInputSource } from '../input/SubtitlesInputSource.ts';
|
||||||
import {
|
import {
|
||||||
@@ -633,7 +634,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder {
|
|||||||
if (
|
if (
|
||||||
!isNull(this.desiredAudioState?.audioVolume) &&
|
!isNull(this.desiredAudioState?.audioVolume) &&
|
||||||
this.desiredAudioState.audioVolume !== 100 &&
|
this.desiredAudioState.audioVolume !== 100 &&
|
||||||
encoder.name != 'copy' &&
|
encoder.name != TranscodeAudioOutputFormat.Copy &&
|
||||||
this.desiredAudioState.audioVolume > 0
|
this.desiredAudioState.audioVolume > 0
|
||||||
) {
|
) {
|
||||||
this.audioInputSource?.filterSteps?.push(
|
this.audioInputSource?.filterSteps?.push(
|
||||||
@@ -641,7 +642,7 @@ export abstract class BasePipelineBuilder implements PipelineBuilder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (encoder.name !== 'copy') {
|
if (encoder.name !== TranscodeAudioOutputFormat.Copy) {
|
||||||
// This seems to help with audio sync issues in QSV
|
// This seems to help with audio sync issues in QSV
|
||||||
const asyncSamples =
|
const asyncSamples =
|
||||||
this.ffmpegState.decoderHwAccelMode === HardwareAccelerationMode.Qsv
|
this.ffmpegState.decoderHwAccelMode === HardwareAccelerationMode.Qsv
|
||||||
@@ -655,6 +656,32 @@ export abstract class BasePipelineBuilder implements PipelineBuilder {
|
|||||||
this.audioInputSource?.filterSteps.push(new AudioPadFilter());
|
this.audioInputSource?.filterSteps.push(new AudioPadFilter());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isNull(this.desiredAudioState.loudnormConfig) &&
|
||||||
|
encoder.name !== TranscodeAudioOutputFormat.Copy
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
this.desiredAudioState.loudnormConfig.i < -70.0 ||
|
||||||
|
this.desiredAudioState.loudnormConfig.i > -5.0 ||
|
||||||
|
this.desiredAudioState.loudnormConfig.lra < 1.0 ||
|
||||||
|
this.desiredAudioState.loudnormConfig.lra > 50.0 ||
|
||||||
|
this.desiredAudioState.loudnormConfig.tp < -9.0 ||
|
||||||
|
this.desiredAudioState.loudnormConfig.tp > 0
|
||||||
|
) {
|
||||||
|
this.logger.warn(
|
||||||
|
'Loudnorm config is not valid: %O',
|
||||||
|
this.desiredAudioState.loudnormConfig,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.audioInputSource?.filterSteps.push(
|
||||||
|
new LoudnormFilter(
|
||||||
|
this.desiredAudioState.loudnormConfig,
|
||||||
|
this.desiredAudioState.audioSampleRate ?? 48_000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract setupVideoFilters(): void;
|
protected abstract setupVideoFilters(): void;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ExcludeByValueType, Nullable } from '@/types/util.js';
|
import type { ExcludeByValueType, Nullable } from '@/types/util.js';
|
||||||
|
import type { LoudnormConfig } from '@tunarr/types';
|
||||||
import { isNil, omitBy } from 'lodash-es';
|
import { isNil, omitBy } from 'lodash-es';
|
||||||
import type { AnyFunction } from 'ts-essentials';
|
import type { AnyFunction } from 'ts-essentials';
|
||||||
import type { TranscodeAudioOutputFormat } from '../../../db/schema/TranscodeConfig.ts';
|
import type { TranscodeAudioOutputFormat } from '../../../db/schema/TranscodeConfig.ts';
|
||||||
@@ -13,6 +14,8 @@ const DefaultAudioState: AudioState = {
|
|||||||
audioSampleRate: null,
|
audioSampleRate: null,
|
||||||
audioDuration: null,
|
audioDuration: null,
|
||||||
audioVolume: null,
|
audioVolume: null,
|
||||||
|
normalizeLoudness: false,
|
||||||
|
loudnormConfig: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AudioState {
|
export class AudioState {
|
||||||
@@ -23,6 +26,8 @@ export class AudioState {
|
|||||||
audioSampleRate: Nullable<number>;
|
audioSampleRate: Nullable<number>;
|
||||||
audioDuration: Nullable<number>;
|
audioDuration: Nullable<number>;
|
||||||
audioVolume: Nullable<number>;
|
audioVolume: Nullable<number>;
|
||||||
|
normalizeLoudness: boolean;
|
||||||
|
loudnormConfig: Nullable<LoudnormConfig>;
|
||||||
|
|
||||||
private constructor(fields: Partial<AudioStateFields> = {}) {
|
private constructor(fields: Partial<AudioStateFields> = {}) {
|
||||||
const merged: AudioStateFields = {
|
const merged: AudioStateFields = {
|
||||||
@@ -36,6 +41,8 @@ export class AudioState {
|
|||||||
this.audioSampleRate = merged.audioSampleRate;
|
this.audioSampleRate = merged.audioSampleRate;
|
||||||
this.audioDuration = merged.audioDuration;
|
this.audioDuration = merged.audioDuration;
|
||||||
this.audioVolume = merged.audioVolume;
|
this.audioVolume = merged.audioVolume;
|
||||||
|
this.normalizeLoudness = merged.normalizeLoudness ?? false;
|
||||||
|
this.loudnormConfig = merged.loudnormConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(fields: Partial<AudioStateFields> = {}) {
|
static create(fields: Partial<AudioStateFields> = {}) {
|
||||||
|
|||||||
@@ -196,6 +196,9 @@ export class DirectMigrationProvider implements MigrationProvider {
|
|||||||
'./sql/0040_daffy_bishop.sql',
|
'./sql/0040_daffy_bishop.sql',
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
|
migration1770236998: makeKyselyMigrationFromSqlFile(
|
||||||
|
'./sql/0041_easy_firebird.sql',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
wrapWithTransaction,
|
wrapWithTransaction,
|
||||||
),
|
),
|
||||||
|
|||||||
1
server/src/migration/db/sql/0041_easy_firebird.sql
Normal file
1
server/src/migration/db/sql/0041_easy_firebird.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `transcode_config` ADD `audio_loudnorm_config` text;
|
||||||
3981
server/src/migration/db/sql/meta/0041_snapshot.json
Normal file
3981
server/src/migration/db/sql/meta/0041_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -288,6 +288,13 @@
|
|||||||
"when": 1769361500423,
|
"when": 1769361500423,
|
||||||
"tag": "0040_daffy_bishop",
|
"tag": "0040_daffy_bishop",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 41,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1770236977185,
|
||||||
|
"tag": "0041_easy_firebird",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
"license": "Zlib",
|
"license": "Zlib",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@microsoft/api-extractor": "^7.43.0",
|
"@microsoft/api-extractor": "^7.43.0",
|
||||||
"@typescript-eslint/eslint-plugin": "6.0.0",
|
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||||
"@typescript-eslint/parser": "6.0.0",
|
"@typescript-eslint/parser": "catalog:",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"rimraf": "^5.0.5",
|
"rimraf": "^5.0.5",
|
||||||
"tsup": "^8.0.2",
|
"tsup": "^8.0.2",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { z } from 'zod/v4';
|
import type { z } from 'zod';
|
||||||
import type {
|
import type {
|
||||||
SupportedTranscodeVideoOutputFormats,
|
SupportedTranscodeVideoOutputFormats,
|
||||||
TranscodeConfigSchema,
|
TranscodeConfigSchema,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type z from 'zod/v4';
|
|||||||
import type { ExternalId } from './Program.js';
|
import type { ExternalId } from './Program.js';
|
||||||
import type {
|
import type {
|
||||||
HealthCheckSchema,
|
HealthCheckSchema,
|
||||||
|
LoudnormConfigSchema,
|
||||||
ResolutionSchema,
|
ResolutionSchema,
|
||||||
} from './schemas/miscSchemas.js';
|
} from './schemas/miscSchemas.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -28,3 +29,5 @@ export type SingleExternalId = z.infer<typeof SingleExternalIdSchema>;
|
|||||||
export type MultiExternalId = z.infer<typeof MultiExternalIdSchema>;
|
export type MultiExternalId = z.infer<typeof MultiExternalIdSchema>;
|
||||||
|
|
||||||
export type HealthCheck = z.infer<typeof HealthCheckSchema>;
|
export type HealthCheck = z.infer<typeof HealthCheckSchema>;
|
||||||
|
|
||||||
|
export type LoudnormConfig = z.infer<typeof LoudnormConfigSchema>;
|
||||||
|
|||||||
@@ -12,3 +12,30 @@ export const HealthCheckSchema = z.union([
|
|||||||
context: z.string(),
|
context: z.string(),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const LoudnormConfigSchema = z.object({
|
||||||
|
i: z.coerce
|
||||||
|
.number()
|
||||||
|
.min(-70.0)
|
||||||
|
.max(-5.0)
|
||||||
|
.default(-24.0)
|
||||||
|
.describe('integrated loudness target'),
|
||||||
|
lra: z.coerce
|
||||||
|
.number()
|
||||||
|
.min(1.0)
|
||||||
|
.max(50.0)
|
||||||
|
.default(7.0)
|
||||||
|
.describe('loudness range target'),
|
||||||
|
tp: z.coerce
|
||||||
|
.number()
|
||||||
|
.min(-9.0)
|
||||||
|
.max(0.0)
|
||||||
|
.default(-2.0)
|
||||||
|
.describe('maximum true peak'),
|
||||||
|
offsetGain: z.coerce
|
||||||
|
.number()
|
||||||
|
.min(-99.0)
|
||||||
|
.max(99.0)
|
||||||
|
.optional()
|
||||||
|
.describe('offset gain to add before peak limiter'),
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import z from 'zod/v4';
|
import z from 'zod/v4';
|
||||||
import type { TupleToUnion } from '../util.js';
|
import type { TupleToUnion } from '../util.js';
|
||||||
import { ResolutionSchema } from './miscSchemas.js';
|
import { LoudnormConfigSchema, ResolutionSchema } from './miscSchemas.js';
|
||||||
import {
|
import {
|
||||||
SupportedErrorAudioTypes,
|
SupportedErrorAudioTypes,
|
||||||
SupportedErrorScreens,
|
SupportedErrorScreens,
|
||||||
@@ -39,8 +39,13 @@ export type SupportedTranscodeAudioOutputFormats = TupleToUnion<
|
|||||||
|
|
||||||
export const TranscodeConfigSchema = z.object({
|
export const TranscodeConfigSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z
|
||||||
threadCount: z.number(),
|
.string({
|
||||||
|
error: (iss) =>
|
||||||
|
iss.input === undefined ? 'Name is required' : 'Invalid input',
|
||||||
|
})
|
||||||
|
.min(1, { error: 'Name cannot be empty' }),
|
||||||
|
threadCount: z.coerce.number(),
|
||||||
hardwareAccelerationMode: z.enum(SupportedHardwareAccels),
|
hardwareAccelerationMode: z.enum(SupportedHardwareAccels),
|
||||||
vaapiDriver: z.enum(SupportedVaapiDrivers),
|
vaapiDriver: z.enum(SupportedVaapiDrivers),
|
||||||
vaapiDevice: z.string().nullable(),
|
vaapiDevice: z.string().nullable(),
|
||||||
@@ -49,14 +54,15 @@ export const TranscodeConfigSchema = z.object({
|
|||||||
videoProfile: z.string().nullable(),
|
videoProfile: z.string().nullable(),
|
||||||
videoPreset: z.string().nullable(),
|
videoPreset: z.string().nullable(),
|
||||||
videoBitDepth: z.union([z.literal(8), z.literal(10)]).nullable(),
|
videoBitDepth: z.union([z.literal(8), z.literal(10)]).nullable(),
|
||||||
videoBitRate: z.number(),
|
videoBitRate: z.coerce.number(),
|
||||||
videoBufferSize: z.number(),
|
videoBufferSize: z.coerce.number(),
|
||||||
audioChannels: z.number(),
|
audioChannels: z.coerce.number(),
|
||||||
audioFormat: z.enum(SupportedTranscodeAudioOutputFormats),
|
audioFormat: z.enum(SupportedTranscodeAudioOutputFormats),
|
||||||
audioBitRate: z.number(),
|
audioBitRate: z.coerce.number(),
|
||||||
audioBufferSize: z.number(),
|
audioBufferSize: z.coerce.number(),
|
||||||
audioSampleRate: z.number(),
|
audioSampleRate: z.coerce.number(),
|
||||||
audioVolumePercent: z.number().default(100),
|
audioVolumePercent: z.coerce.number().default(100),
|
||||||
|
audioLoudnormConfig: LoudnormConfigSchema.optional(),
|
||||||
normalizeFrameRate: z.boolean(),
|
normalizeFrameRate: z.boolean(),
|
||||||
deinterlaceVideo: z.boolean(),
|
deinterlaceVideo: z.boolean(),
|
||||||
disableChannelOverlay: z.boolean(),
|
disableChannelOverlay: z.boolean(),
|
||||||
|
|||||||
@@ -21,9 +21,11 @@
|
|||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@hookform/error-message": "^2.0.1",
|
"@hookform/error-message": "^2.0.1",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@mui/icons-material": "^7.0.2",
|
"@mui/icons-material": "^7.0.2",
|
||||||
"@mui/material": "^7.0.2",
|
"@mui/material": "^7.0.2",
|
||||||
"@mui/x-date-pickers": "^8.4.0",
|
"@mui/x-date-pickers": "^8.4.0",
|
||||||
|
"@tanstack/react-form": "^1.28.0",
|
||||||
"@tanstack/react-query": "^5.18.1",
|
"@tanstack/react-query": "^5.18.1",
|
||||||
"@tanstack/react-query-devtools": "^5.18.1",
|
"@tanstack/react-query-devtools": "^5.18.1",
|
||||||
"@tanstack/react-router": "^1.133.13",
|
"@tanstack/react-router": "^1.133.13",
|
||||||
@@ -43,7 +45,7 @@
|
|||||||
"notistack": "^3.0.1",
|
"notistack": "^3.0.1",
|
||||||
"pluralize": "^8.0.0",
|
"pluralize": "^8.0.0",
|
||||||
"query-string": "^9.1.1",
|
"query-string": "^9.1.1",
|
||||||
"random-js": "2.1.0",
|
"random-js": "catalog:",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
@@ -62,9 +64,11 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.80.16",
|
"@hey-api/openapi-ts": "0.80.16",
|
||||||
|
"@tanstack/react-devtools": "^0.9.4",
|
||||||
|
"@tanstack/react-form-devtools": "^0.2.13",
|
||||||
|
"@tanstack/react-router-devtools": "^1.158.1",
|
||||||
"@tanstack/react-table": "8.19.3",
|
"@tanstack/react-table": "8.19.3",
|
||||||
"@tanstack/router-cli": "^1.35.4",
|
"@tanstack/router-cli": "^1.35.4",
|
||||||
"@tanstack/router-devtools": "1.133.13",
|
|
||||||
"@tanstack/router-vite-plugin": "^1.133.13",
|
"@tanstack/router-vite-plugin": "^1.133.13",
|
||||||
"@types/lodash-es": "4.17.9",
|
"@types/lodash-es": "4.17.9",
|
||||||
"@types/pluralize": "^0.0.33",
|
"@types/pluralize": "^0.0.33",
|
||||||
@@ -73,8 +77,8 @@
|
|||||||
"@types/react-transition-group": "^4.4.12",
|
"@types/react-transition-group": "^4.4.12",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@types/uuid": "^9.0.6",
|
"@types/uuid": "^9.0.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.19.0",
|
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||||
"@typescript-eslint/parser": "^8.19.0",
|
"@typescript-eslint/parser": "catalog:",
|
||||||
"@vitejs/plugin-react-swc": "^3.11.0",
|
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
|||||||
43
web/src/components/form/BasicCheckboxInput.tsx
Normal file
43
web/src/components/form/BasicCheckboxInput.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { FormControlProps, FormHelperTextProps } from '@mui/material';
|
||||||
|
import {
|
||||||
|
Checkbox,
|
||||||
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
|
FormHelperText,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { isNil } from 'lodash-es';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useFieldContext } from '../../hooks/form.ts';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: string;
|
||||||
|
formControlProps?: FormControlProps;
|
||||||
|
helperText?: ReactNode;
|
||||||
|
formHelperTextProps?: FormHelperTextProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BasicCheckboxInput({
|
||||||
|
formControlProps,
|
||||||
|
formHelperTextProps,
|
||||||
|
helperText,
|
||||||
|
label,
|
||||||
|
}: Props) {
|
||||||
|
const field = useFieldContext<boolean>();
|
||||||
|
return (
|
||||||
|
<FormControl {...formControlProps}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
value={field.state.value}
|
||||||
|
checked={field.state.value}
|
||||||
|
onChange={(_, checked) => field.handleChange(checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
{!isNil(helperText) && (
|
||||||
|
<FormHelperText {...formHelperTextProps}>{helperText}</FormHelperText>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
web/src/components/form/BasicSelectInput.tsx
Normal file
102
web/src/components/form/BasicSelectInput.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import type {
|
||||||
|
FormControlProps,
|
||||||
|
FormHelperTextProps,
|
||||||
|
SelectProps,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormHelperText,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { isNonEmptyString } from '@tunarr/shared/util';
|
||||||
|
import { identity } from 'lodash-es';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { StrictOmit } from 'ts-essentials';
|
||||||
|
import type { DropdownOption } from '../../helpers/DropdownOption';
|
||||||
|
import { useFieldContext } from '../../hooks/form.ts';
|
||||||
|
|
||||||
|
export interface Converter<In, Out> {
|
||||||
|
to: (input: In) => Out;
|
||||||
|
from: (out: Out) => In;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props<FieldTypeT, InputTypeT extends string | number> = {
|
||||||
|
options: DropdownOption<InputTypeT>[] | readonly DropdownOption<InputTypeT>[];
|
||||||
|
converter: Converter<FieldTypeT, InputTypeT>;
|
||||||
|
helperText?: string;
|
||||||
|
selectProps?: SelectProps;
|
||||||
|
formControlProps?: FormControlProps;
|
||||||
|
formHelperTextProps?: FormHelperTextProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
function identityConverter<T>(): Converter<T, T> {
|
||||||
|
return {
|
||||||
|
to: identity,
|
||||||
|
from: identity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BasicSelectInput<ValueTypeT extends string | number>(
|
||||||
|
props: StrictOmit<Props<ValueTypeT, ValueTypeT>, 'converter'>,
|
||||||
|
) {
|
||||||
|
// const field = useFieldContext<ValueTypeT>();
|
||||||
|
// return (
|
||||||
|
// <FormControl {...formControlProps}>
|
||||||
|
// {isNonEmptyString(selectProps?.label) ? (
|
||||||
|
// <InputLabel>{selectProps?.label}</InputLabel>
|
||||||
|
// ) : null}
|
||||||
|
// <Select
|
||||||
|
// {...selectProps}
|
||||||
|
// value={field.state.value}
|
||||||
|
// onChange={(e) => field.handleChange(e.target.value as ValueTypeT)}
|
||||||
|
// >
|
||||||
|
// {options.map((opt) => (
|
||||||
|
// <MenuItem key={opt.value} value={opt.value}>
|
||||||
|
// {opt.description}
|
||||||
|
// </MenuItem>
|
||||||
|
// ))}
|
||||||
|
// </Select>
|
||||||
|
// {isNonEmptyString(helperText) ? (
|
||||||
|
// <FormHelperText {...formHelperTextProps}>{helperText}</FormHelperText>
|
||||||
|
// ) : null}
|
||||||
|
// </FormControl>
|
||||||
|
// );
|
||||||
|
const converter = useMemo(() => identityConverter<ValueTypeT>(), []);
|
||||||
|
return <SelectInput {...props} converter={converter} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectInput<ValueTypeT, InputTypeT extends string | number>({
|
||||||
|
options,
|
||||||
|
selectProps,
|
||||||
|
formControlProps,
|
||||||
|
formHelperTextProps,
|
||||||
|
helperText,
|
||||||
|
converter,
|
||||||
|
}: Props<ValueTypeT, InputTypeT>) {
|
||||||
|
const field = useFieldContext<ValueTypeT>();
|
||||||
|
return (
|
||||||
|
<FormControl {...formControlProps}>
|
||||||
|
{isNonEmptyString(selectProps?.label) ? (
|
||||||
|
<InputLabel>{selectProps?.label}</InputLabel>
|
||||||
|
) : null}
|
||||||
|
<Select
|
||||||
|
{...selectProps}
|
||||||
|
value={converter.to(field.state.value)}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.handleChange(converter.from(e.target.value as InputTypeT))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.description}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{isNonEmptyString(helperText) ? (
|
||||||
|
<FormHelperText {...formHelperTextProps}>{helperText}</FormHelperText>
|
||||||
|
) : null}
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
web/src/components/form/BasicTextInput.tsx
Normal file
43
web/src/components/form/BasicTextInput.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { TextFieldProps } from '@mui/material';
|
||||||
|
import { TextField } from '@mui/material';
|
||||||
|
import type { StandardSchemaV1Issue } from '@tanstack/react-form';
|
||||||
|
import { head, isObject, isString, partition } from 'lodash-es';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useFieldContext } from '../../hooks/form.ts';
|
||||||
|
|
||||||
|
function isStandardSchemaIssue(err: unknown): err is StandardSchemaV1Issue {
|
||||||
|
return isObject(err) && 'message' in err && isString(err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BasicTextInput(props: TextFieldProps) {
|
||||||
|
const field = useFieldContext<string>();
|
||||||
|
// console.log(field.state.meta.errors);
|
||||||
|
const errors = useMemo(() => {
|
||||||
|
if (field.state.meta.errors.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [standardIssues, nonStandard] = partition(
|
||||||
|
field.state.meta.errors,
|
||||||
|
isStandardSchemaIssue,
|
||||||
|
);
|
||||||
|
|
||||||
|
const prettyErrors = standardIssues.map((issue) => issue.message);
|
||||||
|
const others = nonStandard.map((s) => JSON.stringify(s));
|
||||||
|
return head(prettyErrors.concat(others));
|
||||||
|
}, [field.state.meta.errors]);
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
{...props}
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
onBlur={() => field.handleBlur()}
|
||||||
|
error={field.state.meta.errors.length > 0}
|
||||||
|
helperText={
|
||||||
|
<>
|
||||||
|
{props.helperText}
|
||||||
|
{errors}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { TranscodeConfigSchema } from '@tunarr/types/schemas';
|
||||||
|
import type z from 'zod';
|
||||||
|
|
||||||
|
export type BaseTranscodeConfigProps = {
|
||||||
|
initialConfig: z.input<typeof TranscodeConfigSchema>;
|
||||||
|
showAdvancedSettings?: boolean;
|
||||||
|
};
|
||||||
@@ -1,16 +1,25 @@
|
|||||||
|
import { useTypedAppFormContext } from '@/hooks/form.ts';
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormHelperText,
|
||||||
|
Grid,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
|
Link,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import type { TranscodeConfig } from '@tunarr/types';
|
import {
|
||||||
import type { SupportedTranscodeAudioOutputFormats } from '@tunarr/types/schemas';
|
LoudnormConfigSchema,
|
||||||
import { Controller, useFormContext } from 'react-hook-form';
|
type SupportedTranscodeAudioOutputFormats,
|
||||||
|
} from '@tunarr/types/schemas';
|
||||||
|
import { isNil } from 'lodash-es';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
import type { DropdownOption } from '../../../helpers/DropdownOption';
|
import type { DropdownOption } from '../../../helpers/DropdownOption';
|
||||||
import { NumericFormControllerText } from '../../util/TypedController.tsx';
|
import type { BaseTranscodeConfigProps } from './BaseTranscodeConfigProps.ts';
|
||||||
|
import { useBaseTranscodeConfigFormOptions } from './useTranscodeConfigFormOptions.ts';
|
||||||
|
|
||||||
const AudioFormats: DropdownOption<SupportedTranscodeAudioOutputFormats>[] = [
|
const AudioFormats: DropdownOption<SupportedTranscodeAudioOutputFormats>[] = [
|
||||||
{
|
{
|
||||||
@@ -31,120 +40,313 @@ const AudioFormats: DropdownOption<SupportedTranscodeAudioOutputFormats>[] = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const TranscodeConfigAudioSettingsForm = () => {
|
export const TranscodeConfigAudioSettingsForm = ({
|
||||||
const { control, watch } = useFormContext<TranscodeConfig>();
|
initialConfig,
|
||||||
const encoder = watch('audioFormat');
|
showAdvancedSettings,
|
||||||
|
}: BaseTranscodeConfigProps) => {
|
||||||
|
const formOpts = useBaseTranscodeConfigFormOptions(initialConfig);
|
||||||
|
const form = useTypedAppFormContext({ ...formOpts });
|
||||||
|
const [loudnormEnabled, setLoudnormEnabled] = useState(
|
||||||
|
!isNil(form.getFieldValue('audioLoudnormConfig')),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onLoudnormChange = useCallback(
|
||||||
|
(enabled: boolean) => {
|
||||||
|
setLoudnormEnabled(enabled);
|
||||||
|
if (enabled) {
|
||||||
|
form.setFieldValue(
|
||||||
|
'audioLoudnormConfig',
|
||||||
|
LoudnormConfigSchema.decode({}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
form.setFieldValue('audioLoudnormConfig', undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={2}>
|
<Stack spacing={2}>
|
||||||
<FormControl fullWidth>
|
<Grid container spacing={2}>
|
||||||
<InputLabel>Audio Format</InputLabel>
|
<Grid size={{ xs: 12 }}>
|
||||||
<Controller
|
<form.AppField
|
||||||
control={control}
|
name="audioFormat"
|
||||||
name="audioFormat"
|
children={(field) => (
|
||||||
render={({ field }) => (
|
<field.BasicSelectInput
|
||||||
<Select<SupportedTranscodeAudioOutputFormats>
|
formControlProps={{ fullWidth: true }}
|
||||||
label="Audio Format"
|
selectProps={{ label: 'Audio Format' }}
|
||||||
{...field}
|
options={AudioFormats}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<form.Subscribe
|
||||||
|
selector={(s) => s.values.audioFormat}
|
||||||
|
children={(encoder) => (
|
||||||
|
<form.AppField
|
||||||
|
name="audioBitRate"
|
||||||
|
children={(field) => (
|
||||||
|
<field.BasicTextInput
|
||||||
|
fullWidth
|
||||||
|
label="Audio Bitrate"
|
||||||
|
disabled={encoder === 'copy'}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">kbps</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
helperText={
|
||||||
|
encoder === 'copy'
|
||||||
|
? 'Bitrate cannot be changed when copying input audio'
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<form.Subscribe
|
||||||
|
selector={(s) => s.values.audioFormat}
|
||||||
|
children={(encoder) => (
|
||||||
|
<form.AppField
|
||||||
|
name="audioBufferSize"
|
||||||
|
children={(field) => (
|
||||||
|
<field.BasicTextInput
|
||||||
|
fullWidth
|
||||||
|
label="Audio Buffer Size"
|
||||||
|
disabled={encoder === 'copy'}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">kb</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
helperText={
|
||||||
|
encoder === 'copy'
|
||||||
|
? 'Buffer size cannot be changed when copying input audio'
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* <NumericFormControllerText
|
||||||
|
control={control}
|
||||||
|
name="audioBufferSize"
|
||||||
|
prettyFieldName="Audio Buffer Size"
|
||||||
|
TextFieldProps={{
|
||||||
|
id: 'audio-buffer-size',
|
||||||
|
label: 'Audio Buffer Size',
|
||||||
|
fullWidth: true,
|
||||||
|
disabled: encoder === 'copy',
|
||||||
|
helperText:
|
||||||
|
encoder === 'copy'
|
||||||
|
? 'Buffer size cannot be changed when copying input audio'
|
||||||
|
: null,
|
||||||
|
InputProps: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">kb</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/> */}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<form.Subscribe
|
||||||
|
selector={(s) => s.values.audioFormat}
|
||||||
|
children={(encoder) => (
|
||||||
|
<form.AppField
|
||||||
|
name="audioSampleRate"
|
||||||
|
children={(field) => (
|
||||||
|
<field.BasicTextInput
|
||||||
|
fullWidth
|
||||||
|
label="Audio Sample Rate"
|
||||||
|
disabled={encoder === 'copy'}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">kHz</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
helperText={
|
||||||
|
encoder === 'copy'
|
||||||
|
? 'Sample rate cannot be changed when copying input audio'
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* <NumericFormControllerText
|
||||||
|
control={control}
|
||||||
|
name="audioSampleRate"
|
||||||
|
prettyFieldName="Audio Sample Rate"
|
||||||
|
TextFieldProps={{
|
||||||
|
id: 'audio-sample-rate',
|
||||||
|
label: 'Audio Sample Rate',
|
||||||
|
fullWidth: true,
|
||||||
|
disabled: encoder === 'copy',
|
||||||
|
helperText:
|
||||||
|
encoder === 'copy'
|
||||||
|
? 'Sample rate cannot be changed when copying input audio'
|
||||||
|
: null,
|
||||||
|
InputProps: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">kHz</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/> */}
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
{/* <NumericFormControllerText
|
||||||
|
control={control}
|
||||||
|
name="audioChannels"
|
||||||
|
prettyFieldName="Audio Channels"
|
||||||
|
TextFieldProps={{
|
||||||
|
id: 'audio-bitrate',
|
||||||
|
label: 'Audio Channels',
|
||||||
|
fullWidth: true,
|
||||||
|
}}
|
||||||
|
/> */}
|
||||||
|
<form.AppField
|
||||||
|
name="audioChannels"
|
||||||
|
children={(field) => (
|
||||||
|
<field.BasicTextInput fullWidth label="Audio Channels" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
{/* <NumericFormControllerText
|
||||||
|
control={control}
|
||||||
|
name="audioVolumePercent"
|
||||||
|
prettyFieldName="Audio Volume Percent"
|
||||||
|
TextFieldProps={{
|
||||||
|
id: 'audio-volume',
|
||||||
|
label: 'Audio Volume',
|
||||||
|
fullWidth: true,
|
||||||
|
helperText:
|
||||||
|
'Adjust the output volume (not recommended). Values higher than 100 will boost the audio.',
|
||||||
|
InputProps: {
|
||||||
|
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/> */}
|
||||||
|
<form.AppField
|
||||||
|
name="audioVolumePercent"
|
||||||
|
children={(field) => (
|
||||||
|
<field.BasicTextInput
|
||||||
|
fullWidth
|
||||||
|
label="Audio Volume"
|
||||||
|
helperText={
|
||||||
|
'Adjust the output volume (not recommended). Values higher than 100 will boost the audio.'
|
||||||
|
}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">%</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Audio Loudness Normalization</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Audio Loudness Normalization"
|
||||||
|
value={loudnormEnabled ? 'enabled' : 'disabled'}
|
||||||
|
onChange={() => onLoudnormChange(!loudnormEnabled)}
|
||||||
>
|
>
|
||||||
{AudioFormats.map((opt) => (
|
<MenuItem value={'disabled'}>Disabled</MenuItem>
|
||||||
<MenuItem key={opt.value} value={opt.value}>
|
<MenuItem value={'enabled'}>Enabled (loudnorm)</MenuItem>
|
||||||
{opt.description}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
<FormHelperText>
|
||||||
/>
|
Enable{' '}
|
||||||
</FormControl>
|
<Link
|
||||||
|
href="https://en.wikipedia.org/wiki/EBU_R_128"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
EBU R 128
|
||||||
|
</Link>{' '}
|
||||||
|
loudness normalization via the <code>loudnorm</code> FFmpeg
|
||||||
|
filter. May increase CPU usage during streaming.
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
{showAdvancedSettings && (
|
||||||
|
<form.Subscribe
|
||||||
|
children={(state) => {
|
||||||
|
const hasOneAdvancedSetting = !!state.values.audioLoudnormConfig;
|
||||||
|
if (!hasOneAdvancedSetting) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
<Stack direction={{ sm: 'column', md: 'row' }} gap={2} useFlexGap>
|
return (
|
||||||
<NumericFormControllerText
|
<Stack>
|
||||||
control={control}
|
<Typography component="h6" variant="h6" mb={1}>
|
||||||
name="audioBitRate"
|
Advanced Video Options
|
||||||
prettyFieldName="Audio Bitrate"
|
</Typography>
|
||||||
TextFieldProps={{
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||||
id: 'audio-bitrate',
|
Advanced options relating to audio. In general, do not change
|
||||||
label: 'Audio Bitrate',
|
these unless you know what you are doing!
|
||||||
fullWidth: true,
|
</Typography>
|
||||||
disabled: encoder === 'copy',
|
{!!state.values.audioLoudnormConfig && (
|
||||||
helperText:
|
<>
|
||||||
encoder === 'copy'
|
<Typography sx={{ mb: 1 }}>Loudnorm Options</Typography>
|
||||||
? 'Bitrate cannot be changed when copying input audio'
|
<Stack direction={{ sm: 'column', md: 'row' }} spacing={2}>
|
||||||
: null,
|
<form.AppField
|
||||||
InputProps: {
|
name="audioLoudnormConfig.i"
|
||||||
endAdornment: (
|
children={(field) => (
|
||||||
<InputAdornment position="end">kbps</InputAdornment>
|
<field.BasicTextInput
|
||||||
),
|
fullWidth
|
||||||
},
|
label="Loudness Target"
|
||||||
|
helperText="[-70.0, -5.0]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<form.AppField
|
||||||
|
name="audioLoudnormConfig.lra"
|
||||||
|
children={(field) => (
|
||||||
|
<field.BasicTextInput
|
||||||
|
fullWidth
|
||||||
|
label="Loudness Range Target"
|
||||||
|
helperText="[1.0, 50.0]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<form.AppField
|
||||||
|
name="audioLoudnormConfig.tp"
|
||||||
|
children={(field) => (
|
||||||
|
<field.BasicTextInput
|
||||||
|
fullWidth
|
||||||
|
label="Max True Peak"
|
||||||
|
helperText="[-9.0, 0.0]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NumericFormControllerText
|
)}
|
||||||
control={control}
|
|
||||||
name="audioBufferSize"
|
|
||||||
prettyFieldName="Audio Buffer Size"
|
|
||||||
TextFieldProps={{
|
|
||||||
id: 'audio-buffer-size',
|
|
||||||
label: 'Audio Buffer Size',
|
|
||||||
fullWidth: true,
|
|
||||||
disabled: encoder === 'copy',
|
|
||||||
helperText:
|
|
||||||
encoder === 'copy'
|
|
||||||
? 'Buffer size cannot be changed when copying input audio'
|
|
||||||
: null,
|
|
||||||
InputProps: {
|
|
||||||
endAdornment: <InputAdornment position="end">kb</InputAdornment>,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction={{ sm: 'column', md: 'row' }} gap={2} useFlexGap>
|
|
||||||
<NumericFormControllerText
|
|
||||||
control={control}
|
|
||||||
name="audioVolumePercent"
|
|
||||||
prettyFieldName="Audio Volume Percent"
|
|
||||||
TextFieldProps={{
|
|
||||||
id: 'audio-volume',
|
|
||||||
label: 'Audio Volume',
|
|
||||||
fullWidth: true,
|
|
||||||
sx: { my: 1 },
|
|
||||||
helperText: 'Values higher than 100 will boost the audio.',
|
|
||||||
InputProps: {
|
|
||||||
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<NumericFormControllerText
|
|
||||||
control={control}
|
|
||||||
name="audioChannels"
|
|
||||||
prettyFieldName="Audio Channels"
|
|
||||||
TextFieldProps={{
|
|
||||||
id: 'audio-bitrate',
|
|
||||||
label: 'Audio Channels',
|
|
||||||
fullWidth: true,
|
|
||||||
sx: { my: 1 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<NumericFormControllerText
|
|
||||||
control={control}
|
|
||||||
name="audioSampleRate"
|
|
||||||
prettyFieldName="Audio Sample Rate"
|
|
||||||
TextFieldProps={{
|
|
||||||
id: 'audio-sample-rate',
|
|
||||||
label: 'Audio Sample Rate',
|
|
||||||
fullWidth: true,
|
|
||||||
disabled: encoder === 'copy',
|
|
||||||
helperText:
|
|
||||||
encoder === 'copy'
|
|
||||||
? 'Sample rate cannot be changed when copying input audio'
|
|
||||||
: null,
|
|
||||||
sx: { my: 1 },
|
|
||||||
InputProps: {
|
|
||||||
endAdornment: <InputAdornment position="end">kHz</InputAdornment>,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,98 +1,69 @@
|
|||||||
import {
|
import { Grid } from '@mui/material';
|
||||||
FormControl,
|
import type { DropdownOption } from '../../../helpers/DropdownOption';
|
||||||
FormHelperText,
|
import { useTypedAppFormContext } from '../../../hooks/form.ts';
|
||||||
Grid,
|
import type { BaseTranscodeConfigProps } from './BaseTranscodeConfigProps.ts';
|
||||||
InputLabel,
|
import { useBaseTranscodeConfigFormOptions } from './useTranscodeConfigFormOptions.ts';
|
||||||
MenuItem,
|
|
||||||
Select,
|
|
||||||
} from '@mui/material';
|
|
||||||
import type { TranscodeConfig } from '@tunarr/types';
|
|
||||||
import { Controller, useFormContext } from 'react-hook-form';
|
|
||||||
|
|
||||||
const supportedErrorScreens = [
|
const supportedErrorScreens = [
|
||||||
{
|
{
|
||||||
value: 'pic',
|
value: 'pic',
|
||||||
string: 'Default Generic Error Image',
|
description: 'Default Generic Error Image',
|
||||||
},
|
},
|
||||||
{ value: 'blank', string: 'Blank Screen' },
|
{ value: 'blank', description: 'Blank Screen' },
|
||||||
{ value: 'static', string: 'Static' },
|
{ value: 'static', description: 'Static' },
|
||||||
{
|
{
|
||||||
value: 'testsrc',
|
value: 'testsrc',
|
||||||
string: 'Test Pattern (color bars + timer)',
|
description: 'Test Pattern (color bars + timer)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'text',
|
value: 'text',
|
||||||
string: 'Detailed error (requires ffmpeg with drawtext)',
|
description: 'Detailed error (requires ffmpeg with drawtext)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'kill',
|
value: 'kill',
|
||||||
string: 'Stop stream, show errors in logs',
|
description: 'Stop stream, show errors in logs',
|
||||||
},
|
},
|
||||||
];
|
] satisfies DropdownOption<string>[];
|
||||||
|
|
||||||
const supportedErrorAudio = [
|
const supportedErrorAudio = [
|
||||||
{ value: 'whitenoise', string: 'White Noise' },
|
{ value: 'whitenoise', description: 'White Noise' },
|
||||||
{ value: 'sine', string: 'Beep' },
|
{ value: 'sine', description: 'Beep' },
|
||||||
{ value: 'silent', string: 'No Audio' },
|
{ value: 'silent', description: 'No Audio' },
|
||||||
];
|
] satisfies DropdownOption<string>[];
|
||||||
|
|
||||||
export const TranscodeConfigErrorOptions = () => {
|
|
||||||
const { control } = useFormContext<TranscodeConfig>();
|
|
||||||
|
|
||||||
|
export const TranscodeConfigErrorOptions = ({
|
||||||
|
initialConfig,
|
||||||
|
}: BaseTranscodeConfigProps) => {
|
||||||
|
const formOpts = useBaseTranscodeConfigFormOptions(initialConfig);
|
||||||
|
const form = useTypedAppFormContext({ ...formOpts });
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid size={{ sm: 12, md: 6 }}>
|
<Grid size={{ sm: 12, md: 6 }}>
|
||||||
<FormControl sx={{ mt: 2 }}>
|
<form.AppField
|
||||||
<InputLabel id="error-screen-label">Error Screen</InputLabel>
|
name="errorScreen"
|
||||||
<Controller
|
children={(field) => (
|
||||||
control={control}
|
<field.BasicSelectInput
|
||||||
name="errorScreen"
|
formControlProps={{ sx: { mt: 2 }, fullWidth: true }}
|
||||||
render={({ field }) => (
|
options={supportedErrorScreens}
|
||||||
<Select
|
selectProps={{ label: 'Error Screen' }}
|
||||||
labelId="error-screen-label"
|
helperText="If there are issues playing a video, Tunarr will try to use an error
|
||||||
id="error-screen"
|
|
||||||
label="Error Screen"
|
|
||||||
{...field}
|
|
||||||
>
|
|
||||||
{supportedErrorScreens.map((error) => (
|
|
||||||
<MenuItem key={error.value} value={error.value}>
|
|
||||||
{error.string}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormHelperText>
|
|
||||||
If there are issues playing a video, Tunarr will try to use an error
|
|
||||||
screen as a placeholder while retrying loading the video every 60
|
screen as a placeholder while retrying loading the video every 60
|
||||||
seconds.
|
seconds."
|
||||||
</FormHelperText>
|
/>
|
||||||
</FormControl>
|
)}
|
||||||
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ sm: 12, md: 6 }}>
|
<Grid size={{ sm: 12, md: 6 }}>
|
||||||
<FormControl sx={{ mt: 2 }} fullWidth>
|
<form.AppField
|
||||||
<InputLabel id="error-audio-label">Error Audio</InputLabel>
|
name="errorScreenAudio"
|
||||||
<Controller
|
children={(field) => (
|
||||||
control={control}
|
<field.BasicSelectInput
|
||||||
name="errorScreenAudio"
|
formControlProps={{ sx: { mt: 2 }, fullWidth: true }}
|
||||||
render={({ field }) => (
|
options={supportedErrorAudio}
|
||||||
<Select
|
selectProps={{ label: 'Error Audio', fullWidth: true }}
|
||||||
labelId="error-audio-label"
|
/>
|
||||||
id="error-screen"
|
)}
|
||||||
label="Error Audio"
|
/>
|
||||||
fullWidth
|
|
||||||
{...field}
|
|
||||||
>
|
|
||||||
{supportedErrorAudio.map((error) => (
|
|
||||||
<MenuItem key={error.value} value={error.value}>
|
|
||||||
{error.string}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import {
|
import { useAppForm } from '@/hooks/form.ts';
|
||||||
CheckboxFormController,
|
import { Check } from '@mui/icons-material';
|
||||||
NumericFormControllerText,
|
|
||||||
} from '@/components/util/TypedController';
|
|
||||||
import { isNonEmptyString } from '@/helpers/util';
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Divider,
|
Divider,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
@@ -14,83 +12,83 @@ import {
|
|||||||
Link as MuiLink,
|
Link as MuiLink,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
|
ToggleButton,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import type { TranscodeConfig } from '@tunarr/types';
|
import type { TranscodeConfig } from '@tunarr/types';
|
||||||
import { useSnackbar } from 'notistack';
|
import type { TranscodeConfigSchema } from '@tunarr/types/schemas';
|
||||||
import type { FieldErrors } from 'react-hook-form';
|
import type z from 'zod';
|
||||||
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
import useStore from '../../../store/index.ts';
|
||||||
|
import { setShowAdvancedSettings } from '../../../store/settings/actions.ts';
|
||||||
import Breadcrumbs from '../../Breadcrumbs.tsx';
|
import Breadcrumbs from '../../Breadcrumbs.tsx';
|
||||||
import { TranscodeConfigAdvancedOptions } from './TranscodeConfigAdvancedOptions.tsx';
|
import { TranscodeConfigAdvancedOptions } from './TranscodeConfigAdvancedOptions.tsx';
|
||||||
import { TranscodeConfigAudioSettingsForm } from './TranscodeConfigAudioSettingsForm.tsx';
|
import { TranscodeConfigAudioSettingsForm } from './TranscodeConfigAudioSettingsForm.tsx';
|
||||||
import { TranscodeConfigErrorOptions } from './TranscodeConfigErrorOptions.tsx';
|
import { TranscodeConfigErrorOptions } from './TranscodeConfigErrorOptions.tsx';
|
||||||
import { TranscodeConfigVideoSettingsForm } from './TranscodeConfigVideoSettingsForm.tsx';
|
import { TranscodeConfigVideoSettingsForm } from './TranscodeConfigVideoSettingsForm.tsx';
|
||||||
|
import { useTranscodeConfigFormOptions } from './useTranscodeConfigFormOptions.ts';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSave: (config: TranscodeConfig) => Promise<TranscodeConfig>;
|
initialConfig: z.input<typeof TranscodeConfigSchema>;
|
||||||
initialConfig: TranscodeConfig;
|
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TranscodeConfigSettingsForm = ({
|
export const TranscodeConfigSettingsForm = ({
|
||||||
onSave,
|
|
||||||
initialConfig,
|
initialConfig,
|
||||||
isNew,
|
isNew,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const snackbar = useSnackbar();
|
const showAdvancedSettings = useStore(
|
||||||
|
(s) => s.settings.ui.showAdvancedSettings,
|
||||||
|
);
|
||||||
|
|
||||||
const transcodeConfigForm = useForm<TranscodeConfig>({
|
const saveForm = (newConfig: TranscodeConfig) => {
|
||||||
defaultValues: initialConfig,
|
transcodeConfigForm.reset(newConfig, { keepDefaultValues: true });
|
||||||
mode: 'onChange',
|
};
|
||||||
|
|
||||||
|
const formOpts = useTranscodeConfigFormOptions({
|
||||||
|
initialConfig,
|
||||||
|
isNew,
|
||||||
|
onSave: saveForm,
|
||||||
});
|
});
|
||||||
const {
|
const transcodeConfigForm = useAppForm({ ...formOpts });
|
||||||
control,
|
|
||||||
reset,
|
|
||||||
formState: { isSubmitting, isValid, isDirty },
|
|
||||||
handleSubmit,
|
|
||||||
watch,
|
|
||||||
} = transcodeConfigForm;
|
|
||||||
|
|
||||||
const hardwareAccelerationMode = watch('hardwareAccelerationMode');
|
|
||||||
|
|
||||||
const saveForm = async (data: TranscodeConfig) => {
|
|
||||||
try {
|
|
||||||
const newConfig = await onSave(data);
|
|
||||||
reset(newConfig);
|
|
||||||
snackbar.enqueueSnackbar('Successfully saved config!', {
|
|
||||||
variant: 'success',
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
snackbar.enqueueSnackbar(
|
|
||||||
'Error while saving transcode config. See console log for details.',
|
|
||||||
{
|
|
||||||
variant: 'error',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmitError = (errors: FieldErrors<TranscodeConfig>) => {
|
|
||||||
console.error(errors);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box component="form" onSubmit={handleSubmit(saveForm, handleSubmitError)}>
|
<Box
|
||||||
|
component="form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
transcodeConfigForm.handleSubmit().catch(console.error);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Breadcrumbs />
|
<Breadcrumbs />
|
||||||
<FormProvider {...transcodeConfigForm}>
|
<transcodeConfigForm.AppForm>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2} divider={<Divider />}>
|
||||||
<Typography variant="h5">
|
<Stack direction={'row'}>
|
||||||
Edit Config: "{initialConfig.name}"
|
<Typography variant="h4">
|
||||||
</Typography>
|
Edit Transcode Config: "{initialConfig.name}"
|
||||||
<Divider />
|
</Typography>
|
||||||
|
<ToggleButton
|
||||||
|
value={showAdvancedSettings}
|
||||||
|
selected={showAdvancedSettings}
|
||||||
|
onChange={() => setShowAdvancedSettings(!showAdvancedSettings)}
|
||||||
|
sx={{ ml: 'auto' }}
|
||||||
|
>
|
||||||
|
<Check sx={{ mr: 0.5 }} /> Show Advanced
|
||||||
|
</ToggleButton>
|
||||||
|
</Stack>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
<Typography variant="h5" sx={{ mb: 2 }}>
|
||||||
General
|
General
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container columnSpacing={2}>
|
<Grid container columnSpacing={2}>
|
||||||
<Grid size={{ sm: 12, md: 6 }}>
|
<Grid size={{ sm: 12, md: 6 }}>
|
||||||
<Controller
|
<transcodeConfigForm.AppField
|
||||||
|
name="name"
|
||||||
|
children={(field) => (
|
||||||
|
<field.BasicTextInput fullWidth label="Name" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* <Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="name"
|
name="name"
|
||||||
rules={{
|
rules={{
|
||||||
@@ -114,10 +112,37 @@ export const TranscodeConfigSettingsForm = ({
|
|||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/> */}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ sm: 12, md: 6 }}>
|
<Grid size={{ sm: 12, md: 6 }}>
|
||||||
<NumericFormControllerText
|
<transcodeConfigForm.Field
|
||||||
|
name="threadCount"
|
||||||
|
children={(field) => (
|
||||||
|
<TextField
|
||||||
|
label="Threads"
|
||||||
|
fullWidth
|
||||||
|
helperText={
|
||||||
|
<>
|
||||||
|
Sets the number of threads used to decode the input
|
||||||
|
stream. Set to 0 to let ffmpeg automatically decide
|
||||||
|
how many threads to use. Read more about this option{' '}
|
||||||
|
<MuiLink
|
||||||
|
target="_blank"
|
||||||
|
href="https://ffmpeg.org/ffmpeg-codecs.html#:~:text=threads%20integer%20(decoding/encoding%2Cvideo)"
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</MuiLink>
|
||||||
|
. <strong>Note: </strong> this option is overridden to
|
||||||
|
1 when using hardware accelearation for stability
|
||||||
|
reasons.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* <NumericFormControllerText
|
||||||
control={control}
|
control={control}
|
||||||
name="threadCount"
|
name="threadCount"
|
||||||
prettyFieldName="Threads"
|
prettyFieldName="Threads"
|
||||||
@@ -135,19 +160,35 @@ export const TranscodeConfigSettingsForm = ({
|
|||||||
>
|
>
|
||||||
here
|
here
|
||||||
</MuiLink>
|
</MuiLink>
|
||||||
|
. <strong>Note: </strong> this option is overridden to 1
|
||||||
|
when using hardware accelearation for stability reasons.
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/> */}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={12}>
|
<Grid size={12}>
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<CheckboxFormController
|
<transcodeConfigForm.Field
|
||||||
control={control}
|
|
||||||
name="disableChannelOverlay"
|
name="disableChannelOverlay"
|
||||||
|
children={(field) => (
|
||||||
|
<Checkbox
|
||||||
|
// {...field}
|
||||||
|
value={field.state.value}
|
||||||
|
checked={field.state.value}
|
||||||
|
onChange={(_, checked) =>
|
||||||
|
field.handleChange(checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
// <CheckboxFormController
|
||||||
|
// control={control}
|
||||||
|
// name="disableChannelOverlay"
|
||||||
|
// />
|
||||||
}
|
}
|
||||||
label={'Disable Watermarks'}
|
label={'Disable Watermarks'}
|
||||||
/>
|
/>
|
||||||
@@ -159,72 +200,78 @@ export const TranscodeConfigSettingsForm = ({
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
<Box>
|
||||||
<Grid size={{ sm: 12, md: 6 }}>
|
<Typography component="h5" variant="h5" sx={{ mb: 2 }}>
|
||||||
<Typography component="h6" variant="h6" sx={{ mb: 2 }}>
|
Video Options
|
||||||
Video Options
|
</Typography>
|
||||||
</Typography>
|
<TranscodeConfigVideoSettingsForm initialConfig={initialConfig} />
|
||||||
<TranscodeConfigVideoSettingsForm />
|
<transcodeConfigForm.Subscribe
|
||||||
</Grid>
|
selector={(s) => s.values.hardwareAccelerationMode}
|
||||||
<Grid size={{ sm: 12, md: 6 }}>
|
children={(hardwareAccelerationMode) =>
|
||||||
<Typography component="h6" variant="h6" sx={{ mb: 2 }}>
|
showAdvancedSettings &&
|
||||||
Audio Options
|
hardwareAccelerationMode !== 'none' && (
|
||||||
</Typography>
|
<Box>
|
||||||
<TranscodeConfigAudioSettingsForm />
|
<Typography component="h6" variant="h6" mb={1}>
|
||||||
</Grid>
|
Advanced Video Options
|
||||||
<Grid size={12} sx={{ mt: 2 }}>
|
</Typography>
|
||||||
<Divider />
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||||
</Grid>
|
Advanced options relating to transcoding. In general, do
|
||||||
{hardwareAccelerationMode !== 'none' && (
|
not change these unless you know what you are doing! These
|
||||||
<>
|
settings exist in order to leave some parity with the old
|
||||||
<Grid size={{ sm: 12 }}>
|
dizqueTV transcode pipeline as well as to provide
|
||||||
<Typography component="h6" variant="h6" mb={1}>
|
mechanisms to aid in debugging streaming issues.
|
||||||
Advanced Options
|
</Typography>
|
||||||
</Typography>
|
<TranscodeConfigAdvancedOptions />
|
||||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
</Box>
|
||||||
Advanced options relating to transcoding. In general, do not
|
)
|
||||||
change these unless you know what you are doing! These
|
}
|
||||||
settings exist in order to leave some parity with the old
|
/>
|
||||||
dizqueTV transcode pipeline as well as to provide mechanisms
|
</Box>
|
||||||
to aid in debugging streaming issues.
|
<Box>
|
||||||
</Typography>
|
<Typography component="h5" variant="h5" sx={{ mb: 2 }}>
|
||||||
<TranscodeConfigAdvancedOptions />
|
Audio Options
|
||||||
</Grid>
|
</Typography>
|
||||||
<Grid size={12} sx={{ mt: 2 }}>
|
<TranscodeConfigAudioSettingsForm
|
||||||
<Divider />
|
initialConfig={initialConfig}
|
||||||
</Grid>
|
showAdvancedSettings={showAdvancedSettings}
|
||||||
</>
|
/>
|
||||||
)}
|
</Box>
|
||||||
<Grid size={12}>
|
<Box>
|
||||||
<Typography component="h6" variant="h6" sx={{ pt: 2, pb: 1 }}>
|
<Typography component="h6" variant="h6" sx={{ pb: 1 }}>
|
||||||
Error Options
|
Error Options
|
||||||
</Typography>
|
</Typography>
|
||||||
<TranscodeConfigErrorOptions />
|
<TranscodeConfigErrorOptions initialConfig={initialConfig} />
|
||||||
</Grid>
|
</Box>
|
||||||
</Grid>
|
|
||||||
<Stack spacing={2} direction="row" justifyContent="right">
|
<Stack spacing={2} direction="row" justifyContent="right">
|
||||||
{(isDirty || (isDirty && !isSubmitting)) && (
|
<transcodeConfigForm.Subscribe
|
||||||
<Button
|
selector={(state) => state}
|
||||||
variant="outlined"
|
children={({ isPristine, canSubmit, isSubmitting }) => (
|
||||||
onClick={() => {
|
<>
|
||||||
reset();
|
{!isPristine ? (
|
||||||
}}
|
<Button
|
||||||
>
|
variant="outlined"
|
||||||
Reset Changes
|
onClick={() => {
|
||||||
</Button>
|
transcodeConfigForm.reset();
|
||||||
)}
|
}}
|
||||||
<Button
|
>
|
||||||
variant="contained"
|
Reset Changes
|
||||||
disabled={!isValid || isSubmitting || (!isDirty && !isNew)}
|
</Button>
|
||||||
type="submit"
|
) : null}
|
||||||
>
|
<Button
|
||||||
Save
|
variant="contained"
|
||||||
</Button>
|
disabled={!canSubmit}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</FormProvider>
|
</transcodeConfigForm.AppForm>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,13 @@
|
|||||||
import type { SelectChangeEvent } from '@mui/material';
|
import { useTypedAppFormContext } from '@/hooks/form.ts';
|
||||||
import {
|
import { InputAdornment, Link as MuiLink, Stack } from '@mui/material';
|
||||||
FormControl,
|
import { useStore } from '@tanstack/react-form';
|
||||||
FormControlLabel,
|
|
||||||
FormHelperText,
|
|
||||||
InputAdornment,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Link as MuiLink,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
} from '@mui/material';
|
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import type {
|
import type {
|
||||||
|
Resolution,
|
||||||
SupportedTranscodeVideoOutputFormat,
|
SupportedTranscodeVideoOutputFormat,
|
||||||
TranscodeConfig,
|
|
||||||
} from '@tunarr/types';
|
} from '@tunarr/types';
|
||||||
import type { SupportedHardwareAccels } from '@tunarr/types/schemas';
|
import type { SupportedHardwareAccels } from '@tunarr/types/schemas';
|
||||||
import { Controller, useFormContext } from 'react-hook-form';
|
import { useMemo } from 'react';
|
||||||
import { getApiFfmpegInfoOptions } from '../../../generated/@tanstack/react-query.gen.ts';
|
import { getApiFfmpegInfoOptions } from '../../../generated/@tanstack/react-query.gen.ts';
|
||||||
import { TranscodeResolutionOptions } from '../../../helpers/constants.ts';
|
import { TranscodeResolutionOptions } from '../../../helpers/constants.ts';
|
||||||
import type { DropdownOption } from '../../../helpers/DropdownOption';
|
import type { DropdownOption } from '../../../helpers/DropdownOption';
|
||||||
@@ -25,11 +15,10 @@ import {
|
|||||||
resolutionFromAnyString,
|
resolutionFromAnyString,
|
||||||
resolutionToString,
|
resolutionToString,
|
||||||
} from '../../../helpers/util.ts';
|
} from '../../../helpers/util.ts';
|
||||||
import {
|
|
||||||
CheckboxFormController,
|
import type { Converter } from '../../form/BasicSelectInput.tsx';
|
||||||
NumericFormControllerText,
|
import type { BaseTranscodeConfigProps } from './BaseTranscodeConfigProps.ts';
|
||||||
TypedController,
|
import { useBaseTranscodeConfigFormOptions } from './useTranscodeConfigFormOptions.ts';
|
||||||
} from '../../util/TypedController.tsx';
|
|
||||||
|
|
||||||
const VideoFormats: DropdownOption<SupportedTranscodeVideoOutputFormat>[] = [
|
const VideoFormats: DropdownOption<SupportedTranscodeVideoOutputFormat>[] = [
|
||||||
{
|
{
|
||||||
@@ -70,35 +59,55 @@ const VideoHardwareAccelerationOptions: DropdownOption<SupportedHardwareAccels>[
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const TranscodeConfigVideoSettingsForm = () => {
|
const resolutionConverter: Converter<Resolution, string> = {
|
||||||
|
to: (res) => resolutionToString(res),
|
||||||
|
from: (str) => resolutionFromAnyString(str)!,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TranscodeConfigVideoSettingsForm = ({
|
||||||
|
initialConfig,
|
||||||
|
}: BaseTranscodeConfigProps) => {
|
||||||
const ffmpegInfo = useSuspenseQuery({
|
const ffmpegInfo = useSuspenseQuery({
|
||||||
...getApiFfmpegInfoOptions(),
|
...getApiFfmpegInfoOptions(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { control, watch } = useFormContext<TranscodeConfig>();
|
const formOpts = useBaseTranscodeConfigFormOptions(initialConfig);
|
||||||
|
const form = useTypedAppFormContext({ ...formOpts });
|
||||||
|
|
||||||
const hardwareAccelerationMode = watch('hardwareAccelerationMode');
|
const hardwareAccelerationMode = useStore(
|
||||||
|
form.store,
|
||||||
|
(state) => state.values.hardwareAccelerationMode,
|
||||||
|
); // watch('hardwareAccelerationMode');
|
||||||
|
|
||||||
|
const hardwareAccelerationOptions = useMemo(() => {
|
||||||
|
return VideoHardwareAccelerationOptions.filter(
|
||||||
|
({ value }) =>
|
||||||
|
value === 'none' ||
|
||||||
|
ffmpegInfo.data.hardwareAccelerationTypes.includes(value),
|
||||||
|
);
|
||||||
|
}, [ffmpegInfo.data.hardwareAccelerationTypes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={2}>
|
<Stack spacing={2}>
|
||||||
<FormControl fullWidth>
|
<form.AppField
|
||||||
<InputLabel>Video Format</InputLabel>
|
name="videoFormat"
|
||||||
<Controller
|
children={(field) => (
|
||||||
control={control}
|
<field.BasicSelectInput
|
||||||
name="videoFormat"
|
selectProps={{ label: 'Video Format' }}
|
||||||
render={({ field }) => (
|
options={VideoFormats}
|
||||||
<Select label="Video Format" {...field}>
|
/>
|
||||||
{VideoFormats.map((opt) => (
|
)}
|
||||||
<MenuItem key={opt.value} value={opt.value}>
|
/>
|
||||||
{opt.description}
|
<form.AppField
|
||||||
</MenuItem>
|
name="hardwareAccelerationMode"
|
||||||
))}
|
children={(field) => (
|
||||||
</Select>
|
<field.BasicSelectInput
|
||||||
)}
|
options={hardwareAccelerationOptions}
|
||||||
/>
|
selectProps={{ label: 'Hardware Acceleration' }}
|
||||||
<FormHelperText></FormHelperText>
|
/>
|
||||||
</FormControl>
|
)}
|
||||||
<FormControl fullWidth>
|
/>
|
||||||
|
{/* <FormControl fullWidth>
|
||||||
<InputLabel>Hardware Acceleration</InputLabel>
|
<InputLabel>Hardware Acceleration</InputLabel>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
@@ -118,9 +127,33 @@ export const TranscodeConfigVideoSettingsForm = () => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormHelperText></FormHelperText>
|
<FormHelperText></FormHelperText>
|
||||||
</FormControl>
|
</FormControl> */}
|
||||||
|
<form.Subscribe
|
||||||
|
selector={(s) => s.values.hardwareAccelerationMode}
|
||||||
|
children={(hwAccel) =>
|
||||||
|
(hwAccel === 'qsv' || hwAccel === 'vaapi') && (
|
||||||
|
<form.AppField
|
||||||
|
name="vaapiDevice"
|
||||||
|
children={(field) => (
|
||||||
|
<field.BasicTextInput
|
||||||
|
fullWidth
|
||||||
|
label={hwAccel === 'qsv' ? 'QSV Device' : 'VA-API Device'}
|
||||||
|
helperText={
|
||||||
|
<span>
|
||||||
|
Override the default{' '}
|
||||||
|
{hardwareAccelerationMode === 'qsv' ? 'QSV' : 'VA-API'}{' '}
|
||||||
|
device path (defaults to <code>/dev/dri/renderD128</code>{' '}
|
||||||
|
on Linux and blank otherwise)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{(hardwareAccelerationMode === 'vaapi' ||
|
{/* {(hardwareAccelerationMode === 'vaapi' ||
|
||||||
hardwareAccelerationMode === 'qsv') && (
|
hardwareAccelerationMode === 'qsv') && (
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
@@ -145,78 +178,88 @@ export const TranscodeConfigVideoSettingsForm = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)} */}
|
||||||
|
<form.AppField
|
||||||
|
name="resolution"
|
||||||
|
children={(field) => (
|
||||||
|
<field.SelectInput
|
||||||
|
options={TranscodeResolutionOptions}
|
||||||
|
converter={resolutionConverter}
|
||||||
|
selectProps={{ label: 'Resolution' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormControl fullWidth>
|
<Stack direction={{ sm: 'column', md: 'row' }} spacing={2} useFlexGap>
|
||||||
<InputLabel id="target-resolution-label">Resolution</InputLabel>
|
<form.AppField
|
||||||
<TypedController
|
name="videoBitRate"
|
||||||
control={control}
|
children={(field) => (
|
||||||
name="resolution"
|
<field.BasicTextInput
|
||||||
toFormType={resolutionFromAnyString}
|
fullWidth
|
||||||
valueExtractor={(e) => (e as SelectChangeEvent).target.value}
|
label="Video Bitrate"
|
||||||
render={({ field }) => (
|
slotProps={{
|
||||||
<Select
|
input: {
|
||||||
labelId="target-resolution-label"
|
endAdornment: (
|
||||||
id="target-resolution"
|
<InputAdornment position="end">kbps</InputAdornment>
|
||||||
label="Resolution"
|
),
|
||||||
{...field}
|
},
|
||||||
value={resolutionToString(field.value)}
|
}}
|
||||||
>
|
/>
|
||||||
{TranscodeResolutionOptions.map((resolution) => (
|
|
||||||
<MenuItem key={resolution.value} value={resolution.value}>
|
|
||||||
{resolution.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
<form.AppField
|
||||||
|
|
||||||
<Stack direction={{ sm: 'column', md: 'row' }} gap={2} useFlexGap>
|
|
||||||
<NumericFormControllerText
|
|
||||||
control={control}
|
|
||||||
name="videoBitRate"
|
|
||||||
prettyFieldName="Video Bitrate"
|
|
||||||
TextFieldProps={{
|
|
||||||
id: 'video-bitrate',
|
|
||||||
label: 'Video Bitrate',
|
|
||||||
fullWidth: true,
|
|
||||||
sx: { my: 1 },
|
|
||||||
InputProps: {
|
|
||||||
endAdornment: (
|
|
||||||
<InputAdornment position="end">kbps</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<NumericFormControllerText
|
|
||||||
control={control}
|
|
||||||
name="videoBufferSize"
|
name="videoBufferSize"
|
||||||
prettyFieldName="Video Buffer Size"
|
children={(field) => (
|
||||||
TextFieldProps={{
|
<field.BasicTextInput
|
||||||
id: 'video-buffer-size',
|
fullWidth
|
||||||
label: 'Video Buffer Size',
|
label="Video Buffer Size"
|
||||||
fullWidth: true,
|
slotProps={{
|
||||||
sx: { my: 1 },
|
input: {
|
||||||
InputProps: {
|
endAdornment: (
|
||||||
endAdornment: <InputAdornment position="end">kb</InputAdornment>,
|
<InputAdornment position="end">kb</InputAdornment>
|
||||||
},
|
),
|
||||||
helperText: (
|
},
|
||||||
<>
|
}}
|
||||||
Buffer size effects how frequently ffmpeg reconsiders the output
|
helperText={
|
||||||
bitrate.{' '}
|
<>
|
||||||
<MuiLink
|
Buffer size effects how frequently ffmpeg reconsiders the
|
||||||
target="_blank"
|
output bitrate.{' '}
|
||||||
href="https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate#Whatdoes-bufsizedo"
|
<MuiLink
|
||||||
>
|
target="_blank"
|
||||||
Read more
|
href="https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate#Whatdoes-bufsizedo"
|
||||||
</MuiLink>
|
>
|
||||||
</>
|
Read more
|
||||||
),
|
</MuiLink>
|
||||||
}}
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={1}>
|
<Stack gap={1} direction={{ sm: 'column', md: 'row' }}>
|
||||||
|
<form.AppField
|
||||||
|
name="deinterlaceVideo"
|
||||||
|
children={(field) => (
|
||||||
|
<field.BasicCheckboxInput
|
||||||
|
label="Auto Deinterlace Video"
|
||||||
|
formControlProps={{ fullWidth: true }}
|
||||||
|
helperText="If set, all watermark overlays will be disabled for channels assigned this transcode config."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<form.AppField
|
||||||
|
name="normalizeFrameRate"
|
||||||
|
children={(field) => (
|
||||||
|
<field.BasicCheckboxInput
|
||||||
|
label="Normalize Frame Rate"
|
||||||
|
formControlProps={{ fullWidth: true }}
|
||||||
|
helperText="Output video at a constant frame rate."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
{/*
|
||||||
|
<Stack gap={1} direction={{ sm: 'column', md: 'row' }}>
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
@@ -227,7 +270,7 @@ export const TranscodeConfigVideoSettingsForm = () => {
|
|||||||
}
|
}
|
||||||
label={'Auto Deinterlace Video'}
|
label={'Auto Deinterlace Video'}
|
||||||
/>
|
/>
|
||||||
<FormHelperText></FormHelperText>
|
<FormHelperText> </FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
@@ -243,7 +286,7 @@ export const TranscodeConfigVideoSettingsForm = () => {
|
|||||||
Output video at a constant frame rate.
|
Output video at a constant frame rate.
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Stack>
|
</Stack> */}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ export const TranscodeConfigsTable = () => {
|
|||||||
visibleInShowHideMenu: false,
|
visibleInShowHideMenu: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
positionActionsColumn: 'last',
|
|
||||||
renderTopToolbarCustomActions() {
|
renderTopToolbarCustomActions() {
|
||||||
return (
|
return (
|
||||||
<Stack direction="row" alignItems="center" gap={2} useFlexGap>
|
<Stack direction="row" alignItems="center" gap={2} useFlexGap>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { formOptions } from '@tanstack/react-form';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { TranscodeConfig } from '@tunarr/types';
|
||||||
|
import { TranscodeConfigSchema } from '@tunarr/types/schemas';
|
||||||
|
import { useSnackbar } from 'notistack';
|
||||||
|
import type z from 'zod';
|
||||||
|
import {
|
||||||
|
getApiTranscodeConfigsQueryKey,
|
||||||
|
postApiTranscodeConfigsMutation,
|
||||||
|
putApiTranscodeConfigsByIdMutation,
|
||||||
|
} from '../../../generated/@tanstack/react-query.gen.ts';
|
||||||
|
|
||||||
|
type Opts = {
|
||||||
|
initialConfig: z.input<typeof TranscodeConfigSchema>;
|
||||||
|
isNew?: boolean;
|
||||||
|
onSave: (newConfig: TranscodeConfig) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useBaseTranscodeConfigFormOptions = (
|
||||||
|
initialConfig: z.input<typeof TranscodeConfigSchema>,
|
||||||
|
) => {
|
||||||
|
return formOptions({
|
||||||
|
defaultValues: initialConfig,
|
||||||
|
// mode: 'onChange',
|
||||||
|
validators: {
|
||||||
|
onChange: TranscodeConfigSchema,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTranscodeConfigFormOptions = ({
|
||||||
|
initialConfig,
|
||||||
|
onSave,
|
||||||
|
isNew,
|
||||||
|
}: Opts) => {
|
||||||
|
const snackbar = useSnackbar();
|
||||||
|
const baseOpts = useBaseTranscodeConfigFormOptions(initialConfig);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const updateConfigMutation = useMutation({
|
||||||
|
...putApiTranscodeConfigsByIdMutation(),
|
||||||
|
onSuccess: (ret) => {
|
||||||
|
snackbar.enqueueSnackbar('Successfully saved config!', {
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
onSave(ret);
|
||||||
|
return queryClient.invalidateQueries({
|
||||||
|
queryKey: getApiTranscodeConfigsQueryKey(),
|
||||||
|
exact: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
console.error(e);
|
||||||
|
snackbar.enqueueSnackbar(
|
||||||
|
'Error while saving transcode config. See console log for details.',
|
||||||
|
{
|
||||||
|
variant: 'error',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const newConfigMutation = useMutation({
|
||||||
|
...postApiTranscodeConfigsMutation(),
|
||||||
|
onSuccess: (ret) => {
|
||||||
|
snackbar.enqueueSnackbar('Successfully saved config!', {
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
onSave(ret);
|
||||||
|
return queryClient.invalidateQueries({
|
||||||
|
queryKey: getApiTranscodeConfigsQueryKey(),
|
||||||
|
exact: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
console.error(e);
|
||||||
|
snackbar.enqueueSnackbar(
|
||||||
|
'Error while saving transcode config. See console log for details.',
|
||||||
|
{
|
||||||
|
variant: 'error',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return formOptions({
|
||||||
|
...baseOpts,
|
||||||
|
onSubmit: (config) => {
|
||||||
|
const parsedConfig = TranscodeConfigSchema.parse(config.value);
|
||||||
|
if (isNew) {
|
||||||
|
newConfigMutation.mutate({ body: parsedConfig });
|
||||||
|
} else {
|
||||||
|
updateConfigMutation.mutate({
|
||||||
|
path: { id: initialConfig.id },
|
||||||
|
body: parsedConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -318,12 +318,17 @@ export const NumericFormControllerText = <
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let formValue = displayValue ?? field.value;
|
||||||
|
if (props.float) {
|
||||||
|
formValue = parseFloat(field.value)?.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
error={!isNil(fieldError)}
|
error={!isNil(fieldError)}
|
||||||
{...field}
|
{...field}
|
||||||
{...fieldProps}
|
{...fieldProps}
|
||||||
value={displayValue ?? field.value}
|
value={formValue}
|
||||||
helperText={helperText}
|
helperText={helperText}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import React from 'react';
|
|||||||
export const TanStackRouterDevtools = import.meta.env.PROD
|
export const TanStackRouterDevtools = import.meta.env.PROD
|
||||||
? () => null // Render nothing in production
|
? () => null // Render nothing in production
|
||||||
: React.lazy(async () => {
|
: React.lazy(async () => {
|
||||||
|
// const TanStackDevtools = (await import('@tanstack/react-devtools'))
|
||||||
|
// .TanStackDevtools;
|
||||||
const TanStackRouterDevtoolsComponent = (
|
const TanStackRouterDevtoolsComponent = (
|
||||||
await import('@tanstack/router-devtools')
|
await import('@tanstack/react-router-devtools')
|
||||||
).TanStackRouterDevtools;
|
).TanStackRouterDevtools;
|
||||||
const TanStackQueryDevtoolsComponent = (
|
const TanStackQueryDevtoolsComponent = (
|
||||||
await import('@tanstack/react-query-devtools')
|
await import('@tanstack/react-query-devtools')
|
||||||
).ReactQueryDevtools;
|
).ReactQueryDevtools;
|
||||||
|
// const formDevtoolsPlugin = (await import('@tanstack/react-form-devtools'))
|
||||||
|
// .formDevtoolsPlugin;
|
||||||
// Lazy load in development
|
// Lazy load in development
|
||||||
return {
|
return {
|
||||||
default: () => (
|
default: () => (
|
||||||
@@ -21,6 +25,10 @@ export const TanStackRouterDevtools = import.meta.env.PROD
|
|||||||
initialIsOpen={false}
|
initialIsOpen={false}
|
||||||
buttonPosition="bottom-left"
|
buttonPosition="bottom-left"
|
||||||
/>
|
/>
|
||||||
|
{/* <TanStackDevtools
|
||||||
|
plugins={[formDevtoolsPlugin()]}
|
||||||
|
eventBusConfig={{ debug: true }}
|
||||||
|
/> */}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -114,11 +114,11 @@ export const getChannelsOptions = (options?: Options<GetChannelsData>) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createChannelV2QueryKey = (options: Options<CreateChannelV2Data>) => createQueryKey('createChannelV2', options, false, [
|
export const createChannelV2QueryKey = (options?: Options<CreateChannelV2Data>) => createQueryKey('createChannelV2', options, false, [
|
||||||
'Channels'
|
'Channels'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const createChannelV2Options = (options: Options<CreateChannelV2Data>) => {
|
export const createChannelV2Options = (options?: Options<CreateChannelV2Data>) => {
|
||||||
return queryOptions({
|
return queryOptions({
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
const { data } = await createChannelV2({
|
const { data } = await createChannelV2({
|
||||||
@@ -1822,11 +1822,11 @@ export const getApiMediaSourcesOptions = (options?: Options<GetApiMediaSourcesDa
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const postApiMediaSourcesQueryKey = (options: Options<PostApiMediaSourcesData>) => createQueryKey('postApiMediaSources', options, false, [
|
export const postApiMediaSourcesQueryKey = (options?: Options<PostApiMediaSourcesData>) => createQueryKey('postApiMediaSources', options, false, [
|
||||||
'Media Source'
|
'Media Source'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const postApiMediaSourcesOptions = (options: Options<PostApiMediaSourcesData>) => {
|
export const postApiMediaSourcesOptions = (options?: Options<PostApiMediaSourcesData>) => {
|
||||||
return queryOptions({
|
return queryOptions({
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
const { data } = await postApiMediaSources({
|
const { data } = await postApiMediaSources({
|
||||||
@@ -2208,7 +2208,8 @@ export const putApiFfmpegSettingsMutation = (options?: Partial<Options<PutApiFfm
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getApiTranscodeConfigsQueryKey = (options?: Options<GetApiTranscodeConfigsData>) => createQueryKey('getApiTranscodeConfigs', options, false, [
|
export const getApiTranscodeConfigsQueryKey = (options?: Options<GetApiTranscodeConfigsData>) => createQueryKey('getApiTranscodeConfigs', options, false, [
|
||||||
'Settings'
|
'Settings',
|
||||||
|
'Transcode Configs'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const getApiTranscodeConfigsOptions = (options?: Options<GetApiTranscodeConfigsData>) => {
|
export const getApiTranscodeConfigsOptions = (options?: Options<GetApiTranscodeConfigsData>) => {
|
||||||
@@ -2227,7 +2228,8 @@ export const getApiTranscodeConfigsOptions = (options?: Options<GetApiTranscodeC
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const postApiTranscodeConfigsQueryKey = (options: Options<PostApiTranscodeConfigsData>) => createQueryKey('postApiTranscodeConfigs', options, false, [
|
export const postApiTranscodeConfigsQueryKey = (options: Options<PostApiTranscodeConfigsData>) => createQueryKey('postApiTranscodeConfigs', options, false, [
|
||||||
'Settings'
|
'Settings',
|
||||||
|
'Transcode Configs'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const postApiTranscodeConfigsOptions = (options: Options<PostApiTranscodeConfigsData>) => {
|
export const postApiTranscodeConfigsOptions = (options: Options<PostApiTranscodeConfigsData>) => {
|
||||||
@@ -2274,7 +2276,8 @@ export const deleteApiTranscodeConfigsByIdMutation = (options?: Partial<Options<
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getApiTranscodeConfigsByIdQueryKey = (options: Options<GetApiTranscodeConfigsByIdData>) => createQueryKey('getApiTranscodeConfigsById', options, false, [
|
export const getApiTranscodeConfigsByIdQueryKey = (options: Options<GetApiTranscodeConfigsByIdData>) => createQueryKey('getApiTranscodeConfigsById', options, false, [
|
||||||
'Settings'
|
'Settings',
|
||||||
|
'Transcode Configs'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const getApiTranscodeConfigsByIdOptions = (options: Options<GetApiTranscodeConfigsByIdData>) => {
|
export const getApiTranscodeConfigsByIdOptions = (options: Options<GetApiTranscodeConfigsByIdData>) => {
|
||||||
@@ -2306,7 +2309,10 @@ export const putApiTranscodeConfigsByIdMutation = (options?: Partial<Options<Put
|
|||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const postApiTranscodeConfigsByIdCopyQueryKey = (options: Options<PostApiTranscodeConfigsByIdCopyData>) => createQueryKey('postApiTranscodeConfigsByIdCopy', options);
|
export const postApiTranscodeConfigsByIdCopyQueryKey = (options: Options<PostApiTranscodeConfigsByIdCopyData>) => createQueryKey('postApiTranscodeConfigsByIdCopy', options, false, [
|
||||||
|
'Settings',
|
||||||
|
'Transcode Configs'
|
||||||
|
]);
|
||||||
|
|
||||||
export const postApiTranscodeConfigsByIdCopyOptions = (options: Options<PostApiTranscodeConfigsByIdCopyData>) => {
|
export const postApiTranscodeConfigsByIdCopyOptions = (options: Options<PostApiTranscodeConfigsByIdCopyData>) => {
|
||||||
return queryOptions({
|
return queryOptions({
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ export const getChannels = <ThrowOnError extends boolean = false>(options?: Opti
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createChannelV2 = <ThrowOnError extends boolean = false>(options: Options<CreateChannelV2Data, ThrowOnError>) => {
|
export const createChannelV2 = <ThrowOnError extends boolean = false>(options?: Options<CreateChannelV2Data, ThrowOnError>) => {
|
||||||
return (options.client ?? _heyApiClient).post<CreateChannelV2Responses, CreateChannelV2Errors, ThrowOnError>({
|
return (options?.client ?? _heyApiClient).post<CreateChannelV2Responses, CreateChannelV2Errors, ThrowOnError>({
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
url: '/api/channels',
|
url: '/api/channels',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options.headers
|
...options?.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -650,14 +650,14 @@ export const getApiMediaSources = <ThrowOnError extends boolean = false>(options
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const postApiMediaSources = <ThrowOnError extends boolean = false>(options: Options<PostApiMediaSourcesData, ThrowOnError>) => {
|
export const postApiMediaSources = <ThrowOnError extends boolean = false>(options?: Options<PostApiMediaSourcesData, ThrowOnError>) => {
|
||||||
return (options.client ?? _heyApiClient).post<PostApiMediaSourcesResponses, PostApiMediaSourcesErrors, ThrowOnError>({
|
return (options?.client ?? _heyApiClient).post<PostApiMediaSourcesResponses, PostApiMediaSourcesErrors, ThrowOnError>({
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
url: '/api/media-sources',
|
url: '/api/media-sources',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options.headers
|
...options?.headers
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1945,7 +1945,7 @@ export type GetChannelsResponses = {
|
|||||||
export type GetChannelsResponse = GetChannelsResponses[keyof GetChannelsResponses];
|
export type GetChannelsResponse = GetChannelsResponses[keyof GetChannelsResponses];
|
||||||
|
|
||||||
export type CreateChannelV2Data = {
|
export type CreateChannelV2Data = {
|
||||||
body: {
|
body?: {
|
||||||
type: 'new';
|
type: 'new';
|
||||||
channel: {
|
channel: {
|
||||||
disableFillerOverlay: boolean;
|
disableFillerOverlay: boolean;
|
||||||
@@ -3495,7 +3495,7 @@ export type GetApiChannelsByIdProgrammingResponses = {
|
|||||||
export type GetApiChannelsByIdProgrammingResponse = GetApiChannelsByIdProgrammingResponses[keyof GetApiChannelsByIdProgrammingResponses];
|
export type GetApiChannelsByIdProgrammingResponse = GetApiChannelsByIdProgrammingResponses[keyof GetApiChannelsByIdProgrammingResponses];
|
||||||
|
|
||||||
export type PostApiChannelsByIdProgrammingData = {
|
export type PostApiChannelsByIdProgrammingData = {
|
||||||
body: {
|
body?: {
|
||||||
type: 'manual';
|
type: 'manual';
|
||||||
programs: Array<{
|
programs: Array<{
|
||||||
type: 'content';
|
type: 'content';
|
||||||
@@ -7098,6 +7098,24 @@ export type GetApiChannelsByIdTranscodeConfigResponses = {
|
|||||||
audioBufferSize: number;
|
audioBufferSize: number;
|
||||||
audioSampleRate: number;
|
audioSampleRate: number;
|
||||||
audioVolumePercent: number;
|
audioVolumePercent: number;
|
||||||
|
audioLoudnormConfig?: {
|
||||||
|
/**
|
||||||
|
* integrated loudness target
|
||||||
|
*/
|
||||||
|
i: number;
|
||||||
|
/**
|
||||||
|
* loudness range target
|
||||||
|
*/
|
||||||
|
lra: number;
|
||||||
|
/**
|
||||||
|
* maximum true peak
|
||||||
|
*/
|
||||||
|
tp: number;
|
||||||
|
/**
|
||||||
|
* offset gain to add before peak limiter
|
||||||
|
*/
|
||||||
|
offsetGain?: number;
|
||||||
|
};
|
||||||
normalizeFrameRate: boolean;
|
normalizeFrameRate: boolean;
|
||||||
deinterlaceVideo: boolean;
|
deinterlaceVideo: boolean;
|
||||||
disableChannelOverlay: boolean;
|
disableChannelOverlay: boolean;
|
||||||
@@ -14747,7 +14765,7 @@ export type GetApiMediaSourcesResponses = {
|
|||||||
export type GetApiMediaSourcesResponse = GetApiMediaSourcesResponses[keyof GetApiMediaSourcesResponses];
|
export type GetApiMediaSourcesResponse = GetApiMediaSourcesResponses[keyof GetApiMediaSourcesResponses];
|
||||||
|
|
||||||
export type PostApiMediaSourcesData = {
|
export type PostApiMediaSourcesData = {
|
||||||
body: {
|
body?: {
|
||||||
name: string;
|
name: string;
|
||||||
uri: string;
|
uri: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
@@ -15651,7 +15669,7 @@ export type DeleteApiMediaSourcesByIdResponses = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type PutApiMediaSourcesByIdData = {
|
export type PutApiMediaSourcesByIdData = {
|
||||||
body: {
|
body?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
uri: string;
|
uri: string;
|
||||||
@@ -15883,6 +15901,24 @@ export type GetApiTranscodeConfigsResponses = {
|
|||||||
audioBufferSize: number;
|
audioBufferSize: number;
|
||||||
audioSampleRate: number;
|
audioSampleRate: number;
|
||||||
audioVolumePercent: number;
|
audioVolumePercent: number;
|
||||||
|
audioLoudnormConfig?: {
|
||||||
|
/**
|
||||||
|
* integrated loudness target
|
||||||
|
*/
|
||||||
|
i: number;
|
||||||
|
/**
|
||||||
|
* loudness range target
|
||||||
|
*/
|
||||||
|
lra: number;
|
||||||
|
/**
|
||||||
|
* maximum true peak
|
||||||
|
*/
|
||||||
|
tp: number;
|
||||||
|
/**
|
||||||
|
* offset gain to add before peak limiter
|
||||||
|
*/
|
||||||
|
offsetGain?: number;
|
||||||
|
};
|
||||||
normalizeFrameRate: boolean;
|
normalizeFrameRate: boolean;
|
||||||
deinterlaceVideo: boolean;
|
deinterlaceVideo: boolean;
|
||||||
disableChannelOverlay: boolean;
|
disableChannelOverlay: boolean;
|
||||||
@@ -15920,6 +15956,24 @@ export type PostApiTranscodeConfigsData = {
|
|||||||
audioBufferSize: number;
|
audioBufferSize: number;
|
||||||
audioSampleRate: number;
|
audioSampleRate: number;
|
||||||
audioVolumePercent?: number;
|
audioVolumePercent?: number;
|
||||||
|
audioLoudnormConfig?: {
|
||||||
|
/**
|
||||||
|
* integrated loudness target
|
||||||
|
*/
|
||||||
|
i?: number;
|
||||||
|
/**
|
||||||
|
* loudness range target
|
||||||
|
*/
|
||||||
|
lra?: number;
|
||||||
|
/**
|
||||||
|
* maximum true peak
|
||||||
|
*/
|
||||||
|
tp?: number;
|
||||||
|
/**
|
||||||
|
* offset gain to add before peak limiter
|
||||||
|
*/
|
||||||
|
offsetGain?: number;
|
||||||
|
};
|
||||||
normalizeFrameRate: boolean;
|
normalizeFrameRate: boolean;
|
||||||
deinterlaceVideo: boolean;
|
deinterlaceVideo: boolean;
|
||||||
disableChannelOverlay: boolean;
|
disableChannelOverlay: boolean;
|
||||||
@@ -15962,6 +16016,24 @@ export type PostApiTranscodeConfigsResponses = {
|
|||||||
audioBufferSize: number;
|
audioBufferSize: number;
|
||||||
audioSampleRate: number;
|
audioSampleRate: number;
|
||||||
audioVolumePercent: number;
|
audioVolumePercent: number;
|
||||||
|
audioLoudnormConfig?: {
|
||||||
|
/**
|
||||||
|
* integrated loudness target
|
||||||
|
*/
|
||||||
|
i: number;
|
||||||
|
/**
|
||||||
|
* loudness range target
|
||||||
|
*/
|
||||||
|
lra: number;
|
||||||
|
/**
|
||||||
|
* maximum true peak
|
||||||
|
*/
|
||||||
|
tp: number;
|
||||||
|
/**
|
||||||
|
* offset gain to add before peak limiter
|
||||||
|
*/
|
||||||
|
offsetGain?: number;
|
||||||
|
};
|
||||||
normalizeFrameRate: boolean;
|
normalizeFrameRate: boolean;
|
||||||
deinterlaceVideo: boolean;
|
deinterlaceVideo: boolean;
|
||||||
disableChannelOverlay: boolean;
|
disableChannelOverlay: boolean;
|
||||||
@@ -16042,6 +16114,24 @@ export type GetApiTranscodeConfigsByIdResponses = {
|
|||||||
audioBufferSize: number;
|
audioBufferSize: number;
|
||||||
audioSampleRate: number;
|
audioSampleRate: number;
|
||||||
audioVolumePercent: number;
|
audioVolumePercent: number;
|
||||||
|
audioLoudnormConfig?: {
|
||||||
|
/**
|
||||||
|
* integrated loudness target
|
||||||
|
*/
|
||||||
|
i: number;
|
||||||
|
/**
|
||||||
|
* loudness range target
|
||||||
|
*/
|
||||||
|
lra: number;
|
||||||
|
/**
|
||||||
|
* maximum true peak
|
||||||
|
*/
|
||||||
|
tp: number;
|
||||||
|
/**
|
||||||
|
* offset gain to add before peak limiter
|
||||||
|
*/
|
||||||
|
offsetGain?: number;
|
||||||
|
};
|
||||||
normalizeFrameRate: boolean;
|
normalizeFrameRate: boolean;
|
||||||
deinterlaceVideo: boolean;
|
deinterlaceVideo: boolean;
|
||||||
disableChannelOverlay: boolean;
|
disableChannelOverlay: boolean;
|
||||||
@@ -16080,6 +16170,24 @@ export type PutApiTranscodeConfigsByIdData = {
|
|||||||
audioBufferSize: number;
|
audioBufferSize: number;
|
||||||
audioSampleRate: number;
|
audioSampleRate: number;
|
||||||
audioVolumePercent?: number;
|
audioVolumePercent?: number;
|
||||||
|
audioLoudnormConfig?: {
|
||||||
|
/**
|
||||||
|
* integrated loudness target
|
||||||
|
*/
|
||||||
|
i?: number;
|
||||||
|
/**
|
||||||
|
* loudness range target
|
||||||
|
*/
|
||||||
|
lra?: number;
|
||||||
|
/**
|
||||||
|
* maximum true peak
|
||||||
|
*/
|
||||||
|
tp?: number;
|
||||||
|
/**
|
||||||
|
* offset gain to add before peak limiter
|
||||||
|
*/
|
||||||
|
offsetGain?: number;
|
||||||
|
};
|
||||||
normalizeFrameRate: boolean;
|
normalizeFrameRate: boolean;
|
||||||
deinterlaceVideo: boolean;
|
deinterlaceVideo: boolean;
|
||||||
disableChannelOverlay: boolean;
|
disableChannelOverlay: boolean;
|
||||||
@@ -16124,6 +16232,24 @@ export type PutApiTranscodeConfigsByIdResponses = {
|
|||||||
audioBufferSize: number;
|
audioBufferSize: number;
|
||||||
audioSampleRate: number;
|
audioSampleRate: number;
|
||||||
audioVolumePercent: number;
|
audioVolumePercent: number;
|
||||||
|
audioLoudnormConfig?: {
|
||||||
|
/**
|
||||||
|
* integrated loudness target
|
||||||
|
*/
|
||||||
|
i: number;
|
||||||
|
/**
|
||||||
|
* loudness range target
|
||||||
|
*/
|
||||||
|
lra: number;
|
||||||
|
/**
|
||||||
|
* maximum true peak
|
||||||
|
*/
|
||||||
|
tp: number;
|
||||||
|
/**
|
||||||
|
* offset gain to add before peak limiter
|
||||||
|
*/
|
||||||
|
offsetGain?: number;
|
||||||
|
};
|
||||||
normalizeFrameRate: boolean;
|
normalizeFrameRate: boolean;
|
||||||
deinterlaceVideo: boolean;
|
deinterlaceVideo: boolean;
|
||||||
disableChannelOverlay: boolean;
|
disableChannelOverlay: boolean;
|
||||||
@@ -16185,6 +16311,24 @@ export type PostApiTranscodeConfigsByIdCopyResponses = {
|
|||||||
audioBufferSize: number;
|
audioBufferSize: number;
|
||||||
audioSampleRate: number;
|
audioSampleRate: number;
|
||||||
audioVolumePercent: number;
|
audioVolumePercent: number;
|
||||||
|
audioLoudnormConfig?: {
|
||||||
|
/**
|
||||||
|
* integrated loudness target
|
||||||
|
*/
|
||||||
|
i: number;
|
||||||
|
/**
|
||||||
|
* loudness range target
|
||||||
|
*/
|
||||||
|
lra: number;
|
||||||
|
/**
|
||||||
|
* maximum true peak
|
||||||
|
*/
|
||||||
|
tp: number;
|
||||||
|
/**
|
||||||
|
* offset gain to add before peak limiter
|
||||||
|
*/
|
||||||
|
offsetGain?: number;
|
||||||
|
};
|
||||||
normalizeFrameRate: boolean;
|
normalizeFrameRate: boolean;
|
||||||
deinterlaceVideo: boolean;
|
deinterlaceVideo: boolean;
|
||||||
disableChannelOverlay: boolean;
|
disableChannelOverlay: boolean;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type Channel } from '@tunarr/types';
|
import { type Channel } from '@tunarr/types';
|
||||||
import { range } from 'lodash-es';
|
import { range } from 'lodash-es';
|
||||||
import { type MarkOptional } from 'ts-essentials';
|
import { type MarkOptional } from 'ts-essentials';
|
||||||
|
import type { DropdownOption } from './DropdownOption';
|
||||||
|
|
||||||
export const OneDayMillis = 1000 * 60 * 60 * 24;
|
export const OneDayMillis = 1000 * 60 * 60 * 24;
|
||||||
export const OneWeekMillis = OneDayMillis * 7;
|
export const OneWeekMillis = OneDayMillis * 7;
|
||||||
@@ -41,19 +42,19 @@ export const DefaultChannel: MarkOptional<
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const TranscodeResolutionOptions = [
|
export const TranscodeResolutionOptions = [
|
||||||
{ value: '420x420', label: '420x420 (1:1)' },
|
{ value: '420x420', description: '420x420 (1:1)' },
|
||||||
{ value: '480x270', label: '480x270 (HD1080/16 16:9)' },
|
{ value: '480x270', description: '480x270 (HD1080/16 16:9)' },
|
||||||
{ value: '576x320', label: '576x320 (18:10)' },
|
{ value: '576x320', description: '576x320 (18:10)' },
|
||||||
{ value: '640x360', label: '640x360 (nHD 16:9)' },
|
{ value: '640x360', description: '640x360 (nHD 16:9)' },
|
||||||
{ value: '720x480', label: '720x480 (WVGA 3:2)' },
|
{ value: '720x480', description: '720x480 (WVGA 3:2)' },
|
||||||
{ value: '800x480', label: '800x480 (WVGA 15:9)' },
|
{ value: '800x480', description: '800x480 (WVGA 15:9)' },
|
||||||
{ value: '854x480', label: '854x480 (FWVGA 16:9)' },
|
{ value: '854x480', description: '854x480 (FWVGA 16:9)' },
|
||||||
{ value: '800x600', label: '800x600 (SVGA 4:3)' },
|
{ value: '800x600', description: '800x600 (SVGA 4:3)' },
|
||||||
{ value: '1024x768', label: '1024x768 (WXGA 4:3)' },
|
{ value: '1024x768', description: '1024x768 (WXGA 4:3)' },
|
||||||
{ value: '1280x720', label: '1280x720 (HD 16:9)' },
|
{ value: '1280x720', description: '1280x720 (HD 16:9)' },
|
||||||
{ value: '1920x1080', label: '1920x1080 (FHD 16:9)' },
|
{ value: '1920x1080', description: '1920x1080 (FHD 16:9)' },
|
||||||
{ value: '3840x2160', label: '3840x2160 (4K 16:9)' },
|
{ value: '3840x2160', description: '3840x2160 (4K 16:9)' },
|
||||||
] as const;
|
] satisfies DropdownOption<string>[];
|
||||||
|
|
||||||
export const Plex = 'plex';
|
export const Plex = 'plex';
|
||||||
export const Jellyfin = 'jellyfin';
|
export const Jellyfin = 'jellyfin';
|
||||||
|
|||||||
@@ -165,7 +165,8 @@ export const handleNumericFormValue = (
|
|||||||
|
|
||||||
// Special-case for typing a trailing decimal on a float
|
// Special-case for typing a trailing decimal on a float
|
||||||
if (float && value.endsWith('.')) {
|
if (float && value.endsWith('.')) {
|
||||||
return parseFloat(value + '0'); // This still doesn't work
|
console.log(value, parseFloat(value + '0'));
|
||||||
|
return parseFloat(value + '0'); // TODO: This still doesn't work
|
||||||
}
|
}
|
||||||
|
|
||||||
return float ? parseFloat(value) : parseInt(value);
|
return float ? parseFloat(value) : parseInt(value);
|
||||||
|
|||||||
22
web/src/hooks/form.ts
Normal file
22
web/src/hooks/form.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { createFormHook, createFormHookContexts } from '@tanstack/react-form';
|
||||||
|
import { BasicCheckboxInput } from '../components/form/BasicCheckboxInput.tsx';
|
||||||
|
import {
|
||||||
|
BasicSelectInput,
|
||||||
|
SelectInput,
|
||||||
|
} from '../components/form/BasicSelectInput.tsx';
|
||||||
|
import { BasicTextInput } from '../components/form/BasicTextInput.tsx';
|
||||||
|
|
||||||
|
export const { formContext, fieldContext, useFormContext, useFieldContext } =
|
||||||
|
createFormHookContexts();
|
||||||
|
|
||||||
|
export const { useAppForm, withForm, useTypedAppFormContext } = createFormHook({
|
||||||
|
fieldComponents: {
|
||||||
|
BasicSelectInput,
|
||||||
|
BasicTextInput,
|
||||||
|
SelectInput,
|
||||||
|
BasicCheckboxInput,
|
||||||
|
},
|
||||||
|
formComponents: {},
|
||||||
|
fieldContext,
|
||||||
|
formContext,
|
||||||
|
});
|
||||||
@@ -3,35 +3,10 @@ import { TranscodeConfigSettingsForm } from '@/components/settings/ffmpeg/Transc
|
|||||||
import { useTranscodeConfig } from '@/hooks/settingsHooks';
|
import { useTranscodeConfig } from '@/hooks/settingsHooks';
|
||||||
import { Route } from '@/routes/settings/ffmpeg_/$configId';
|
import { Route } from '@/routes/settings/ffmpeg_/$configId';
|
||||||
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import {
|
|
||||||
getApiTranscodeConfigsQueryKey,
|
|
||||||
putApiTranscodeConfigsByIdMutation,
|
|
||||||
} from '../../generated/@tanstack/react-query.gen.ts';
|
|
||||||
|
|
||||||
export const EditTranscodeConfigSettingsPage = () => {
|
export const EditTranscodeConfigSettingsPage = () => {
|
||||||
const { configId } = Route.useParams();
|
const { configId } = Route.useParams();
|
||||||
|
|
||||||
const transcodeConfig = useTranscodeConfig(configId);
|
const transcodeConfig = useTranscodeConfig(configId);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
return <TranscodeConfigSettingsForm initialConfig={transcodeConfig.data} />;
|
||||||
|
|
||||||
const updateConfigMutation = useMutation({
|
|
||||||
...putApiTranscodeConfigsByIdMutation(),
|
|
||||||
onSuccess: () => {
|
|
||||||
return queryClient.invalidateQueries({
|
|
||||||
queryKey: getApiTranscodeConfigsQueryKey(),
|
|
||||||
exact: false,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TranscodeConfigSettingsForm
|
|
||||||
initialConfig={transcodeConfig.data}
|
|
||||||
onSave={(conf) =>
|
|
||||||
updateConfigMutation.mutateAsync({ path: { id: configId }, body: conf })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { TranscodeConfigSettingsForm } from '@/components/settings/ffmpeg/TranscodeConfigSettingsForm';
|
import { TranscodeConfigSettingsForm } from '@/components/settings/ffmpeg/TranscodeConfigSettingsForm';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import type { TranscodeConfig } from '@tunarr/types';
|
import type { TranscodeConfig } from '@tunarr/types';
|
||||||
import {
|
|
||||||
getApiTranscodeConfigsQueryKey,
|
|
||||||
postApiTranscodeConfigsMutation,
|
|
||||||
} from '../../generated/@tanstack/react-query.gen.ts';
|
|
||||||
|
|
||||||
const defaultNewTranscodeConfig: TranscodeConfig = {
|
const defaultNewTranscodeConfig: TranscodeConfig = {
|
||||||
id: '',
|
id: '',
|
||||||
@@ -41,22 +36,9 @@ const defaultNewTranscodeConfig: TranscodeConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const NewTranscodeConfigSettingsPage = () => {
|
export const NewTranscodeConfigSettingsPage = () => {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const updateConfigMutation = useMutation({
|
|
||||||
...postApiTranscodeConfigsMutation(),
|
|
||||||
onSuccess: () => {
|
|
||||||
return queryClient.invalidateQueries({
|
|
||||||
queryKey: getApiTranscodeConfigsQueryKey(),
|
|
||||||
exact: false,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TranscodeConfigSettingsForm
|
<TranscodeConfigSettingsForm
|
||||||
initialConfig={defaultNewTranscodeConfig}
|
initialConfig={defaultNewTranscodeConfig}
|
||||||
onSave={(conf) => updateConfigMutation.mutateAsync({ body: conf })}
|
|
||||||
isNew
|
isNew
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,3 +46,8 @@ export const setUiLocale = (locale: SupportedLocales) =>
|
|||||||
dayjs.locale(locale); // Changes the default dayjs locale globally
|
dayjs.locale(locale); // Changes the default dayjs locale globally
|
||||||
settings.ui.i18n.locale = locale;
|
settings.ui.i18n.locale = locale;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const setShowAdvancedSettings = (value: boolean) =>
|
||||||
|
useStore.setState(({ settings }) => {
|
||||||
|
settings.ui.showAdvancedSettings = value;
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { PaginationState } from '@tanstack/react-table';
|
import type { PaginationState } from '@tanstack/react-table';
|
||||||
import { DeepPartial } from 'ts-essentials';
|
import type { DeepPartial } from 'ts-essentials';
|
||||||
import type { StateCreator } from 'zustand';
|
import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
// Only these 2 are supported currently
|
// Only these 2 are supported currently
|
||||||
@@ -19,6 +19,7 @@ export interface SettingsStateInternal {
|
|||||||
locale: SupportedLocales;
|
locale: SupportedLocales;
|
||||||
};
|
};
|
||||||
tableSettings: Record<string, TableSettings>;
|
tableSettings: Record<string, TableSettings>;
|
||||||
|
showAdvancedSettings: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ export const createSettingsSlice: StateCreator<SettingsState> = () => ({
|
|||||||
locale: 'en',
|
locale: 'en',
|
||||||
},
|
},
|
||||||
tableSettings: {},
|
tableSettings: {},
|
||||||
|
showAdvancedSettings: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user