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:
Christian Benincasa
2026-02-13 15:09:01 -05:00
parent d54a728d2f
commit f25f3f599f
47 changed files with 6175 additions and 1257 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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"
}, },

View File

@@ -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(),

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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),

View 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}`;
}
}

View File

@@ -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();
});
}); });

View File

@@ -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;

View File

@@ -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> = {}) {

View File

@@ -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,
), ),

View File

@@ -0,0 +1 @@
ALTER TABLE `transcode_config` ADD `audio_loudnorm_config` text;

File diff suppressed because it is too large Load Diff

View File

@@ -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
} }
] ]
} }

View File

@@ -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",

View File

@@ -1,4 +1,4 @@
import type { z } from 'zod/v4'; import type { z } from 'zod';
import type { import type {
SupportedTranscodeVideoOutputFormats, SupportedTranscodeVideoOutputFormats,
TranscodeConfigSchema, TranscodeConfigSchema,

View File

@@ -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>;

View File

@@ -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'),
});

View File

@@ -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(),

View File

@@ -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",

View 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>
);
}

View 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>
);
}

View 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}
</>
}
/>
);
}

View File

@@ -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;
};

View File

@@ -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>
); );
}; };

View File

@@ -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>
); );

View File

@@ -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>
); );
}; };

View File

@@ -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>
); );
}; };

View File

@@ -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>

View File

@@ -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,
});
}
},
});
};

View File

@@ -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}
/> />
); );

View File

@@ -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 }}
/> */}
</> </>
), ),
}; };

View File

@@ -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({

View File

@@ -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
} }
}); });
}; };

View File

@@ -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;

View File

@@ -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';

View File

@@ -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
View 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,
});

View File

@@ -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 })
}
/>
);
}; };

View File

@@ -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
/> />
); );

View File

@@ -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;
});

View File

@@ -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,
}, },
}, },
}); });