create-aura3d 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/index.js +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -3
  5. package/templates/animation-channel/package.json +1 -1
  6. package/templates/animation-studio/dist/episodes/scene/episode-3d.webm +0 -0
  7. package/templates/animation-studio/dist/episodes/scene/frames/action.png +0 -0
  8. package/templates/animation-studio/dist/episodes/scene/frames/dialogue.png +0 -0
  9. package/templates/animation-studio/dist/episodes/scene/frames/final.png +0 -0
  10. package/templates/animation-studio/dist/episodes/scene/frames/first.png +0 -0
  11. package/templates/animation-studio/dist/episodes/scene/frames/mouth-closed.png +0 -0
  12. package/templates/animation-studio/dist/episodes/scene/frames/mouth-open.png +0 -0
  13. package/templates/animation-studio/dist/episodes/scene/render-live-summary.json +2086 -6754
  14. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/broken.png +0 -0
  15. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/chefs.png +0 -0
  16. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/customer.png +0 -0
  17. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-1.png +0 -0
  18. package/templates/animation-studio/dist/episodes/scene/skeleton-overlays/worker-2.png +0 -0
  19. package/templates/animation-studio/dist/scene/working.document.json +219 -348
  20. package/templates/animation-studio/package-lock.json +1307 -55
  21. package/templates/animation-studio/package.json +11 -5
  22. package/templates/animation-studio/studio/dist/assets/index-i8URxOOt.css +1 -0
  23. package/templates/animation-studio/studio/dist/assets/index-pC9hdZ-B.js +11 -0
  24. package/templates/animation-studio/studio/dist/index.html +19 -0
  25. package/templates/animation-studio/studio/index.html +18 -0
  26. package/templates/animation-studio/studio/src/App.tsx +430 -0
  27. package/templates/animation-studio/studio/src/components/Console.tsx +366 -0
  28. package/templates/animation-studio/studio/src/components/Icon.tsx +90 -0
  29. package/templates/animation-studio/studio/src/components/Inspector.tsx +209 -0
  30. package/templates/animation-studio/studio/src/components/Outliner.tsx +210 -0
  31. package/templates/animation-studio/studio/src/components/Palette.tsx +204 -0
  32. package/templates/animation-studio/studio/src/components/Stage.tsx +339 -0
  33. package/templates/animation-studio/studio/src/components/Timeline.tsx +183 -0
  34. package/templates/animation-studio/studio/src/components/Topbar.tsx +66 -0
  35. package/templates/animation-studio/studio/src/main.tsx +10 -0
  36. package/templates/animation-studio/studio/src/state/backend.ts +108 -0
  37. package/templates/animation-studio/studio/src/state/fidelity.ts +110 -0
  38. package/templates/animation-studio/studio/src/state/mapDocument.ts +419 -0
  39. package/templates/animation-studio/studio/src/state/sceneTool.ts +55 -0
  40. package/templates/animation-studio/studio/src/state/types.ts +165 -0
  41. package/templates/animation-studio/studio/src/state/util.ts +46 -0
  42. package/templates/animation-studio/studio/src/styles.css +528 -0
  43. package/templates/animation-studio/studio/vite.config.ts +175 -0
  44. package/templates/character-controller/package.json +3 -3
  45. package/templates/cinematic-scene/package.json +1 -1
  46. package/templates/episode-builder/package.json +1 -1
  47. package/templates/fighting-game/package.json +1 -1
  48. package/templates/mini-game/package.json +1 -1
  49. package/templates/product-viewer/package.json +1 -1
  50. package/templates/prompt-animation-channel/package.json +1 -1
@@ -0,0 +1,339 @@
1
+ /* Stage viewport — render preview, HUD, title-safe guides, transport. */
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Icon } from "./Icon";
4
+ import { cap, fmt } from "../state/util";
5
+ import type { EpisodeDocument, Shot, ViewMode } from "../state/types";
6
+
7
+ function RenderOverlay({ pct }: { pct: number }) {
8
+ const r = 32;
9
+ const c = 2 * Math.PI * r;
10
+ return (
11
+ <div className="render-ov">
12
+ <div className="render-ring">
13
+ <svg width={74} height={74}>
14
+ <circle cx={37} cy={37} r={r} fill="none" stroke="rgba(255,255,255,.12)" strokeWidth={5} />
15
+ <circle
16
+ cx={37}
17
+ cy={37}
18
+ r={r}
19
+ fill="none"
20
+ stroke="var(--warm)"
21
+ strokeWidth={5}
22
+ strokeLinecap="round"
23
+ strokeDasharray={c}
24
+ strokeDashoffset={c * (1 - pct / 100)}
25
+ style={{ transition: "stroke-dashoffset .25s" }}
26
+ />
27
+ </svg>
28
+ <div className="render-pct">{Math.round(pct) + "%"}</div>
29
+ </div>
30
+ <div style={{ textAlign: "center" }}>
31
+ <div className="ttl">Rendering preview</div>
32
+ <div className="sub">
33
+ {pct < 40 ? "aura · rigging cast…" : pct < 75 ? "aura · simulating lighting…" : "aura · compositing frames…"}
34
+ </div>
35
+ </div>
36
+ </div>
37
+ );
38
+ }
39
+
40
+ export interface StageProps {
41
+ data: EpisodeDocument;
42
+ shot: Shot | null;
43
+ time: number;
44
+ playing: boolean;
45
+ onPlay: () => void;
46
+ onScrub: (t: number) => void;
47
+ onStep: (dir: number) => void;
48
+ viewMode: ViewMode;
49
+ rendering: boolean;
50
+ renderPct: number;
51
+ /** Most recent REAL render output (served under /preview/*), or null before first render. */
52
+ renderVideo?: string | null;
53
+ renderPoster?: string | null;
54
+ /** No real working document exists yet — render the empty "describe a scene" state. */
55
+ empty?: boolean;
56
+ }
57
+
58
+ export function Stage({ data, shot, time, playing, onPlay, onScrub, onStep, viewMode, rendering, renderPct, renderVideo, renderPoster, empty }: StageProps) {
59
+ const [guides, setGuides] = useState(false);
60
+ const [cc, setCc] = useState(true);
61
+ const [isFullscreen, setIsFullscreen] = useState(false);
62
+ const stageRef = useRef<HTMLElement>(null);
63
+ const DUR = data.DUR;
64
+
65
+ useEffect(() => {
66
+ const onFsChange = () => setIsFullscreen(document.fullscreenElement === stageRef.current);
67
+ document.addEventListener("fullscreenchange", onFsChange);
68
+ return () => document.removeEventListener("fullscreenchange", onFsChange);
69
+ }, []);
70
+
71
+ const toggleFullscreen = () => {
72
+ if (document.fullscreenElement === stageRef.current) {
73
+ void document.exitFullscreen();
74
+ } else {
75
+ void stageRef.current?.requestFullscreen();
76
+ }
77
+ };
78
+
79
+ // Drive the actual <video> element from the transport so the Play button really plays/pauses the
80
+ // rendered clip (it previously only toggled a separate clock, so the video would start/stop on its
81
+ // own). The video's own playback advances the clock (onTimeUpdate below), so the playhead +
82
+ // caption follow it; scrubbing / shot-selection seeks the element.
83
+ const videoRef = useRef<HTMLVideoElement>(null);
84
+ useEffect(() => {
85
+ const v = videoRef.current;
86
+ if (!v || !renderVideo) return;
87
+ if (playing) void v.play().catch(() => undefined);
88
+ else v.pause();
89
+ }, [playing, renderVideo]);
90
+ useEffect(() => {
91
+ const v = videoRef.current;
92
+ if (!v || !renderVideo) return;
93
+ // Seek on an EXTERNAL clock move (scrub, shot select); ignore the tiny drift of normal playback.
94
+ if (Math.abs(v.currentTime - time) > 0.4) v.currentTime = time;
95
+ }, [time, renderVideo]);
96
+ const beat = data.beats.find((b) => time >= b.start && time < b.start + b.dur);
97
+ const speaker = beat && data.cast.find((c) => c.id === beat.who);
98
+ const beatPct = beat ? Math.max(0, Math.min(1, (time - beat.start) / beat.dur)) : 0;
99
+
100
+ const scrubAt = (clientX: number, el: HTMLElement) => {
101
+ const r = el.getBoundingClientRect();
102
+ const pct = Math.max(0, Math.min(1, (clientX - r.left) / r.width));
103
+ onScrub(pct * DUR);
104
+ };
105
+ const onDown = (e: React.MouseEvent) => {
106
+ const el = e.currentTarget as HTMLElement;
107
+ scrubAt(e.clientX, el);
108
+ const mv = (ev: MouseEvent) => scrubAt(ev.clientX, el);
109
+ const up = () => {
110
+ window.removeEventListener("mousemove", mv);
111
+ window.removeEventListener("mouseup", up);
112
+ };
113
+ window.addEventListener("mousemove", mv);
114
+ window.addEventListener("mouseup", up);
115
+ };
116
+
117
+ const filter =
118
+ viewMode === "Wireframe"
119
+ ? "grayscale(1) contrast(2.4) brightness(1.15) invert(1) sepia(1) hue-rotate(170deg) saturate(3)"
120
+ : "none";
121
+
122
+ // No working document yet → the production empty state (no baked stage image).
123
+ if (empty || !shot) {
124
+ return (
125
+ <section className="panel stage">
126
+ <div className="stage-canvas">
127
+ <div className="stage-env" />
128
+ <div className="region-wrap">
129
+ <div className="render-region">
130
+ <div className="stage-frame" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
131
+ <div className="stage-empty">
132
+ <Icon name="film" size={30} style={{ color: "var(--tx-faint)", opacity: 0.7 }} />
133
+ <div className="ttl">No render yet</div>
134
+ <div className="sub">
135
+ Describe a scene in the Director Console, or run <code>new --prompt</code>.
136
+ </div>
137
+ </div>
138
+ </div>
139
+ <div className="stage-vig" />
140
+ <div className="stage-grain" />
141
+ <span className="tick tl" />
142
+ <span className="tick tr" />
143
+ <span className="tick bl" />
144
+ <span className="tick br" />
145
+ </div>
146
+ </div>
147
+ </div>
148
+ </section>
149
+ );
150
+ }
151
+
152
+ if (viewMode === "Storyboard") {
153
+ return (
154
+ <section className="panel stage">
155
+ <div
156
+ className="stage-canvas"
157
+ style={{
158
+ padding: 18,
159
+ display: "grid",
160
+ gridTemplateColumns: "1fr 1fr",
161
+ gridAutoRows: "1fr",
162
+ gap: 12,
163
+ alignItems: "stretch",
164
+ justifyItems: "stretch",
165
+ background: "#070810"
166
+ }}
167
+ >
168
+ {data.shots.map((s, i) => (
169
+ <div
170
+ key={s.id}
171
+ style={{
172
+ position: "relative",
173
+ borderRadius: 10,
174
+ overflow: "hidden",
175
+ background: "url(" + s.frame + ") center/cover",
176
+ border: shot.id === s.id ? "2px solid var(--acc)" : "1px solid var(--line-2)"
177
+ }}
178
+ >
179
+ <div style={{ position: "absolute", inset: 0, boxShadow: "inset 0 0 60px rgba(0,0,0,.5)" }} />
180
+ <div className="hud-chip" style={{ position: "absolute", left: 10, top: 10 }}>
181
+ <span className="k">{"SHOT " + (i + 1)}</span>
182
+ <span className="v">{s.name}</span>
183
+ </div>
184
+ <div className="hud-chip" style={{ position: "absolute", right: 10, bottom: 10, fontSize: 9.5 }}>
185
+ {s.cam}
186
+ </div>
187
+ </div>
188
+ ))}
189
+ </div>
190
+ </section>
191
+ );
192
+ }
193
+
194
+ return (
195
+ <section className="panel stage" ref={stageRef}>
196
+ <div className="stage-canvas">
197
+ <div className="stage-env" />
198
+ {/* HUD top-left */}
199
+ <div className="hud tl">
200
+ <div className="hud-chip">
201
+ <span className="live">
202
+ <span className="d" />
203
+ LIVE
204
+ </span>
205
+ <span className="k">·</span>
206
+ <span className="v">low-fi preview</span>
207
+ </div>
208
+ <div className="hud-chip">
209
+ <span className="k">SHOT</span>
210
+ <span className="v">{data.shots.indexOf(shot) + 1 + " / " + data.shots.length}</span>
211
+ <span className="k">{shot.name}</span>
212
+ </div>
213
+ </div>
214
+ {/* HUD top-right */}
215
+ <div className="hud tr">
216
+ <div className="hud-chip">
217
+ <Icon name="camera" size={12} />
218
+ <span className="v">{shot.cam}</span>
219
+ </div>
220
+ <div className="hud-chip">
221
+ <span className="k">1920×1080</span>
222
+ <span className="v">24fps</span>
223
+ </div>
224
+ <div style={{ display: "flex", gap: 7 }}>
225
+ <button className="hud-chip" onClick={() => setGuides(!guides)} style={{ color: guides ? "var(--acc)" : "var(--tx)" }}>
226
+ <Icon name="frame" size={12} />
227
+ Guides
228
+ </button>
229
+ <button
230
+ className="hud-chip"
231
+ onClick={toggleFullscreen}
232
+ title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
233
+ style={{ color: isFullscreen ? "var(--acc)" : "var(--tx)" }}
234
+ >
235
+ <Icon name="expand" size={12} />
236
+ </button>
237
+ </div>
238
+ </div>
239
+ {/* centered render region */}
240
+ <div className="region-wrap">
241
+ <div className="render-region">
242
+ {viewMode === "Render" && renderVideo ? (
243
+ // The REAL rendered preview (webm) from the render pipeline, loaded in place of
244
+ // the static frame. Same slot/styling as `stage-frame` so the design is unchanged.
245
+ <video
246
+ ref={videoRef}
247
+ className="stage-frame"
248
+ key={renderVideo}
249
+ src={renderVideo}
250
+ poster={renderPoster ?? shot.frame ?? undefined}
251
+ style={{ filter, objectFit: "cover", width: "100%", height: "100%" }}
252
+ muted
253
+ playsInline
254
+ onTimeUpdate={() => {
255
+ const v = videoRef.current;
256
+ if (v && playing) onScrub(v.currentTime);
257
+ }}
258
+ onEnded={() => {
259
+ if (playing) onPlay(); // stop at the end (onPlay toggles play→pause)
260
+ }}
261
+ />
262
+ ) : shot.frame ? (
263
+ <div className="stage-frame" key={shot.id} style={{ backgroundImage: "url(" + shot.frame + ")", filter }} />
264
+ ) : (
265
+ // Document exists but this shot has no rendered frame yet — keep the contained
266
+ // 16:9 frame (ticks/vignette/grain) and surface the no-render message inside it.
267
+ <div className="stage-frame" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
268
+ <div className="stage-empty">
269
+ <Icon name="film" size={30} style={{ color: "var(--tx-faint)", opacity: 0.7 }} />
270
+ <div className="ttl">No render yet</div>
271
+ <div className="sub">
272
+ Hit <b>Render</b> to preview this shot, or run <code>new --prompt</code>.
273
+ </div>
274
+ </div>
275
+ </div>
276
+ )}
277
+ <div className="stage-vig" />
278
+ <div className="stage-grain" />
279
+ {guides && (
280
+ <div className="guides">
281
+ <div className="safe action" />
282
+ <div className="safe title" />
283
+ <div className="cross" />
284
+ </div>
285
+ )}
286
+ <span className="tick tl" />
287
+ <span className="tick tr" />
288
+ <span className="tick bl" />
289
+ <span className="tick br" />
290
+ </div>
291
+ </div>
292
+ {/* The system caption sits BELOW the video frame so it never overlaps the captions already
293
+ burned into the rendered clip. It still shows the live speaker + beat progress. */}
294
+ {cc && beat && speaker && (
295
+ <div className="caption caption-below" key={beat.id}>
296
+ <span className="who" style={{ background: speaker.color }}>
297
+ {speaker.name}
298
+ </span>
299
+ <span className="ctext">{cap(beat.text)}</span>
300
+ <span className="bar">
301
+ <i style={{ width: beatPct * 100 + "%", background: speaker.color }} />
302
+ </span>
303
+ </div>
304
+ )}
305
+ {rendering && <RenderOverlay pct={renderPct} />}
306
+ {/* transport */}
307
+ <div className="transport">
308
+ <button className="tp-btn" onClick={() => onStep(-1)} title="Prev shot">
309
+ <Icon name="prev" size={16} />
310
+ </button>
311
+ <button className="tp-btn tp-play" onClick={onPlay}>
312
+ <Icon name={playing ? "pause" : "play"} size={16} />
313
+ </button>
314
+ <button className="tp-btn" onClick={() => onStep(1)} title="Next shot">
315
+ <Icon name="next" size={16} />
316
+ </button>
317
+ <div className="tp-time">
318
+ <b>{fmt(time)}</b>
319
+ <span className="sl">{" / " + fmt(DUR)}</span>
320
+ </div>
321
+ <div className="scrub" onMouseDown={onDown}>
322
+ <div className="scrub-rail">
323
+ <div className="scrub-fill" style={{ width: (time / DUR) * 100 + "%" }} />
324
+ <div className="scrub-marks">
325
+ {data.shots.map((s) => (
326
+ <div key={s.id} className="scrub-mark" style={{ left: (s.start / DUR) * 100 + "%", background: s.color }} />
327
+ ))}
328
+ </div>
329
+ <div className="scrub-head" style={{ left: (time / DUR) * 100 + "%" }} />
330
+ </div>
331
+ </div>
332
+ <button className={"tp-pill" + (cc ? " on" : "")} onClick={() => setCc(!cc)} title="Captions">
333
+ CC
334
+ </button>
335
+ </div>
336
+ </div>
337
+ </section>
338
+ );
339
+ }
@@ -0,0 +1,183 @@
1
+ /* Multi-track Timeline — Shots / Dialogue / Camera / FX, playhead, zoom. */
2
+ import { useRef, useState } from "react";
3
+ import { Icon } from "./Icon";
4
+ import { fmt } from "../state/util";
5
+ import type { EpisodeDocument } from "../state/types";
6
+
7
+ interface ClipModel {
8
+ id: string;
9
+ start: number;
10
+ dur: number;
11
+ label: string;
12
+ accent: string;
13
+ }
14
+
15
+ interface TrackModel {
16
+ name: string;
17
+ icon: string;
18
+ dot: string;
19
+ rows: ClipModel[];
20
+ }
21
+
22
+ function Clip({
23
+ clip,
24
+ DUR,
25
+ sel,
26
+ onClick
27
+ }: {
28
+ clip: ClipModel;
29
+ DUR: number;
30
+ sel: boolean;
31
+ onClick: (e: React.MouseEvent) => void;
32
+ }) {
33
+ const left = (clip.start / DUR) * 100;
34
+ const width = (clip.dur / DUR) * 100;
35
+ return (
36
+ <div
37
+ className={"clip" + (sel ? " sel" : "")}
38
+ onClick={onClick}
39
+ style={{
40
+ left: left + "%",
41
+ width: "calc(" + width + "% - 3px)",
42
+ background: "linear-gradient(180deg," + clip.accent + "d9," + clip.accent + "a6)"
43
+ }}
44
+ >
45
+ <span className="clab">{clip.label}</span>
46
+ </div>
47
+ );
48
+ }
49
+
50
+ export interface TimelineProps {
51
+ data: EpisodeDocument;
52
+ time: number;
53
+ onScrub: (t: number) => void;
54
+ selShot: string | null;
55
+ onSelShot: (id: string) => void;
56
+ }
57
+
58
+ export function Timeline({ data, time, onScrub, selShot, onSelShot }: TimelineProps) {
59
+ const DUR = data.DUR;
60
+ const [zoom, setZoom] = useState(1);
61
+ const ref = useRef<HTMLDivElement>(null);
62
+
63
+ const ticks: number[] = [];
64
+ for (let t = 0; t <= DUR; t += 10) ticks.push(t);
65
+
66
+ const scrubAt = (clientX: number) => {
67
+ const el = ref.current;
68
+ if (!el) return;
69
+ const r = el.getBoundingClientRect();
70
+ const pct = Math.max(0, Math.min(1, (clientX - r.left + el.scrollLeft) / (r.width * zoom)));
71
+ onScrub(pct * DUR);
72
+ };
73
+ const onDown = (e: React.MouseEvent) => {
74
+ scrubAt(e.clientX);
75
+ const mv = (ev: MouseEvent) => scrubAt(ev.clientX);
76
+ const up = () => {
77
+ window.removeEventListener("mousemove", mv);
78
+ window.removeEventListener("mouseup", up);
79
+ };
80
+ window.addEventListener("mousemove", mv);
81
+ window.addEventListener("mouseup", up);
82
+ };
83
+
84
+ const charColor = (who: string) => data.cast.find((c) => c.id === who)?.color || "#6b6bff";
85
+
86
+ const tracks: TrackModel[] = [
87
+ {
88
+ name: "Shots",
89
+ icon: "film",
90
+ dot: "#6b6bff",
91
+ rows: data.shots.map((s) => ({ id: s.id, start: s.start, dur: s.dur, label: s.name, accent: s.color }))
92
+ },
93
+ {
94
+ name: "Dialogue",
95
+ icon: "mic",
96
+ dot: "#ff8a5b",
97
+ rows: data.beats.map((b) => {
98
+ const name = data.cast.find((c) => c.id === b.who)?.name || "?";
99
+ const label = name + " · " + (b.text.length > 22 ? b.text.slice(0, 22) + "…" : b.text);
100
+ return { id: b.id, start: b.start, dur: b.dur, label, accent: charColor(b.who) };
101
+ })
102
+ },
103
+ {
104
+ name: "Gestures",
105
+ icon: "sparkles",
106
+ dot: "#9a7bff",
107
+ rows: data.gestures.map((g) => ({ id: g.id, start: g.start, dur: g.dur, label: g.text, accent: g.color }))
108
+ },
109
+ {
110
+ name: "Camera",
111
+ icon: "camera",
112
+ dot: "#2dd4a7",
113
+ rows: data.camera.map((c) => ({ id: c.id, start: c.start, dur: c.dur, label: c.text, accent: c.color }))
114
+ },
115
+ {
116
+ name: "FX",
117
+ icon: "zap",
118
+ dot: "#ff6f4d",
119
+ rows: data.fx.map((fxc) => ({ id: fxc.id, start: fxc.start, dur: fxc.dur, label: fxc.text, accent: fxc.color }))
120
+ }
121
+ ];
122
+
123
+ return (
124
+ <section className="panel tl">
125
+ <div className="tl-h">
126
+ <Icon name="layers" size={15} style={{ color: "var(--tx-dim)" }} />
127
+ <span className="ttl">Timeline</span>
128
+ <span style={{ fontFamily: "var(--mono)", fontSize: 11, color: "var(--tx-hi)", marginLeft: 2 }}>{fmt(time)}</span>
129
+ <div className="tl-zoom">
130
+ <button className="hicon" onClick={() => setZoom(Math.max(1, zoom - 0.5))} title="Zoom out">
131
+ <Icon name="expand" size={14} />
132
+ </button>
133
+ <span style={{ fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--tx-faint)", minWidth: 30, textAlign: "center" }}>
134
+ {zoom.toFixed(1) + "×"}
135
+ </span>
136
+ <button className="hicon" onClick={() => setZoom(Math.min(4, zoom + 0.5))} title="Zoom in">
137
+ <Icon name="plus" size={14} />
138
+ </button>
139
+ </div>
140
+ </div>
141
+ <div className="tl-body">
142
+ <div className="tl-labels">
143
+ <div className="tl-lab ruler">time</div>
144
+ {tracks.map((t) => (
145
+ <div key={t.name} className="tl-lab">
146
+ <span className="dot" style={{ background: t.dot }} />
147
+ {t.name}
148
+ </div>
149
+ ))}
150
+ </div>
151
+ <div className="tl-tracks" ref={ref} onMouseDown={onDown}>
152
+ <div className="tl-inner" style={{ width: zoom * 100 + "%" }}>
153
+ <div className="tl-ruler">
154
+ {ticks.map((t) => (
155
+ <div key={t} className="tl-tick" style={{ left: (t / DUR) * 100 + "%" }}>
156
+ <span>{fmt(t)}</span>
157
+ </div>
158
+ ))}
159
+ </div>
160
+ {tracks.map((t) => (
161
+ <div key={t.name} className="tl-row">
162
+ {t.rows.map((r) => (
163
+ <Clip
164
+ key={r.id}
165
+ clip={r}
166
+ DUR={DUR}
167
+ sel={t.name === "Shots" && selShot === r.id}
168
+ onClick={(e) => {
169
+ e.stopPropagation();
170
+ onScrub(r.start);
171
+ if (t.name === "Shots") onSelShot(r.id);
172
+ }}
173
+ />
174
+ ))}
175
+ </div>
176
+ ))}
177
+ <div className="tl-playhead" style={{ left: (time / DUR) * 100 + "%" }} />
178
+ </div>
179
+ </div>
180
+ </div>
181
+ </section>
182
+ );
183
+ }
@@ -0,0 +1,66 @@
1
+ /* Topbar — logo + version, breadcrumb, saved status, view toggle, share, Render. */
2
+ import { Icon, Logo } from "./Icon";
3
+ import type { ViewMode } from "../state/types";
4
+
5
+ const VIEW_MODES: ViewMode[] = ["Render", "Wireframe", "Storyboard"];
6
+
7
+ export interface TopbarProps {
8
+ scene: string;
9
+ onRender: () => void;
10
+ rendering: boolean;
11
+ viewMode: ViewMode;
12
+ setViewMode: (m: ViewMode) => void;
13
+ saved: boolean;
14
+ }
15
+
16
+ export function Topbar({ scene, onRender, rendering, viewMode, setViewMode, saved }: TopbarProps) {
17
+ return (
18
+ <header className="top">
19
+ <div className="brand">
20
+ <Logo size={30} />
21
+ <div>
22
+ <div className="wm">
23
+ Aura3D <b>Studio</b>
24
+ </div>
25
+ </div>
26
+ </div>
27
+
28
+ <div className="crumb">
29
+ <span className="proj">Animations</span>
30
+ <span className="sep">/</span>
31
+ {/* No scene-switcher menu exists, so the dead down-chevron (which implied a
32
+ dropdown) was removed — only the live scene name remains. */}
33
+ <span className="scene">{scene}</span>
34
+ <div className="save">
35
+ <span className="dot" style={saved ? undefined : { background: "var(--warn)", boxShadow: "0 0 8px var(--warn)" }} />
36
+ {saved ? "All changes saved" : "Saving…"}
37
+ </div>
38
+ </div>
39
+
40
+ <div className="grow" />
41
+
42
+ <div className="seg">
43
+ {VIEW_MODES.map((m) => (
44
+ <button key={m} className={viewMode === m ? "on" : ""} onClick={() => setViewMode(m)}>
45
+ <Icon name={m === "Render" ? "film" : m === "Wireframe" ? "cube" : "grid"} size={14} />
46
+ {m}
47
+ </button>
48
+ ))}
49
+ </div>
50
+
51
+ <div className="grow" />
52
+
53
+ {/* Removed the dead History button, the FAKE collaborator avatars (no multi-user collab
54
+ exists — they were pure decoration), and the dead Share button. Only real, wired controls
55
+ remain: the view-mode toggle and Render. */}
56
+ <button className="btn btn-warm" onClick={onRender} disabled={rendering}>
57
+ <Icon
58
+ name={rendering ? "render" : "play2"}
59
+ size={15}
60
+ style={rendering ? { animation: "sp 2s linear infinite" } : undefined}
61
+ />
62
+ {rendering ? "Rendering…" : "Render"}
63
+ </button>
64
+ </header>
65
+ );
66
+ }
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./App";
4
+ import "./styles.css";
5
+
6
+ createRoot(document.getElementById("root")!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>
10
+ );