rvlib-shipkit 2.1.1 → 2.3.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 +50 -0
- package/package.json +2 -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 +7 -3
- package/src/assets/run-assets.ts +18 -4
- package/src/checks/SK004-gitignore-policy.check.ts +11 -6
- package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Commands.ts +163 -43
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +361 -0
- package/src/cli/kit/builtins.ts +40 -0
- package/src/cli/kit/calls.ts +177 -0
- package/src/cli/kit/families.ts +67 -0
- package/src/cli/kit/index.ts +8 -0
- package/src/cli/kit/pages-check.ts +36 -0
- package/src/cli/kit/project-cli.ts +73 -0
- package/src/cli/kit/verb.ts +167 -0
- package/src/cli.ts +15 -2
- 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/layout.ts +12 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/reflect/prompt-corpus.ts +15 -114
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -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 +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -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 +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/scaffold/cli-skeleton.ts +149 -0
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/files/tsconfig.json +3 -0
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +90 -12
- 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/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- 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 +45 -13
- package/src/serve/hub-app/icons.tsx +9 -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/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -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 +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -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 +329 -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-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +12 -4
- package/src/shot/client.ts +67 -0
- package/src/shot/clip-encoder.ts +146 -0
- package/src/shot/dom-clip.ts +124 -0
- package/src/shot/serve-shot.ts +54 -0
- package/src/shot/shooter.ts +343 -0
- package/src/shot/shot-ask.ts +189 -0
- package/src/shot/shot.shipkit.ts +14 -0
- package/src/smoke/run-smoke.ts +2 -2
- package/src/start-repo.ts +29 -14
- package/src/suite/judge-suites.ts +56 -10
- package/src/suite/select.ts +2 -5
- package/src/suite/suite-cli.ts +3 -2
- package/src/trailer/adapter.ts +78 -0
- package/src/trailer/build.ts +603 -0
- package/src/trailer/ffmpeg.ts +72 -0
- package/src/trailer/footage.ts +173 -0
- package/src/trailer/grammar.ts +1290 -0
- package/src/trailer/graph.ts +508 -0
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +31 -0
- package/src/trailer/look.ts +124 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +192 -0
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +233 -0
- package/src/trailer/titles.ts +379 -0
- package/src/trailer/url.ts +108 -0
- package/src/trailer/vertical-draw.ts +286 -0
- package/src/trailer/vertical.ts +148 -0
- package/src/trailer/vocab.ts +59 -0
- package/src/trailer/words.ts +254 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// the threads of the store (wiki build/shipkit/review.md): a card's versions, each superseding the last, and the raised
|
|
2
|
+
// items (a report, his note) the card answers, read as one thread per bug; their status, conversation and order, and
|
|
3
|
+
// the active thread a new card already covers. Pure, over the items of review/model.ts
|
|
4
|
+
import {
|
|
5
|
+
isReply,
|
|
6
|
+
partyOf,
|
|
7
|
+
reworkOf,
|
|
8
|
+
stateOf,
|
|
9
|
+
subjectKey,
|
|
10
|
+
unreadBy,
|
|
11
|
+
verdictOf,
|
|
12
|
+
withdrawnOf,
|
|
13
|
+
wordsOf,
|
|
14
|
+
type Message,
|
|
15
|
+
type ReviewItem,
|
|
16
|
+
type ShownStatus,
|
|
17
|
+
type Spec,
|
|
18
|
+
type Subject,
|
|
19
|
+
NO_SPEC,
|
|
20
|
+
} from './model.ts'
|
|
21
|
+
|
|
22
|
+
/** one thread of versions: a head no later card supersedes, then the chain it supersedes back to the first, and the
|
|
23
|
+
* raised items any of them answers */
|
|
24
|
+
export interface Thread {
|
|
25
|
+
head: ReviewItem
|
|
26
|
+
/** the head first, then each older version; v1 is the last */
|
|
27
|
+
versions: ReviewItem[]
|
|
28
|
+
/** the reports and notes the versions answer, oldest first */
|
|
29
|
+
answers: ReviewItem[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** every item of a thread: its versions, then the raised items they answer */
|
|
33
|
+
export const membersOf = (t: Thread): ReviewItem[] => [...t.versions, ...t.answers]
|
|
34
|
+
|
|
35
|
+
const answersOf = (x: ReviewItem): readonly string[] => (x.item.origin.by === 'agent' ? (x.item.answers ?? []) : [])
|
|
36
|
+
|
|
37
|
+
const newest = (a: ReviewItem, b: ReviewItem): number => b.item.created.localeCompare(a.item.created) || b.item.id.localeCompare(a.item.id)
|
|
38
|
+
const oldest = (a: ReviewItem, b: ReviewItem): number => a.item.created.localeCompare(b.item.created) || a.item.id.localeCompare(b.item.id)
|
|
39
|
+
|
|
40
|
+
function answeredBy(versions: readonly ReviewItem[], byId: ReadonlyMap<string, ReviewItem>): ReviewItem[] {
|
|
41
|
+
return [...new Set(versions.flatMap(answersOf))]
|
|
42
|
+
.flatMap((id) => {
|
|
43
|
+
const y = byId.get(id)
|
|
44
|
+
return y && y.item.origin.by !== 'agent' ? [y] : []
|
|
45
|
+
})
|
|
46
|
+
.sort(oldest)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** the raised item's opening: his or the player's words and rectangles that raised it, none on an agent card */
|
|
50
|
+
export const openingOf = (x: ReviewItem): Message | undefined =>
|
|
51
|
+
x.item.origin.by === 'agent' ? undefined : x.thread.find((m) => m.op === undefined && partyOf(m.who) === 'owner' && (m.text !== undefined || m.rects !== undefined))
|
|
52
|
+
|
|
53
|
+
/** the links of one list of items, built once per list: every reader of a 3000 item store walks them per item */
|
|
54
|
+
interface Links {
|
|
55
|
+
byId: Map<string, ReviewItem>
|
|
56
|
+
/** by id, the cards superseding it, newest first */
|
|
57
|
+
superseders: Map<string, ReviewItem[]>
|
|
58
|
+
/** by id, the cards answering it, newest first */
|
|
59
|
+
answerers: Map<string, ReviewItem[]>
|
|
60
|
+
}
|
|
61
|
+
const links = new WeakMap<readonly ReviewItem[], Links>()
|
|
62
|
+
|
|
63
|
+
function linksOf(items: readonly ReviewItem[]): Links {
|
|
64
|
+
const held = links.get(items)
|
|
65
|
+
if (held) return held
|
|
66
|
+
const l: Links = { byId: new Map(), superseders: new Map(), answerers: new Map() }
|
|
67
|
+
const push = (m: Map<string, ReviewItem[]>, id: string, x: ReviewItem): void => void (m.get(id) ?? m.set(id, []).get(id))?.push(x)
|
|
68
|
+
for (const x of items) {
|
|
69
|
+
l.byId.set(x.item.id, x)
|
|
70
|
+
if (x.item.supersedes) push(l.superseders, x.item.supersedes, x)
|
|
71
|
+
for (const a of answersOf(x)) push(l.answerers, a, x)
|
|
72
|
+
}
|
|
73
|
+
for (const m of [l.superseders, l.answerers]) for (const xs of m.values()) xs.sort(newest)
|
|
74
|
+
links.set(items, l)
|
|
75
|
+
return l
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** the item, the one it supersedes, that one's, back to the first; a missing link or a cycle ends the walk */
|
|
79
|
+
export function chainOf(x: ReviewItem, items: readonly ReviewItem[]): ReviewItem[] {
|
|
80
|
+
const byId = linksOf(items).byId
|
|
81
|
+
const out = [x]
|
|
82
|
+
const seen = new Set([x.item.id])
|
|
83
|
+
for (let cur = x; cur.item.supersedes !== undefined; ) {
|
|
84
|
+
const next = byId.get(cur.item.supersedes)
|
|
85
|
+
if (!next || seen.has(next.item.id)) break
|
|
86
|
+
out.push(next)
|
|
87
|
+
seen.add(next.item.id)
|
|
88
|
+
cur = next
|
|
89
|
+
}
|
|
90
|
+
return out
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** every card superseding this one, the newest first */
|
|
94
|
+
export const childrenOf = (id: string, items: readonly ReviewItem[]): ReviewItem[] => [...(linksOf(items).superseders.get(id) ?? [])]
|
|
95
|
+
|
|
96
|
+
/** every item a later card follows: a version it supersedes, a raised item it answers */
|
|
97
|
+
export const followedIds = (items: readonly ReviewItem[]): Set<string> => {
|
|
98
|
+
const l = linksOf(items)
|
|
99
|
+
return new Set([...l.superseders.keys(), ...l.answerers.keys()])
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** one thread per head: two cards superseding the same one are two threads sharing their history; a raised item a
|
|
103
|
+
* card answers is in that card's thread, never a head */
|
|
104
|
+
export function threadsOf(items: readonly ReviewItem[]): Thread[] {
|
|
105
|
+
const byId = linksOf(items).byId
|
|
106
|
+
const followed = followedIds(items)
|
|
107
|
+
return items
|
|
108
|
+
.filter((x) => !followed.has(x.item.id))
|
|
109
|
+
.map((head) => {
|
|
110
|
+
const versions = chainOf(head, items)
|
|
111
|
+
return { head, versions, answers: answeredBy(versions, byId) }
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** the newest card answering this raised item, null when none does */
|
|
116
|
+
export const answererOf = (id: string, items: readonly ReviewItem[]): ReviewItem | null => linksOf(items).answerers.get(id)?.[0] ?? null
|
|
117
|
+
|
|
118
|
+
const supersederOf = (id: string, items: readonly ReviewItem[]): ReviewItem | null => linksOf(items).superseders.get(id)?.[0] ?? null
|
|
119
|
+
|
|
120
|
+
/** a later card follows the item: a new version of it, or a card answering it (stateOf's `superseded`) */
|
|
121
|
+
export const isFollowed = (id: string, items: readonly ReviewItem[]): boolean => supersederOf(id, items) !== null || answererOf(id, items) !== null
|
|
122
|
+
|
|
123
|
+
/** the thread an item reads in: its own, the one whose history holds it, or the card's that answers it */
|
|
124
|
+
export const threadOfItem = (id: string, threads: readonly Thread[]): Thread | null =>
|
|
125
|
+
threads.find((t) => t.head.item.id === id) ?? threads.find((t) => membersOf(t).some((x) => x.item.id === id)) ?? null
|
|
126
|
+
|
|
127
|
+
/** a thread still needs someone: its head pending, or answered and not yet acted on by the agent (an approval needs
|
|
128
|
+
* nothing), or a lane reworking it; what he or a player raised, until it is closed */
|
|
129
|
+
export function isActive(t: Thread, now = new Date()): boolean {
|
|
130
|
+
if (t.versions.some((x) => unreadBy(x, 'agent').some((m) => m !== openingOf(x)))) return true
|
|
131
|
+
if (t.answers.some((x) => unreadBy(x, 'agent').some((m) => m !== openingOf(x)))) return true
|
|
132
|
+
if (t.head.item.origin.by !== 'agent') return stateOf(t.head, false).state !== 'closed'
|
|
133
|
+
if (reworkOf(t.head, false, now)) return true
|
|
134
|
+
const f = verdictOf(t.head)
|
|
135
|
+
if (!f) return withdrawnOf(t.head) === undefined
|
|
136
|
+
return f.status !== 'approved' && f.status !== 'archived' && !f.seen
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** the thread's conversation: every version's and every answered item's replies, oldest first, each with the item it
|
|
140
|
+
* sits on; an answered item's opening words show with the item, not here */
|
|
141
|
+
export function conversationOf(t: Thread): (Message & { on: string })[] {
|
|
142
|
+
const openings = new Set(t.answers.map(openingOf))
|
|
143
|
+
return membersOf(t)
|
|
144
|
+
.flatMap((x) => x.thread.filter((m) => isReply(m) && !openings.has(m)).map((m) => ({ ...m, on: x.item.id })))
|
|
145
|
+
.sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** a head waits for the owner's verdict: an agent's card, none yet, not withdrawn, not a draft */
|
|
149
|
+
export const waits = (x: ReviewItem): boolean => x.item.origin.by === 'agent' && !x.item.wip && verdictOf(x) === null && withdrawnOf(x) === undefined
|
|
150
|
+
|
|
151
|
+
/** the active pending heads first in the order they were sent, then every other thread, newest first */
|
|
152
|
+
export function orderedThreads(items: readonly ReviewItem[]): Thread[] {
|
|
153
|
+
const all = threadsOf(items)
|
|
154
|
+
const first = (x: ReviewItem): boolean => waits(x) || (x.item.wip !== undefined && verdictOf(x) === null && withdrawnOf(x) === undefined)
|
|
155
|
+
const pending = all.filter((t) => first(t.head)).sort((a, b) => oldest(a.head, b.head))
|
|
156
|
+
const rest = all.filter((t) => !first(t.head)).sort((a, b) => newest(a.head, b.head))
|
|
157
|
+
return [...pending, ...rest]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const STOP = new Set(['a', 'an', 'the', 'of', 'on', 'in', 'at', 'to', 'and', 'with', 'by', 'de', 'la', 'le', 'les', 'du', 'des', 'au', 'aux', 'l', 'd', 'et', 'en'])
|
|
161
|
+
|
|
162
|
+
/** two titles share most of their words (Dice over the words, stop words aside): a new version sent without --supersedes */
|
|
163
|
+
export function closeTitles(a: string, b: string): boolean {
|
|
164
|
+
const wa = new Set(wordsOf(a).filter((w) => !STOP.has(w)))
|
|
165
|
+
const wb = new Set(wordsOf(b).filter((w) => !STOP.has(w)))
|
|
166
|
+
if (wa.size === 0 || wb.size === 0) return false
|
|
167
|
+
const common = [...wa].filter((w) => wb.has(w)).length
|
|
168
|
+
return (2 * common) / (wa.size + wb.size) >= 0.6
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** the head of an active thread that already covers this subject, null when none. Subjects first: two that each name
|
|
172
|
+
* one thing are one subject exactly when they name the same; else the same topic and a subject its kind calls near;
|
|
173
|
+
* else a close title */
|
|
174
|
+
export function coveringHead(card: { title: string; topic?: string; subject: Subject }, items: readonly ReviewItem[], spec: Spec = NO_SPEC): { head: ReviewItem; by: 'subject' | 'near' | 'title' } | null {
|
|
175
|
+
const key = subjectKey(card.subject, spec)
|
|
176
|
+
const comparable = threadsOf(items)
|
|
177
|
+
.filter((t) => isActive(t))
|
|
178
|
+
.filter((t) => {
|
|
179
|
+
const k = subjectKey(t.head.item.subject, spec)
|
|
180
|
+
return key === null || k === null || k === key
|
|
181
|
+
})
|
|
182
|
+
const same = key === null ? undefined : comparable.find((t) => subjectKey(t.head.item.subject, spec) === key)
|
|
183
|
+
if (same) return { head: same.head, by: 'subject' }
|
|
184
|
+
const near = spec.kinds[card.subject.kind]?.near
|
|
185
|
+
const close = near
|
|
186
|
+
? comparable.find((t) => t.head.item.topic === card.topic && t.head.item.subject.kind === card.subject.kind && near(card.subject, t.head.item.subject))
|
|
187
|
+
: undefined
|
|
188
|
+
if (close) return { head: close.head, by: 'near' }
|
|
189
|
+
const title = comparable.find((t) => closeTitles(card.title, t.head.item.title))
|
|
190
|
+
return title ? { head: title.head, by: 'title' } : null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** what the list says of a card: the owner's verdict, else withdrawn, a draft, replaced, or waiting for him */
|
|
194
|
+
export function statusOf(x: ReviewItem, items: readonly ReviewItem[]): ShownStatus {
|
|
195
|
+
if (x.item.origin.by !== 'agent') {
|
|
196
|
+
// a report a card answers shows the bug's status: its card's, at the newest version
|
|
197
|
+
let card = answererOf(x.item.id, items)
|
|
198
|
+
const seen = new Set<string>()
|
|
199
|
+
for (let next = card; next && !seen.has(next.item.id); next = supersederOf(next.item.id, items)) {
|
|
200
|
+
seen.add(next.item.id)
|
|
201
|
+
card = next
|
|
202
|
+
}
|
|
203
|
+
if (card) return statusOf(card, items)
|
|
204
|
+
const st = stateOf(x, false)
|
|
205
|
+
if (st.state !== 'closed') return 'raised'
|
|
206
|
+
return st.why === 'wontfix' || st.why === 'dropped' ? 'wontfix' : st.why === 'rated' ? 'rated' : 'fixed'
|
|
207
|
+
}
|
|
208
|
+
const v = verdictOf(x)
|
|
209
|
+
if (v) return v.status
|
|
210
|
+
if (withdrawnOf(x) !== undefined) return 'withdrawn'
|
|
211
|
+
if (x.item.wip) return 'wip'
|
|
212
|
+
return supersederOf(x.item.id, items) ? 'superseded' : 'pending'
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** the latest thing on `x` the agent has not seen: its verdict or a reply of the owner */
|
|
216
|
+
const lastUnseen = (x: ReviewItem, answered: boolean): string => {
|
|
217
|
+
const v = verdictOf(x)
|
|
218
|
+
const opening = answered || stateOf(x, false).state === 'closed' ? openingOf(x) : undefined
|
|
219
|
+
const replies = unreadBy(x, 'agent').filter((m) => m !== opening)
|
|
220
|
+
return [...(v && !v.seen ? [v.at] : []), ...replies.map((m) => m.at)].sort().at(-1) ?? ''
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** the items with a verdict or an owner reply the agent has not marked seen yet, oldest first */
|
|
224
|
+
export function unseen(items: readonly ReviewItem[]): ReviewItem[] {
|
|
225
|
+
const answered = new Set(items.flatMap(answersOf))
|
|
226
|
+
return items
|
|
227
|
+
.map((x) => ({ x, at: lastUnseen(x, answered.has(x.item.id)) }))
|
|
228
|
+
.filter((u) => u.at !== '')
|
|
229
|
+
.sort((a, b) => a.at.localeCompare(b.at))
|
|
230
|
+
.map((u) => u.x)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** what waits on him: the cards waiting for the owner's verdict, and what he raised that asks him a question or is blocked on him */
|
|
234
|
+
export function waitingOnOwner(items: readonly ReviewItem[]): Thread[] {
|
|
235
|
+
const followed = followedIds(items)
|
|
236
|
+
return orderedThreads(items).filter((t) => waits(t.head) || (t.head.item.origin.by !== 'agent' && ['question', 'blocked'].includes(stateOf(t.head, followed.has(t.head.item.id)).state)))
|
|
237
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// the trailer view's cuts, renders and timelines (wiki build/trailer.md, Frame notes), and the still a note on a frame
|
|
2
|
+
// is raised with: the note itself is an item of the one store, its subject the frame, anchored on its shot's name, so it
|
|
3
|
+
// survives a new render of the cut. A cut is a `trailer/<id>/<cut>.trailer` and its render `out/<name>.mp4` (`<name>`
|
|
4
|
+
// its `trailer` line), or a render with no file beside it. The trailer core (src/trailer/) is read, never changed
|
|
5
|
+
import { constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { join, relative } from 'node:path'
|
|
7
|
+
import { LAYOUT } from '../core/layout.ts'
|
|
8
|
+
import { parseTrailer, starts, total, type Trailer } from '../trailer/grammar.ts'
|
|
9
|
+
import { THUMB_W } from '../trailer/graph.ts'
|
|
10
|
+
import { probe, stillAt } from './media.ts'
|
|
11
|
+
import type { FrameSubject } from './model.ts'
|
|
12
|
+
import { readAllCached, storeDir } from './store.ts'
|
|
13
|
+
import { CUT_RE, notesOf, shotAt, TRAILER_ID_RE, type CutInfo, type SectionSpan, type ShotSpan, type Timeline, type TrailerInfo } from './trailer-model.ts'
|
|
14
|
+
|
|
15
|
+
const round = (v: number, d = 4): number => Number(v.toFixed(d))
|
|
16
|
+
|
|
17
|
+
/** the cut's montage on its clock: each shot from its start to the next one's, the sections, the captions */
|
|
18
|
+
export function timelineOf(t: Pick<Trailer, 'shots' | 'fps' | 'size'>): Timeline {
|
|
19
|
+
const st = starts(t)
|
|
20
|
+
const end = total(t)
|
|
21
|
+
let section: string | null = null
|
|
22
|
+
const shots: ShotSpan[] = t.shots.map((s, i) => {
|
|
23
|
+
if (s.section) section = s.section.name
|
|
24
|
+
const start = st[i] ?? 0
|
|
25
|
+
return {
|
|
26
|
+
name: s.name,
|
|
27
|
+
line: s.line,
|
|
28
|
+
start: round(start),
|
|
29
|
+
end: round(st[i + 1] ?? end),
|
|
30
|
+
length: s.length,
|
|
31
|
+
section,
|
|
32
|
+
captions: s.titles.map((ti) => ({ text: ti.text, style: ti.style, start: round(start + ti.at), end: round(start + (ti.for === null ? s.length : ti.at + ti.for)) })),
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
const sections: SectionSpan[] = []
|
|
36
|
+
for (const s of shots) {
|
|
37
|
+
if (s.section === null) continue
|
|
38
|
+
const last = sections.at(-1)
|
|
39
|
+
if (last?.name === s.section) last.end = s.end
|
|
40
|
+
else sections.push({ name: s.section, start: s.start, end: s.end })
|
|
41
|
+
}
|
|
42
|
+
return { total: round(end), fps: t.fps, size: t.size, shots, sections }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** a render with no .trailer beside it: one shot spanning the whole video, so the player and the notes still work */
|
|
46
|
+
const wholeTimeline = (duration: number, fps: number, size: [number, number]): Timeline => ({
|
|
47
|
+
total: round(duration),
|
|
48
|
+
fps,
|
|
49
|
+
size,
|
|
50
|
+
shots: [{ name: 'cut', line: 0, start: 0, end: round(duration), length: round(duration), section: null, captions: [] }],
|
|
51
|
+
sections: [],
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const trailersDir = (root: string): string => join(root, 'trailer')
|
|
55
|
+
|
|
56
|
+
/** a trailer folder by its name, or null when the name is not one */
|
|
57
|
+
export function trailerDir(root: string, id: string): string | null {
|
|
58
|
+
if (!TRAILER_ID_RE.test(id)) return null
|
|
59
|
+
const dir = join(trailersDir(root), id)
|
|
60
|
+
return existsSync(dir) && statSync(dir).isDirectory() ? dir : null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** one cut of a folder, as found on disk */
|
|
64
|
+
interface CutFiles {
|
|
65
|
+
cut: string
|
|
66
|
+
/** the render's stem */
|
|
67
|
+
name: string
|
|
68
|
+
trailer: string | null
|
|
69
|
+
/** a kept version: its text in versions/, its render and strip in versions/<v>/ */
|
|
70
|
+
version?: true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** the `trailer <name>` line of a file, read without a parse; its stem when it has none */
|
|
74
|
+
const nameOf = (file: string, stem: string): string => /^\s*trailer\s+([a-z0-9][a-z0-9-]*)\s*(#.*)?$/m.exec(readFileSync(file, 'utf8'))?.[1] ?? stem
|
|
75
|
+
|
|
76
|
+
/** the one file of a version's folder ending so, null when it has none */
|
|
77
|
+
const versionFile = (dir: string, v: string, end: string): string | null => {
|
|
78
|
+
const d = join(dir, 'versions', v)
|
|
79
|
+
const f = existsSync(d) ? readdirSync(d).find((x) => x.endsWith(end) && (end !== '.mp4' || !x.endsWith('.part.mp4'))) : undefined
|
|
80
|
+
return f ? join(d, f) : null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** a trailer's kept versions (wiki build/trailer.md, Versions): each `versions/<v>.trailer`, its render in its folder
|
|
84
|
+
* when the machine still holds it */
|
|
85
|
+
function versionsOf(dir: string): CutFiles[] {
|
|
86
|
+
const vd = join(dir, 'versions')
|
|
87
|
+
if (!existsSync(vd)) return []
|
|
88
|
+
return readdirSync(vd)
|
|
89
|
+
.filter((f) => f.endsWith('.trailer') && CUT_RE.test(f.slice(0, -'.trailer'.length)))
|
|
90
|
+
.map((f) => f.slice(0, -'.trailer'.length))
|
|
91
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
|
92
|
+
.map((v) => ({ cut: v, name: v, trailer: join(vd, `${v}.trailer`), version: true as const }))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** a trailer's cuts: each .trailer file, then each render no file names (a shot's preview `shot-*`, a passage `clip-*`
|
|
96
|
+
* and a section's preview `<cut>-<section>` are never a cut), then each kept version a cut of that name does not shadow */
|
|
97
|
+
export function cutsOf(dir: string): CutFiles[] {
|
|
98
|
+
const files = readdirSync(dir)
|
|
99
|
+
.filter((f) => f.endsWith('.trailer') && CUT_RE.test(f.slice(0, -'.trailer'.length)))
|
|
100
|
+
.sort()
|
|
101
|
+
const out: CutFiles[] = files.map((f) => {
|
|
102
|
+
const cut = f.slice(0, -'.trailer'.length)
|
|
103
|
+
return { cut, name: nameOf(join(dir, f), cut), trailer: join(dir, f) }
|
|
104
|
+
})
|
|
105
|
+
const named = new Set(out.map((c) => c.name))
|
|
106
|
+
const outDir = join(dir, 'out')
|
|
107
|
+
if (existsSync(outDir))
|
|
108
|
+
for (const f of readdirSync(outDir).sort()) {
|
|
109
|
+
const stem = f.slice(0, -'.mp4'.length)
|
|
110
|
+
if (!f.endsWith('.mp4') || /^(shot|clip)-/.test(f) || named.has(stem) || [...named].some((n) => stem.startsWith(`${n}-`)) || !CUT_RE.test(stem) || out.some((c) => c.cut === stem)) continue
|
|
111
|
+
out.push({ cut: stem, name: stem, trailer: null })
|
|
112
|
+
}
|
|
113
|
+
out.sort((a, b) => (a.cut === 'main' ? -1 : b.cut === 'main' ? 1 : a.cut.localeCompare(b.cut)))
|
|
114
|
+
return [...out, ...versionsOf(dir).filter((v) => !out.some((c) => c.cut === v.cut))]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** every trailer folder: a folder of trailer/ that holds a cut */
|
|
118
|
+
export function trailerIds(root: string): string[] {
|
|
119
|
+
const d = trailersDir(root)
|
|
120
|
+
if (!existsSync(d)) return []
|
|
121
|
+
return readdirSync(d)
|
|
122
|
+
.filter((id) => TRAILER_ID_RE.test(id) && statSync(join(d, id)).isDirectory() && cutsOf(join(d, id)).length > 0)
|
|
123
|
+
.sort()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const cutOf = (dir: string, cut: string): CutFiles | null => (CUT_RE.test(cut) ? (cutsOf(dir).find((c) => c.cut === cut) ?? null) : null)
|
|
127
|
+
/** a cut's render: out/<name>.mp4, or a version's own */
|
|
128
|
+
export const videoOf = (dir: string, c: CutFiles): string => (c.version ? (versionFile(dir, c.cut, '.mp4') ?? join(dir, 'versions', c.cut, `${c.cut}.mp4`)) : join(dir, 'out', `${c.name}.mp4`))
|
|
129
|
+
const stripOf = (dir: string, c: CutFiles): string => (c.version ? (versionFile(dir, c.cut, '.strip.png') ?? join(dir, 'versions', c.cut, `${c.cut}.strip.png`)) : join(dir, 'out', `${c.name}.strip.png`))
|
|
130
|
+
export const montageOf = (dir: string, c: CutFiles): string => join(dir, 'out', `${c.name}.montage.md`)
|
|
131
|
+
|
|
132
|
+
/** a cut's .trailer parsed: the film lines need the project CLI's words, which a timeline never does, so their errors
|
|
133
|
+
* are left out */
|
|
134
|
+
function parseCut(file: string): { trailer: Trailer; errors: string[] } {
|
|
135
|
+
const text = readFileSync(file, 'utf8')
|
|
136
|
+
const lines = text.split('\n')
|
|
137
|
+
const { trailer, errors } = parseTrailer(text, { verbs: null })
|
|
138
|
+
const own = errors.filter((e) => !/^\s*film\s/.test(lines[e.line - 1] ?? ''))
|
|
139
|
+
return { trailer, errors: own.map((e) => `${file.split('/').pop()}:${e.line}: ${e.message}`) }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** a render younger than this may still be written: ffmpeg writes in place */
|
|
143
|
+
const SETTLE_MS = 2500
|
|
144
|
+
const settled = (mtime: number, now: number): boolean => now - mtime > SETTLE_MS
|
|
145
|
+
|
|
146
|
+
/** where each finished render is kept as the page plays it: an APFS clone, free, untouched by the next render */
|
|
147
|
+
export const viewDir = (root: string): string => join(root, LAYOUT.cache, 'trailer-view')
|
|
148
|
+
/** the versions kept per cut: the one on screen may be the one before the newest */
|
|
149
|
+
const VIEWS_KEPT = 3
|
|
150
|
+
|
|
151
|
+
/** a finished render of a cut: its clone, its length and rate, its size, the timeline it was rendered from (null: unknown) */
|
|
152
|
+
export interface View {
|
|
153
|
+
mtime: number
|
|
154
|
+
duration: number
|
|
155
|
+
fps: number
|
|
156
|
+
size: [number, number]
|
|
157
|
+
timeline: Timeline | null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const viewBase = (root: string, id: string, cut: string, mtime: number): string => join(viewDir(root), `${id}--${cut}--${Math.round(mtime)}`)
|
|
161
|
+
export const viewVideo = (root: string, id: string, cut: string, mtime: number): string => `${viewBase(root, id, cut, mtime)}.mp4`
|
|
162
|
+
export const viewStrip = (root: string, id: string, cut: string, mtime: number): string => `${viewBase(root, id, cut, mtime)}.strip.png`
|
|
163
|
+
|
|
164
|
+
/** the render's contact strip kept beside it, once the build has written it (just after the mp4) */
|
|
165
|
+
function keepStrip(root: string, dir: string, id: string, c: CutFiles, mtime: number): void {
|
|
166
|
+
const kept = viewStrip(root, id, c.cut, mtime)
|
|
167
|
+
const sf = stripOf(dir, c)
|
|
168
|
+
if (existsSync(kept) || !existsSync(sf) || statSync(sf).mtimeMs < mtime || !settled(statSync(sf).mtimeMs, Date.now())) return
|
|
169
|
+
copyFileSync(sf, kept, constants.COPYFILE_FICLONE)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** a kept version by its mtime, null when it was pruned */
|
|
173
|
+
export function readView(root: string, id: string, cut: string, mtime: number): View | null {
|
|
174
|
+
const f = `${viewBase(root, id, cut, mtime)}.json`
|
|
175
|
+
return existsSync(f) && existsSync(viewVideo(root, id, cut, mtime)) ? (JSON.parse(readFileSync(f, 'utf8')) as View) : null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function newestView(root: string, id: string, cut: string): View | null {
|
|
179
|
+
if (!existsSync(viewDir(root))) return null
|
|
180
|
+
const prefix = `${id}--${cut}--`
|
|
181
|
+
const ms = readdirSync(viewDir(root))
|
|
182
|
+
.filter((f) => f.startsWith(prefix) && f.endsWith('.json'))
|
|
183
|
+
.map((f) => Number(f.slice(prefix.length, -5)))
|
|
184
|
+
.filter((m) => Number.isFinite(m))
|
|
185
|
+
.sort((x, y) => y - x)
|
|
186
|
+
for (const m of ms) {
|
|
187
|
+
const v = readView(root, id, cut, m)
|
|
188
|
+
if (v) return v
|
|
189
|
+
}
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** the render's length, rate and size; null while ffprobe cannot read it (a render still written) */
|
|
194
|
+
function probeVideo(video: string): { duration: number; fps: number; size: [number, number] } | null {
|
|
195
|
+
try {
|
|
196
|
+
const p = probe(video)
|
|
197
|
+
return { duration: p.duration, fps: p.video?.fps || 30, size: [p.video?.width ?? 1920, p.video?.height ?? 1080] }
|
|
198
|
+
} catch {
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** every repo the hub serves keeps its own: the key carries the root */
|
|
204
|
+
const views = new Map<string, View>()
|
|
205
|
+
|
|
206
|
+
/** the cut's render as the page plays it: the newest finished version, kept; while a new one is written, the last one */
|
|
207
|
+
export function viewOf(root: string, dir: string, id: string, c: CutFiles, now = Date.now()): { view: View | null; rendering: boolean } {
|
|
208
|
+
const key = `${root}\0${id}/${c.cut}`
|
|
209
|
+
const kept = views.get(key) ?? newestView(root, id, c.cut)
|
|
210
|
+
const video = videoOf(dir, c)
|
|
211
|
+
if (!existsSync(video)) return { view: kept, rendering: false }
|
|
212
|
+
const mtime = Math.round(statSync(video).mtimeMs)
|
|
213
|
+
if (kept?.mtime === mtime) {
|
|
214
|
+
views.set(key, kept)
|
|
215
|
+
keepStrip(root, dir, id, c, mtime)
|
|
216
|
+
return { view: kept, rendering: false }
|
|
217
|
+
}
|
|
218
|
+
if (!settled(mtime, now)) return { view: kept, rendering: true }
|
|
219
|
+
const p = probeVideo(video)
|
|
220
|
+
if (!p) return { view: kept, rendering: true }
|
|
221
|
+
mkdirSync(viewDir(root), { recursive: true })
|
|
222
|
+
copyFileSync(video, viewVideo(root, id, c.cut, mtime), constants.COPYFILE_FICLONE)
|
|
223
|
+
// the .trailer saved before the render is the one it was rendered from; one saved after may differ
|
|
224
|
+
let timeline: Timeline | null = null
|
|
225
|
+
if (c.trailer === null) timeline = wholeTimeline(p.duration, p.fps, p.size)
|
|
226
|
+
else if (statSync(c.trailer).mtimeMs <= mtime)
|
|
227
|
+
try {
|
|
228
|
+
timeline = timelineOf(parseCut(c.trailer).trailer)
|
|
229
|
+
} catch {
|
|
230
|
+
timeline = null
|
|
231
|
+
}
|
|
232
|
+
const view: View = { mtime, ...p, timeline }
|
|
233
|
+
writeFileSync(`${viewBase(root, id, c.cut, mtime)}.json`, JSON.stringify(view))
|
|
234
|
+
keepStrip(root, dir, id, c, mtime)
|
|
235
|
+
views.set(key, view)
|
|
236
|
+
pruneViews(root, id, c.cut)
|
|
237
|
+
return { view, rendering: false }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function pruneViews(root: string, id: string, cut: string): void {
|
|
241
|
+
const prefix = `${id}--${cut}--`
|
|
242
|
+
for (const f of viewsToDrop(root)) if (f.slice(viewDir(root).length + 1).startsWith(prefix)) rmSync(f, { force: true })
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** the kept renders that go: every one past the newest VIEWS_KEPT of a cut, and every one of a cut its folder no longer
|
|
246
|
+
* has (a note keeps its own still) */
|
|
247
|
+
export function viewsToDrop(root: string): string[] {
|
|
248
|
+
const vd = viewDir(root)
|
|
249
|
+
if (!existsSync(vd)) return []
|
|
250
|
+
const live = new Set(trailerIds(root).flatMap((id) => cutsOf(join(trailersDir(root), id)).map((c) => `${id}--${c.cut}`)))
|
|
251
|
+
const byCut = new Map<string, Set<number>>()
|
|
252
|
+
const files = readdirSync(vd)
|
|
253
|
+
for (const f of files) {
|
|
254
|
+
const m = /^(.+)--(\d+)\./.exec(f)
|
|
255
|
+
if (!m) continue
|
|
256
|
+
const ms = byCut.get(m[1]!) ?? new Set<number>()
|
|
257
|
+
byCut.set(m[1]!, ms.add(Number(m[2])))
|
|
258
|
+
}
|
|
259
|
+
const kept = new Set(
|
|
260
|
+
[...byCut].flatMap(([cut, ms]) =>
|
|
261
|
+
live.has(cut)
|
|
262
|
+
? [...ms]
|
|
263
|
+
.sort((x, y) => y - x)
|
|
264
|
+
.slice(0, VIEWS_KEPT)
|
|
265
|
+
.map((m) => `${cut}--${m}`)
|
|
266
|
+
: [],
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
return files.filter((f) => !kept.has(/^(.+--\d+)\./.exec(f)?.[1] ?? '')).map((f) => join(vd, f))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** the contact strip's grid as src/trailer/stitch.ts lays it: rows of six tiles THUMB_W wide, 6 px apart */
|
|
273
|
+
const STRIP_COLS = 6
|
|
274
|
+
const STRIP_PAD = 6
|
|
275
|
+
|
|
276
|
+
/** the strip's grid, when the file's size fits this timeline's shots */
|
|
277
|
+
function stripInfo(file: string, tl: Timeline): CutInfo['strip'] {
|
|
278
|
+
if (!existsSync(file) || tl.shots.length === 0) return null
|
|
279
|
+
const head = readFileSync(file).subarray(0, 24)
|
|
280
|
+
const w = head.readUInt32BE(16)
|
|
281
|
+
const h = head.readUInt32BE(20)
|
|
282
|
+
const cols = Math.min(STRIP_COLS, tl.shots.length)
|
|
283
|
+
const rows = Math.ceil(tl.shots.length / cols)
|
|
284
|
+
const tileH = Math.round((THUMB_W * tl.size[1]) / tl.size[0] / 2) * 2
|
|
285
|
+
const fits = w === cols * THUMB_W + (cols - 1) * STRIP_PAD && h === rows * tileH + (rows - 1) * STRIP_PAD
|
|
286
|
+
return fits ? { cols, tileW: THUMB_W, tileH, pad: STRIP_PAD } : null
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function cutInfo(root: string, dir: string, id: string, c: CutFiles): CutInfo {
|
|
290
|
+
const video = videoOf(dir, c)
|
|
291
|
+
const fileMtime = c.trailer ? statSync(c.trailer).mtimeMs : existsSync(video) ? statSync(video).mtimeMs : 0
|
|
292
|
+
let parsed: Timeline | null = null
|
|
293
|
+
let errors: string[] = []
|
|
294
|
+
if (c.trailer)
|
|
295
|
+
try {
|
|
296
|
+
const p = parseCut(c.trailer)
|
|
297
|
+
parsed = timelineOf(p.trailer)
|
|
298
|
+
errors = p.errors
|
|
299
|
+
} catch (e) {
|
|
300
|
+
errors = [`${c.cut}.trailer: ${(e as Error).message}`]
|
|
301
|
+
}
|
|
302
|
+
const { view, rendering } = viewOf(root, dir, id, c)
|
|
303
|
+
const timeline = view?.timeline ?? parsed
|
|
304
|
+
const rel = (p: string): string => relative(root, p)
|
|
305
|
+
const montage = montageOf(dir, c)
|
|
306
|
+
return {
|
|
307
|
+
cut: c.cut,
|
|
308
|
+
name: c.name,
|
|
309
|
+
files: { trailer: c.trailer ? rel(c.trailer) : null, video: rel(video), strip: rel(stripOf(dir, c)), montage: existsSync(montage) ? rel(montage) : null },
|
|
310
|
+
timeline,
|
|
311
|
+
timelineFromRender: view?.timeline != null,
|
|
312
|
+
errors,
|
|
313
|
+
video: view ? { mtime: view.mtime, duration: view.duration, fps: view.fps } : null,
|
|
314
|
+
rendering: !c.version && rendering,
|
|
315
|
+
edited: !c.version && c.trailer !== null && view !== null && fileMtime > view.mtime,
|
|
316
|
+
strip: timeline && view ? stripInfo(viewStrip(root, id, c.cut, view.mtime), timeline) : null,
|
|
317
|
+
fileMtime,
|
|
318
|
+
version: c.version === true,
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function trailerInfo(root: string, id: string): TrailerInfo | null {
|
|
323
|
+
const dir = trailerDir(root, id)
|
|
324
|
+
if (!dir) return null
|
|
325
|
+
return { id, cuts: cutsOf(dir).map((c) => cutInfo(root, dir, id, c)), notes: notesOf(readAllCached(storeDir(root)).items, id) }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** the montage page of a cut, null when its build wrote none */
|
|
329
|
+
export function montagePage(root: string, id: string, cut: string): string | null {
|
|
330
|
+
const dir = trailerDir(root, id)
|
|
331
|
+
const c = dir ? cutOf(dir, cut) : null
|
|
332
|
+
if (!dir || !c) return null
|
|
333
|
+
const f = montageOf(dir, c)
|
|
334
|
+
return existsSync(f) ? readFileSync(f, 'utf8') : null
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── a note's frame ──
|
|
338
|
+
|
|
339
|
+
/** a note's frame on a cut: its index and the frame's middle, where the player and ffmpeg both show it */
|
|
340
|
+
export function frameAt(time: number, fps: number, duration: number): { frame: number; time: number } {
|
|
341
|
+
const last = Math.max(0, Math.round(duration * fps) - 1)
|
|
342
|
+
const frame = Math.max(0, Math.min(last, Math.floor(time * fps + 1e-6)))
|
|
343
|
+
return { frame, time: (frame + 0.5) / fps }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface NewNote {
|
|
347
|
+
cut: string
|
|
348
|
+
/** the render on screen, by its mtime: the still is cut from that version, whatever came after */
|
|
349
|
+
version: number
|
|
350
|
+
time: number
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** the frame at `time` of the render on screen as a note's subject, and its still cut into `still`: the shot read from
|
|
354
|
+
* that render's timeline */
|
|
355
|
+
export async function frameNote(root: string, trailer: string, input: NewNote, still: string): Promise<FrameSubject> {
|
|
356
|
+
const dir = trailerDir(root, trailer)
|
|
357
|
+
const c = dir ? cutOf(dir, input.cut) : null
|
|
358
|
+
if (!dir || !c) throw new Error(`no cut ${trailer}/${input.cut}`)
|
|
359
|
+
const v = readView(root, trailer, input.cut, input.version)
|
|
360
|
+
if (!v) throw new Error(`the render of ${input.cut} on screen is gone: load the new one`)
|
|
361
|
+
const tl = v.timeline ?? (c.trailer ? timelineOf(parseCut(c.trailer).trailer) : wholeTimeline(v.duration, v.fps, v.size))
|
|
362
|
+
const f = frameAt(input.time, v.fps, v.duration)
|
|
363
|
+
const shot = shotAt(tl, f.time)
|
|
364
|
+
if (!shot) throw new Error(`${input.cut} has no shot`)
|
|
365
|
+
await stillAt(viewVideo(root, trailer, input.cut, v.mtime), (f.frame - 0.5) / v.fps, still)
|
|
366
|
+
return { kind: 'frame', trailer, cut: input.cut, version: String(v.mtime), time: round(f.time), frame: f.frame, shot: shot.name, shotTime: round(f.time - shot.start), section: shot.section }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** what a live poll compares: every .trailer file and render of every trailer, by name and mtime, and whether each
|
|
370
|
+
* render has settled */
|
|
371
|
+
export function trailerStamp(root: string, now = Date.now()): string {
|
|
372
|
+
const parts: string[] = []
|
|
373
|
+
for (const id of trailerIds(root)) {
|
|
374
|
+
const dir = join(trailersDir(root), id)
|
|
375
|
+
for (const d of [dir, join(dir, 'out'), join(dir, 'versions')]) {
|
|
376
|
+
if (!existsSync(d)) continue
|
|
377
|
+
for (const f of readdirSync(d)) {
|
|
378
|
+
if (!/\.(trailer|mp4)$/.test(f) || f.startsWith('shot-')) continue
|
|
379
|
+
const m = statSync(join(d, f)).mtimeMs
|
|
380
|
+
parts.push(`${id}/${f}:${m}${f.endsWith('.mp4') && !settled(m, now) ? ':writing' : ''}`)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return parts.join('|')
|
|
385
|
+
}
|