@ossclip/core 0.1.20 → 0.1.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossclip/core",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "ossclip's framework-free pipeline: schema, transcription, analysis, cutlist, captions, framing, and the LLM producer",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,99 @@
1
+ import { basename } from "node:path";
2
+ import { keptPauseLabel, keptPauses } from "./export-markers";
3
+ import type { Production, RemovalReason, Segment } from "./schema";
4
+
5
+ /**
6
+ * Resolve marker EDL export (§142). The FCPXML exporter's markers are real,
7
+ * but DaVinci Resolve's FCPXML import silently drops clip markers — verified
8
+ * in the field the day FCPXML shipped: timeline imported, 127 markers gone,
9
+ * marker display on. Resolve's dedicated path is `Timeline → Import →
10
+ * Timeline Markers from EDL`, and its format is Resolve's OWN marker-EDL
11
+ * dialect: a CMX3600-shaped event per marker plus a continuation line
12
+ * ` |C:ResolveColor<name> |M:<label> |D:<frames>`.
13
+ *
14
+ * This dialect is also where the §141 colour decision gets un-made for one
15
+ * consumer: FCPXML markers cannot carry colour, but |C: can — so here the
16
+ * cut REASON maps to a colour as well as living in the label.
17
+ */
18
+
19
+ /**
20
+ * Reason → Resolve marker colour. Names must be from Resolve's fixed
21
+ * View → Show Markers list (verified against Resolve 20); an unknown name
22
+ * imports as the default colour rather than erroring, but stay exact anyway.
23
+ */
24
+ const REASON_COLOURS: Record<RemovalReason, string> = {
25
+ silence: "Blue",
26
+ pause: "Sky",
27
+ filler: "Yellow",
28
+ retake: "Red",
29
+ user: "Green",
30
+ clip: "Purple",
31
+ };
32
+
33
+ /** Non-drop hh:mm:ss:ff at an integer frame rate. */
34
+ export function framesToTimecode(frames: number, fps: number): string {
35
+ const ff = frames % fps;
36
+ const totalSec = Math.floor(frames / fps);
37
+ const ss = totalSec % 60;
38
+ const mm = Math.floor(totalSec / 60) % 60;
39
+ const hh = Math.floor(totalSec / 3600);
40
+ const p = (n: number) => String(n).padStart(2, "0");
41
+ return `${p(hh)}:${p(mm)}:${p(ss)}:${p(ff)}`;
42
+ }
43
+
44
+ /**
45
+ * ASCII on purpose, unlike the report and the FCPXML labels: EDL is a
46
+ * fixed-width 1970s interchange format and the true minus sign (U+2212) is
47
+ * exactly the kind of byte that a strict parser turns into a silently
48
+ * skipped line. Pipes are stripped because ` |` is the field separator.
49
+ */
50
+ function label(seg: Segment): string {
51
+ const dur = (seg.srcOut - seg.srcIn).toFixed(2);
52
+ const conf = seg.confidence !== undefined ? ` (conf ${seg.confidence.toFixed(2)})` : "";
53
+ return `${seg.reason ?? "cut"} -${dur}s${conf}`.replaceAll("|", "/");
54
+ }
55
+
56
+ export function buildResolveMarkerEdl(production: Production): string {
57
+ const { path, probe } = production.source;
58
+ const fps = Math.round(probe.fps);
59
+ // SPANS, not 1-frame points (§142 round 2): |D: carries the whole
60
+ // suggested cut in frames, so Resolve draws the marker across the region —
61
+ // the field editor needs where content RESUMES as much as where the cut
62
+ // starts. Kept pauses (export-markers.ts) join the list in Lavender, a
63
+ // colour no cut reason uses, so suggestion and information stay distinct.
64
+ const items = [
65
+ ...(production.cutlist ?? [])
66
+ .filter((s) => s.kind === "remove")
67
+ .map((s) => ({
68
+ start: s.srcIn,
69
+ end: s.srcOut,
70
+ colour: REASON_COLOURS[s.reason ?? "user"] ?? "Green",
71
+ label: label(s),
72
+ })),
73
+ ...keptPauses(production).map((p) => ({
74
+ start: p.start,
75
+ end: p.end,
76
+ colour: "Lavender",
77
+ label: keptPauseLabel(p).replaceAll("|", "/"),
78
+ })),
79
+ ].sort((a, b) => a.start - b.start);
80
+ const lines: string[] = [
81
+ `TITLE: ${basename(path)} — ossclip markers`,
82
+ "FCM: NON-DROP FRAME",
83
+ "",
84
+ ];
85
+ items.forEach((m, i) => {
86
+ const inFrames = Math.round(m.start * fps);
87
+ // At least one frame — a zero-length marker is invisible.
88
+ const durFrames = Math.max(1, Math.round(m.end * fps) - inFrames);
89
+ const tcIn = framesToTimecode(inFrames, fps);
90
+ const tcOut = framesToTimecode(inFrames + durFrames, fps);
91
+ // Record TC = source TC: the FCPXML timeline this rides on starts at
92
+ // 00:00:00:00 (tcStart 0), so the marker lands at the source position.
93
+ const num = String(i + 1).padStart(3, "0");
94
+ lines.push(`${num} 001 V C ${tcIn} ${tcOut} ${tcIn} ${tcOut} `);
95
+ lines.push(` |C:ResolveColor${m.colour} |M:${m.label} |D:${durFrames}`);
96
+ lines.push("");
97
+ });
98
+ return `${lines.join("\n")}\n`;
99
+ }
@@ -1,5 +1,6 @@
1
1
  import { basename } from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
+ import { keptPauseLabel, keptPauses } from "./export-markers";
3
4
  import type { Production, Segment } from "./schema";
4
5
 
5
6
  /**
@@ -74,12 +75,26 @@ export function buildFcpxmlMarkers(production: Production): string {
74
75
  const fd = fpsToFrameDuration(probe.fps);
75
76
  const dur = quantizeToFrame(probe.duration, fd);
76
77
  const name = basename(path);
78
+ // SPANS, not points (§142 round 2): the 1-frame marker was a deliberate
79
+ // "don't overstate the suggestion" choice, and the first real editor
80
+ // reversed it — she needs where the cut starts AND where content resumes,
81
+ // and a span's two edges are exactly that. Kept pauses ride along as their
82
+ // own markers (see export-markers.ts); one time-ordered list, so the
83
+ // NLE's marker index reads like the take.
84
+ const spanFrames = (startSec: number, endSec: number) => {
85
+ const frames = (sec: number) => Math.round((sec * fd.den) / fd.num);
86
+ return Math.max(1, frames(endSec) - frames(startSec));
87
+ };
77
88
  const removals = (production.cutlist ?? []).filter((s) => s.kind === "remove");
78
- const markers = removals
89
+ const items = [
90
+ ...removals.map((s) => ({ start: s.srcIn, end: s.srcOut, label: markerName(s) })),
91
+ ...keptPauses(production).map((p) => ({ start: p.start, end: p.end, label: keptPauseLabel(p) })),
92
+ ].sort((a, b) => a.start - b.start);
93
+ const markers = items
79
94
  .map(
80
- (s) =>
81
- ` <marker start="${quantizeToFrame(s.srcIn, fd)}" ` +
82
- `duration="${fd.num}/${fd.den}s" value="${esc(markerName(s))}"/>`,
95
+ (m) =>
96
+ ` <marker start="${quantizeToFrame(m.start, fd)}" ` +
97
+ `duration="${spanFrames(m.start, m.end) * fd.num}/${fd.den}s" value="${esc(m.label)}"/>`,
83
98
  )
84
99
  .join("\n");
85
100
  // pathToFileURL, not string concat: it percent-encodes the characters a
@@ -0,0 +1,43 @@
1
+ import type { Production, Span } from "./schema";
2
+
3
+ /**
4
+ * Shared derivations for the NLE marker exporters (§142 round 2).
5
+ *
6
+ * The field editor's second complaint, verified frame-by-frame against her
7
+ * recorded feedback: real pauses sat visibly in her waveform with no marker
8
+ * on them. Those are pauses the analysis DETECTED but the cut rules KEPT —
9
+ * below the cut bar, or sentence-snapped away. The exports read only the
10
+ * cutlist, so a kept pause was invisible in exactly the tool she uses to
11
+ * find pauses. `analysis.cuttable` is the right pool: regions with no
12
+ * audible speech AFTER the transcript veto — the same candidates every
13
+ * silence/pause cut was drawn from.
14
+ */
15
+
16
+ /**
17
+ * 0.25s, not `breaths`' 120ms floor: a 30-minute take carries hundreds of
18
+ * sub-quarter-second breaths, and a marker per breath buries the markers the
19
+ * editor asked for under the ones she didn't (§142 round 2 — marker-spam
20
+ * floor).
21
+ */
22
+ export const MIN_KEPT_PAUSE_SEC = 0.25;
23
+
24
+ /**
25
+ * Detected pauses the cut rules kept: cuttable regions no remove segment
26
+ * overlaps, at or above the floor. A PARTIALLY overlapped region is excluded
27
+ * too — the cut already marks that neighbourhood, and a second marker there
28
+ * would read as a second suggestion.
29
+ */
30
+ export function keptPauses(production: Production): Span[] {
31
+ const cuttable = production.analysis?.cuttable ?? [];
32
+ const removes = (production.cutlist ?? []).filter((s) => s.kind === "remove");
33
+ return cuttable.filter(
34
+ (span) =>
35
+ span.end - span.start >= MIN_KEPT_PAUSE_SEC &&
36
+ !removes.some((r) => r.srcIn < span.end && r.srcOut > span.start),
37
+ );
38
+ }
39
+
40
+ /** The kept-pause label, shared so all three formats say the same words. */
41
+ export function keptPauseLabel(span: Span): string {
42
+ return `pause ${(span.end - span.start).toFixed(2)}s (kept)`;
43
+ }
@@ -0,0 +1,406 @@
1
+ import { basename } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { keptPauseLabel, keptPauses } from "./export-markers";
4
+ import { esc, xmemlRate, xmemlRateXml } from "./export-xmeml-util";
5
+ import { mapFromKeptSpans, type KeptSpan } from "./timemap";
6
+ import { zoomScaleAt, type ZoomSegment } from "./zoom";
7
+ import type { Production } from "./schema";
8
+
9
+ /**
10
+ * `--format premiere-project` — the produce experience delivered INSIDE
11
+ * Premiere, editable. The marker export (export-premiere-xml.ts) annotates an
12
+ * untouched take; this one applies the cutlist: an xmeml v4 sequence at the
13
+ * OUTPUT frame with one `<clipitem>` per kept span. xmeml trims are
14
+ * non-destructive — dragging a clip edge restores removed material — which is
15
+ * what neutralizes the original "blind cuts" objection to a pre-cut export.
16
+ * Each clip carries a Basic Motion filter reproducing the render's camera:
17
+ * cover-crop base scale × alternating punch-in × cosine-eased zoom keyframes.
18
+ *
19
+ * Same house split as every exporter: plain data in, two strings out; the CLI
20
+ * owns the files. Both strings are pure ASCII (§142 — decades-old consumers
21
+ * silently skip lines they can't decode; no U+2212 anywhere).
22
+ *
23
+ * Cover-crop parity is the FULL-BLEED framing only — VideoStage layouts
24
+ * differ per scene, and v1 deliberately exports the one framing every scene
25
+ * shares (plan 2026-08-13, known risks).
26
+ */
27
+
28
+ /** The slice of a caption line the SRT needs — start/end are OUTPUT seconds. */
29
+ export interface SrtLine {
30
+ words: readonly { text: string }[];
31
+ start: number;
32
+ end: number;
33
+ }
34
+
35
+ export interface PremiereProjectInput {
36
+ production: Production;
37
+ spans: readonly KeptSpan[];
38
+ captionLines: readonly SrtLine[];
39
+ zoomPlan: readonly ZoomSegment[];
40
+ /** render-props' flag: true disables BOTH motion layers (zoom AND punch). */
41
+ staticCamera?: boolean;
42
+ /** The OUTPUT frame (production.render), e.g. 1080×1920. */
43
+ frame: { width: number; height: number };
44
+ }
45
+
46
+ /**
47
+ * The render's jump-cut concealer, replicated from EdlVideo.tsx:41-51 — the
48
+ * alternating toggle, the srcIn−prev.srcOut gap, and the INCLUSIVE >=
49
+ * threshold must all match, or the exported project punches different clips
50
+ * than the render did. Defaults mirror EdlVideoProps; they are parameters so
51
+ * a drift in either place shows up as a failing hand-computed test, not a
52
+ * silent divergence.
53
+ */
54
+ export function punchScales(
55
+ spans: readonly KeptSpan[],
56
+ punchInScale = 1.07,
57
+ punchThresholdSec = 0.15,
58
+ ): number[] {
59
+ const out: number[] = [];
60
+ let punched = false;
61
+ for (let i = 0; i < spans.length; i++) {
62
+ const prev = spans[i - 1];
63
+ const gap = prev ? spans[i]!.srcIn - prev.srcOut : 0;
64
+ if (i > 0 && gap >= punchThresholdSec) punched = !punched;
65
+ out.push(punched ? punchInScale : 1);
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * The full-bleed cover-crop as Basic Motion parameters. Premiere imports a
72
+ * source 1:1-pixel centered in the sequence, so the cover factor —
73
+ * max(frame/probe) per axis, the CSS `object-fit: cover` rule the render
74
+ * uses — maps directly onto the scale percent (100 = native size). The
75
+ * center offset re-centers the measured face point and is clamped so the
76
+ * scaled picture never reveals an edge: |offset| ≤ (scaledDim − frameDim)/2
77
+ * per axis — the same "never show the backing" guarantee cover gives the
78
+ * render. xmeml center units: fractions of the sequence dimension, 0 =
79
+ * centered. No face → centered, exactly like the render's 50% default.
80
+ */
81
+ export function coverTransform(
82
+ probe: { width: number; height: number },
83
+ face: { centerXFrac: number; centerYFrac: number } | null | undefined,
84
+ frame: { width: number; height: number },
85
+ ): { scalePct: number; centerH: number; centerV: number } {
86
+ const scale = Math.max(frame.width / probe.width, frame.height / probe.height);
87
+ const offset = (scaledDim: number, frameDim: number, frac: number): number => {
88
+ const slack = (scaledDim - frameDim) / 2;
89
+ const want = scaledDim * (0.5 - frac);
90
+ return Math.max(-slack, Math.min(slack, want));
91
+ };
92
+ return {
93
+ scalePct: scale * 100,
94
+ centerH: face ? offset(probe.width * scale, frame.width, face.centerXFrac) / frame.width : 0,
95
+ centerV: face ? offset(probe.height * scale, frame.height, face.centerYFrac) / frame.height : 0,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Zoom samples for one clip's output window, in OUTPUT seconds: segment
101
+ * endpoints plus two interior points per ramp (a cosine ease through four
102
+ * points reads as the ease; through two it reads as linear). Only this
103
+ * clip's own segments are consulted — `zoomScaleAt` matches half-open, so at
104
+ * the clip's final instant the unfiltered plan would answer with the NEXT
105
+ * clip's reset instead of this clip's hold (zoom.ts's boundary note).
106
+ * Interior samples of a constant run are dropped; a hold keeps only its
107
+ * endpoints.
108
+ */
109
+ export function zoomKeyframesFor(
110
+ span: KeptSpan,
111
+ zoomPlan: readonly ZoomSegment[],
112
+ ): { tSec: number; scale: number }[] {
113
+ const segs = zoomPlan.filter((s) => s.startSec < span.outOut && s.endSec > span.outIn);
114
+ const times = new Set<number>([span.outIn, span.outOut]);
115
+ for (const seg of segs) {
116
+ const a = Math.max(seg.startSec, span.outIn);
117
+ const b = Math.min(seg.endSec, span.outOut);
118
+ times.add(a);
119
+ times.add(b);
120
+ if (seg.from !== seg.to) {
121
+ times.add(a + (b - a) / 3);
122
+ times.add(a + (2 * (b - a)) / 3);
123
+ }
124
+ }
125
+ const samples = [...times]
126
+ .sort((x, y) => x - y)
127
+ .map((tSec) => ({ tSec, scale: zoomScaleAt(segs, tSec) }));
128
+ const eq = (a: number, b: number) => Math.abs(a - b) < 1e-9;
129
+ return samples.filter((cur, i) => {
130
+ const prev = samples[i - 1];
131
+ const next = samples[i + 1];
132
+ // Interior of a constant run — the run's endpoints carry the hold.
133
+ return !(prev && next && eq(prev.scale, cur.scale) && eq(cur.scale, next.scale));
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Keyframe `<when>` domain: CLIP-RELATIVE frames — the one xmeml spec detail
139
+ * memory can't settle (media-time vs clip-time; the plan's stated open
140
+ * risk). Kept as a single function so the §142 fail-fast field smoke can
141
+ * flip the domain with a one-line change plus its test, not a hunt through
142
+ * the emitter.
143
+ */
144
+ export function keyframeWhen(span: KeptSpan, tSec: number, fps: number): number {
145
+ return Math.round((tSec - span.outIn) * fps);
146
+ }
147
+
148
+ /** HH:MM:SS,mmm — the comma is SRT's own; everything here is ASCII (§142). */
149
+ function srtTime(sec: number): string {
150
+ const ms = Math.round(sec * 1000);
151
+ const pad = (n: number, w: number) => String(n).padStart(w, "0");
152
+ return (
153
+ `${pad(Math.floor(ms / 3_600_000), 2)}:${pad(Math.floor(ms / 60_000) % 60, 2)}:` +
154
+ `${pad(Math.floor(ms / 1000) % 60, 2)},${pad(ms % 1000, 3)}`
155
+ );
156
+ }
157
+
158
+ /**
159
+ * Captions as SRT, in output (sequence) time so the file drops straight onto
160
+ * the exported timeline. Word-level karaoke timing does not survive SRT —
161
+ * the format has no word granularity — so a line is its words joined.
162
+ */
163
+ export function srtFromCaptionLines(lines: readonly SrtLine[]): string {
164
+ return lines
165
+ .map(
166
+ (line, i) =>
167
+ `${i + 1}\n${srtTime(line.start)} --> ${srtTime(line.end)}\n` +
168
+ `${line.words.map((w) => w.text).join(" ")}\n`,
169
+ )
170
+ .join("\n");
171
+ }
172
+
173
+ /** Keyframes numeric noise-floor: 4 decimals is sub-0.001% of scale. */
174
+ const fmt = (n: number): string => String(Number(n.toFixed(4)));
175
+
176
+ export function buildPremiereProject(input: PremiereProjectInput): { xml: string; srt: string } {
177
+ const { production, spans, captionLines, zoomPlan, staticCamera, frame } = input;
178
+ const { path, probe } = production.source;
179
+ // The whole document runs at the SOURCE rate (the marker exporter's
180
+ // precedent): clip in/out and sequence start/end share one timebase, so no
181
+ // frame position depends on Premiere's rate conform.
182
+ const { timebase, ntsc } = xmemlRate(probe.fps);
183
+ const toFrames = (sec: number) => Math.round(sec * probe.fps);
184
+ const name = esc(basename(path));
185
+ const rateXml = (indent: string) => xmemlRateXml(indent, timebase, ntsc);
186
+ const outputEnd = spans.length > 0 ? spans[spans.length - 1]!.outOut : 0;
187
+ const durFrames = toFrames(outputEnd);
188
+
189
+ const cover = coverTransform(probe, production.source.face, frame);
190
+ // staticCamera kills BOTH motion drivers (produce's render-props contract:
191
+ // zoomPlan alone can't reach the punch) — cover-crop is all that remains.
192
+ const punches = staticCamera ? spans.map(() => 1) : punchScales(spans);
193
+
194
+ // Per-clip frame geometry. A sub-frame span still occupies one frame on
195
+ // both axes — a zero-length clipitem is undefined importer behavior, same
196
+ // rule as the marker exporter's zero-length span.
197
+ const geo = spans.map((sp) => {
198
+ const start = toFrames(sp.outIn);
199
+ const srcIn = toFrames(sp.srcIn);
200
+ return {
201
+ start,
202
+ end: Math.max(start + 1, toFrames(sp.outOut)),
203
+ in: srcIn,
204
+ out: Math.max(srcIn + 1, toFrames(sp.srcOut)),
205
+ };
206
+ });
207
+
208
+ const scaleParam = (sp: KeptSpan, punch: number, indent: string): string => {
209
+ const kfs = staticCamera ? [] : zoomKeyframesFor(sp, zoomPlan);
210
+ const values = kfs.map((k) => ({ when: keyframeWhen(sp, k.tSec, probe.fps), value: cover.scalePct * punch * k.scale }));
211
+ // A motionless clip (staticCamera, or a plan that never moves it) gets a
212
+ // plain static value — two identical keyframes would read as "animated"
213
+ // in Effect Controls for no reason.
214
+ const still =
215
+ values.length < 2 || values.every((v) => Math.abs(v.value - values[0]!.value) < 1e-9);
216
+ const body = still
217
+ ? `${indent} <value>${fmt(values[0]?.value ?? cover.scalePct * punch)}</value>`
218
+ : values
219
+ .map(
220
+ (v) =>
221
+ `${indent} <keyframe>\n${indent} <when>${v.when}</when>\n` +
222
+ `${indent} <value>${fmt(v.value)}</value>\n${indent} </keyframe>`,
223
+ )
224
+ .join("\n");
225
+ return (
226
+ `${indent}<parameter>\n` +
227
+ `${indent} <parameterid>scale</parameterid>\n` +
228
+ `${indent} <name>Scale</name>\n` +
229
+ `${indent} <valuemin>0</valuemin>\n` +
230
+ `${indent} <valuemax>1000</valuemax>\n` +
231
+ body + "\n" +
232
+ `${indent}</parameter>`
233
+ );
234
+ };
235
+
236
+ const basicMotion = (sp: KeptSpan, punch: number, indent: string): string =>
237
+ `${indent}<filter>\n` +
238
+ `${indent} <effect>\n` +
239
+ `${indent} <name>Basic Motion</name>\n` +
240
+ `${indent} <effectid>basic</effectid>\n` +
241
+ `${indent} <effectcategory>motion</effectcategory>\n` +
242
+ `${indent} <effecttype>motion</effecttype>\n` +
243
+ `${indent} <mediatype>video</mediatype>\n` +
244
+ scaleParam(sp, punch, `${indent} `) + "\n" +
245
+ `${indent} <parameter>\n` +
246
+ `${indent} <parameterid>center</parameterid>\n` +
247
+ `${indent} <name>Center</name>\n` +
248
+ `${indent} <value>\n` +
249
+ `${indent} <horiz>${fmt(cover.centerH)}</horiz>\n` +
250
+ `${indent} <vert>${fmt(cover.centerV)}</vert>\n` +
251
+ `${indent} </value>\n` +
252
+ `${indent} </parameter>\n` +
253
+ `${indent} </effect>\n` +
254
+ `${indent}</filter>`;
255
+
256
+ // Both clipitems of a pair carry both links (xmeml convention, same as the
257
+ // marker exporter's field fix 2): this is what makes Premiere treat Vn+An
258
+ // as ONE linked clip instead of two strangers.
259
+ const links = (i: number, indent: string): string =>
260
+ !probe.hasAudio
261
+ ? ""
262
+ : `${indent}<link>\n` +
263
+ `${indent} <linkclipref>ci-v${i}</linkclipref>\n` +
264
+ `${indent} <mediatype>video</mediatype>\n` +
265
+ `${indent} <trackindex>1</trackindex>\n` +
266
+ `${indent} <clipindex>${i + 1}</clipindex>\n` +
267
+ `${indent}</link>\n` +
268
+ `${indent}<link>\n` +
269
+ `${indent} <linkclipref>ci-a${i}</linkclipref>\n` +
270
+ `${indent} <mediatype>audio</mediatype>\n` +
271
+ `${indent} <trackindex>1</trackindex>\n` +
272
+ `${indent} <clipindex>${i + 1}</clipindex>\n` +
273
+ `${indent}</link>`;
274
+
275
+ // The file is DEFINED once (first video clipitem) and referenced by id
276
+ // everywhere else — repeating full definitions is how importers end up
277
+ // with N copies of one clip in the project panel.
278
+ const fileXml = (first: boolean, indent: string): string =>
279
+ first
280
+ ? `${indent}<file id="f-1">\n` +
281
+ `${indent} <name>${name}</name>\n` +
282
+ `${indent} <pathurl>${esc(pathToFileURL(path).href)}</pathurl>\n` +
283
+ xmemlRateXml(`${indent} `, timebase, ntsc) + "\n" +
284
+ `${indent} <duration>${toFrames(probe.duration)}</duration>\n` +
285
+ `${indent} <media>\n` +
286
+ `${indent} <video>\n` +
287
+ `${indent} <samplecharacteristics>\n` +
288
+ `${indent} <width>${probe.width}</width>\n` +
289
+ `${indent} <height>${probe.height}</height>\n` +
290
+ `${indent} </samplecharacteristics>\n` +
291
+ `${indent} </video>${probe.hasAudio ? `\n${indent} <audio>\n${indent} <channelcount>2</channelcount>\n${indent} </audio>` : ""}\n` +
292
+ `${indent} </media>\n` +
293
+ `${indent}</file>`
294
+ : `${indent}<file id="f-1"/>`;
295
+
296
+ const videoClips = spans
297
+ .map((sp, i) => {
298
+ const g = geo[i]!;
299
+ return (
300
+ ` <clipitem id="ci-v${i}">\n` +
301
+ ` <name>${name}</name>\n` +
302
+ ` <duration>${toFrames(probe.duration)}</duration>\n` +
303
+ rateXml(" ") + "\n" +
304
+ ` <start>${g.start}</start>\n` +
305
+ ` <end>${g.end}</end>\n` +
306
+ ` <in>${g.in}</in>\n` +
307
+ ` <out>${g.out}</out>\n` +
308
+ fileXml(i === 0, " ") + "\n" +
309
+ basicMotion(sp, punches[i]!, " ") + "\n" +
310
+ (probe.hasAudio ? links(i, " ") + "\n" : "") +
311
+ ` </clipitem>`
312
+ );
313
+ })
314
+ .join("\n");
315
+
316
+ const audioClips = !probe.hasAudio
317
+ ? ""
318
+ : spans
319
+ .map((_sp, i) => {
320
+ const g = geo[i]!;
321
+ return (
322
+ ` <clipitem id="ci-a${i}">\n` +
323
+ ` <name>${name}</name>\n` +
324
+ ` <duration>${toFrames(probe.duration)}</duration>\n` +
325
+ rateXml(" ") + "\n" +
326
+ ` <start>${g.start}</start>\n` +
327
+ ` <end>${g.end}</end>\n` +
328
+ ` <in>${g.in}</in>\n` +
329
+ ` <out>${g.out}</out>\n` +
330
+ ` <file id="f-1"/>\n` +
331
+ ` <sourcetrack>\n` +
332
+ ` <mediatype>audio</mediatype>\n` +
333
+ ` <trackindex>1</trackindex>\n` +
334
+ ` </sourcetrack>\n` +
335
+ links(i, " ") + "\n" +
336
+ ` </clipitem>`
337
+ );
338
+ })
339
+ .join("\n");
340
+
341
+ // Kept pauses still ride the sequence (§142 round 2) — they are the
342
+ // "detected but kept" overview the field editor asked for. The cuts
343
+ // themselves are APPLIED here, so no cut markers: the cut IS the edit.
344
+ // Pause times are source seconds; the sequence runs in output time, so
345
+ // they map through the spans (clamped — a pause never overlaps a remove,
346
+ // but its edges can graze one).
347
+ const map = mapFromKeptSpans(spans);
348
+ const markers = keptPauses(production)
349
+ .map((p) => ({ start: map.toOutputClamped(p.start), end: map.toOutputClamped(p.end), label: keptPauseLabel(p) }))
350
+ .filter((m) => m.end > m.start)
351
+ .map(
352
+ (m) =>
353
+ ` <marker>\n` +
354
+ ` <name>${esc(m.label)}</name>\n` +
355
+ ` <comment>ossclip detected pause - kept, below the cut bar</comment>\n` +
356
+ ` <in>${toFrames(m.start)}</in>\n` +
357
+ ` <out>${Math.max(toFrames(m.start) + 1, toFrames(m.end))}</out>\n` +
358
+ ` </marker>`,
359
+ )
360
+ .join("\n");
361
+
362
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
363
+ <!DOCTYPE xmeml>
364
+ <xmeml version="4">
365
+ <sequence id="ossclip-project">
366
+ <name>${name} - ossclip cut</name>
367
+ <duration>${durFrames}</duration>
368
+ ${rateXml(" ")}
369
+ <timecode>
370
+ ${rateXml(" ")}
371
+ <string>00:00:00:00</string>
372
+ <frame>0</frame>
373
+ <displayformat>${ntsc ? "DF" : "NDF"}</displayformat>
374
+ </timecode>
375
+ <media>
376
+ <video>
377
+ <format>
378
+ <samplecharacteristics>
379
+ ${rateXml(" ")}
380
+ <width>${frame.width}</width>
381
+ <height>${frame.height}</height>
382
+ <anamorphic>FALSE</anamorphic>
383
+ <pixelaspectratio>square</pixelaspectratio>
384
+ <fielddominance>none</fielddominance>
385
+ </samplecharacteristics>
386
+ </format>
387
+ <track>
388
+ ${videoClips}
389
+ </track>
390
+ </video>${
391
+ probe.hasAudio
392
+ ? `
393
+ <audio>
394
+ <track>
395
+ ${audioClips}
396
+ </track>
397
+ </audio>`
398
+ : ""
399
+ }
400
+ </media>
401
+ ${markers ? `${markers}\n` : ""} </sequence>
402
+ </xmeml>
403
+ `;
404
+
405
+ return { xml, srt: srtFromCaptionLines(captionLines) };
406
+ }
@@ -0,0 +1,177 @@
1
+ import { basename } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { keptPauseLabel, keptPauses } from "./export-markers";
4
+ import { esc, xmemlRate, xmemlRateXml } from "./export-xmeml-util";
5
+ import type { Production, Segment } from "./schema";
6
+
7
+ /**
8
+ * Premiere marker export — legacy FCP7 XML, "xmeml" (§142). The field test
9
+ * that shipped fcpxml killed its own premise within the hour: Premiere Pro
10
+ * does NOT import modern FCPXML ("File format not supported"); the format it
11
+ * lists as "Final Cut Pro XML" is the LEGACY xmeml interchange, a different
12
+ * document entirely. So: fcpxml serves actual Final Cut Pro, resolve-edl
13
+ * serves Resolve (whose fcpxml import drops markers), and THIS serves
14
+ * Premiere — an xmeml v4 sequence whose SEQUENCE-level markers carry the cut
15
+ * list onto the timeline ruler after File → Import.
16
+ *
17
+ * Same house split as the other two exporters: Production in, string out.
18
+ */
19
+
20
+ /** ASCII hyphen like the EDL label — xmeml consumers are as old as it is. */
21
+ function label(seg: Segment): string {
22
+ const dur = (seg.srcOut - seg.srcIn).toFixed(2);
23
+ const conf = seg.confidence !== undefined ? ` (conf ${seg.confidence.toFixed(2)})` : "";
24
+ return `${seg.reason ?? "cut"} -${dur}s${conf}`;
25
+ }
26
+
27
+ export function buildPremiereXmlMarkers(production: Production): string {
28
+ const { path, probe } = production.source;
29
+ const { timebase, ntsc } = xmemlRate(probe.fps);
30
+ const toFrames = (sec: number) => Math.round(sec * probe.fps);
31
+ const durFrames = toFrames(probe.duration);
32
+ const name = esc(basename(path));
33
+ const rateXml = (indent: string) => xmemlRateXml(indent, timebase, ntsc);
34
+ // Emitted at BOTH levels, deliberately (field feedback, first real editor,
35
+ // §142). SEQUENCE markers sit at fixed timecode — the moment the editor
36
+ // ripple-deletes their first blooper, every downstream one points at the
37
+ // wrong moment, which is exactly why our field editor went back to manual.
38
+ // CLIP markers are anchored to the clip's MEDIA time, so they ride through
39
+ // razor cuts and ripple deletes and stay on the words they describe. The
40
+ // sequence copies stay too: they are the read-only overview of the
41
+ // untouched take, and the two agree while nothing is edited.
42
+ // SPANS, not points (§142 round 2). The original -1 (point marker) was a
43
+ // deliberate "a span overstates the suggestion" call — the first real
44
+ // editor reversed it: she needs the cut's END ("the video resumes from
45
+ // here") as much as its start, and a span's edges are both. Kept pauses
46
+ // (export-markers.ts) join the same time-ordered list.
47
+ const markerItems = [
48
+ ...(production.cutlist ?? [])
49
+ .filter((s) => s.kind === "remove")
50
+ .map((s) => ({
51
+ start: s.srcIn,
52
+ end: s.srcOut,
53
+ label: label(s),
54
+ comment: "ossclip suggested cut — review before applying",
55
+ })),
56
+ ...keptPauses(production).map((p) => ({
57
+ start: p.start,
58
+ end: p.end,
59
+ label: keptPauseLabel(p),
60
+ comment: "ossclip detected pause — kept, below the cut bar",
61
+ })),
62
+ ].sort((a, b) => a.start - b.start);
63
+ const markerXml = (indent: string) =>
64
+ markerItems
65
+ .map(
66
+ (m) =>
67
+ `${indent}<marker>\n` +
68
+ `${indent} <name>${esc(m.label)}</name>\n` +
69
+ `${indent} <comment>${esc(m.comment)}</comment>\n` +
70
+ `${indent} <in>${toFrames(m.start)}</in>\n` +
71
+ // At least one frame: a zero-length span is as invisible as the
72
+ // point markers this replaced.
73
+ `${indent} <out>${Math.max(toFrames(m.start) + 1, toFrames(m.end))}</out>\n` +
74
+ `${indent}</marker>`,
75
+ )
76
+ .join("\n");
77
+ const markers = markerXml(" ");
78
+ const clipMarkers = markerXml(" ");
79
+ // Both clipitems carry both links (xmeml convention): this is what makes
80
+ // Premiere treat V1+A1 as ONE linked clip instead of two strangers —
81
+ // field fix 2: without an audio clipitem at all, the sequence imported
82
+ // silent and the editor had to add and link the audio by hand.
83
+ const links = (indent: string) =>
84
+ !probe.hasAudio
85
+ ? ""
86
+ : `${indent}<link>\n` +
87
+ `${indent} <linkclipref>ci-1</linkclipref>\n` +
88
+ `${indent} <mediatype>video</mediatype>\n` +
89
+ `${indent} <trackindex>1</trackindex>\n` +
90
+ `${indent} <clipindex>1</clipindex>\n` +
91
+ `${indent}</link>\n` +
92
+ `${indent}<link>\n` +
93
+ `${indent} <linkclipref>ci-2</linkclipref>\n` +
94
+ `${indent} <mediatype>audio</mediatype>\n` +
95
+ `${indent} <trackindex>1</trackindex>\n` +
96
+ `${indent} <clipindex>1</clipindex>\n` +
97
+ `${indent}</link>`;
98
+ return `<?xml version="1.0" encoding="UTF-8"?>
99
+ <!DOCTYPE xmeml>
100
+ <xmeml version="4">
101
+ <sequence id="ossclip-markers">
102
+ <name>${name} — ossclip markers</name>
103
+ <duration>${durFrames}</duration>
104
+ ${rateXml(" ")}
105
+ <timecode>
106
+ ${rateXml(" ")}
107
+ <string>00:00:00:00</string>
108
+ <frame>0</frame>
109
+ <displayformat>${ntsc ? "DF" : "NDF"}</displayformat>
110
+ </timecode>
111
+ <media>
112
+ <video>
113
+ <format>
114
+ <samplecharacteristics>
115
+ ${rateXml(" ")}
116
+ <width>${probe.width}</width>
117
+ <height>${probe.height}</height>
118
+ <anamorphic>FALSE</anamorphic>
119
+ <pixelaspectratio>square</pixelaspectratio>
120
+ <fielddominance>none</fielddominance>
121
+ </samplecharacteristics>
122
+ </format>
123
+ <track>
124
+ <clipitem id="ci-1">
125
+ <name>${name}</name>
126
+ <duration>${durFrames}</duration>
127
+ ${rateXml(" ")}
128
+ <start>0</start>
129
+ <end>${durFrames}</end>
130
+ <in>0</in>
131
+ <out>${durFrames}</out>
132
+ <file id="f-1">
133
+ <name>${name}</name>
134
+ <pathurl>${esc(pathToFileURL(path).href)}</pathurl>
135
+ ${rateXml(" ")}
136
+ <duration>${durFrames}</duration>
137
+ <media>
138
+ <video>
139
+ <samplecharacteristics>
140
+ <width>${probe.width}</width>
141
+ <height>${probe.height}</height>
142
+ </samplecharacteristics>
143
+ </video>${probe.hasAudio ? "\n <audio>\n <channelcount>2</channelcount>\n </audio>" : ""}
144
+ </media>
145
+ </file>
146
+ ${clipMarkers ? `${clipMarkers}\n` : ""}${links(" ")}
147
+ </clipitem>
148
+ </track>
149
+ </video>${
150
+ probe.hasAudio
151
+ ? `
152
+ <audio>
153
+ <track>
154
+ <clipitem id="ci-2">
155
+ <name>${name}</name>
156
+ <duration>${durFrames}</duration>
157
+ ${rateXml(" ")}
158
+ <start>0</start>
159
+ <end>${durFrames}</end>
160
+ <in>0</in>
161
+ <out>${durFrames}</out>
162
+ <file id="f-1"/>
163
+ <sourcetrack>
164
+ <mediatype>audio</mediatype>
165
+ <trackindex>1</trackindex>
166
+ </sourcetrack>
167
+ ${links(" ")}
168
+ </clipitem>
169
+ </track>
170
+ </audio>`
171
+ : ""
172
+ }
173
+ </media>
174
+ ${markers ? `${markers}\n` : ""} </sequence>
175
+ </xmeml>
176
+ `;
177
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared xmeml plumbing (§142): the marker export (export-premiere-xml.ts)
3
+ * and the pre-cut project export (export-premiere-project.ts) both emit the
4
+ * legacy FCP7 dialect, and its rate mapping and escaping rules must not be
5
+ * allowed to drift apart — a "30 TRUE" that one exporter renders as "29.97"
6
+ * would import a sequence that slips a frame a minute.
7
+ */
8
+
9
+ /**
10
+ * xmeml has no rational time: a rate is an integer `timebase` plus an `ntsc`
11
+ * flag, and every position is a frame count at that timebase. 29.97 is
12
+ * "30 TRUE" — the flag, not the number, is where the 1001 lives.
13
+ */
14
+ export function xmemlRate(fps: number): { timebase: number; ntsc: boolean } {
15
+ const rounded = Math.round(fps);
16
+ // The NTSC family sits ~0.1% under its integer; a probe float lands close
17
+ // to the exact rational, never on the integer itself.
18
+ const ntsc = Math.abs(fps - rounded) > 0.001;
19
+ return { timebase: ntsc ? Math.ceil(fps) : rounded, ntsc };
20
+ }
21
+
22
+ /** The `<rate>` block both exporters emit, at the caller's indentation. */
23
+ export function xmemlRateXml(indent: string, timebase: number, ntsc: boolean): string {
24
+ return (
25
+ `${indent}<rate>\n` +
26
+ `${indent} <timebase>${timebase}</timebase>\n` +
27
+ `${indent} <ntsc>${ntsc ? "TRUE" : "FALSE"}</ntsc>\n` +
28
+ `${indent}</rate>`
29
+ );
30
+ }
31
+
32
+ export function esc(s: string): string {
33
+ return s
34
+ .replaceAll("&", "&amp;")
35
+ .replaceAll("<", "&lt;")
36
+ .replaceAll(">", "&gt;")
37
+ .replaceAll('"', "&quot;")
38
+ .replaceAll("'", "&apos;");
39
+ }
package/src/index.ts CHANGED
@@ -27,6 +27,11 @@ export * from "./face";
27
27
  export * from "./cover";
28
28
  export * from "./source-text";
29
29
  export * from "./report";
30
+ export * from "./export-markers";
30
31
  export * from "./export-fcpxml";
32
+ export * from "./export-edl";
33
+ export * from "./export-premiere-xml";
34
+ export * from "./export-premiere-project";
35
+ export * from "./export-xmeml-util";
31
36
  export * from "./config";
32
37
  export { run } from "./exec";
@@ -2,7 +2,7 @@ import { z } from "zod/v4";
2
2
  import type { LlmProvider } from "./provider";
3
3
  import { estimateTokens, type LlmUsage } from "./usage";
4
4
 
5
- export const DEFAULT_GEMINI_MODEL = "gemini-3.6-flash";
5
+ export const DEFAULT_GEMINI_MODEL = "gemini-3.7-flash";
6
6
 
7
7
  /**
8
8
  * Convert a zod-derived JSON Schema into Gemini's `responseSchema` dialect
@@ -64,11 +64,11 @@ export const DEFAULT_FAST_MODEL: Partial<Record<ProviderName, string>> = {
64
64
  claude: "claude-haiku-4-5-20251001",
65
65
  "claude-cli": "claude-haiku-4-5-20251001",
66
66
  gemini: "gemini-3.5-flash-lite",
67
- // Substring-matches the `gemini-3.6-flash` pricing family, so mechanical
67
+ // Substring-matches the `gemini-3.7-flash` pricing family, so mechanical
68
68
  // calls price from the existing table with no changes; the editorial tier
69
69
  // (agy's own default, reported as "antigravity-default") matches nothing and
70
70
  // reports "cost unknown" — the house honesty rule, not an oversight.
71
- antigravity: "gemini-3.6-flash-low",
71
+ antigravity: "gemini-3.7-flash-low",
72
72
  };
73
73
 
74
74
  export interface TieringOptions {
@@ -69,6 +69,9 @@ export const DEFAULT_PRICING: Record<string, ModelPrice> = {
69
69
  haiku: { inputPerMTok: 1, outputPerMTok: 5 },
70
70
  "gemini-2.5-pro": { inputPerMTok: 1.25, outputPerMTok: 10 },
71
71
  "gemini-2.5-flash": { inputPerMTok: 0.3, outputPerMTok: 2.5 },
72
+ // Gemini 3.7 Flash: $0.75 in / $3.75 out per 1M tokens (active rate)
73
+ "gemini-3.7-flash": { inputPerMTok: 0.75, outputPerMTok: 3.75 },
74
+ "gemini-3.7-flash-lite": { inputPerMTok: 0.3, outputPerMTok: 2.5 },
72
75
  "gemini-3.6-flash": { inputPerMTok: 1.5, outputPerMTok: 7.5 },
73
76
  "gemini-3.5-flash-lite": { inputPerMTok: 0.3, outputPerMTok: 2.5 },
74
77
  };