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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions studio-app/src/ShotLook.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Disclosure
className="shot-look"
summary={
<>
{t('shotLook.title')} <small className="muted">{summary}</small>
</>
}
>
<div className="shot-look-grid" role="group" aria-label={t('shotLook.lookLabel')}>
{['', ...LOOKS].map((value) => (
<Button
key={value || 'auto'}
variant="option"
className="shot-look-pick"
aria-pressed={look === value}
onClick={() => onChange({ look: value })}
>
{value ? (
<img src={lookPicture(value)} alt="" width="160" height="90" loading="lazy" />
) : (
<span className="shot-look-auto" aria-hidden="true">
A
</span>
)}
<span>{t('shotLook.look.' + (value || 'auto'))}</span>
</Button>
))}
</div>
{look && (
<Button
size="sm"
disabled={storyStyle === lookStyle}
onClick={() => dispatch({ type: 'project', patch: { style: lookStyle } })}
>
{t(storyStyle === lookStyle ? 'shotLook.isStory' : 'shotLook.useForStory')}
</Button>
)}
<div className="shot-look-cameras" role="group" aria-label={t('shotLook.cameraLabel')}>
{['', ...CAMERAS].map((value) => (
<Button
key={value || 'auto'}
size="sm"
variant={camera === value ? 'primary' : 'secondary'}
aria-pressed={camera === value}
onClick={() => onChange({ camera: value })}
>
{value && <span aria-hidden="true">{cameraArrow(value)}</span>}
{t('shotLook.camera.' + (value || 'auto'))}
</Button>
))}
</div>
</Disclosure>
);
}
2 changes: 2 additions & 0 deletions studio-app/src/StoryboardComponents.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2098,6 +2099,7 @@ export function ShotEditor({ shotId, onClose, inline, wide, onWide }) {
placeholder={tr('storyboard.finish11')}
/>
</label>
{!restoredExact && <ShotLook shot={shot} onChange={upd} />}

{restoredExact ? (
<p>{tr('workbench.restoredBindings')}</p>
Expand Down
36 changes: 36 additions & 0 deletions studio-app/src/demo-v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
58 changes: 58 additions & 0 deletions studio-app/src/i18n/storyboardCatalogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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': '下个镜头从这一帧开始',
Expand Down
28 changes: 28 additions & 0 deletions studio-app/src/shot-look.js
Original file line number Diff line number Diff line change
@@ -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);
11 changes: 9 additions & 2 deletions studio-app/src/storyboard-actions.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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') &&
Expand Down Expand Up @@ -185,6 +188,8 @@ function validShotPatch(project, patch) {
'place',
'props',
'continuePrev',
'look',
'camera',
'quality',
'seedLock',
'aspect',
Expand Down Expand Up @@ -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 };
Expand All @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions studio-app/src/storyboard-actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 5 additions & 1 deletion studio-app/src/storyboard-domain.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)) &&
Expand Down Expand Up @@ -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.
Expand Down
Loading