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
43 changes: 43 additions & 0 deletions frontend/src/components/ProjectDefaults.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it } from 'vitest'
import type { VideoSpec } from '@/api/projects'
import ProjectDefaults from '@/components/ProjectDefaults'

const start: VideoSpec = {
title: 'T',
style: { transition: { style: 'fade', duration: 0.6 } },
segments: [],
}

let latest = start
function Harness() {
const [spec, setSpec] = useState(start)
latest = spec
return <ProjectDefaults spec={spec} onChange={setSpec} />
}

describe('ProjectDefaults', () => {
it('sets the move every still follows, and removes it again when set back to still', () => {
render(<Harness />)
expect(screen.queryByLabelText('Default Ken Burns amount')).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: /two frames/ })).not.toBeInTheDocument()

fireEvent.change(screen.getByLabelText('Default Ken Burns move'), {
target: { value: 'alternate' },
})
expect(latest.style?.motion).toEqual({ effect: 'alternate' })

fireEvent.change(screen.getByLabelText('Default Ken Burns amount'), {
target: { value: '0.2' },
})
expect(latest.style?.motion).toEqual({ effect: 'alternate', amount: 0.2 })
fireEvent.change(screen.getByLabelText('Default Ken Burns amount'), {
target: { value: '' },
})
fireEvent.change(screen.getByLabelText('Default Ken Burns move'), {
target: { value: 'none' },
})
expect(latest.style).toEqual({ transition: { style: 'fade', duration: 0.6 } })
})
})
76 changes: 76 additions & 0 deletions frontend/src/components/ProjectDefaults.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { VideoSpec } from '@/api/projects'
import { DEFAULT_MOTION_AMOUNT, PROJECT_MOTIONS, setProjectMotion } from '@/spec/scene'

interface Props {
spec: VideoSpec
onChange: (next: VideoSpec) => void
}

/**
* What every scene follows unless it says otherwise. Without this, a scene's
* "Project default" camera named a setting nothing in the editor could change, and
* the spec's own default is no move at all.
*/
export default function ProjectDefaults({ spec, onChange }: Props) {
const motion = spec.style?.motion
const effect = motion?.effect ?? 'none'

return (
<section
aria-label="Project defaults"
className="card flex flex-wrap items-center gap-2 p-3 text-xs text-gray-600 dark:text-gray-300"
>
<span className="font-medium text-gray-900 dark:text-gray-100">
For every scene
</span>
<label className="flex items-center gap-1">
Camera
<select
className="input w-auto py-0.5"
aria-label="Default Ken Burns move"
value={effect}
onChange={(e) =>
onChange(
setProjectMotion(spec, {
effect: e.target.value,
...(motion?.amount !== undefined ? { amount: motion.amount } : {}),
})
)
}
>
{PROJECT_MOTIONS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</label>
{effect !== 'none' && (
<label className="flex items-center gap-1">
by
<input
type="number"
min="0.02"
max="0.5"
step="0.01"
aria-label="Default Ken Burns amount"
className="input w-16 py-0.5"
placeholder={String(DEFAULT_MOTION_AMOUNT)}
value={motion?.amount ?? ''}
onChange={(e) =>
onChange(
setProjectMotion(spec, {
effect,
...(e.target.value ? { amount: Number(e.target.value) } : {}),
})
)
}
/>
</label>
)}
<span className="text-gray-500">
Scenes with a camera move of their own keep it.
</span>
</section>
)
}
31 changes: 31 additions & 0 deletions frontend/src/components/SceneSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,35 @@ describe('SceneSettings', () => {
fireEvent.change(screen.getByLabelText('End x'), { target: { value: '0.9' } })
expect(latest.segments[1].visual?.motion?.to?.x).toBe(0.7)
})

it('offers a camera move for a generated picture, naming the project default', () => {
const spec: VideoSpec = {
...start,
style: { ...start.style, motion: { effect: 'alternate' } },
assets: {
a_gen: {
from: 'generate',
type: 'image',
prompt: 'A map',
result: { from: 'gam', id: 'p' },
},
},
segments: [
start.segments[0],
{ id: 's1', kind: 'scene', visual: { asset: 'a_gen' } },
],
}
render(
<SceneSettings
spec={spec}
index={1}
segment={spec.segments[1]}
gamAssets={{ p: map }}
onChange={() => {}}
/>
)
expect(
screen.getByRole('option', { name: 'Project default (Alternate zooms and pans)' })
).toBeInTheDocument()
})
})
20 changes: 9 additions & 11 deletions frontend/src/components/SceneSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { GamAsset } from '@/api/gam'
import type { Segment, VideoSpec } from '@/api/projects'
import { footageOf } from '@/spec/clip'
import FrameEditor from '@/components/FrameEditor'
import OverlayEditor from '@/components/OverlayEditor'
import {
DEFAULT_PATH,
MOTIONS,
TRANSITIONS,
motionLabel,
setMotion,
setTransition,
} from '@/spec/scene'
Expand All @@ -20,15 +22,6 @@ interface Props {

const ASPECTS: Record<string, number> = { '16:9': 16 / 9, '9:16': 9 / 16, '1:1': 1 }

/** The GAM asset a scene shows, if the spec's ref resolves to one. */
function pictureOf(spec: VideoSpec, segment: Segment, gamAssets: Props['gamAssets']) {
const key = segment.visual?.asset
const ref = key ? spec.assets?.[key] : undefined
const id =
ref?.from === 'gam' ? ref.id : ref?.from === 'search' ? ref.result?.id : undefined
return id ? (gamAssets[id] ?? null) : null
}

/**
* How a scene comes in and how its picture moves. "Project default" removes the
* scene's own setting, so changing the project's default later changes this scene too.
Expand All @@ -41,7 +34,10 @@ export default function SceneSettings({
onChange,
}: Props) {
const transition = segment.transition_in
const picture = pictureOf(spec, segment, gamAssets)
// `footageOf`, not a lookup of its own: a generated picture is as much a still as
// one picked from the library, and a second copy of this resolution once left
// generated pictures without a Camera control.
const picture = footageOf(spec, segment, gamAssets)
const isStill = picture?.asset_type === 'image'
const motion = segment.visual?.motion
const outputAspect = ASPECTS[spec.output?.aspect ?? '16:9'] ?? 16 / 9
Expand Down Expand Up @@ -126,7 +122,9 @@ export default function SceneSettings({
)
}}
>
<option value="">Project default</option>
<option value="">
Project default ({motionLabel(spec.style?.motion?.effect)})
</option>
{MOTIONS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/spec/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,41 @@ export const MOTIONS = [
{ value: 'path', label: 'Move between two frames' },
] as const

/**
* The moves a project can default to. No path, which is one picture's framing and the
* spec refuses project-wide; `alternate` instead, which only makes sense project-wide:
* it cycles zoom and pan scene by scene, so a run of stills doesn't all move alike.
*/
export const PROJECT_MOTIONS = [
...MOTIONS.filter((m) => m.value !== 'path'),
{ value: 'alternate', label: 'Alternate zooms and pans' },
] as const

/** A move in words; unset is the spec's own default, which is no move. */
export function motionLabel(effect: string | undefined): string {
return (
PROJECT_MOTIONS.find((m) => m.value === (effect ?? 'none'))?.label ?? effect ?? ''
)
}

/** The spec's default `amount` for a preset move (`Motion.amount` in model.py). */
export const DEFAULT_MOTION_AMOUNT = 0.12

/**
* The project's default move, which every still without one of its own follows.
* A still default at the default amount removes `style.motion` rather than writing
* the spec's default back in.
*/
export function setProjectMotion(
spec: VideoSpec,
motion: { effect: string; amount?: number }
): VideoSpec {
const style = { ...(spec.style ?? {}) }
if (motion.effect === 'none' && motion.amount === undefined) delete style.motion
else style.motion = motion
return { ...spec, style }
}

export function setTransition(
spec: VideoSpec,
index: number,
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/views/ProjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import RenderPanel from '@/components/RenderPanel'
import SoundtrackPanel from '@/components/SoundtrackPanel'
import IssueList from '@/components/IssueList'
import JsonPanel from '@/components/JsonPanel'
import ProjectDefaults from '@/components/ProjectDefaults'
import SceneList from '@/components/SceneList'
import { RUNNING } from '@/api/generations'
import { gamIdsIn, placeHit, resolveSearch, unresolvedSearches } from '@/spec/footage'
Expand Down Expand Up @@ -282,6 +283,7 @@ export default function ProjectView() {
Could not show pictures from the asset library: {gamError}
</p>
)}
{tab === 'scenes' && <ProjectDefaults spec={draft} onChange={edit} />}
{tab === 'scenes' && (
<SceneList
spec={draft}
Expand Down
Loading