working on cutscenes

This commit is contained in:
Vladislav Khorev committed 2026-06-07 14:40:15 +03:00
1 parent 54cc118df7
commit 6349859e66
47 files changed
+6234 -21

No files matched your search

@@ -0,0 +1,18 @@
.panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.previewArea {
flex: 0 1 420px;
min-height: 120px;
display: flex;
justify-content: center;
align-items: center;
background: #111;
padding: 8px;
overflow: hidden;
}
@@ -0,0 +1,16 @@
import styles from './CenterPanel.module.css';
import Preview from '../Preview/Preview';
import Controls from '../Controls/Controls';
import Timeline from '../Timeline/Timeline';
export default function CenterPanel() {
return (
<div className={styles.panel}>
<div className={styles.previewArea}>
<Preview />
</div>
<Controls />
<Timeline />
</div>
);
}
@@ -0,0 +1,94 @@
.controls {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: #1e1e1e;
border-top: 1px solid #333;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.btn {
background: #2d2d2d;
border: 1px solid #404040;
color: #ccc;
border-radius: 4px;
padding: 4px 10px;
font-size: 13px;
transition: background 0.1s;
min-width: 32px;
flex-shrink: 0;
}
.btn:hover:not(:disabled) { background: #3a3a3a; color: #fff; }
.btn:disabled { opacity: 0.35; cursor: default; }
.active {
color: #5ba3e0;
border-color: #4a7aaa;
}
/* ── Scrubber ───────────────────────────────────────────────── */
.scrubber {
flex: 1;
min-width: 0;
height: 4px;
-webkit-appearance: none;
appearance: none;
border-radius: 2px;
outline: none;
cursor: pointer;
border: none;
padding: 0;
/* filled portion via CSS variable set inline */
background: linear-gradient(
to right,
#5b9bd5 0%,
#5b9bd5 var(--progress, 0%),
#3a3a3a var(--progress, 0%),
#3a3a3a 100%
);
}
.scrubber:disabled {
opacity: 0.3;
cursor: default;
}
.scrubber::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #5b9bd5;
cursor: pointer;
border: 2px solid #1e1e1e;
transition: transform 0.1s;
}
.scrubber:not(:disabled)::-webkit-slider-thumb:hover {
transform: scale(1.3);
}
.scrubber::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #5b9bd5;
cursor: pointer;
border: 2px solid #1e1e1e;
}
.scrubber::-moz-range-track {
height: 4px;
border-radius: 2px;
background: #3a3a3a;
}
.time {
flex-shrink: 0;
font-size: 11px;
color: #888;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
@@ -0,0 +1,67 @@
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
import { usePlayback } from '../../hooks/usePlayback';
import styles from './Controls.module.css';
function formatMs(ms: number) {
const s = Math.floor(ms / 1000);
const frac = Math.floor((ms % 1000) / 10).toString().padStart(2, '0');
return `${s}.${frac}s`;
}
export default function Controls() {
usePlayback();
const { playState, currentTimeMs, setPlayState, setCurrentTime } = useCutsceneStore();
const cutscene = useSelectedCutscene();
const totalMs = cutscene
? Math.max(
cutscene.durationMs,
cutscene.imageSegments.reduce((m, s) => Math.max(m, s.endMs), 0)
) + cutscene.endFadeOutMs + cutscene.endFadeInMs
: 0;
function play() {
if (playState === 'stopped' || currentTimeMs >= totalMs) setCurrentTime(0);
setPlayState('playing');
}
function pause() { setPlayState('paused'); }
function stop() { setPlayState('stopped'); setCurrentTime(0); }
function handleScrub(e: React.ChangeEvent<HTMLInputElement>) {
const ms = Number(e.target.value);
setCurrentTime(ms);
if (playState === 'playing') setPlayState('paused');
}
const progress = totalMs > 0 ? currentTimeMs / totalMs : 0;
return (
<div className={styles.controls}>
<button className={styles.btn} onClick={stop} title="Stop" disabled={!cutscene}>⏹</button>
<button className={styles.btn} onClick={() => setCurrentTime(0)} title="Rewind" disabled={!cutscene}>⏮</button>
{playState === 'playing' ? (
<button className={`${styles.btn} ${styles.active}`} onClick={pause} title="Pause" disabled={!cutscene}>⏸</button>
) : (
<button className={`${styles.btn} ${styles.active}`} onClick={play} title="Play" disabled={!cutscene}>▶</button>
)}
<input
className={styles.scrubber}
type="range"
min={0}
max={totalMs || 1}
step={16}
value={currentTimeMs}
onChange={handleScrub}
disabled={!cutscene}
style={{ '--progress': `${progress * 100}%` } as React.CSSProperties}
/>
<div className={styles.time}>
{formatMs(currentTimeMs)} / {formatMs(totalMs)}
</div>
</div>
);
}
@@ -0,0 +1,100 @@
.panel {
width: 200px;
min-width: 180px;
background: #252525;
border-right: 1px solid #333;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
padding: 10px 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #888;
border-bottom: 1px solid #333;
}
.actions {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
border-bottom: 1px solid #333;
}
.btn {
background: #333;
color: #ddd;
border: 1px solid #444;
border-radius: 4px;
padding: 5px 8px;
text-align: center;
transition: background 0.15s;
}
.btn:hover:not(:disabled) { background: #3d3d3d; }
.btn:disabled { opacity: 0.4; cursor: default; }
.btnPrimary {
composes: btn;
background: #2a4a6e;
border-color: #3a6090;
color: #a8d0f0;
}
.btnPrimary:hover { background: #2e5480; }
.list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.empty {
padding: 16px 12px;
color: #555;
font-size: 11px;
line-height: 1.5;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 12px;
cursor: pointer;
user-select: none;
border-left: 3px solid transparent;
transition: background 0.1s;
}
.item:hover { background: #2e2e2e; }
.selected {
background: #1e3a55;
border-left-color: #5b9bd5;
}
.itemId {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.deleteBtn {
background: none;
border: none;
color: #666;
padding: 2px 4px;
border-radius: 3px;
font-size: 10px;
opacity: 0;
transition: opacity 0.1s, color 0.1s;
}
.item:hover .deleteBtn { opacity: 1; }
.deleteBtn:hover { color: #e06c6c; }
@@ -0,0 +1,76 @@
import { useRef } from 'react';
import { useCutsceneStore } from '../../store/cutsceneStore';
import { parseFile, triggerDownload } from '../../utils/fileIO';
import styles from './LeftPanel.module.css';
export default function LeftPanel() {
const fileInputRef = useRef<HTMLInputElement>(null);
const { file, selectedCutsceneId, loadFile, addCutscene, deleteCutscene, selectCutscene, getExportData } = useCutsceneStore();
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (!f) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const json = JSON.parse(ev.target!.result as string);
loadFile(parseFile(json));
} catch {
alert('Invalid JSON file');
}
};
reader.readAsText(f);
e.target.value = '';
}
function handleSave() {
const data = getExportData();
if (data) triggerDownload(data);
}
function handleDelete(id: string) {
if (confirm(`Delete cutscene "${id}"?`)) deleteCutscene(id);
}
return (
<div className={styles.panel}>
<div className={styles.header}>Cutscenes</div>
<div className={styles.actions}>
<button className={styles.btnPrimary} onClick={() => fileInputRef.current?.click()}>
Load JSON
</button>
<input ref={fileInputRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleFileChange} />
<button className={styles.btn} onClick={handleSave} disabled={!file}>
Save JSON
</button>
<button className={styles.btn} onClick={addCutscene}>
+ New
</button>
</div>
<div className={styles.list}>
{!file || file.cutscenes.length === 0 ? (
<div className={styles.empty}>No cutscenes. Load a JSON or create new.</div>
) : (
file.cutscenes.map(c => (
<div
key={c.id}
className={`${styles.item} ${c.id === selectedCutsceneId ? styles.selected : ''}`}
onClick={() => selectCutscene(c.id)}
>
<span className={styles.itemId}>{c.id}</span>
<button
className={styles.deleteBtn}
onClick={(e) => { e.stopPropagation(); handleDelete(c.id); }}
title="Delete"
>
✕
</button>
</div>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,51 @@
.wrapper {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.viewport {
position: relative;
aspect-ratio: 16 / 9;
width: 100%;
max-height: 100%;
background: #000;
overflow: hidden;
}
.empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #444;
font-size: 13px;
}
.subtitleBar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 10px 20px 14px;
background: linear-gradient(transparent, rgba(0,0,0,0.75));
text-align: center;
}
.speaker {
font-size: 11px;
font-weight: 600;
color: #f0c060;
margin-bottom: 4px;
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
}
.text {
font-size: 14px;
color: #fff;
line-height: 1.5;
text-shadow: 0 1px 4px rgba(0,0,0,0.9);
}
@@ -0,0 +1,85 @@
import { useRef, useEffect, useState } from 'react';
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
import { computeSegmentState, poseToStyle } from '../../utils/rendering';
import styles from './Preview.module.css';
const LOGICAL_W = 1280;
const LOGICAL_H = 720;
function computeSubtitle(cutscene: ReturnType<typeof useSelectedCutscene>, currentMs: number) {
if (!cutscene) return null;
let elapsed = 0;
for (const line of cutscene.lines) {
const dur = line.durationMs > 0
? line.durationMs
: Math.max(1500, Math.round((line.text.length / 17) * 1000));
if (currentMs >= elapsed && currentMs < elapsed + dur) return line;
elapsed += dur;
}
return null;
}
export default function Preview() {
const containerRef = useRef<HTMLDivElement>(null);
const [containerSize, setContainerSize] = useState({ w: LOGICAL_W, h: LOGICAL_H });
const currentTimeMs = useCutsceneStore(s => s.currentTimeMs);
const cutscene = useSelectedCutscene();
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(entries => {
const e = entries[0];
if (e) setContainerSize({ w: e.contentRect.width, h: e.contentRect.height });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const subtitle = computeSubtitle(cutscene, currentTimeMs);
return (
<div className={styles.wrapper}>
<div className={styles.viewport} ref={containerRef}>
{!cutscene ? (
<div className={styles.empty}>Select or create a cutscene</div>
) : (
<>
{cutscene.imageSegments.map((seg, i) => {
const state = computeSegmentState(seg, currentTimeMs);
if (!state) return null;
const imgStyle = poseToStyle(
state.pose,
seg.width || LOGICAL_W,
seg.height || LOGICAL_H,
containerSize.w,
containerSize.h,
);
return (
<img
key={i}
src={`/${seg.path}`}
alt=""
style={{ ...imgStyle, opacity: state.alpha }}
draggable={false}
/>
);
})}
{subtitle && (
<div className={styles.subtitleBar}>
{subtitle.speaker && (
<div className={styles.speaker}>{subtitle.speaker}</div>
)}
<div className={styles.text}>{subtitle.text}</div>
</div>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
import type { Cutscene } from '../../types/cutscene';
import styles from './RightPanel.module.css';
type CutscenePatch = Partial<Omit<Cutscene, 'imageSegments' | 'lines'>>;
function NumField({ label, value, onChange, min }: {
label: string; value: number; onChange: (v: number) => void; min?: number;
}) {
return (
<div className={styles.field}>
<label>{label}</label>
<input
type="number"
value={value}
min={min}
onChange={e => onChange(Number(e.target.value))}
/>
</div>
);
}
export default function CutsceneProperties() {
const cutscene = useSelectedCutscene();
const updateCutscene = useCutsceneStore(s => s.updateCutscene);
if (!cutscene) return <div className={styles.empty}>No cutscene selected</div>;
function upd(patch: CutscenePatch) {
updateCutscene(cutscene!.id, patch);
}
return (
<div className={styles.section}>
<div className={styles.sectionTitle}>Cutscene</div>
<div className={styles.field}>
<label>ID</label>
<input
type="text"
value={cutscene.id}
onChange={e => upd({ id: e.target.value })}
/>
</div>
<div className={styles.field}>
<label>Skippable</label>
<input
type="checkbox"
checked={cutscene.skippable}
onChange={e => upd({ skippable: e.target.checked })}
/>
</div>
<NumField label="Duration (ms)" value={cutscene.durationMs} onChange={v => upd({ durationMs: v })} min={0} />
<NumField label="Fade out (ms)" value={cutscene.fadeOutMs} onChange={v => upd({ fadeOutMs: v })} min={0} />
<NumField label="Fade in (ms)" value={cutscene.fadeInMs} onChange={v => upd({ fadeInMs: v })} min={0} />
<NumField label="End fade out (ms)" value={cutscene.endFadeOutMs} onChange={v => upd({ endFadeOutMs: v })} min={0} />
<NumField label="End fade in (ms)" value={cutscene.endFadeInMs} onChange={v => upd({ endFadeInMs: v })} min={0} />
<div className={styles.field}>
<label>onFadeIn callback</label>
<input
type="text"
value={cutscene.onFadeInCallback}
onChange={e => upd({ onFadeInCallback: e.target.value })}
placeholder="lua function name"
/>
</div>
</div>
);
}
@@ -0,0 +1,132 @@
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
import { useShallow } from 'zustand/react/shallow';
import { AVAILABLE_IMAGES } from '../../constants/images';
import { EASING_OPTIONS } from '../../constants/easings';
import type { ImagePose } from '../../types/cutscene';
import styles from './RightPanel.module.css';
function NumField({ label, value, onChange, min, step }: {
label: string; value: number; onChange: (v: number) => void; min?: number; step?: number;
}) {
return (
<div className={styles.field}>
<label>{label}</label>
<input
type="number"
value={value}
min={min}
step={step ?? 1}
onChange={e => onChange(Number(e.target.value))}
/>
</div>
);
}
function PoseFields({ label, pose, onChange }: {
label: string;
pose: ImagePose;
onChange: (patch: Partial<ImagePose>) => void;
}) {
return (
<div className={styles.poseGroup}>
<div className={styles.poseTitle}>{label}</div>
<div className={styles.poseRow}>
<div className={styles.field}>
<label>centerX</label>
<input type="number" value={pose.centerX} step={0.01} min={0} max={1}
onChange={e => onChange({ centerX: Number(e.target.value) })} />
</div>
<div className={styles.field}>
<label>centerY</label>
<input type="number" value={pose.centerY} step={0.01} min={0} max={1}
onChange={e => onChange({ centerY: Number(e.target.value) })} />
</div>
<div className={styles.field}>
<label>scale</label>
<input type="number" value={pose.scale} step={0.05} min={0.1}
onChange={e => onChange({ scale: Number(e.target.value) })} />
</div>
</div>
</div>
);
}
export default function LayerProperties() {
const cutscene = useSelectedCutscene();
const { selectedLayerIndex, updateLayer, updateLayerFrom, updateLayerTo } = useCutsceneStore(useShallow(s => ({
selectedLayerIndex: s.selectedLayerIndex,
updateLayer: s.updateLayer,
updateLayerFrom: s.updateLayerFrom,
updateLayerTo: s.updateLayerTo,
})));
if (!cutscene || selectedLayerIndex === null) return null;
const seg = cutscene.imageSegments[selectedLayerIndex];
if (!seg) return null;
return (
<div className={styles.section}>
<div className={styles.sectionTitle}>Layer {selectedLayerIndex + 1}</div>
<div className={styles.field}>
<label>Image</label>
<select
value={AVAILABLE_IMAGES.includes(seg.path) ? seg.path : '__custom__'}
onChange={e => {
if (e.target.value !== '__custom__') updateLayer(selectedLayerIndex, { path: e.target.value });
}}
>
{AVAILABLE_IMAGES.map(img => (
<option key={img} value={img}>{img.split('/').pop()}</option>
))}
{!AVAILABLE_IMAGES.includes(seg.path) && (
<option value="__custom__">(custom)</option>
)}
</select>
</div>
<div className={styles.field}>
<label>Path (manual)</label>
<input
type="text"
value={seg.path}
onChange={e => updateLayer(selectedLayerIndex, { path: e.target.value })}
placeholder="resources/..."
/>
</div>
<div className={styles.row2}>
<NumField label="Width" value={seg.width} onChange={v => updateLayer(selectedLayerIndex, { width: v })} min={1} />
<NumField label="Height" value={seg.height} onChange={v => updateLayer(selectedLayerIndex, { height: v })} min={1} />
</div>
<div className={styles.row2}>
<NumField label="Start (ms)" value={seg.startMs} onChange={v => updateLayer(selectedLayerIndex, { startMs: v })} min={0} />
<NumField label="End (ms)" value={seg.endMs} onChange={v => updateLayer(selectedLayerIndex, { endMs: v })} min={0} />
</div>
<div className={styles.row2}>
<NumField label="Fade in (ms)" value={seg.fadeInMs} onChange={v => updateLayer(selectedLayerIndex, { fadeInMs: v })} min={0} />
<NumField label="Fade out (ms)" value={seg.fadeOutMs} onChange={v => updateLayer(selectedLayerIndex, { fadeOutMs: v })} min={0} />
</div>
<div className={styles.field}>
<label>Easing</label>
<select value={seg.easing} onChange={e => updateLayer(selectedLayerIndex, { easing: e.target.value as typeof seg.easing })}>
{EASING_OPTIONS.map(e => <option key={e} value={e}>{e}</option>)}
</select>
</div>
<PoseFields
label="From"
pose={seg.from}
onChange={patch => updateLayerFrom(selectedLayerIndex, patch)}
/>
<PoseFields
label="To"
pose={seg.to}
onChange={patch => updateLayerTo(selectedLayerIndex, patch)}
/>
</div>
);
}
@@ -0,0 +1,208 @@
.panel {
width: 260px;
min-width: 240px;
background: #252525;
border-left: 1px solid #333;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tabs {
display: flex;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.tab {
flex: 1;
padding: 8px 6px;
background: none;
border: none;
color: #777;
font-size: 11px;
border-bottom: 2px solid transparent;
transition: color 0.1s;
}
.tab:hover { color: #bbb; }
.tabActive {
color: #5b9bd5;
border-bottom-color: #5b9bd5;
}
.scroll {
flex: 1;
overflow-y: auto;
padding-bottom: 20px;
}
/* ── Sections ──────────────────────────────────────────────────── */
.section {
padding: 10px;
border-bottom: 1px solid #2e2e2e;
}
.sectionTitle {
font-size: 11px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.addBtn {
background: #2a4a6e;
border: 1px solid #3a6090;
color: #a8d0f0;
border-radius: 3px;
padding: 2px 8px;
font-size: 11px;
}
.addBtn:hover { background: #2e5480; }
/* ── Fields ────────────────────────────────────────────────────── */
.field {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 5px;
gap: 6px;
}
.field label {
flex-shrink: 0;
width: 100px;
text-align: right;
}
.field input[type="text"],
.field input[type="number"],
.field select,
.field textarea {
flex: 1;
min-width: 0;
width: 100%;
}
.field textarea {
resize: vertical;
min-height: 40px;
}
.field input[type="checkbox"] {
width: auto;
margin: 0;
}
.row2 {
display: flex;
gap: 6px;
}
.row2 .field {
flex: 1;
flex-direction: column;
align-items: flex-start;
}
.row2 .field label {
width: auto;
text-align: left;
margin-bottom: 2px;
}
/* ── Pose groups ───────────────────────────────────────────────── */
.poseGroup {
margin-top: 8px;
background: #1e1e1e;
border-radius: 4px;
padding: 6px 8px;
}
.poseTitle {
font-size: 10px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 5px;
}
.poseRow {
display: flex;
gap: 6px;
}
.poseRow .field {
flex: 1;
flex-direction: column;
align-items: flex-start;
margin-bottom: 0;
}
.poseRow .field label {
width: auto;
text-align: left;
margin-bottom: 2px;
}
/* ── Subtitle line cards ───────────────────────────────────────── */
.lineCard {
background: #1e1e1e;
border: 1px solid #2e2e2e;
border-radius: 4px;
padding: 8px;
margin-bottom: 6px;
}
.lineHeader {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 6px;
}
.lineIndex {
font-size: 10px;
color: #666;
flex: 1;
}
.iconBtn {
background: none;
border: 1px solid #3a3a3a;
color: #888;
border-radius: 3px;
padding: 1px 5px;
font-size: 11px;
}
.iconBtn:hover:not(:disabled) { background: #333; color: #ccc; }
.iconBtn:disabled { opacity: 0.3; cursor: default; }
.iconBtnDanger {
composes: iconBtn;
color: #a05050;
border-color: #5a2a2a;
}
.iconBtnDanger:hover { background: #3a2020; color: #e06c6c; }
.emptyLines {
color: #555;
font-size: 11px;
padding: 4px 0;
}
.empty {
padding: 20px;
color: #555;
font-size: 11px;
text-align: center;
}
@@ -0,0 +1,41 @@
import { useCutsceneStore } from '../../store/cutsceneStore';
import { useShallow } from 'zustand/react/shallow';
import CutsceneProperties from './CutsceneProperties';
import LayerProperties from './LayerProperties';
import SubtitleLines from './SubtitleLines';
import styles from './RightPanel.module.css';
export default function RightPanel() {
const { selectedLayerIndex, selectLayer } = useCutsceneStore(useShallow(s => ({
selectedLayerIndex: s.selectedLayerIndex,
selectLayer: s.selectLayer,
})));
return (
<div className={styles.panel}>
{/* Tab strip */}
<div className={styles.tabs}>
<button
className={`${styles.tab} ${selectedLayerIndex === null ? styles.tabActive : ''}`}
onClick={() => selectLayer(null)}
>
Cutscene
</button>
{selectedLayerIndex !== null && (
<button className={`${styles.tab} ${styles.tabActive}`}>
Layer {selectedLayerIndex + 1}
</button>
)}
</div>
<div className={styles.scroll}>
{selectedLayerIndex !== null ? (
<LayerProperties />
) : (
<CutsceneProperties />
)}
<SubtitleLines />
</div>
</div>
);
}
@@ -0,0 +1,89 @@
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
import { useShallow } from 'zustand/react/shallow';
import styles from './RightPanel.module.css';
export default function SubtitleLines() {
const cutscene = useSelectedCutscene();
const { addLine, removeLine, moveLineUp, moveLineDown, updateLine } = useCutsceneStore(useShallow(s => ({
addLine: s.addLine,
removeLine: s.removeLine,
moveLineUp: s.moveLineUp,
moveLineDown: s.moveLineDown,
updateLine: s.updateLine,
})));
if (!cutscene) return null;
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>Subtitle Lines</div>
<button className={styles.addBtn} onClick={addLine}>+ Add</button>
</div>
{cutscene.lines.length === 0 && (
<div className={styles.emptyLines}>No lines. Click + Add.</div>
)}
{cutscene.lines.map((line, i) => (
<div key={i} className={styles.lineCard}>
<div className={styles.lineHeader}>
<span className={styles.lineIndex}>#{i + 1}</span>
<button className={styles.iconBtn} onClick={() => moveLineUp(i)} disabled={i === 0} title="Move up">↑</button>
<button className={styles.iconBtn} onClick={() => moveLineDown(i)} disabled={i === cutscene.lines.length - 1} title="Move down">↓</button>
<button className={styles.iconBtnDanger} onClick={() => removeLine(i)} title="Remove">✕</button>
</div>
<div className={styles.field}>
<label>Speaker</label>
<input
type="text"
value={line.speaker}
onChange={e => updateLine(i, { speaker: e.target.value })}
placeholder="(none)"
/>
</div>
<div className={styles.field}>
<label>Text</label>
<textarea
value={line.text}
rows={2}
onChange={e => updateLine(i, { text: e.target.value })}
/>
</div>
<div className={styles.row2}>
<div className={styles.field}>
<label>Duration (ms)</label>
<input
type="number"
value={line.durationMs}
min={0}
onChange={e => updateLine(i, { durationMs: Number(e.target.value) })}
/>
</div>
<div className={styles.field}>
<label>Wait confirm</label>
<input
type="checkbox"
checked={line.waitForConfirm}
onChange={e => updateLine(i, { waitForConfirm: e.target.checked })}
/>
</div>
</div>
<div className={styles.field}>
<label>Lua callback</label>
<input
type="text"
value={line.luaCallback}
onChange={e => updateLine(i, { luaCallback: e.target.value })}
placeholder="function name"
/>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,192 @@
.container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background: #1a1a1a;
border-top: 1px solid #333;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: #222;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.tbBtn {
background: #2d2d2d;
border: 1px solid #404040;
color: #bbb;
border-radius: 3px;
padding: 3px 8px;
font-size: 11px;
transition: background 0.1s;
}
.tbBtn:hover:not(:disabled) { background: #3a3a3a; color: #fff; }
.tbBtn:disabled { opacity: 0.3; cursor: default; }
.spacer { flex: 1; }
.zoomLabel { font-size: 11px; color: #666; margin-right: 2px; }
.scroll {
flex: 1;
overflow: auto;
position: relative;
cursor: default;
}
/* ── Ruler ────────────────────────────────────────────────────── */
.ruler {
display: flex;
height: 22px;
background: #212121;
border-bottom: 1px solid #333;
position: sticky;
top: 0;
z-index: 10;
user-select: none;
cursor: crosshair;
}
.tick {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: #444;
}
.tickLabel {
position: absolute;
top: 3px;
left: 3px;
font-size: 9px;
color: #666;
white-space: nowrap;
}
/* ── Layers area ──────────────────────────────────────────────── */
.layersArea {
position: relative;
}
.playhead {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: #e05050;
z-index: 20;
pointer-events: none;
}
.gridLine {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: rgba(255,255,255,0.04);
pointer-events: none;
}
/* ── Layer row ────────────────────────────────────────────────── */
.row {
position: absolute;
left: 0;
right: 0;
height: 32px;
display: flex;
align-items: center;
border-bottom: 1px solid #2a2a2a;
cursor: pointer;
transition: background 0.1s;
}
.row:hover { background: rgba(255,255,255,0.03); }
.rowSelected { background: rgba(91,155,213,0.08); }
.rowLabel {
position: absolute;
left: 0;
width: 140px;
height: 100%;
display: flex;
align-items: center;
padding: 0 8px;
background: #1e1e1e;
border-right: 1px solid #2a2a2a;
z-index: 5;
overflow: hidden;
flex-shrink: 0;
}
.layerName {
font-size: 11px;
color: #bbb;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Segment bar ──────────────────────────────────────────────── */
.bar {
position: absolute;
height: 22px;
border-radius: 3px;
cursor: grab;
top: 5px;
border: 1px solid rgba(255,255,255,0.15);
box-sizing: border-box;
min-width: 4px;
}
.bar:active { cursor: grabbing; }
.handle {
position: absolute;
top: 0;
bottom: 0;
width: 6px;
cursor: ew-resize;
z-index: 2;
}
.handleLeft { left: 0; border-radius: 3px 0 0 3px; background: rgba(255,255,255,0.15); }
.handleRight { right: 0; border-radius: 0 3px 3px 0; background: rgba(255,255,255,0.15); }
/* ── Subtitle row ─────────────────────────────────────────────── */
.subtitleRow {
position: absolute;
left: 0;
right: 0;
height: 14px;
border-bottom: 1px solid #2a2a2a;
}
.subtitleLabel {
position: absolute;
left: 0;
width: 140px;
height: 100%;
background: #1e1e1e;
border-right: 1px solid #2a2a2a;
display: flex;
align-items: center;
padding: 0 8px;
font-size: 9px;
color: #555;
z-index: 5;
}
.subtitleBlock {
position: absolute;
top: 2px;
height: 10px;
background: #8a6040;
border-radius: 2px;
border: 1px solid #aa7050;
opacity: 0.8;
}
@@ -0,0 +1,285 @@
import { useRef, useCallback, useState, useEffect } from 'react';
import { useCutsceneStore, useSelectedCutscene } from '../../store/cutsceneStore';
import { useShallow } from 'zustand/react/shallow';
import styles from './Timeline.module.css';
const LABEL_WIDTH = 140;
const ROW_HEIGHT = 32;
const SUBTITLE_ROW_HEIGHT = 14;
const MIN_ZOOM = 20; // px per second
const MAX_ZOOM = 400;
const LAYER_COLORS = ['#4a7fc1', '#c17a4a', '#4ac17a', '#c14a7a', '#7a4ac1', '#c1b44a', '#4ac1c1'];
function layerColor(i: number) { return LAYER_COLORS[i % LAYER_COLORS.length]; }
function basename(path: string) {
return path.split('/').pop() ?? path;
}
export default function Timeline() {
const cutscene = useSelectedCutscene();
const { selectedLayerIndex, currentTimeMs, playState } = useCutsceneStore(useShallow(s => ({
selectedLayerIndex: s.selectedLayerIndex,
currentTimeMs: s.currentTimeMs,
playState: s.playState,
})));
const { selectLayer, addLayer, removeLayer, moveLayerUp, moveLayerDown, updateLayer, setCurrentTime, setPlayState } = useCutsceneStore(useShallow(s => ({
selectLayer: s.selectLayer,
addLayer: s.addLayer,
removeLayer: s.removeLayer,
moveLayerUp: s.moveLayerUp,
moveLayerDown: s.moveLayerDown,
updateLayer: s.updateLayer,
setCurrentTime: s.setCurrentTime,
setPlayState: s.setPlayState,
})));
const [pxPerSec, setPxPerSec] = useState(60);
const scrollRef = useRef<HTMLDivElement>(null);
const isDraggingPlayhead = useRef(false);
const dragState = useRef<{
type: 'move' | 'left' | 'right';
layerIndex: number;
startX: number;
startMs: number;
endMs: number;
durationMs: number;
} | null>(null);
const msToX = useCallback((ms: number) => (ms / 1000) * pxPerSec, [pxPerSec]);
const xToMs = useCallback((x: number) => Math.max(0, Math.round((x / pxPerSec) * 1000)), [pxPerSec]);
const totalMs = cutscene
? Math.max(
cutscene.durationMs,
cutscene.imageSegments.reduce((m, s) => Math.max(m, s.endMs), 0),
10000,
) + 2000
: 12000;
const rulerWidth = Math.ceil(msToX(totalMs));
// Ruler ticks
const tickStepMs = pxPerSec >= 100 ? 500 : pxPerSec >= 50 ? 1000 : 2000;
const labelStepMs = pxPerSec >= 100 ? 1000 : pxPerSec >= 50 ? 2000 : 4000;
const ticks: number[] = [];
for (let ms = 0; ms <= totalMs; ms += tickStepMs) ticks.push(ms);
// Scroll wheel zoom
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
function onWheel(e: WheelEvent) {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
setPxPerSec(prev => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, prev * (e.deltaY < 0 ? 1.15 : 0.87))));
}
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
// Auto-scroll playhead into view while playing
useEffect(() => {
if (playState !== 'playing') return;
const el = scrollRef.current;
if (!el) return;
const x = msToX(currentTimeMs) + LABEL_WIDTH;
const { scrollLeft, clientWidth } = el;
if (x > scrollLeft + clientWidth - 40) {
el.scrollLeft = x - clientWidth + 80;
}
}, [currentTimeMs, playState, msToX]);
// ── Playhead drag ──────────────────────────────────────────────────────────
function onRulerMouseDown(e: React.MouseEvent) {
if (e.button !== 0) return;
isDraggingPlayhead.current = true;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX - rect.left - LABEL_WIDTH + (scrollRef.current?.scrollLeft ?? 0);
setCurrentTime(xToMs(x));
if (playState === 'playing') setPlayState('paused');
function onMove(ev: MouseEvent) {
const x2 = ev.clientX - rect.left - LABEL_WIDTH + (scrollRef.current?.scrollLeft ?? 0);
setCurrentTime(xToMs(x2));
}
function onUp() {
isDraggingPlayhead.current = false;
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
// ── Segment bar drag ───────────────────────────────────────────────────────
function onBarMouseDown(e: React.MouseEvent, layerIndex: number, type: 'move' | 'left' | 'right') {
e.preventDefault();
e.stopPropagation();
selectLayer(layerIndex);
const seg = cutscene!.imageSegments[layerIndex];
dragState.current = {
type,
layerIndex,
startX: e.clientX,
startMs: seg.startMs,
endMs: seg.endMs,
durationMs: seg.endMs - seg.startMs,
};
function onMove(ev: MouseEvent) {
if (!dragState.current) return;
const dx = ev.clientX - dragState.current.startX;
const dMs = Math.round((dx / pxPerSec) * 1000);
const { type, layerIndex: li, startMs, endMs, durationMs } = dragState.current;
if (type === 'move') {
const newStart = Math.max(0, startMs + dMs);
updateLayer(li, { startMs: newStart, endMs: newStart + durationMs });
} else if (type === 'left') {
const newStart = Math.max(0, Math.min(endMs - 100, startMs + dMs));
updateLayer(li, { startMs: newStart });
} else {
const newEnd = Math.max(startMs + 100, endMs + dMs);
updateLayer(li, { endMs: newEnd });
}
}
function onUp() {
dragState.current = null;
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
const playheadX = msToX(currentTimeMs) + LABEL_WIDTH;
// Subtitle timing for display
let subtitleBlocks: { x: number; w: number; text: string }[] = [];
if (cutscene) {
let elapsed = 0;
for (const line of cutscene.lines) {
const dur = line.durationMs > 0
? line.durationMs
: Math.max(1500, Math.round((line.text.length / 17) * 1000));
subtitleBlocks.push({ x: msToX(elapsed), w: msToX(dur), text: line.text || '…' });
elapsed += dur;
}
}
const layerCount = cutscene?.imageSegments.length ?? 0;
const totalHeight = layerCount * ROW_HEIGHT + SUBTITLE_ROW_HEIGHT + 20;
return (
<div className={styles.container}>
{/* Toolbar */}
<div className={styles.toolbar}>
<button className={styles.tbBtn} onClick={addLayer} disabled={!cutscene} title="Add layer">+ Layer</button>
{selectedLayerIndex !== null && cutscene && (
<>
<button className={styles.tbBtn} onClick={() => moveLayerUp(selectedLayerIndex!)} disabled={selectedLayerIndex! <= 0} title="Move up">↑</button>
<button className={styles.tbBtn} onClick={() => moveLayerDown(selectedLayerIndex!)} disabled={selectedLayerIndex! >= layerCount - 1} title="Move down">↓</button>
<button className={styles.tbBtn} onClick={() => removeLayer(selectedLayerIndex!)} title="Remove layer" style={{ color: '#e06c6c' }}>✕ Layer</button>
</>
)}
<div className={styles.spacer} />
<span className={styles.zoomLabel}>Zoom:</span>
<button className={styles.tbBtn} onClick={() => setPxPerSec(p => Math.max(MIN_ZOOM, p * 0.75))}>−</button>
<button className={styles.tbBtn} onClick={() => setPxPerSec(p => Math.min(MAX_ZOOM, p * 1.33))}>+</button>
<button className={styles.tbBtn} onClick={() => setPxPerSec(60)}>Reset</button>
</div>
{/* Scrollable area */}
<div className={styles.scroll} ref={scrollRef}>
{/* Ruler */}
<div
className={styles.ruler}
style={{ width: LABEL_WIDTH + rulerWidth }}
onMouseDown={onRulerMouseDown}
>
<div style={{ width: LABEL_WIDTH, flexShrink: 0 }} />
<div style={{ position: 'relative', flex: 1 }}>
{ticks.map(ms => (
<div
key={ms}
className={styles.tick}
style={{ left: msToX(ms) }}
>
{ms % labelStepMs === 0 && (
<span className={styles.tickLabel}>{ms / 1000}s</span>
)}
</div>
))}
</div>
</div>
{/* Layers + playhead overlay */}
<div
className={styles.layersArea}
style={{ width: LABEL_WIDTH + rulerWidth, minHeight: totalHeight }}
>
{/* Playhead */}
<div className={styles.playhead} style={{ left: playheadX }} />
{/* Grid lines */}
{ticks.filter(ms => ms % labelStepMs === 0).map(ms => (
<div key={ms} className={styles.gridLine} style={{ left: LABEL_WIDTH + msToX(ms) }} />
))}
{/* Layer rows */}
{cutscene?.imageSegments.map((seg, i) => {
const isSelected = selectedLayerIndex === i;
const color = layerColor(i);
const barX = LABEL_WIDTH + msToX(seg.startMs);
const barW = Math.max(4, msToX(seg.endMs) - msToX(seg.startMs));
return (
<div
key={i}
className={`${styles.row} ${isSelected ? styles.rowSelected : ''}`}
style={{ top: i * ROW_HEIGHT }}
onClick={() => selectLayer(i)}
>
{/* Label */}
<div className={styles.rowLabel} style={{ borderLeft: `3px solid ${color}` }}>
<span className={styles.layerName}>{basename(seg.path) || `Layer ${i + 1}`}</span>
</div>
{/* Bar */}
<div
className={styles.bar}
style={{ left: barX, width: barW, background: color + (isSelected ? 'cc' : '88') }}
onMouseDown={e => onBarMouseDown(e, i, 'move')}
>
<div className={`${styles.handle} ${styles.handleLeft}`}
onMouseDown={e => onBarMouseDown(e, i, 'left')} />
<div className={`${styles.handle} ${styles.handleRight}`}
onMouseDown={e => onBarMouseDown(e, i, 'right')} />
</div>
</div>
);
})}
{/* Subtitle blocks row */}
{cutscene && (
<div
className={styles.subtitleRow}
style={{ top: layerCount * ROW_HEIGHT }}
>
<div className={styles.subtitleLabel}>Subtitles</div>
{subtitleBlocks.map((b, i) => (
<div
key={i}
className={styles.subtitleBlock}
style={{ left: LABEL_WIDTH + b.x, width: Math.max(2, b.w) }}
title={b.text}
/>
))}
</div>
)}
</div>
</div>
</div>
);
}