@tongue-core/studio 0.1.0-rc.1 → 0.1.0-rc.3

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Backpine
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -20,7 +20,7 @@ What it shows: the film on a stage (a quiet "drawing…" while the frame on scre
20
20
  playhead's, sharp); play, step, jump to edits, zoom; the timeline with a ruler you scrub, filmstrips
21
21
  of each clip, what is loaded (the green line under the ruler) and the playhead; an inspector for the
22
22
  selected clip (its timing, source, props and where it is written) or the film. Keys: Space plays,
23
- ←/→ step (Shift: a second), ↑/↓ jump between edits, Home/End, = and − zoom, 0 fits.
23
+ ←/→ step (Shift: a second), ↑/↓ jump between edits, Home/End, = and − zoom, 0 fits. The inspector and timeline also show what film.json says about the drawing: a clip's blend and opacity, and each transition's kind, noise and edge. What a scene draws inside itself (masks, strokes, perspective) is in its code, and shows in the picture.
24
24
 
25
25
  `window.tongueStudio` drives it from outside (an agent, a check): the frame, the state, seek, play,
26
26
  select, whether it has settled, and a hash of any frame's pixels.
package/assets/studio.css CHANGED
@@ -79,11 +79,17 @@ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
79
79
  /* ------------------------------------------------------------ stage and inspector */
80
80
 
81
81
  .studio-main { display: grid; grid-template-columns: minmax(0, 1fr) 300px; min-height: 0; }
82
- .stage { position: relative; display: grid; place-items: center; padding: 20px; min-height: 0; min-width: 0; background: var(--bg); }
83
- .stage-frame { position: relative; max-width: 100%; max-height: 100%; height: 100%; background: #000; border-radius: 3px; box-shadow: 0 0 0 1px var(--line); overflow: hidden; }
82
+ .stage { position: relative; display: grid; place-items: center; padding: 20px; min-height: 0; min-width: 0; background: var(--bg); container-type: size; }
83
+ /* Sized by the stage (its width is set inline from the film's shape), never stretched. */
84
+ .stage-frame { position: relative; background: #000; border-radius: 3px; box-shadow: 0 0 0 1px var(--line); overflow: hidden; }
84
85
  .stage-canvas { display: block; width: 100%; height: 100%; }
85
86
  .stage-note { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); padding: 24px; text-align: center; }
86
87
  .stage-note.bad { color: var(--bad); }
88
+ /* A failed build, over the last frame that built: the message as the compiler wrote it. */
89
+ .stage-error { position: absolute; left: 12px; right: 12px; bottom: 12px; max-height: 50%; overflow: auto; padding: 12px 14px; border: 1px solid var(--line); border-left: 2px solid var(--bad); border-radius: 8px; background: rgba(10, 10, 10, 0.94); color: var(--soft); font-size: 12px; }
90
+ .stage-error strong { display: block; margin-bottom: 6px; color: var(--bad); font-weight: 600; }
91
+ .stage-error pre { margin: 0 0 8px; color: var(--text); white-space: pre-wrap; word-break: break-word; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
92
+ .stage-error span { color: var(--muted); }
87
93
  .stage-drafting { position: absolute; right: 28px; bottom: 26px; color: var(--muted); font-size: 11px; letter-spacing: 0.04em; opacity: 0; transition: opacity 120ms; }
88
94
  .stage[data-exact="no"] .stage-drafting { opacity: 1; transition-delay: 250ms; }
89
95
 
@@ -164,6 +170,7 @@ button.icon.play[data-playing] { color: var(--accent); background: var(--accent-
164
170
  [data-tl-run] { top: 5px !important; bottom: 5px !important; border-radius: 2px; background: repeating-linear-gradient(90deg, var(--scene) 0 3px, var(--scene-edge) 3px 4px); opacity: 0.8; cursor: pointer; }
165
171
  .row[data-kind="audio"] [data-tl-run] { background: repeating-linear-gradient(90deg, var(--audio) 0 3px, var(--audio-edge) 3px 4px); }
166
172
  [data-tl-transition] { top: 5px !important; bottom: 5px !important; background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent); border-radius: 4px; }
173
+ [data-tl-transition][data-style="dissolve"] { background: linear-gradient(90deg, transparent, rgba(255, 183, 101, 0.42), transparent); }
167
174
 
168
175
  .playhead { width: 0; z-index: 3; }
169
176
  .playhead::before { content: ""; position: absolute; top: 0; bottom: 0; left: -1px; width: 2px; background: var(--accent); }
@@ -6,9 +6,11 @@ export function studioHandle(registry, timeline, player) {
6
6
  const f = player.film;
7
7
  return f ? { project: f.build.project, length: f.doc.film.length, fps: f.doc.film.fps, clips: registry.get(timeline.model)?.clips.size ?? 0 } : null;
8
8
  },
9
+ // Against the player's own playhead: it moves the moment a seek is asked for, while the timeline
10
+ // hears of it on the next tick, so a seek just made isn't mistaken for one already shown.
9
11
  settled: () => {
10
12
  const r = registry.get(timeline.readiness), shown = registry.get(timeline.shown);
11
- return r !== null && r.ready + r.failed === r.files && shown !== null && shown.exact && shown.frame === registry.get(timeline.playhead);
13
+ return r !== null && r.ready + r.failed === r.files && shown !== null && shown.exact && shown.frame === player.frame && registry.get(timeline.playhead) === player.frame;
12
14
  },
13
15
  seek: (frame) => registry.set(timeline.commands, { type: "seek", frame }),
14
16
  play: () => registry.set(timeline.commands, { type: "play" }),
package/dist/page.js CHANGED
@@ -6,7 +6,7 @@ export function studioPage(o) {
6
6
  return `<!doctype html>
7
7
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
8
8
  <title>${escapeHtml(o.title)} · Tongue Studio</title>
9
- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23ff6b4a'/%3E%3Cpath d='M9 12h14M16 12v11' stroke='%230b0c0f' stroke-width='3.2' stroke-linecap='round'/%3E%3C/svg%3E">
9
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23f4739b'/%3E%3Cpath d='M9 12h14M16 12v11' stroke='%230a0a0a' stroke-width='3.2' stroke-linecap='round'/%3E%3C/svg%3E">
10
10
  <link rel="stylesheet" href="${escapeHtml(o.stylesheet)}">
11
11
  </head><body><div id="studio"></div>
12
12
  <script>window.TONGUE_STUDIO = ${config};</script>
package/dist/studio.js CHANGED
@@ -47,7 +47,7 @@ export function Studio(props) {
47
47
  player.reload().catch((e) => setLoadError(e instanceof Error ? e.message : String(e)));
48
48
  });
49
49
  }, [config.events, player]);
50
- return (_jsxs(TimelineProvider, { timeline: timeline, registry: registry, previews: player, previewsVersion: loads, children: [_jsx(Keys, {}), _jsxs("div", { className: "studio", children: [_jsx(TopBar, { config: config, film: film, build: build, loadError: loadError }), _jsxs("div", { className: "studio-main", children: [_jsx(Stage, { canvas: canvas, film: film, loadError: loadError }), _jsx(Inspector, { film: film, errors: errors })] }), _jsx(TransportBar, { film: film }), _jsx(TimelinePanel, {})] })] }));
50
+ return (_jsxs(TimelineProvider, { timeline: timeline, registry: registry, previews: player, previewsVersion: loads, children: [_jsx(Keys, {}), _jsxs("div", { className: "studio", children: [_jsx(TopBar, { config: config, film: film, build: build, loadError: loadError }), _jsxs("div", { className: "studio-main", children: [_jsx(Stage, { canvas: canvas, film: film, loadError: loadError, buildError: build.kind === "failed" ? build.message : null }), _jsx(Inspector, { film: film, errors: errors })] }), _jsx(TransportBar, { film: film }), _jsx(TimelinePanel, {})] })] }));
51
51
  }
52
52
  function Keys() {
53
53
  useTimelineKeys();
@@ -93,8 +93,11 @@ function Stage(props) {
93
93
  // Whether what is on screen is the playhead's frame, sharp: written to the element, not rendered.
94
94
  useAtomEffect(timeline.shown, (s) => { if (stage.current)
95
95
  stage.current.dataset["exact"] = s?.exact ? "yes" : "no"; });
96
- const aspect = props.film ? `${props.film.doc.film.width} / ${props.film.doc.film.height}` : "16 / 9";
97
- return (_jsxs("section", { className: "stage", ref: stage, children: [_jsxs("div", { className: "stage-frame", style: { aspectRatio: aspect }, children: [_jsx("canvas", { ref: props.canvas, className: "stage-canvas" }), !props.film && !props.loadError ? _jsx("div", { className: "stage-note", children: "Opening the film\u2026" }) : null, props.loadError ? _jsx("div", { className: "stage-note bad", children: props.loadError }) : null] }), _jsx("div", { className: "stage-drafting", "aria-hidden": "true", children: "drawing\u2026" })] }));
96
+ const size = props.film ? props.film.doc.film : { width: 16, height: 9 };
97
+ // As large as the stage allows in both directions, in the film's shape (the stage is a size
98
+ // container: 100cqw and 100cqh are its width and height).
99
+ const frame = { aspectRatio: `${size.width} / ${size.height}`, width: `min(100cqw, calc(100cqh * ${size.width / size.height}))` };
100
+ return (_jsxs("section", { className: "stage", ref: stage, children: [_jsxs("div", { className: "stage-frame", style: frame, children: [_jsx("canvas", { ref: props.canvas, className: "stage-canvas" }), !props.film && !props.loadError ? _jsx("div", { className: "stage-note", children: "Opening the film\u2026" }) : null, props.loadError ? _jsx("div", { className: "stage-note bad", children: props.loadError }) : null, props.buildError ? (_jsxs("div", { className: "stage-error", role: "alert", children: [_jsx("strong", { children: "Build failed" }), _jsx("pre", { children: props.buildError }), _jsx("span", { children: "The last build that worked stays up; save a fix and it reloads." })] })) : null] }), _jsx("div", { className: "stage-drafting", "aria-hidden": "true", children: "drawing\u2026" })] }));
98
101
  }
99
102
  // ---------------------------------------------------------------- transport
100
103
  function TransportBar(props) {
@@ -124,16 +127,30 @@ function Inspector(props) {
124
127
  }
125
128
  const mb = (bytes) => (bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB`);
126
129
  const KIND_NAMES = { scene: "Scene", video: "Video", image: "Image", audio: "Audio" };
130
+ /** A transition in words: what film.json says about it. */
131
+ function transitionText(t) {
132
+ switch (t.style) {
133
+ case "fade": return `Fade, ${t.frames} frames`;
134
+ case "wipe": return `Wipe ${t.direction}, ${t.frames} frames`;
135
+ case "push": return `Push ${t.direction}, ${t.frames} frames`;
136
+ case "morph": return `Morph, ${t.frames} frames`;
137
+ case "dissolve": return `Dissolve, ${t.noise} noise at ${t.size} px${t.edge ? `, ${t.edge} edge` : ""}, ${t.frames} frames`;
138
+ }
139
+ }
127
140
  function ClipDetails(props) {
128
141
  const { clip, fps } = props;
129
142
  const doc = clip.doc;
130
143
  const transport = useTransport();
131
- return (_jsxs("section", { className: "panel", children: [_jsxs("h2", { className: "clip-title", children: [_jsx("span", { className: "kind", "data-kind": clip.kind, children: KIND_NAMES[clip.kind] }), " ", clip.id] }), _jsxs("dl", { children: [_jsx(Field, { name: doc.kind === "scene" ? "Scene" : "File", children: clip.label }), _jsx(Field, { name: "Track", children: clip.row }), _jsx(Field, { name: "Starts", children: timecode(clip.start, fps) }), _jsx(Field, { name: "Ends", children: timecode(clip.end, fps) }), _jsxs(Field, { name: "Length", children: [seconds(clip.end - clip.start, fps), " \u00B7 ", clip.end - clip.start, " frames"] }), doc.kind === "video" || doc.kind === "audio" ? _jsxs(Field, { name: "Source", children: [doc.in.toFixed(2), " s \u2192 ", doc.out.toFixed(2), " s", doc.speed !== 1 ? ` at ${doc.speed}×` : ""] }) : null, doc.kind === "audio" ? _jsxs(Field, { name: "Gain", children: [doc.gain, " dB \u00B7 fades ", doc.fadeIn, "/", doc.fadeOut, " frames"] }) : null, doc.kind === "video" && (doc.reverse || doc.freeze) ? _jsx(Field, { name: "Plays", children: doc.freeze ? "frozen" : "reversed" }) : null, doc.origin ? _jsxs(Field, { name: "Written at", children: [doc.origin.file, ":", doc.origin.line] }) : null] }), doc.kind === "scene" ? _jsx("pre", { className: "props", children: JSON.stringify(doc.props, null, 2) }) : null, _jsxs("div", { className: "panel-actions", children: [_jsx("button", { type: "button", className: "text", onClick: () => transport.seek(clip.start), children: "Go to its start" }), _jsx("button", { type: "button", className: "text", onClick: () => transport.send({ type: "range", from: clip.start, to: clip.end - 1 }), children: "Play just this" })] })] }));
144
+ const { timeline } = useTimelineContext();
145
+ const model = useAtomValue(timeline.model);
146
+ const transitions = model?.rows.find((r) => r.id === clip.row)?.transitions ?? [];
147
+ const into = transitions.find((t) => t.to === clip.id), outOf = transitions.find((t) => t.from === clip.id);
148
+ return (_jsxs("section", { className: "panel", children: [_jsxs("h2", { className: "clip-title", children: [_jsx("span", { className: "kind", "data-kind": clip.kind, children: KIND_NAMES[clip.kind] }), " ", clip.id] }), _jsxs("dl", { children: [_jsx(Field, { name: doc.kind === "scene" ? "Scene" : "File", children: clip.label }), _jsx(Field, { name: "Track", children: clip.row }), _jsx(Field, { name: "Starts", children: timecode(clip.start, fps) }), _jsx(Field, { name: "Ends", children: timecode(clip.end, fps) }), _jsxs(Field, { name: "Length", children: [seconds(clip.end - clip.start, fps), " \u00B7 ", clip.end - clip.start, " frames"] }), doc.kind === "video" || doc.kind === "audio" ? _jsxs(Field, { name: "Source", children: [doc.in.toFixed(2), " s \u2192 ", doc.out.toFixed(2), " s", doc.speed !== 1 ? ` at ${doc.speed}×` : ""] }) : null, doc.kind === "audio" ? _jsxs(Field, { name: "Gain", children: [doc.gain, " dB \u00B7 fades ", doc.fadeIn, "/", doc.fadeOut, " frames"] }) : null, doc.kind === "video" && (doc.reverse || doc.freeze) ? _jsx(Field, { name: "Plays", children: doc.freeze ? "frozen" : "reversed" }) : null, doc.kind !== "audio" && doc.blend ? _jsx(Field, { name: "Blend", children: doc.blend }) : null, doc.kind !== "audio" && doc.opacity !== 1 ? _jsx(Field, { name: "Opacity", children: doc.opacity }) : null, into ? _jsx(Field, { name: "Comes in", children: transitionText(into.doc) }) : null, outOf ? _jsx(Field, { name: "Goes out", children: transitionText(outOf.doc) }) : null, doc.origin ? _jsxs(Field, { name: "Written at", children: [doc.origin.file, ":", doc.origin.line] }) : null] }), doc.kind === "scene" ? _jsx("pre", { className: "props", children: JSON.stringify(doc.props, null, 2) }) : null, _jsxs("div", { className: "panel-actions", children: [_jsx("button", { type: "button", className: "text", onClick: () => transport.seek(clip.start), children: "Go to its start" }), _jsx("button", { type: "button", className: "text", onClick: () => transport.send({ type: "range", from: clip.start, to: clip.end - 1 }), children: "Play just this" })] })] }));
132
149
  }
133
150
  // ---------------------------------------------------------------- timeline
134
151
  const StudioClip = memo(function StudioClip(props) {
135
152
  const { clip } = props;
136
- return (_jsxs(Timeline.Clip, { clip: clip, className: "clip", children: [props.picture && clip.kind !== "audio" ? _jsx(Filmstrip, { clip: clip, className: "clip-strip" }) : null, _jsxs("span", { className: "clip-label", children: [_jsx("span", { className: "clip-name", children: clip.detail ?? clip.label }), clip.detail ? _jsx("span", { className: "clip-sub", children: clip.label }) : null] })] }));
153
+ return (_jsxs(Timeline.Clip, { clip: clip, className: "clip", children: [props.picture && clip.kind !== "audio" ? _jsx(Filmstrip, { clip: clip, className: "clip-strip" }) : null, _jsxs("span", { className: "clip-label", children: [_jsx("span", { className: "clip-name", children: clip.detail ?? clip.label }), clip.detail ? _jsx("span", { className: "clip-sub", children: clip.label }) : clip.doc.kind !== "audio" && clip.doc.blend ? _jsx("span", { className: "clip-sub", children: clip.doc.blend }) : null] })] }));
137
154
  });
138
155
  function RowHeader(props) {
139
156
  return (_jsxs("div", { className: "row-header", "data-kind": props.row.kind, children: [props.row.kind === "audio" ? _jsx(SoundIcon, {}) : _jsx(PictureIcon, {}), _jsx("span", { children: props.row.id })] }));
@@ -141,5 +158,5 @@ function RowHeader(props) {
141
158
  function TimelinePanel() {
142
159
  const { timeline } = useTimelineContext();
143
160
  const rows = useAtomValue(timeline.rows);
144
- return (_jsxs("section", { className: "timeline", children: [_jsxs("div", { className: "timeline-headers", children: [_jsx("div", { className: "ruler-spacer" }), rows.map((row) => _jsx(RowHeader, { row: row }, row.id))] }), _jsx(Timeline.Root, { className: "timeline-body", children: _jsxs(Timeline.Scroller, { className: "timeline-scroller", children: [_jsx(Timeline.Ruler, { className: "ruler", renderTick: (tick) => (tick.label ? _jsx("span", { className: "tick-label", children: tick.label }) : null) }), _jsx(Timeline.Ready, { className: "ready" }), _jsx(Timeline.PlayRange, { className: "range" }), _jsx(Timeline.Rows, { children: (row) => (_jsx(Timeline.Row, { row: row, className: "row", renderClip: (clip) => _jsx(StudioClip, { clip: clip, picture: row.kind === "visual" }, clip.id) }, row.id)) }), _jsx(Timeline.Playhead, { className: "playhead", children: _jsx("span", { className: "playhead-handle" }) })] }) })] }));
161
+ return (_jsxs("section", { className: "timeline", children: [_jsxs("div", { className: "timeline-headers", children: [_jsx("div", { className: "ruler-spacer" }), rows.map((row) => _jsx(RowHeader, { row: row }, row.id))] }), _jsx(Timeline.Root, { className: "timeline-body", children: _jsxs(Timeline.Scroller, { className: "timeline-scroller", children: [_jsx(Timeline.Ruler, { className: "ruler", renderTick: (tick) => (tick.label ? _jsx("span", { className: "tick-label", children: tick.label }) : null) }), _jsx(Timeline.Ready, { className: "ready" }), _jsx(Timeline.PlayRange, { className: "range" }), _jsx(Timeline.Rows, { children: (row) => (_jsx(Timeline.Row, { row: row, className: "row", renderClip: (clip) => _jsx(StudioClip, { clip: clip, picture: row.kind === "visual" }, clip.id), renderTransition: (t) => _jsx(Timeline.Transition, { transition: t, title: transitionText(t.doc) }, `${t.from}>${t.to}`) }, row.id)) }), _jsx(Timeline.Playhead, { className: "playhead", children: _jsx("span", { className: "playhead-handle" }) })] }) })] }));
145
162
  }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@tongue-core/studio",
3
- "version": "0.1.0-rc.1",
3
+ "version": "0.1.0-rc.3",
4
4
  "description": "Tongue's studio: a film's player, timeline and inspector in the browser, built only from @tongue-core/ui. Served by `tongue studio`, for a project on this machine or a build anywhere.",
5
5
  "type": "module",
6
- "license": "UNLICENSED",
6
+ "license": "MIT",
7
7
  "author": "Backpine",
8
8
  "repository": {
9
9
  "type": "git",
@@ -28,8 +28,8 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "@effect/atom-react": "4.0.6",
31
- "@tongue-core/project": "0.1.0-rc.1",
32
- "@tongue-core/ui": "0.1.0-rc.1",
31
+ "@tongue-core/project": "0.1.0-rc.3",
32
+ "@tongue-core/ui": "0.1.0-rc.3",
33
33
  "effect": "4.0.6",
34
34
  "react": "19.3.0",
35
35
  "react-dom": "19.3.0"