@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 +1 -1
- package/src/export-edl.ts +99 -0
- package/src/export-fcpxml.ts +19 -4
- package/src/export-markers.ts +43 -0
- package/src/export-premiere-project.ts +406 -0
- package/src/export-premiere-xml.ts +177 -0
- package/src/export-xmeml-util.ts +39 -0
- package/src/index.ts +5 -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,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
|
+
}
|
package/src/export-fcpxml.ts
CHANGED
|
@@ -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
|
|
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
|
-
(
|
|
81
|
-
` <marker start="${quantizeToFrame(
|
|
82
|
-
`duration="${fd.num}/${fd.den}s" value="${esc(
|
|
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("&", "&")
|
|
35
|
+
.replaceAll("<", "<")
|
|
36
|
+
.replaceAll(">", ">")
|
|
37
|
+
.replaceAll('"', """)
|
|
38
|
+
.replaceAll("'", "'");
|
|
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";
|
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
|
};
|