@ossclip/core 0.1.19 → 0.1.20
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-fcpxml.ts +112 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import type { Production, Segment } from "./schema";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* FCPXML 1.10 marker export — the file format half of `ossclip analyse`
|
|
7
|
+
* (next-directions §2; design doc 2026-08-12-analyse-fcpxml-export-design.md).
|
|
8
|
+
* Pure by the house split: Production in, XML string out; the CLI owns the
|
|
9
|
+
* file write. The document is a project whose single asset-clip is the
|
|
10
|
+
* ORIGINAL source with one named marker per planned cut — markers, not
|
|
11
|
+
* applied cuts, because an editor wants to REVIEW "dead air here / blooper
|
|
12
|
+
* here" before acting on it, and both Resolve and Premiere read this format.
|
|
13
|
+
*
|
|
14
|
+
* No marker colours, resolved deliberately: stock FCPXML `<marker>` carries
|
|
15
|
+
* no colour attribute, and colour does not reliably survive a Resolve round
|
|
16
|
+
* trip — so the cut REASON travels in the marker name (the report.txt
|
|
17
|
+
* vocabulary), which every importer displays.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A frame's duration as an exact rational — FCPXML's own time currency. */
|
|
21
|
+
export interface FrameDuration {
|
|
22
|
+
num: number;
|
|
23
|
+
den: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* NTSC rates have no integer-denominator form — 29.97 is exactly 30000/1001,
|
|
28
|
+
* and ffprobe reports it as a float that equals neither 29.97 nor the
|
|
29
|
+
* rational. Matched by tolerance for that reason; anything else is treated
|
|
30
|
+
* as the integer rate it rounds to.
|
|
31
|
+
*/
|
|
32
|
+
const NTSC_RATES: Array<{ fps: number; fd: FrameDuration }> = [
|
|
33
|
+
{ fps: 24000 / 1001, fd: { num: 1001, den: 24000 } },
|
|
34
|
+
{ fps: 30000 / 1001, fd: { num: 1001, den: 30000 } },
|
|
35
|
+
{ fps: 60000 / 1001, fd: { num: 1001, den: 60000 } },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export function fpsToFrameDuration(fps: number): FrameDuration {
|
|
39
|
+
for (const { fps: ntsc, fd } of NTSC_RATES) {
|
|
40
|
+
if (Math.abs(fps - ntsc) < 0.01) return fd;
|
|
41
|
+
}
|
|
42
|
+
return { num: 1, den: Math.round(fps) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A seconds value as a frame-aligned FCPXML rational (`"53/30s"`). A float
|
|
47
|
+
* like `1.77s` is legal FCPXML but frame-misaligned, and importers round it
|
|
48
|
+
* silently — quantizing here keeps the marker on the frame the report named,
|
|
49
|
+
* with the rounding visible in this codebase instead of implicit in theirs.
|
|
50
|
+
*/
|
|
51
|
+
export function quantizeToFrame(seconds: number, fd: FrameDuration): string {
|
|
52
|
+
const frames = Math.round((seconds * fd.den) / fd.num);
|
|
53
|
+
return `${frames * fd.num}/${fd.den}s`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function esc(s: string): string {
|
|
57
|
+
return s
|
|
58
|
+
.replaceAll("&", "&")
|
|
59
|
+
.replaceAll("<", "<")
|
|
60
|
+
.replaceAll(">", ">")
|
|
61
|
+
.replaceAll('"', """)
|
|
62
|
+
.replaceAll("'", "'");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The report.txt line, minus the timestamps the marker position already is. */
|
|
66
|
+
function markerName(seg: Segment): string {
|
|
67
|
+
const dur = (seg.srcOut - seg.srcIn).toFixed(2);
|
|
68
|
+
const conf = seg.confidence !== undefined ? ` (conf ${seg.confidence.toFixed(2)})` : "";
|
|
69
|
+
return `${seg.reason ?? "cut"} −${dur}s${conf}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildFcpxmlMarkers(production: Production): string {
|
|
73
|
+
const { path, probe } = production.source;
|
|
74
|
+
const fd = fpsToFrameDuration(probe.fps);
|
|
75
|
+
const dur = quantizeToFrame(probe.duration, fd);
|
|
76
|
+
const name = basename(path);
|
|
77
|
+
const removals = (production.cutlist ?? []).filter((s) => s.kind === "remove");
|
|
78
|
+
const markers = removals
|
|
79
|
+
.map(
|
|
80
|
+
(s) =>
|
|
81
|
+
` <marker start="${quantizeToFrame(s.srcIn, fd)}" ` +
|
|
82
|
+
`duration="${fd.num}/${fd.den}s" value="${esc(markerName(s))}"/>`,
|
|
83
|
+
)
|
|
84
|
+
.join("\n");
|
|
85
|
+
// pathToFileURL, not string concat: it percent-encodes the characters a
|
|
86
|
+
// URL cannot carry (spaces, #) while the XML escaping below handles the
|
|
87
|
+
// ones an ATTRIBUTE cannot (&) — two encodings, two owners.
|
|
88
|
+
const srcUrl = esc(pathToFileURL(path).href);
|
|
89
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
90
|
+
<!DOCTYPE fcpxml>
|
|
91
|
+
<fcpxml version="1.10">
|
|
92
|
+
<resources>
|
|
93
|
+
<format id="r1" frameDuration="${fd.num}/${fd.den}s" width="${probe.width}" height="${probe.height}"/>
|
|
94
|
+
<asset id="r2" name="${esc(name)}" start="0/${fd.den}s" duration="${dur}" hasVideo="1" hasAudio="${probe.hasAudio ? 1 : 0}" format="r1">
|
|
95
|
+
<media-rep kind="original-media" src="${srcUrl}"/>
|
|
96
|
+
</asset>
|
|
97
|
+
</resources>
|
|
98
|
+
<library>
|
|
99
|
+
<event name="ossclip">
|
|
100
|
+
<project name="${esc(`${name} — ossclip markers`)}">
|
|
101
|
+
<sequence format="r1" duration="${dur}" tcStart="0/${fd.den}s">
|
|
102
|
+
<spine>
|
|
103
|
+
<asset-clip ref="r2" offset="0/${fd.den}s" name="${esc(name)}" start="0/${fd.den}s" duration="${dur}" format="r1">
|
|
104
|
+
${markers ? `${markers}\n` : ""} </asset-clip>
|
|
105
|
+
</spine>
|
|
106
|
+
</sequence>
|
|
107
|
+
</project>
|
|
108
|
+
</event>
|
|
109
|
+
</library>
|
|
110
|
+
</fcpxml>
|
|
111
|
+
`;
|
|
112
|
+
}
|