@ossclip/core 0.1.19 → 0.1.21
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 +127 -0
- package/src/export-markers.ts +43 -0
- package/src/export-premiere-xml.ts +200 -0
- package/src/index.ts +4 -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
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { keptPauseLabel, keptPauses } from "./export-markers";
|
|
4
|
+
import type { Production, Segment } from "./schema";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* FCPXML 1.10 marker export — the file format half of `ossclip analyse`
|
|
8
|
+
* (next-directions §2; design doc 2026-08-12-analyse-fcpxml-export-design.md).
|
|
9
|
+
* Pure by the house split: Production in, XML string out; the CLI owns the
|
|
10
|
+
* file write. The document is a project whose single asset-clip is the
|
|
11
|
+
* ORIGINAL source with one named marker per planned cut — markers, not
|
|
12
|
+
* applied cuts, because an editor wants to REVIEW "dead air here / blooper
|
|
13
|
+
* here" before acting on it, and both Resolve and Premiere read this format.
|
|
14
|
+
*
|
|
15
|
+
* No marker colours, resolved deliberately: stock FCPXML `<marker>` carries
|
|
16
|
+
* no colour attribute, and colour does not reliably survive a Resolve round
|
|
17
|
+
* trip — so the cut REASON travels in the marker name (the report.txt
|
|
18
|
+
* vocabulary), which every importer displays.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A frame's duration as an exact rational — FCPXML's own time currency. */
|
|
22
|
+
export interface FrameDuration {
|
|
23
|
+
num: number;
|
|
24
|
+
den: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* NTSC rates have no integer-denominator form — 29.97 is exactly 30000/1001,
|
|
29
|
+
* and ffprobe reports it as a float that equals neither 29.97 nor the
|
|
30
|
+
* rational. Matched by tolerance for that reason; anything else is treated
|
|
31
|
+
* as the integer rate it rounds to.
|
|
32
|
+
*/
|
|
33
|
+
const NTSC_RATES: Array<{ fps: number; fd: FrameDuration }> = [
|
|
34
|
+
{ fps: 24000 / 1001, fd: { num: 1001, den: 24000 } },
|
|
35
|
+
{ fps: 30000 / 1001, fd: { num: 1001, den: 30000 } },
|
|
36
|
+
{ fps: 60000 / 1001, fd: { num: 1001, den: 60000 } },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export function fpsToFrameDuration(fps: number): FrameDuration {
|
|
40
|
+
for (const { fps: ntsc, fd } of NTSC_RATES) {
|
|
41
|
+
if (Math.abs(fps - ntsc) < 0.01) return fd;
|
|
42
|
+
}
|
|
43
|
+
return { num: 1, den: Math.round(fps) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A seconds value as a frame-aligned FCPXML rational (`"53/30s"`). A float
|
|
48
|
+
* like `1.77s` is legal FCPXML but frame-misaligned, and importers round it
|
|
49
|
+
* silently — quantizing here keeps the marker on the frame the report named,
|
|
50
|
+
* with the rounding visible in this codebase instead of implicit in theirs.
|
|
51
|
+
*/
|
|
52
|
+
export function quantizeToFrame(seconds: number, fd: FrameDuration): string {
|
|
53
|
+
const frames = Math.round((seconds * fd.den) / fd.num);
|
|
54
|
+
return `${frames * fd.num}/${fd.den}s`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function esc(s: string): string {
|
|
58
|
+
return s
|
|
59
|
+
.replaceAll("&", "&")
|
|
60
|
+
.replaceAll("<", "<")
|
|
61
|
+
.replaceAll(">", ">")
|
|
62
|
+
.replaceAll('"', """)
|
|
63
|
+
.replaceAll("'", "'");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The report.txt line, minus the timestamps the marker position already is. */
|
|
67
|
+
function markerName(seg: Segment): string {
|
|
68
|
+
const dur = (seg.srcOut - seg.srcIn).toFixed(2);
|
|
69
|
+
const conf = seg.confidence !== undefined ? ` (conf ${seg.confidence.toFixed(2)})` : "";
|
|
70
|
+
return `${seg.reason ?? "cut"} −${dur}s${conf}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildFcpxmlMarkers(production: Production): string {
|
|
74
|
+
const { path, probe } = production.source;
|
|
75
|
+
const fd = fpsToFrameDuration(probe.fps);
|
|
76
|
+
const dur = quantizeToFrame(probe.duration, fd);
|
|
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
|
+
};
|
|
88
|
+
const removals = (production.cutlist ?? []).filter((s) => s.kind === "remove");
|
|
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
|
|
94
|
+
.map(
|
|
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)}"/>`,
|
|
98
|
+
)
|
|
99
|
+
.join("\n");
|
|
100
|
+
// pathToFileURL, not string concat: it percent-encodes the characters a
|
|
101
|
+
// URL cannot carry (spaces, #) while the XML escaping below handles the
|
|
102
|
+
// ones an ATTRIBUTE cannot (&) — two encodings, two owners.
|
|
103
|
+
const srcUrl = esc(pathToFileURL(path).href);
|
|
104
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
105
|
+
<!DOCTYPE fcpxml>
|
|
106
|
+
<fcpxml version="1.10">
|
|
107
|
+
<resources>
|
|
108
|
+
<format id="r1" frameDuration="${fd.num}/${fd.den}s" width="${probe.width}" height="${probe.height}"/>
|
|
109
|
+
<asset id="r2" name="${esc(name)}" start="0/${fd.den}s" duration="${dur}" hasVideo="1" hasAudio="${probe.hasAudio ? 1 : 0}" format="r1">
|
|
110
|
+
<media-rep kind="original-media" src="${srcUrl}"/>
|
|
111
|
+
</asset>
|
|
112
|
+
</resources>
|
|
113
|
+
<library>
|
|
114
|
+
<event name="ossclip">
|
|
115
|
+
<project name="${esc(`${name} — ossclip markers`)}">
|
|
116
|
+
<sequence format="r1" duration="${dur}" tcStart="0/${fd.den}s">
|
|
117
|
+
<spine>
|
|
118
|
+
<asset-clip ref="r2" offset="0/${fd.den}s" name="${esc(name)}" start="0/${fd.den}s" duration="${dur}" format="r1">
|
|
119
|
+
${markers ? `${markers}\n` : ""} </asset-clip>
|
|
120
|
+
</spine>
|
|
121
|
+
</sequence>
|
|
122
|
+
</project>
|
|
123
|
+
</event>
|
|
124
|
+
</library>
|
|
125
|
+
</fcpxml>
|
|
126
|
+
`;
|
|
127
|
+
}
|
|
@@ -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,200 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { keptPauseLabel, keptPauses } from "./export-markers";
|
|
4
|
+
import type { Production, Segment } from "./schema";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Premiere marker export — legacy FCP7 XML, "xmeml" (§142). The field test
|
|
8
|
+
* that shipped fcpxml killed its own premise within the hour: Premiere Pro
|
|
9
|
+
* does NOT import modern FCPXML ("File format not supported"); the format it
|
|
10
|
+
* lists as "Final Cut Pro XML" is the LEGACY xmeml interchange, a different
|
|
11
|
+
* document entirely. So: fcpxml serves actual Final Cut Pro, resolve-edl
|
|
12
|
+
* serves Resolve (whose fcpxml import drops markers), and THIS serves
|
|
13
|
+
* Premiere — an xmeml v4 sequence whose SEQUENCE-level markers carry the cut
|
|
14
|
+
* list onto the timeline ruler after File → Import.
|
|
15
|
+
*
|
|
16
|
+
* Same house split as the other two exporters: Production in, string out.
|
|
17
|
+
*/
|
|
18
|
+
|
|
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
|
+
/** ASCII hyphen like the EDL label — xmeml consumers are as old as it is. */
|
|
42
|
+
function label(seg: Segment): string {
|
|
43
|
+
const dur = (seg.srcOut - seg.srcIn).toFixed(2);
|
|
44
|
+
const conf = seg.confidence !== undefined ? ` (conf ${seg.confidence.toFixed(2)})` : "";
|
|
45
|
+
return `${seg.reason ?? "cut"} -${dur}s${conf}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildPremiereXmlMarkers(production: Production): string {
|
|
49
|
+
const { path, probe } = production.source;
|
|
50
|
+
const { timebase, ntsc } = xmemlRate(probe.fps);
|
|
51
|
+
const ntscStr = ntsc ? "TRUE" : "FALSE";
|
|
52
|
+
const toFrames = (sec: number) => Math.round(sec * probe.fps);
|
|
53
|
+
const durFrames = toFrames(probe.duration);
|
|
54
|
+
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>`;
|
|
57
|
+
// Emitted at BOTH levels, deliberately (field feedback, first real editor,
|
|
58
|
+
// §142). SEQUENCE markers sit at fixed timecode — the moment the editor
|
|
59
|
+
// ripple-deletes their first blooper, every downstream one points at the
|
|
60
|
+
// wrong moment, which is exactly why our field editor went back to manual.
|
|
61
|
+
// CLIP markers are anchored to the clip's MEDIA time, so they ride through
|
|
62
|
+
// razor cuts and ripple deletes and stay on the words they describe. The
|
|
63
|
+
// sequence copies stay too: they are the read-only overview of the
|
|
64
|
+
// untouched take, and the two agree while nothing is edited.
|
|
65
|
+
// SPANS, not points (§142 round 2). The original -1 (point marker) was a
|
|
66
|
+
// deliberate "a span overstates the suggestion" call — the first real
|
|
67
|
+
// editor reversed it: she needs the cut's END ("the video resumes from
|
|
68
|
+
// here") as much as its start, and a span's edges are both. Kept pauses
|
|
69
|
+
// (export-markers.ts) join the same time-ordered list.
|
|
70
|
+
const markerItems = [
|
|
71
|
+
...(production.cutlist ?? [])
|
|
72
|
+
.filter((s) => s.kind === "remove")
|
|
73
|
+
.map((s) => ({
|
|
74
|
+
start: s.srcIn,
|
|
75
|
+
end: s.srcOut,
|
|
76
|
+
label: label(s),
|
|
77
|
+
comment: "ossclip suggested cut — review before applying",
|
|
78
|
+
})),
|
|
79
|
+
...keptPauses(production).map((p) => ({
|
|
80
|
+
start: p.start,
|
|
81
|
+
end: p.end,
|
|
82
|
+
label: keptPauseLabel(p),
|
|
83
|
+
comment: "ossclip detected pause — kept, below the cut bar",
|
|
84
|
+
})),
|
|
85
|
+
].sort((a, b) => a.start - b.start);
|
|
86
|
+
const markerXml = (indent: string) =>
|
|
87
|
+
markerItems
|
|
88
|
+
.map(
|
|
89
|
+
(m) =>
|
|
90
|
+
`${indent}<marker>\n` +
|
|
91
|
+
`${indent} <name>${esc(m.label)}</name>\n` +
|
|
92
|
+
`${indent} <comment>${esc(m.comment)}</comment>\n` +
|
|
93
|
+
`${indent} <in>${toFrames(m.start)}</in>\n` +
|
|
94
|
+
// At least one frame: a zero-length span is as invisible as the
|
|
95
|
+
// point markers this replaced.
|
|
96
|
+
`${indent} <out>${Math.max(toFrames(m.start) + 1, toFrames(m.end))}</out>\n` +
|
|
97
|
+
`${indent}</marker>`,
|
|
98
|
+
)
|
|
99
|
+
.join("\n");
|
|
100
|
+
const markers = markerXml(" ");
|
|
101
|
+
const clipMarkers = markerXml(" ");
|
|
102
|
+
// Both clipitems carry both links (xmeml convention): this is what makes
|
|
103
|
+
// Premiere treat V1+A1 as ONE linked clip instead of two strangers —
|
|
104
|
+
// field fix 2: without an audio clipitem at all, the sequence imported
|
|
105
|
+
// silent and the editor had to add and link the audio by hand.
|
|
106
|
+
const links = (indent: string) =>
|
|
107
|
+
!probe.hasAudio
|
|
108
|
+
? ""
|
|
109
|
+
: `${indent}<link>\n` +
|
|
110
|
+
`${indent} <linkclipref>ci-1</linkclipref>\n` +
|
|
111
|
+
`${indent} <mediatype>video</mediatype>\n` +
|
|
112
|
+
`${indent} <trackindex>1</trackindex>\n` +
|
|
113
|
+
`${indent} <clipindex>1</clipindex>\n` +
|
|
114
|
+
`${indent}</link>\n` +
|
|
115
|
+
`${indent}<link>\n` +
|
|
116
|
+
`${indent} <linkclipref>ci-2</linkclipref>\n` +
|
|
117
|
+
`${indent} <mediatype>audio</mediatype>\n` +
|
|
118
|
+
`${indent} <trackindex>1</trackindex>\n` +
|
|
119
|
+
`${indent} <clipindex>1</clipindex>\n` +
|
|
120
|
+
`${indent}</link>`;
|
|
121
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
122
|
+
<!DOCTYPE xmeml>
|
|
123
|
+
<xmeml version="4">
|
|
124
|
+
<sequence id="ossclip-markers">
|
|
125
|
+
<name>${name} — ossclip markers</name>
|
|
126
|
+
<duration>${durFrames}</duration>
|
|
127
|
+
${rateXml(" ")}
|
|
128
|
+
<timecode>
|
|
129
|
+
${rateXml(" ")}
|
|
130
|
+
<string>00:00:00:00</string>
|
|
131
|
+
<frame>0</frame>
|
|
132
|
+
<displayformat>${ntsc ? "DF" : "NDF"}</displayformat>
|
|
133
|
+
</timecode>
|
|
134
|
+
<media>
|
|
135
|
+
<video>
|
|
136
|
+
<format>
|
|
137
|
+
<samplecharacteristics>
|
|
138
|
+
${rateXml(" ")}
|
|
139
|
+
<width>${probe.width}</width>
|
|
140
|
+
<height>${probe.height}</height>
|
|
141
|
+
<anamorphic>FALSE</anamorphic>
|
|
142
|
+
<pixelaspectratio>square</pixelaspectratio>
|
|
143
|
+
<fielddominance>none</fielddominance>
|
|
144
|
+
</samplecharacteristics>
|
|
145
|
+
</format>
|
|
146
|
+
<track>
|
|
147
|
+
<clipitem id="ci-1">
|
|
148
|
+
<name>${name}</name>
|
|
149
|
+
<duration>${durFrames}</duration>
|
|
150
|
+
${rateXml(" ")}
|
|
151
|
+
<start>0</start>
|
|
152
|
+
<end>${durFrames}</end>
|
|
153
|
+
<in>0</in>
|
|
154
|
+
<out>${durFrames}</out>
|
|
155
|
+
<file id="f-1">
|
|
156
|
+
<name>${name}</name>
|
|
157
|
+
<pathurl>${esc(pathToFileURL(path).href)}</pathurl>
|
|
158
|
+
${rateXml(" ")}
|
|
159
|
+
<duration>${durFrames}</duration>
|
|
160
|
+
<media>
|
|
161
|
+
<video>
|
|
162
|
+
<samplecharacteristics>
|
|
163
|
+
<width>${probe.width}</width>
|
|
164
|
+
<height>${probe.height}</height>
|
|
165
|
+
</samplecharacteristics>
|
|
166
|
+
</video>${probe.hasAudio ? "\n <audio>\n <channelcount>2</channelcount>\n </audio>" : ""}
|
|
167
|
+
</media>
|
|
168
|
+
</file>
|
|
169
|
+
${clipMarkers ? `${clipMarkers}\n` : ""}${links(" ")}
|
|
170
|
+
</clipitem>
|
|
171
|
+
</track>
|
|
172
|
+
</video>${
|
|
173
|
+
probe.hasAudio
|
|
174
|
+
? `
|
|
175
|
+
<audio>
|
|
176
|
+
<track>
|
|
177
|
+
<clipitem id="ci-2">
|
|
178
|
+
<name>${name}</name>
|
|
179
|
+
<duration>${durFrames}</duration>
|
|
180
|
+
${rateXml(" ")}
|
|
181
|
+
<start>0</start>
|
|
182
|
+
<end>${durFrames}</end>
|
|
183
|
+
<in>0</in>
|
|
184
|
+
<out>${durFrames}</out>
|
|
185
|
+
<file id="f-1"/>
|
|
186
|
+
<sourcetrack>
|
|
187
|
+
<mediatype>audio</mediatype>
|
|
188
|
+
<trackindex>1</trackindex>
|
|
189
|
+
</sourcetrack>
|
|
190
|
+
${links(" ")}
|
|
191
|
+
</clipitem>
|
|
192
|
+
</track>
|
|
193
|
+
</audio>`
|
|
194
|
+
: ""
|
|
195
|
+
}
|
|
196
|
+
</media>
|
|
197
|
+
${markers ? `${markers}\n` : ""} </sequence>
|
|
198
|
+
</xmeml>
|
|
199
|
+
`;
|
|
200
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -27,5 +27,9 @@ export * from "./face";
|
|
|
27
27
|
export * from "./cover";
|
|
28
28
|
export * from "./source-text";
|
|
29
29
|
export * from "./report";
|
|
30
|
+
export * from "./export-markers";
|
|
31
|
+
export * from "./export-fcpxml";
|
|
32
|
+
export * from "./export-edl";
|
|
33
|
+
export * from "./export-premiere-xml";
|
|
30
34
|
export * from "./config";
|
|
31
35
|
export { run } from "./exec";
|