@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.
- package/dist/assets/index-B7notge7.css +1 -0
- package/dist/assets/index-BBE6Ta9L.js +52 -0
- package/dist/index.html +13 -0
- package/package.json +45 -0
- package/src/api.ts +289 -0
- package/src/app/App.tsx +394 -0
- package/src/audio/index.tsx +74 -0
- package/src/audio/preview.ts +364 -0
- package/src/bridge.ts +45 -0
- package/src/bridgeTypes.ts +582 -0
- package/src/channels.ts +40 -0
- package/src/main.tsx +15 -0
- package/src/panels/ResizeHandle.tsx +113 -0
- package/src/panels/agent/AgentPanel.tsx +478 -0
- package/src/panels/agent/agent.css +322 -0
- package/src/panels/export/ExportDialog.tsx +284 -0
- package/src/panels/files/FilePanel.tsx +564 -0
- package/src/panels/files/McpSection.tsx +66 -0
- package/src/panels/files/icons.tsx +118 -0
- package/src/panels/header/DocSwitcher.tsx +85 -0
- package/src/panels/header/Header.tsx +132 -0
- package/src/panels/header/HistoryControls.tsx +20 -0
- package/src/panels/header/StatusBadges.tsx +49 -0
- package/src/panels/header/TransportBar.tsx +86 -0
- package/src/panels/header/ZoomControls.tsx +38 -0
- package/src/panels/help/HelpSection.tsx +190 -0
- package/src/panels/inspector/FramingRows.tsx +69 -0
- package/src/panels/inspector/InspectorPanel.tsx +178 -0
- package/src/panels/inspector/MarkerSection.tsx +63 -0
- package/src/panels/inspector/ParamRow.tsx +71 -0
- package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
- package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
- package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
- package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
- package/src/panels/inspector/animate/ShotSection.tsx +126 -0
- package/src/panels/inspector/animate/useBlocks.ts +43 -0
- package/src/panels/inspector/design/AlignRow.tsx +63 -0
- package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
- package/src/panels/inspector/design/ClonerSection.tsx +142 -0
- package/src/panels/inspector/design/ComponentSection.tsx +94 -0
- package/src/panels/inspector/design/CornerSection.tsx +52 -0
- package/src/panels/inspector/design/FillSection.tsx +89 -0
- package/src/panels/inspector/design/HtmlSection.tsx +50 -0
- package/src/panels/inspector/design/InstanceSection.tsx +302 -0
- package/src/panels/inspector/design/MediaSection.tsx +251 -0
- package/src/panels/inspector/design/MeshSection.tsx +172 -0
- package/src/panels/inspector/design/ShadowSection.tsx +73 -0
- package/src/panels/inspector/design/StrokeSection.tsx +85 -0
- package/src/panels/inspector/design/TransformSection.tsx +91 -0
- package/src/panels/inspector/design/TypographySection.tsx +170 -0
- package/src/panels/inspector/scene/CameraSection.tsx +59 -0
- package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
- package/src/panels/inspector/scene/SceneSection.tsx +71 -0
- package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
- package/src/panels/inspector/useDocWrite.ts +268 -0
- package/src/panels/material/MaterialPicker.tsx +172 -0
- package/src/panels/stage/StageMenu.tsx +38 -0
- package/src/panels/stage/StagePanel.tsx +131 -0
- package/src/panels/timeline/AudioLane.tsx +340 -0
- package/src/panels/timeline/BlockRow.tsx +242 -0
- package/src/panels/timeline/ChannelRow.tsx +266 -0
- package/src/panels/timeline/LifespanRow.tsx +165 -0
- package/src/panels/timeline/MarkerLane.tsx +146 -0
- package/src/panels/timeline/SceneStrip.tsx +145 -0
- package/src/panels/timeline/TimelineDock.tsx +488 -0
- package/src/panels/timeline/model.ts +641 -0
- package/src/panels/timeline/snap.ts +191 -0
- package/src/panels/toolbar/AddMenu.tsx +230 -0
- package/src/panels/toolbar/CameraViewBar.tsx +70 -0
- package/src/panels/toolbar/ShadingBar.tsx +42 -0
- package/src/panels/toolbar/ToolDockBar.tsx +110 -0
- package/src/panels/toolbar/ViewModeBar.tsx +49 -0
- package/src/shotTokens.css +131 -0
- package/src/stage/FrustumGizmo.tsx +184 -0
- package/src/stage/GridPlane.tsx +274 -0
- package/src/stage/MoveGizmo.tsx +411 -0
- package/src/stage/PerfHud.tsx +206 -0
- package/src/stage/SplitRenderer.tsx +511 -0
- package/src/stage/StageApp.tsx +3423 -0
- package/src/stage/StageTools.tsx +581 -0
- package/src/stage/bridgeClient.ts +278 -0
- package/src/stage/feed.ts +243 -0
- package/src/stage/frustumGeometry.ts +172 -0
- package/src/stage/gizmoMath.ts +494 -0
- package/src/stage/handles.ts +151 -0
- package/src/stage/instanceModules.ts +152 -0
- package/src/stage/interactions/move.ts +172 -0
- package/src/stage/interactions/panZoom.ts +60 -0
- package/src/stage/isolation.ts +155 -0
- package/src/stage/main.tsx +110 -0
- package/src/stage/modalState.ts +427 -0
- package/src/stage/overlays/Overlays.tsx +1046 -0
- package/src/stage/panes.ts +121 -0
- package/src/stage/penState.ts +112 -0
- package/src/stage/project.ts +402 -0
- package/src/stage/shading.ts +55 -0
- package/src/stage/tokenColor.ts +26 -0
- package/src/stage/view.ts +182 -0
- package/src/stage/workingView.ts +636 -0
- package/src/state/agentActions.ts +203 -0
- package/src/state/clientId.ts +25 -0
- package/src/state/context.tsx +33 -0
- package/src/state/insert.ts +507 -0
- package/src/state/keymap.ts +560 -0
- package/src/state/menu.ts +141 -0
- package/src/state/panelSize.ts +26 -0
- package/src/state/renderEvents.ts +100 -0
- package/src/state/store.ts +4094 -0
- package/src/state/triangles.ts +70 -0
- package/src/state/types.ts +384 -0
- package/src/styles.css +2948 -0
- 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
|
+
}
|