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 (
+
+ )
+}
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) => (
)}
+ {tab === 'scenes' && }
{tab === 'scenes' && (