diff --git a/.impeccable/design.json b/.impeccable/design.json new file mode 100644 index 0000000..26e2f35 --- /dev/null +++ b/.impeccable/design.json @@ -0,0 +1,172 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-07-30T10:20:04Z", + "title": "Design System: ScriptCut", + "extensions": { + "colorMeta": { + "editor-bg": { + "role": "neutral", + "displayName": "Projection Black", + "canonical": "#0b0d0c", + "tonalRamp": ["#0b0d0c", "#171a18", "#2b302d", "#505852", "#747f78", "#a6aea8", "#d7dad5", "#f3f5f3"] + }, + "editor-accent": { + "role": "primary", + "displayName": "Live Mint", + "canonical": "#71d9b0", + "tonalRamp": ["#08261b", "#0e422e", "#176b4c", "#23805b", "#41a77d", "#71d9b0", "#b5ecd6", "#edfbf5"] + }, + "editor-paper": { + "role": "neutral", + "displayName": "Working Paper", + "canonical": "#f4f4ef", + "tonalRamp": ["#191a18", "#30322e", "#53574f", "#7d867f", "#aeb4ad", "#d7dad5", "#e9ebe6", "#f4f4ef"] + }, + "editor-danger": { + "role": "tertiary", + "displayName": "Cut Red", + "canonical": "#ff716d", + "tonalRamp": ["#36100f", "#5b1b1a", "#812725", "#a02f2c", "#c84743", "#ff716d", "#ffaaa7", "#fff0ef"] + } + }, + "typographyMeta": { + "headline": { + "displayName": "Editor Headline", + "purpose": "Sparse project and modal headings." + }, + "transcript": { + "displayName": "Working Transcript", + "purpose": "The dominant editable speech surface." + }, + "mono": { + "displayName": "Editorial Metadata", + "purpose": "Timecodes, confidence, durations, and ruler labels." + } + }, + "shadows": [ + { + "name": "overlay-only", + "value": "0 16px 48px rgba(0, 0, 0, 0.32)", + "purpose": "Reserved for temporary overlays; persistent editor regions remain flat." + } + ], + "motion": [ + { + "name": "state-standard", + "value": "150ms ease", + "purpose": "Color, focus, selection, and progress state changes." + }, + { + "name": "workbench-enter", + "value": "180ms cubic-bezier(0.16, 1, 0.3, 1)", + "purpose": "A 14px horizontal settle for the responsive review overlay." + } + ], + "breakpoints": [ + { + "name": "workbench-overlay", + "value": "1180px" + }, + { + "name": "compact-editor", + "value": "760px" + } + ] + }, + "components": [ + { + "name": "Primary Export Button", + "kind": "button", + "refersTo": "button-primary", + "description": "The mint terminal action for export and confirmed batch operations.", + "html": "", + "css": ".ds-button-primary { height: 32px; padding: 0 12px; border: 1px solid #71d9b0; border-radius: 4px; background: #71d9b0; color: #08110d; font: 600 12px/1 'Onest Variable', 'Segoe UI', sans-serif; transition: background 150ms ease, border-color 150ms ease; } .ds-button-primary:hover { background: #8ee6c2; border-color: #8ee6c2; } .ds-button-primary:focus-visible { outline: 2px solid #71d9b0; outline-offset: 2px; } .ds-button-primary:active { background: #71d9b0; }" + }, + { + "name": "Compact Dark Field", + "kind": "input", + "refersTo": "input-dark", + "description": "A compact workbench field for prompts, filters, and model settings.", + "html": "", + "css": ".ds-field { display: grid; gap: 6px; color: #a6aea8; font: 500 11px/1 'Onest Variable', 'Segoe UI', sans-serif; } .ds-field input { min-height: 32px; padding: 6px 8px; border: 1px solid #2b302d; border-radius: 4px; background: #171a18; color: #f3f5f3; font: 400 12px/1.35 'Onest Variable', 'Segoe UI', sans-serif; } .ds-field input:hover { border-color: #505852; } .ds-field input:focus-visible { border-color: #71d9b0; outline: 2px solid #71d9b0; outline-offset: 2px; }" + }, + { + "name": "Active Transcript Row", + "kind": "custom", + "refersTo": "transcript-row-active", + "description": "A timecoded paper record whose words act as direct video handles.", + "html": "

96%
", + "css": ".ds-transcript-row { display: grid; grid-template-columns: 48px 1fr 40px; gap: 12px; align-items: start; padding: 12px 16px 12px 13px; border-bottom: 1px solid #d7dad5; border-left: 3px solid #23805b; background: rgba(35, 128, 91, 0.10); color: #111411; font: 400 13px/1.5rem 'Onest Variable', 'Segoe UI', sans-serif; } .ds-transcript-row time, .ds-transcript-row output { color: #677069; font: 400 10px/1.5rem 'SFMono-Regular', monospace; } .ds-transcript-row output { text-align: right; } .ds-transcript-row p { margin: 0; } .ds-transcript-row button { padding: 2px 4px; border: 1px solid transparent; border-radius: 3px; background: transparent; color: inherit; font: inherit; } .ds-transcript-row button:hover { border-color: rgba(35, 128, 91, 0.32); } .ds-transcript-row button:focus-visible { outline: 2px solid #71d9b0; outline-offset: 1px; } .ds-transcript-row .ds-word-selected { background: rgba(35, 128, 91, 0.22); }" + }, + { + "name": "Censorship Review Row", + "kind": "custom", + "refersTo": "review-row", + "description": "A hairline-separated profanity match that stays visibly pending until confirmed.", + "html": "
блять00:18.42 · 94% · точное
На проверке
", + "css": ".ds-review-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; gap: 8px; align-items: center; padding: 12px 16px; border-bottom: 1px solid #2b302d; background: #101311; color: #f3f5f3; font: 400 12px/1.35 'Onest Variable', 'Segoe UI', sans-serif; } .ds-review-row div { display: grid; gap: 3px; } .ds-review-row small, .ds-review-row span { color: #a6aea8; font: 400 10px/1.2 'SFMono-Regular', monospace; } .ds-review-row button { min-height: 28px; padding: 0 8px; border: 1px solid #2b302d; border-radius: 4px; background: #171a18; color: #f3f5f3; } .ds-review-row button:hover { border-color: #71d9b0; } .ds-review-row button:focus-visible { outline: 2px solid #71d9b0; outline-offset: 2px; } .ds-review-row .ds-apply { border-color: #71d9b0; color: #71d9b0; }" + }, + { + "name": "Timeline Marker Lane", + "kind": "custom", + "refersTo": "timeline-marker-lane", + "description": "A compact lane aligning the selection, bleep event, and low-confidence marker to transcript time.", + "html": "
", + "css": ".ds-marker-lane { position: relative; height: 28px; border-top: 1px solid #252a27; border-bottom: 1px solid #252a27; background: #0b0d0c; } .ds-marker-lane .ds-range { position: absolute; left: 22%; right: 31%; top: 12px; height: 2px; background: #71d9b0; } .ds-marker-lane i { position: absolute; top: 9px; width: 8px; height: 8px; border-radius: 9999px; background: #71d9b0; } .ds-marker-lane .ds-start { left: calc(22% - 4px); } .ds-marker-lane .ds-end { right: calc(31% - 4px); } .ds-marker-lane .ds-danger { position: absolute; left: 66%; top: 9px; width: 8px; height: 8px; background: #ff716d; transform: rotate(45deg); }" + } + ], + "narrative": { + "northStar": "The Transcript Contact Sheet", + "overview": "ScriptCut treats a long recording like a film lab treats a contact sheet: the black media stage holds the source, while a continuous Working Paper transcript exposes every usable moment as editable evidence. The result is editorial, precise, local-first, and calm under long-form workload. It refuses the dense timeline-first editor wall; words are the primary handles of the video, and the waveform is their instrument of verification.\n\nThe visual world is built from Projection Black, Working Paper, Graphite Rule, and one Live Mint signal. Surfaces remain flat and tool-like. Hairlines, tonal fields, exact alignment, and tightly controlled type establish hierarchy; color and motion appear only when they communicate live position, selection, review, acceptance, or risk.", + "keyCharacteristics": [ + "A black media stage beside a dominant, continuous paper transcript.", + "Dense but breathable editorial typography led by Onest and measured by compact monospace metadata.", + "Graphite hairlines and tonal fields instead of decorative cards or ambient elevation.", + "One mint live signal connecting transcript selection, playhead, accepted action, and primary export.", + "Reversible, reviewable controls that expose state without turning the editor into a dashboard." + ], + "rules": [ + { + "name": "The One Live Signal Rule", + "body": "Live Mint is reserved for present position, selection, acceptance, focus, and the primary terminal action; it does not decorate passive surfaces.", + "section": "colors" + }, + { + "name": "The Working Copy Rule", + "body": "Transcript text may breathe; interface text stays compact. Do not enlarge controls to compete with the words being edited.", + "section": "typography" + }, + { + "name": "The Transcript Dominance Rule", + "body": "Preserve a legible media check and a usable waveform, but give the continuous transcript the largest uninterrupted working field.", + "section": "layout" + }, + { + "name": "The Flat-by-Default Rule", + "body": "A persistent editor region never earns depth from a decorative shadow; use a hairline or a tonal field unless the surface is temporarily overlaying another one.", + "section": "elevation" + }, + { + "name": "The Structural Edge Rule", + "body": "Round the control, not the canvas: never put the transcript, media stage, workbench, or timeline inside large-radius cards.", + "section": "shapes" + } + ], + "dos": [ + "Do keep Working Paper as the largest light field and the transcript as the primary editing surface.", + "Do align selected words, active transcript rows, waveform ranges, and the playhead through the same Live Mint signal.", + "Do use 1px rules and tonal shifts to explain region boundaries before reaching for elevation.", + "Do keep timestamps, confidence, duration, and ruler labels in compact monospace.", + "Do show review, applied, deleted, warning, disabled, and low-confidence states with text or shape as well as color.", + "Do preserve a visible 2px Live Mint keyboard focus outline and reduced-motion behavior." + ], + "donts": [ + "Don't turn the workspace into a grid of floating rounded cards.", + "Don't use Live Mint as decorative fill on passive regions or for unrelated secondary actions.", + "Don't replace the continuous transcript with a conventional timeline-first editing wall.", + "Don't hide deletions, censorship, AI suggestions, or confidence behind invisible automatic state.", + "Don't introduce gradients, ambient shadows, glass effects, or oversized display typography into the operational editor.", + "Don't let responsive behavior collapse the transcript merely to keep the review workbench permanently docked." + ] + } +} diff --git a/.impeccable/editor-surface-brief.md b/.impeccable/editor-surface-brief.md new file mode 100644 index 0000000..fab5f27 --- /dev/null +++ b/.impeccable/editor-surface-brief.md @@ -0,0 +1,33 @@ +# ScriptCut editor surface + +- Mode: Operate. +- Scope: opening state, active editing workspace, transcript, review queue, timeline, settings/export sheets. +- Audience: Russian-speaking streamers editing long VODs on Windows and macOS, often without professional editor knowledge. +- Job: open media, understand transcription progress, edit from words, review suggested cuts/profanity, preview, and export. +- Primary action: make a reversible transcript edit; terminal action: export. +- Direction: editorial film contact sheet adapted from the Runway design analysis. The media stage is black, the transcript is a continuous paper-white working surface, state is communicated by hairlines, typography, and one mint signal. +- Approved comp: `.impeccable/mocks/scriptcut-editor-b-approved.png`. +- Memorable moment: selecting a word synchronously illuminates the transcript row and the exact corresponding waveform range; censorship remains a visible review item instead of an invisible automatic mutation. +- Constraints: preserve all existing capabilities, keyboard use, Russian/English copy, laptop widths, reduced motion, Electron desktop behavior, and test hooks. + +## Implementation inventory + +| Visible ingredient | Commitment | Medium | +| --- | --- | --- | +| Compact top command bar | Project identity/status left; undo, AI, export right; one mint primary action | Semantic React + CSS | +| Black video preview dock | Familiar playback controls, no ornamental frame | Existing `VideoPlayer` + CSS | +| Continuous white transcript canvas | Transcript is the largest light field; timestamps and confidence are quiet metadata; deletions remain legible | Existing `TranscriptEditor` + semantic React/CSS | +| Review queue | AI, profanity, and export tools open in a persistent right workbench without stacked decorative cards | Existing panels in a shared workbench shell | +| Precision waveform | Full-width lower lane with selection, cuts, bleep and current-time signals | Existing canvas + CSS | +| Hairline topology | Regions meet through 1px rules and tonal shifts, not shadows | CSS tokens | +| Mint signal | Active word, playhead, accepted action, and primary export only | CSS tokens | +| ScriptCut brand | Existing mark/wordmark retained, rendered monochrome where required | Existing SVG assets | +| Responsive laptop layout | Right workbench becomes overlay below 1180px; transcript/video remain usable | React state + CSS media queries | +| Motion | One coordinated panel/selection transition, disabled for reduced motion | CSS | + +## Do not literalize from the comp + +- Do not invent media metadata, confidence benchmarks, or automatic apply behavior. +- Do not create permanently visible controls for features that are currently modal/sheet actions. +- Do not rasterize transcript text, waveform, controls, or media. +- Do not copy the comp's English-only labels; product copy should stay creator-friendly and may remain mixed-language until localization is complete. diff --git a/.impeccable/mock-active-ui.js b/.impeccable/mock-active-ui.js new file mode 100644 index 0000000..c10abae --- /dev/null +++ b/.impeccable/mock-active-ui.js @@ -0,0 +1,155 @@ +async page => { + await page.addInitScript(() => { + window.localStorage.setItem('scriptcut.onboarding.dismissed.v1', 'true'); + window.electronAPI = { + getBackendUrl: async () => 'http://127.0.0.1:8642', + getStartupStatus: async () => ({ backendError: null }), + openFile: async () => '/demo/stream.mp4', + openProject: async () => null, + readProjectFile: async () => { + throw new Error('No saved project'); + }, + writeProjectFile: async () => {}, + saveFile: async () => null, + quit: async () => {}, + }; + }); + + const transcript = [ + 'Ребят сегодня разберём почему эта механика вообще не оптимизирована', + 'я сначала думал что всё работает нормально но потом начался полный пиздец', + 'вот здесь мы спокойно поднимаемся наверх и поворачиваем направо', + 'блять этот моб опять появился прямо перед камерой', + 'ладно оставим только разговор про оптимизацию и вырежем лишний гринд', + 'если коротко проблема не в игроке а в том как считается каждый тик', + 'поэтому на слабом компьютере всё начинает тормозить и звук заикается', + 'в финальной нарезке добавим субтитры и запикаем спорные слова', + ]; + let cursor = 0; + const segments = transcript.map((text, id) => { + const segmentWords = text.split(' ').map((word, index) => { + const item = { + word, + start: cursor, + end: cursor + 0.42, + confidence: word === 'пиздец' ? 0.58 : 0.92 + ((index % 6) * 0.01), + }; + cursor += 0.48; + return item; + }); + return { + id, + start: segmentWords[0].start, + end: segmentWords[segmentWords.length - 1].end, + text, + words: segmentWords, + }; + }); + const words = segments.flatMap(segment => segment.words); + + await page.route(/http:\/\/127\.0\.0\.1:8642\/.*/, async route => { + const pathname = route.request().url().replace('http://127.0.0.1:8642', '').split('?')[0]; + if (pathname === '/transcription/engines') { + return route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + default_engine: 'faster-whisper', + default_model: 'smart', + recommended_language: 'ru', + engines: { + 'faster-whisper': { + available: true, + selectable: true, + default_model: 'smart', + label: 'ScriptCut Smart Transcript', + }, + }, + }), + }); + } + if (pathname === '/system/checks') { + const ready = label => ({ ok: true, label, detail: 'Ready' }); + return route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + status: 'ready', + checks: { + backend: ready('Local backend'), + python: ready('Python'), + ffmpeg: ready('FFmpeg'), + captions: ready('Burn-in captions'), + transcription: ready('Transcription'), + audio: ready('Studio Sound'), + background: ready('Background removal'), + }, + }), + }); + } + if (pathname === '/background/capabilities') { + return route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + available: false, + models: [], + devices: [], + message: 'Synthetic QA fixture', + }), + }); + } + if (pathname === '/jobs/transcribe') { + return route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ job_id: 'demo' }), + }); + } + if (pathname === '/jobs/demo') { + return route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + status: 'succeeded', + progress: 100, + message: 'Smart Transcript готов', + result: { + words, + segments, + language: 'ru', + engine: 'faster-whisper', + model: 'small', + requested_model: 'smart', + }, + }), + }); + } + if (pathname === '/audio/waveform') { + const peaks = Array.from({ length: 1200 }, (_, index) => { + const phrase = + Math.abs(Math.sin(index / 17)) * 0.38 + + Math.abs(Math.sin(index / 43)) * 0.28 + + Math.abs(Math.sin(index / 7)) * 0.12; + const silenceGate = index % 173 > 142 ? 0.14 : 1; + const power = Math.min(0.92, 0.06 + phrase * silenceGate); + return [-power, power]; + }); + return route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ duration: 40, peaks }), + }); + } + if (pathname === '/file') { + return route.fulfill({ + status: 200, + contentType: 'video/mp4', + path: '/Users/alice/Documents/streams/.impeccable/mocks/scriptcut-demo-media.mp4', + }); + } + return route.fulfill({ status: 404, contentType: 'application/json', body: '{}' }); + }); + + await page.reload(); + await page.getByRole('button', { name: 'Открыть полный стрим' }).click(); + await page.waitForTimeout(1200); + const firstApply = page.getByRole('button', { name: 'Применить', exact: true }).first(); + if (await firstApply.isVisible()) await firstApply.click(); + await page.locator('[data-word-index="19"]').click(); + await page.waitForTimeout(300); +} diff --git a/.impeccable/mocks/scriptcut-demo-frame.png b/.impeccable/mocks/scriptcut-demo-frame.png new file mode 100644 index 0000000..bfbf354 Binary files /dev/null and b/.impeccable/mocks/scriptcut-demo-frame.png differ diff --git a/.impeccable/mocks/scriptcut-demo-media.mp4 b/.impeccable/mocks/scriptcut-demo-media.mp4 new file mode 100644 index 0000000..833dfcd Binary files /dev/null and b/.impeccable/mocks/scriptcut-demo-media.mp4 differ diff --git a/.impeccable/mocks/scriptcut-editor-a.png b/.impeccable/mocks/scriptcut-editor-a.png new file mode 100644 index 0000000..7784755 Binary files /dev/null and b/.impeccable/mocks/scriptcut-editor-a.png differ diff --git a/.impeccable/mocks/scriptcut-editor-a.png.json b/.impeccable/mocks/scriptcut-editor-a.png.json new file mode 100644 index 0000000..b73feb4 --- /dev/null +++ b/.impeccable/mocks/scriptcut-editor-a.png.json @@ -0,0 +1,7 @@ +{ + "useCase": "ui-mockup", + "surface": "ScriptCut desktop editor", + "composition": "video-first split with a floating profanity review tray", + "visualWorld": "Runway-inspired editorial restraint, black media stage, paper transcript, mint signal", + "approved": false +} diff --git a/.impeccable/mocks/scriptcut-editor-b-approved.png b/.impeccable/mocks/scriptcut-editor-b-approved.png new file mode 100644 index 0000000..e020c8a Binary files /dev/null and b/.impeccable/mocks/scriptcut-editor-b-approved.png differ diff --git a/.impeccable/mocks/scriptcut-editor-b-approved.png.json b/.impeccable/mocks/scriptcut-editor-b-approved.png.json new file mode 100644 index 0000000..b3f9d49 --- /dev/null +++ b/.impeccable/mocks/scriptcut-editor-b-approved.png.json @@ -0,0 +1,8 @@ +{ + "useCase": "ui-mockup", + "surface": "ScriptCut desktop editor", + "composition": "transcript-first three-column workspace with persistent review queue and bottom waveform", + "visualWorld": "Runway-inspired editorial restraint, black media stage, paper transcript, mint signal", + "approvalEvidence": "User delegated the choice; selected because transcript editing is ScriptCut's unique mechanism and the persistent review queue makes AI and censorship reversible.", + "approved": true +} diff --git a/.impeccable/mocks/scriptcut-editor-c.png b/.impeccable/mocks/scriptcut-editor-c.png new file mode 100644 index 0000000..b4ff10d Binary files /dev/null and b/.impeccable/mocks/scriptcut-editor-c.png differ diff --git a/.impeccable/mocks/scriptcut-editor-c.png.json b/.impeccable/mocks/scriptcut-editor-c.png.json new file mode 100644 index 0000000..f343728 --- /dev/null +++ b/.impeccable/mocks/scriptcut-editor-c.png.json @@ -0,0 +1,7 @@ +{ + "useCase": "ui-mockup", + "surface": "ScriptCut desktop editor", + "composition": "video-first cut desk with horizontal transcript strip and multi-lane timeline", + "visualWorld": "Runway-inspired editorial restraint, black media stage, paper transcript, mint signal", + "approved": false +} diff --git a/.impeccable/mocks/scriptcut-empty-desktop.png b/.impeccable/mocks/scriptcut-empty-desktop.png new file mode 100644 index 0000000..2af8bbc Binary files /dev/null and b/.impeccable/mocks/scriptcut-empty-desktop.png differ diff --git a/.impeccable/mocks/scriptcut-empty-laptop.png b/.impeccable/mocks/scriptcut-empty-laptop.png new file mode 100644 index 0000000..03e66fd Binary files /dev/null and b/.impeccable/mocks/scriptcut-empty-laptop.png differ diff --git a/.impeccable/mocks/scriptcut-verdict2-desktop.png b/.impeccable/mocks/scriptcut-verdict2-desktop.png new file mode 100644 index 0000000..1e491c7 Binary files /dev/null and b/.impeccable/mocks/scriptcut-verdict2-desktop.png differ diff --git a/.impeccable/mocks/scriptcut-verdict2-laptop.png b/.impeccable/mocks/scriptcut-verdict2-laptop.png new file mode 100644 index 0000000..7831036 Binary files /dev/null and b/.impeccable/mocks/scriptcut-verdict2-laptop.png differ diff --git a/.impeccable/surfaces/frontend-src-app-tsx.md b/.impeccable/surfaces/frontend-src-app-tsx.md new file mode 100644 index 0000000..9a9581c --- /dev/null +++ b/.impeccable/surfaces/frontend-src-app-tsx.md @@ -0,0 +1,40 @@ +--- +version: 1 +slug: "frontend-src-app-tsx" +primary_target: "frontend/src/App.tsx" +related_targets: ["frontend/src/components/TranscriptEditor.tsx","frontend/src/components/WaveformTimeline.tsx","frontend/src/components/AIPanel.tsx","frontend/src/components/SettingsPanel.tsx","frontend/src/components/ExportDialog.tsx"] +--- + +# ScriptCut editor surface + +- Mode: Operate. +- Scope: opening state, active editing workspace, transcript, review queue, timeline, settings/export sheets. +- Audience: Russian-speaking streamers editing long VODs on Windows and macOS, often without professional editor knowledge. +- Job: open media, understand transcription progress, edit from words, review suggested cuts/profanity, preview, and export. +- Primary action: make a reversible transcript edit; terminal action: export. +- Direction: editorial film contact sheet adapted from the Runway design analysis. The media stage is black, the transcript is a continuous paper-white working surface, state is communicated by hairlines, typography, and one mint signal. +- Approved comp: `.impeccable/mocks/scriptcut-editor-b-approved.png`. +- Memorable moment: selecting a word synchronously illuminates the transcript row and the exact corresponding waveform range; censorship remains a visible review item instead of an invisible automatic mutation. +- Constraints: preserve all existing capabilities, keyboard use, Russian/English copy, laptop widths, reduced motion, Electron desktop behavior, and test hooks. + +## Implementation inventory + +| Visible ingredient | Commitment | Medium | +| --- | --- | --- | +| Compact top command bar | Project identity/status left; undo, AI, export right; one mint primary action | Semantic React + CSS | +| Black video preview dock | Familiar playback controls, no ornamental frame | Existing `VideoPlayer` + CSS | +| Continuous white transcript canvas | Transcript is the largest light field; timestamps and confidence are quiet metadata; deletions remain legible | Existing `TranscriptEditor` + semantic React/CSS | +| Review queue | AI, profanity, and export tools open in a persistent right workbench without stacked decorative cards | Existing panels in a shared workbench shell | +| Precision waveform | Full-width lower lane with selection, cuts, bleep and current-time signals | Existing canvas + CSS | +| Hairline topology | Regions meet through 1px rules and tonal shifts, not shadows | CSS tokens | +| Mint signal | Active word, playhead, accepted action, and primary export only | CSS tokens | +| ScriptCut brand | Existing mark/wordmark retained, rendered monochrome where required | Existing SVG assets | +| Responsive laptop layout | Right workbench becomes overlay below 1180px; transcript/video remain usable | React state + CSS media queries | +| Motion | One coordinated panel/selection transition, disabled for reduced motion | CSS | + +## Do not literalize from the comp + +- Do not invent media metadata, confidence benchmarks, or automatic apply behavior. +- Do not create permanently visible controls for features that are currently modal/sheet actions. +- Do not rasterize transcript text, waveform, controls, or media. +- Do not copy the comp's English-only labels; product copy should stay creator-friendly and may remain mixed-language until localization is complete. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..9f808db --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,318 @@ +--- +name: ScriptCut +description: A transcript-first, local media editor built as a precise film-lab contact sheet. +colors: + editor-bg: "#0b0d0c" + editor-panel: "#101311" + editor-surface: "#171a18" + editor-border: "#2b302d" + editor-accent: "#71d9b0" + editor-accent-hover: "#8ee6c2" + editor-accent-ink: "#08110d" + editor-text: "#f3f5f3" + editor-text-muted: "#a6aea8" + editor-paper: "#f4f4ef" + editor-paper-soft: "#e9ebe6" + editor-ink: "#111411" + editor-ink-muted: "#677069" + editor-danger: "#ff716d" + editor-success: "#71d9b0" + editor-warning: "#e7bd63" + editor-word-hover: "rgba(35, 128, 91, 0.10)" + editor-word-selected: "rgba(35, 128, 91, 0.22)" + editor-word-deleted: "rgba(193, 57, 54, 0.14)" + editor-word-filler: "rgba(174, 119, 24, 0.18)" + transcript-rule: "#d7dad5" + paper-signal: "#176b4c" + paper-signal-rule: "#23805b" + paper-danger: "#a02f2c" + paper-warning: "#855d11" + paper-placeholder: "#7d867f" + timeline-rule: "#252a27" + waveform: "#69716b" +typography: + headline: + fontFamily: "Onest Variable, Onest, Segoe UI Variable, Segoe UI, ui-sans-serif, sans-serif" + fontSize: "1.35rem" + fontWeight: 500 + lineHeight: "normal" + letterSpacing: "-0.025em" + title: + fontFamily: "Onest Variable, Onest, Segoe UI Variable, Segoe UI, ui-sans-serif, sans-serif" + fontSize: "0.875rem" + fontWeight: 600 + lineHeight: "1.25rem" + letterSpacing: "-0.02em" + transcript: + fontFamily: "Onest Variable, Onest, Segoe UI Variable, Segoe UI, ui-sans-serif, sans-serif" + fontSize: "0.8125rem" + fontWeight: 400 + lineHeight: "1.5rem" + letterSpacing: "normal" + body: + fontFamily: "Onest Variable, Onest, Segoe UI Variable, Segoe UI, ui-sans-serif, sans-serif" + fontSize: "0.75rem" + fontWeight: 400 + lineHeight: "1rem" + letterSpacing: "normal" + label: + fontFamily: "Onest Variable, Onest, Segoe UI Variable, Segoe UI, ui-sans-serif, sans-serif" + fontSize: "0.6875rem" + fontWeight: 500 + lineHeight: "1rem" + letterSpacing: "normal" + mono: + fontFamily: "SFMono-Regular, Cascadia Mono, Consolas, monospace" + fontSize: "0.625rem" + fontWeight: 400 + lineHeight: "normal" + letterSpacing: "normal" +rounded: + default: "4px" + md: "6px" + lg: "8px" + full: "9999px" +spacing: + "0.5": "2px" + "1": "4px" + "1.5": "6px" + "2": "8px" + "2.5": "10px" + "3": "12px" + "4": "16px" + "5": "20px" +components: + button-primary: + backgroundColor: "{colors.editor-accent}" + textColor: "{colors.editor-accent-ink}" + typography: "{typography.body}" + rounded: "{rounded.default}" + padding: "0 12px" + height: "32px" + button-ghost: + backgroundColor: "transparent" + textColor: "{colors.editor-text-muted}" + typography: "{typography.body}" + rounded: "{rounded.default}" + padding: "0 12px" + height: "32px" + input-search: + backgroundColor: "{colors.editor-paper-soft}" + textColor: "{colors.editor-ink}" + typography: "{typography.body}" + rounded: "{rounded.default}" + padding: "4px 8px" + height: "26px" + word-handle-selected: + backgroundColor: "{colors.editor-word-selected}" + textColor: "{colors.editor-ink}" + typography: "{typography.transcript}" + rounded: "{rounded.default}" + padding: "2px 4px" + tab-review-active: + backgroundColor: "transparent" + textColor: "{colors.editor-accent}" + typography: "{typography.body}" + rounded: "0" + padding: "10px 12px" + segmented-option-active: + backgroundColor: "rgba(113, 217, 176, 0.15)" + textColor: "{colors.editor-accent}" + typography: "{typography.label}" + rounded: "{rounded.default}" + padding: "8px" + transcript-row: + backgroundColor: "{colors.editor-paper}" + textColor: "{colors.editor-ink}" + typography: "{typography.transcript}" + rounded: "0" + padding: "12px 16px" + review-row: + backgroundColor: "{colors.editor-panel}" + textColor: "{colors.editor-text}" + typography: "{typography.body}" + rounded: "0" + padding: "12px 16px" + video-control-primary: + backgroundColor: "{colors.editor-paper}" + textColor: "{colors.editor-ink}" + rounded: "{rounded.default}" + size: "32px" + height: "32px" + width: "32px" +--- + +# Design System: ScriptCut + +## Overview + +**Creative North Star: "The Transcript Contact Sheet"** + +ScriptCut treats a long recording like a film lab treats a contact sheet: the black media stage holds the source, while a continuous Working Paper transcript exposes every usable moment as editable evidence. The result is editorial, precise, local-first, and calm under long-form workload. It refuses the dense timeline-first editor wall; words are the primary handles of the video, and the waveform is their instrument of verification. + +The visual world is built from Projection Black, Working Paper, Graphite Rule, and one Live Mint signal. Surfaces remain flat and tool-like. Hairlines, tonal fields, exact alignment, and tightly controlled type establish hierarchy; color and motion appear only when they communicate live position, selection, review, acceptance, or risk. + +**Key Characteristics:** + +- A black media stage beside a dominant, continuous paper transcript. +- Dense but breathable editorial typography led by Onest and measured by compact monospace metadata. +- Graphite hairlines and tonal fields instead of decorative cards or ambient elevation. +- One mint live signal connecting transcript selection, playhead, accepted action, and primary export. +- Reversible, reviewable controls that expose state without turning the editor into a dashboard. + +## Colors + +The palette behaves like a dark projection room wrapped around a marked-up paper transcript: near-neutrals carry structure, while mint is scarce enough to remain meaningful. + +### Primary + +- **Live Mint** (`editor-accent`, #71d9b0): primary export, active review tabs, selected timeline spans, progress, focus, and accepted states. +- **Live Mint Hover** (`editor-accent-hover`, #8ee6c2): hover response for solid mint actions. +- **Paper Signal** (`paper-signal`, #176b4c): accessible mint-derived text on Working Paper. +- **Paper Signal Rule** (`paper-signal-rule`, #23805b): the inset edge on an active transcript row. + +### Neutral + +- **Projection Black** (`editor-bg`, #0b0d0c): application shell and video-stage ground. +- **Workbench Black** (`editor-panel`, #101311): persistent review and waveform surfaces. +- **Graphite Surface** (`editor-surface`, #171a18): dark fields, menus, and quiet control fills. +- **Graphite Rule** (`editor-border`, #2b302d): primary dark-surface dividers and control strokes. +- **Working Paper** (`editor-paper`, #f4f4ef): the continuous transcript canvas and light primary video control. +- **Soft Paper** (`editor-paper-soft`, #e9ebe6): search and selected-action fields inside the transcript. +- **Transcript Rule** (`transcript-rule`, #d7dad5): row boundaries on Working Paper. +- **Projection Text** (`editor-text`, #f3f5f3) and **Projection Muted** (`editor-text-muted`, #a6aea8): dark-surface foreground hierarchy. +- **Carbon Ink** (`editor-ink`, #111411), **Graphite Ink** (`editor-ink-muted`, #677069), and **Paper Placeholder** (`paper-placeholder`, #7d867f): transcript foreground hierarchy. +- **Timeline Rule** (`timeline-rule`, #252a27) and **Waveform Graphite** (`waveform`, #69716b): precision lanes, ticks, and audio amplitude. + +### Functional + +- **Cut Red** (`editor-danger`, #ff716d) and **Paper Cut Red** (`paper-danger`, #a02f2c): deletions, low confidence, errors, and destructive review. +- **Review Amber** (`editor-warning`, #e7bd63) and **Paper Review Amber** (`paper-warning`, #855d11): room tone, uncertainty, and warnings. +- **Accepted Mint** (`editor-success`, #71d9b0): confirmed, complete, or applied status. + +### State Washes + +- **Word Hover** (`editor-word-hover`, rgba(35, 128, 91, 0.10)): the lightest paper interaction wash. +- **Word Selected** (`editor-word-selected`, rgba(35, 128, 91, 0.22)): the stronger selected-word fill. +- **Word Deleted** (`editor-word-deleted`, rgba(193, 57, 54, 0.14)): reversible deletion history. +- **Word Filler** (`editor-word-filler`, rgba(174, 119, 24, 0.18)): reviewable filler or hesitation state. + +**The One Live Signal Rule.** Live Mint is reserved for present position, selection, acceptance, focus, and the primary terminal action; it does not decorate passive surfaces. + +## Typography + +**Display Font:** Onest Variable (with Onest, Segoe UI Variable, Segoe UI, and system sans-serif fallbacks) +**Body Font:** Onest Variable (with the same fallbacks) +**Label/Mono Font:** SFMono-Regular (with Cascadia Mono, Consolas, and monospace fallbacks) + +**Character:** Onest keeps dense Russian and mixed-language editing copy contemporary and open without feeling consumer-soft. Monospace is strictly instrumental: timecodes, confidence, duration, coordinates, and ruler labels. + +### Hierarchy + +- **Headline** (500, 1.35rem, normal): the Smart Transcript heading; the single largest operational title. +- **Title** (600, 0.875rem, 1.25rem): product identity and compact region titles. +- **Transcript** (400, 0.8125rem, 1.5rem): the editable word stream, with generous line-height for targeting individual words. +- **Body** (400, 0.75rem, 1rem): controls, review explanations, buttons, and workbench copy. +- **Label** (500, 0.6875rem, 1rem): compact field labels and review metadata. +- **Mono** (400, 0.625rem, normal): timestamps, confidence values, timeline ticks, and numeric status. + +**The Working Copy Rule.** Transcript text may breathe; interface text stays compact. Do not enlarge controls to compete with the words being edited. + +## Layout + +The editor is a fixed-height desktop workbench. A compact command bar (56px) spans the top. Below it, a narrow media dock occupies 34% of the main lane with a 320px minimum and 520px maximum; the transcript takes all remaining central width. The review workbench is a fixed 360px right rail, and the precision waveform forms a 192px lower lane beneath media and transcript. + +Spacing follows the implemented 2, 4, 6, 8, 10, 12, 16, and 20px rhythm. The transcript rows use a stable three-column measure—60px timecode, fluid words, 48px confidence—with 12px gaps and 12px by 16px row padding. Major regions meet edge-to-edge so the largest light field reads as one continuous sheet, not a stack of cards. + +At 1180px and below, the review workbench becomes a right overlay at `min(390px, 44vw)` and the media dock becomes 38% wide with a 300px minimum. At 860px and below, the workbench becomes `min(430px, 58vw)` and the media dock becomes 42% wide with a 250px minimum. Compact metadata begins hiding at the 640px utility breakpoint. At 1280px and above, the AI workbench opens by default. + +**The Transcript Dominance Rule.** Preserve a legible media check and a usable waveform, but give the continuous transcript the largest uninterrupted working field. + +## Elevation & Depth + +The system is flat and structural at rest. Depth comes from Projection Black against Working Paper, small tonal steps between dark surfaces, and 1px rules. Shadows are limited to transient overlays: the narrow-width workbench casts a directional shadow back into the editor, the more-tools menu uses the framework's extra-large floating shadow, and the reframe preview uses an oversized mask shadow to dim excluded media. + +### Shadow Vocabulary + +- **Workbench Overlay** (`-24px 0 48px rgba(0, 0, 0, 0.32)`): separates the review rail only when it overlays the workspace below 1180px. +- **Menu Float** (`0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10)`): applies only to the transient more-tools menu. +- **Reframe Mask** (`0 0 0 9999px rgba(0, 0, 0, 0.28)`): dims media outside a vertical or square safe frame; it is a viewport tool, not surface elevation. + +**The Flat-by-Default Rule.** A persistent editor region never earns depth from a decorative shadow; use a hairline or a tonal field unless the surface is temporarily overlaying another one. + +## Shapes + +The form language is predominantly rectilinear. Workspace regions, transcript rows, review rows, and timeline lanes have square corners and meet on graphite hairlines. Small interactive targets use restrained 4px corners; compact menus use 6px corners; only larger first-run actions reach 8px. Circular geometry is reserved for playhead knobs, checkboxes, and other genuinely radial indicators. + +Word handles use a 3px corner in the shipped stylesheet, just softer than the surrounding paper grid. This slight rounding helps each word read as a manipulable handle without turning the transcript into a cloud of pills. + +**The Structural Edge Rule.** Round the control, not the canvas: never put the transcript, media stage, workbench, or timeline inside large-radius cards. + +## Components + +Components are restrained, reviewable, and tool-like. Their states are carried by tone, a precise border, and sparse mint—not by ornamental depth. + +### Buttons + +- **Shape:** compact controls use restrained corners (4px); the topbar action height is 32px with 12px horizontal padding. +- **Primary:** Live Mint with Accent Ink, used for Export and confirmed batch actions. +- **Hover / Focus:** solid mint actions shift to Live Mint Hover; every keyboard focus uses a 2px Live Mint outline with a 2px offset. Pressed state stays flat. +- **Ghost:** transparent with Projection Muted text; hover introduces Graphite Surface and Projection Text. +- **Icon Control:** 32px square. The video play control inverts to Working Paper and Carbon Ink; active editing tools use a low-opacity mint field. + +### Chips + +- **Style:** word handles are text-sized chips only during interaction, with 3px corners, 2px by 4px padding, and a transparent border at rest. +- **State:** hover adds the faintest green rule; selection uses Word Selected; active playback uses a lighter mint wash; deletion remains visible through Cut Red wash and strike-through. + +### Cards / Containers + +- **Corner Style:** persistent rows remain square; only bounded status blocks and transient controls use 4–8px corners. +- **Background:** review rows stay on Workbench Black; bounded secondary fields may use Graphite Surface. Transcript rows stay on Working Paper or a mint active wash. +- **Shadow Strategy:** none at rest; see Elevation & Depth for overlay-only exceptions. +- **Border:** 1px Graphite Rule on dark surfaces or Transcript Rule on paper. +- **Internal Padding:** review and transcript rows use 12px by 16px; compact field groups use 8–12px. + +### Inputs / Fields + +- **Style:** 1px structural stroke, 4px corners, compact 4–8px padding, and a tonal fill appropriate to the material: Soft Paper in the transcript, Graphite Surface in the workbench. +- **Focus:** border shifts to Live Mint; the global keyboard focus outline remains visible. +- **Error / Disabled:** errors use Cut Red text with a translucent red field; disabled controls remain present at 40–50% opacity and keep their label. + +### Navigation + +The command bar is a single 56px black strip with ScriptCut identity and project state left, then open/save, undo/redo, AI, export, and overflow actions right. Review tabs divide the workbench evenly; the active tab uses mint text and a 2px bottom rule, while inactive tabs remain muted and borderless. Below 1180px the workbench navigation moves with the overlay rather than collapsing into a second navigation system. + +### Transcript Row + +Each row is a three-column editorial record: monospace timecode, a fluid word stream, and monospace confidence. Rows are divided by a Transcript Rule; the active row receives a pale mint wash and a 3px Paper Signal Rule on the left. Search, selection, edits, and low confidence remain independently legible at word level. + +### Review Row + +Review rows use full-width hairline-separated bands, not stacked cards. They lead with the matched text, then time/source/confidence metadata, an explicit applied state, and paired Verify/Apply actions. Suggestions never look applied until the creator confirms them. + +### Waveform Timeline + +The lower lane combines a compact label/control header, ruler, waveform, edit-event lane, and marker lane. Graphite amplitude is neutral; mint selection and playhead, Cut Red deletion or low confidence, and Review Amber room tone line up precisely with transcript time. + +Motion is terse: common color and progress transitions use 150ms standard easing; the workbench enters in 180ms with a 14px horizontal settle and `cubic-bezier(0.16, 1, 0.3, 1)`. All animation and transition durations collapse under reduced-motion preference. + +## Do's and Don'ts + +### Do: + +- **Do** keep Working Paper as the largest light field and the transcript as the primary editing surface. +- **Do** align selected words, active transcript rows, waveform ranges, and the playhead through the same Live Mint signal. +- **Do** use 1px rules and tonal shifts to explain region boundaries before reaching for elevation. +- **Do** keep timestamps, confidence, duration, and ruler labels in compact monospace. +- **Do** show review, applied, deleted, warning, disabled, and low-confidence states with text or shape as well as color. +- **Do** preserve a visible 2px Live Mint keyboard focus outline and reduced-motion behavior. + +### Don't: + +- **Don't** turn the workspace into a grid of floating rounded cards. +- **Don't** use Live Mint as decorative fill on passive regions or for unrelated secondary actions. +- **Don't** replace the continuous transcript with a conventional timeline-first editing wall. +- **Don't** hide deletions, censorship, AI suggestions, or confidence behind invisible automatic state. +- **Don't** introduce gradients, ambient shadows, glass effects, or oversized display typography into the operational editor. +- **Don't** let responsive behavior collapse the transcript merely to keep the review workbench permanently docked. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..1dc59dc --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,69 @@ +# Product + + + +## Platform + +web + +## Users + +- Primary: Russian-speaking Twitch streamers and their friends who turn long conversational VODs into compilations and vertical clips without learning a traditional timeline editor. +- Secondary: creators who want a local-first, transcript-led workflow for YouTube Shorts, TikTok, Reels, podcasts, and social publishing. +- Inferred from the supplied brief and current repository: users may be non-technical and should not need to understand Python, FFmpeg, speech-model installation, or API billing to complete a first edit. + +## Product Purpose + +ScriptCut turns a local video into a word-timed transcript, lets the creator edit the recording by editing words, proposes topic-based cuts, detects speech that should be censored, and exports creator-ready videos and social packages. Success means a creator can go from a long VOD to a reviewable edit quickly while retaining manual control before destructive-looking changes are applied. + +## Positioning + +ScriptCut is a creator-owned, local-first editor where transcript, AI suggestions, profanity review, captions, clip packaging, and video export stay inside one reversible desktop workflow. AI is optional; the core edit and export path remains usable without a paid cloud provider. + +## Operating Context + +- The main workflow starts after a Twitch or conversational stream has ended and a local recording is available. +- Creators work on Windows 10/11 x64 or macOS, often with long media files and consumer hardware. +- Transcription models may download on first use; packaged builds include the local backend and FFmpeg. +- Topic edits, filler removal, profanity censorship, caption styling, vertical reframing, and export are reviewed in the desktop editor. +- Original media remains local. Transcript text leaves the device only when the user deliberately invokes a cloud AI provider. + +## Capabilities and Constraints + +- Word-level transcription, waveform, transcript editing, undo/redo, non-destructive edit operations, preview, autosave, recovery, and project files. +- Russian profanity and custom-phrase censorship with bleep, silence, or room tone. +- Optional local and cloud AI providers, including a Codex/ChatGPT-plan integration. +- Creator exports for source, Shorts, TikTok/Reels, and square podcast formats, with captions and social packaging. +- macOS Apple Silicon and Windows 10/11 x64 are the verified desktop targets; browser mode is for development. +- Long recordings must be processed with bounded memory and understandable progress, cancellation, retry, and recovery. +- Inferred constraint: defaults must favor reliable Russian transcription and censorship over the smallest download, while exposing a faster low-resource mode. + +## Brand Commitments + +- Product name: ScriptCut. +- Voice: direct, practical, creator-friendly, and non-judgmental. Error states explain what happened and the next action without blaming the user. +- Existing ScriptCut mark and wordmark remain recognizable. +- The application is open source and local-first. +- Visual direction for this redesign was delegated by the user. The selected external reference is Runway's media-centric design-system analysis from getdesign.md, adapted for an operational desktop editor rather than copied as a marketing page. + +## Evidence on Hand + +- Working React/Electron/FastAPI application and smoke suites in this repository. +- Existing ScriptCut wordmark and mark under `frontend/public/brand/`. +- Creator workflow documentation under `docs/`. +- No approved customer testimonials, benchmark corpus, measured transcription-accuracy claim, or licensed product photography is present; future surfaces must not fabricate them. + +## Product Principles + +1. Show the media, transcript, and current edit state before secondary controls. +2. Propose edits automatically, but require review and make every change reversible. +3. Pick a trustworthy local default; hide model plumbing until a creator asks for it. +4. Explain long-running work in plain language with progress, cancellation, and recovery. +5. Treat censorship as an assisted review workflow, never as an infallible automatic judgment. + +## Accessibility & Inclusion + +- Maintain keyboard-first editing, visible focus states, readable contrast, and non-color status cues. +- Primary controls must remain usable at common laptop widths on both macOS and Windows. +- Russian creator copy and mixed Russian/English technical terms must not break layout. +- Motion must respect reduced-motion preferences. diff --git a/ROADMAP.md b/ROADMAP.md index 54e771e..4f59573 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,6 +26,12 @@ - Subscription-login verification that refuses accidental API-key billing - Sandboxed, ephemeral Codex transcript analysis with Luna as the default +## v0.1.4 + +- Smart Transcript with device-aware Faster Whisper models and Russian decoding hints +- Morphology-aware Russian censorship for disguised, stretched, and split profanity +- Transcript-first Runway-inspired editing workspace with clearer first-run guidance + ## v0.2.0 - Better AI clipping workflow diff --git a/backend/launcher.py b/backend/launcher.py index 304828e..1d40902 100644 --- a/backend/launcher.py +++ b/backend/launcher.py @@ -4,11 +4,25 @@ import argparse import multiprocessing +import os import uvicorn +def configure_certificate_bundle() -> None: + """Give frozen HTTP clients an explicit, bundled CA path.""" + try: + import certifi + + certificate_bundle = certifi.where() + os.environ.setdefault("SSL_CERT_FILE", certificate_bundle) + os.environ.setdefault("REQUESTS_CA_BUNDLE", certificate_bundle) + except (ImportError, OSError): + pass + + def main() -> None: + configure_certificate_bundle() from main import app parser = argparse.ArgumentParser(description="ScriptCut local backend") diff --git a/backend/requirements.txt b/backend/requirements.txt index f24573f..7c5c9a8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -24,6 +24,7 @@ numpy>=1.24.0 openai>=1.50.0 anthropic>=0.39.0 requests>=2.28.0 +certifi>=2024.8.30 # Noise cleanup and background removal have FFmpeg/disabled fallbacks. Their # heavy native packages live in requirements-optional.txt so a fresh install diff --git a/backend/routers/transcribe.py b/backend/routers/transcribe.py index a89428d..2e849a5 100644 --- a/backend/routers/transcribe.py +++ b/backend/routers/transcribe.py @@ -15,7 +15,7 @@ class TranscribeRequest(BaseModel): file_path: str - model: str = "base" + model: str = "smart" engine: str = "auto" language: Optional[str] = None use_gpu: bool = True @@ -43,13 +43,18 @@ def progress(percent: int, message: str): "whisper": "Whisper", "parakeet": "Parakeet", }.get(req.engine, req.engine) - progress( - 5, - ( - f"Loading {engine_label} model '{req.model}'. " - "On first use, an available engine downloads its speech model automatically." - ), - ) + if req.model == "smart": + progress( + 5, + "Smart Transcript выбирает оптимальную модель для этого компьютера. " + "При первом запуске модель речи скачается автоматически.", + ) + else: + progress( + 5, + f"Загружаем {engine_label}, модель «{req.model}». " + "При первом запуске модель речи скачается автоматически.", + ) result = transcribe_audio( file_path=req.file_path, model_name=req.model, @@ -57,10 +62,11 @@ def progress(percent: int, message: str): use_gpu=req.use_gpu, use_cache=req.use_cache, language=req.language, + progress_callback=progress, ) if req.diarize and req.hf_token: - progress(75, "Labeling speakers") + progress(75, "Определяем говорящих") result = diarize_and_label( transcription_result=result, audio_path=req.file_path, @@ -69,7 +75,7 @@ def progress(percent: int, message: str): use_gpu=req.use_gpu, ) - progress(100, "Transcription complete") + progress(100, "Расшифровка готова") return result except FileNotFoundError: diff --git a/backend/scripts/smoke_backend.py b/backend/scripts/smoke_backend.py index ac2656f..249429e 100644 --- a/backend/scripts/smoke_backend.py +++ b/backend/scripts/smoke_backend.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import time import unittest import subprocess @@ -74,6 +75,11 @@ def test_faster_whisper_cuda_failure_falls_back_to_cpu(self) -> None: try: with ( patch.object(transcription, "FASTER_WHISPER_AVAILABLE", True), + patch.object( + transcription, + "_resolve_faster_whisper_model_source", + return_value="/models/base", + ), patch.object( transcription, "WhisperModel", @@ -99,6 +105,11 @@ def test_faster_whisper_uses_supported_cuda_compute_type(self) -> None: with ( patch.dict(sys.modules, {"ctranslate2": fake_ctranslate2}), patch.object(transcription, "FASTER_WHISPER_AVAILABLE", True), + patch.object( + transcription, + "_resolve_faster_whisper_model_source", + return_value="/models/base", + ), patch.object(transcription, "WhisperModel", return_value=fake_model) as model_factory, ): self.assertIs( @@ -110,6 +121,58 @@ def test_faster_whisper_uses_supported_cuda_compute_type(self) -> None: finally: transcription._model_cache.clear() + def test_faster_whisper_prefers_complete_local_snapshot_without_network(self) -> None: + with patch.object( + transcription, + "download_faster_whisper_model", + return_value="/cache/small", + ) as download: + source = transcription._resolve_faster_whisper_model_source("small") + + self.assertEqual(source, "/cache/small") + download.assert_called_once_with("small", local_files_only=True) + + def test_faster_whisper_model_download_retries_and_resumes(self) -> None: + progress: list[tuple[int, str]] = [] + with ( + patch.object( + transcription, + "download_faster_whisper_model", + side_effect=[ + RuntimeError("not cached"), + ConnectionError("SSL EOF"), + "/cache/small", + ], + ) as download, + patch.object(transcription.time, "sleep") as sleep, + ): + source = transcription._resolve_faster_whisper_model_source( + "small", + lambda percent, message: progress.append((percent, message)), + ) + + self.assertEqual(source, "/cache/small") + self.assertEqual(download.call_count, 3) + sleep.assert_called_once_with(1) + self.assertTrue(any("попытка 2 из 3" in message for _, message in progress)) + + def test_faster_whisper_model_download_uses_safe_russian_error(self) -> None: + with ( + patch.object( + transcription, + "download_faster_whisper_model", + side_effect=ConnectionError("SSL EOF from private network details"), + ), + patch.object(transcription.time, "sleep"), + ): + with self.assertRaises(transcription.ModelDownloadError) as raised: + transcription._resolve_faster_whisper_model_source("small") + + message = str(raised.exception) + self.assertIn("Не удалось скачать локальную модель речи", message) + self.assertIn("Повторить расшифровку", message) + self.assertNotIn("private network details", message) + def test_faster_whisper_inference_float16_failure_retries_original_video_on_cpu(self) -> None: class FailingGpuModel: def transcribe(self, _audio_path, **_options): @@ -158,7 +221,10 @@ def transcribe(self, audio_path, **_options): self.assertEqual(result["words"][0]["word"], "hello") self.assertEqual(seen_paths, [str(video_path)]) - self.assertEqual(load_model.call_args_list[1].args, ("base", "cpu", "faster-whisper")) + self.assertEqual( + load_model.call_args_list[1].args, + ("small", "cpu", "faster-whisper", None), + ) def test_waveform_reduces_media_to_bounded_peak_count(self) -> None: pcm = array("h", [-32768, -12000, 0, 8000, 32767, -4000, 2000, 0]).tobytes() @@ -403,7 +469,9 @@ def test_bleep_audio_layer_mutes_source_and_adds_tone(self) -> None: ) self.assertIn("volume=0:enable='between(t,1.000,1.800)'", filter_graph) - self.assertIn("sine=frequency=1000", filter_graph) + self.assertIn("sine=frequency=1050", filter_graph) + self.assertIn("afade=t=in", filter_graph) + self.assertIn("afade=t=out", filter_graph) self.assertIn("amix=inputs=2", filter_graph) def test_captions_hide_deleted_words(self) -> None: @@ -450,12 +518,14 @@ def test_unknown_transcription_engine_fails_clearly(self) -> None: def test_parakeet_auto_resolution_and_model_normalization(self) -> None: transcription = self._load_transcription_service_or_skip() + original_faster_whisper = transcription.FASTER_WHISPER_AVAILABLE original_nemo = transcription.NEMO_AVAILABLE original_whisperx = transcription.WHISPERX_AVAILABLE try: + transcription.FASTER_WHISPER_AVAILABLE = True transcription.NEMO_AVAILABLE = True transcription.WHISPERX_AVAILABLE = True - self.assertEqual(transcription._resolve_engine("auto"), "parakeet") + self.assertEqual(transcription._resolve_engine("auto"), "faster-whisper") self.assertEqual( transcription._normalize_model_for_engine("base", "parakeet"), transcription.PARAKEET_DEFAULT_MODEL, @@ -465,6 +535,7 @@ def test_parakeet_auto_resolution_and_model_normalization(self) -> None: transcription.PARAKEET_DEFAULT_MODEL, ) finally: + transcription.FASTER_WHISPER_AVAILABLE = original_faster_whisper transcription.NEMO_AVAILABLE = original_nemo transcription.WHISPERX_AVAILABLE = original_whisperx @@ -525,6 +596,33 @@ def transcribe(self, audio_path, **options): ) self.assertTrue(model.options["word_timestamps"]) self.assertTrue(model.options["vad_filter"]) + self.assertEqual(model.options["beam_size"], 5) + self.assertEqual(model.options["vad_parameters"]["speech_pad_ms"], 240) + self.assertIn("обсценные слова", model.options["initial_prompt"]) + self.assertIn("пиздец", model.options["hotwords"]) + + def test_smart_transcript_selects_a_practical_model_for_device(self) -> None: + transcription = self._load_transcription_service_or_skip() + + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("SCRIPTCUT_SMART_TRANSCRIPTION_MODEL", None) + self.assertEqual( + transcription._normalize_model_for_engine("smart", "faster-whisper", "cpu"), + "small", + ) + self.assertEqual( + transcription._normalize_model_for_engine("smart", "faster-whisper", "cuda"), + "large-v3-turbo", + ) + + with patch.dict( + os.environ, + {"SCRIPTCUT_SMART_TRANSCRIPTION_MODEL": "medium"}, + ): + self.assertEqual( + transcription._normalize_model_for_engine("smart", "faster-whisper", "cpu"), + "medium", + ) def test_system_checks_payload_covers_onboarding_requirements(self) -> None: import asyncio diff --git a/backend/services/transcription.py b/backend/services/transcription.py index 795f10c..678a067 100644 --- a/backend/services/transcription.py +++ b/backend/services/transcription.py @@ -3,17 +3,47 @@ from __future__ import annotations import logging +import os +import threading +import time from pathlib import Path -from typing import Literal, Optional +from typing import Callable, Literal, Optional from utils.cache import load_from_cache, save_to_cache logger = logging.getLogger(__name__) _model_cache: dict = {} +_model_cache_lock = threading.RLock() TranscriptionEngine = Literal["faster-whisper", "whisperx", "whisper", "parakeet", "auto"] PARAKEET_DEFAULT_MODEL = "nvidia/parakeet-tdt-0.6b-v3" -WHISPER_MODEL_NAMES = {"tiny", "base", "small", "medium", "large"} +SMART_MODEL_NAME = "smart" +WHISPER_MODEL_NAMES = { + "tiny", + "base", + "small", + "medium", + "large", + "large-v3", + "large-v3-turbo", + "turbo", + SMART_MODEL_NAME, +} + +# Whisper occasionally replaces or drops profanity because the acoustic signal is +# short, clipped, or masked by game audio. Faster Whisper's hotword support is a +# decoding hint, not a post-processing replacement: the audio still has to support +# the word. Keeping the list compact reduces unrelated hallucinations. +RUSSIAN_PROFANITY_HOTWORDS = ( + "блядь блять бля сука сучка хуй хуево хуёво хуевый охуеть охуенно " + "нахуй похуй пизда пиздец пиздеть пиздатый ебать ебаный ёбаный " + "ебучий заебал заебись наебал проебал уебок долбоеб мудак мудила " + "гандон гондон пидор пидорас" +) +RUSSIAN_CONTEXT_PROMPT = ( + "Разговорный стрим на русском языке. Сохраняй разговорную лексику дословно, " + "включая обсценные слова, имена, игровые термины и самокоррекции." +) try: import torch @@ -26,9 +56,11 @@ try: from faster_whisper import WhisperModel + from faster_whisper.utils import download_model as download_faster_whisper_model FASTER_WHISPER_AVAILABLE = True except ImportError: WhisperModel = None + download_faster_whisper_model = None FASTER_WHISPER_AVAILABLE = False try: @@ -74,45 +106,104 @@ def _get_device(use_gpu: bool = True) -> str: return "cpu" -def _load_model(model_name: str, device: str, engine: TranscriptionEngine): - cache_key = f"{engine}_{model_name}_{device}" - if cache_key in _model_cache: - return _model_cache[cache_key] - - logger.info(f"Loading {engine} model: {model_name} on {device}") - if engine == "parakeet": - model = _load_parakeet_model(model_name, device) - elif engine == "faster-whisper" and FASTER_WHISPER_AVAILABLE: - faster_device = "cuda" if device.startswith("cuda") else "cpu" - compute_type = _select_faster_whisper_compute_type(faster_device) +class ModelDownloadError(RuntimeError): + """A safe, localized error for the first speech-model download.""" + + +def _resolve_faster_whisper_model_source( + model_name: str, + progress_callback: Optional[Callable[[int, str], None]] = None, +) -> str: + """Prefer a complete local snapshot, then retry the resumable Hub download.""" + if download_faster_whisper_model is None: + return model_name + + try: + local_path = download_faster_whisper_model(model_name, local_files_only=True) + if progress_callback: + progress_callback(8, "Локальная модель речи готова") + return str(local_path) + except Exception: + # A missing local snapshot is normal on first use. The online attempts + # below reuse any partial Hugging Face blobs instead of starting over. + pass + + last_error: Optional[Exception] = None + for attempt in range(1, 4): + if progress_callback: + progress_callback( + 8 + attempt, + f"Скачиваем модель речи: попытка {attempt} из 3. " + "Прерванная загрузка продолжится автоматически.", + ) try: - model = WhisperModel(model_name, device=faster_device, compute_type=compute_type) - except (RuntimeError, ValueError) as error: - if faster_device != "cuda": - raise - logger.warning("CUDA Faster Whisper initialization failed; falling back to CPU: %s", error) - model = WhisperModel( + return str(download_faster_whisper_model(model_name)) + except ValueError: + raise + except Exception as error: + last_error = error + logger.warning( + "Faster Whisper model download attempt %s/3 failed: %s", + attempt, + error, + ) + if attempt < 3: + time.sleep(attempt) + + raise ModelDownloadError( + "Не удалось скачать локальную модель речи после трёх попыток. " + "Проверьте подключение к интернету и нажмите «Повторить расшифровку». " + "Уже загруженная часть сохранена — скачивание продолжится с неё. " + "API-ключ для этого не нужен." + ) from last_error + + +def _load_model( + model_name: str, + device: str, + engine: TranscriptionEngine, + progress_callback: Optional[Callable[[int, str], None]] = None, +): + cache_key = f"{engine}_{model_name}_{device}" + with _model_cache_lock: + if cache_key in _model_cache: + return _model_cache[cache_key] + + logger.info(f"Loading {engine} model: {model_name} on {device}") + if engine == "parakeet": + model = _load_parakeet_model(model_name, device) + elif engine == "faster-whisper" and FASTER_WHISPER_AVAILABLE: + faster_device = "cuda" if device.startswith("cuda") else "cpu" + compute_type = _select_faster_whisper_compute_type(faster_device) + model_source = _resolve_faster_whisper_model_source(model_name, progress_callback) + try: + model = WhisperModel(model_source, device=faster_device, compute_type=compute_type) + except (RuntimeError, ValueError) as error: + if faster_device != "cuda": + raise + logger.warning("CUDA Faster Whisper initialization failed; falling back to CPU: %s", error) + model = WhisperModel( + model_source, + device="cpu", + compute_type=_select_faster_whisper_compute_type("cpu"), + ) + elif engine == "whisperx" and WHISPERX_AVAILABLE: + whisperx_device = "cuda" if device.startswith("cuda") else "cpu" + compute_type = "float16" if whisperx_device == "cuda" else "int8" + model = whisperx.load_model( model_name, - device="cpu", - compute_type=_select_faster_whisper_compute_type("cpu"), + device=whisperx_device, + compute_type=compute_type, + ) + elif engine in {"whisper", "auto"} and WHISPER_AVAILABLE: + model = whisper.load_model(model_name, device=device) + else: + raise RuntimeError( + "No requested transcription backend is installed. Install faster-whisper, WhisperX, openai-whisper, or Parakeet dependencies." ) - elif engine == "whisperx" and WHISPERX_AVAILABLE: - whisperx_device = "cuda" if device.startswith("cuda") else "cpu" - compute_type = "float16" if whisperx_device == "cuda" else "int8" - model = whisperx.load_model( - model_name, - device=whisperx_device, - compute_type=compute_type, - ) - elif engine in {"whisper", "auto"} and WHISPER_AVAILABLE: - model = whisper.load_model(model_name, device=device) - else: - raise RuntimeError( - "No requested transcription backend is installed. Install faster-whisper, WhisperX, openai-whisper, or Parakeet dependencies." - ) - _model_cache[cache_key] = model - return model + _model_cache[cache_key] = model + return model def _select_faster_whisper_compute_type(device: str) -> str: @@ -177,10 +268,10 @@ def _resolve_engine(engine: TranscriptionEngine) -> TranscriptionEngine: "its selected model downloads automatically on first use." ) return engine - if NEMO_AVAILABLE: - return "parakeet" if FASTER_WHISPER_AVAILABLE: return "faster-whisper" + if NEMO_AVAILABLE: + return "parakeet" if WHISPERX_AVAILABLE: return "whisperx" if WHISPER_AVAILABLE: @@ -208,24 +299,33 @@ def _load_parakeet_model(model_name: str, device: str): def get_transcription_engine_status() -> dict: return { "default_engine": ( - "parakeet" - if NEMO_AVAILABLE - else "faster-whisper" + "faster-whisper" if FASTER_WHISPER_AVAILABLE + else "parakeet" + if NEMO_AVAILABLE else "whisperx" if WHISPERX_AVAILABLE else "whisper" if WHISPER_AVAILABLE else None ), - "default_model": PARAKEET_DEFAULT_MODEL if NEMO_AVAILABLE else "base", + "default_model": SMART_MODEL_NAME if FASTER_WHISPER_AVAILABLE else ( + PARAKEET_DEFAULT_MODEL if NEMO_AVAILABLE else "base" + ), + "recommended_language": "ru", "engines": { "faster-whisper": { "available": FASTER_WHISPER_AVAILABLE, "selectable": FASTER_WHISPER_AVAILABLE, - "default_model": "base", - "label": "Faster Whisper word timestamps", + "default_model": SMART_MODEL_NAME, + "label": "ScriptCut Smart Transcript", "first_class": True, + "profiles": { + "smart": "large-v3-turbo on NVIDIA GPU, small on CPU", + "base": "fastest practical local draft", + "large-v3-turbo": "maximum speed/accuracy balance on a capable GPU", + "large-v3": "maximum accuracy, slowest and largest", + }, "download_behavior": "Selected speech model downloads automatically on first use.", "unavailable_reason": ( None @@ -278,19 +378,36 @@ def get_transcription_engine_status() -> dict: } -def _normalize_model_for_engine(model_name: str, engine: TranscriptionEngine) -> str: +def _select_smart_faster_whisper_model(device: str) -> str: + override = os.environ.get("SCRIPTCUT_SMART_TRANSCRIPTION_MODEL", "").strip() + if override: + return override + # large-v3-turbo is the strongest practical long-form default when + # CTranslate2 can use an NVIDIA GPU. On CPU, small is a meaningful accuracy + # upgrade over the old base default without making hour-long VODs unusable. + return "large-v3-turbo" if device.startswith("cuda") else "small" + + +def _normalize_model_for_engine( + model_name: str, + engine: TranscriptionEngine, + device: str = "cpu", +) -> str: if engine == "parakeet" and model_name in WHISPER_MODEL_NAMES: return PARAKEET_DEFAULT_MODEL + if engine == "faster-whisper" and model_name == SMART_MODEL_NAME: + return _select_smart_faster_whisper_model(device) return model_name def transcribe_audio( file_path: str, - model_name: str = "base", + model_name: str = SMART_MODEL_NAME, engine: TranscriptionEngine = "auto", use_gpu: bool = True, use_cache: bool = True, language: Optional[str] = None, + progress_callback: Optional[Callable[[int, str], None]] = None, ) -> dict: """ Transcribe audio/video file and return word-level timestamps. @@ -303,8 +420,10 @@ def transcribe_audio( raise FileNotFoundError(str(file_path)) resolved_engine = _resolve_engine(engine) - model_name = _normalize_model_for_engine(model_name, resolved_engine) - cache_operation = f"transcribe_{resolved_engine}" + requested_model = model_name + device = _get_device(use_gpu) + model_name = _normalize_model_for_engine(model_name, resolved_engine, device) + cache_operation = f"transcribe_v3_{resolved_engine}_{language or 'auto'}" if use_cache: cached = load_from_cache(file_path, model_name, cache_operation) @@ -321,9 +440,8 @@ def transcribe_audio( temporary_audio_path = extract_audio(file_path) audio_path = temporary_audio_path - device = _get_device(use_gpu) try: - model = _load_model(model_name, device, resolved_engine) + model = _load_model(model_name, device, resolved_engine, progress_callback) logger.info(f"Transcribing with {resolved_engine}: {file_path}") @@ -331,7 +449,12 @@ def transcribe_audio( result = _transcribe_parakeet(model, str(audio_path)) elif resolved_engine == "faster-whisper": try: - result = _transcribe_faster_whisper(model, str(audio_path), language) + result = _transcribe_faster_whisper( + model, + str(audio_path), + language, + progress_callback, + ) except (RuntimeError, ValueError) as error: if not device.startswith("cuda") or not _is_faster_whisper_acceleration_error(error): raise @@ -340,8 +463,24 @@ def transcribe_audio( error, ) _model_cache.pop(f"{resolved_engine}_{model_name}_{device}", None) - cpu_model = _load_model(model_name, "cpu", resolved_engine) - result = _transcribe_faster_whisper(cpu_model, str(audio_path), language) + cpu_model_name = ( + _select_smart_faster_whisper_model("cpu") + if requested_model == SMART_MODEL_NAME + else model_name + ) + cpu_model = _load_model( + cpu_model_name, + "cpu", + resolved_engine, + progress_callback, + ) + result = _transcribe_faster_whisper( + cpu_model, + str(audio_path), + language, + progress_callback, + ) + model_name = cpu_model_name elif resolved_engine == "whisperx": result = _transcribe_whisperx(model, str(audio_path), device, language) else: @@ -354,6 +493,8 @@ def transcribe_audio( result["engine"] = resolved_engine result["model"] = model_name + result["requested_model"] = requested_model + result["quality_profile"] = "smart" if requested_model == SMART_MODEL_NAME else "custom" if use_cache: save_to_cache(file_path, result, model_name, cache_operation) @@ -361,17 +502,41 @@ def transcribe_audio( return result -def _transcribe_faster_whisper(model, audio_path: str, language: Optional[str]) -> dict: +def _transcribe_faster_whisper( + model, + audio_path: str, + language: Optional[str], + progress_callback: Optional[Callable[[int, str], None]] = None, +) -> dict: + is_russian = language in {"ru", "russian", "Русский"} options = { "word_timestamps": True, "vad_filter": True, + "vad_parameters": { + "threshold": 0.45, + "min_speech_duration_ms": 120, + "min_silence_duration_ms": 280, + "speech_pad_ms": 240, + }, + "beam_size": 5, + "best_of": 5, + "patience": 1.0, + "temperature": [0.0, 0.2, 0.4], + "condition_on_previous_text": True, + "hallucination_silence_threshold": 2.0, + "language_detection_segments": 3, } if language: options["language"] = language + if is_russian: + options["initial_prompt"] = RUSSIAN_CONTEXT_PROMPT + options["hotwords"] = RUSSIAN_PROFANITY_HOTWORDS segment_iterator, info = model.transcribe(audio_path, **options) words = [] segments = [] + duration = max(float(getattr(info, "duration", 0) or 0), 0.001) + last_reported_progress = -1 for segment_id, segment in enumerate(segment_iterator): segment_words = [] for item in segment.words or []: @@ -392,11 +557,18 @@ def _transcribe_faster_whisper(model, audio_path: str, language: Optional[str]) "text": str(segment.text or "").strip(), "words": segment_words, }) + if progress_callback: + percent = min(72, max(10, int(10 + (float(segment.end or 0) / duration) * 62))) + if percent > last_reported_progress: + progress_callback(percent, f"Расшифровка: {percent}% · точные тайминги слов") + last_reported_progress = percent return { "words": words, "segments": segments, "language": str(getattr(info, "language", None) or language or "auto"), + "language_probability": round(float(getattr(info, "language_probability", 0) or 0), 3), + "duration_after_vad": round(float(getattr(info, "duration_after_vad", duration) or duration), 3), } diff --git a/backend/services/video_editor.py b/backend/services/video_editor.py index b4bce51..37ac4e1 100644 --- a/backend/services/video_editor.py +++ b/backend/services/video_editor.py @@ -350,9 +350,16 @@ def _build_audio_trim_filter(index: int, segment: dict, muted_ranges: List[dict] elif muted.get("kind") == "bleep" and segment_duration > 0: tone_label = f"a{index}b{step}" mixed_label = f"a{index}bm{step}" + tone_duration = max(0.02, local_end - local_start) + fade_duration = min(0.015, tone_duration / 4) + fade_out_start = max(0, tone_duration - fade_duration) + delay_ms = max(0, round(local_start * 1000)) chain += ( - f"sine=frequency=1000:sample_rate=48000:duration={segment_duration:.3f}," - f"volume='if(between(t,{local_start:.3f},{local_end:.3f}),0.16,0)':eval=frame" + f"sine=frequency=1050:sample_rate=48000:duration={tone_duration:.3f}," + f"volume=0.18," + f"afade=t=in:st=0:d={fade_duration:.3f}," + f"afade=t=out:st={fade_out_start:.3f}:d={fade_duration:.3f}," + f"adelay={delay_ms}:all=1" f"[{tone_label}];" f"[{current_label}][{tone_label}]amix=inputs=2:duration=first:normalize=0" f"[{mixed_label}];" diff --git a/frontend/index.html b/frontend/index.html index e0ab834..0462c77 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,16 +1,21 @@ - + - - - - + ScriptCut +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2f8ac7f..88049b6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,13 +1,14 @@ { "name": "scriptcut-frontend", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scriptcut-frontend", - "version": "0.1.3", + "version": "0.1.4", "dependencies": { + "@fontsource-variable/onest": "^5.2.8", "lucide-react": "^0.468.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -912,6 +913,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fontsource-variable/onest": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/onest/-/onest-5.2.8.tgz", + "integrity": "sha512-DCZaaYZGx4evSzVp/FcWSezXpsFXr+yq/AsnaxZ7KJtGIbNd8s80ET8Hnugdhr3WQ1muEvN3WR4v4Rpa6vDU6g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 07b827b..a0aa791 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "scriptcut-frontend", "private": true, - "version": "0.1.3", + "version": "0.1.4", "type": "module", "scripts": { "dev": "vite", @@ -21,6 +21,7 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource-variable/onest": "^5.2.8", "lucide-react": "^0.468.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/frontend/public/brand/scriptcut-mark.svg b/frontend/public/brand/scriptcut-mark.svg index 132ea6f..8bb58d6 100644 --- a/frontend/public/brand/scriptcut-mark.svg +++ b/frontend/public/brand/scriptcut-mark.svg @@ -5,14 +5,14 @@ - - + + - - - - \ No newline at end of file + + + + diff --git a/frontend/public/brand/scriptcut-wordmark.svg b/frontend/public/brand/scriptcut-wordmark.svg index 3ae043f..3ddc0ac 100644 --- a/frontend/public/brand/scriptcut-wordmark.svg +++ b/frontend/public/brand/scriptcut-wordmark.svg @@ -5,16 +5,16 @@ - - + + - - Script - Cut - \ No newline at end of file + + Script + Cut + diff --git a/frontend/scripts/smoke-censorship.mjs b/frontend/scripts/smoke-censorship.mjs index 84d1f0e..c43c07e 100644 --- a/frontend/scripts/smoke-censorship.mjs +++ b/frontend/scripts/smoke-censorship.mjs @@ -19,7 +19,7 @@ const module = { exports: {} }; const run = new Function('exports', 'module', 'require', compiled.outputText); run(module.exports, module, require); -const { findCensorMatches, parseCustomPhrases } = module.exports; +const { findCensorMatches, normalizeToken, parseCustomPhrases } = module.exports; const words = [ { word: 'Это', start: 0, end: 0.3, confidence: 1 }, @@ -48,3 +48,35 @@ assert.deepEqual( findCensorMatches(words, '', false), [], ); + +const difficultRussianWords = [ + { word: 'бл*я-я-ять', start: 0, end: 0.4, confidence: 0.72 }, + { word: 'это', start: 0.4, end: 0.7, confidence: 0.98 }, + { word: 'на', start: 0.7, end: 0.85, confidence: 0.91 }, + { word: 'х', start: 0.85, end: 0.92, confidence: 0.61 }, + { word: 'у', start: 0.92, end: 1.0, confidence: 0.62 }, + { word: 'й', start: 1.0, end: 1.08, confidence: 0.63 }, + { word: 'заебааал', start: 1.08, end: 1.5, confidence: 0.76 }, + { word: 'песдец', start: 1.5, end: 1.9, confidence: 0.68 }, + { word: 'страхуй', start: 1.9, end: 2.2, confidence: 0.99 }, + { word: 'ребенок', start: 2.2, end: 2.5, confidence: 0.99 }, +]; + +const difficultMatches = findCensorMatches(difficultRussianWords, ''); +assert.deepEqual( + difficultMatches.map((match) => [ + match.startWordIndex, + match.endWordIndex, + match.matchKind, + ]), + [ + [0, 0, 'obfuscated'], + [2, 5, 'split'], + [6, 6, 'exact'], + [7, 7, 'exact'], + ], +); +assert.equal(difficultMatches[0].startTime, 0); +assert.equal(difficultMatches[0].endTime, 0.52); +assert.equal(normalizeToken('XУЙ'), 'хуй'); +assert.equal(normalizeToken('6ЛЯДЬ'), 'блядь'); diff --git a/frontend/scripts/smoke-waveform.mjs b/frontend/scripts/smoke-waveform.mjs index 3f92d6d..43da1d6 100644 --- a/frontend/scripts/smoke-waveform.mjs +++ b/frontend/scripts/smoke-waveform.mjs @@ -9,4 +9,8 @@ const source = readFileSync(resolve(__dirname, '../src/components/WaveformTimeli assert.match(source, /\/audio\/waveform/, 'Waveform must be built through the bounded backend endpoint'); assert.doesNotMatch(source, /decodeAudioData/, 'Waveform must not decode a complete stream in browser memory'); assert.doesNotMatch(source, /response\.arrayBuffer/, 'Waveform must not download a complete stream into an ArrayBuffer'); -assert.match(source, /editing and transcription still work/, 'Waveform failure must be explained as non-blocking'); +assert.match( + source, + /монтаж и расшифровка продолжают работать/, + 'Waveform failure must be explained as non-blocking', +); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 702a6e8..40faf8f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState, useRef } from 'react'; +import { useStore } from 'zustand'; import { useEditorStore } from './store/editorStore'; import { useAIStore } from './store/aiStore'; import VideoPlayer from './components/VideoPlayer'; @@ -39,6 +40,9 @@ import { Info, LogOut, MoreHorizontal, + X, + Undo2, + Redo2, } from 'lucide-react'; import { RELEASE_LINKS } from './utils/releaseInfo'; @@ -51,6 +55,7 @@ type TranscriptionEngine = 'auto' | 'faster-whisper' | 'whisperx' | 'whisper' | type TranscriptionEngineStatus = { default_engine?: TranscriptionEngine | null; default_model?: string; + recommended_language?: string; engines?: Record> = { auto: [ - { value: 'base', label: 'Auto best available' }, + { value: 'smart', label: 'Smart · оптимальный баланс' }, + { value: 'base', label: 'Быстрый черновик' }, { value: 'small', label: 'small (better accuracy)' }, { value: 'medium', label: 'medium (high accuracy, slower)' }, + { value: 'large-v3-turbo', label: 'Max · large-v3-turbo' }, ], 'faster-whisper': [ + { value: 'smart', label: 'Smart · оптимально для этого компьютера' }, { value: 'tiny', label: 'tiny (fastest)' }, - { value: 'base', label: 'base (recommended)' }, + { value: 'base', label: 'base (быстрый черновик)' }, { value: 'small', label: 'small (better accuracy)' }, { value: 'medium', label: 'medium (high accuracy, slower)' }, + { value: 'large-v3-turbo', label: 'large-v3-turbo (лучший баланс GPU)' }, { value: 'large-v3', label: 'large-v3 (best, very slow)' }, ], whisperx: [ @@ -114,6 +123,24 @@ interface BackendJob { error?: string; } +function friendlyTranscriptionError(message: string): string { + const normalized = message.toLowerCase(); + if ( + normalized.includes('ssl') || + normalized.includes('unexpected_eof') || + normalized.includes('hugging face') || + normalized.includes('huggingface') || + normalized.includes('snapshot folder') || + normalized.includes('trying to locate the files on the hub') + ) { + return 'Не удалось скачать локальную модель речи. Проверьте интернет и нажмите «Повторить расшифровку»: уже загруженная часть сохранена, поэтому скачивание продолжится, а не начнётся заново. API-ключ не нужен.'; + } + if (normalized.includes('canceled') || normalized.includes('cancelled')) { + return 'Расшифровка отменена'; + } + return message; +} + export default function App() { const { videoPath, @@ -129,10 +156,13 @@ export default function App() { backendUrl, } = useEditorStore(); - const [activePanel, setActivePanel] = useState(null); + const [activePanel, setActivePanel] = useState( + () => (window.innerWidth >= 1280 ? 'ai' : null), + ); const [showMoreMenu, setShowMoreMenu] = useState(false); const [transcriptionEngine, setTranscriptionEngine] = useState('auto'); - const [transcriptionModel, setTranscriptionModel] = useState('base'); + const [transcriptionModel, setTranscriptionModel] = useState('smart'); + const [transcriptionLanguage, setTranscriptionLanguage] = useState('ru'); const [transcriptionEngineStatus, setTranscriptionEngineStatus] = useState(null); const [transcriptionMessage, setTranscriptionMessage] = useState(''); const [transcriptionError, setTranscriptionError] = useState(''); @@ -154,6 +184,14 @@ export default function App() { () => window.localStorage.getItem(ONBOARDING_DISMISSED_KEY) === 'true', ); const fileInputRef = useRef(null); + const canUndo = useStore( + useEditorStore.temporal, + (state) => state.pastStates.length > 0, + ); + const canRedo = useStore( + useEditorStore.temporal, + (state) => state.futureStates.length > 0, + ); useKeyboardShortcuts(); const autosave = useProjectAutosave(); @@ -179,6 +217,7 @@ export default function App() { if (status.default_engine && status.default_model) { setTranscriptionEngine(status.default_engine); setTranscriptionModel(status.default_model); + setTranscriptionLanguage(status.recommended_language || 'ru'); } }) .catch(() => { @@ -438,7 +477,7 @@ export default function App() { const transcribeVideo = async (path: string, intent?: WorkflowIntent) => { setTranscribing(true, 0); - setTranscriptionMessage('Starting transcription'); + setTranscriptionMessage('Запускаем Smart Transcript'); setTranscriptionError(''); setTranscriptionLogs([]); setLastTranscriptionJobId(''); @@ -446,7 +485,12 @@ export default function App() { const res = await fetch(`${backendUrl}/jobs/transcribe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ file_path: path, engine: transcriptionEngine, model: transcriptionModel }), + body: JSON.stringify({ + file_path: path, + engine: transcriptionEngine, + model: transcriptionModel, + language: transcriptionLanguage || null, + }), }); if (!res.ok) { let detail = res.statusText; @@ -462,11 +506,11 @@ export default function App() { setLastTranscriptionJobId(jobId); const data = await pollTranscriptionJob(jobId); setTranscription(data); - if (intent) setActivePanel(intent === 'short' ? 'ai' : 'export'); + if (intent === 'short') setActivePanel('ai'); } catch (err) { console.error('Transcription error:', err); const message = err instanceof Error ? err.message : String(err); - setTranscriptionError(message.toLowerCase().includes('canceled') ? 'Transcription canceled' : message); + setTranscriptionError(friendlyTranscriptionError(message)); } finally { setTranscriptionMessage(''); setTranscribing(false); @@ -488,7 +532,7 @@ export default function App() { const retryTranscription = async () => { if (!lastTranscriptionJobId) return; setTranscriptionError(''); - setTranscriptionMessage('Retrying transcription'); + setTranscriptionMessage('Повторяем расшифровку'); setTranscribing(true, 1); try { const res = await fetch(`${backendUrl}/jobs/${lastTranscriptionJobId}/retry`, { method: 'POST' }); @@ -499,7 +543,7 @@ export default function App() { setTranscription(data); } catch (err) { console.error('Transcription retry error:', err); - setTranscriptionError(err instanceof Error ? err.message : String(err)); + setTranscriptionError(friendlyTranscriptionError(err instanceof Error ? err.message : String(err))); } finally { setTranscriptionMessage(''); setTranscribing(false); @@ -536,7 +580,7 @@ export default function App() { if (!videoPath) { return ( -
+
{!IS_ELECTRON && ( - Exit + Выход )} -
+
- ScriptCut -

- Open-source text-based video editing powered by AI. +

+ ScriptCut +
+

+ Откройте запись стрима. Smart Transcript превратит речь в монтаж, + который можно проверить слово за словом.

@@ -584,8 +627,8 @@ export default function App() { /> )} -
- Transcription settings +
+ Настройки расшифровки
+

{transcriptionEngine === 'auto' - ? 'Auto uses the best installed local engine. Its selected speech model downloads automatically on first use.' + ? 'Smart Transcript сам выберет сильную модель под компьютер. Для русского включаются дословный режим, VAD и подсказки для сложной разговорной лексики.' : transcriptionEngineStatus?.engines?.[transcriptionEngine]?.available ? transcriptionEngineStatus.engines[transcriptionEngine].download_behavior || - 'The selected speech model downloads automatically on first use.' + 'Выбранная модель речи скачается автоматически при первом запуске.' : transcriptionEngineStatus?.engines?.[transcriptionEngine]?.unavailable_reason || - 'This optional engine is not included in the desktop build. Choose Faster Whisper.'} + 'Этот дополнительный движок не входит в сборку. Выберите Faster Whisper.'}

{transcriptionEngineStatus && (

- Disabled engines are separate program components. Downloading a model file does not install them. + Неактивные движки — отдельные компоненты программы. Скачивание файла модели их не устанавливает.

)}
{IS_ELECTRON ? ( -
+
{recoveryCandidate && (
-
Recover autosaved project
+
Восстановить автосохранение
{recoveryCandidate.videoPath.split(/[\\/]/).pop()} · {new Date(recoveryCandidate.modifiedAt).toLocaleString()}
@@ -663,7 +721,7 @@ export default function App() { onClick={() => recoverAutosave(recoveryCandidate)} className="rounded bg-editor-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-editor-accent-hover" > - Recover + Восстановить {getAutosaveSnapshotPaths(recoveryCandidate.videoPath) .slice(1, (recoveryCandidate.snapshotCount || 0) + 1) @@ -673,14 +731,14 @@ export default function App() { onClick={() => recoverAutosave(recoveryCandidate, index + 1)} className="rounded bg-editor-surface px-3 py-1.5 text-xs text-editor-text-muted hover:text-editor-text" > - Earlier {index + 1} + Ранее {index + 1} ))}
@@ -689,7 +747,7 @@ export default function App() { )} {recentProjects.length > 0 && (
-
Recent projects
+
Недавние проекты
{recentProjects.map((project) => (
) : ( -
+
e.preventDefault()} onDrop={handleBrowserDrop} - className="group flex min-h-48 flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-editor-border bg-editor-surface/45 px-6 py-8 text-center transition-colors hover:border-editor-accent/60 hover:bg-editor-surface/70" + className="scriptcut-dropzone group flex min-h-48 flex-col items-center justify-center gap-4 rounded-lg border border-editor-border px-6 py-8 text-center transition-colors hover:border-editor-accent" >
{isBrowserUploading ? : }
- {isBrowserUploading ? 'Uploading media...' : 'Choose a video or audio file'} + {isBrowserUploading ? 'Загружаем медиа…' : 'Выберите видео или аудио'}

- Pick a file from your folders or drop it here. ScriptCut uploads it to the local backend before transcription. + Выберите файл или перетащите его сюда. Обработка и расшифровка выполняются локально.

: } - title="Edit full video" - detail="Choose file and start transcription" + title="Открыть полный стрим" + detail="Выбрать файл и запустить Smart Transcript" onClick={() => void handleOpenFile('full-video')} disabled={isBrowserUploading} /> } - title="Create a short" - detail="Set up vertical output immediately" + title="Сделать вертикальный клип" + detail="Сразу настроить формат 9:16" onClick={() => void handleOpenFile('short')} disabled={isBrowserUploading} primary @@ -770,7 +828,7 @@ export default function App() {
{browserUploadName && (
- {isBrowserUploading ? 'Uploading' : 'Last selected'}: {browserUploadName} + {isBrowserUploading ? 'Загружается' : 'Последний файл'}: {browserUploadName}
)}
@@ -780,7 +838,7 @@ export default function App() {
)}

- Supported: MP4, AVI, MOV, MKV, WebM, M4A, MP3, WAV, FLAC + Поддерживаются: MP4, AVI, MOV, MKV, WebM, M4A, MP3, WAV, FLAC

)} @@ -789,7 +847,7 @@ export default function App() { } return ( -
+
{!IS_ELECTRON && ( )} {/* Top bar */} -
+
- ScriptCut +
+ + ScriptCut +
+
- - {videoPath.split(/[\\/]/).pop()} - +
+ + {videoPath.split(/[\\/]/).pop()} + + {videoPath.startsWith('/demo/') && ( + + синтетическое демо + + )} +
} - label="Open" + label="Открыть" onClick={handleOpenFile} disabled={isBrowserUploading} /> @@ -824,11 +893,24 @@ export default function App() { ? 'Saved' : manualSaveStatus === 'error' ? 'Save failed' - : 'Save Project' + : 'Сохранить' } onClick={handleSaveProject} disabled={words.length === 0 || manualSaveStatus === 'saving'} /> +
+ } + label="Отменить" + onClick={() => useEditorStore.temporal.getState().undo()} + disabled={!canUndo} + /> + } + label="Повторить" + onClick={() => useEditorStore.temporal.getState().redo()} + disabled={!canRedo} + /> } label="AI" @@ -838,10 +920,11 @@ export default function App() { /> } - label="Export" + label="Экспорт" active={activePanel === 'export'} onClick={() => togglePanel('export')} disabled={words.length === 0} + primary />
)} {transcriptionLogs.length > 0 && (
- Job log + Журнал задачи
{transcriptionLogs.slice(-8).map((entry, index) => (
@@ -944,12 +1027,12 @@ export default function App() { onClick={retryTranscription} className="rounded bg-editor-accent px-3 py-2 text-sm font-medium hover:bg-editor-accent-hover" > - Retry transcription + Повторить расшифровку )} {transcriptionLogs.length > 0 && (
- Job log + Журнал задачи
{transcriptionLogs.slice(-8).map((entry, index) => (
@@ -962,28 +1045,56 @@ export default function App() {
) : (
-
Transcript will appear here
+
Здесь появится расшифровка

- Open media to transcribe it. After transcription, edit words directly to cut video and use the timeline for review. + Откройте медиа. После расшифровки выделяйте слова прямо в тексте, а результат проверяйте на таймлайне.

)} -
+
{/* Waveform timeline */} -
+
{/* Right panel (AI / Export / Settings) */} {activePanel && ( -
- {activePanel === 'ai' && } - {activePanel === 'export' && } - {activePanel === 'settings' && } -
+ )}
@@ -1039,7 +1150,7 @@ function FirstRunChecklist({ }; return ( -
+
Setup assistant
@@ -1234,12 +1345,14 @@ function ToolbarButton({ icon, label, active, + primary, onClick, disabled, }: { icon: React.ReactNode; label: string; active?: boolean; + primary?: boolean; onClick: () => void; disabled?: boolean; }) { @@ -1248,8 +1361,10 @@ function ToolbarButton({ onClick={onClick} disabled={disabled} title={label} - className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${ - active + className={`flex h-8 items-center gap-1.5 rounded px-3 text-xs font-medium transition-colors ${ + primary + ? 'bg-editor-accent text-editor-ink hover:bg-editor-accent-hover' + : active ? 'bg-editor-accent text-white' : 'text-editor-text-muted hover:text-editor-text hover:bg-editor-surface' } ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`} diff --git a/frontend/src/components/AIPanel.tsx b/frontend/src/components/AIPanel.tsx index 954fee9..4cd7cb7 100644 --- a/frontend/src/components/AIPanel.tsx +++ b/frontend/src/components/AIPanel.tsx @@ -189,7 +189,7 @@ export default function AIPanel() { setProcessing, } = useAIStore(); - const [activeTab, setActiveTab] = useState<'edit' | 'censor' | 'filler' | 'clips'>('edit'); + const [activeTab, setActiveTab] = useState<'edit' | 'censor' | 'filler' | 'clips'>('censor'); const [topicContextPadding, setTopicContextPadding] = useState(0.45); const [fillerQueueFilter, setFillerQueueFilter] = useState('all'); const [fillerReasonFilter, setFillerReasonFilter] = useState('all'); @@ -1305,19 +1305,19 @@ export default function AIPanel() { ]); return ( -
+
setActiveTab('edit')} icon={} - label="AI Editor" + label="Темы" /> setActiveTab('filler')} icon={} - label="Filler Words" + label="Паузы" /> setActiveTab('clips')} icon={} - label="Create Clips" + label="Клипы" />
@@ -1549,16 +1549,16 @@ export default function AIPanel() { )} {activeTab === 'censor' && ( -
-

- Поиск работает локально по готовой расшифровке. Совпадения не меняют видео, - пока вы не примените к ним звуковой слой. +

+

+ Smart Transcript ищет формы, искажения и слова, разбитые распознаванием на части. + Всё работает локально и не меняет видео, пока вы не подтвердите совпадение.

-