@ossclip/core 0.1.21 → 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 +1 -1
- package/src/export-premiere-project.ts +406 -0
- package/src/export-premiere-xml.ts +2 -25
- package/src/export-xmeml-util.ts +39 -0
- package/src/index.ts +2 -0
- package/src/producer/gemini.ts +1 -1
- package/src/producer/index.ts +2 -2
- package/src/producer/usage.ts +3 -0
package/package.json
CHANGED
|
@@ -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("&", "&")
|
|
35
|
-
.replaceAll("<", "<")
|
|
36
|
-
.replaceAll(">", ">")
|
|
37
|
-
.replaceAll('"', """)
|
|
38
|
-
.replaceAll("'", "'");
|
|
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("&", "&")
|
|
35
|
+
.replaceAll("<", "<")
|
|
36
|
+
.replaceAll(">", ">")
|
|
37
|
+
.replaceAll('"', """)
|
|
38
|
+
.replaceAll("'", "'");
|
|
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";
|
package/src/producer/gemini.ts
CHANGED
|
@@ -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.
|
|
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
|
package/src/producer/index.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
71
|
+
antigravity: "gemini-3.7-flash-low",
|
|
72
72
|
};
|
|
73
73
|
|
|
74
74
|
export interface TieringOptions {
|
package/src/producer/usage.ts
CHANGED
|
@@ -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
|
};
|