diff --git a/frontend/src/api/projects.ts b/frontend/src/api/projects.ts
index f486fca..528ada4 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
@@ -175,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
@@ -184,7 +220,10 @@ 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?: {
transition?: { style?: string; duration?: number }
motion?: Motion
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
+ ) => (
+
+ {label}
+ set(e.target.value as T)}
+ >
+ {options.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ )
+
+ 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 }))
+ )}
+
+ Frames per second
+ {
+ // Out of range is refused by the spec on save; hold the box to it here.
+ const fps = Math.round(Number(e.target.value))
+ if (fps >= 12 && fps <= 60) onChange(setOutput(spec, { fps }))
+ }}
+ />
+
+ {select('Subtitles', o.subtitles, SUBTITLES, (subtitles) =>
+ onChange(setOutput(spec, { subtitles }))
+ )}
+
+
+
+ onChange(setOutput(spec, { chapters: e.target.checked }))}
+ />
+ Chapter marks in the file
+
+
+ onChange(setOutput(spec, { thumbnail: e.target.checked }))}
+ />
+ Cover picture
+
+
+
+ )
+}
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/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/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 (
+
+ )
+}
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 (
+
+ )
+}
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
new file mode 100644
index 0000000..0cd8eb9
--- /dev/null
+++ b/frontend/src/spec/settings.ts
@@ -0,0 +1,89 @@
+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
+ * `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
+}
+
+/**
+ * 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/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..fc435d8 100644
--- a/frontend/src/views/ProjectView.tsx
+++ b/frontend/src/views/ProjectView.tsx
@@ -10,7 +10,10 @@ 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'
import { gamIdsIn, placeHit, resolveSearch, unresolvedSearches } from '@/spec/footage'
@@ -18,7 +21,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 +29,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 +287,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' && (