@ossclip/core 0.1.21 → 0.1.23

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.21",
3
+ "version": "0.1.23",
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,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
+ }
@@ -1,6 +1,7 @@
1
1
  import { basename } from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { keptPauseLabel, keptPauses } from "./export-markers";
4
+ import { esc, xmemlRate, xmemlRateXml } from "./export-xmeml-util";
4
5
  import type { Production, Segment } from "./schema";
5
6
 
6
7
  /**
@@ -16,28 +17,6 @@ import type { Production, Segment } from "./schema";
16
17
  * Same house split as the other two exporters: Production in, string out.
17
18
  */
18
19
 
19
- /**
20
- * xmeml has no rational time: a rate is an integer `timebase` plus an `ntsc`
21
- * flag, and every position is a frame count at that timebase. 29.97 is
22
- * "30 TRUE" — the flag, not the number, is where the 1001 lives.
23
- */
24
- export function xmemlRate(fps: number): { timebase: number; ntsc: boolean } {
25
- const rounded = Math.round(fps);
26
- // The NTSC family sits ~0.1% under its integer; a probe float lands close
27
- // to the exact rational, never on the integer itself.
28
- const ntsc = Math.abs(fps - rounded) > 0.001;
29
- return { timebase: ntsc ? Math.ceil(fps) : rounded, ntsc };
30
- }
31
-
32
- function esc(s: string): string {
33
- return s
34
- .replaceAll("&", "&amp;")
35
- .replaceAll("<", "&lt;")
36
- .replaceAll(">", "&gt;")
37
- .replaceAll('"', "&quot;")
38
- .replaceAll("'", "&apos;");
39
- }
40
-
41
20
  /** ASCII hyphen like the EDL label — xmeml consumers are as old as it is. */
42
21
  function label(seg: Segment): string {
43
22
  const dur = (seg.srcOut - seg.srcIn).toFixed(2);
@@ -48,12 +27,10 @@ function label(seg: Segment): string {
48
27
  export function buildPremiereXmlMarkers(production: Production): string {
49
28
  const { path, probe } = production.source;
50
29
  const { timebase, ntsc } = xmemlRate(probe.fps);
51
- const ntscStr = ntsc ? "TRUE" : "FALSE";
52
30
  const toFrames = (sec: number) => Math.round(sec * probe.fps);
53
31
  const durFrames = toFrames(probe.duration);
54
32
  const name = esc(basename(path));
55
- const rateXml = (indent: string) =>
56
- `${indent}<rate>\n${indent} <timebase>${timebase}</timebase>\n${indent} <ntsc>${ntscStr}</ntsc>\n${indent}</rate>`;
33
+ const rateXml = (indent: string) => xmemlRateXml(indent, timebase, ntsc);
57
34
  // Emitted at BOTH levels, deliberately (field feedback, first real editor,
58
35
  // §142). SEQUENCE markers sit at fixed timecode — the moment the editor
59
36
  // ripple-deletes their first blooper, every downstream one points at the
@@ -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
@@ -31,5 +31,7 @@ export * from "./export-markers";
31
31
  export * from "./export-fcpxml";
32
32
  export * from "./export-edl";
33
33
  export * from "./export-premiere-xml";
34
+ export * from "./export-premiere-project";
35
+ export * from "./export-xmeml-util";
34
36
  export * from "./config";
35
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
  };