diff --git a/.gitignore b/.gitignore index 3d3e2e7..b53f0c7 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,8 @@ !studio-app/package-lock.json !studio-app/package.json !studio-app/src/AccountPassword.jsx +!studio-app/src/ShotLook.jsx +!studio-app/src/shot-look.js !studio-app/src/TakeNextStep.jsx !studio-app/src/ShotCardPrompt.jsx !studio-app/src/shot-mention.js diff --git a/studio-app/src/ShotLook.jsx b/studio-app/src/ShotLook.jsx new file mode 100644 index 0000000..9d002e5 --- /dev/null +++ b/studio-app/src/ShotLook.jsx @@ -0,0 +1,75 @@ +// A shot's look and camera move from pictures, both Auto by default (h3-studio#1043). The choice +// reaches the prompt on the server. A look can also become the whole story's style. +import React from 'react'; +import { useI18n } from './i18n/I18nProvider.jsx'; +import { Button } from './ui/Button.jsx'; +import { Disclosure } from './ui/Disclosure.jsx'; +import { useStoryboardUI as useStore } from './storyboard-ui-context.jsx'; +import { CAMERAS, LOOKS, cameraArrow, lookPicture, styleOf } from './shot-look.js'; + +export function ShotLook({ shot, onChange }) { + const { t } = useI18n(); + const { state, dispatch } = useStore(); + const look = shot.look || '', + camera = shot.camera || ''; + const storyStyle = state.project.style || ''; + const lookStyle = look ? styleOf(t, look) : ''; + const summary = [ + t('shotLook.summaryLook', { name: t('shotLook.look.' + (look || 'auto')) }), + t('shotLook.summaryCamera', { name: t('shotLook.camera.' + (camera || 'auto')) }), + ].join(' · '); + return ( + + {t('shotLook.title')} {summary} + + } + > +
+ {['', ...LOOKS].map((value) => ( + + ))} +
+ {look && ( + + )} +
+ {['', ...CAMERAS].map((value) => ( + + ))} +
+
+ ); +} diff --git a/studio-app/src/StoryboardComponents.jsx b/studio-app/src/StoryboardComponents.jsx index d81e444..c1973fb 100644 --- a/studio-app/src/StoryboardComponents.jsx +++ b/studio-app/src/StoryboardComponents.jsx @@ -80,6 +80,7 @@ import { queuePositions, } from './storyboard-ui-model.js'; import { useStoryboardUI as useStore } from './storyboard-ui-context.jsx'; +import { ShotLook } from './ShotLook.jsx'; import { ShotCardPrompt } from './ShotCardPrompt.jsx'; import { VoicePicker } from './VoicePicker.jsx'; import { useShotReadiness } from './shot-readiness.js'; @@ -2098,6 +2099,7 @@ export function ShotEditor({ shotId, onClose, inline, wide, onWide }) { placeholder={tr('storyboard.finish11')} /> + {!restoredExact && } {restoredExact ? (

{tr('workbench.restoredBindings')}

diff --git a/studio-app/src/demo-v2.css b/studio-app/src/demo-v2.css index 482fc46..33f9eeb 100644 --- a/studio-app/src/demo-v2.css +++ b/studio-app/src/demo-v2.css @@ -3607,3 +3607,39 @@ h4, display: none; } } + +/* Look and camera for a shot (#1043): closed until opened, so the editor stays short. */ +.shot-look > summary { + cursor: pointer; +} +.shot-look-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); + gap: 8px; + margin: 8px 0; +} +.shot-look-pick { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + height: auto; + padding: 4px; +} +.shot-look-pick img, +.shot-look-auto { + display: grid; + place-items: center; + width: 100%; + height: auto; + aspect-ratio: 16 / 9; + object-fit: cover; + background: var(--line); + border-radius: 4px; +} +.shot-look-cameras { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} diff --git a/studio-app/src/i18n/storyboardCatalogs.js b/studio-app/src/i18n/storyboardCatalogs.js index 48c6149..1b816dc 100644 --- a/studio-app/src/i18n/storyboardCatalogs.js +++ b/studio-app/src/i18n/storyboardCatalogs.js @@ -104,6 +104,36 @@ export const storyboardCatalogs = { 'storyboard.sceneN': 'Scene {number}', 'storyboard.pricing': 'Getting the price for your shots…', 'storyboard.addShot': 'Add shot', + 'shotLook.title': 'Look and camera', + 'shotLook.summaryLook': 'Look: {name}', + 'shotLook.summaryCamera': 'Camera: {name}', + 'shotLook.lookLabel': 'Look', + 'shotLook.cameraLabel': 'Camera move', + 'shotLook.useForStory': 'Use for the whole story', + 'shotLook.isStory': "This is the story's look", + 'shotLook.look.auto': 'Auto', + 'shotLook.look.film': 'Film', + 'shotLook.look.anime': 'Anime', + 'shotLook.look.watercolor': 'Watercolour', + 'shotLook.look.noir': 'Black and white', + 'shotLook.look.soft3d': 'Soft 3D', + 'shotLook.look.storybook': 'Storybook', + 'shotLook.style.film': + 'Live-action cinema, natural skin, shallow depth of field, soft film grain.', + 'shotLook.style.anime': 'Hand-drawn Japanese-style animation, clean line art, flat colours.', + 'shotLook.style.watercolor': 'Watercolour painting, soft washes, visible paper texture.', + 'shotLook.style.noir': 'Black and white, hard shadows, high contrast, moody.', + 'shotLook.style.soft3d': 'Polished 3D animated film, soft rounded forms, warm light.', + 'shotLook.style.storybook': + 'Hand-painted storybook illustration, warm gouache, gentle colours.', + 'shotLook.camera.auto': 'Auto', + 'shotLook.camera.static': 'Still', + 'shotLook.camera.push': 'Push in', + 'shotLook.camera.pull': 'Pull back', + 'shotLook.camera.pan': 'Pan', + 'shotLook.camera.track': 'Follow', + 'shotLook.camera.handheld': 'Handheld', + 'shotLook.camera.orbit': 'Circle', 'takeNext.title': 'Next', 'takeNext.use': 'Use this take', 'takeNext.carry': 'Start the next shot from this frame', @@ -676,6 +706,34 @@ export const storyboardCatalogs = { 'storyboard.sceneN': '场景 {number}', 'storyboard.pricing': '正在计算这些镜头的费用…', 'storyboard.addShot': '加镜头', + 'shotLook.title': '画面和运镜', + 'shotLook.summaryLook': '画面:{name}', + 'shotLook.summaryCamera': '运镜:{name}', + 'shotLook.lookLabel': '画面', + 'shotLook.cameraLabel': '运镜', + 'shotLook.useForStory': '用于整个故事', + 'shotLook.isStory': '已是整个故事的画面', + 'shotLook.look.auto': '自动', + 'shotLook.look.film': '电影', + 'shotLook.look.anime': '动画', + 'shotLook.look.watercolor': '水彩', + 'shotLook.look.noir': '黑白', + 'shotLook.look.soft3d': '柔和 3D', + 'shotLook.look.storybook': '绘本', + 'shotLook.style.film': '真人电影质感,自然肤色,浅景深,柔和胶片颗粒。', + 'shotLook.style.anime': '日式手绘动画,线条干净,平涂色彩。', + 'shotLook.style.watercolor': '水彩画,柔和晕染,可见纸张纹理。', + 'shotLook.style.noir': '黑白影像,硬朗阴影,高对比,情绪浓重。', + 'shotLook.style.soft3d': '精致 3D 动画电影,圆润造型,温暖光线。', + 'shotLook.style.storybook': '手绘绘本插画,温暖水粉,柔和色彩。', + 'shotLook.camera.auto': '自动', + 'shotLook.camera.static': '固定', + 'shotLook.camera.push': '推近', + 'shotLook.camera.pull': '拉远', + 'shotLook.camera.pan': '横摇', + 'shotLook.camera.track': '跟拍', + 'shotLook.camera.handheld': '手持', + 'shotLook.camera.orbit': '环绕', 'takeNext.title': '下一步', 'takeNext.use': '用这个版本', 'takeNext.carry': '下个镜头从这一帧开始', diff --git a/studio-app/src/shot-look.js b/studio-app/src/shot-look.js new file mode 100644 index 0000000..60e8bf4 --- /dev/null +++ b/studio-app/src/shot-look.js @@ -0,0 +1,28 @@ +// Closed lists shared with the server's storyboard check (h3-studio#1043). Empty means Auto. +export const LOOKS = Object.freeze(['film', 'anime', 'watercolor', 'noir', 'soft3d', 'storybook']); +export const CAMERAS = Object.freeze([ + 'static', + 'push', + 'pull', + 'pan', + 'track', + 'handheld', + 'orbit', +]); + +const ARROWS = { + static: '·', + push: '↗', + pull: '↙', + pan: '→', + track: '⇉', + handheld: '~', + orbit: '↻', +}; + +export const validLook = (value) => value === '' || LOOKS.includes(value); +export const validCamera = (value) => value === '' || CAMERAS.includes(value); +export const cameraArrow = (value) => ARROWS[value] || ''; +export const lookPicture = (value) => '/v1/story-chats/starter-pictures/look-' + value; +// The words a look puts in the story's style, in the person's language, so they can read and edit it. +export const styleOf = (t, value) => t('shotLook.style.' + value); diff --git a/studio-app/src/storyboard-actions.js b/studio-app/src/storyboard-actions.js index f2aa24f..5923d5e 100644 --- a/studio-app/src/storyboard-actions.js +++ b/studio-app/src/storyboard-actions.js @@ -1,4 +1,5 @@ /* Pure edits of the existing document. Cloud IO and execution stay in the controller. */ +import { validCamera, validLook } from './shot-look.js'; import { children, ancestors } from './store.js'; import { detachDeletedReferences } from './entity-references.js'; import { @@ -115,6 +116,8 @@ function validShotScalars(patch) { ]) if (owns(patch, key) && !text(patch[key], max)) return false; if (owns(patch, 'mode') && !['FL', 'REF'].includes(patch.mode)) return false; + if (owns(patch, 'look') && !validLook(patch.look)) return false; + if (owns(patch, 'camera') && !validCamera(patch.camera)) return false; if (owns(patch, 'quality') && !['draft', 'final'].includes(patch.quality)) return false; if ( owns(patch, 'duration') && @@ -185,6 +188,8 @@ function validShotPatch(project, patch) { 'place', 'props', 'continuePrev', + 'look', + 'camera', 'quality', 'seedLock', 'aspect', @@ -228,7 +233,9 @@ function patchShot(project, shot, patch) { if ( !shot.data.storyboard && shot.data.h3?.explicitReferences && - Object.keys(patch).some((key) => ['cast', 'place', 'props', 'continuePrev'].includes(key)) + Object.keys(patch).some((key) => + ['cast', 'place', 'props', 'continuePrev', 'look', 'camera'].includes(key), + ) ) return false; const clean = { ...patch }; @@ -237,7 +244,7 @@ function patchShot(project, shot, patch) { !shot.data.storyboard && !shot.data.h3?.explicitReferences && Object.keys(clean).some((key) => - ['mode', 'cast', 'place', 'props', 'continuePrev'].includes(key), + ['mode', 'cast', 'place', 'props', 'continuePrev', 'look', 'camera'].includes(key), ) ) materializeLegacyContext(project, shot, clean); diff --git a/studio-app/src/storyboard-actions.test.js b/studio-app/src/storyboard-actions.test.js index 3c011fc..e1d90e0 100644 --- a/studio-app/src/storyboard-actions.test.js +++ b/studio-app/src/storyboard-actions.test.js @@ -125,6 +125,19 @@ test('scene header rejects foreign, absent and non-scene targets without changin assert.equal(get(project, added.id).parentId, 'foreign-scene'); }); +test('a shot look and camera come from closed lists, and empty means Auto', () => { + const project = fixture(), + shot = project.entities.find((entity) => entity.type === 'shot'); + assert.notEqual(act(project, { type: 'update', id: shot.id, patch: { look: 'noir' } }), false); + assert.notEqual(act(project, { type: 'update', id: shot.id, patch: { camera: 'push' } }), false); + const block = project.entities.find((entity) => entity.id === shot.id).data.storyboard; + assert.equal(block.look, 'noir'); + assert.equal(block.camera, 'push'); + assert.notEqual(act(project, { type: 'update', id: shot.id, patch: { look: '' } }), false); + assert.equal(act(project, { type: 'update', id: shot.id, patch: { look: 'other' } }), false); + assert.equal(act(project, { type: 'update', id: shot.id, patch: { camera: 'crane' } }), false); +}); + test('a shot added after a card lands right behind it, in its scene', () => { const project = fixture(), first = project.entities.find((entity) => entity.type === 'shot'); diff --git a/studio-app/src/storyboard-domain.js b/studio-app/src/storyboard-domain.js index c3a4066..5fe9b3f 100644 --- a/studio-app/src/storyboard-domain.js +++ b/studio-app/src/storyboard-domain.js @@ -228,6 +228,8 @@ function shotView(project, s, { jobs, assets }) { place: shotPlace(s, b, scene), props: b.propIds || [], continuePrev: !!b.continuePrevious, + look: b.look || '', + camera: b.camera || '', previousFrame: b.previousFrame?.frameAssetId, quality: b.quality ?? s.data.qualityIntent ?? 'draft', seedLock: !!s.data.h3?.controls?.seed, @@ -797,7 +799,7 @@ function patchCreatorControls(shot, patch) { shot.data.h3 = { ...shot.data.h3, controls }; } // A legacy shot (no storyboard block) stays legacy unless the patch needs storyboard fields. -const STORYBOARD_ONLY = ['cast', 'place', 'props', 'continuePrev']; +const STORYBOARD_ONLY = ['cast', 'place', 'props', 'continuePrev', 'look', 'camera']; const staysLegacy = (shot, patch) => !shot.data.storyboard && !Object.keys(patch).some((key) => STORYBOARD_ONLY.includes(key)) && @@ -875,6 +877,8 @@ const STORYBOARD_FIELDS = { end: 'lastFrameId', continuePrev: 'continuePrevious', props: 'propIds', + look: 'look', + camera: 'camera', }; // The storyboard block, cast and extra references for a storyboard shot.