@@ -183,9 +183,9 @@ function ControlButton({
onClick={onClick}
title={title}
disabled={disabled}
- className={`p-1.5 rounded-md transition-colors ${
+ className={`flex h-8 w-8 items-center justify-center rounded transition-colors ${
primary
- ? 'bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30'
+ ? 'bg-editor-paper text-editor-ink hover:bg-white'
: active
? 'bg-editor-accent/15 text-editor-accent hover:bg-editor-accent/25'
: 'text-editor-text-muted hover:text-editor-text hover:bg-editor-surface'
diff --git a/frontend/src/components/WaveformTimeline.tsx b/frontend/src/components/WaveformTimeline.tsx
index 54539a8..4f87a56 100644
--- a/frontend/src/components/WaveformTimeline.tsx
+++ b/frontend/src/components/WaveformTimeline.tsx
@@ -56,12 +56,47 @@ export default function WaveformTimeline() {
const width = rect.width;
const height = rect.height;
ctx.clearRect(0, 0, width, height);
+ const rulerHeight = 18;
+ const eventLaneHeight = 38;
+ const eventLaneTop = height - eventLaneHeight;
+ const markerLaneTop = eventLaneTop + 20;
+ const waveformTop = rulerHeight + 5;
+ const waveformHeight = Math.max(24, eventLaneTop - waveformTop - 5);
+ const waveformMid = waveformTop + waveformHeight / 2;
+
+ ctx.strokeStyle = '#252a27';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(0, rulerHeight + 0.5);
+ ctx.lineTo(width, rulerHeight + 0.5);
+ ctx.moveTo(0, eventLaneTop + 0.5);
+ ctx.lineTo(width, eventLaneTop + 0.5);
+ ctx.moveTo(0, markerLaneTop + 0.5);
+ ctx.lineTo(width, markerLaneTop + 0.5);
+ ctx.stroke();
+
+ const tickCount = Math.max(4, Math.min(12, Math.floor(width / 120)));
+ ctx.font = '9px SFMono-Regular, Cascadia Mono, monospace';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = '#7d867f';
+ ctx.strokeStyle = '#353b37';
+ ctx.beginPath();
+ for (let tick = 0; tick <= tickCount; tick++) {
+ const x = (tick / tickCount) * width;
+ const tickTime = (tick / tickCount) * timelineDuration;
+ ctx.moveTo(x, rulerHeight - 5);
+ ctx.lineTo(x, rulerHeight);
+ ctx.fillText(formatTimelineTime(tickTime), Math.min(width - 34, x + 3), 2);
+ }
+ ctx.stroke();
for (const range of deletedRanges) {
const x1 = (range.start / timelineDuration) * width;
const x2 = (range.end / timelineDuration) * width;
- ctx.fillStyle = 'rgba(239, 68, 68, 0.15)';
- ctx.fillRect(x1, 0, x2 - x1, height);
+ ctx.fillStyle = 'rgba(255, 113, 109, 0.16)';
+ ctx.fillRect(x1, waveformTop, x2 - x1, waveformHeight);
+ ctx.fillStyle = 'rgba(255, 113, 109, 0.7)';
+ ctx.fillRect(x1, eventLaneTop + 5, Math.max(2, x2 - x1), 8);
}
for (const operation of editOperations) {
@@ -69,15 +104,15 @@ export default function WaveformTimeline() {
const x2 = (operation.end / timelineDuration) * width;
ctx.fillStyle =
operation.kind === 'mute'
- ? 'rgba(99, 102, 241, 0.18)'
+ ? 'rgba(166, 174, 168, 0.18)'
: operation.kind === 'bleep'
- ? 'rgba(236, 72, 153, 0.22)'
+ ? 'rgba(255, 113, 109, 0.28)'
: operation.kind === 'room-tone'
- ? 'rgba(245, 158, 11, 0.18)'
+ ? 'rgba(231, 189, 99, 0.20)'
: operation.kind === 'caption-only'
? 'rgba(148, 163, 184, 0.18)'
- : 'rgba(34, 197, 94, 0.12)';
- ctx.fillRect(x1, 0, x2 - x1, height);
+ : 'rgba(113, 217, 176, 0.13)';
+ ctx.fillRect(x1, eventLaneTop + 5, Math.max(2, x2 - x1), 8);
}
if (selectedWordIndices.length > 0 && words.length > 0) {
@@ -85,31 +120,48 @@ export default function WaveformTimeline() {
for (const range of selectedRanges) {
const x1 = (range.start / timelineDuration) * width;
const x2 = (range.end / timelineDuration) * width;
- ctx.fillStyle = 'rgba(99, 102, 241, 0.28)';
- ctx.fillRect(x1, 0, Math.max(2, x2 - x1), height);
+ ctx.fillStyle = 'rgba(113, 217, 176, 0.28)';
+ ctx.fillRect(x1, waveformTop, Math.max(2, x2 - x1), waveformHeight);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.45)';
ctx.lineWidth = 1;
- ctx.strokeRect(x1, 0.5, Math.max(2, x2 - x1), height - 1);
+ ctx.strokeRect(x1, waveformTop + 0.5, Math.max(2, x2 - x1), waveformHeight - 1);
+ ctx.fillStyle = '#71d9b0';
+ ctx.fillRect(x1, eventLaneTop + 5, Math.max(2, x2 - x1), 8);
+ ctx.beginPath();
+ ctx.arc(x1, markerLaneTop + 9, 3, 0, Math.PI * 2);
+ ctx.arc(x2, markerLaneTop + 9, 3, 0, Math.PI * 2);
+ ctx.fill();
}
}
- const mid = height / 2;
+ for (const word of words) {
+ if (word.confidence >= 0.65) continue;
+ const x = (word.start / timelineDuration) * width;
+ ctx.fillStyle = '#ff716d';
+ ctx.beginPath();
+ ctx.moveTo(x, markerLaneTop + 4);
+ ctx.lineTo(x + 4, markerLaneTop + 9);
+ ctx.lineTo(x, markerLaneTop + 14);
+ ctx.lineTo(x - 4, markerLaneTop + 9);
+ ctx.closePath();
+ ctx.fill();
+ }
+
ctx.beginPath();
- ctx.strokeStyle = '#4a4d5e';
+ ctx.strokeStyle = '#69716b';
ctx.lineWidth = 1;
if (peaks.length === 0) {
- ctx.moveTo(0, mid);
- ctx.lineTo(width, mid);
+ ctx.moveTo(0, waveformMid);
+ ctx.lineTo(width, waveformMid);
ctx.stroke();
ctx.beginPath();
- ctx.strokeStyle = '#2a2d3a';
- const tickCount = Math.max(2, Math.min(12, Math.floor(timelineDuration / 10)));
+ ctx.strokeStyle = '#2b302d';
for (let tick = 0; tick <= tickCount; tick++) {
const x = (tick / tickCount) * width;
- ctx.moveTo(x, height * 0.25);
- ctx.lineTo(x, height * 0.75);
+ ctx.moveTo(x, waveformTop + waveformHeight * 0.25);
+ ctx.lineTo(x, waveformTop + waveformHeight * 0.75);
}
ctx.stroke();
return;
@@ -118,8 +170,8 @@ export default function WaveformTimeline() {
for (let index = 0; index < peaks.length; index++) {
const x = peaks.length === 1 ? 0 : (index / (peaks.length - 1)) * width;
const [minimum, maximum] = peaks[index];
- const yMin = mid + minimum * mid * 0.9;
- const yMax = mid + maximum * mid * 0.9;
+ const yMin = waveformMid + minimum * waveformHeight * 0.46;
+ const yMax = waveformMid + maximum * waveformHeight * 0.46;
ctx.moveTo(x, yMin);
ctx.lineTo(x, yMax);
}
@@ -169,7 +221,7 @@ export default function WaveformTimeline() {
console.warn('Could not build waveform:', err);
waveformPeaksRef.current = [];
waveformDurationRef.current = 0;
- setAudioError('Waveform unavailable — editing and transcription still work');
+ setAudioError('Форма волны недоступна — монтаж и расшифровка продолжают работать');
setWaveformRevision((revision) => revision + 1);
} finally {
if (!canceled) setWaveformLoading(false);
@@ -221,7 +273,7 @@ export default function WaveformTimeline() {
if (dur > 0) {
const px = (currentTimeRef.current / dur) * width;
ctx.beginPath();
- ctx.strokeStyle = '#6366f1';
+ ctx.strokeStyle = '#71d9b0';
ctx.lineWidth = 2;
ctx.moveTo(px, 0);
ctx.lineTo(px, height);
@@ -337,7 +389,7 @@ export default function WaveformTimeline() {
if (!videoPath) {
return (
- Load a video to see the waveform
+ Откройте видео, чтобы увидеть форму волны
);
}
@@ -345,8 +397,8 @@ export default function WaveformTimeline() {
return (
-
- Timeline
+
+ Звук · монтаж · цензура
@@ -355,7 +407,7 @@ export default function WaveformTimeline() {
setFollowPlayhead((current) => !current)}
className={`p-0.5 ${followPlayhead ? 'text-editor-accent' : 'text-editor-text-muted'} hover:text-editor-text`}
- title={followPlayhead ? 'Following playhead' : 'Follow playhead'}
+ title={followPlayhead ? 'Следуем за курсором' : 'Следовать за курсором'}
>
@@ -363,14 +415,14 @@ export default function WaveformTimeline() {
onClick={() => setZoom((current) => Math.max(1, current - 0.5))}
disabled={zoom <= 1}
className="p-0.5 text-editor-text-muted hover:text-editor-text"
- title="Zoom out"
+ title="Уменьшить масштаб"
>
setZoom((current) => Math.min(8, current + 0.5))}
className="p-0.5 text-editor-text-muted hover:text-editor-text"
- title="Zoom in"
+ title="Увеличить масштаб"
>
@@ -405,7 +457,7 @@ export default function WaveformTimeline() {
)}
{waveformLoading && !audioError && (
- Building a memory-safe waveform…
+ Строим безопасную форму волны…
)}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 80a0377..b4dbde8 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -8,10 +8,49 @@
box-sizing: border-box;
}
+:root {
+ color-scheme: dark;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+}
+
body {
- font-family: 'Inter', system-ui, -apple-system, sans-serif;
+ font-family: 'Onest Variable', 'Onest', 'Segoe UI Variable', 'Segoe UI', ui-sans-serif, sans-serif;
overflow: hidden;
user-select: none;
+ background: #0b0d0c;
+ color: #f3f5f3;
+}
+
+button,
+input,
+textarea,
+select {
+ font: inherit;
+}
+
+button,
+summary,
+select {
+ -webkit-tap-highlight-color: transparent;
+}
+
+button:focus-visible,
+input:focus-visible,
+textarea:focus-visible,
+select:focus-visible,
+summary:focus-visible {
+ outline: 2px solid #71d9b0;
+ outline-offset: 2px;
+}
+
+button.bg-editor-accent {
+ color: #08110d;
+}
+
+::selection {
+ background: rgba(113, 217, 176, 0.34);
+ color: inherit;
}
::-webkit-scrollbar {
@@ -24,14 +63,216 @@ body {
}
::-webkit-scrollbar-thumb {
- background: #2a2d3a;
+ background: #353b37;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
- background: #3a3d4a;
+ background: #4b544e;
}
video::-webkit-media-controls {
display: none !important;
}
+
+.scriptcut-shell {
+ background: #0b0d0c;
+}
+
+.scriptcut-topbar {
+ background: #0b0d0c;
+ border-color: #282d2a;
+}
+
+.scriptcut-video-pane {
+ background: #0b0d0c;
+}
+
+.scriptcut-transcript-pane {
+ background: #f4f4ef;
+ color: #111411;
+ border-color: #d7dad5;
+}
+
+.scriptcut-transcript-pane .text-editor-text {
+ color: #111411;
+}
+
+.scriptcut-transcript-pane .text-editor-text-muted {
+ color: #677069;
+}
+
+.scriptcut-transcript-pane .text-white {
+ color: #111411;
+}
+
+.scriptcut-transcript-pane .text-editor-accent {
+ color: #176b4c;
+}
+
+.scriptcut-transcript-pane .text-editor-success {
+ color: #176b4c;
+}
+
+.scriptcut-transcript-pane .text-editor-warning {
+ color: #855d11;
+}
+
+.scriptcut-transcript-pane .text-editor-danger {
+ color: #a02f2c;
+}
+
+.scriptcut-transcript-pane .bg-editor-bg {
+ background: #f4f4ef;
+}
+
+.scriptcut-transcript-pane .bg-editor-surface {
+ background: #e9ebe6;
+}
+
+.scriptcut-transcript-pane .bg-editor-border {
+ background: #d7dad5;
+}
+
+.scriptcut-transcript-pane .border-editor-border {
+ border-color: #d7dad5;
+}
+
+.scriptcut-transcript-pane input,
+.scriptcut-transcript-pane textarea,
+.scriptcut-transcript-pane select {
+ color: #111411;
+}
+
+.scriptcut-transcript-pane input::placeholder,
+.scriptcut-transcript-pane textarea::placeholder {
+ color: #7d867f;
+}
+
+.scriptcut-transcript-row {
+ border-bottom: 1px solid #d7dad5;
+}
+
+.scriptcut-transcript-row[data-active='true'] {
+ background: rgba(113, 217, 176, 0.13);
+ box-shadow: inset 3px 0 0 #23805b;
+}
+
+.scriptcut-word-handle {
+ border: 1px solid transparent;
+ border-radius: 3px;
+}
+
+.scriptcut-word-handle:hover {
+ border-color: rgba(35, 128, 91, 0.26);
+}
+
+.scriptcut-word-handle[data-selected='true'] {
+ border-color: rgba(35, 128, 91, 0.38);
+ background: rgba(35, 128, 91, 0.22);
+}
+
+.scriptcut-word-handle[data-confidence='low'] {
+ border-bottom-color: rgba(160, 47, 44, 0.7);
+}
+
+.scriptcut-workbench {
+ background: #101311;
+ border-color: #2b302d;
+ animation: workbench-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.scriptcut-review-section {
+ border-bottom: 1px solid #2b302d;
+}
+
+.scriptcut-review-row {
+ border-bottom: 1px solid #252a27;
+ background: transparent;
+}
+
+.scriptcut-timeline {
+ background: #101311;
+ border-color: #2b302d;
+}
+
+.scriptcut-empty {
+ position: relative;
+ isolation: isolate;
+ background: #0b0d0c;
+}
+
+.scriptcut-empty::before {
+ content: '';
+ position: absolute;
+ inset: 16px;
+ z-index: -1;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ pointer-events: none;
+}
+
+.scriptcut-dropzone {
+ background: #f4f4ef;
+ color: #111411;
+ border-color: #cdd1cc;
+}
+
+.scriptcut-dropzone .text-editor-text {
+ color: #111411;
+}
+
+.scriptcut-dropzone .text-editor-text-muted {
+ color: #677069;
+}
+
+@keyframes workbench-in {
+ from {
+ opacity: 0.7;
+ transform: translateX(14px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@media (max-width: 1180px) {
+ .scriptcut-workspace {
+ position: relative;
+ }
+
+ .scriptcut-workbench {
+ position: absolute;
+ inset: 0 0 0 auto;
+ z-index: 30;
+ width: min(390px, 44vw) !important;
+ box-shadow: -24px 0 48px rgba(0, 0, 0, 0.32);
+ }
+
+ .scriptcut-video-pane {
+ min-width: 300px !important;
+ width: 38% !important;
+ }
+}
+
+@media (max-width: 860px) {
+ .scriptcut-video-pane {
+ min-width: 250px !important;
+ width: 42% !important;
+ }
+
+ .scriptcut-workbench {
+ width: min(430px, 58vw) !important;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 9aa52ff..23cb546 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
+import '@fontsource-variable/onest';
import App from './App';
import './index.css';
diff --git a/frontend/src/store/editorStore.ts b/frontend/src/store/editorStore.ts
index 8f6ae5c..fadade2 100644
--- a/frontend/src/store/editorStore.ts
+++ b/frontend/src/store/editorStore.ts
@@ -336,11 +336,14 @@ export const useEditorStore = create()(
let prev = sorted[0];
const flush = () => {
+ const edgePadding = kind === 'bleep'
+ ? { before: 0.08, after: 0.12 }
+ : { before: 0, after: 0 };
ranges.push({
id: `op_${nextRangeId++}`,
kind,
- start: words[start].start,
- end: words[prev].end,
+ start: Math.max(0, words[start].start - edgePadding.before),
+ end: words[prev].end + edgePadding.after,
wordIndices: Array.from({ length: prev - start + 1 }, (_, i) => start + i),
});
};
diff --git a/frontend/src/utils/censorship.ts b/frontend/src/utils/censorship.ts
index e8418e2..1d170b8 100644
--- a/frontend/src/utils/censorship.ts
+++ b/frontend/src/utils/censorship.ts
@@ -8,19 +8,94 @@ export type CensorMatch = {
endTime: number;
text: string;
source: 'built-in' | 'custom';
+ confidence: number;
+ matchKind: 'exact' | 'split' | 'obfuscated' | 'custom';
+ reason: string;
};
-const BUILT_IN_RUSSIAN_PATTERNS = [
- /^бл(?:я|е)(?:д|т)?[а-я]*$/u,
- /^ху(?:й|я|е|и|ю)[а-я]*$/u,
- /^(?:пизд|пезд)[а-я]*$/u,
- /^(?:еб|ебан|ебуч|ебл)[а-я]*$/u,
- /^долбоеб[а-я]*$/u,
- /^мудак[а-я]*$/u,
- /^сук(?:а|и|у|ой|е)?$/u,
- /^гандон[а-я]*$/u,
+type ProfanityRule = {
+ id: string;
+ label: string;
+ patterns: RegExp[];
+};
+
+// These are morphology-aware families rather than a flat list. They cover the
+// common inflections and streamer-style distortions that ASR produces, while
+// keeping every pattern anchored so innocent substrings such as "страхуй" are
+// not flagged merely because they end in the same letters.
+const RUSSIAN_PROFANITY_RULES: ProfanityRule[] = [
+ {
+ id: 'blyad',
+ label: 'семейство «блядь»',
+ patterns: [
+ /^бл(?:я|е|иа)(?:д|т|ть)?[а-я]*$/u,
+ /^бл(?:е|и)ат[а-я]*$/u,
+ ],
+ },
+ {
+ id: 'khuy',
+ label: 'семейство «хуй»',
+ patterns: [
+ /^(?:(?:на|по|о|а|за|до|вы|про|при|не|об)?ху(?:й|я|е|и|ю|ев|ёв))[а-я]*$/u,
+ /^ху(?:есос|еплет|еплёт|йн)[а-я]*$/u,
+ ],
+ },
+ {
+ id: 'pizda',
+ label: 'семейство «пизда»',
+ patterns: [
+ /^(?:(?:рас|за|на|по|про|вы|от|до|при|под)?п(?:и|е|ы)[зс]д)[а-я]*$/u,
+ ],
+ },
+ {
+ id: 'ebat',
+ label: 'семейство «ебать»',
+ patterns: [
+ /^(?:(?:за|на|по|про|вы|у|до|от|под|пере|при|с|вз|об)?[ъь]?(?:е|э|и|йо|ио)б)[а-я]*$/u,
+ /^(?:епт|епта|ептить|йопт|йопта)[а-я]*$/u,
+ /^(?:долбоеб|мозгоеб|скотоеб)[а-я]*$/u,
+ ],
+ },
+ {
+ id: 'suka',
+ label: 'семейство «сука»',
+ patterns: [
+ /^сук(?:а|и|у|ой|ою|е|ам|ами)?$/u,
+ /^суч(?:ка|ки|ку|кой|ара|ий|ье|онок|оны)[а-я]*$/u,
+ ],
+ },
+ {
+ id: 'insults',
+ label: 'грубая обсценная лексика',
+ patterns: [
+ /^(?:мудак|мудила|мудозвон)[а-я]*$/u,
+ /^(?:гандон|гондон)[а-я]*$/u,
+ /^(?:пидор|пидар|пидарас|пидорас|педераст|педик)[а-я]*$/u,
+ /^(?:шлюх|залуп|мандов|мандав)[а-я]*$/u,
+ ],
+ },
];
+const LOOKALIKE_MAP: Record = {
+ a: 'а',
+ b: 'б',
+ c: 'с',
+ e: 'е',
+ k: 'к',
+ m: 'м',
+ o: 'о',
+ p: 'р',
+ t: 'т',
+ x: 'х',
+ y: 'у',
+ '0': 'о',
+ '3': 'е',
+ '4': 'ч',
+ '6': 'б',
+ '@': 'а',
+ '$': 'с',
+};
+
export function findCensorMatches(
words: Word[],
customInput: string,
@@ -34,6 +109,7 @@ export function findCensorMatches(
startWordIndex: number,
endWordIndex: number,
source: CensorMatch['source'],
+ details?: Partial>,
) => {
const key = `${startWordIndex}:${endWordIndex}`;
if (seen.has(key)) return;
@@ -45,19 +121,58 @@ export function findCensorMatches(
id: `censor_${source}_${startWordIndex}_${endWordIndex}`,
startWordIndex,
endWordIndex,
- startTime: startWord.start,
- endTime: endWord.end,
+ startTime: Math.max(0, startWord.start - 0.08),
+ endTime: endWord.end + 0.12,
text: words.slice(startWordIndex, endWordIndex + 1).map((word) => word.word).join(' '),
source,
+ confidence: details?.confidence ?? (source === 'custom' ? 1 : 0.98),
+ matchKind: details?.matchKind ?? (source === 'custom' ? 'custom' : 'exact'),
+ reason: details?.reason ?? (source === 'custom' ? 'Совпадение с вашим списком' : 'Русский словарь'),
});
};
if (includeBuiltIn) {
- normalizedWords.forEach((word, index) => {
- if (word && BUILT_IN_RUSSIAN_PATTERNS.some((pattern) => pattern.test(word))) {
- addMatch(index, index, 'built-in');
- }
+ normalizedWords.forEach((token, index) => {
+ const rule = findProfanityRule(token);
+ if (!rule) return;
+ const obfuscated = isObfuscatedSurface(words[index]?.word || '');
+ addMatch(index, index, 'built-in', {
+ confidence: obfuscated ? 0.94 : 0.99,
+ matchKind: obfuscated ? 'obfuscated' : 'exact',
+ reason: obfuscated
+ ? `${rule.label}: распознана замаскированная запись`
+ : rule.label,
+ });
});
+
+ // Speech-to-text often separates a short profanity into syllables or even
+ // letters ("е бал", "б л я т ь", "на хуй"). Join only short spans and
+ // require the complete joined form to match an anchored family.
+ for (let start = 0; start < normalizedWords.length; start++) {
+ if (findProfanityRule(normalizedWords[start])) continue;
+ let joined = '';
+ for (let end = start; end < Math.min(normalizedWords.length, start + 6); end++) {
+ const part = normalizedWords[end];
+ if (!part || part.length > 5) break;
+ joined += part;
+ if (end === start) continue;
+ const rule = findProfanityRule(joined);
+ if (!rule) continue;
+ const overlapsKnownMatch = matches.some(
+ (match) =>
+ match.source === 'built-in' &&
+ match.startWordIndex <= end &&
+ match.endWordIndex >= start,
+ );
+ if (overlapsKnownMatch) break;
+ addMatch(start, end, 'built-in', {
+ confidence: 0.93,
+ matchKind: 'split',
+ reason: `${rule.label}: слово было разбито распознаванием`,
+ });
+ break;
+ }
+ }
}
for (const phrase of parseCustomPhrases(customInput)) {
@@ -90,10 +205,27 @@ export function parseCustomPhrases(input: string): string[][] {
}
export function normalizeToken(value: string): string {
- return value
- .toLocaleLowerCase('ru-RU')
+ const normalized = value.toLocaleLowerCase('ru-RU').normalize('NFKC');
+ const shouldMapLookalikes =
+ /[а-яё]/u.test(normalized) ||
+ (/[a-z]/u.test(normalized) && /[0-9@$*#_]/u.test(normalized));
+ const mapped = shouldMapLookalikes
+ ? [...normalized].map((character) => LOOKALIKE_MAP[character] || character).join('')
+ : normalized;
+
+ return mapped
.replace(/ё/gu, 'е')
- .replace(/[0-9_]/gu, '')
- .replace(/[^\p{L}-]/gu, '')
- .replace(/^-+|-+$/gu, '');
+ .replace(/[^\p{L}]/gu, '')
+ .replace(/(.)\1{1,}/gu, '$1');
+}
+
+function findProfanityRule(value: string): ProfanityRule | undefined {
+ if (!value || value.length < 3) return undefined;
+ return RUSSIAN_PROFANITY_RULES.find((rule) =>
+ rule.patterns.some((pattern) => pattern.test(value)),
+ );
+}
+
+function isObfuscatedSurface(value: string): boolean {
+ return /[a-z0-9@$*#_]/iu.test(value) || /[\p{L}][.\-_/\\*]+[\p{L}]/u.test(value);
}
diff --git a/frontend/src/utils/releaseInfo.ts b/frontend/src/utils/releaseInfo.ts
index 6ea67eb..5349712 100644
--- a/frontend/src/utils/releaseInfo.ts
+++ b/frontend/src/utils/releaseInfo.ts
@@ -1,4 +1,4 @@
-export const SCRIPTCUT_VERSION = '0.1.3';
+export const SCRIPTCUT_VERSION = '0.1.4';
export const RELEASE_LINKS = {
latestRelease: 'https://github.com/SmetankaKluss/ScriptCut/releases/latest',
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index 39a4cbf..aa3fe5b 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -5,24 +5,29 @@ export default {
extend: {
colors: {
editor: {
- bg: '#0f1117',
- surface: '#1a1d27',
- border: '#2a2d3a',
- accent: '#6366f1',
- 'accent-hover': '#818cf8',
- text: '#e2e8f0',
- 'text-muted': '#94a3b8',
- danger: '#ef4444',
- success: '#22c55e',
- warning: '#f59e0b',
- 'word-hover': 'rgba(99, 102, 241, 0.15)',
- 'word-selected': 'rgba(99, 102, 241, 0.3)',
- 'word-deleted': 'rgba(239, 68, 68, 0.2)',
- 'word-filler': 'rgba(245, 158, 11, 0.25)',
+ bg: '#0b0d0c',
+ panel: '#101311',
+ surface: '#171a18',
+ border: '#2b302d',
+ accent: '#71d9b0',
+ 'accent-hover': '#8ee6c2',
+ text: '#f3f5f3',
+ 'text-muted': '#a6aea8',
+ paper: '#f4f4ef',
+ 'paper-soft': '#e9ebe6',
+ ink: '#111411',
+ 'ink-muted': '#677069',
+ danger: '#ff716d',
+ success: '#71d9b0',
+ warning: '#e7bd63',
+ 'word-hover': 'rgba(35, 128, 91, 0.10)',
+ 'word-selected': 'rgba(35, 128, 91, 0.22)',
+ 'word-deleted': 'rgba(193, 57, 54, 0.14)',
+ 'word-filler': 'rgba(174, 119, 24, 0.18)',
},
},
fontFamily: {
- mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
+ mono: ['SFMono-Regular', 'Cascadia Mono', 'Consolas', 'monospace'],
},
},
},
diff --git a/package-lock.json b/package-lock.json
index 7c85f4e..00c9c5c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "scriptcut",
- "version": "0.1.3",
+ "version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "scriptcut",
- "version": "0.1.3",
+ "version": "0.1.4",
"dependencies": {
"python-shell": "^5.0.0"
},
diff --git a/package.json b/package.json
index f5d802a..6e243dc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "scriptcut",
- "version": "0.1.3",
+ "version": "0.1.4",
"private": true,
"author": "Fernando Abishai",
"description": "ScriptCut — Open-source AI-powered text-based video editor",
diff --git a/scripts/prepare-backend-runtime.js b/scripts/prepare-backend-runtime.js
index aa08052..385e483 100644
--- a/scripts/prepare-backend-runtime.js
+++ b/scripts/prepare-backend-runtime.js
@@ -36,6 +36,7 @@ const collectPackages = [
'faster_whisper',
'tokenizers',
'av',
+ 'certifi',
];
const collectBinaryDataPackages = ['ctranslate2'];
const hiddenImports = [