@dailies-studio/shot-editor 0.1.0

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 (112) hide show
  1. package/dist/assets/index-B7notge7.css +1 -0
  2. package/dist/assets/index-BBE6Ta9L.js +52 -0
  3. package/dist/index.html +13 -0
  4. package/package.json +45 -0
  5. package/src/api.ts +289 -0
  6. package/src/app/App.tsx +394 -0
  7. package/src/audio/index.tsx +74 -0
  8. package/src/audio/preview.ts +364 -0
  9. package/src/bridge.ts +45 -0
  10. package/src/bridgeTypes.ts +582 -0
  11. package/src/channels.ts +40 -0
  12. package/src/main.tsx +15 -0
  13. package/src/panels/ResizeHandle.tsx +113 -0
  14. package/src/panels/agent/AgentPanel.tsx +478 -0
  15. package/src/panels/agent/agent.css +322 -0
  16. package/src/panels/export/ExportDialog.tsx +284 -0
  17. package/src/panels/files/FilePanel.tsx +564 -0
  18. package/src/panels/files/McpSection.tsx +66 -0
  19. package/src/panels/files/icons.tsx +118 -0
  20. package/src/panels/header/DocSwitcher.tsx +85 -0
  21. package/src/panels/header/Header.tsx +132 -0
  22. package/src/panels/header/HistoryControls.tsx +20 -0
  23. package/src/panels/header/StatusBadges.tsx +49 -0
  24. package/src/panels/header/TransportBar.tsx +86 -0
  25. package/src/panels/header/ZoomControls.tsx +38 -0
  26. package/src/panels/help/HelpSection.tsx +190 -0
  27. package/src/panels/inspector/FramingRows.tsx +69 -0
  28. package/src/panels/inspector/InspectorPanel.tsx +178 -0
  29. package/src/panels/inspector/MarkerSection.tsx +63 -0
  30. package/src/panels/inspector/ParamRow.tsx +71 -0
  31. package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
  32. package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
  33. package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
  34. package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
  35. package/src/panels/inspector/animate/ShotSection.tsx +126 -0
  36. package/src/panels/inspector/animate/useBlocks.ts +43 -0
  37. package/src/panels/inspector/design/AlignRow.tsx +63 -0
  38. package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
  39. package/src/panels/inspector/design/ClonerSection.tsx +142 -0
  40. package/src/panels/inspector/design/ComponentSection.tsx +94 -0
  41. package/src/panels/inspector/design/CornerSection.tsx +52 -0
  42. package/src/panels/inspector/design/FillSection.tsx +89 -0
  43. package/src/panels/inspector/design/HtmlSection.tsx +50 -0
  44. package/src/panels/inspector/design/InstanceSection.tsx +302 -0
  45. package/src/panels/inspector/design/MediaSection.tsx +251 -0
  46. package/src/panels/inspector/design/MeshSection.tsx +172 -0
  47. package/src/panels/inspector/design/ShadowSection.tsx +73 -0
  48. package/src/panels/inspector/design/StrokeSection.tsx +85 -0
  49. package/src/panels/inspector/design/TransformSection.tsx +91 -0
  50. package/src/panels/inspector/design/TypographySection.tsx +170 -0
  51. package/src/panels/inspector/scene/CameraSection.tsx +59 -0
  52. package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
  53. package/src/panels/inspector/scene/SceneSection.tsx +71 -0
  54. package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
  55. package/src/panels/inspector/useDocWrite.ts +268 -0
  56. package/src/panels/material/MaterialPicker.tsx +172 -0
  57. package/src/panels/stage/StageMenu.tsx +38 -0
  58. package/src/panels/stage/StagePanel.tsx +131 -0
  59. package/src/panels/timeline/AudioLane.tsx +340 -0
  60. package/src/panels/timeline/BlockRow.tsx +242 -0
  61. package/src/panels/timeline/ChannelRow.tsx +266 -0
  62. package/src/panels/timeline/LifespanRow.tsx +165 -0
  63. package/src/panels/timeline/MarkerLane.tsx +146 -0
  64. package/src/panels/timeline/SceneStrip.tsx +145 -0
  65. package/src/panels/timeline/TimelineDock.tsx +488 -0
  66. package/src/panels/timeline/model.ts +641 -0
  67. package/src/panels/timeline/snap.ts +191 -0
  68. package/src/panels/toolbar/AddMenu.tsx +230 -0
  69. package/src/panels/toolbar/CameraViewBar.tsx +70 -0
  70. package/src/panels/toolbar/ShadingBar.tsx +42 -0
  71. package/src/panels/toolbar/ToolDockBar.tsx +110 -0
  72. package/src/panels/toolbar/ViewModeBar.tsx +49 -0
  73. package/src/shotTokens.css +131 -0
  74. package/src/stage/FrustumGizmo.tsx +184 -0
  75. package/src/stage/GridPlane.tsx +274 -0
  76. package/src/stage/MoveGizmo.tsx +411 -0
  77. package/src/stage/PerfHud.tsx +206 -0
  78. package/src/stage/SplitRenderer.tsx +511 -0
  79. package/src/stage/StageApp.tsx +3423 -0
  80. package/src/stage/StageTools.tsx +581 -0
  81. package/src/stage/bridgeClient.ts +278 -0
  82. package/src/stage/feed.ts +243 -0
  83. package/src/stage/frustumGeometry.ts +172 -0
  84. package/src/stage/gizmoMath.ts +494 -0
  85. package/src/stage/handles.ts +151 -0
  86. package/src/stage/instanceModules.ts +152 -0
  87. package/src/stage/interactions/move.ts +172 -0
  88. package/src/stage/interactions/panZoom.ts +60 -0
  89. package/src/stage/isolation.ts +155 -0
  90. package/src/stage/main.tsx +110 -0
  91. package/src/stage/modalState.ts +427 -0
  92. package/src/stage/overlays/Overlays.tsx +1046 -0
  93. package/src/stage/panes.ts +121 -0
  94. package/src/stage/penState.ts +112 -0
  95. package/src/stage/project.ts +402 -0
  96. package/src/stage/shading.ts +55 -0
  97. package/src/stage/tokenColor.ts +26 -0
  98. package/src/stage/view.ts +182 -0
  99. package/src/stage/workingView.ts +636 -0
  100. package/src/state/agentActions.ts +203 -0
  101. package/src/state/clientId.ts +25 -0
  102. package/src/state/context.tsx +33 -0
  103. package/src/state/insert.ts +507 -0
  104. package/src/state/keymap.ts +560 -0
  105. package/src/state/menu.ts +141 -0
  106. package/src/state/panelSize.ts +26 -0
  107. package/src/state/renderEvents.ts +100 -0
  108. package/src/state/store.ts +4094 -0
  109. package/src/state/triangles.ts +70 -0
  110. package/src/state/types.ts +384 -0
  111. package/src/styles.css +2948 -0
  112. package/src/tools.ts +181 -0
@@ -0,0 +1,322 @@
1
+ /* Agent drawer: transcript, layer-tree + diff cards, BYOK setup, composer.
2
+ Shares the motion editor's class vocabulary on purpose — the two panels
3
+ render the same transcript model, and one visual language across chromes is
4
+ what makes the lane feel like one feature. The canvas-only additions are
5
+ .agent-tree (the layer summary) and .agent-card-meta (doc name, layer count
6
+ and, when the change animates, a track count). */
7
+
8
+ .agent-panel {
9
+ position: relative;
10
+ display: flex;
11
+ flex-direction: column;
12
+ height: 100%;
13
+ min-width: 0;
14
+ }
15
+
16
+ .agent-title {
17
+ display: flex;
18
+ align-items: center;
19
+ gap: 6px;
20
+ }
21
+
22
+ .agent-title-label {
23
+ flex: none;
24
+ }
25
+
26
+ .agent-models {
27
+ display: flex;
28
+ gap: 2px;
29
+ flex: 1;
30
+ min-width: 0;
31
+ }
32
+
33
+ /* ---------- BYOK setup ---------------------------------------------------- */
34
+
35
+ .agent-setup {
36
+ padding: 8px;
37
+ border-bottom: 1px solid var(--border);
38
+ flex: none;
39
+ display: flex;
40
+ flex-direction: column;
41
+ gap: 6px;
42
+ }
43
+
44
+ .agent-setup-blurb {
45
+ margin: 0;
46
+ color: var(--text-2);
47
+ font-size: 12px;
48
+ line-height: 1.5;
49
+ }
50
+
51
+ .agent-setup-row {
52
+ display: flex;
53
+ gap: 4px;
54
+ }
55
+
56
+ .agent-key-input {
57
+ flex: 1;
58
+ min-width: 0;
59
+ background: var(--bg-2);
60
+ border: 1px solid var(--border);
61
+ border-radius: var(--radius);
62
+ color: var(--text-1);
63
+ padding: 4px 6px;
64
+ font: var(--mono);
65
+ }
66
+
67
+ /* ---------- transcript ----------------------------------------------------- */
68
+
69
+ .agent-transcript {
70
+ flex: 1;
71
+ min-height: 0;
72
+ overflow-y: auto;
73
+ padding: 8px;
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: 6px;
77
+ }
78
+
79
+ .agent-msg {
80
+ border-radius: var(--radius);
81
+ padding: 5px 8px;
82
+ font-size: 12px;
83
+ line-height: 1.5;
84
+ white-space: pre-wrap;
85
+ overflow-wrap: anywhere;
86
+ }
87
+
88
+ .agent-msg-user {
89
+ background: var(--accent-soft);
90
+ color: var(--text-1);
91
+ align-self: flex-end;
92
+ max-width: 90%;
93
+ }
94
+
95
+ .agent-msg-assistant {
96
+ background: var(--bg-2);
97
+ color: var(--text-1);
98
+ }
99
+
100
+ .agent-msg-error {
101
+ background: var(--bg-2);
102
+ color: var(--danger);
103
+ }
104
+
105
+ .agent-msg-info {
106
+ color: var(--text-3);
107
+ font-style: italic;
108
+ }
109
+
110
+ .agent-tool {
111
+ color: var(--text-3);
112
+ font: var(--mono);
113
+ font-size: 11px;
114
+ padding-left: 8px;
115
+ }
116
+ .agent-tool::before {
117
+ content: "⚙ ";
118
+ }
119
+
120
+ .agent-thinking {
121
+ animation: agent-pulse 1.2s ease-in-out infinite;
122
+ }
123
+ @keyframes agent-pulse {
124
+ 50% {
125
+ opacity: 0.4;
126
+ }
127
+ }
128
+
129
+ /* ---------- proposal cards ------------------------------------------------- */
130
+
131
+ .agent-card {
132
+ border: 1px solid var(--border);
133
+ border-radius: var(--radius);
134
+ background: var(--bg-1);
135
+ overflow: hidden;
136
+ flex: none;
137
+ }
138
+ .agent-card-applied {
139
+ border-color: var(--accent);
140
+ }
141
+
142
+ .agent-card-head {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 6px;
146
+ padding: 4px 6px;
147
+ border-bottom: 1px solid var(--border);
148
+ background: var(--bg-2);
149
+ }
150
+
151
+ .agent-card-path {
152
+ flex: 1;
153
+ min-width: 0;
154
+ overflow: hidden;
155
+ text-overflow: ellipsis;
156
+ white-space: nowrap;
157
+ color: var(--text-1);
158
+ }
159
+ /* File cards address a path; canvas cards address an intent sentence. */
160
+ .agent-card:not(.agent-card-canvas) .agent-card-path {
161
+ font: var(--mono);
162
+ }
163
+
164
+ .agent-card-status {
165
+ flex: none;
166
+ font-size: 11px;
167
+ text-transform: uppercase;
168
+ letter-spacing: 0.04em;
169
+ }
170
+ .agent-card-status.is-applied {
171
+ color: var(--accent);
172
+ }
173
+ .agent-card-status.is-stale,
174
+ .agent-card-status.is-failed {
175
+ color: var(--danger);
176
+ }
177
+ .agent-card-status.is-applying {
178
+ color: var(--text-2);
179
+ }
180
+
181
+ /* ---------- canvas card: doc + layer/track counts + the layer tree -------- */
182
+
183
+ .agent-card-meta {
184
+ display: flex;
185
+ align-items: center;
186
+ flex-wrap: wrap;
187
+ gap: 4px;
188
+ padding: 4px 8px;
189
+ font-size: 11px;
190
+ color: var(--text-3);
191
+ }
192
+
193
+ .agent-card-doc {
194
+ color: var(--text-2);
195
+ font: var(--mono);
196
+ font-size: 11px;
197
+ }
198
+
199
+ .agent-card-count::before {
200
+ content: "· ";
201
+ }
202
+
203
+ .agent-tree {
204
+ margin: 0;
205
+ padding: 4px 0;
206
+ max-height: 260px;
207
+ overflow: auto;
208
+ font: var(--mono);
209
+ font-size: 11px;
210
+ line-height: 1.45;
211
+ color: var(--text-2);
212
+ }
213
+ .agent-tree-line {
214
+ padding: 0 8px;
215
+ white-space: pre;
216
+ }
217
+
218
+ /* ---------- file card diff -------------------------------------------------- */
219
+
220
+ .agent-diff {
221
+ margin: 0;
222
+ padding: 4px 0;
223
+ max-height: 260px;
224
+ overflow: auto;
225
+ font: var(--mono);
226
+ font-size: 11px;
227
+ line-height: 1.45;
228
+ }
229
+ .agent-diff > div {
230
+ padding: 0 8px;
231
+ white-space: pre;
232
+ }
233
+ .agent-diff-add {
234
+ background: rgba(64, 160, 96, 0.16);
235
+ color: #7fd79f;
236
+ }
237
+ .agent-diff-del {
238
+ background: rgba(200, 72, 72, 0.14);
239
+ color: #e79a9a;
240
+ }
241
+ .agent-diff-hunk {
242
+ color: var(--text-3);
243
+ }
244
+ .agent-diff-ctx {
245
+ color: var(--text-2);
246
+ }
247
+
248
+ .agent-card-note {
249
+ padding: 4px 8px;
250
+ color: var(--text-3);
251
+ font-size: 11px;
252
+ }
253
+
254
+ .agent-card-error {
255
+ margin: 0;
256
+ white-space: normal;
257
+ }
258
+
259
+ /* "Applied, but the generated component was not updated" — not a failure (the
260
+ document is saved), so not the error red; loud enough to be read. */
261
+ .agent-card-warning {
262
+ color: var(--warn, #e0a33e);
263
+ white-space: normal;
264
+ }
265
+
266
+ /* ---------- footer --------------------------------------------------------- */
267
+
268
+ .agent-strip {
269
+ flex: none;
270
+ display: flex;
271
+ align-items: center;
272
+ gap: 8px;
273
+ }
274
+
275
+ .agent-strip-msg {
276
+ flex: 1;
277
+ min-width: 0;
278
+ overflow: hidden;
279
+ text-overflow: ellipsis;
280
+ white-space: nowrap;
281
+ }
282
+
283
+ .agent-cost {
284
+ flex: none;
285
+ padding: 2px 8px;
286
+ color: var(--text-3);
287
+ font-size: 11px;
288
+ text-align: right;
289
+ }
290
+
291
+ .agent-composer {
292
+ display: flex;
293
+ align-items: flex-end;
294
+ gap: 4px;
295
+ padding: 8px;
296
+ border-top: 1px solid var(--border);
297
+ flex: none;
298
+ flex-wrap: wrap;
299
+ }
300
+
301
+ .agent-pin {
302
+ flex: 0 0 100%;
303
+ color: var(--text-3);
304
+ font-size: 11px;
305
+ }
306
+
307
+ .agent-input {
308
+ flex: 1;
309
+ min-width: 0;
310
+ resize: none;
311
+ background: var(--bg-2);
312
+ border: 1px solid var(--border);
313
+ border-radius: var(--radius);
314
+ color: var(--text-1);
315
+ padding: 5px 7px;
316
+ font: inherit;
317
+ font-size: 12px;
318
+ line-height: 1.4;
319
+ }
320
+ .agent-input:focus {
321
+ outline: 1px solid var(--accent);
322
+ }
@@ -0,0 +1,284 @@
1
+ // The export dialog: format, quality, range, and an honest estimate.
2
+ //
3
+ // ── WHAT THE DIALOG PROMISES ────────────────────────────────────────────
4
+ //
5
+ // Every control here resolves through `@dailies-studio/shot`'s export.ts, which
6
+ // is the SAME table `dailies shot render` reads. So the button and the
7
+ // command produce the same file, and the exit criterion for this slice is a
8
+ // property of the wiring rather than a thing to remember.
9
+ //
10
+ // ── AND WHAT IT REFUSES TO PROMISE ──────────────────────────────────────
11
+ //
12
+ // The size figure is a RANGE, never a point value: at a fixed CRF the encoder
13
+ // spends what the content needs, and a title card and a full-frame parallax
14
+ // move differ by more than any model would admit.
15
+ //
16
+ // The time figure is a median of THIS project's own past renders and reads
17
+ // "estimating…" until there is one. A shipped ms-per-frame table would be wrong
18
+ // by an order of magnitude on most machines, and the author plans around it.
19
+ //
20
+ // GIF gets NO size figure at all — its size is dominated by palette and by how
21
+ // much of the frame changes, neither of which the bits-per-pixel model knows.
22
+ // It shows the frame count instead, which is true.
23
+ import { useEffect, useState } from "react";
24
+ import {
25
+ EXPORT_QUALITIES,
26
+ docDurationFrames,
27
+ estimateMp4Size,
28
+ exportQuality,
29
+ formatSizeEstimate,
30
+ formatTimeEstimate,
31
+ predecessorTransitionOmitted,
32
+ sceneWindows,
33
+ } from "@dailies-studio/shot/doc";
34
+ import type { ExportFormat, ExportQualityId, RenderStats } from "@dailies-studio/shot/doc";
35
+ import { Dialog, SegmentedControl } from "@dailies-studio/ui";
36
+ import { ApiError, cancelRender, renderState, startRender } from "../../api.js";
37
+ import type { RenderJob } from "../../api.js";
38
+ import { useShotEditor, useStore } from "../../state/context.js";
39
+
40
+ /** "the whole timeline", as a select value. Not a scene id, and cannot be one. */
41
+ const WHOLE = "";
42
+
43
+ export function ExportDialog(props: { onClose(): void }) {
44
+ const store = useStore();
45
+ const doc = useShotEditor((s) => s.doc);
46
+ const name = useShotEditor((s) => s.name);
47
+ const currentScene = useShotEditor((s) => s.sceneId);
48
+ const job = useShotEditor((s) => s.renderJob);
49
+
50
+ const [format, setFormat] = useState<ExportFormat>("mp4");
51
+ const [quality, setQuality] = useState<ExportQualityId>("social");
52
+ // Defaults to the WHOLE timeline even though a scene is open, because
53
+ // "export" without qualification means the deliverable. Choosing one scene is
54
+ // deliberate, and the select says which one.
55
+ const [scope, setScope] = useState<string>(WHOLE);
56
+ const [stats, setStats] = useState<RenderStats>({ runs: [] });
57
+ const [error, setError] = useState<string | null>(null);
58
+ // ON by default: the document's clips are part of the deliverable, and an
59
+ // export that dropped them because a checkbox defaulted off is the silent
60
+ // wrong artifact this lane refuses to produce. Unticking it is deliberate,
61
+ // and the row says how many clips it is dropping.
62
+ const [withAudio, setWithAudio] = useState(true);
63
+
64
+ // The dialog can open MID-RENDER (the author closed it and came back), so it
65
+ // asks rather than assuming idle — and picks up this project's measured speed
66
+ // in the same round trip.
67
+ useEffect(() => {
68
+ if (name === null) return;
69
+ let live = true;
70
+ void renderState(name)
71
+ .then((state) => {
72
+ if (!live) return;
73
+ setStats(state.stats);
74
+ if (state.job !== null) store.setRenderJob(state.job);
75
+ })
76
+ .catch(() => {
77
+ // An estimate is a nicety. A server without the render lane still lets
78
+ // the author press Export and get a real error from the attempt.
79
+ });
80
+ return () => {
81
+ live = false;
82
+ };
83
+ }, [name, store, job?.phase]);
84
+
85
+ if (doc === null || name === null) return null;
86
+
87
+ const windows = sceneWindows(doc);
88
+ const frames =
89
+ scope === WHOLE
90
+ ? docDurationFrames(doc)
91
+ : (windows.find((w) => w.id === scope)?.end ?? 0) - (windows.find((w) => w.id === scope)?.start ?? 0);
92
+ const megapixels = (doc.size.width * doc.size.height) / 1_000_000;
93
+ // GIF has no audio stream at all, so the checkbox is not a choice there and
94
+ // the size model must not budget for one either.
95
+ const clipCount = Object.keys(doc.audio).length;
96
+ const audible = format === "mp4" && withAudio && clipCount > 0;
97
+ const size = estimateMp4Size({
98
+ width: doc.size.width,
99
+ height: doc.size.height,
100
+ durationFrames: frames,
101
+ fps: doc.fps,
102
+ quality: exportQuality(quality),
103
+ audio: audible,
104
+ });
105
+ const running = job?.phase === "running";
106
+
107
+ const submit = (): void => {
108
+ setError(null);
109
+ void startRender(name, {
110
+ format,
111
+ quality,
112
+ ...(scope === WHOLE ? {} : { sceneId: scope }),
113
+ // Only ever sent as `false`. Absent means the server's default, which is
114
+ // to mux whatever `doc.audio` carries, and saying so explicitly would
115
+ // make this chrome the thing that decides it.
116
+ ...(withAudio ? {} : { audio: false }),
117
+ })
118
+ .then((started) => store.setRenderJob(started))
119
+ .catch((e: unknown) => {
120
+ // A 409 carries the running job, which is more useful than the message.
121
+ if (e instanceof ApiError && e.conflict) setError(e.message);
122
+ else setError(e instanceof Error ? e.message : String(e));
123
+ });
124
+ };
125
+
126
+ return (
127
+ <Dialog
128
+ open
129
+ title="Export"
130
+ // The shared dialog is a hard 320px, which is right for a confirm and
131
+ // too narrow for three labelled rows plus a size RANGE — the estimate
132
+ // was overflowing and clipping mid-range, and "911 KB–2" reads as a
133
+ // finished number rather than a truncated one.
134
+ className="shot-export-dialog"
135
+ onClose={props.onClose}
136
+ actions={
137
+ <>
138
+ {running ? (
139
+ <button type="button" className="btn" onClick={() => void cancelRender(name)}>
140
+ Cancel render
141
+ </button>
142
+ ) : (
143
+ <button type="button" className="btn is-primary" onClick={submit}>
144
+ Export
145
+ </button>
146
+ )}
147
+ <button type="button" className="btn" onClick={props.onClose}>
148
+ Close
149
+ </button>
150
+ </>
151
+ }
152
+ >
153
+ <div className="export-dialog" data-testid="export-dialog">
154
+ <label className="export-row">
155
+ <span>Format</span>
156
+ <SegmentedControl
157
+ ariaLabel="Export format"
158
+ value={format}
159
+ options={[
160
+ { value: "mp4", label: "MP4" },
161
+ { value: "gif", label: "GIF" },
162
+ ]}
163
+ onChange={(next) => setFormat(next as ExportFormat)}
164
+ />
165
+ </label>
166
+
167
+ <label className="export-row">
168
+ <span>Quality</span>
169
+ <select
170
+ className="select"
171
+ aria-label="Export quality"
172
+ value={quality}
173
+ onChange={(e) => setQuality(e.target.value as ExportQualityId)}
174
+ >
175
+ {EXPORT_QUALITIES.map((q) => (
176
+ <option key={q.id} value={q.id}>
177
+ {q.label}
178
+ </option>
179
+ ))}
180
+ </select>
181
+ </label>
182
+
183
+ <label className="export-row">
184
+ <span>Range</span>
185
+ <select
186
+ className="select"
187
+ aria-label="Export range"
188
+ value={scope}
189
+ onChange={(e) => setScope(e.target.value)}
190
+ >
191
+ <option value={WHOLE}>Whole timeline</option>
192
+ {doc.scenes.map((id) => (
193
+ <option key={id} value={id}>
194
+ {doc.scenesById[id]?.name ?? id}
195
+ {id === currentScene ? " (open)" : ""}
196
+ </option>
197
+ ))}
198
+ </select>
199
+ </label>
200
+
201
+ {/* A transition is stored on the shot BEFORE this one, and a one-shot
202
+ render does not carry it. Warned HERE, client-side off (doc, scope),
203
+ because the dialog already holds both and this costs the author
204
+ nothing — the alternative is finding out after the render. */}
205
+ {scope !== WHOLE && predecessorTransitionOmitted(doc, scope) ? (
206
+ <p className="export-note" data-testid="export-transition-note">
207
+ The transition into this shot plays from the shot before it, so a
208
+ single-shot render starts on a cut. Render the whole timeline to
209
+ include it.
210
+ </p>
211
+ ) : null}
212
+
213
+ <label className="export-row">
214
+ <span>Audio</span>
215
+ <span className="export-audio" data-testid="export-audio">
216
+ <input
217
+ type="checkbox"
218
+ aria-label="Include audio"
219
+ checked={withAudio}
220
+ disabled={format === "gif" || clipCount === 0}
221
+ onChange={(e) => setWithAudio(e.currentTarget.checked)}
222
+ />
223
+ <span>
224
+ {clipCount === 0
225
+ ? "no clips in this scene"
226
+ : format === "gif"
227
+ ? `${clipCount} clip${clipCount === 1 ? "" : "s"} — GIF has no audio track`
228
+ : withAudio
229
+ ? `${clipCount} clip${clipCount === 1 ? "" : "s"} included`
230
+ : `${clipCount} clip${clipCount === 1 ? "" : "s"} will be dropped`}
231
+ </span>
232
+ </span>
233
+ </label>
234
+
235
+ <dl className="export-estimate" data-testid="export-estimate">
236
+ <dt>Frames</dt>
237
+ <dd>{frames}</dd>
238
+ <dt>Size</dt>
239
+ {/* See the header: a GIF's size is a palette question, and the
240
+ bits-per-pixel model does not know the answer. */}
241
+ <dd>{format === "gif" ? "varies with palette" : formatSizeEstimate(size)}</dd>
242
+ <dt>Time</dt>
243
+ <dd>{formatTimeEstimate(stats, megapixels, frames)}</dd>
244
+ </dl>
245
+
246
+ {job !== null && <JobRow job={job} />}
247
+ {error !== null && <p className="export-error">{error}</p>}
248
+ </div>
249
+ </Dialog>
250
+ );
251
+ }
252
+
253
+ /** The live job, or the last one this session. Shown after a "done" too: the
254
+ * author's next question is always "where did it go". */
255
+ function JobRow(props: { job: RenderJob }) {
256
+ const { job } = props;
257
+ if (job.phase === "running") {
258
+ return (
259
+ <p className="export-progress" data-testid="export-progress">
260
+ Rendering frame {job.done}/{job.total || "?"}
261
+ </p>
262
+ );
263
+ }
264
+ if (job.phase === "done") {
265
+ return (
266
+ <p className="export-progress is-done" data-testid="export-progress">
267
+ Written to <code>{job.out}</code>
268
+ </p>
269
+ );
270
+ }
271
+ if (job.phase === "cancelled") {
272
+ // Not an error, and it must not look like one: the author pressed Cancel.
273
+ return (
274
+ <p className="export-progress" data-testid="export-progress">
275
+ Render cancelled — nothing was written.
276
+ </p>
277
+ );
278
+ }
279
+ return (
280
+ <p className="export-error" data-testid="export-progress">
281
+ {job.error ?? "The render failed."}
282
+ </p>
283
+ );
284
+ }