/**
* @module tools
*/
import { GAP_THRESHOLD } from './constants.js';
/**
* Draws a rounded rectangle path on a 2D canvas context with independent
* corner radii. Each radius is clamped to half the shorter dimension to
* prevent over-rounding artefacts.
* @param {CanvasRenderingContext2D} ctx - canvas rendering context to draw on
* @param {number} x - x coordinate of the rectangle's top-left corner
* @param {number} y - y coordinate of the rectangle's top-left corner
* @param {number} w - width
* @param {number} h - height
* @param {number[]} radii - Corner radii [top-left, top-right, bottom-right, bottom-left]
*/
export function roundRectCorners(ctx, x, y, w, h, radii) {
w = Math.max(0, w);
h = Math.max(0, h);
const [tl, tr, br, bl] = radii.map(r => Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + tl, y);
ctx.lineTo(x + w - tr, y);
ctx.arcTo(x + w, y, x + w, y + tr, tr);
ctx.lineTo(x + w, y + h - br);
ctx.arcTo(x + w, y + h, x + w - br, y + h, br);
ctx.lineTo(x + bl, y + h);
ctx.arcTo(x, y + h, x, y + h - bl, bl);
ctx.lineTo(x, y + tl);
ctx.arcTo(x, y, x + tl, y, tl);
ctx.closePath();
}
/**
* Generates a locally-unique project id.
* Format: "local_" + base-36 timestamp + 4 random base-36 chars.
* Collision probability is negligible for typical usage.
* @returns {string}
*/
export function generateId() {
return 'local_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}
/**
* Formats a time in seconds as "H:MM:SS" (always includes hours).
* @param {number} s - seconds
* @returns {string} e.g. "0:01:05"
*/
export function formatTime(s) {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = Math.floor(s % 60);
return `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
}
/**
* Formats a time in seconds as "H:MM:SS.S" (always includes hours, one decimal second).
* Used for timecode display and split popup labels.
* @param {number} s - seconds
* @returns {string} e.g. "0:01:05.3"
*/
export function formatTimeMs(s) {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = (s % 60).toFixed(1);
return `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(4, '0')}`;
}
/**
* Parses a 6-digit hex color string into separate R, G, B integer components.
* @param {string} hex - e.g. '#47c8ff' or '47c8ff'
* @returns {{r: number, g: number, b: number}}
*/
export function hexToRgb(hex) {
const m = hex.replace('#','');
const n = parseInt(m, 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
/**
* Converts HSL color values to a 6-digit hex string.
* Uses the standard HSL → RGB algorithm.
* @param {number} h - hue in degrees 0–360
* @param {number} s - saturation 0–100
* @param {number} l - lightness 0–100
* @returns {string} e.g. '#47c8ff'
*/
export function hslToHex(h, s, l) {
s /= 100; l /= 100;
const k = n => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const toHex = x => Math.round(x * 255).toString(16).padStart(2, '0');
return '#' + toHex(f(0)) + toHex(f(8)) + toHex(f(4));
}
/**
* Extracts the hue angle (0–360) from a hex color string.
* Used to position the cursor in the hue picker at the color's current hue.
* @param {string} hex - e.g. '#47c8ff' or '47c8ff'
* @returns {number} hue in degrees
*/
export function hexToHue(hex) {
const { r, g, b } = hexToRgb(hex);
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
if (max === min) return 0; // achromatic — any hue works, return 0
const d = max - min;
// Standard hue formula split by which channel is dominant
let h = max === rn ? (gn - bn) / d + (gn < bn ? 6 : 0)
: max === gn ? (bn - rn) / d + 2
: (rn - gn) / d + 4;
return (h * 60) % 360;
}
/**
* Converts a CSS color string (hex or rgb()) to HSV components.
* @param {string} color - A hex color ('#rgb', '#rrggbb') or 'rgb(r, g, b)' string
* @returns {{h: number, s: number, v: number}} Hue 0–360, saturation 0–1, value 0–1
*/
export function colorToHSV(color) {
let r, g, b;
color = color.trim();
if (color.startsWith('rgb')) {
[r, g, b] = color.match(/[\d.]+/g).map(Number);
} else {
let hex = color.replace(/^#/, '');
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(hex)) {
throw new Error(`Unsupported color format: ${color}`);
}
if (hex.length === 3) {
hex = hex.split('').map(c => c + c).join('');
}
r = parseInt(hex.slice(0, 2), 16);
g = parseInt(hex.slice(2, 4), 16);
b = parseInt(hex.slice(4, 6), 16);
}
// Normalize RGB to 0–1
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const delta = max - min;
// Value
const v = max;
// Saturation
const s = max === 0 ? 0 : delta / max;
// Hue
let h = 0;
if (delta !== 0) {
if (max === rn) h = ((gn - bn) / delta) % 6;
else if (max === gn) h = (bn - rn) / delta + 2;
else h = (rn - gn) / delta + 4;
h = h * 60;
if (h < 0) h += 360;
}
return { h, s, v };
}
/**
* Flattens a transcript JSON object (paragraphs > sentences > words) into a flat
* segments array compatible with the Transcript class. Each segment gains a
* `words` array for word-level playback highlighting.
* @param {object} json - transcript JSON with a `paragraphs` array
* @returns {Array<{start: number, end: number, speaker: string, text: string, words: object[]}>}
*/
export function parseTranscriptJSON(json) {
const segments = [];
for (const p of json.paragraphs) {
if (p.sentences) {
// New format: paragraph > sentences > words
for (let i = 0; i < p.sentences.length; i++) {
const s = p.sentences[i];
const seg = {
start: s.start,
end: s.end,
speaker: p.speaker,
text: s.text,
words: s.words ?? [],
};
if (s.wordsStale) seg.wordsStale = true;
if (i === 0) {
// First sentence: preserve same-speaker paragraph boundary
if (segments.length > 0 && segments[segments.length - 1].speaker === p.speaker) {
seg.manualParaBreak = true;
}
} else {
// Subsequent sentences: prevent gap-based re-splitting within the paragraph
if (s.start - p.sentences[i - 1].end > GAP_THRESHOLD) {
seg.manualParaBreak = false;
}
}
segments.push(seg);
}
} else {
// Old format: paragraph is a single flat text block (no sentences)
const seg = {
start: p.start,
end: p.end,
speaker: p.speaker,
text: p.text ?? '',
words: [],
};
if (segments.length > 0 && segments[segments.length - 1].speaker === p.speaker) {
seg.manualParaBreak = true;
}
segments.push(seg);
}
}
return segments;
}
/**
* Merges a flat array of Whisper segments (arbitrary audio chunks) into
* sentence-like units. Flushes the accumulator on:
* - speaker change
* - gap ≥ GAP_THRESHOLD between segments
* - accumulated text ending with sentence-final punctuation (.?!)
* Used as a transitional shim when loading legacy CSV transcripts.
* @param {object[]} segments - flat segments from parseCSV
* @returns {object[]} merged sentence-like segments
*/
export function mergeSegmentsToSentences(segments) {
if (!segments.length) return [];
const merged = [];
let acc = { ...segments[0] };
for (let i = 1; i < segments.length; i++) {
const seg = segments[i];
const gap = seg.start - acc.end;
const speakerChanged = seg.speaker !== acc.speaker;
const sentenceEnd = /[.?!]$/.test(acc.text.trimEnd());
if (speakerChanged || gap >= GAP_THRESHOLD || sentenceEnd) {
merged.push(acc);
acc = { ...seg };
} else {
acc = { ...acc, end: seg.end, text: acc.text.trimEnd() + ' ' + seg.text.trimStart() };
}
}
merged.push(acc);
return merged;
}
/**
* Parses a CSV string in the format: start,end,speaker,text
* Implements RFC 4180 field quoting: quoted fields may contain commas,
* newlines, and escaped double quotes ("" → ").
* The header row is skipped; rows with fewer than 4 columns are ignored.
* @param {string} text - raw CSV string
* @returns {Array<{start: number, end: number, speaker: string, text: string}>}
*/
export function parseCSV(text) {
const rows = [];
let i = 0;
while (i < text.length && text[i] !== '\n') i++;
i++;
while (i < text.length) {
const row = [];
while (i < text.length && text[i] !== '\n') {
if (text[i] === '"') {
i++;
let val = '';
while (i < text.length) {
if (text[i] === '"' && text[i+1] === '"') { val += '"'; i += 2; }
else if (text[i] === '"') { i++; break; }
else { val += text[i++]; }
}
row.push(val);
if (text[i] === ',') i++;
} else {
let val = '';
while (i < text.length && text[i] !== ',' && text[i] !== '\n') val += text[i++];
row.push(val.trim());
if (text[i] === ',') i++;
}
}
if (text[i] === '\n') i++;
if (row.length >= 4) {
rows.push({ start: parseFloat(row[0]), end: parseFloat(row[1]), speaker: row[2].trim(), text: row[3].trim() });
}
}
return rows;
}
/**
* Finds the color farthest from all others, considering only hue.
*
* @param {string[]} colors - Array of CSS color strings (hex, rgb, hsl, named)
* @returns {string} - The color that is farthest from all others
*/
export function farthestColor(colors) {
const hueAngles = colors.map((color) => {
return colorToHSV(color).h;
});
const farthest = farthestAngle(hueAngles);
return hslToHex(farthest, 100, 50)
}
/**
* Finds the angle (in degrees) that is farthest from all given angles on a
* circular 0–360 scale. Works by locating the largest arc gap between the
* sorted input angles and returning its midpoint.
* - Zero angles: returns a random angle.
* - One angle: returns the opposite (180° away).
* - Multiple angles: returns the midpoint of the largest gap.
* @param {number[]} angles - Array of angles in degrees (0–360)
* @returns {number} The angle farthest from all inputs, in degrees (0–360)
*/
export function farthestAngle(angles = []) {
// No numbers: return a random angle
if (angles.length === 0) {
return Math.random() * 360;
}
// One number: return the circular opposite (180 degrees away)
if (angles.length === 1) {
return (angles[0] + 180) % 360;
}
// Multiple numbers: find the angle farthest from all others
// Strategy: find the largest gap in the sorted angles, and return its midpoint
const sorted = [...angles].sort((a, b) => a - b);
let largestGap = -1;
let gapMidpoint = 0;
for (let i = 0; i < sorted.length; i++) {
const current = sorted[i];
const next = sorted[(i + 1) % sorted.length];
// For the wraparound gap, next will be < current, so we add 360
const gap = (next - current + 360) % 360;
if (gap > largestGap) {
largestGap = gap;
// Midpoint of this gap, wrapped to [0, 360)
gapMidpoint = (current + gap / 2) % 360;
}
}
return gapMidpoint;
}