@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossclip/core",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "ossclip's framework-free pipeline: schema, transcription, analysis, cutlist, captions, framing, and the LLM producer",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,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("&", "&amp;")
59
+ .replaceAll("<", "&lt;")
60
+ .replaceAll(">", "&gt;")
61
+ .replaceAll('"', "&quot;")
62
+ .replaceAll("'", "&apos;");
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
+ }
package/src/index.ts CHANGED
@@ -27,5 +27,6 @@ export * from "./face";
27
27
  export * from "./cover";
28
28
  export * from "./source-text";
29
29
  export * from "./report";
30
+ export * from "./export-fcpxml";
30
31
  export * from "./config";
31
32
  export { run } from "./exec";