From 419f6c9e669f3971abf4489e5c9e0979093ada20 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 10:24:26 +0000 Subject: [PATCH] Offer Camera on generated pictures, and set the project's default move The scene card worked out whether its picture was a still with its own lookup, which knew GAM and search refs but not generated ones, so a generated picture never got a Camera control. It now uses footageOf, as the thumbnail already did. A scene's "Project default" camera meant style.motion, which defaults to no move and which nothing in the editor could set. The Scenes tab now has a "For every scene" Camera (any preset, or alternate) with its amount, and each scene's default option names the move it follows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014PPyHwCtvqQbpW2Hvrb63Z --- .../src/components/ProjectDefaults.test.tsx | 43 +++++++++++ frontend/src/components/ProjectDefaults.tsx | 76 +++++++++++++++++++ .../src/components/SceneSettings.test.tsx | 31 ++++++++ frontend/src/components/SceneSettings.tsx | 20 +++-- frontend/src/spec/scene.ts | 35 +++++++++ frontend/src/views/ProjectView.tsx | 2 + 6 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/ProjectDefaults.test.tsx create mode 100644 frontend/src/components/ProjectDefaults.tsx diff --git a/frontend/src/components/ProjectDefaults.test.tsx b/frontend/src/components/ProjectDefaults.test.tsx new file mode 100644 index 0000000..1687966 --- /dev/null +++ b/frontend/src/components/ProjectDefaults.test.tsx @@ -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 +} + +describe('ProjectDefaults', () => { + it('sets the move every still follows, and removes it again when set back to still', () => { + render() + 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 } }) + }) +}) diff --git a/frontend/src/components/ProjectDefaults.tsx b/frontend/src/components/ProjectDefaults.tsx new file mode 100644 index 0000000..a958b6f --- /dev/null +++ b/frontend/src/components/ProjectDefaults.tsx @@ -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 ( +
+ + For every scene + + + {effect !== 'none' && ( + + )} + + Scenes with a camera move of their own keep it. + +
+ ) +} diff --git a/frontend/src/components/SceneSettings.test.tsx b/frontend/src/components/SceneSettings.test.tsx index f12d2e5..8e54aca 100644 --- a/frontend/src/components/SceneSettings.test.tsx +++ b/frontend/src/components/SceneSettings.test.tsx @@ -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( + {}} + /> + ) + expect( + screen.getByRole('option', { name: 'Project default (Alternate zooms and pans)' }) + ).toBeInTheDocument() + }) }) diff --git a/frontend/src/components/SceneSettings.tsx b/frontend/src/components/SceneSettings.tsx index e6bcfd4..1ba94c7 100644 --- a/frontend/src/components/SceneSettings.tsx +++ b/frontend/src/components/SceneSettings.tsx @@ -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' @@ -20,15 +22,6 @@ interface Props { const ASPECTS: Record = { '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. @@ -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 @@ -126,7 +122,9 @@ export default function SceneSettings({ ) }} > - + {MOTIONS.map((m) => (