Character dialogue
Portraits, subtitles, voices, and lip sync.
Dialogue scenes are built from three pieces. Each works in React and in JSON projects:
- A character defines a portrait (one image per expression, or a layered PSD), optional lip-sync mouths, and how that character’s subtitles look.
- A character view places the portrait on screen.
- A dialogue line shows a subtitle, plays a voice recording, and can change the view’s expression or mouth while it is on screen.
Your first conversation in React
Save the file below with two portrait images and two voice recordings beside it:
import * as React from 'react';
import { Assets, Composition, Rect, Sequence } from '@celesta/react';
import { Character, CharacterView, Dialogue } from '@celesta/character';
import type { AssetReference } from '@celesta/react';
import type { CharacterViewReference } from '@celesta/character';
const mira = React.createRef<AssetReference>();
const miraView = React.createRef<CharacterViewReference>();
export default function Root() {
return (
<Composition width={1920} height={1080} fps={30} durationInFrames={180}>
<Rect width={1920} height={1080} fill="#20243a" />
<Assets>
<Character
ref={mira}
name="Mira"
portrait={{
defaultExpression: 'calm',
expressions: {
calm: './mira/calm.png',
smile: './mira/smile.png',
},
}}
subtitle={{
x: 960, y: 940, anchorX: 0.5, anchorY: 0.5, maxWidth: 1600,
style: {
fontSize: 56,
fill: { type: 'solid', color: '#ffffff' },
stroke: { paint: { type: 'solid', color: '#3a2d52' }, width: 6 },
align: 'center',
},
}}
/>
</Assets>
<CharacterView ref={miraView} character={mira} x={1400} y={200} />
<Sequence durationInFrames={90}>
<Dialogue character={miraView} audio="./voices/line-01.wav">
Welcome back. Shall we begin?
</Dialogue>
</Sequence>
<Sequence from={90} durationInFrames={90}>
<Dialogue character={miraView} expression="smile"
audio="./voices/line-02.wav">
Every scene starts with a single line.
</Dialogue>
</Sequence>
</Composition>
);
}
- Declare the character inside
<Assets>. The ref lets views refer to it.subtitletakes the same position and style props asText. - Place a view with
<CharacterView>. It shows thedefaultExpressionunless a line says otherwise, and takesx,y,scale, and anchors like any layer. - Time each line by wrapping
<Dialogue>in aSequence. The subtitle, voice, andexpressionapply only while it is active, then the view returns to its default.
Adjust a line with volume, startFrom, playbackRate, or muted, just as on Audio. Setting x, y, or opacity on a Dialogue moves or fades its subtitle, which is handy inside a Transition.
Emphasize part of a line with <Span> inside the Dialogue. A subtitle.render receives the line as written in content: draw it with <Text style={style}>{content}</Text> to keep the emphasis; text is the same line as a plain string.
Automatic lip sync
loadLipSync() listens to a voice recording and spreads the vowels of its transcript across the spoken parts, producing a mouth shape (a, i, u, e, o, or closed) for every moment. Call it from prepare() and pass the result to a Dialogue or CharacterView as lipSync.
import * as React from 'react';
import { Assets, Composition } from '@celesta/react';
import { Character, CharacterView, Dialogue, loadLipSync, loadPsdPreset } from '@celesta/character';
import type { AssetReference } from '@celesta/react';
import type { CharacterViewReference, LipSyncTrack } from '@celesta/character';
const FPS = 30;
const VOICE = './voices/hello.wav';
const LINE = 'こんにちは、はじめまして!';
const hana = React.createRef<AssetReference>();
const hanaView = React.createRef<CharacterViewReference>();
// Filled once by prepare(), before the first frame renders.
let pose: string[] = [];
let voice: LipSyncTrack | null = null;
export async function prepare() {
pose = await loadPsdPreset({ src: './hana/hana.pfv', favorite: 'smile' });
voice = await loadLipSync({ src: VOICE, text: LINE });
}
export default function Root() {
const seconds = (voice?.durationInSeconds ?? 2) + 0.5;
return (
<Composition width={1920} height={1080} fps={FPS}
durationInFrames={Math.ceil(seconds * FPS)}>
<Assets>
<Character
ref={hana}
name="Hana"
portrait={{
type: 'psd',
src: './hana/hana.psd',
layers: pose,
lipSync: {
a: 'face/mouth/a', i: 'face/mouth/i', u: 'face/mouth/u',
e: 'face/mouth/e', o: 'face/mouth/o',
closed: 'face/mouth/closed',
},
}}
subtitle={{
x: 960, y: 960, anchorX: 0.5, anchorY: 0.5,
style: {
fontSize: 60, align: 'center',
fill: { type: 'solid', color: '#ffffff' },
stroke: { paint: { type: 'solid', color: '#c2577a' }, width: 6 },
},
}}
/>
</Assets>
<CharacterView ref={hanaView} character={hana} x={1300} y={120} />
{voice && (
<Dialogue character={hanaView} audio={VOICE} lipSync={voice}>
{LINE}
</Dialogue>
)}
</Composition>
);
}
- Voices must be uncompressed WAV files. Write the transcript in kana or romaji so its vowels can be read; kanji are skipped.
- PSD portraits name their mouth layers by path, such as
face/mouth/a. Layered “tachie” files usually save every folder hidden, so pick visible layers withlayers: an array of layer paths, a PSDTool layer-state string, or a.pfvfavorite loaded withloadPsdPreset(). - Image portraits can lip-sync too. Give
portrait.lipSyncone transparent mouth image per shape, the same size as the portrait, so each lines up when drawn over it. - To drive a mouth yourself, pass
mouth="a"(or another shape) to aCharacterVieworDialogue.useLipSync(track)returns the current shape if you want to react to it elsewhere.
Sequence a conversation automatically
Call planDialogue(lines, { fps, gap, sceneLeadIn }) in prepare(). It measures each audio file unless durationInFrames is supplied, then places the lines back to back. gap and leadIn use seconds; durations use frames. Every line needs audio or a positive durationInFrames. Set Composition durationInFrames from the plan and pass speaker names to DialogueSeries through views. The plan exposes line(), startOf(), range(), and scene() for other direction.
import * as React from 'react';
import { Assets, Composition, Rect } from '@celesta/react';
import { Character, CharacterView, DialogueSeries, planDialogue } from '@celesta/character';
import type { AssetReference } from '@celesta/react';
import type { CharacterViewReference, DialoguePlan } from '@celesta/character';
const mira = React.createRef<AssetReference>();
const miraView = React.createRef<CharacterViewReference>();
let plan: DialoguePlan;
export async function prepare() {
plan = await planDialogue([
{ id: 'hello', speaker: 'mira', text: 'Welcome back. Shall we begin?',
audio: './voices/line-01.wav' },
{ id: 'start', speaker: 'mira', text: 'Every scene starts with a single line.',
audio: './voices/line-02.wav', expression: 'smile' },
], { fps: 30, gap: 0.25 });
}
export default function Root() {
return (
<Composition width={1920} height={1080} fps={30} durationInFrames={plan.durationInFrames}>
<Rect width={1920} height={1080} fill="#20243a" />
<Assets>
<Character
ref={mira}
name="Mira"
portrait={{
defaultExpression: 'calm',
expressions: {
calm: './mira/calm.png',
smile: './mira/smile.png',
},
}}
subtitle={{
x: 960, y: 940, anchorX: 0.5, anchorY: 0.5, maxWidth: 1600,
style: {
fontSize: 56,
fill: { type: 'solid', color: '#ffffff' },
stroke: { paint: { type: 'solid', color: '#3a2d52' }, width: 6 },
align: 'center',
},
}}
/>
</Assets>
<CharacterView ref={miraView} character={mira} x={1400} y={200} />
<DialogueSeries plan={plan} views={{ mira: miraView }} holdThroughGap />
</Composition>
);
}
holdThroughGap keeps the previous line’s text and expression through its gap. holdSubtitle lets a custom subtitle.render keep its background between lines; its held flag identifies the gap. subtitle.render receives text, character (including displayName), metrics, style, maxWidth, frame, and durationInFrames so you can draw a measured subtitle panel. See the repository’s with-dialogue-series.tsx for a complete example.
For voices generated with VOICEVOX or AivisSpeech, use the synthesis AudioQuery with @celesta/voicevox or @celesta/aivis-speech instead of spreading the transcript’s vowels over the WAV.
See it with real artwork
packages/cli/examples/with-dialogue-series.tsx demonstrates an automatically timed conversation; packages/cli/examples/with-lip-sync.tsx shows a PSD portrait with lip sync. On GitHub, choose Code Download ZIP, extract the complete archive including assets, and open an entry with File Open…. No source build is needed.