@ossclip/core 0.1.20 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossclip/core",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
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,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
+ }
@@ -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 markers = removals
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
- (s) =>
81
- ` <marker start="${quantizeToFrame(s.srcIn, fd)}" ` +
82
- `duration="${fd.num}/${fd.den}s" value="${esc(markerName(s))}"/>`,
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,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("&", "&amp;")
35
+ .replaceAll("<", "&lt;")
36
+ .replaceAll(">", "&gt;")
37
+ .replaceAll('"', "&quot;")
38
+ .replaceAll("'", "&apos;");
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,6 +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";
30
31
  export * from "./export-fcpxml";
32
+ export * from "./export-edl";
33
+ export * from "./export-premiere-xml";
31
34
  export * from "./config";
32
35
  export { run } from "./exec";