ossclip 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/README.md +3 -0
- package/package.json +4 -4
- package/src/analyze.ts +138 -0
- package/src/phase-timing.ts +140 -0
- package/src/produce.ts +84 -52
- package/src/program.ts +88 -0
package/README.md
CHANGED
|
@@ -27,6 +27,9 @@ ossclip produce input.mp4 -o out.mp4
|
|
|
27
27
|
# Long-form in, one short out: the strongest ~60s window, chosen by the producer
|
|
28
28
|
ossclip produce podcast.mp4 --produce --clip 60 -o clip.mp4
|
|
29
29
|
|
|
30
|
+
# Keep your own editor: export the planned cuts as labelled markers, no render, no LLM
|
|
31
|
+
ossclip analyze input.mp4 --format premiere-xml # Premiere Pro; resolve-edl for Resolve, fcpxml for FCP
|
|
32
|
+
|
|
30
33
|
# Edit what it produced — direct manipulation, in the browser
|
|
31
34
|
ossclip edit "<work directory>"
|
|
32
35
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ossclip",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"description": "Local-first CLI video producer: cuts silence and fillers, word-timed captions, face-aware framing, and LLM-planned code-rendered graphics — transcription and rendering never leave your machine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"commander": "^12.1.0",
|
|
37
37
|
"tsx": "^4.19.0",
|
|
38
38
|
"zod": "^3.25.76",
|
|
39
|
-
"@ossclip/
|
|
40
|
-
"@ossclip/
|
|
41
|
-
"@ossclip/scenes": "0.1.
|
|
39
|
+
"@ossclip/renderer": "0.1.21",
|
|
40
|
+
"@ossclip/core": "0.1.21",
|
|
41
|
+
"@ossclip/scenes": "0.1.21"
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://github.com/AhsanAyaz/ossclip#readme",
|
|
44
44
|
"bugs": {
|
package/src/analyze.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { z } from "zod/v4";
|
|
4
|
+
import {
|
|
5
|
+
ProductionSchema,
|
|
6
|
+
buildFcpxmlMarkers,
|
|
7
|
+
buildPremiereXmlMarkers,
|
|
8
|
+
buildResolveMarkerEdl,
|
|
9
|
+
keptPauses,
|
|
10
|
+
type CleanupLevel,
|
|
11
|
+
} from "@ossclip/core";
|
|
12
|
+
import { produce } from "./produce";
|
|
13
|
+
import type { PhaseTimings } from "./phase-timing";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `ossclip analyze` (next-directions §2–3; design doc
|
|
17
|
+
* 2026-08-12-analyse-fcpxml-export-design.md): the analyzer without the
|
|
18
|
+
* renderer. §140 measured the render at 85% of wall time on two machines —
|
|
19
|
+
* this command is the product of skipping it: the same pipeline up to the
|
|
20
|
+
* cut report (no LLM, no Remotion), plus an export file an editor's own NLE
|
|
21
|
+
* can review. Markers, not applied cuts, by design — see the exporter.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* zod enum, not a string: a typo'd `--format fcpxmll` must error naming the
|
|
26
|
+
* choice, never fall back to a default that silently writes the wrong file
|
|
27
|
+
* (CLAUDE.md's `--source-fit containn` rule, verbatim).
|
|
28
|
+
*
|
|
29
|
+
* `resolve-edl`, not `edl` (§142): it is Resolve's marker-EDL dialect —
|
|
30
|
+
* markers with colours, for `Timeline → Import → Timeline Markers from EDL`
|
|
31
|
+
* — not a cut EDL. A future cut EDL gets its own name; "edl" meaning either
|
|
32
|
+
* would be a permanent ambiguity. It exists because Resolve's FCPXML import
|
|
33
|
+
* silently drops clip markers (field-verified the day fcpxml shipped);
|
|
34
|
+
* Premiere reads the fcpxml markers fine.
|
|
35
|
+
*/
|
|
36
|
+
export const ExportFormatSchema = z.enum(["fcpxml", "resolve-edl", "premiere-xml"]);
|
|
37
|
+
export type ExportFormat = z.infer<typeof ExportFormatSchema>;
|
|
38
|
+
|
|
39
|
+
/** The FILE extension per format — "demo.resolve-edl" would import nowhere. */
|
|
40
|
+
const FORMAT_EXTENSIONS: Record<ExportFormat, string> = {
|
|
41
|
+
fcpxml: "fcpxml",
|
|
42
|
+
"resolve-edl": "edl",
|
|
43
|
+
"premiere-xml": "xml",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Same shape as produce's `defaultOutPath`: beside the input, new extension. */
|
|
47
|
+
export function defaultExportPath(input: string, format: ExportFormat): string {
|
|
48
|
+
return input.replace(/(\.[^.]+)?$/, `.${FORMAT_EXTENSIONS[format]}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AnalyzeOptions {
|
|
52
|
+
cleanup: CleanupLevel;
|
|
53
|
+
format: ExportFormat;
|
|
54
|
+
out?: string;
|
|
55
|
+
transcript?: string;
|
|
56
|
+
workdir?: string;
|
|
57
|
+
noiseDb?: number;
|
|
58
|
+
whisperModel?: string;
|
|
59
|
+
whisperLanguage?: string;
|
|
60
|
+
blooperMarker?: string;
|
|
61
|
+
collapseRetakes?: boolean;
|
|
62
|
+
sort?: "name" | "mtime";
|
|
63
|
+
sortExplicit?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface AnalyzeResult {
|
|
67
|
+
workdir: string;
|
|
68
|
+
outPath: string;
|
|
69
|
+
/** Suggested-cut markers — one per remove segment. */
|
|
70
|
+
markerCount: number;
|
|
71
|
+
/** Informational kept-pause markers (§142 round 2) — counted separately
|
|
72
|
+
* so the CLI and telemetry can say which is which. */
|
|
73
|
+
pauseCount: number;
|
|
74
|
+
sourceDurationSec: number;
|
|
75
|
+
phaseTimings: PhaseTimings;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The I/O glue: run the existing no-render pipeline, read back the
|
|
80
|
+
* `production.json` it wrote, hand it to the pure exporter, write the file.
|
|
81
|
+
* The read-back goes through `ProductionSchema.parse` even though this very
|
|
82
|
+
* run just wrote it — the file is user-visible and hand-editable, and a
|
|
83
|
+
* truncated or tweaked one must error here, not export garbage markers.
|
|
84
|
+
*/
|
|
85
|
+
export async function runAnalyze(
|
|
86
|
+
inputArg: string,
|
|
87
|
+
opts: AnalyzeOptions,
|
|
88
|
+
): Promise<AnalyzeResult> {
|
|
89
|
+
const result = await produce(inputArg, {
|
|
90
|
+
cleanup: opts.cleanup,
|
|
91
|
+
transcript: opts.transcript,
|
|
92
|
+
render: false,
|
|
93
|
+
mezzanine: false,
|
|
94
|
+
workdir: opts.workdir,
|
|
95
|
+
noiseDb: opts.noiseDb,
|
|
96
|
+
whisperModel: opts.whisperModel,
|
|
97
|
+
whisperLanguage: opts.whisperLanguage,
|
|
98
|
+
blooperMarker: opts.blooperMarker,
|
|
99
|
+
collapseRetakes: opts.collapseRetakes,
|
|
100
|
+
sort: opts.sort,
|
|
101
|
+
sortExplicit: opts.sortExplicit,
|
|
102
|
+
cover: false,
|
|
103
|
+
});
|
|
104
|
+
const production = ProductionSchema.parse(
|
|
105
|
+
JSON.parse(await readFile(join(result.workdir, "production.json"), "utf8")),
|
|
106
|
+
);
|
|
107
|
+
// §142, learned the hard way in one field-test hour: each NLE gets its OWN
|
|
108
|
+
// dialect. Premiere rejects modern fcpxml outright, Resolve's fcpxml import
|
|
109
|
+
// silently drops markers — so fcpxml is for actual Final Cut Pro.
|
|
110
|
+
const content =
|
|
111
|
+
opts.format === "resolve-edl"
|
|
112
|
+
? buildResolveMarkerEdl(production)
|
|
113
|
+
: opts.format === "premiere-xml"
|
|
114
|
+
? buildPremiereXmlMarkers(production)
|
|
115
|
+
: buildFcpxmlMarkers(production);
|
|
116
|
+
const markerCount = (production.cutlist ?? []).filter((s) => s.kind === "remove").length;
|
|
117
|
+
const pauseCount = keptPauses(production).length;
|
|
118
|
+
const outPath = resolve(opts.out ?? defaultExportPath(resolve(inputArg), opts.format));
|
|
119
|
+
await writeFile(outPath, content);
|
|
120
|
+
const detail =
|
|
121
|
+
opts.format === "resolve-edl"
|
|
122
|
+
? "in Resolve: Media Pool → right-click your timeline → Timelines → Import → Timeline Markers from EDL"
|
|
123
|
+
: opts.format === "premiere-xml"
|
|
124
|
+
? "in Premiere: File → Import, pick the .xml, relink media if offline"
|
|
125
|
+
: "for Final Cut Pro; Premiere needs --format premiere-xml, Resolve --format resolve-edl";
|
|
126
|
+
console.log(
|
|
127
|
+
`✓ ${opts.format} → ${outPath} (${markerCount} cut marker${markerCount === 1 ? "" : "s"}` +
|
|
128
|
+
`${pauseCount > 0 ? ` + ${pauseCount} kept-pause marker${pauseCount === 1 ? "" : "s"}` : ""} — ${detail})`,
|
|
129
|
+
);
|
|
130
|
+
return {
|
|
131
|
+
workdir: result.workdir,
|
|
132
|
+
outPath,
|
|
133
|
+
markerCount,
|
|
134
|
+
pauseCount,
|
|
135
|
+
sourceDurationSec: result.sourceDurationSec,
|
|
136
|
+
phaseTimings: result.phaseTimings,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-phase timing for `produce` (FINDINGS §140). "3 hours" was
|
|
3
|
+
* unattributed: `produce_completed` carried one `duration_ms` for the whole
|
|
4
|
+
* run, so nothing could say WHICH of the four candidate phases — whisper,
|
|
5
|
+
* LLM planning, the Remotion render, ffmpeg concat/normalize — the time went
|
|
6
|
+
* to, and each has a completely different fix if it dominates. Everything in
|
|
7
|
+
* this file is pure (clock injected, no I/O), per the house split; produce()
|
|
8
|
+
* owns the wrapping and program.ts owns the telemetry event.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The four attributed phases, exactly the candidates from the §140 table.
|
|
13
|
+
* Everything ELSE produce does (audio extraction, silence/level analysis,
|
|
14
|
+
* content-rect and face sampling, the mezzanine) lands in the log line's
|
|
15
|
+
* `other` remainder rather than a fifth phase — the remainder is PRINTED, so
|
|
16
|
+
* if it ever dominates a real run it accuses itself and earns a phase then.
|
|
17
|
+
*/
|
|
18
|
+
export type ProducePhase = "transcribe" | "llm" | "render" | "ffmpeg";
|
|
19
|
+
|
|
20
|
+
/** Milliseconds per phase; a phase that never ran is ABSENT, never 0. */
|
|
21
|
+
export type PhaseTimings = Partial<Record<ProducePhase, number>>;
|
|
22
|
+
|
|
23
|
+
/** Pipeline order — the log line reads like the run did. */
|
|
24
|
+
const PHASE_ORDER: ProducePhase[] = ["transcribe", "llm", "render", "ffmpeg"];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The log labels name the TOOL, not the internal phase id, because the §140
|
|
28
|
+
* table (and any hardware decision made from it) is about the tools:
|
|
29
|
+
* "whisper 12m" tells the user what to swap; "transcribe 12m" makes them ask.
|
|
30
|
+
*/
|
|
31
|
+
const PHASE_LABELS: Record<ProducePhase, string> = {
|
|
32
|
+
transcribe: "whisper",
|
|
33
|
+
llm: "llm",
|
|
34
|
+
render: "render",
|
|
35
|
+
ffmpeg: "ffmpeg",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export class PhaseTimer {
|
|
39
|
+
private readonly ms: PhaseTimings = {};
|
|
40
|
+
private readonly startedAt: number;
|
|
41
|
+
|
|
42
|
+
constructor(private readonly now: () => number = () => performance.now()) {
|
|
43
|
+
this.startedAt = this.now();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Accumulates — the llm phase is repair + window selection + scenes, and
|
|
48
|
+
* the ffmpeg phase is concat + loudnorm, so a phase is a SUM of calls, not
|
|
49
|
+
* one interval. Recorded in a `finally` so time spent is time recorded even
|
|
50
|
+
* when the phase throws: the throw aborts the run either way, but a future
|
|
51
|
+
* `produce_failed` that wants to say where the time went must not find the
|
|
52
|
+
* books cooked.
|
|
53
|
+
*/
|
|
54
|
+
async time<T>(phase: ProducePhase, fn: () => Promise<T>): Promise<T> {
|
|
55
|
+
const t0 = this.now();
|
|
56
|
+
try {
|
|
57
|
+
return await fn();
|
|
58
|
+
} finally {
|
|
59
|
+
this.ms[phase] = (this.ms[phase] ?? 0) + (this.now() - t0);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
timings(): PhaseTimings {
|
|
64
|
+
return { ...this.ms };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Wall clock since construction — the phases never sum to it; `other` is the gap. */
|
|
68
|
+
totalMs(): number {
|
|
69
|
+
return this.now() - this.startedAt;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Three scales, matched to what a human checks at each: one decimal under a
|
|
75
|
+
* minute (an 8.2s llm phase must not flatten to "8s" when comparing runs),
|
|
76
|
+
* zero-padded seconds under an hour ("1m03s", so it can't be misread as
|
|
77
|
+
* 1m30s), minutes only above it.
|
|
78
|
+
*/
|
|
79
|
+
export function formatPhaseDuration(ms: number): string {
|
|
80
|
+
const sec = ms / 1000;
|
|
81
|
+
if (sec < 60) return `${sec.toFixed(1)}s`;
|
|
82
|
+
if (sec < 3600) {
|
|
83
|
+
return `${Math.floor(sec / 60)}m${String(Math.floor(sec % 60)).padStart(2, "0")}s`;
|
|
84
|
+
}
|
|
85
|
+
return `${Math.floor(sec / 3600)}h${String(Math.floor((sec % 3600) / 60)).padStart(2, "0")}m`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The run log's one-line breakdown, in the ▸ voice, real seconds — this is
|
|
90
|
+
* the user's own machine, so unlike telemetry there is nothing to bucket.
|
|
91
|
+
* Absent phases are omitted (a cached transcript is not a 0.0s whisper run),
|
|
92
|
+
* the remainder is printed as `other` so unattributed time stays visible,
|
|
93
|
+
* and a run with no measured phases prints only the total — an `other` at
|
|
94
|
+
* 100% would just restate it. The remainder clamps at zero: the phases and
|
|
95
|
+
* the total read the clock at different instants, and a -0.0s from that skew
|
|
96
|
+
* would read as a bug in the very line meant to build trust in the numbers.
|
|
97
|
+
*/
|
|
98
|
+
export function formatPhaseLine(timings: PhaseTimings, totalMs: number): string {
|
|
99
|
+
const measured = PHASE_ORDER.filter((p) => timings[p] !== undefined);
|
|
100
|
+
const total = `▸ time: total ${formatPhaseDuration(totalMs)}`;
|
|
101
|
+
if (measured.length === 0) return total;
|
|
102
|
+
const parts = measured.map((p) => `${PHASE_LABELS[p]} ${formatPhaseDuration(timings[p]!)}`);
|
|
103
|
+
const other = totalMs - measured.reduce((sum, p) => sum + timings[p]!, 0);
|
|
104
|
+
if (other > 0) parts.push(`other ${formatPhaseDuration(other)}`);
|
|
105
|
+
return `${total} — ${parts.join(" · ")}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Same idea as telemetry.ts's `durationBucket` — the exact seconds never
|
|
110
|
+
* leave the machine — but with sub-minute resolution, because the question
|
|
111
|
+
* this answers ("which phase dominates?") has phases that legitimately live
|
|
112
|
+
* in seconds: an llm plan at 8s and a render at 40 minutes both being ">1m"
|
|
113
|
+
* would erase the very comparison §140 exists to make.
|
|
114
|
+
*/
|
|
115
|
+
export function phaseDurationBucket(
|
|
116
|
+
seconds: number,
|
|
117
|
+
): "<10s" | "10-60s" | "1-5m" | "5-15m" | ">15m" {
|
|
118
|
+
if (seconds < 10) return "<10s";
|
|
119
|
+
if (seconds <= 60) return "10-60s";
|
|
120
|
+
if (seconds <= 300) return "1-5m";
|
|
121
|
+
if (seconds <= 900) return "5-15m";
|
|
122
|
+
return ">15m";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The `produce_completed` props for the phases that ran: `<phase>_bucket`,
|
|
127
|
+
* bucketed, never raw milliseconds (§134 floor — a raw per-phase duration is
|
|
128
|
+
* even closer to fingerprinting a specific take than the total the floor
|
|
129
|
+
* already buckets). Keys are pinned against `assertSafeProps` in
|
|
130
|
+
* phase-timing.test.ts, since a spread into `telemetry.record` is invisible
|
|
131
|
+
* to telemetry.test.ts's source-text drift check.
|
|
132
|
+
*/
|
|
133
|
+
export function phaseBucketProps(timings: PhaseTimings): Record<string, string> {
|
|
134
|
+
const props: Record<string, string> = {};
|
|
135
|
+
for (const p of PHASE_ORDER) {
|
|
136
|
+
const ms = timings[p];
|
|
137
|
+
if (ms !== undefined) props[`${p}_bucket`] = phaseDurationBucket(ms / 1000);
|
|
138
|
+
}
|
|
139
|
+
return props;
|
|
140
|
+
}
|
package/src/produce.ts
CHANGED
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
} from "@ossclip/core";
|
|
99
99
|
import { recordRecentProject } from "./edit";
|
|
100
100
|
import { binOnPath, detectionLine } from "./llm-detect";
|
|
101
|
+
import { PhaseTimer, formatPhaseLine, type PhaseTimings } from "./phase-timing";
|
|
101
102
|
import {
|
|
102
103
|
strandedOverrideSiblings,
|
|
103
104
|
strandedPointerLine,
|
|
@@ -134,6 +135,13 @@ export interface ProduceResult {
|
|
|
134
135
|
sceneCount: number;
|
|
135
136
|
/** Resolved provider name when the LLM ran; undefined without --produce. */
|
|
136
137
|
llmProvider?: string;
|
|
138
|
+
/**
|
|
139
|
+
* Milliseconds per attributed phase (FINDINGS §140) — same contract as the
|
|
140
|
+
* fields above: produce() surfaces the raw numbers, the command layer
|
|
141
|
+
* buckets them before anything crosses the wire (`phaseBucketProps`). A
|
|
142
|
+
* phase that never ran (cached transcript, --no-render) is absent, not 0.
|
|
143
|
+
*/
|
|
144
|
+
phaseTimings: PhaseTimings;
|
|
137
145
|
}
|
|
138
146
|
|
|
139
147
|
/**
|
|
@@ -532,6 +540,10 @@ async function preflight(bin: string, hint: string): Promise<void> {
|
|
|
532
540
|
}
|
|
533
541
|
|
|
534
542
|
export async function produce(inputArg: string, opts: ProduceOptions): Promise<ProduceResult> {
|
|
543
|
+
// First line on purpose: totalMs is the same wall clock program.ts wraps
|
|
544
|
+
// around this call for duration_ms, so `other` in the printed breakdown is
|
|
545
|
+
// genuinely "everything this run did that isn't an attributed phase" (§140).
|
|
546
|
+
const phases = new PhaseTimer();
|
|
535
547
|
const cfg = loadConfig();
|
|
536
548
|
// `let`, not `const`: a folder input is reassigned to the concat
|
|
537
549
|
// intermediate below (folder-input-brief.md) so nothing past that point has
|
|
@@ -641,10 +653,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
641
653
|
|
|
642
654
|
if (isFolder && folderListing) {
|
|
643
655
|
const sort = opts.sort ?? "name";
|
|
644
|
-
const result = await
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
656
|
+
const result = await phases.time("ffmpeg", () =>
|
|
657
|
+
concatFolder(tools, input, folderListing!, work, sort, {
|
|
658
|
+
w: frame.width,
|
|
659
|
+
h: frame.height,
|
|
660
|
+
}),
|
|
661
|
+
);
|
|
648
662
|
console.log(
|
|
649
663
|
`▸ folder: ${result.clips.length} clip(s), sorted by ${sort}, ` +
|
|
650
664
|
`concat ${result.durationSec.toFixed(1)}s${result.cached ? " (cached)" : ""}`,
|
|
@@ -758,14 +772,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
758
772
|
);
|
|
759
773
|
}
|
|
760
774
|
console.log(`▸ transcribing (${basename(modelPath)})…`);
|
|
761
|
-
transcript = await
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
775
|
+
transcript = await phases.time("transcribe", () =>
|
|
776
|
+
runWhisper(
|
|
777
|
+
{
|
|
778
|
+
whisperPath: cfg.whisperPath,
|
|
779
|
+
modelPath,
|
|
780
|
+
outBase: join(work, "whisper"),
|
|
781
|
+
language: opts.whisperLanguage,
|
|
782
|
+
},
|
|
783
|
+
audioPath,
|
|
784
|
+
),
|
|
769
785
|
);
|
|
770
786
|
console.log(`▸ transcribed ${transcript.words.length} words`);
|
|
771
787
|
await writeFile(transcriptKeyPath, JSON.stringify(requestedKey, null, 2));
|
|
@@ -901,14 +917,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
901
917
|
).transcript;
|
|
902
918
|
console.log(`▸ repairs cached (${repairs.filter((r) => r.applied).length})`);
|
|
903
919
|
} else {
|
|
904
|
-
const result = await
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
(
|
|
910
|
-
|
|
911
|
-
|
|
920
|
+
const result = await phases.time("llm", () =>
|
|
921
|
+
repairTranscript(provider!, rawTranscript, {
|
|
922
|
+
speaker: opts.speaker ?? cfg.speaker,
|
|
923
|
+
// A repair may not merge words across a cut.
|
|
924
|
+
isCut: (startSec, endSec) =>
|
|
925
|
+
cutlist.some(
|
|
926
|
+
(s) => s.kind === "remove" && s.srcIn < endSec && s.srcOut > startSec,
|
|
927
|
+
),
|
|
928
|
+
}),
|
|
929
|
+
);
|
|
912
930
|
transcript = result.transcript;
|
|
913
931
|
repairs = result.applied;
|
|
914
932
|
if (result.error) {
|
|
@@ -1055,16 +1073,18 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1055
1073
|
console.log("▸ clip window cached");
|
|
1056
1074
|
} else {
|
|
1057
1075
|
console.log(`▸ selecting the strongest ~${clipTargetSec}s window (${providerName})…`);
|
|
1058
|
-
clipFresh = await
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1076
|
+
clipFresh = await phases.time("llm", () =>
|
|
1077
|
+
produceScenes(provider!, {
|
|
1078
|
+
transcript,
|
|
1079
|
+
outputDuration: clipTargetSec!,
|
|
1080
|
+
intent: opts.intent,
|
|
1081
|
+
speaker: opts.speaker ?? cfg.speaker,
|
|
1082
|
+
forceComponent: opts.forceComponent,
|
|
1083
|
+
framing: framingCtx,
|
|
1084
|
+
clip: { targetSec: clipTargetSec! },
|
|
1085
|
+
aspect: landscape ? "16:9" : "9:16",
|
|
1086
|
+
}),
|
|
1087
|
+
);
|
|
1068
1088
|
clipWindow = clipFresh.clip!.window;
|
|
1069
1089
|
for (const note of clipFresh.clip!.notes) console.log(` ▸ ${note}`);
|
|
1070
1090
|
await writeFile(clipWindowCache, JSON.stringify(clipWindow, null, 2));
|
|
@@ -1180,15 +1200,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1180
1200
|
} else {
|
|
1181
1201
|
console.log(`▸ producing scenes (${providerName})…`);
|
|
1182
1202
|
if (opts.forceComponent) console.log(`▸ forcing every graphic to ${opts.forceComponent}`);
|
|
1183
|
-
const result = await
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1203
|
+
const result = await phases.time("llm", () =>
|
|
1204
|
+
produceScenes(provider!, {
|
|
1205
|
+
transcript,
|
|
1206
|
+
outputDuration: map.outputDuration,
|
|
1207
|
+
intent: opts.intent,
|
|
1208
|
+
speaker: opts.speaker ?? cfg.speaker,
|
|
1209
|
+
forceComponent: opts.forceComponent,
|
|
1210
|
+
framing: framingCtx,
|
|
1211
|
+
aspect: landscape ? "16:9" : "9:16",
|
|
1212
|
+
}),
|
|
1213
|
+
);
|
|
1192
1214
|
scenes = result.scenes;
|
|
1193
1215
|
beatSheet = { hook: result.beatSheet.hook, coverText: result.beatSheet.coverText };
|
|
1194
1216
|
console.log(`▸ hook: ${result.beatSheet.hook}`);
|
|
@@ -2230,6 +2252,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2230
2252
|
}
|
|
2231
2253
|
|
|
2232
2254
|
if (!opts.render) {
|
|
2255
|
+
// §140: the breakdown goes above the closing lines on both exits, so the
|
|
2256
|
+
// last thing on screen stays the success line and the edit hint.
|
|
2257
|
+
console.log(formatPhaseLine(phases.timings(), phases.totalMs()));
|
|
2233
2258
|
console.log(`▸ skipping render (--no-render). Props at ${join(work, "render-props.json")}`);
|
|
2234
2259
|
console.log(editHint(work));
|
|
2235
2260
|
return {
|
|
@@ -2238,6 +2263,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2238
2263
|
sourceDurationSec: sourceProbe.duration,
|
|
2239
2264
|
sceneCount: scenes.length,
|
|
2240
2265
|
llmProvider: provider ? providerName : undefined,
|
|
2266
|
+
phaseTimings: phases.timings(),
|
|
2241
2267
|
};
|
|
2242
2268
|
}
|
|
2243
2269
|
|
|
@@ -2245,21 +2271,23 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2245
2271
|
const rawPath = join(work, "render-raw.mp4");
|
|
2246
2272
|
console.log("▸ rendering…");
|
|
2247
2273
|
let lastPct = -10;
|
|
2248
|
-
await
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
lastPct
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2274
|
+
await phases.time("render", () =>
|
|
2275
|
+
renderProduction(props, {
|
|
2276
|
+
publicDir: dirname(renderVideo),
|
|
2277
|
+
outPath: rawPath,
|
|
2278
|
+
browserExecutable: cfg.browserExecutable,
|
|
2279
|
+
onProgress: (p) => {
|
|
2280
|
+
const pct = Math.floor(p * 100);
|
|
2281
|
+
if (pct >= lastPct + 10) {
|
|
2282
|
+
lastPct = pct;
|
|
2283
|
+
process.stdout.write(` ${pct}%\n`);
|
|
2284
|
+
}
|
|
2285
|
+
},
|
|
2286
|
+
}),
|
|
2287
|
+
);
|
|
2260
2288
|
console.log("▸ normalizing loudness…");
|
|
2261
2289
|
const normPath = join(work, "render-norm.mp4");
|
|
2262
|
-
await loudnorm(tools, rawPath, normPath);
|
|
2290
|
+
await phases.time("ffmpeg", () => loudnorm(tools, rawPath, normPath));
|
|
2263
2291
|
await rename(normPath, outPath);
|
|
2264
2292
|
|
|
2265
2293
|
// ---- Cover image (FINDINGS §31) -----------------------------------------
|
|
@@ -2419,6 +2447,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2419
2447
|
// Every produce run is a project the picker should offer (R17 §83) —
|
|
2420
2448
|
// best-effort, so a read-only home dir never fails the render.
|
|
2421
2449
|
await recordRecentProject(work);
|
|
2450
|
+
// §140, same placement as the --no-render exit: breakdown, then the
|
|
2451
|
+
// success line and the hint keep the bottom of the screen.
|
|
2452
|
+
console.log(formatPhaseLine(phases.timings(), phases.totalMs()));
|
|
2422
2453
|
console.log(`✓ done → ${outPath}`);
|
|
2423
2454
|
console.log(editHint(work));
|
|
2424
2455
|
return {
|
|
@@ -2428,5 +2459,6 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2428
2459
|
sourceDurationSec: sourceProbe.duration,
|
|
2429
2460
|
sceneCount: scenes.length,
|
|
2430
2461
|
llmProvider: provider ? providerName : undefined,
|
|
2462
|
+
phaseTimings: phases.timings(),
|
|
2431
2463
|
};
|
|
2432
2464
|
}
|
package/src/program.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { z } from "zod/v4";
|
|
|
6
6
|
import { CleanupLevelSchema, SceneComponentIdSchema } from "@ossclip/core";
|
|
7
7
|
import { STUDIO_ENTRY } from "@ossclip/renderer";
|
|
8
8
|
import { loadEnvFiles } from "./env";
|
|
9
|
+
import { ExportFormatSchema, runAnalyze } from "./analyze";
|
|
10
|
+
import { phaseBucketProps } from "./phase-timing";
|
|
9
11
|
import { produce } from "./produce";
|
|
10
12
|
// The one interactive import that is STATIC rather than `await import()`: the
|
|
11
13
|
// `resetInputSource()` run boundary in `buildProgram` has to run synchronously
|
|
@@ -461,6 +463,11 @@ export function buildProgram(): Command {
|
|
|
461
463
|
render: opts.render !== false,
|
|
462
464
|
source_duration_bucket: durationBucket(result.sourceDurationSec),
|
|
463
465
|
scenes: result.sceneCount,
|
|
466
|
+
// Per-phase buckets (§140), one `<phase>_bucket` per phase that
|
|
467
|
+
// ran — bucketed in phaseBucketProps, never raw ms. Spread keys are
|
|
468
|
+
// invisible to telemetry.test.ts's source-text drift check, so the
|
|
469
|
+
// §134 pin for these lives in phase-timing.test.ts instead.
|
|
470
|
+
...phaseBucketProps(result.phaseTimings),
|
|
464
471
|
// Which branch of the input prompt was used — a branch name, never
|
|
465
472
|
// the path itself (§136). The picker exists because typing a path
|
|
466
473
|
// blocked non-technical users; this is how we find out if it helped.
|
|
@@ -528,6 +535,87 @@ export function buildProgram(): Command {
|
|
|
528
535
|
await telemetry.flush();
|
|
529
536
|
});
|
|
530
537
|
|
|
538
|
+
program
|
|
539
|
+
// American spelling is primary (house style, 2026-08-12); the British
|
|
540
|
+
// alias stays because a command someone has already learned must not
|
|
541
|
+
// become an "unknown command" over an s/z.
|
|
542
|
+
.command("analyze")
|
|
543
|
+
.alias("analyse")
|
|
544
|
+
.description(
|
|
545
|
+
"analyze a take and export the planned cuts as labelled NLE markers — no render, no LLM " +
|
|
546
|
+
"(FCPXML imports into Resolve and Premiere; review the markers, then cut in your own editor)",
|
|
547
|
+
)
|
|
548
|
+
.argument("<input>", "input video file (or a folder of clips)")
|
|
549
|
+
.option(
|
|
550
|
+
"--format <format>",
|
|
551
|
+
"export format: fcpxml (Premiere) | resolve-edl (Resolve coloured timeline markers — " +
|
|
552
|
+
"its fcpxml import drops markers)",
|
|
553
|
+
"fcpxml",
|
|
554
|
+
)
|
|
555
|
+
.option("--out <path>", "export file path (default: <input>.<format>)")
|
|
556
|
+
.option("--cleanup <level>", "exact | light | standard | aggressive", "standard")
|
|
557
|
+
.option("--transcript <path>", "inject a transcript JSON instead of running whisper")
|
|
558
|
+
.option("--noise-db <db>", "override the measured silence threshold, e.g. -30", parseFloat)
|
|
559
|
+
.option("--workdir <dir>", "cache/work directory")
|
|
560
|
+
.option("--whisper-model <name>", "transcription model for this run, e.g. base.en | small.en")
|
|
561
|
+
.option(
|
|
562
|
+
"--whisper-language <code>",
|
|
563
|
+
"transcription language code for a multilingual model, e.g. ur | de | auto (whisper defaults to en)",
|
|
564
|
+
)
|
|
565
|
+
.option(
|
|
566
|
+
"--blooper-marker <word>",
|
|
567
|
+
"mark the flubbed take wherever you say this word out loud (e.g. blooper). Off unless given",
|
|
568
|
+
)
|
|
569
|
+
.option(
|
|
570
|
+
"--collapse-retakes",
|
|
571
|
+
"also mark consecutive near-identical sentences, keeping only the last complete attempt",
|
|
572
|
+
false,
|
|
573
|
+
)
|
|
574
|
+
.option("--sort <order>", "folder input: clip order, name | mtime", "name")
|
|
575
|
+
.action(async (input: string, opts, command) => {
|
|
576
|
+
const cleanup = CleanupLevelSchema.parse(opts.cleanup);
|
|
577
|
+
// Same parse-don't-coerce guard as --source-fit: a typo'd format must
|
|
578
|
+
// error naming the flag, never silently export a different file.
|
|
579
|
+
const format = ExportFormatSchema.parse(opts.format);
|
|
580
|
+
try {
|
|
581
|
+
const result = await runAnalyze(input, {
|
|
582
|
+
cleanup,
|
|
583
|
+
format,
|
|
584
|
+
out: opts.out,
|
|
585
|
+
transcript: opts.transcript,
|
|
586
|
+
workdir: opts.workdir,
|
|
587
|
+
noiseDb: opts.noiseDb,
|
|
588
|
+
whisperModel: opts.whisperModel,
|
|
589
|
+
whisperLanguage:
|
|
590
|
+
opts.whisperLanguage !== undefined
|
|
591
|
+
? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
|
|
592
|
+
: undefined,
|
|
593
|
+
blooperMarker: opts.blooperMarker,
|
|
594
|
+
collapseRetakes: opts.collapseRetakes,
|
|
595
|
+
sort: opts.sort === "mtime" ? "mtime" : "name",
|
|
596
|
+
sortExplicit: command.getOptionValueSource("sort") === "cli",
|
|
597
|
+
});
|
|
598
|
+
// Counts, buckets and names only (§134) — the format is an enum name,
|
|
599
|
+
// never a path; per-phase buckets ride along like produce's.
|
|
600
|
+
telemetry.record("analyze_completed", {
|
|
601
|
+
format,
|
|
602
|
+
cleanup_level: cleanup,
|
|
603
|
+
source_duration_bucket: durationBucket(result.sourceDurationSec),
|
|
604
|
+
markers: result.markerCount,
|
|
605
|
+
kept_pauses: result.pauseCount,
|
|
606
|
+
...phaseBucketProps(result.phaseTimings),
|
|
607
|
+
});
|
|
608
|
+
} catch (err) {
|
|
609
|
+
// Constructor name only, like produce_failed: messages quote paths.
|
|
610
|
+
telemetry.record("analyze_failed", {
|
|
611
|
+
error_class: err instanceof Error ? err.constructor.name : "NonError",
|
|
612
|
+
});
|
|
613
|
+
throw err;
|
|
614
|
+
} finally {
|
|
615
|
+
await telemetry.flush();
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
|
|
531
619
|
program
|
|
532
620
|
.command("studio")
|
|
533
621
|
.description("open Remotion Studio on a produced composition (visual debugging)")
|