├── art ├── screenshot.png └── screenshot-larger.png ├── colors.go ├── .gitignore ├── structs.go ├── go.mod ├── .github └── workflows │ └── release.yml ├── README.md ├── go.sum ├── funcs.go └── main.go /art/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aschmelyun/tsplice/HEAD/art/screenshot.png -------------------------------------------------------------------------------- /art/screenshot-larger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aschmelyun/tsplice/HEAD/art/screenshot-larger.png -------------------------------------------------------------------------------- /colors.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | var ( 6 | TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3")) 7 | BulletStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).PaddingRight(1) 8 | TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) 9 | DimTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) 10 | SpinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) 11 | TimestampStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).PaddingLeft(2) 12 | ItemStyle = lipgloss.NewStyle().PaddingLeft(2) 13 | SelectedItemStyle = lipgloss.NewStyle().PaddingLeft(0).Foreground(lipgloss.Color("3")) 14 | ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) 15 | SuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) 16 | ) 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binaries 8 | *_test 9 | 10 | # Compiled Go binaries 11 | # These are typically named after the package or module 12 | # You might need to adjust this if your build process creates differently named binaries 13 | # For example, if you build in a 'bin' directory: 14 | # bin/ 15 | 16 | # Dependency directories 17 | # Vendor directory for Go modules 18 | vendor/ 19 | 20 | # Go module cache 21 | # This directory is created by 'go mod download' and contains downloaded modules 22 | # It's usually located in your user's Go path, but can be in the project if configured 23 | # For example, if you have a local module cache: 24 | # pkg/mod/ 25 | 26 | # Editor and IDE specific files 27 | .idea/ 28 | .vscode/ 29 | .DS_Store 30 | Thumbs.db 31 | 32 | # Log files 33 | *.log 34 | 35 | # Environment variables 36 | .env 37 | .env.* 38 | 39 | # Temporary files 40 | *~ 41 | #*# 42 | .#* 43 | 44 | -------------------------------------------------------------------------------- /structs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/list" 5 | "github.com/charmbracelet/bubbles/spinner" 6 | ) 7 | 8 | type audioExtractedMsg struct { 9 | audioFile string 10 | } 11 | 12 | type transcriptionDoneMsg struct { 13 | vttContent string 14 | transcriptItems []TranscriptItem 15 | } 16 | 17 | type errorMsg struct { 18 | err error 19 | } 20 | 21 | type videoCompilationDoneMsg struct { 22 | outputFile string 23 | } 24 | 25 | type TranscriptItem struct { 26 | StartTime string 27 | EndTime string 28 | Text string 29 | } 30 | 31 | type model struct { 32 | spinner spinner.Model 33 | loading bool 34 | loadingMsg string 35 | list list.Model 36 | quitting bool 37 | inputFile string 38 | errorMsg string 39 | gate bool 40 | transcriptItems []TranscriptItem 41 | statuses []string 42 | } 43 | 44 | type item struct { 45 | title string 46 | timestamp string 47 | selected bool 48 | } 49 | 50 | type itemDelegate struct{} 51 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aschmelyun/tsplice 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.7 6 | 7 | require ( 8 | github.com/charmbracelet/bubbles v0.21.0 9 | github.com/charmbracelet/bubbletea v1.3.6 10 | github.com/charmbracelet/lipgloss v1.1.0 11 | ) 12 | 13 | require ( 14 | al.essio.dev/pkg/shellescape v1.5.1 // indirect 15 | github.com/atotto/clipboard v0.1.4 // indirect 16 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 17 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 18 | github.com/charmbracelet/x/ansi v0.9.3 // indirect 19 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 20 | github.com/charmbracelet/x/term v0.2.1 // indirect 21 | github.com/danieljoos/wincred v1.2.2 // indirect 22 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 23 | github.com/godbus/dbus/v5 v5.1.0 // indirect 24 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 25 | github.com/mattn/go-isatty v0.0.20 // indirect 26 | github.com/mattn/go-localereader v0.0.1 // indirect 27 | github.com/mattn/go-runewidth v0.0.16 // indirect 28 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 29 | github.com/muesli/cancelreader v0.2.2 // indirect 30 | github.com/muesli/termenv v0.16.0 // indirect 31 | github.com/rivo/uniseg v0.4.7 // indirect 32 | github.com/sahilm/fuzzy v0.1.1 // indirect 33 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 34 | github.com/zalando/go-keyring v0.2.6 // indirect 35 | golang.org/x/sync v0.15.0 // indirect 36 | golang.org/x/sys v0.36.0 // indirect 37 | golang.org/x/term v0.35.0 // indirect 38 | golang.org/x/text v0.3.8 // indirect 39 | ) 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | goos: [linux, windows, darwin] 14 | goarch: [amd64, arm64] 15 | exclude: 16 | - goos: windows 17 | goarch: arm64 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: '1.24' 26 | 27 | - name: Get dependencies 28 | run: go mod download 29 | 30 | - name: Build binary 31 | env: 32 | GOOS: ${{ matrix.goos }} 33 | GOARCH: ${{ matrix.goarch }} 34 | run: | 35 | binary_name="tsplice-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}" 36 | if [ "${{ matrix.goos }}" = "windows" ]; then 37 | binary_name="${binary_name}.exe" 38 | fi 39 | go build -ldflags="-s -w" -o "$binary_name" . 40 | # Verify it's actually a binary 41 | file "$binary_name" 42 | id: build 43 | 44 | - name: Upload build artifacts 45 | uses: actions/upload-artifact@v4 46 | with: 47 | name: tsplice-${{ matrix.goos }}-${{ matrix.goarch }} 48 | path: tsplice-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.exe' || '' }} 49 | 50 | release: 51 | needs: build 52 | runs-on: ubuntu-latest 53 | permissions: 54 | contents: write 55 | 56 | steps: 57 | - uses: actions/checkout@v4 58 | 59 | - name: Download all artifacts 60 | uses: actions/download-artifact@v4 61 | with: 62 | path: ./artifacts 63 | 64 | - name: Prepare release assets 65 | run: | 66 | mkdir -p release-assets 67 | find ./artifacts -type f -exec cp {} release-assets/ \; 68 | ls -lah release-assets/ 69 | # Verify binaries 70 | for file in release-assets/*; do 71 | if [[ ! "$file" == *.exe ]]; then 72 | file "$file" 73 | fi 74 | done 75 | 76 | - name: Create Release 77 | run: | 78 | gh release create ${{ github.ref_name }} \ 79 | --title "Release ${{ github.ref_name }}" \ 80 | --notes "## Changes 81 | 82 | Release ${{ github.ref_name }} of tsplice. 83 | 84 | ## Downloads 85 | 86 | Choose the appropriate binary for your platform: 87 | 88 | - **Linux AMD64**: \`tsplice-${{ github.ref_name }}-linux-amd64\` 89 | - **Linux ARM64**: \`tsplice-${{ github.ref_name }}-linux-arm64\` 90 | - **macOS AMD64 (Intel)**: \`tsplice-${{ github.ref_name }}-darwin-amd64\` 91 | - **macOS ARM64 (Apple Silicon)**: \`tsplice-${{ github.ref_name }}-darwin-arm64\` 92 | - **Windows AMD64**: \`tsplice-${{ github.ref_name }}-windows-amd64.exe\`" \ 93 | release-assets/* 94 | env: 95 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tsplice 2 | 3 | A modest command-line application to **splice** and merge video files from the **t**erminal. 4 | 5 | ![](art/screenshot.png) 6 | 7 | :construction: This is an experimental app so you might see some weird bugs. If so, [let me know](https://github.com/aschmelyun/tsplice/issues)! :construction: 8 | 9 | ## Installation 10 | 11 | To install, download the latest [release binary](https://github.com/aschmelyun/tsplice/releases/latest) to your system. If you're on Linux or MacOS, you can use this one-liner to handle that: 12 | 13 | ``` 14 | sudo curl -L https://github.com/aschmelyun/tsplice/releases/download/v1.0.3/tsplice-v1.0.3-darwin-arm64 -o /usr/local/bin/tsplice && sudo chmod +x /usr/local/bin/tsplice 15 | ``` 16 | 17 | > [!NOTE] 18 | > The release above is for Apple Chip MacOS machines, be sure to change the link in the curl call to the appropriate binary for your system. 19 | 20 | ## Requirements 21 | 22 | You'll need to have the following software installed on your system to use `tsplice` effectively: 23 | 24 | - ffmpeg 25 | - mpv 26 | 27 | Additionally, you'll need to have an [OpenAI API key](https://platform.openai.com/api-keys) ready to be set on the first run. 28 | 29 | ## Usage 30 | 31 | Run `tsplice` in any terminal window, followed by the file that you want to edit. 32 | 33 | ```sh 34 | tsplice ./Movies/my_facecam_vid_20250629.mp4 35 | ``` 36 | 37 | Additionally, you can pass in some options between the command and the video file. The ones available are: 38 | 39 | - `lang`: (optional, string) sets the language for the transcription, if you want to prompt the model to make it easier to transcribe 40 | - `prompt`: (optional, string) sets a prompt for the Whisper model, if you want to provide extra context to the model during transcription 41 | - `gate`: (optional, bool) removes blocks of 10+ seconds of silence from the audio before sending off for transcription 42 | 43 | A command using some of these might look like: 44 | 45 | ```sh 46 | tsplice --gate --lang=fr ./Movies/my_facecam_vid_20250629.mp4 47 | ``` 48 | 49 | After running through the initial steps of extracting audio and transcribing with Whisper, you'll be presented with a list of lines from your video's audio that you can toggle to select or deselect. 50 | 51 | You can press `p` at any time to see a pop-up preview of that current line using your original video. 52 | 53 | Press `c` after you've selected all of the clips that you want in your final video to start the merge process. 54 | 55 | At any time, you can get a help screen by running just `tsplice` or `tsplice --help`. You can see the current version installed by running `tsplice --version`. 56 | 57 | ## How it works 58 | 59 | This app performs a few basic steps: 60 | 61 | 1. Extract audio from the video using `ffmpeg` 62 | 2. Send the audio to OpenAI's Whisper API for transcription 63 | 3. Save the transcription to a local file 64 | 4. Parse the transcription into individual lines and add it to a checklist 65 | 5. Take the selected checklist items and compile them to a list of timestamps 66 | 6. Merge together the final video with `ffmpeg` and the timestamp list above 67 | 68 | That's it! Your spliced video will be available in the same directory as your original. 69 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= 2 | al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= 3 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 4 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 5 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 6 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 7 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 8 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 9 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 10 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 11 | github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= 12 | github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= 13 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 14 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 15 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 16 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 17 | github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= 18 | github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 19 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 20 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 21 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 22 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 23 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 24 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 25 | github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= 26 | github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= 27 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 28 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 29 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 30 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 31 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 32 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 33 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 34 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 35 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 36 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 37 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 38 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 39 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 40 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 41 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 42 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 43 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 44 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 45 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 46 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 47 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 48 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 49 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 50 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 51 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 52 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 53 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 54 | github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= 55 | github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= 56 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 57 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 58 | golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 59 | golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 60 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 61 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 63 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 64 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 65 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 66 | golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= 67 | golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= 68 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 69 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 70 | -------------------------------------------------------------------------------- /funcs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "mime/multipart" 8 | "net/http" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "regexp" 13 | "strings" 14 | 15 | "github.com/charmbracelet/bubbles/list" 16 | tea "github.com/charmbracelet/bubbletea" 17 | ) 18 | 19 | func extractAudioCmd(inputFile string, gate bool) tea.Cmd { 20 | return func() tea.Msg { 21 | audioFile, err := extractAudio(inputFile, gate) 22 | if err != nil { 23 | return errorMsg{err: err} 24 | } 25 | return audioExtractedMsg{audioFile: audioFile} 26 | } 27 | } 28 | 29 | func transcribeAudioCmd(audioFile string) tea.Cmd { 30 | return func() tea.Msg { 31 | vttContent, err := transcribeWithOpenAI(audioFile) 32 | if err != nil { 33 | return errorMsg{err: err} 34 | } 35 | 36 | transcriptItems, err := parseVTT(vttContent) 37 | if err != nil { 38 | return errorMsg{err: err} 39 | } 40 | 41 | basename := strings.TrimSuffix(filepath.Base(audioFile), filepath.Ext(audioFile)) 42 | vttFile := basename + ".vtt" 43 | if err := os.WriteFile(vttFile, []byte(vttContent), 0644); err != nil { 44 | return errorMsg{err: err} 45 | } 46 | 47 | os.Remove(audioFile) 48 | 49 | return transcriptionDoneMsg{vttContent: vttContent, transcriptItems: transcriptItems} 50 | } 51 | } 52 | 53 | func extractAudio(inputFile string, gate bool) (string, error) { 54 | basename := strings.TrimSuffix(filepath.Base(inputFile), filepath.Ext(inputFile)) 55 | audioFile := basename + ".mp3" 56 | 57 | args := []string{"-y", "-i", inputFile} 58 | 59 | if gate { 60 | args = append(args, "-af", "silenceremove=stop_periods=-1:stop_duration=10:stop_threshold=-50dB") 61 | } 62 | 63 | args = append(args, audioFile) 64 | cmd := exec.Command("ffmpeg", args...) 65 | 66 | if err := cmd.Run(); err != nil { 67 | return "", fmt.Errorf("failed to extract audio: %w", err) 68 | } 69 | 70 | return audioFile, nil 71 | } 72 | 73 | func transcribeWithOpenAI(audioFile string) (string, error) { 74 | apiKey := os.Getenv("OPENAI_API_KEY") 75 | if apiKey == "" { 76 | return "", fmt.Errorf("OPENAI_API_KEY environment variable is not set") 77 | } 78 | 79 | file, err := os.Open(audioFile) 80 | if err != nil { 81 | return "", fmt.Errorf("failed to open audio file: %w", err) 82 | } 83 | defer file.Close() 84 | 85 | var b bytes.Buffer 86 | writer := multipart.NewWriter(&b) 87 | 88 | part, err := writer.CreateFormFile("file", filepath.Base(audioFile)) 89 | if err != nil { 90 | return "", fmt.Errorf("failed to create form file: %w", err) 91 | } 92 | 93 | if _, err := io.Copy(part, file); err != nil { 94 | return "", fmt.Errorf("failed to copy file: %w", err) 95 | } 96 | 97 | writer.WriteField("model", "whisper-1") 98 | writer.WriteField("response_format", "vtt") 99 | 100 | if err := writer.Close(); err != nil { 101 | return "", fmt.Errorf("failed to close writer: %w", err) 102 | } 103 | 104 | req, err := http.NewRequest("POST", "https://api.openai.com/v1/audio/transcriptions", &b) 105 | if err != nil { 106 | return "", fmt.Errorf("failed to create request: %w", err) 107 | } 108 | 109 | req.Header.Set("Authorization", "Bearer "+apiKey) 110 | req.Header.Set("Content-Type", writer.FormDataContentType()) 111 | 112 | client := &http.Client{} 113 | resp, err := client.Do(req) 114 | if err != nil { 115 | return "", fmt.Errorf("failed to make request: %w", err) 116 | } 117 | defer resp.Body.Close() 118 | 119 | if resp.StatusCode != http.StatusOK { 120 | body, _ := io.ReadAll(resp.Body) 121 | return "", fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) 122 | } 123 | 124 | body, err := io.ReadAll(resp.Body) 125 | if err != nil { 126 | return "", fmt.Errorf("failed to read response: %w", err) 127 | } 128 | 129 | return string(body), nil 130 | } 131 | 132 | func parseVTT(vttContent string) ([]TranscriptItem, error) { 133 | lines := strings.Split(vttContent, "\n") 134 | var transcriptItems []TranscriptItem 135 | 136 | timeStampRegex := regexp.MustCompile(`^(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})`) 137 | var currentStartTime, currentEndTime string 138 | 139 | for _, line := range lines { 140 | line = strings.TrimSpace(line) 141 | 142 | if matches := timeStampRegex.FindStringSubmatch(line); matches != nil { 143 | currentStartTime = matches[1] 144 | currentEndTime = matches[2] 145 | continue 146 | } 147 | 148 | if strings.HasPrefix(line, "WEBVTT") || line == "" { 149 | continue 150 | } 151 | 152 | if line != "" && currentStartTime != "" { 153 | transcriptItems = append(transcriptItems, TranscriptItem{ 154 | StartTime: currentStartTime, 155 | EndTime: currentEndTime, 156 | Text: line, 157 | }) 158 | currentStartTime = "" 159 | currentEndTime = "" 160 | } 161 | } 162 | 163 | return transcriptItems, nil 164 | } 165 | 166 | func previewVideo(inputFile, startTime, endTime string) { 167 | cmd := exec.Command("mpv", "--start="+startTime, "--end="+endTime, inputFile) 168 | cmd.Run() 169 | } 170 | 171 | func getEndTime(items []list.Item, currentIndex int) string { 172 | if currentIndex+1 < len(items) { 173 | if nextItem, ok := items[currentIndex+1].(item); ok { 174 | return strings.Split(nextItem.timestamp, " - ")[0] 175 | } 176 | } 177 | if currentItem, ok := items[currentIndex].(item); ok { 178 | startTime := strings.Split(currentItem.timestamp, " - ")[0] 179 | return addSecondsToTimestamp(startTime, 10) 180 | } 181 | return "00:00:10.000" 182 | } 183 | 184 | func addSecondsToTimestamp(timestamp string, seconds int) string { 185 | parts := strings.Split(timestamp, ":") 186 | if len(parts) != 3 { 187 | return timestamp 188 | } 189 | 190 | secParts := strings.Split(parts[2], ".") 191 | if len(secParts) != 2 { 192 | return timestamp 193 | } 194 | 195 | currentSec := 0 196 | fmt.Sscanf(secParts[0], "%d", ¤tSec) 197 | newSec := currentSec + seconds 198 | 199 | if newSec >= 60 { 200 | newSec = 59 201 | } 202 | 203 | return fmt.Sprintf("%s:%s:%02d.%s", parts[0], parts[1], newSec, secParts[1]) 204 | } 205 | 206 | func compileVideoCmd(inputFile string, items []list.Item) tea.Cmd { 207 | return func() tea.Msg { 208 | outputFile, err := compileVideoSegments(inputFile, items) 209 | if err != nil { 210 | return errorMsg{err: err} 211 | } 212 | return videoCompilationDoneMsg{outputFile: outputFile} 213 | } 214 | } 215 | 216 | func compileVideoSegments(inputFile string, items []list.Item) (string, error) { 217 | // Collect selected segments 218 | var segments []struct { 219 | start, end float64 220 | } 221 | 222 | for _, listItem := range items { 223 | if i, ok := listItem.(item); ok && i.selected { 224 | timestamps := strings.Split(i.timestamp, " - ") 225 | if len(timestamps) == 2 { 226 | // Convert MM:SS.XX back to HH:MM:SS.mmm format for ffmpeg 227 | start, err := parseTimeToSeconds(timestamps[0]) 228 | if err != nil { 229 | return "", fmt.Errorf("could not parse start time '%s': %w", timestamps[0], err) 230 | } 231 | 232 | end, err := parseTimeToSeconds(timestamps[1]) 233 | if err != nil { 234 | return "", fmt.Errorf("could not parse end time '%s': %w", timestamps[1], err) 235 | } 236 | 237 | segments = append(segments, struct { 238 | start, end float64 239 | }{start: start, end: end}) 240 | } 241 | } 242 | } 243 | 244 | if len(segments) == 0 { 245 | return "", fmt.Errorf("no segments selected") 246 | } 247 | 248 | // Generate output filename 249 | basename := strings.TrimSuffix(filepath.Base(inputFile), filepath.Ext(inputFile)) 250 | outputFile := fmt.Sprintf("%s_compiled.mp4", basename) 251 | 252 | // Use the same directory as input file 253 | outputFile = filepath.Join(filepath.Dir(inputFile), outputFile) 254 | 255 | // Build ffmpeg filter_complex command for multiple segments 256 | var filterParts []string 257 | 258 | for _, segment := range segments { 259 | filterParts = append(filterParts, fmt.Sprintf("between(t,%.3f,%.3f)", segment.start, segment.end)) 260 | } 261 | 262 | selectFilter := strings.Join(filterParts, "+") 263 | 264 | cmd := exec.Command( 265 | "ffmpeg", 266 | "-y", 267 | "-i", 268 | inputFile, 269 | "-vf", 270 | fmt.Sprintf("select='%s',setpts=N/FRAME_RATE/TB", selectFilter), 271 | "-af", 272 | fmt.Sprintf("aselect='%s',asetpts=N/SR/TB", selectFilter), 273 | outputFile, 274 | ) 275 | 276 | if err := cmd.Run(); err != nil { 277 | return "", fmt.Errorf("failed to compile video segments: %w", err) 278 | } 279 | 280 | return outputFile, nil 281 | } 282 | 283 | func parseTimeToSeconds(timeStr string) (float64, error) { 284 | var hours, minutes int 285 | var seconds float64 286 | 287 | _, err := fmt.Sscanf(timeStr, "%d:%d:%f", &hours, &minutes, &seconds) 288 | if err != nil { 289 | return 0, err 290 | } 291 | 292 | totalSeconds := float64(hours*3600) + float64(minutes*60) + seconds 293 | return totalSeconds, nil 294 | } 295 | 296 | func styleOutput(statuses []string) string { 297 | var styledStatuses []string 298 | for i, status := range statuses { 299 | bullet := "├" 300 | if i == len(statuses)-1 { 301 | bullet = "└" 302 | } 303 | styledStatuses = append(styledStatuses, BulletStyle.Render(bullet)+TextStyle.Render(status)) 304 | } 305 | return strings.Join(styledStatuses, "\n") + "\n" 306 | } 307 | 308 | func checkDependency(command string) bool { 309 | _, err := exec.LookPath(command) 310 | return err == nil 311 | } 312 | 313 | func getSystemUser() string { 314 | username := os.Getenv("USER") 315 | if username == "" { 316 | username = os.Getenv("USERNAME") // Windows fallback 317 | } 318 | if username == "" { 319 | username = "anon" // Default fallback 320 | } 321 | 322 | return username 323 | } 324 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "slices" 10 | "strings" 11 | "syscall" 12 | 13 | "github.com/charmbracelet/bubbles/key" 14 | "github.com/charmbracelet/bubbles/list" 15 | "github.com/charmbracelet/bubbles/spinner" 16 | tea "github.com/charmbracelet/bubbletea" 17 | "github.com/zalando/go-keyring" 18 | "golang.org/x/term" 19 | ) 20 | 21 | const VERSION = "1.0.3" 22 | 23 | func (i item) FilterValue() string { return i.title } 24 | 25 | func (d itemDelegate) Height() int { return 2 } 26 | func (d itemDelegate) Spacing() int { return 0 } 27 | func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } 28 | func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { 29 | i, ok := listItem.(item) 30 | if !ok { 31 | return 32 | } 33 | 34 | checkbox := "☐" 35 | if i.selected { 36 | checkbox = "◼" 37 | } 38 | 39 | timestampLine := TimestampStyle.Render(i.timestamp) 40 | str := fmt.Sprintf("%s %s", checkbox, i.title) 41 | 42 | fn := ItemStyle.Render 43 | if index == m.Index() { 44 | fn = func(s ...string) string { 45 | return SelectedItemStyle.Render("> " + strings.Join(s, " ")) 46 | } 47 | } 48 | 49 | fmt.Fprintf(w, "%s\n%s", timestampLine, fn(str)) 50 | } 51 | 52 | func (m model) Init() tea.Cmd { 53 | if m.loading { 54 | // Start the spinner and begin audio extraction 55 | return tea.Batch( 56 | m.spinner.Tick, 57 | extractAudioCmd(m.inputFile, m.gate), 58 | ) 59 | } 60 | // If not loading, just return nil (no commands to run) 61 | return nil 62 | } 63 | 64 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 65 | switch msg := msg.(type) { 66 | case tea.KeyMsg: 67 | switch msg.String() { 68 | case "q", "ctrl+c": 69 | m.quitting = true 70 | return m, tea.Quit 71 | 72 | case "enter", " ": 73 | if !m.loading && len(m.list.Items()) > 0 { 74 | selectedIndex := m.list.Index() 75 | if selectedIndex >= 0 && selectedIndex < len(m.list.Items()) { 76 | items := m.list.Items() 77 | if i, ok := items[selectedIndex].(item); ok { 78 | i.selected = !i.selected 79 | items[selectedIndex] = i 80 | m.list.SetItems(items) 81 | } 82 | } 83 | } 84 | return m, nil 85 | 86 | case "p": 87 | if !m.loading && len(m.list.Items()) > 0 { 88 | selectedIndex := m.list.Index() 89 | if selectedIndex >= 0 && selectedIndex < len(m.list.Items()) { 90 | items := m.list.Items() 91 | if i, ok := items[selectedIndex].(item); ok { 92 | startTime := strings.Split(i.timestamp, " - ")[0] 93 | go previewVideo(m.inputFile, startTime, getEndTime(items, selectedIndex)) 94 | } 95 | } 96 | } 97 | return m, nil 98 | 99 | case "c": 100 | if !m.loading && len(m.list.Items()) > 0 { 101 | // Check if any items are selected 102 | items := m.list.Items() 103 | hasSelected := false 104 | for _, listItem := range items { 105 | if i, ok := listItem.(item); ok && i.selected { 106 | hasSelected = true 107 | break 108 | } 109 | } 110 | if hasSelected { 111 | m.loading = true 112 | m.loadingMsg = "Compiling video segments with ffmpeg..." 113 | return m, tea.Batch( 114 | m.spinner.Tick, 115 | compileVideoCmd(m.inputFile, items), 116 | ) 117 | } 118 | } 119 | return m, nil 120 | } 121 | 122 | // If not loading, pass to list 123 | if !m.loading { 124 | var cmd tea.Cmd 125 | m.list, cmd = m.list.Update(msg) 126 | return m, cmd 127 | } 128 | 129 | case audioExtractedMsg: 130 | m.statuses = append(m.statuses, "Audio extracted from ffmpeg.") 131 | m.loadingMsg = "Transcribing with OpenAI Whisper..." 132 | return m, transcribeAudioCmd(msg.audioFile) 133 | 134 | case transcriptionDoneMsg: 135 | m.statuses = append(m.statuses, "Transcription finished and saved locally.") 136 | m.loading = false 137 | m.transcriptItems = msg.transcriptItems 138 | 139 | // Convert transcript items to list items 140 | items := make([]list.Item, len(msg.transcriptItems)) 141 | for i, transcriptItem := range msg.transcriptItems { 142 | items[i] = item{ 143 | title: transcriptItem.Text, 144 | timestamp: transcriptItem.StartTime + " - " + transcriptItem.EndTime, 145 | selected: false, 146 | } 147 | } 148 | 149 | // Create and configure the list 150 | l := list.New(items, itemDelegate{}, 64, 16) 151 | l.SetShowTitle(false) 152 | l.SetShowStatusBar(false) 153 | l.SetFilteringEnabled(true) 154 | l.SetShowHelp(true) 155 | l.SetShowPagination(false) 156 | 157 | // Add custom key bindings for help 158 | l.AdditionalShortHelpKeys = func() []key.Binding { 159 | return []key.Binding{ 160 | key.NewBinding( 161 | key.WithKeys("p"), 162 | key.WithHelp("p", "preview"), 163 | ), 164 | key.NewBinding( 165 | key.WithKeys("c"), 166 | key.WithHelp("c", "compile"), 167 | ), 168 | } 169 | } 170 | 171 | m.list = l 172 | 173 | return m, nil 174 | 175 | case videoCompilationDoneMsg: 176 | m.statuses = append(m.statuses, "Video compiled successfully.") 177 | m.statuses = append(m.statuses, "Saved output to "+msg.outputFile) 178 | m.loading = false 179 | m.quitting = true 180 | return m, tea.Quit 181 | 182 | case errorMsg: 183 | m.statuses = append(m.statuses, msg.err.Error()) 184 | m.loading = false 185 | m.errorMsg = msg.err.Error() 186 | return m, nil 187 | 188 | case spinner.TickMsg: 189 | if m.loading { 190 | var cmd tea.Cmd 191 | m.spinner, cmd = m.spinner.Update(msg) 192 | return m, cmd 193 | } 194 | return m, nil 195 | } 196 | 197 | return m, nil 198 | } 199 | 200 | func (m model) View() string { 201 | if m.quitting { 202 | return styleOutput(m.statuses) 203 | } 204 | 205 | // Content area 206 | if m.errorMsg != "" { 207 | return styleOutput(m.statuses) + "\nPress 'q' to quit" 208 | } else if m.loading { 209 | loadingText := fmt.Sprintf("%s%s", m.spinner.View(), m.loadingMsg) 210 | if len(m.statuses) > 0 { 211 | return styleOutput(m.statuses) + loadingText 212 | } 213 | return loadingText 214 | } else { 215 | // Show transcript list 216 | if len(m.transcriptItems) == 0 { 217 | return styleOutput(m.statuses) + "No transcript items found" 218 | } 219 | 220 | // Add header with total time info 221 | var header string 222 | if len(m.transcriptItems) > 0 { 223 | firstStart := m.transcriptItems[0].StartTime 224 | lastEnd := m.transcriptItems[len(m.transcriptItems)-1].EndTime 225 | header = fmt.Sprintf(" Start: %s | End: %s\n", firstStart, lastEnd) 226 | } 227 | 228 | return styleOutput(m.statuses) + header + m.list.View() 229 | } 230 | } 231 | 232 | func main() { 233 | fmt.Println(BulletStyle.Render("┌") + TitleStyle.Render("tsplice")) 234 | 235 | var lang string 236 | var prompt string 237 | var gate bool 238 | var help bool 239 | var version bool 240 | 241 | flag.StringVar(&lang, "lang", "auto", "Language for transcription (e.g. en, es, fr)") 242 | flag.StringVar(&prompt, "prompt", "", "Optional prompt used to create a more accurate transcription") 243 | flag.BoolVar(&gate, "gate", false, "Remove long periods of silence during audio extraction") 244 | flag.BoolVar(&help, "help", false, "Show usage info") 245 | flag.BoolVar(&version, "version", false, "Show version info") 246 | flag.Usage = func() { 247 | fmt.Println(BulletStyle.Render("├") + TextStyle.Render("Usage: tsplice [options] ")) 248 | fmt.Println(BulletStyle.Render("│")) 249 | fmt.Println(BulletStyle.Render("├") + TextStyle.Render("Options:")) 250 | fmt.Println(BulletStyle.Render("├────") + TextStyle.Render("--lang") + DimTextStyle.Render(" language for transcription (e.g. en, es, fr)")) 251 | fmt.Println(BulletStyle.Render("├────") + TextStyle.Render("--prompt") + DimTextStyle.Render(" optional prompt used to create a more accurate transcription")) 252 | fmt.Println(BulletStyle.Render("├────") + TextStyle.Render("--gate") + DimTextStyle.Render(" remove long periods of silence (>10s) during audio extraction")) 253 | fmt.Println(BulletStyle.Render("│")) 254 | fmt.Println(BulletStyle.Render("├") + TextStyle.Render("Requirements:")) 255 | 256 | dependencies := []string{"ffmpeg", "mpv"} 257 | for _, dependency := range dependencies { 258 | status := "✔ installed" 259 | if !checkDependency(dependency) { 260 | status = "✗ missing" 261 | } 262 | spaces := strings.Repeat(" ", 10-len(dependency)) 263 | fmt.Println(BulletStyle.Render("├────") + TextStyle.Render(dependency) + DimTextStyle.Render(spaces+status)) 264 | } 265 | 266 | fmt.Println(BulletStyle.Render("│")) 267 | fmt.Println(BulletStyle.Render("└") + TextStyle.Render("Supported formats:") + DimTextStyle.Render(" .mp4, .avi, .mov, .mkv, .m4v")) 268 | } 269 | 270 | flag.Parse() 271 | 272 | if help { 273 | flag.Usage() 274 | os.Exit(0) 275 | } 276 | 277 | if version { 278 | fmt.Println(BulletStyle.Render("└") + TextStyle.Render(VERSION)) 279 | os.Exit(0) 280 | } 281 | 282 | args := flag.Args() 283 | if len(args) != 1 { 284 | flag.Usage() 285 | os.Exit(0) 286 | } 287 | 288 | // Validate the file exists 289 | inputFile := args[0] 290 | if inputFile == "help" { 291 | flag.Usage() 292 | os.Exit(0) 293 | } 294 | 295 | if _, err := os.Stat(inputFile); os.IsNotExist(err) { 296 | fmt.Printf(BulletStyle.Render("└")+TextStyle.Render("Error: file '%s' does not exist.")+"\n", inputFile) 297 | os.Exit(1) 298 | } 299 | 300 | // Validate the input file is a video file 301 | validExtensions := []string{".mp4", ".avi", ".mov", ".mkv", ".m4v"} 302 | fileExt := strings.ToLower(filepath.Ext(inputFile)) 303 | 304 | if !slices.Contains(validExtensions, fileExt) { 305 | fmt.Printf(BulletStyle.Render("└")+TextStyle.Render("Error: file '%s' is not a valid video file.")+"\n", inputFile) 306 | os.Exit(1) 307 | } 308 | 309 | // Check if OPENAI_API_KEY env variable is set, and if not, prompt for it 310 | username := getSystemUser() 311 | 312 | apiKey, err := keyring.Get("tsplice", username) 313 | if err != nil { 314 | if !strings.Contains(err.Error(), "secret not found") { 315 | fmt.Println("Error reading API key:", err) 316 | return 317 | } 318 | } 319 | 320 | if apiKey != "" { 321 | os.Setenv("OPENAI_API_KEY", apiKey) 322 | fmt.Println(BulletStyle.Render("├") + TextStyle.Render("API key set for this session.")) 323 | } 324 | 325 | if os.Getenv("OPENAI_API_KEY") == "" { 326 | fmt.Print(BulletStyle.Render("├") + TextStyle.Render("OPENAI_API_KEY not found, enter one: ")) 327 | 328 | byteApiKey, err := term.ReadPassword(int(syscall.Stdin)) 329 | if err != nil { 330 | fmt.Println("Error reading API key:", err) 331 | return 332 | } 333 | 334 | fmt.Println() 335 | apiKey := strings.TrimSpace(string(byteApiKey)) 336 | 337 | if apiKey == "" { 338 | fmt.Println(BulletStyle.Render("└") + TextStyle.Render("An OpenAI API key is required to proceed.")) 339 | os.Exit(1) 340 | } 341 | 342 | err = keyring.Set("tsplice", username, apiKey) 343 | if err != nil { 344 | fmt.Println("Error saving API key:", err) 345 | return 346 | } 347 | 348 | os.Setenv("OPENAI_API_KEY", apiKey) 349 | fmt.Println(BulletStyle.Render("├") + TextStyle.Render("API key set for this session.")) 350 | } 351 | 352 | // Check if VTT file already exists 353 | basename := strings.TrimSuffix(filepath.Base(inputFile), filepath.Ext(inputFile)) 354 | vttFile := basename + ".vtt" 355 | 356 | // Initialize spinner 357 | s := spinner.New() 358 | s.Spinner = spinner.Dot 359 | s.Style = SpinnerStyle 360 | 361 | // Create initial model 362 | initialModel := model{ 363 | spinner: s, 364 | loading: true, 365 | loadingMsg: "Extracting audio with ffmpeg...", 366 | inputFile: inputFile, 367 | gate: gate, 368 | } 369 | 370 | // Check if transcript already exists 371 | if _, err := os.Stat(vttFile); err == nil { 372 | // Load existing transcript 373 | vttBytes, err := os.ReadFile(vttFile) 374 | if err != nil { 375 | fmt.Fprintf(os.Stderr, BulletStyle.Render("└")+TextStyle.Render("There was a problem reading the existing VTT file: %v")+"\n", err) 376 | os.Exit(1) 377 | } 378 | 379 | transcriptItems, err := parseVTT(string(vttBytes)) 380 | if err != nil { 381 | fmt.Fprintf(os.Stderr, BulletStyle.Render("└")+TextStyle.Render("There was a problem parsing the existing VTT file: %v")+"\n", err) 382 | os.Exit(1) 383 | } 384 | 385 | // Convert to list items 386 | items := make([]list.Item, len(transcriptItems)) 387 | for i, transcriptItem := range transcriptItems { 388 | items[i] = item{ 389 | title: transcriptItem.Text, 390 | timestamp: transcriptItem.StartTime + " - " + transcriptItem.EndTime, 391 | selected: false, 392 | } 393 | } 394 | 395 | // Create list 396 | l := list.New(items, itemDelegate{}, 64, 16) 397 | l.SetShowTitle(false) 398 | l.SetShowStatusBar(false) 399 | l.SetFilteringEnabled(true) 400 | l.SetShowHelp(true) 401 | l.SetShowPagination(false) 402 | 403 | // Add custom key bindings for help 404 | l.AdditionalShortHelpKeys = func() []key.Binding { 405 | return []key.Binding{ 406 | key.NewBinding( 407 | key.WithKeys("p"), 408 | key.WithHelp("p", "preview"), 409 | ), 410 | key.NewBinding( 411 | key.WithKeys("c"), 412 | key.WithHelp("c", "compile"), 413 | ), 414 | } 415 | } 416 | 417 | initialModel.loading = false 418 | initialModel.list = l 419 | initialModel.transcriptItems = transcriptItems 420 | initialModel.statuses = append(initialModel.statuses, "Transcript already exists locally") 421 | } 422 | 423 | // Create and run the program 424 | p := tea.NewProgram( 425 | initialModel, 426 | ) 427 | 428 | if _, err := p.Run(); err != nil { 429 | fmt.Printf("Error running program: %v", err) 430 | } 431 | } 432 | --------------------------------------------------------------------------------