rvlib-shipkit 2.2.0 → 2.4.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/CHANGELOG.md +47 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +9 -0
- package/src/cli/cmd_Review.ts +974 -0
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +264 -6
- package/src/cli/kit/verb.ts +22 -4
- package/src/cli.ts +8 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/app-icon.ts +10 -6
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/repo-config.ts +3 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +158 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +896 -0
- package/src/review/project.ts +126 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +239 -0
- package/src/review/store.ts +1011 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +183 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +36 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +17 -3
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +83 -14
- package/src/serve/hub-app/icons.tsx +15 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +26 -6
- package/src/serve/hub-app/project-list.tsx +46 -10
- package/src/serve/hub-app/projects-svc.ts +7 -2
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +61 -4
- package/src/serve/hub-app/review/clip-st.ts +258 -0
- package/src/serve/hub-app/review/kind-view.tsx +240 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +269 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +96 -0
- package/src/serve/hub-app/review/review-st.ts +902 -0
- package/src/serve/hub-app/review/review-views.tsx +648 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +389 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +9 -7
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +15 -0
- package/src/shipkit-files.ts +9 -5
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +90 -6
- package/src/trailer/build.ts +459 -59
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +112 -43
- package/src/trailer/grammar.ts +864 -127
- package/src/trailer/graph.ts +308 -49
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +20 -3
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +82 -23
- package/src/trailer/prune.ts +204 -0
- package/src/trailer/score/compose.ts +256 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +98 -0
- package/src/trailer/score/score.ts +228 -0
- package/src/trailer/score/sound.ts +123 -0
- package/src/trailer/score/synth.ts +383 -0
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +471 -150
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +34 -4
- package/src/trailer/words.ts +275 -0
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// a repo's trailer page (`#page:trailer`, wiki build/trailer.md, Frame notes): the cuts on the left, the player over
|
|
2
|
+
// its timeline and its shot cards in the middle, the notes on the right. A note pins a frame of the render on screen
|
|
3
|
+
// and becomes an item of the review store. On a phone the three stacked
|
|
4
|
+
import { observer } from 'mobx-react-lite'
|
|
5
|
+
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
6
|
+
import { Group, Panel, Separator } from 'react-resizable-panels'
|
|
7
|
+
import { clock, cutsByRecent, shotAt, type CutInfo, type TrailerNote } from '../../../review/trailer-model.ts'
|
|
8
|
+
import { editorHref } from '../check-words.ts'
|
|
9
|
+
import { renderMd } from '../md.ts'
|
|
10
|
+
import type { RepoSt } from '../repo-st.ts'
|
|
11
|
+
import { keepFocus } from './kit.tsx'
|
|
12
|
+
import { PANEL, PhoneTop } from './review-page.tsx'
|
|
13
|
+
import { ClipSound } from './review-views.tsx'
|
|
14
|
+
import { hueOf, ShotCards, TrailerTimeline } from './trailer-timeline.tsx'
|
|
15
|
+
import { captionText, type PlacedNote, type TrailerSt } from './trailer-st.ts'
|
|
16
|
+
|
|
17
|
+
export const TrailerPage = observer(({ app }: { app: RepoSt }) => {
|
|
18
|
+
const tr = app.trailer
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
tr.start()
|
|
21
|
+
return () => tr.stop()
|
|
22
|
+
}, [tr])
|
|
23
|
+
if (app.review.phone.on)
|
|
24
|
+
return (
|
|
25
|
+
<div className="tr-phone">
|
|
26
|
+
<PhoneTop app={app} />
|
|
27
|
+
<PlayerColumn tr={tr} app={app} />
|
|
28
|
+
<NotesColumn tr={tr} />
|
|
29
|
+
<CutList tr={tr} />
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
return (
|
|
33
|
+
<Group className="rv-cols" orientation="horizontal">
|
|
34
|
+
<Panel id="trailer-cuts" defaultSize="15%" minSize={190} className="rv-pane" style={PANEL}>
|
|
35
|
+
<div className="rv-scroll"><CutList tr={tr} /></div>
|
|
36
|
+
</Panel>
|
|
37
|
+
<Separator className="rsep" />
|
|
38
|
+
<Panel id="trailer-player" defaultSize="57%" minSize={420} className="rv-pane" style={PANEL}>
|
|
39
|
+
<div className="rv-scroll"><PlayerColumn tr={tr} app={app} /></div>
|
|
40
|
+
</Panel>
|
|
41
|
+
<Separator className="rsep" />
|
|
42
|
+
<Panel id="trailer-notes" defaultSize="28%" minSize={300} className="rv-pane" style={PANEL}>
|
|
43
|
+
<NotesColumn tr={tr} />
|
|
44
|
+
</Panel>
|
|
45
|
+
</Group>
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// ── the cuts ──
|
|
50
|
+
|
|
51
|
+
/** `m:ss`, the seconds rounded */
|
|
52
|
+
const mmss = (t: number): string => {
|
|
53
|
+
const s = Math.round(Math.max(0, t))
|
|
54
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ago = (ms: number): string => {
|
|
58
|
+
const s = Math.max(0, Math.round((Date.now() - ms) / 1000))
|
|
59
|
+
return s < 60 ? `${s}s` : s < 3600 ? `${Math.round(s / 60)}m` : s < 86400 ? `${Math.round(s / 3600)}h` : `${Math.round(s / 86400)}d`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const CutList = observer(({ tr }: { tr: TrailerSt }) => (
|
|
63
|
+
<div className="tr-cuts">
|
|
64
|
+
{tr.deskError ? <div className="rv-err">{tr.deskError}</div> : null}
|
|
65
|
+
{tr.trailers.length > 1 ? (
|
|
66
|
+
<select value={tr.trailer?.id ?? ''} onChange={(e) => tr.pick(e.currentTarget.value, null)}>
|
|
67
|
+
{tr.trailers.map((t) => <option key={t.id} value={t.id}>{t.id}</option>)}
|
|
68
|
+
</select>
|
|
69
|
+
) : (
|
|
70
|
+
<div className="tr-trailername">🎬 {tr.trailer?.id ?? 'trailer'}</div>
|
|
71
|
+
)}
|
|
72
|
+
{!tr.desk && !tr.deskError ? <div className="rv-empty"><span className="spin" /> reading the cuts…</div> : null}
|
|
73
|
+
{tr.desk && tr.trailers.length === 0 ? <div className="rv-empty">No trailer under trailer/: write trailer/<name>/main.trailer, then sk trailer.</div> : null}
|
|
74
|
+
<div className="tr-cutrows">
|
|
75
|
+
{cutsByRecent(tr.trailer?.cuts ?? []).map((c, i, all) => (
|
|
76
|
+
<Fragment key={c.cut}>
|
|
77
|
+
{c.version && !all[i - 1]?.version ? <div className="tr-cuthead" style={{ margin: '8px 0 2px', fontSize: 11, fontWeight: 700, color: 'var(--dim)' }}>Kept versions</div> : null}
|
|
78
|
+
<CutRow tr={tr} c={c} />
|
|
79
|
+
</Fragment>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
<Keys />
|
|
83
|
+
</div>
|
|
84
|
+
))
|
|
85
|
+
|
|
86
|
+
/** one row a cut: the name on the left, its length and render age on the right; the rest in marks and tooltips */
|
|
87
|
+
const CutRow = observer(({ tr, c }: { tr: TrailerSt; c: CutInfo }) => {
|
|
88
|
+
const t = tr.trailer
|
|
89
|
+
const on = tr.cut?.cut === c.cut
|
|
90
|
+
const open = tr.openOn(c)
|
|
91
|
+
const v = c.video
|
|
92
|
+
const shots = c.timeline?.shots.length ?? 0
|
|
93
|
+
const age = v ? (c.version ? `kept, rendered ${new Date(v.mtime).toLocaleDateString()}` : `rendered ${ago(v.mtime)} ago`) : 'not rendered yet'
|
|
94
|
+
return (
|
|
95
|
+
<button className={`tr-cutrow${on ? ' on' : ''}${v ? '' : ' unrendered'}`} disabled={!v} onClick={() => t && tr.pick(t.id, c.cut)}
|
|
96
|
+
title={v ? `${clock(v.duration)} · ${shots} shot${shots > 1 ? 's' : ''} · ${age} · ${c.files.video}` : `${age}: sk trailer ${c.files.trailer ?? ''}`}>
|
|
97
|
+
<span className="tr-cutname">{c.cut}</span>
|
|
98
|
+
{c.version ? <span className="tr-kept" style={{ color: 'var(--accent)', fontSize: 11 }} title="a kept version: versions/ holds its text and its render, it never changes">▣</span> : null}
|
|
99
|
+
{c.rendering ? <span className="spin" title="a render is being written" /> : null}
|
|
100
|
+
{c.edited && !c.rendering ? <span className="tr-edited" title="the .trailer changed since this render">✎</span> : null}
|
|
101
|
+
{c.errors.length > 0 ? <span className="tr-cuterr" title={c.errors.join('\n')}>!</span> : null}
|
|
102
|
+
<span className="sp" />
|
|
103
|
+
{open > 0 ? <span className="tr-opencount" title={`${open} open note${open > 1 ? 's' : ''} on its shots`}>{open}</span> : null}
|
|
104
|
+
<span className="tr-cutage">{v ? `${mmss(v.duration)} · ${c.version ? new Date(v.mtime).toLocaleDateString(undefined, { day: 'numeric', month: 'numeric' }) : ago(v.mtime)}` : '—'}</span>
|
|
105
|
+
</button>
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const KEYS: [string, string][] = [
|
|
110
|
+
['Space', 'play, pause'],
|
|
111
|
+
[', .', 'frame back, on'],
|
|
112
|
+
['← →', 'one second (Shift: five)'],
|
|
113
|
+
['↑ ↓', 'shot back, on'],
|
|
114
|
+
['⇧↑ ⇧↓', 'note back, on'],
|
|
115
|
+
['N', 'a note on this frame'],
|
|
116
|
+
['click', 'a note pinned to that point'],
|
|
117
|
+
['M', 'sound'],
|
|
118
|
+
['- +', 'timeline zoom'],
|
|
119
|
+
['Home', 'to the start'],
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
const Keys = () => (
|
|
123
|
+
<div className="tr-keys">
|
|
124
|
+
<div className="tr-keyshead">Keys</div>
|
|
125
|
+
{KEYS.map(([k, what]) => <div key={k} className="tr-keyrow"><kbd>{k}</kbd><span>{what}</span></div>)}
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
// ── the player ──
|
|
130
|
+
|
|
131
|
+
const PlayerColumn = observer(({ tr, app }: { tr: TrailerSt; app: RepoSt }) => {
|
|
132
|
+
const c = tr.cut
|
|
133
|
+
if (!tr.desk) return <div className="rv-empty">{tr.deskError ?? 'Reading the cuts…'}</div>
|
|
134
|
+
if (!c) return <div className="rv-empty">No cut. A trailer is trailer/<name>/<cut>.trailer, rendered by sk trailer.</div>
|
|
135
|
+
if (!tr.shown) return <div className="rv-empty">{c.video ? `${c.cut}: its timeline does not read (${c.errors[0] ?? '?'})` : `${c.cut} is not rendered yet: sk trailer ${c.files.trailer ?? ''}`}</div>
|
|
136
|
+
return (
|
|
137
|
+
<div className="tr-player">
|
|
138
|
+
{tr.newer ? (
|
|
139
|
+
<div className="tr-banner">
|
|
140
|
+
<span>A new render of {c.cut} is ready ({c.video ? `${ago(c.video.mtime)} ago` : ''}).</span>
|
|
141
|
+
<button className="primary" onClick={() => tr.loadNewer()}>Load it, on the same shot</button>
|
|
142
|
+
</div>
|
|
143
|
+
) : null}
|
|
144
|
+
{!tr.newer && c.edited && !c.timelineFromRender ? (
|
|
145
|
+
<div className="tr-banner warn">The .trailer changed since this render: the timeline comes from the file, its shots may be off until the next sk trailer.</div>
|
|
146
|
+
) : null}
|
|
147
|
+
<Player tr={tr} />
|
|
148
|
+
<Transport tr={tr} app={app} />
|
|
149
|
+
<NowLine tr={tr} />
|
|
150
|
+
<TrailerTimeline st={tr} />
|
|
151
|
+
<ShotCards st={tr} />
|
|
152
|
+
<Montage tr={tr} />
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const Player = observer(({ tr }: { tr: TrailerSt }) => {
|
|
158
|
+
const shown = tr.shown
|
|
159
|
+
const ref = useRef<HTMLVideoElement>(null)
|
|
160
|
+
const src = tr.videoUrl
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
const el = ref.current
|
|
163
|
+
if (!el || !src) return
|
|
164
|
+
return tr.clock.attach(el)
|
|
165
|
+
}, [tr, src])
|
|
166
|
+
if (!shown || !src) return null
|
|
167
|
+
const [w, h] = shown.timeline.size
|
|
168
|
+
return (
|
|
169
|
+
<div className="tr-screen">
|
|
170
|
+
<div className="tr-frame" style={{ width: `min(100%, calc(64vh * ${w / h}))`, aspectRatio: `${w} / ${h}` }}>
|
|
171
|
+
<video ref={ref} src={src} playsInline preload="auto" onEnded={() => tr.clock.pause()} />
|
|
172
|
+
<div className="tr-clickzone" title="click: a note pinned to this point"
|
|
173
|
+
onClick={(e) => {
|
|
174
|
+
const r = e.currentTarget.getBoundingClientRect()
|
|
175
|
+
tr.clickFrame({ x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height })
|
|
176
|
+
}}>
|
|
177
|
+
<FramePins tr={tr} />
|
|
178
|
+
</div>
|
|
179
|
+
{tr.clock.paused ? <div className="tr-paused">❚❚ frame {tr.frame + 1}</div> : null}
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
/** the pins of the notes on this frame, and the draft's */
|
|
186
|
+
const FramePins = observer(({ tr }: { tr: TrailerSt }) => {
|
|
187
|
+
const d = tr.draft
|
|
188
|
+
return (
|
|
189
|
+
<>
|
|
190
|
+
{tr.pinsNow.map((n) => <Pin key={n.note.id} x={n.note.pin!.x} y={n.note.pin!.y} state={n.note.state} label={String(n.note.n)} on={tr.selected === n.note.id} />)}
|
|
191
|
+
{d?.pin ? <Pin x={d.pin.x} y={d.pin.y} state="open" label="+" on pulse /> : null}
|
|
192
|
+
</>
|
|
193
|
+
)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const Pin = ({ x, y, state, label, on, pulse }: { x: number; y: number; state: string; label: string; on: boolean; pulse?: boolean }) => (
|
|
197
|
+
<div className="tr-pin" style={{ left: `${x * 100}%`, top: `${y * 100}%` }}>
|
|
198
|
+
{pulse ? <div className="tr-pulse" /> : null}
|
|
199
|
+
<div className={`tr-pinhead ns-${state}${on ? ' on' : ''}`}><span>{label}</span></div>
|
|
200
|
+
</div>
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
const RATES = [0.25, 0.5, 1, 1.25, 1.5, 2]
|
|
204
|
+
|
|
205
|
+
const Transport = observer(({ tr, app }: { tr: TrailerSt; app: RepoSt }) => {
|
|
206
|
+
const c = tr.clock
|
|
207
|
+
const btn = (glyph: ReactNode, label: string, go: () => void, big = false): ReactNode => (
|
|
208
|
+
<button className={`tr-tbtn${big ? ' big' : ''}`} title={label} aria-label={label} onMouseDown={keepFocus} onClick={go}>{glyph}</button>
|
|
209
|
+
)
|
|
210
|
+
const file = tr.cut?.files.trailer ?? tr.cut?.files.video
|
|
211
|
+
const href = editorHref(app.root, file)
|
|
212
|
+
return (
|
|
213
|
+
<div className="tr-transport">
|
|
214
|
+
{btn('⏮', 'shot back (↑)', () => tr.shotBy(-1))}
|
|
215
|
+
{btn('‹', 'frame back (,)', () => tr.step(-1))}
|
|
216
|
+
{btn(c.paused ? '▶' : '❚❚', c.paused ? 'play (Space)' : 'pause (Space)', () => c.toggle(), true)}
|
|
217
|
+
{btn('›', 'frame on (.)', () => tr.step(1))}
|
|
218
|
+
{btn('⏭', 'shot on (↓)', () => tr.shotBy(1))}
|
|
219
|
+
<div className="tr-time">
|
|
220
|
+
<div className="tr-timebig">{clock(tr.time)} <span className="tr-dim">/ {clock(tr.shown?.duration ?? 0)}</span></div>
|
|
221
|
+
<div className="tr-dim tr-small">frame {tr.frame + 1} / {tr.frames} · {tr.fps} fps</div>
|
|
222
|
+
</div>
|
|
223
|
+
<span className="sp" />
|
|
224
|
+
<span className="seg">{RATES.map((r) => <button key={r} className={c.rate === r ? 'on' : ''} onMouseDown={keepFocus} onClick={() => c.setRate(r)}>×{r}</button>)}</span>
|
|
225
|
+
<ClipSound clip={c} keyHint="M" />
|
|
226
|
+
{href && file ? <a className="tr-file" href={href} title={`open ${file} in the editor`}>{file.split('/').pop()}</a> : null}
|
|
227
|
+
{tr.shown ? <button className="tr-tbtn" title={`show ${tr.cut?.files.video ?? 'the render'} in the Finder`} aria-label="show the render in the Finder" onMouseDown={keepFocus} onClick={() => void tr.reveal()}>📂</button> : null}
|
|
228
|
+
<button className="tr-notebtn" onMouseDown={keepFocus} onClick={() => tr.newNote()}>✚ Note (N)</button>
|
|
229
|
+
</div>
|
|
230
|
+
)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
/** what is on screen: the section, the shot and the time into it, the caption shown */
|
|
234
|
+
const NowLine = observer(({ tr }: { tr: TrailerSt }) => {
|
|
235
|
+
const s = tr.shot
|
|
236
|
+
const tl = tr.timeline
|
|
237
|
+
if (!s || !tl) return null
|
|
238
|
+
return (
|
|
239
|
+
<div className="tr-now">
|
|
240
|
+
<span className={`tr-secbadge ${hueOf(tl, s.section)}`}>{s.section ?? 'opening'}</span>
|
|
241
|
+
<b className="tr-shotnow">{s.name}</b>
|
|
242
|
+
<span className="tr-dim">shot {tr.shotIndex + 1}/{tl.shots.length} · {(tr.time - s.start).toFixed(2)} s of {s.length} s · line {s.line}</span>
|
|
243
|
+
{tr.captions.map((c) => <span key={`${c.start}${c.text}`} className="tr-capnow">« {captionText(c)} »</span>)}
|
|
244
|
+
</div>
|
|
245
|
+
)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
/** the cut's montage page (out/<name>.montage.md), folded until asked */
|
|
249
|
+
const Montage = observer(({ tr }: { tr: TrailerSt }) => {
|
|
250
|
+
const m = tr.montage
|
|
251
|
+
return (
|
|
252
|
+
<div className="tr-montage">
|
|
253
|
+
<button className="ghost" onClick={() => void tr.toggleMontage()}>{tr.montageOpen ? '▾' : '▸'} montage</button>
|
|
254
|
+
{tr.montageOpen ? (m == null ? <span className="spin" /> : m.md == null ? <div className="rv-dim">This render has no montage page.</div> : <div className="md tr-montagebody" dangerouslySetInnerHTML={{ __html: renderMd(m.md) }} />) : null}
|
|
255
|
+
</div>
|
|
256
|
+
)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
const DraftBox = observer(({ tr }: { tr: TrailerSt }) => {
|
|
260
|
+
const d = tr.draft
|
|
261
|
+
const tl = tr.timeline
|
|
262
|
+
if (!d || !tl) return null
|
|
263
|
+
const s = shotAt(tl, d.time)
|
|
264
|
+
return (
|
|
265
|
+
<div className="tr-draft">
|
|
266
|
+
<div className="tr-drafthead">
|
|
267
|
+
<b>New note · {clock(d.time)} · {s?.name}</b>
|
|
268
|
+
{d.pin ? (
|
|
269
|
+
<span><span className="tr-pinned">📍 pinned</span> <button className="ghost" onClick={() => tr.unpinDraft()}>remove</button></span>
|
|
270
|
+
) : (
|
|
271
|
+
<span className="tr-dim tr-small">click the image to pin a point</span>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
<textarea autoFocus rows={3} placeholder="What is wrong, what you want instead…" value={d.text} onChange={(e) => tr.setDraftText(e.currentTarget.value)}
|
|
275
|
+
onKeyDown={(e) => {
|
|
276
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
277
|
+
e.preventDefault()
|
|
278
|
+
void tr.saveDraft()
|
|
279
|
+
} else if (e.key === 'Escape') {
|
|
280
|
+
e.preventDefault()
|
|
281
|
+
tr.cancelDraft()
|
|
282
|
+
}
|
|
283
|
+
}} />
|
|
284
|
+
{tr.error ? <div className="rv-err">{tr.error}</div> : null}
|
|
285
|
+
<div className="tr-dim tr-small">Enter saves · Shift Enter a new line · Escape drops it</div>
|
|
286
|
+
<div className="tr-draftbtns">
|
|
287
|
+
<button onClick={() => tr.cancelDraft()}>Cancel</button>
|
|
288
|
+
<button className="primary" disabled={tr.saving || d.text.trim() === ''} onClick={() => void tr.saveDraft()}>Save</button>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// ── the notes ──
|
|
295
|
+
|
|
296
|
+
const NotesColumn = observer(({ tr }: { tr: TrailerSt }) => {
|
|
297
|
+
const here = tr.here
|
|
298
|
+
const elsewhere = tr.elsewhere
|
|
299
|
+
const total = tr.notes.length
|
|
300
|
+
return (
|
|
301
|
+
<div className="tr-notescol">
|
|
302
|
+
<div className="tr-noteshead">
|
|
303
|
+
<b>Notes</b>
|
|
304
|
+
<span className={`tr-openbadge${tr.openCount > 0 ? ' on' : ''}`}>{tr.openCount} open</span>
|
|
305
|
+
<span className="tr-dim tr-small">of {total}</span>
|
|
306
|
+
<span className="sp" />
|
|
307
|
+
<span className="seg">
|
|
308
|
+
<button className={tr.filter === 'open' ? 'on' : ''} onClick={() => tr.setFilter('open')}>open</button>
|
|
309
|
+
<button className={tr.filter === 'all' ? 'on' : ''} onClick={() => tr.setFilter('all')}>all</button>
|
|
310
|
+
</span>
|
|
311
|
+
</div>
|
|
312
|
+
<div className="tr-notesscroll">
|
|
313
|
+
{tr.draft ? <DraftBox tr={tr} /> : null}
|
|
314
|
+
{total === 0 && !tr.draft ? <div className="rv-empty">No note. N, or a click on the image, writes one on the frame on screen.</div> : null}
|
|
315
|
+
{here.map((n) => <NoteCard key={n.note.id} tr={tr} n={n} />)}
|
|
316
|
+
{elsewhere.length > 0 ? <div className="tr-elsewhere">On shots outside this cut ({elsewhere.length})</div> : null}
|
|
317
|
+
{elsewhere.map((n) => <NoteCard key={n.note.id} tr={tr} n={n} />)}
|
|
318
|
+
{!tr.draft && tr.error ? <div className="rv-err">{tr.error}</div> : null}
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
const STATE_LABEL: Record<string, string> = { open: 'open', done: 'done', wontfix: 'not doing' }
|
|
325
|
+
|
|
326
|
+
const NoteCard = observer(({ tr, n: p }: { tr: TrailerSt; n: PlacedNote }) => {
|
|
327
|
+
const n = p.note
|
|
328
|
+
const on = tr.selected === n.id
|
|
329
|
+
const editing = tr.editing?.id === n.id ? tr.editing : null
|
|
330
|
+
const closing = tr.closing?.id === n.id ? tr.closing : null
|
|
331
|
+
const [sure, setSure] = useState(false)
|
|
332
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
if (on) ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
335
|
+
}, [on])
|
|
336
|
+
const go = (): void => (p.at !== null ? tr.goTo(p) : tr.openOnItsCut(n))
|
|
337
|
+
const enterSaves = (save: () => void, cancel: () => void) => (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
|
338
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
339
|
+
e.preventDefault()
|
|
340
|
+
save()
|
|
341
|
+
} else if (e.key === 'Escape') {
|
|
342
|
+
e.preventDefault()
|
|
343
|
+
cancel()
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return (
|
|
347
|
+
<div ref={ref} className={`tr-note ns-${n.state}${on ? ' on' : ''}`} onClick={(e) => { if (!(e.target as Element).closest('button, textarea, input')) go() }}>
|
|
348
|
+
<div className="tr-notetop">
|
|
349
|
+
{!on ? <Still tr={tr} n={n} w={112} /> : null}
|
|
350
|
+
<div className="tr-notebody">
|
|
351
|
+
<div className="tr-notemeta">
|
|
352
|
+
<span className="tr-mono tr-dim" title={n.id}>#{n.n}</span>
|
|
353
|
+
<b className="tr-mono">{p.at !== null ? clock(p.at) : `${n.cut} ${clock(n.time)}`}</b>
|
|
354
|
+
<span className={`tr-secbadge small ${tr.timeline ? hueOf(tr.timeline, n.section) : 'hue-none'}`}>{n.shot}</span>
|
|
355
|
+
{n.state !== 'open' ? <span className={`tr-statebadge ns-${n.state}`}>{n.state === 'done' ? '✓' : '⊘'} {STATE_LABEL[n.state]}</span> : null}
|
|
356
|
+
</div>
|
|
357
|
+
{p.moved && p.at !== null ? <div className="tr-moved">shot moved: written at {clock(n.time)}, this shot now starts at {clock(p.at - n.shotTime)}</div> : null}
|
|
358
|
+
{p.at === null ? <div className="tr-dim tr-small">written on {n.cut} · this shot is not in {tr.shown?.cut} (click: open {n.cut})</div> : null}
|
|
359
|
+
{!editing ? <div className={`tr-notetext${n.state === 'wontfix' ? ' struck' : ''}`}>{n.text}</div> : null}
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
{on ? <Still tr={tr} n={n} w="100%" /> : null}
|
|
363
|
+
{editing ? (
|
|
364
|
+
<div className="tr-edit">
|
|
365
|
+
<textarea autoFocus rows={3} value={editing.text} onChange={(e) => tr.setEditText(e.currentTarget.value)} onKeyDown={enterSaves(() => void tr.saveEdit(), () => tr.cancelEdit())} />
|
|
366
|
+
<div className="tr-dim tr-small">Enter saves · Escape drops it · on its frame, a click moves the pin</div>
|
|
367
|
+
</div>
|
|
368
|
+
) : null}
|
|
369
|
+
{n.reply && !closing ? (
|
|
370
|
+
<div className={`tr-reply ns-${n.state}`}>
|
|
371
|
+
<div className="rv-pre">{n.reply}</div>
|
|
372
|
+
{n.closed ? <div className="tr-dim tr-small">{ago(Date.parse(n.closed))} ago</div> : null}
|
|
373
|
+
</div>
|
|
374
|
+
) : null}
|
|
375
|
+
{closing ? (
|
|
376
|
+
<div className="tr-edit">
|
|
377
|
+
<textarea autoFocus rows={2} placeholder={closing.state === 'done' ? 'The commit, or what was done (optional)' : 'Why (optional)'} value={closing.reply}
|
|
378
|
+
onChange={(e) => tr.setReply(e.currentTarget.value)} onKeyDown={enterSaves(() => void tr.confirmClose(), () => tr.cancelClose())} />
|
|
379
|
+
<div className="tr-draftbtns">
|
|
380
|
+
<button onClick={() => tr.cancelClose()}>Cancel</button>
|
|
381
|
+
<button className="primary" onClick={() => void tr.confirmClose()}>{closing.state === 'done' ? 'Mark done' : 'Not doing it'}</button>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
) : null}
|
|
385
|
+
{on && !editing && !closing ? (
|
|
386
|
+
<div className="tr-noteacts">
|
|
387
|
+
<button onClick={() => tr.startEdit(n)}>✎ Edit</button>
|
|
388
|
+
{n.state === 'open' ? (
|
|
389
|
+
<>
|
|
390
|
+
<button className="tr-done" onClick={() => tr.askClose(n, 'done')}>✓ Done</button>
|
|
391
|
+
<button onClick={() => tr.askClose(n, 'wontfix')}>⊘ Not doing</button>
|
|
392
|
+
</>
|
|
393
|
+
) : (
|
|
394
|
+
<button onClick={() => void tr.reopen(n)}>↺ Reopen</button>
|
|
395
|
+
)}
|
|
396
|
+
{n.pin ? <button className="ghost" onClick={() => void tr.unpin(n)}>📍 Unpin</button> : null}
|
|
397
|
+
<span className="sp" />
|
|
398
|
+
<button className={`tr-delete${sure ? ' sure' : ''}`} onClick={() => (sure ? void tr.remove(n) : setSure(true))} onMouseLeave={() => setSure(false)}>
|
|
399
|
+
{sure ? 'Sure?' : 'Delete'}
|
|
400
|
+
</button>
|
|
401
|
+
</div>
|
|
402
|
+
) : null}
|
|
403
|
+
</div>
|
|
404
|
+
)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
/** a note's still, as cut when he wrote it, with its pin */
|
|
408
|
+
const Still = ({ tr, n, w }: { tr: TrailerSt; n: TrailerNote; w: number | string }) => {
|
|
409
|
+
const [sw, sh] = tr.shown?.timeline.size ?? [16, 9]
|
|
410
|
+
return (
|
|
411
|
+
<div className={`tr-still${typeof w === 'string' ? ' wide' : ''}`} style={{ width: w, aspectRatio: `${sw} / ${sh}` }}>
|
|
412
|
+
<img src={tr.stillUrl(n)} alt="" loading="lazy" />
|
|
413
|
+
{n.pin ? <div className={`tr-stillpin ns-${n.state}`} style={{ left: `${n.pin.x * 100}%`, top: `${n.pin.y * 100}%` }} /> : null}
|
|
414
|
+
</div>
|
|
415
|
+
)
|
|
416
|
+
}
|