Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion frontend/src/api/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
117 changes: 117 additions & 0 deletions frontend/src/components/OutputSettings.tsx
Original file line number Diff line number Diff line change
@@ -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 = <T extends string>(
label: string,
value: T,
options: { value: T; label: string }[],
set: (v: T) => void
) => (
<label className="flex items-center gap-1">
{label}
<select
className="input w-auto py-0.5"
aria-label={label}
value={value}
onChange={(e) => set(e.target.value as T)}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
)

return (
<section aria-label="Output" className="card space-y-2 p-3 text-sm">
<h2 className="font-medium text-gray-900 dark:text-gray-100">The video file</h2>
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-600 dark:text-gray-300">
{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 }))
)}
<label className="flex items-center gap-1">
Frames per second
<input
type="number"
min="12"
max="60"
step="1"
aria-label="Frames per second"
className="input w-16 py-0.5"
value={o.fps}
onChange={(e) => {
// 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 }))
}}
/>
</label>
{select('Subtitles', o.subtitles, SUBTITLES, (subtitles) =>
onChange(setOutput(spec, { subtitles }))
)}
</div>
<div className="flex flex-wrap items-center gap-4 text-xs text-gray-600 dark:text-gray-300">
<label className="flex items-center gap-1">
<input
type="checkbox"
checked={o.chapters}
onChange={(e) => onChange(setOutput(spec, { chapters: e.target.checked }))}
/>
Chapter marks in the file
</label>
<label className="flex items-center gap-1">
<input
type="checkbox"
checked={o.thumbnail}
onChange={(e) => onChange(setOutput(spec, { thumbnail: e.target.checked }))}
/>
Cover picture
</label>
</div>
</section>
)
}
70 changes: 70 additions & 0 deletions frontend/src/components/OutputVoiceSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<OutputSettings spec={spec} onChange={setSpec} />
<VoiceSettings spec={spec} onChange={setSpec} />
</>
)
}

describe('Output and voice settings', () => {
it('shows the spec defaults and changes the file settings', async () => {
render(<Harness />)
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(<Harness />)
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(<Harness />)
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 },
})
})
})
15 changes: 1 addition & 14 deletions frontend/src/components/OverlayEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, string> = {
waveform: 'Wave',
text: 'Text',
Expand Down
81 changes: 81 additions & 0 deletions frontend/src/components/VoiceSettings.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section aria-label="Narration" className="card space-y-2 p-3 text-sm">
<h2 className="font-medium text-gray-900 dark:text-gray-100">Narration</h2>
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-600 dark:text-gray-300">
<label className="flex items-center gap-1">
Voice
<input
className="input w-40 py-0.5"
aria-label="Narrator voice"
placeholder="The account's voice"
value={voice?.voice ?? ''}
onChange={(e) => onChange(setVoice(spec, { voice: e.target.value }))}
/>
</label>
<label className="flex items-center gap-1">
Speed
<input
type="number"
min="0.5"
max="2"
step="0.05"
aria-label="Speaking speed"
className="input w-16 py-0.5"
value={voice?.speed ?? VOICE_DEFAULTS.speed}
onChange={(e) => {
const speed = Number(e.target.value)
if (speed >= 0.5 && speed <= 2) onChange(setVoice(spec, { speed }))
}}
/>
×
</label>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-600 dark:text-gray-300">
<span className="font-medium">Pause</span>
{PAUSES.map((p) => (
<label key={p.key} className="flex items-center gap-1">
{p.label}
<input
type="number"
min="0"
max={p.max}
step="50"
aria-label={`Pause ${p.label.toLowerCase()}, milliseconds`}
className="input w-20 py-0.5"
value={pauses[p.key]}
onChange={(e) => {
const ms = Math.round(Number(e.target.value))
if (ms >= 0 && ms <= p.max)
onChange(setVoice(spec, { pauses: { [p.key]: ms } }))
}}
/>
ms
</label>
))}
</div>
</section>
)
}
Loading
Loading