From 35813ff7c0cb594c3ea159e9f2d8dac1c2af1a7c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 04:44:24 +0000 Subject: [PATCH 1/2] Add a Settings tab with the project-wide audio wave The spec has always had `chrome.waveform` (what Notes draws across a whole video) and the renderer honours it, but nothing in the editor could set it. A new Settings tab holds it, with the same settings and defaults as a scene's wave layer; the shared vocabulary moves to spec/waveform.ts. The project's default camera moves there from the Scenes tab, so the tab is the one home for what applies to the whole video. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01XHkHXgxVVH3dn6EAVL9rbN --- frontend/src/api/projects.ts | 13 +++ frontend/src/components/OverlayEditor.tsx | 15 +-- .../src/components/WaveformSettings.test.tsx | 42 +++++++ frontend/src/components/WaveformSettings.tsx | 109 ++++++++++++++++++ frontend/src/spec/settings.ts | 19 +++ frontend/src/spec/waveform.ts | 26 +++++ frontend/src/views/ProjectView.tsx | 13 ++- 7 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/WaveformSettings.test.tsx create mode 100644 frontend/src/components/WaveformSettings.tsx create mode 100644 frontend/src/spec/settings.ts create mode 100644 frontend/src/spec/waveform.ts diff --git a/frontend/src/api/projects.ts b/frontend/src/api/projects.ts index f486fca..7a1ed21 100644 --- a/frontend/src/api/projects.ts +++ b/frontend/src/api/projects.ts @@ -109,6 +109,17 @@ export type Position = export type WaveMode = 'line' | 'p2p' | 'cline' | 'point' export type WavePosition = 'top' | 'center' | 'bottom' +/** One wave's look: the same fields on a scene's `waveform` overlay and on the + * project-wide `chrome.waveform`, which Notes draws across a whole video. */ +export interface Waveform { + mode?: WaveMode + color?: string + opacity?: number + position?: WavePosition + height_pct?: number + scrim?: number +} + /** When an overlay shows. Absent on the overlay: its whole scene. */ export interface Span { from: Anchor @@ -185,6 +196,8 @@ export interface VideoSpec { audio_tracks?: AudioTrack[] credits?: Credits | null output?: { aspect?: '16:9' | '9:16' | '1:1'; [key: string]: unknown } + /** Drawn over every scene that doesn't opt out. A block present means on. */ + chrome?: { waveform?: Waveform; [key: string]: unknown } style?: { transition?: { style?: string; duration?: number } motion?: Motion diff --git a/frontend/src/components/OverlayEditor.tsx b/frontend/src/components/OverlayEditor.tsx index e3c55b3..9a062d5 100644 --- a/frontend/src/components/OverlayEditor.tsx +++ b/frontend/src/components/OverlayEditor.tsx @@ -11,6 +11,7 @@ import type { } from '@/api/projects' import AnchorSelect from '@/components/AnchorSelect' import { sceneName, type AnchorOption } from '@/spec/anchors' +import { WAVE_MODES, WAVE_POSITIONS } from '@/spec/waveform' import FootageSearch from '@/components/FootageSearch' import { addImageOverlay, @@ -37,20 +38,6 @@ const POSITIONS: { value: Position; label: string }[] = [ { value: 'bottom-right', label: 'Bottom right' }, ] -// Notes' names for the wave styles, so the same wave is called the same in both. -const WAVE_MODES: { value: WaveMode; label: string }[] = [ - { value: 'line', label: 'Line' }, - { value: 'cline', label: 'Centred line' }, - { value: 'p2p', label: 'Peak to peak' }, - { value: 'point', label: 'Points' }, -] - -const WAVE_POSITIONS: { value: WavePosition; label: string }[] = [ - { value: 'top', label: 'Top' }, - { value: 'center', label: 'Centre' }, - { value: 'bottom', label: 'Bottom' }, -] - const KIND_LABEL: Record = { waveform: 'Wave', text: 'Text', diff --git a/frontend/src/components/WaveformSettings.test.tsx b/frontend/src/components/WaveformSettings.test.tsx new file mode 100644 index 0000000..4dce780 --- /dev/null +++ b/frontend/src/components/WaveformSettings.test.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { describe, expect, it } from 'vitest' +import type { VideoSpec } from '@/api/projects' +import WaveformSettings from '@/components/WaveformSettings' + +const start: VideoSpec = { title: 'T', segments: [] } + +let latest = start +function Harness() { + const [spec, setSpec] = useState(start) + latest = spec + return +} + +describe('WaveformSettings', () => { + it('turns the wave on with the settings Notes defaults to, edits them, and off again', async () => { + render() + expect(screen.queryByLabelText('Wave style')).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('checkbox')) + expect(latest.chrome?.waveform).toEqual({ + mode: 'line', + color: '#00ff41', + opacity: 0.7, + position: 'bottom', + height_pct: 22, + scrim: 0.45, + }) + + fireEvent.change(screen.getByLabelText('Wave style'), { target: { value: 'p2p' } }) + fireEvent.change(screen.getByLabelText('Wave position'), { + target: { value: 'center' }, + }) + expect(latest.chrome?.waveform).toMatchObject({ mode: 'p2p', position: 'center' }) + + // Off removes the block, and the empty `chrome` with it. + await userEvent.click(screen.getByRole('checkbox')) + expect(latest.chrome).toBeUndefined() + }) +}) diff --git a/frontend/src/components/WaveformSettings.tsx b/frontend/src/components/WaveformSettings.tsx new file mode 100644 index 0000000..dafa4b8 --- /dev/null +++ b/frontend/src/components/WaveformSettings.tsx @@ -0,0 +1,109 @@ +import type { VideoSpec, WaveMode, WavePosition, Waveform } from '@/api/projects' +import { setWaveform } from '@/spec/settings' +import { DEFAULT_WAVEFORM, WAVE_MODES, WAVE_POSITIONS } from '@/spec/waveform' + +interface Props { + spec: VideoSpec + onChange: (next: VideoSpec) => void +} + +/** + * An animated audio wave drawn across every scene, as Notes does for a whole video. + * Its settings are those of a scene's own wave. A scene that wants one only for + * itself, or for part of itself, adds the wave as a layer instead. + */ +export default function WaveformSettings({ spec, onChange }: Props) { + const wave = spec.chrome?.waveform ?? null + const value = { ...DEFAULT_WAVEFORM, ...(wave ?? {}) } + const set = (patch: Waveform) => onChange(setWaveform(spec, { ...value, ...patch })) + + return ( +
+ + {wave !== null && ( +
+ + + set({ color: e.target.value })} + /> + + + +
+ )} +
+ ) +} diff --git a/frontend/src/spec/settings.ts b/frontend/src/spec/settings.ts new file mode 100644 index 0000000..dc597ce --- /dev/null +++ b/frontend/src/spec/settings.ts @@ -0,0 +1,19 @@ +import type { VideoSpec, Waveform } from '@/api/projects' + +/** + * Project-wide settings as pure functions from one spec to the next (the rules of + * `edits.ts`). Each setting is a field of the spec, so a project template can later be + * nothing more than a partial spec applied over these same fields. + */ + +/** The wave on (with these settings) or off (`null`). Off removes the block: "present + * means on" is the format's rule, and an emptied `chrome` is removed with it. */ +export function setWaveform(spec: VideoSpec, waveform: Waveform | null): VideoSpec { + const chrome = { ...(spec.chrome ?? {}) } + if (waveform === null) delete chrome.waveform + else chrome.waveform = waveform + if (Object.keys(chrome).length > 0) return { ...spec, chrome } + const rest = { ...spec } + delete rest.chrome + return rest +} diff --git a/frontend/src/spec/waveform.ts b/frontend/src/spec/waveform.ts new file mode 100644 index 0000000..dd1bbb0 --- /dev/null +++ b/frontend/src/spec/waveform.ts @@ -0,0 +1,26 @@ +import type { WaveMode, WavePosition, Waveform } from '@/api/projects' + +// Notes' names for the wave styles, so the same wave is called the same in both. +export const WAVE_MODES: { value: WaveMode; label: string }[] = [ + { value: 'line', label: 'Line' }, + { value: 'cline', label: 'Centred line' }, + { value: 'p2p', label: 'Peak to peak' }, + { value: 'point', label: 'Points' }, +] + +export const WAVE_POSITIONS: { value: WavePosition; label: string }[] = [ + { value: 'top', label: 'Top' }, + { value: 'center', label: 'Centre' }, + { value: 'bottom', label: 'Bottom' }, +] + +/** The same defaults as the waveform in Gecko Notes' video dialog. Written out in full + * so the controls show what is drawn rather than a blank that means "the default". */ +export const DEFAULT_WAVEFORM: Required = { + mode: 'line', + color: '#00ff41', + opacity: 0.7, + position: 'bottom', + height_pct: 22, + scrim: 0.45, +} diff --git a/frontend/src/views/ProjectView.tsx b/frontend/src/views/ProjectView.tsx index be85607..40d83cd 100644 --- a/frontend/src/views/ProjectView.tsx +++ b/frontend/src/views/ProjectView.tsx @@ -11,6 +11,7 @@ import SoundtrackPanel from '@/components/SoundtrackPanel' import IssueList from '@/components/IssueList' import JsonPanel from '@/components/JsonPanel' import ProjectDefaults from '@/components/ProjectDefaults' +import WaveformSettings from '@/components/WaveformSettings' import SceneList from '@/components/SceneList' import { RUNNING } from '@/api/generations' import { gamIdsIn, placeHit, resolveSearch, unresolvedSearches } from '@/spec/footage' @@ -18,7 +19,7 @@ import { canGenerate, latestByKey, unmadeGenerates } from '@/spec/generation' import { useGenerationStore } from '@/stores/generations' import { useProjectStore } from '@/stores/projects' -type Tab = 'scenes' | 'soundtrack' | 'render' | 'json' | 'history' +type Tab = 'scenes' | 'soundtrack' | 'settings' | 'render' | 'json' | 'history' /** What the footage search is open for: one scene, or a placeholder to resolve. */ type Finding = { kind: 'scene'; index: number } | { kind: 'resolve'; key: string } @@ -26,6 +27,7 @@ type Finding = { kind: 'scene'; index: number } | { kind: 'resolve'; key: string const TABS: { id: Tab; label: string }[] = [ { id: 'scenes', label: 'Scenes' }, { id: 'soundtrack', label: 'Soundtrack' }, + { id: 'settings', label: 'Settings' }, { id: 'render', label: 'Render' }, { id: 'json', label: 'JSON' }, { id: 'history', label: 'History' }, @@ -283,7 +285,6 @@ export default function ProjectView() { Could not show pictures from the asset library: {gamError}

)} - {tab === 'scenes' && } {tab === 'scenes' && ( )} + {tab === 'settings' && ( + // Everything that applies to the whole video rather than one scene. The home + // for the rest of the project-wide settings, and for templates over them. +
+ + +
+ )} {tab === 'render' && (
From b01f86b0f3c96bc972f7b4add1adc60faecc85b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 04:53:42 +0000 Subject: [PATCH 2/2] Add Output and Voice sections to the Settings tab Output: shape, size, quality, frame rate, subtitles, chapter marks and cover picture. Voice: a named voice, speaking speed and the three pauses. Both write only what differs from the spec's own defaults, and remove a section left empty. The spec also has `voice.provider` and `voice.expressivity`, which the renderer does not read yet (it warns about expressivity), so there are no controls for them: a control that does nothing is worse than none. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01XHkHXgxVVH3dn6EAVL9rbN --- frontend/src/api/projects.ts | 28 ++++- frontend/src/components/OutputSettings.tsx | 117 ++++++++++++++++++ .../components/OutputVoiceSettings.test.tsx | 70 +++++++++++ frontend/src/components/VoiceSettings.tsx | 81 ++++++++++++ frontend/src/spec/settings.test.ts | 37 ++++++ frontend/src/spec/settings.ts | 72 ++++++++++- frontend/src/views/ProjectView.tsx | 4 + 7 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/OutputSettings.tsx create mode 100644 frontend/src/components/OutputVoiceSettings.test.tsx create mode 100644 frontend/src/components/VoiceSettings.tsx create mode 100644 frontend/src/spec/settings.test.ts diff --git a/frontend/src/api/projects.ts b/frontend/src/api/projects.ts index 7a1ed21..528ada4 100644 --- a/frontend/src/api/projects.ts +++ b/frontend/src/api/projects.ts @@ -186,6 +186,31 @@ export interface Segment { [key: string]: unknown } +export type Aspect = '16:9' | '9:16' | '1:1' +export type Resolution = '720p' | '1080p' | '4k' +export type Quality = 'preview' | 'standard' | 'high' +export type SubtitleMode = 'off' | 'sidecar' | 'soft' | 'burn' + +/** What the finished file is: its shape, size and extras. */ +export interface Output { + aspect?: Aspect + resolution?: Resolution + quality?: Quality + fps?: number + subtitles?: SubtitleMode + chapters?: boolean + thumbnail?: boolean + [key: string]: unknown +} + +/** The narrator, for every scene that doesn't give its own. */ +export interface Voice { + voice?: string + speed?: number + pauses?: { paragraph_ms?: number; heading_ms?: number; segment_end_ms?: number } + [key: string]: unknown +} + export interface VideoSpec { spec_version?: string id?: string @@ -195,7 +220,8 @@ export interface VideoSpec { segments: Segment[] audio_tracks?: AudioTrack[] credits?: Credits | null - output?: { aspect?: '16:9' | '9:16' | '1:1'; [key: string]: unknown } + output?: Output + voice?: Voice /** Drawn over every scene that doesn't opt out. A block present means on. */ chrome?: { waveform?: Waveform; [key: string]: unknown } style?: { diff --git a/frontend/src/components/OutputSettings.tsx b/frontend/src/components/OutputSettings.tsx new file mode 100644 index 0000000..efc0779 --- /dev/null +++ b/frontend/src/components/OutputSettings.tsx @@ -0,0 +1,117 @@ +import type { Aspect, Quality, Resolution, SubtitleMode, VideoSpec } from '@/api/projects' +import { OUTPUT_DEFAULTS, setOutput } from '@/spec/settings' + +interface Props { + spec: VideoSpec + onChange: (next: VideoSpec) => void +} + +const ASPECTS: { value: Aspect; label: string }[] = [ + { value: '16:9', label: 'Widescreen 16:9' }, + { value: '9:16', label: 'Vertical 9:16' }, + { value: '1:1', label: 'Square 1:1' }, +] + +const RESOLUTIONS: { value: Resolution; label: string }[] = [ + { value: '720p', label: '720p' }, + { value: '1080p', label: '1080p' }, + { value: '4k', label: '4K' }, +] + +const QUALITIES: { value: Quality; label: string }[] = [ + { value: 'preview', label: 'Fast, larger file' }, + { value: 'standard', label: 'Standard' }, + { value: 'high', label: 'High, slower' }, +] + +const SUBTITLES: { value: SubtitleMode; label: string }[] = [ + { value: 'off', label: 'None' }, + { value: 'sidecar', label: 'A separate .srt file' }, + { value: 'soft', label: 'Inside the video, can be switched off' }, + { value: 'burn', label: 'Drawn onto the picture' }, +] + +/** + * The finished file: its shape, size, frame rate and extras. Aspect is also what the + * scene editor frames a camera move against, so changing it changes those previews. + */ +export default function OutputSettings({ spec, onChange }: Props) { + const o = { ...OUTPUT_DEFAULTS, ...(spec.output ?? {}) } + const select = ( + label: string, + value: T, + options: { value: T; label: string }[], + set: (v: T) => void + ) => ( + + ) + + return ( +
+

The video file

+
+ {select('Shape', o.aspect, ASPECTS, (aspect) => + onChange(setOutput(spec, { aspect })) + )} + {select('Size', o.resolution, RESOLUTIONS, (resolution) => + onChange(setOutput(spec, { resolution })) + )} + {select('Quality', o.quality, QUALITIES, (quality) => + onChange(setOutput(spec, { quality })) + )} + + {select('Subtitles', o.subtitles, SUBTITLES, (subtitles) => + onChange(setOutput(spec, { subtitles })) + )} +
+
+ + +
+
+ ) +} diff --git a/frontend/src/components/OutputVoiceSettings.test.tsx b/frontend/src/components/OutputVoiceSettings.test.tsx new file mode 100644 index 0000000..1400a70 --- /dev/null +++ b/frontend/src/components/OutputVoiceSettings.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { describe, expect, it } from 'vitest' +import type { VideoSpec } from '@/api/projects' +import OutputSettings from '@/components/OutputSettings' +import VoiceSettings from '@/components/VoiceSettings' + +const start: VideoSpec = { title: 'T', segments: [] } + +let latest = start +function Harness() { + const [spec, setSpec] = useState(start) + latest = spec + return ( + <> + + + + ) +} + +describe('Output and voice settings', () => { + it('shows the spec defaults and changes the file settings', async () => { + render() + expect(screen.getByLabelText('Shape')).toHaveValue('16:9') + expect(screen.getByLabelText('Frames per second')).toHaveValue(30) + + fireEvent.change(screen.getByLabelText('Shape'), { target: { value: '9:16' } }) + fireEvent.change(screen.getByLabelText('Size'), { target: { value: '4k' } }) + fireEvent.change(screen.getByLabelText('Subtitles'), { target: { value: 'burn' } }) + await userEvent.click(screen.getByRole('checkbox', { name: /Cover picture/ })) + expect(latest.output).toEqual({ + aspect: '9:16', + resolution: '4k', + subtitles: 'burn', + thumbnail: false, + }) + }) + + it('ignores a frame rate the spec would refuse', () => { + render() + fireEvent.change(screen.getByLabelText('Frames per second'), { + target: { value: '120' }, + }) + expect(latest.output).toBeUndefined() + fireEvent.change(screen.getByLabelText('Frames per second'), { + target: { value: '24' }, + }) + expect(latest.output).toEqual({ fps: 24 }) + }) + + it('sets the narrator: a named voice, a speed and the pauses', () => { + render() + fireEvent.change(screen.getByLabelText('Narrator voice'), { + target: { value: 'Rachel' }, + }) + fireEvent.change(screen.getByLabelText('Speaking speed'), { + target: { value: '0.9' }, + }) + fireEvent.change(screen.getByLabelText('Pause at a heading, milliseconds'), { + target: { value: '1200' }, + }) + expect(latest.voice).toEqual({ + voice: 'Rachel', + speed: 0.9, + pauses: { heading_ms: 1200 }, + }) + }) +}) diff --git a/frontend/src/components/VoiceSettings.tsx b/frontend/src/components/VoiceSettings.tsx new file mode 100644 index 0000000..74e52b6 --- /dev/null +++ b/frontend/src/components/VoiceSettings.tsx @@ -0,0 +1,81 @@ +import type { VideoSpec } from '@/api/projects' +import { VOICE_DEFAULTS, setVoice } from '@/spec/settings' + +interface Props { + spec: VideoSpec + onChange: (next: VideoSpec) => void +} + +const PAUSES = [ + { key: 'paragraph_ms', label: 'Between paragraphs', max: 3000 }, + { key: 'heading_ms', label: 'At a heading', max: 5000 }, + { key: 'segment_end_ms', label: 'At the end of a scene', max: 3000 }, +] as const + +/** + * The narrator for the whole video. A scene's own speed (in its settings) wins over + * this one. Left blank, the voice is whichever one the account is set to in Notes; + * naming one pins it, and it has to be a name that account's speech engine knows. + */ +export default function VoiceSettings({ spec, onChange }: Props) { + const voice = spec.voice + const pauses = { ...VOICE_DEFAULTS.pauses, ...(voice?.pauses ?? {}) } + + return ( +
+

Narration

+
+ + +
+
+ Pause + {PAUSES.map((p) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/spec/settings.test.ts b/frontend/src/spec/settings.test.ts new file mode 100644 index 0000000..9b6890e --- /dev/null +++ b/frontend/src/spec/settings.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import type { VideoSpec } from '@/api/projects' +import { setOutput, setVoice } from '@/spec/settings' + +const blank: VideoSpec = { title: 'T', segments: [] } + +describe('setOutput', () => { + it('writes only what differs from the default, and removes the section when empty', () => { + const vertical = setOutput(blank, { aspect: '9:16', fps: 30 }) + expect(vertical.output).toEqual({ aspect: '9:16' }) + expect(setOutput(vertical, { aspect: '16:9' }).output).toBeUndefined() + }) + + it('keeps fields it was not asked about, such as ones a newer spec added', () => { + const spec = { ...blank, output: { quality: 'high', future: 1 } } as VideoSpec + expect(setOutput(spec, { chapters: false }).output).toEqual({ + quality: 'high', + future: 1, + chapters: false, + }) + }) +}) + +describe('setVoice', () => { + it('pins a named voice and clears it when blanked', () => { + const named = setVoice(blank, { voice: ' Rachel ' }) + expect(named.voice).toEqual({ voice: 'Rachel' }) + expect(setVoice(named, { voice: '' }).voice).toBeUndefined() + }) + + it('stores a pause only when it is not the default one, and speed 1 not at all', () => { + const slow = setVoice(blank, { speed: 0.9, pauses: { heading_ms: 1200 } }) + expect(slow.voice).toEqual({ speed: 0.9, pauses: { heading_ms: 1200 } }) + const back = setVoice(setVoice(slow, { speed: 1 }), { pauses: { heading_ms: 800 } }) + expect(back.voice).toBeUndefined() + }) +}) diff --git a/frontend/src/spec/settings.ts b/frontend/src/spec/settings.ts index dc597ce..0cd8eb9 100644 --- a/frontend/src/spec/settings.ts +++ b/frontend/src/spec/settings.ts @@ -1,4 +1,6 @@ -import type { VideoSpec, Waveform } from '@/api/projects' +import type { Output, VideoSpec, Voice, Waveform } from '@/api/projects' + +type Pauses = NonNullable /** * Project-wide settings as pure functions from one spec to the next (the rules of @@ -17,3 +19,71 @@ export function setWaveform(spec: VideoSpec, waveform: Waveform | null): VideoSp delete rest.chrome return rest } + +/** + * What the spec means by a field that is left out. These are the model's own defaults + * (backend `app/spec/model.py`); the controls show them, and a setting put back to its + * default is removed rather than written out, so a spec keeps to what was actually said. + */ +export const OUTPUT_DEFAULTS = { + aspect: '16:9', + resolution: '1080p', + quality: 'standard', + fps: 30, + subtitles: 'sidecar', + chapters: true, + thumbnail: true, +} as const + +export const VOICE_DEFAULTS = { + speed: 1, + pauses: { paragraph_ms: 350, heading_ms: 800, segment_end_ms: 600 }, +} as const + +export function setOutput(spec: VideoSpec, patch: Partial): VideoSpec { + const output: Record = { ...(spec.output ?? {}), ...patch } + for (const [key, value] of Object.entries(output)) { + if ( + value === undefined || + value === (OUTPUT_DEFAULTS as Record)[key] + ) + delete output[key] + } + return withSection(spec, 'output', output) +} + +export function setVoice( + spec: VideoSpec, + patch: { voice?: string; speed?: number; pauses?: Partial } +): VideoSpec { + const voice: Record = { ...(spec.voice ?? {}) } + if ('voice' in patch) voice.voice = patch.voice?.trim() ? patch.voice.trim() : undefined + if ('speed' in patch) voice.speed = patch.speed + if (patch.pauses) { + const pauses: Record = { + ...(spec.voice?.pauses ?? {}), + ...patch.pauses, + } + for (const [key, value] of Object.entries(pauses)) { + if (value === (VOICE_DEFAULTS.pauses as Record)[key]) + delete pauses[key] + } + voice.pauses = Object.keys(pauses).length > 0 ? pauses : undefined + } + if (voice.speed === VOICE_DEFAULTS.speed) voice.speed = undefined + for (const [key, value] of Object.entries(voice)) + if (value === undefined) delete voice[key] + return withSection(spec, 'voice', voice) +} + +/** A section with nothing left in it is removed, not kept as `{}`. */ +function withSection( + spec: VideoSpec, + key: 'output' | 'voice', + value: Record +): VideoSpec { + const next: Record = { ...spec } + if (Object.keys(value).length > 0) next[key] = value + else delete next[key] + return next as VideoSpec +} diff --git a/frontend/src/views/ProjectView.tsx b/frontend/src/views/ProjectView.tsx index 40d83cd..fc435d8 100644 --- a/frontend/src/views/ProjectView.tsx +++ b/frontend/src/views/ProjectView.tsx @@ -10,7 +10,9 @@ import RenderPanel from '@/components/RenderPanel' import SoundtrackPanel from '@/components/SoundtrackPanel' import IssueList from '@/components/IssueList' import JsonPanel from '@/components/JsonPanel' +import OutputSettings from '@/components/OutputSettings' import ProjectDefaults from '@/components/ProjectDefaults' +import VoiceSettings from '@/components/VoiceSettings' import WaveformSettings from '@/components/WaveformSettings' import SceneList from '@/components/SceneList' import { RUNNING } from '@/api/generations' @@ -338,6 +340,8 @@ export default function ProjectView() { // Everything that applies to the whole video rather than one scene. The home // for the rest of the project-wide settings, and for templates over them.
+ +