Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ ty = (0.5 - vp.cy) × 100%
| `remotion/types/camera.ts` | `CameraProfiles`, `CameraShot`, `CropViewport` | Will import from `scripts/types/` (Phase 6) |
| `remotion/types/brand.ts` | Brand design tokens + extended identity/hosts/mascot/audio; `id: string` field required by registry | Move overlays + parameterize (Phase 0.5 Steps 3, 6–7) |
| `scripts/config/project.ts` | `ProjectFile` type, `readProject`/`writeProject`, `ProjectNotFoundError`; typed `PipelineParams` (`timestamp_offset`, `diarization_seed`, `num_speakers`, `sync_window_seconds`) | Sprint 1 Issues #1, #3 |
| `scripts/config/metadata.js` | `stampMetadata(artifact, cwd?)` — prepends `schema_version` + `tool_versions` to any JSON artifact; reads tool versions from `.ragtech/project.json` if present | Sprint 1 Issue #4 |
| `scripts/edit-transcript.js` | Sentence merging, `deriveCuts`, doc generation | Migrate to .ts (Phase 3) |
| `scripts/sync/AudioSyncer.js` | FFT sync, `syncMultiple` | Add FFT tie-breaking (Phase 0) |
| `scripts/wizard.js` | Interactive pipeline runner (60KB) | Replace with DAG runner (Phase 2) |
Expand Down
1 change: 1 addition & 0 deletions docs/REFACTOR_ISSUE_INVENTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Create the artifact storage system that writes outputs into `.ragtech/artifacts/
Move runtime parameters such as diarization seed, timestamp offset, and number of speakers out of CLI flags and into the project file so pipeline runs are reproducible.

### 4. Add deterministic metadata to generated artifacts
✅ Done — `refactor/s2-artifact-metadata` — `stampMetadata()` helper in `scripts/config/metadata.js`; stamps `schema_version` + `tool_versions` on `transcript.raw.json`, `diarization.json`, `transcript.json`
Ensure every JSON artifact includes schema version and tool version metadata so outputs can be traced to exact execution conditions.

---
Expand Down
3 changes: 2 additions & 1 deletion scripts/align/align-transcript.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
import { stampMetadata } from '../config/metadata.js';

const ALIGN_SCRIPT_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'run_whisperx_align.py');

Expand Down Expand Up @@ -272,7 +273,7 @@ async function main() {

const { updated, appliedSegments } = applyAlignment(raw, aligned);

await fs.writeJson(rawPath, updated, { spaces: 2 });
await fs.writeJson(rawPath, stampMetadata(updated), { spaces: 2 });

console.log(`\n ✓ Alignment applied to ${appliedSegments} segment(s).`);
console.log(` ✓ Updated transcript: ${rawPath}`);
Expand Down
31 changes: 31 additions & 0 deletions scripts/config/metadata.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import fs from 'fs';
import path from 'path';

export const ARTIFACT_SCHEMA_VERSION = '1';

function readProjectTools(cwd) {
const projectPath = path.join(cwd, '.ragtech', 'project.json');
try {
const raw = fs.readFileSync(projectPath, 'utf-8');
return JSON.parse(raw)?.tools ?? {};
} catch {
return {};
}
}

export function buildToolVersions(cwd = process.cwd()) {
const projectTools = readProjectTools(cwd);
return { node: process.version, ...projectTools };
}

/**
* Returns artifact with schema_version and tool_versions prepended.
* Additive — existing fields are preserved and take precedence over metadata keys.
*/
export function stampMetadata(artifact, cwd = process.cwd()) {
return {
schema_version: ARTIFACT_SCHEMA_VERSION,
tool_versions: buildToolVersions(cwd),
...artifact,
};
}
59 changes: 59 additions & 0 deletions scripts/config/metadata.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { ARTIFACT_SCHEMA_VERSION, buildToolVersions, stampMetadata } from './metadata.js';

describe('metadata helper', () => {
describe('ARTIFACT_SCHEMA_VERSION', () => {
it('is a non-empty string', () => {
expect(typeof ARTIFACT_SCHEMA_VERSION).toBe('string');
expect(ARTIFACT_SCHEMA_VERSION.length).toBeGreaterThan(0);
});
});

describe('buildToolVersions', () => {
it('always includes node version', () => {
const versions = buildToolVersions('/nonexistent-cwd-for-test');
expect(versions.node).toBe(process.version);
});

it('returns plain object even when project.json is absent', () => {
const versions = buildToolVersions('/nonexistent-cwd-for-test');
expect(typeof versions).toBe('object');
expect(versions).not.toBeNull();
});
});

describe('stampMetadata', () => {
it('adds schema_version and tool_versions to an artifact', () => {
const artifact = { meta: { fps: 60 }, segments: [] };
const stamped = stampMetadata(artifact, '/nonexistent-cwd-for-test');

expect(stamped.schema_version).toBe(ARTIFACT_SCHEMA_VERSION);
expect(typeof stamped.tool_versions).toBe('object');
expect(stamped.tool_versions.node).toBe(process.version);
});

it('preserves all original artifact fields', () => {
const artifact = { meta: { fps: 60 }, segments: [{ id: 1 }], extra: 'keep' };
const stamped = stampMetadata(artifact, '/nonexistent-cwd-for-test');

expect(stamped.meta).toEqual({ fps: 60 });
expect(stamped.segments).toEqual([{ id: 1 }]);
expect(stamped.extra).toBe('keep');
});

it('artifact fields take precedence over metadata keys', () => {
// If an artifact already has schema_version, the spread preserves it
const artifact = { schema_version: '99', segments: [] };
const stamped = stampMetadata(artifact, '/nonexistent-cwd-for-test');

expect(stamped.schema_version).toBe('99');
});

it('works with object-wrapped array artifacts', () => {
const artifact = { turns: [{ speaker: 'A', start: 0, end: 1 }] };
const stamped = stampMetadata(artifact, '/nonexistent-cwd-for-test');

expect(stamped.schema_version).toBe(ARTIFACT_SCHEMA_VERSION);
expect(stamped.turns).toEqual(artifact.turns);
});
});
});
9 changes: 6 additions & 3 deletions scripts/diarize/Diarizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'path';
import os from 'os';
import fs from 'fs-extra';
import { fileURLToPath } from 'url';
import { stampMetadata } from '../config/metadata.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -123,7 +124,7 @@ class Diarizer {
const speakerCount = new Set(turns.map((t) => t.speaker)).size;
console.log(` ${speakerCount} speaker(s), ${turns.length} turns.`);

await fs.writeJson(this.diarizationJsonPath, turns, { spaces: 2 });
await fs.writeJson(this.diarizationJsonPath, stampMetadata({ turns }), { spaces: 2 });
console.log(` Saved: ${this.diarizationJsonPath}`);

return turns;
Expand Down Expand Up @@ -162,15 +163,17 @@ class Diarizer {

async runAssignment() {
console.log(`Reading diarization output: ${this.diarizationJsonPath}`);
const turns = await fs.readJson(this.diarizationJsonPath);
const raw = await fs.readJson(this.diarizationJsonPath);
// Support both legacy array format and current object format { turns: [...] }
const turns = Array.isArray(raw) ? raw : (raw.turns ?? []);
console.log(` ${new Set(turns.map((t) => t.speaker)).size} speaker(s), ${turns.length} turns.`);

console.log(`Reading transcript: ${this.rawJsonPath}`);
const transcript = await fs.readJson(this.rawJsonPath);

console.log('Assigning speaker labels...');
const updated = this.assignSpeakers(transcript, turns);
await fs.writeJson(this.rawJsonPath, updated, { spaces: 2 });
await fs.writeJson(this.rawJsonPath, stampMetadata(updated), { spaces: 2 });

const speakers = [...new Set(updated.segments.map((s) => s.speaker).filter(Boolean))];
console.log(` Labels assigned: ${speakers.join(', ') || '(none — check diarization.json timestamps overlap with transcript)'}`);
Expand Down
3 changes: 2 additions & 1 deletion scripts/edit-transcript.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import fs from 'fs-extra';
import path from 'path';
import { convertVttToSrt } from './shared/vtt-to-srt.js';
import { stampMetadata } from './config/metadata.js';

function parseArgs() {
const args = process.argv.slice(2);
Expand Down Expand Up @@ -2011,7 +2012,7 @@ async function main() {
const chaptersPath = outputPath.replace(/\.json$/, '.chapters.txt');

await fs.ensureDir(path.dirname(outputPath));
await fs.writeJson(outputPath, transcript, { spaces: 2 });
await fs.writeJson(outputPath, stampMetadata(transcript), { spaces: 2 });
await fs.writeFile(sentencesSrtPath, buildSentencesSrt(transcript.segments, transcript.meta), 'utf8');
await fs.writeFile(docPath, buildDoc(transcript), 'utf8');
await fs.writeFile(youtubeSrtPath, buildYouTubeSubtitles(transcript), 'utf8');
Expand Down
3 changes: 2 additions & 1 deletion scripts/transcribe/Transcriber.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'path';
import fs from 'fs-extra';
import { open as fsOpen } from 'node:fs/promises';
import { installWhisperCpp, downloadWhisperModel, transcribe } from '@remotion/install-whisper-cpp';
import { stampMetadata } from '../config/metadata.js';

const WHISPER_VERSION = '1.5.5';
const DEFAULT_MODEL = 'medium.en';
Expand Down Expand Up @@ -248,7 +249,7 @@ class Transcriber {
const jsonPath = path.join(this.outputDir, 'transcript.raw.json');

await fs.writeFile(vttPath, this.buildVtt(transcription), 'utf8');
await fs.writeJson(jsonPath, this.buildJson(transcription), { spaces: 2 });
await fs.writeJson(jsonPath, stampMetadata(this.buildJson(transcription)), { spaces: 2 });

console.log(` VTT: ${vttPath}`);
console.log(` JSON: ${jsonPath}`);
Expand Down
72 changes: 72 additions & 0 deletions tests/integration/diarizer-runAssignment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import os from 'os';
import path from 'path';
import fse from 'fs-extra';
import Diarizer from '../../scripts/diarize/Diarizer.js';
import { ARTIFACT_SCHEMA_VERSION } from '../../scripts/config/metadata.js';

const SAMPLE_TRANSCRIPT = {
meta: { fps: 60 },
segments: [
{ id: 's1', start: 0.0, end: 2.0, text: 'Hello world', speaker: '', tokens: [], cuts: [] },
{ id: 's2', start: 3.0, end: 5.0, text: 'How are you', speaker: '', tokens: [], cuts: [] },
],
};

const SAMPLE_TURNS = [
{ speaker: 'Natasha', start: 0.0, end: 2.5 },
{ speaker: 'Saloni', start: 2.8, end: 5.5 },
];

describe('Diarizer.runAssignment — diarization.json format compatibility', () => {
let tmpDir: string;
let diarizationPath: string;
let transcriptPath: string;

beforeEach(async () => {
tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'deckcreate-diarizer-test-'));
diarizationPath = path.join(tmpDir, 'diarization.json');
transcriptPath = path.join(tmpDir, 'transcript.raw.json');
await fse.writeJson(transcriptPath, SAMPLE_TRANSCRIPT, { spaces: 2 });
});

afterEach(async () => {
await fse.remove(tmpDir);
});

function makeDiarizer() {
return new Diarizer({ diarizationJsonPath: diarizationPath, rawJsonPath: transcriptPath });
}

it('reads legacy array format and assigns speakers to segments', async () => {
await fse.writeJson(diarizationPath, SAMPLE_TURNS, { spaces: 2 });
const result = await makeDiarizer().runAssignment();
const speakers = result.segments.map((s: { speaker: string }) => s.speaker);
expect(speakers).toEqual(['Natasha', 'Saloni']);
});

it('reads new object format { turns: [...] } and assigns speakers', async () => {
await fse.writeJson(
diarizationPath,
{ schema_version: ARTIFACT_SCHEMA_VERSION, tool_versions: { node: process.version }, turns: SAMPLE_TURNS },
{ spaces: 2 },
);
const result = await makeDiarizer().runAssignment();
const speakers = result.segments.map((s: { speaker: string }) => s.speaker);
expect(speakers).toEqual(['Natasha', 'Saloni']);
});

it('handles empty turns array gracefully (no speakers assigned)', async () => {
await fse.writeJson(diarizationPath, { turns: [] }, { spaces: 2 });
const result = await makeDiarizer().runAssignment();
const speakers = result.segments.map((s: { speaker: string }) => s.speaker);
expect(speakers.every((sp: string) => sp === '')).toBe(true);
});

it('stamps schema_version and tool_versions on the written transcript', async () => {
await fse.writeJson(diarizationPath, SAMPLE_TURNS, { spaces: 2 });
await makeDiarizer().runAssignment();
const written = await fse.readJson(transcriptPath);
expect(written.schema_version).toBe(ARTIFACT_SCHEMA_VERSION);
expect(written.tool_versions.node).toBe(process.version);
});
});
Loading