From 74d20c4eefd0c73e10ef5443229a95f25acc757a Mon Sep 17 00:00:00 2001 From: Ethan Dye Date: Thu, 19 Sep 2024 17:45:59 -0600 Subject: [PATCH] Convert to Swift package and add tests (#5) Signed-off-by: Ethan Dye --- .github/dependabot.yml | 5 + .github/workflows/build.yml | 32 ++- .github/workflows/codeql.yml | 16 +- .github/workflows/lint.yml | 20 ++ .github/workflows/nightly.yml | 20 +- .gitignore | 6 +- .periphery.yml | 3 + .swiftformat | 40 ++- .../Package.resolved => Package.resolved | 2 +- Package.swift | 31 +++ README.md | 29 +- .../Extensions/DataExtensions.swift | 2 +- .../TextOutputStreamExtensions.swift | 4 +- .../macSubtitleOCR}/MKV/EBML/EBML.swift | 2 +- .../macSubtitleOCR}/MKV/EBML/EBMLParser.swift | 32 +-- .../macSubtitleOCR/MKV/MKVHelpers.swift | 16 +- .../macSubtitleOCR}/MKV/MKVParser.swift | 161 ++++++----- .../macSubtitleOCR}/MKV/MKVTrack.swift | 3 +- .../macSubtitleOCR}/PGS/PGS.swift | 102 +++---- .../macSubtitleOCR}/PGS/PGSError.swift | 5 +- Sources/macSubtitleOCR/PGS/PGSSubtitle.swift | 18 ++ Sources/macSubtitleOCR/PGS/Parsers/ODS.swift | 66 +++++ .../macSubtitleOCR}/PGS/Parsers/PDS.swift | 42 ++- .../PGS/Parsers/RLE/RLEData.swift | 79 ++++++ .../PGS/Parsers/RLE/RLEDataError.swift | 12 + Sources/macSubtitleOCR/SRT/SRT.swift | 60 +++++ Sources/macSubtitleOCR/SRT/SRTError.swift | 11 + Sources/macSubtitleOCR/SRT/SRTSubtitle.swift | 16 ++ .../macSubtitleOCR/macSubtitleOCR.swift | 128 ++++----- .../macSubtitleOCR/macSubtitleOCRError.swift | 10 +- .../Resources}/expectedOutput/test.json | 0 .../Resources}/expectedOutput/test.srt | 0 .../Resources}/testFiles/test.mkv | Bin .../Resources}/testFiles/test.sup | Bin .../macSubtitleOCRTests.swift | 113 ++++++++ macSup2Srt.xcodeproj/project.pbxproj | 253 ------------------ .../contents.xcworkspacedata | 7 - .../xcshareddata/IDEWorkspaceChecks.plist | 8 - .../xcshareddata/WorkspaceSettings.xcsettings | 5 - .../xcschemes/macSup2Srt.xcscheme | 117 -------- macSup2Srt/PGS/PGSSubtitle.swift | 18 -- macSup2Srt/PGS/Parsers/ODS.swift | 91 ------- macSup2Srt/PGS/Parsers/RLE.swift | 65 ----- macSup2Srt/SRT/SRT.swift | 133 --------- macSup2Srt/SRT/SrtSubtitle.swift | 16 -- 45 files changed, 750 insertions(+), 1049 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 .periphery.yml rename macSup2Srt.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved => Package.resolved (79%) create mode 100644 Package.swift rename {macSup2Srt => Sources/macSubtitleOCR}/Extensions/DataExtensions.swift (94%) rename {macSup2Srt => Sources/macSubtitleOCR}/Extensions/TextOutputStreamExtensions.swift (75%) rename {macSup2Srt => Sources/macSubtitleOCR}/MKV/EBML/EBML.swift (97%) rename {macSup2Srt => Sources/macSubtitleOCR}/MKV/EBML/EBMLParser.swift (62%) rename macSup2Srt/MKV/Helpers.swift => Sources/macSubtitleOCR/MKV/MKVHelpers.swift (66%) rename {macSup2Srt => Sources/macSubtitleOCR}/MKV/MKVParser.swift (61%) rename {macSup2Srt => Sources/macSubtitleOCR}/MKV/MKVTrack.swift (84%) rename {macSup2Srt => Sources/macSubtitleOCR}/PGS/PGS.swift (76%) rename {macSup2Srt => Sources/macSubtitleOCR}/PGS/PGSError.swift (69%) create mode 100644 Sources/macSubtitleOCR/PGS/PGSSubtitle.swift create mode 100644 Sources/macSubtitleOCR/PGS/Parsers/ODS.swift rename {macSup2Srt => Sources/macSubtitleOCR}/PGS/Parsers/PDS.swift (70%) create mode 100644 Sources/macSubtitleOCR/PGS/Parsers/RLE/RLEData.swift create mode 100644 Sources/macSubtitleOCR/PGS/Parsers/RLE/RLEDataError.swift create mode 100644 Sources/macSubtitleOCR/SRT/SRT.swift create mode 100644 Sources/macSubtitleOCR/SRT/SRTError.swift create mode 100644 Sources/macSubtitleOCR/SRT/SRTSubtitle.swift rename macSup2Srt/macSup2Srt.swift => Sources/macSubtitleOCR/macSubtitleOCR.swift (57%) rename macSup2Srt/macSup2SrtError.swift => Sources/macSubtitleOCR/macSubtitleOCRError.swift (52%) rename {tests => Tests/macSubtitleOCRTests/Resources}/expectedOutput/test.json (100%) rename {tests => Tests/macSubtitleOCRTests/Resources}/expectedOutput/test.srt (100%) rename {tests => Tests/macSubtitleOCRTests/Resources}/testFiles/test.mkv (100%) rename {tests => Tests/macSubtitleOCRTests/Resources}/testFiles/test.sup (100%) create mode 100644 Tests/macSubtitleOCRTests/macSubtitleOCRTests.swift delete mode 100644 macSup2Srt.xcodeproj/project.pbxproj delete mode 100644 macSup2Srt.xcodeproj/project.xcworkspace/contents.xcworkspacedata delete mode 100644 macSup2Srt.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist delete mode 100644 macSup2Srt.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings delete mode 100644 macSup2Srt.xcodeproj/xcshareddata/xcschemes/macSup2Srt.xcscheme delete mode 100644 macSup2Srt/PGS/PGSSubtitle.swift delete mode 100644 macSup2Srt/PGS/Parsers/ODS.swift delete mode 100644 macSup2Srt/PGS/Parsers/RLE.swift delete mode 100644 macSup2Srt/SRT/SRT.swift delete mode 100644 macSup2Srt/SRT/SrtSubtitle.swift diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 31b0670..f01b374 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,3 +7,8 @@ updates: directory: "/" schedule: interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d9a7144..bac4391 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,8 +3,12 @@ name: Build on: push: branches: [ "main" ] + paths: + - '**/*.swift' pull_request: branches: [ "main" ] + paths: + - '**/*.swift' jobs: build: @@ -15,22 +19,28 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Build with Xcode + - name: Select Xcode uses: mxcl/xcodebuild@v3 with: xcode: 16.0 - platform: macOS - arch: arm64 - action: build - scheme: macSup2Srt - verbosity: verbose - configuration: release + swift: 6.0 + action: none - - name: Archive - run: tar cvJf ~/macSup2Srt.tar.xz -C /Users/runner/Library/Developer/Xcode/DerivedData/macSup2Srt-ceucuocfsoahbsgjtnfgwrlnyxsv/Build/Products/Release macSup2Srt + - name: Build + run: | + xcrun swift build --configuration release --arch arm64 --arch x86_64 + tar -cvJf macSubtitleOCR.tar.xz -C .build/apple/Products/Release macSubtitleOCR + + - name: Test + run: xcrun swift test + + - name: Periphery + run: | + brew install peripheryapp/periphery/periphery + periphery scan --skip-build --index-store-path .build/debug/index/store - name: Save artifacts uses: actions/upload-artifact@v4 with: - name: macSup2Srt - path: ~/macSup2Srt.tar.xz + name: macSubtitleOCR.tar.xz + path: macSubtitleOCR.tar.xz diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4fdc45b..3039495 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -3,8 +3,12 @@ name: CodeQL on: push: branches: [ "main" ] + paths: + - '**/*.swift' pull_request: branches: [ "main" ] + paths: + - '**/*.swift' schedule: - cron: '19 12 * * 6' @@ -26,15 +30,15 @@ jobs: languages: swift build-mode: manual - - name: Build with Xcode + - name: Select Xcode uses: mxcl/xcodebuild@v3 with: xcode: 16.0 - platform: macOS - arch: arm64 - action: build - scheme: macSup2Srt - configuration: release + swift: 6.0 + action: none + + - name: Build + run: xcrun swift build --configuration release --arch arm64 --arch x86_64 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..f48027e --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,20 @@ +name: Lint +on: + push: + paths: + - '**/*.swift' + pull_request: + paths: + - '**/*.swift' + +jobs: + lint: + name: Lint + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: SwiftFormat + run: swiftformat --lint . --reporter github-actions-log --disable fileHeader # seems to be broken currently in Actions env diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 496b25f..3d82a70 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -8,24 +8,24 @@ jobs: build: name: Build runs-on: macos-latest + permissions: + contents: write steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Build with Xcode + - name: Select Xcode uses: mxcl/xcodebuild@v3 with: xcode: 16.0 - platform: macOS - arch: arm64 - action: build - scheme: macSup2Srt - verbosity: verbose - configuration: release + swift: 6.0 + action: none - - name: Archive - run: tar cvJf ~/macSup2Srt.tar.xz -C /Users/runner/Library/Developer/Xcode/DerivedData/macSup2Srt-ceucuocfsoahbsgjtnfgwrlnyxsv/Build/Products/Release macSup2Srt + - name: Build + run: | + xcrun swift build --configuration release --arch arm64 --arch x86_64 + tar -cvJf macSubtitleOCR.tar.xz -C .build/apple/Products/Release macSubtitleOCR - name: Publish uses: softprops/action-gh-release@v2 @@ -34,5 +34,5 @@ jobs: with: name: nightly tag_name: nightly - files: ~/macSup2Srt.tar.xz + files: macSubtitleOCR.tar.xz prerelease: true diff --git a/.gitignore b/.gitignore index 9ffe657..2142beb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ .DS_Store -macSup2Srt.xcodeproj/project.xcworkspace/xcuserdata -macSup2Srt.xcodeproj/xcuserdata -build +/.build/ +/.swiftpm/ +/.vscode/ tests/images tests/*.json tests/*.srt diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 0000000..950bf7b --- /dev/null +++ b/.periphery.yml @@ -0,0 +1,3 @@ +# retain_public: true +targets: +- macSubtitleOCR diff --git a/.swiftformat b/.swiftformat index 324f5db..f0ac5d8 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,24 +1,23 @@ ---exclude /Volumes/Developer/macSup2Srt/build +--exclude .build --minversion 0 --symlinks ignore --acronyms ID,URL,UUID --allman false --anonymousforeach convert --assetliterals visual-width ---asynccapturing ---beforemarks +--asynccapturing +--beforemarks --binarygrouping none --callsiteparen default --categorymark "MARK: %c" --classthreshold 0 ---closingparen balanced +--closingparen same-line --closurevoid remove --commas always --complexattrs preserve --computedvarattrs preserve --condassignment after-property --conflictmarkers reject ---dateformat system --decimalgrouping none --doccomments before-declarations --elseposition same-line @@ -33,10 +32,10 @@ --fractiongrouping disabled --fragment false --funcattributes preserve ---generictypes +--generictypes --groupedextension "MARK: %c" --guardelse auto ---header "\n{file}\nmacSup2Srt\n\nCreated by {author.name} on {created}.\nCopyright © {year} {author.name}. All rights reserved.\n" +--header "\n{file}\nmacSubtitleOCR\n\nCreated by {author.name} on {created}.\nCopyright © {year} {author.name}. All rights reserved.\n" --hexgrouping 4,8 --hexliteralcase uppercase --ifdef indent @@ -45,19 +44,18 @@ --indentcase false --indentstrings false --initcodernil false ---lifecycle +--lifecycle --lineaftermarks true --linebreaks lf --markcategories true --markextensions always --marktypes always --maxwidth none ---modifierorder ---nevertrailing +--modifierorder +--nevertrailing --nilinit remove ---noncomplexattrs ---nospaceoperators ---nowrapoperators +--noncomplexattrs +--nospaceoperators --octalgrouping none --onelineforeach ignore --operatorfunc spaced @@ -66,8 +64,6 @@ --patternlet hoist --ranges spaced --redundanttype infer-locals-only ---self insert ---selfrequired --semicolons never --shortoptionals except-properties --smarttabs enabled @@ -75,25 +71,19 @@ --storedvarattrs preserve --stripunusedargs always --structthreshold 0 ---swiftversion 5 +--swiftversion 6 --tabwidth unspecified ---throwcapturing ---timezone system ---trailingclosures +--throwcapturing +--trailingclosures --trimwhitespace always --typeattributes preserve --typeblanklines remove --typedelimiter space-after --typemark "MARK: - %t" --voidtype void ---wraparguments preserve ---wrapcollections preserve ---wrapconditions preserve ---wrapeffects preserve --wrapenumcases always --wrapparameters after-first ---wrapreturntype preserve +--wrapcollections before-first --wrapternary default --wraptypealiases preserve ---xcodeindentation disabled --yodaswap always diff --git a/macSup2Srt.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Package.resolved similarity index 79% rename from macSup2Srt.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved rename to Package.resolved index d3fbce1..fa80388 100644 --- a/macSup2Srt.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "59ba1edda695b389d6c9ac1809891cd779e4024f505b0ce1a9d5202b6762e38a", + "originHash" : "d18bff28eb0112efae908d1bc6cac45b09cd50befd488fcb3e2bc6de20e463ac", "pins" : [ { "identity" : "swift-argument-parser", diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..7cb2ff8 --- /dev/null +++ b/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "macSubtitleOCR", + platforms: [ + .macOS("13.0"), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .executableTarget( + name: "macSubtitleOCR", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ]), + .testTarget( + name: "macSubtitleOCRTests", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .target(name: "macSubtitleOCR"), + ], + resources: [ + .process("Resources"), + ]), + ]) diff --git a/README.md b/README.md index 0d81c3f..974d778 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# macSup2Srt -[![License](https://img.shields.io/github/license/ecdye/macSup2Srt)](https://github.com/ecdye/macSup2Srt/blob/main/LICENSE.md) -[![CodeQL](https://github.com/ecdye/macSup2Srt/actions/workflows/codeql.yml/badge.svg)](https://github.com/ecdye/macSup2Srt/actions/workflows/codeql.yml) +# macSubtitleOCR +[![License](https://img.shields.io/github/license/ecdye/macSubtitleOCR)](https://github.com/ecdye/macSubtitleOCR/blob/main/LICENSE.md) +[![CodeQL](https://github.com/ecdye/macSubtitleOCR/actions/workflows/codeql.yml/badge.svg)](https://github.com/ecdye/macSubtitleOCR/actions/workflows/codeql.yml) ## Overview -macSup2Srt is used to covert a file containing a PGS Subtitle stream to SubRip subtitles using OCR. +macSubtitleOCR is used to covert a file containing a PGS Subtitle stream to SubRip subtitles using OCR. Currently the supported input file types are `.mkv` and `.sup`. It uses the built in OCR engine in macOS to perform the text recognition, which works really well. For more information on accuracy, see [Accuracy](#accuracy) below. @@ -21,17 +21,26 @@ For more information on accuracy, see [Accuracy](#accuracy) below. ### Building > [!IMPORTANT] -> This project requires at least Xcode 16 to work properly due to breaking changes, made by Apple, to the Xcode project format. +> This project requires Swift 6 work properly! -To get started with macSup2Srt, clone the repository and then build the project with Xcode. +To get started with macSubtitleOCR, clone the repository and then build the project with Swift. ``` shell -git clone https://github.com/ecdye/macSup2Srt -cd macSup2Srt -xcodebuild -scheme macSup2Srt build +git clone https://github.com/ecdye/macSubtitleOCR +cd macSubtitleOCR +swift build ``` -The completed build should be available in the build directory. +The completed build should be available in the `.build/debug` directory. + +### Testing + +Tests compare the output to a know good output. +We target a match of at least 90% as different machines will produce different output. + +``` shell +swift test +``` ### Accuracy diff --git a/macSup2Srt/Extensions/DataExtensions.swift b/Sources/macSubtitleOCR/Extensions/DataExtensions.swift similarity index 94% rename from macSup2Srt/Extensions/DataExtensions.swift rename to Sources/macSubtitleOCR/Extensions/DataExtensions.swift index 15bcc2b..b6550af 100644 --- a/macSup2Srt/Extensions/DataExtensions.swift +++ b/Sources/macSubtitleOCR/Extensions/DataExtensions.swift @@ -1,6 +1,6 @@ // // DataExtensions.swift -// macSup2Srt +// macSubtitleOCR // // Created by Ethan Dye on 9/16/24. // Copyright © 2024 Ethan Dye. All rights reserved. diff --git a/macSup2Srt/Extensions/TextOutputStreamExtensions.swift b/Sources/macSubtitleOCR/Extensions/TextOutputStreamExtensions.swift similarity index 75% rename from macSup2Srt/Extensions/TextOutputStreamExtensions.swift rename to Sources/macSubtitleOCR/Extensions/TextOutputStreamExtensions.swift index e9b247e..3f7200b 100644 --- a/macSup2Srt/Extensions/TextOutputStreamExtensions.swift +++ b/Sources/macSubtitleOCR/Extensions/TextOutputStreamExtensions.swift @@ -1,6 +1,6 @@ // // TextOutputStreamExtensions.swift -// macSup2Srt +// macSubtitleOCR // // Created by Ethan Dye on 9/16/24. // Copyright © 2024 Ethan Dye. All rights reserved. @@ -8,6 +8,6 @@ import Foundation -public struct StandardErrorOutputStream: TextOutputStream { +struct StandardErrorOutputStream: TextOutputStream { public mutating func write(_ string: String) { fputs(string, stderr) } } diff --git a/macSup2Srt/MKV/EBML/EBML.swift b/Sources/macSubtitleOCR/MKV/EBML/EBML.swift similarity index 97% rename from macSup2Srt/MKV/EBML/EBML.swift rename to Sources/macSubtitleOCR/MKV/EBML/EBML.swift index e4c7057..edbfb4f 100644 --- a/macSup2Srt/MKV/EBML/EBML.swift +++ b/Sources/macSubtitleOCR/MKV/EBML/EBML.swift @@ -1,6 +1,6 @@ // // EBML.swift -// macSup2Srt +// macSubtitleOCR // // Created by Ethan Dye on 9/16/24. // Copyright © 2024 Ethan Dye. All rights reserved. diff --git a/macSup2Srt/MKV/EBML/EBMLParser.swift b/Sources/macSubtitleOCR/MKV/EBML/EBMLParser.swift similarity index 62% rename from macSup2Srt/MKV/EBML/EBMLParser.swift rename to Sources/macSubtitleOCR/MKV/EBML/EBMLParser.swift index 643f297..642021c 100644 --- a/macSup2Srt/MKV/EBML/EBMLParser.swift +++ b/Sources/macSubtitleOCR/MKV/EBML/EBMLParser.swift @@ -1,12 +1,15 @@ // // EBMLParser.swift -// macSup2Srt +// macSubtitleOCR // // Created by Ethan Dye on 9/16/24. // Copyright © 2024 Ethan Dye. All rights reserved. // import Foundation +import os + +private let logger = Logger(subsystem: "github.ecdye.macSubtitleOCR", category: "main") // Helper function to read variable-length integers (VINT) from MKV (up to 8 bytes) func readVINT(from fileHandle: FileHandle, unmodified: Bool = false) -> UInt64 { @@ -21,19 +24,19 @@ func readVINT(from fileHandle: FileHandle, unmodified: Bool = false) -> UInt64 { length += 1 mask >>= 1 } -// print("length: \(length)") // Extract the value -// print(String(format: "mask: 0x%08X", mask)) + logger.debug("Length: \(length), Mask: 0x\(String(format: "%08X", mask))") if mask - 1 == 0x0F { - mask = 0xFF - } else if (length == 1) && !unmodified { + mask = 0xFF // Hacky workaround that I still don't understand why is needed + } else if length == 1, !unmodified { mask = firstByte } else { mask = mask - 1 } -// print(String(format: "Byte: 0x%08X", firstByte)) -// print(String(format: "Res: 0x%08X", firstByte & mask)) + logger.debug("Byte before: 0x\(String(format: "%08X", firstByte))") + logger.debug("Byte after: 0x\(String(format: "%08X", firstByte & mask))") + var value = UInt64(firstByte & mask) if length > 1 { @@ -43,21 +46,20 @@ func readVINT(from fileHandle: FileHandle, unmodified: Bool = false) -> UInt64 { value |= UInt64(byte) } } -// print(String(format: "VINT: 0x%08X", value)) + logger.debug("VINT: 0x\(String(format: "%08X", value))") + return value } // Helper function to read a specified number of bytes func readBytes(from fileHandle: FileHandle, length: Int) -> Data? { - return fileHandle.readData(ofLength: length) + fileHandle.readData(ofLength: length) } // Helper function to read an EBML element's ID and size -func readEBMLElement(from fileHandle: FileHandle, - unmodified: Bool = false) -> (elementID: UInt32, elementSize: UInt64) -{ - let elementID = readVINT(from: fileHandle, unmodified: unmodified) // Read element ID - let elementSize = readVINT(from: fileHandle, unmodified: true) // Read element size -// print(String(format: "elementID: 0x%08X, elementSize: \(elementSize)", elementID)) +func readEBMLElement(from fileHandle: FileHandle, unmodified: Bool = false) -> (elementID: UInt32, elementSize: UInt64) { + let elementID = readVINT(from: fileHandle, unmodified: unmodified) + let elementSize = readVINT(from: fileHandle, unmodified: true) + logger.debug("elementID: 0x\(String(format: "%08X", elementID)), elementSize: \(elementSize)") return (UInt32(elementID), elementSize) } diff --git a/macSup2Srt/MKV/Helpers.swift b/Sources/macSubtitleOCR/MKV/MKVHelpers.swift similarity index 66% rename from macSup2Srt/MKV/Helpers.swift rename to Sources/macSubtitleOCR/MKV/MKVHelpers.swift index a912492..0a7dbad 100644 --- a/macSup2Srt/MKV/Helpers.swift +++ b/Sources/macSubtitleOCR/MKV/MKVHelpers.swift @@ -1,6 +1,6 @@ // -// Helpers.swift -// macSup2Srt +// MKVHelpers.swift +// macSubtitleOCR // // Created by Ethan Dye on 9/16/24. // Copyright © 2024 Ethan Dye. All rights reserved. @@ -8,12 +8,12 @@ import Foundation -public func getUInt16BE(buffer: Data, offset: Int) -> UInt16 { - return (UInt16(buffer[offset]) << 8) | UInt16(buffer[offset + 1]) +func getUInt16BE(buffer: Data, offset: Int) -> UInt16 { + (UInt16(buffer[offset]) << 8) | UInt16(buffer[offset + 1]) } // Function to read a fixed length number of bytes and convert in into a (Un)signed integer -public func readFixedLengthNumber(fileHandle: FileHandle, length: Int, signed: Bool = false) -> Int64 { +func readFixedLengthNumber(fileHandle: FileHandle, length: Int, signed: Bool = false) -> Int64 { let data = fileHandle.readData(ofLength: length) let pos = 0 @@ -33,7 +33,7 @@ public func readFixedLengthNumber(fileHandle: FileHandle, length: Int, signed: B } // Encode the absolute timestamp as 4 bytes in big-endian format for PGS -public func encodePTSForPGS(_ timestamp: Int64) -> [UInt8] { +func encodePTSForPGS(_ timestamp: Int64) -> [UInt8] { let timestamp = UInt32(timestamp) // Convert to unsigned 32-bit value return [ UInt8((timestamp >> 24) & 0xFF), @@ -44,7 +44,7 @@ public func encodePTSForPGS(_ timestamp: Int64) -> [UInt8] { } // Calculate the absolute timestamp with 90 kHz accuracy for PGS format -public func calcAbsPTSForPGS(_ clusterTimestamp: Int64, _ blockTimestamp: Int64, _ timestampScale: Double) -> Int64 { +func calcAbsPTSForPGS(_ clusterTimestamp: Int64, _ blockTimestamp: Int64, _ timestampScale: Double) -> Int64 { // The block timestamp is relative, so we add it to the cluster timestamp - return Int64(((Double(clusterTimestamp) + Double(blockTimestamp)) / timestampScale) * 90000000) + Int64(((Double(clusterTimestamp) + Double(blockTimestamp)) / timestampScale) * 90000000) } diff --git a/macSup2Srt/MKV/MKVParser.swift b/Sources/macSubtitleOCR/MKV/MKVParser.swift similarity index 61% rename from macSup2Srt/MKV/MKVParser.swift rename to Sources/macSubtitleOCR/MKV/MKVParser.swift index 391e28c..993990c 100644 --- a/macSup2Srt/MKV/MKVParser.swift +++ b/Sources/macSubtitleOCR/MKV/MKVParser.swift @@ -1,6 +1,6 @@ // // MKVParser.swift -// macSup2Srt +// macSubtitleOCR // // Created by Ethan Dye on 9/16/24. // Copyright © 2024 Ethan Dye. All rights reserved. @@ -10,84 +10,84 @@ import Foundation import os class MKVParser { - // MARK: Properties + // MARK: - Properties private var eof: UInt64 private var fileHandle: FileHandle private var stderr = StandardErrorOutputStream() private var timestampScale: Double = 1000000.0 // Default value if not specified in a given MKV file - private let logger = Logger(subsystem: "github.ecdye.macSup2Srt", category: "main") + private let logger = Logger(subsystem: "github.ecdye.macSubtitleOCR", category: "main") // MARK: - Lifecycle - public init(filePath: String) throws { + init(filePath: String) throws { guard FileManager.default.fileExists(atPath: filePath) else { - print("Error: file '\(filePath)' does not exist", to: &self.stderr) - throw macSup2SrtError.fileReadError + print("Error: file '\(filePath)' does not exist", to: &stderr) + throw macSubtitleOCRError.fileReadError } - self.fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: filePath)) - self.eof = self.fileHandle.seekToEndOfFile() - self.fileHandle.seek(toFileOffset: 0) - self.logger.debug("Sucessfully opened file: \(filePath)") + fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: filePath)) + eof = fileHandle.seekToEndOfFile() + fileHandle.seek(toFileOffset: 0) + logger.debug("Sucessfully opened file: \(filePath)") } - // MARK: Functions + // MARK: - Functions // Parse the EBML structure and find the Tracks section - public func parseTracks() -> [MKVTrack]? { + func parseTracks() -> [MKVTrack]? { guard let _ = findElement(withID: EBML.segmentID) as? (UInt64, UInt32) else { print("Segment element not found") return nil } - self.logger.debug("Found Segment element") + logger.debug("Found Segment element") guard let (tracksSize, _) = findElement(withID: EBML.tracksID) as? (UInt64, UInt32) else { print("Tracks element not found") return nil } - self.logger.debug("Found Tracks element") - let endOfTracksOffset = self.fileHandle.offsetInFile + tracksSize + logger.debug("Found Tracks element") + let endOfTracksOffset = fileHandle.offsetInFile + tracksSize var trackList = [MKVTrack]() - while self.fileHandle.offsetInFile < endOfTracksOffset { + while fileHandle.offsetInFile < endOfTracksOffset { if let (elementID, elementSize, _) = tryParseElement() { if elementID == EBML.trackEntryID { - self.logger.debug("Found TrackEntry element") + logger.debug("Found TrackEntry element") if let track = parseTrackEntry() { trackList.append(track) } } else if elementID == EBML.chapters { break } else { - self.fileHandle.seek(toFileOffset: self.fileHandle.offsetInFile + elementSize) + fileHandle.seek(toFileOffset: fileHandle.offsetInFile + elementSize) } } } return trackList } - public func closeFile() { - self.fileHandle.closeFile() + func closeFile() { + fileHandle.closeFile() } - public func getSubtitleTrackData(trackNumber: Int, outPath: String) throws -> String? { + func getSubtitleTrackData(trackNumber: Int, outPath: String) throws -> String? { let tmpSup = URL(fileURLWithPath: outPath).deletingPathExtension().appendingPathExtension("sup") .lastPathComponent if let trackData = extractTrackData(trackNumber: trackNumber) { - self.logger.debug("Found track data for track number \(trackNumber): \(trackData)") + logger.debug("Found track data for track number \(trackNumber): \(trackData)") let manager = FileManager.default let tmpFilePath = (manager.temporaryDirectory.path + "/" + tmpSup) if manager.createFile(atPath: tmpFilePath, contents: trackData, attributes: nil) { - self.logger.debug("Created file at path: \(tmpFilePath).") + logger.debug("Created file at path: \(tmpFilePath).") return tmpFilePath } else { - self.logger.debug("Failed to create file at path: \(tmpFilePath).") + logger.debug("Failed to create file at path: \(tmpFilePath).") throw PGSError.fileReadError } } else { - print("Failed to find track data for track number: \(trackNumber).", to: &self.stderr) + print("Error: Failed to find track data for track number: \(trackNumber).", to: &stderr) } return nil } @@ -96,80 +96,76 @@ class MKVParser { // Function to seek to the track bytestream for a specific track number and extract all blocks private func extractTrackData(trackNumber: Int) -> Data? { - self.fileHandle.seek(toFileOffset: 0) + fileHandle.seek(toFileOffset: 0) // Step 1: Locate the Segment element if let (segmentSize, _) = findElement(withID: EBML.segmentID) as? (UInt64, UInt32) { - let segmentEndOffset = self.fileHandle.offsetInFile + segmentSize - self.logger - .debug("Found Segment, Size: \(segmentSize), End Offset: \(segmentEndOffset), EOF: \(self.eof)\n") + let segmentEndOffset = fileHandle.offsetInFile + segmentSize + // swiftformat:disable:next redundantSelf + logger.debug("Found Segment, Size: \(segmentSize), End Offset: \(segmentEndOffset), EOF: \(self.eof)") var trackData = Data() // Step 2: Parse Clusters within the Segment - while self.fileHandle.offsetInFile < segmentEndOffset { + while fileHandle.offsetInFile < segmentEndOffset { if let (clusterSize, _) = findElement(withID: EBML.cluster, avoidCluster: false) as? ( UInt64, - UInt32 - ) { - let clusterEndOffset = self.fileHandle.offsetInFile + clusterSize - self.logger.debug("Found Cluster, Size: \(clusterSize), End Offset: \(clusterEndOffset)\n") + UInt32) + { + let clusterEndOffset = fileHandle.offsetInFile + clusterSize + logger.debug("Found Cluster, Size: \(clusterSize), End Offset: \(clusterEndOffset)\n") // Step 3: Extract the cluster timestamp guard let clusterTimestamp = extractClusterTimestamp() else { - self.logger.warning("Failed to extract cluster timestamp, skipping cluster.") + logger.warning("Failed to extract cluster timestamp, skipping cluster.") continue } - self.logger.debug("Cluster Timestamp: \(clusterTimestamp)") + logger.debug("Cluster Timestamp: \(clusterTimestamp)") // Step 4: Parse Blocks (SimpleBlock or Block) within each Cluster - while self.fileHandle.offsetInFile < clusterEndOffset { - self.logger - .debug( - "Looking for Block at Offset: \(self.fileHandle.offsetInFile)/\(clusterEndOffset)" - ) + while fileHandle.offsetInFile < clusterEndOffset { + // swiftformat:disable:next redundantSelf + logger.debug("Looking for Block at Offset: \(self.fileHandle.offsetInFile)/\(clusterEndOffset)") if let (blockSize, blockType) = findElement( withID: EBML.simpleBlock, - EBML.blockGroup - ) as? (UInt64, UInt32) { - var blockStartOffset = self.fileHandle.offsetInFile + EBML.blockGroup) as? (UInt64, UInt32) + { + var blockStartOffset = fileHandle.offsetInFile var blockSize = blockSize if blockType == EBML.blockGroup { guard let (ns, _) = findElement(withID: EBML.block) as? (UInt64, UInt32) else { return nil } blockSize = ns - blockStartOffset = self.fileHandle.offsetInFile + blockStartOffset = fileHandle.offsetInFile } // Step 5: Read the track number in the block and compare it if let (blockTrackNumber, blockTimestamp) = readTrackNumber(from: fileHandle) as? ( UInt64, - Int64 - ) { + Int64) + { if blockTrackNumber == trackNumber { // Step 6: Calculate and encode the timestamp as 4 bytes in big-endian // (PGS format) let absPTS = calcAbsPTSForPGS( clusterTimestamp, blockTimestamp, - timestampScale - ) + timestampScale) let pgsPTS = encodePTSForPGS(absPTS) // Step 7: Read the block data and add needed PGS headers and timestamps let pgsHeader = Data([0x50, 0x47] + pgsPTS + [0x00, 0x00, 0x00, 0x00]) var blockData = Data() - let raw = self.fileHandle + let raw = fileHandle .readData(ofLength: Int(blockSize - - (self.fileHandle.offsetInFile - blockStartOffset))) + (fileHandle.offsetInFile - blockStartOffset))) var offset = 0 while (offset + 3) <= raw.count { let segmentSize = min( Int(getUInt16BE(buffer: raw, offset: offset + 1) + 3), - raw.count - offset - ) - self.logger + raw.count - offset) + logger .debug( "Segment size \(segmentSize) at \(offset) type 0x\(String(format: "%02x", raw[offset]))" ) @@ -182,15 +178,10 @@ class MKVParser { trackData.append(blockData) } else { // Skip this block if it's for a different track - self.logger - .debug( - "Skipping Block at Offset: \(self.fileHandle.offsetInFile)/\(clusterEndOffset)" - ) - self.logger - .debug( - "Got Track Number: \(blockTrackNumber) looking for: \(trackNumber)" - ) - self.fileHandle.seek(toFileOffset: blockStartOffset + blockSize) + // swiftformat:disable:next redundantSelf + logger.debug("Skipping Block at Offset: \(self.fileHandle.offsetInFile)/\(clusterEndOffset)") + logger.debug("Got Track Number: \(blockTrackNumber) looking for: \(trackNumber)") + fileHandle.seek(toFileOffset: blockStartOffset + blockSize) } } } else { @@ -211,7 +202,7 @@ class MKVParser { // Extract the cluster timestamp private func extractClusterTimestamp() -> Int64? { if let (timestampElementSize, _) = findElement(withID: EBML.timestamp) as? (UInt64, UInt32) { - return readFixedLengthNumber(fileHandle: self.fileHandle, length: Int(timestampElementSize)) + return readFixedLengthNumber(fileHandle: fileHandle, length: Int(timestampElementSize)) } return nil } @@ -224,7 +215,7 @@ class MKVParser { let suffix = fileHandle.readData(ofLength: 1).first ?? 0 let lacingFlag = (suffix >> 1) & 0x03 // Bits 1 and 2 are the lacing type - self.logger.debug("Track number: \(trackNumber), Timestamp: \(timestamp), Lacing type: \(lacingFlag)") + logger.debug("Track number: \(trackNumber), Timestamp: \(timestamp), Lacing type: \(lacingFlag)") return (trackNumber, timestamp) } @@ -234,24 +225,24 @@ class MKVParser { { while let (elementID, elementSize, elementOffset) = tryParseElement() { // Ensure we stop if we have reached or passed the EOF - if self.fileHandle.offsetInFile >= self.eof { + if fileHandle.offsetInFile >= eof { return (nil, nil) } // If, by chance, we find a TimestampScale element, update it from the default if elementID == EBML.timestampScale { - self.timestampScale = Double(readFixedLengthNumber( - fileHandle: self.fileHandle, - length: Int(elementSize) - )) - self.logger.debug("Found timestamp scale: \(self.timestampScale)") + timestampScale = Double(readFixedLengthNumber( + fileHandle: fileHandle, + length: Int(elementSize))) + // swiftformat:disable:next redundantSelf + logger.debug("Found timestamp scale: \(self.timestampScale)") return (nil, nil) } // If a Cluster header is encountered, seek back to the start of the Cluster if elementID == EBML.cluster && avoidCluster { - self.logger.debug("Encountered Cluster: seeking back to before the cluster header") - self.fileHandle.seek(toFileOffset: elementOffset) + logger.debug("Encountered Cluster: seeking back to before the cluster header") + fileHandle.seek(toFileOffset: elementOffset) return (nil, nil) } @@ -260,8 +251,8 @@ class MKVParser { return (elementSize, elementID) } else { // Skip over the element's data by seeking to its end - self.logger.debug("Found: \(elementID), but not \(targetID), skipping element") - self.fileHandle.seek(toFileOffset: self.fileHandle.offsetInFile + elementSize) + logger.debug("Found: \(elementID), but not \(targetID), skipping element") + fileHandle.seek(toFileOffset: fileHandle.offsetInFile + elementSize) } } @@ -277,24 +268,24 @@ class MKVParser { while let (elementID, elementSize, _) = tryParseElement() { switch elementID { case EBML.trackNumberID: - trackNumber = Int((readBytes(from: self.fileHandle, length: 1)?.first)!) - self.logger.debug("Found track number: \(trackNumber!)") - case EBML.trackTypeID: - trackType = readBytes(from: self.fileHandle, length: 1)?.first - self.logger.debug("Found track type: \(trackType!)") + trackNumber = Int((readBytes(from: fileHandle, length: 1)?.first)!) + logger.debug("Found track number: \(trackNumber!)") + case EBML.trackTypeID: // Unused by us, left for debugging + trackType = readBytes(from: fileHandle, length: 1)?.first + logger.debug("Found track type: \(trackType!)") case EBML.codecID: var data = readBytes(from: fileHandle, length: Int(elementSize)) data?.removeNullBytes() codecId = data.flatMap { String(data: $0, encoding: .ascii) } - self.logger.debug("Found codec ID: \(codecId!)") + logger.debug("Found codec ID: \(codecId!)") default: - self.fileHandle.seek(toFileOffset: self.fileHandle.offsetInFile + elementSize) + fileHandle.seek(toFileOffset: fileHandle.offsetInFile + elementSize) } if trackNumber != nil, trackType != nil, codecId != nil { break } } - if let trackNumber = trackNumber, let trackType = trackType, let codecId = codecId { - return MKVTrack(trackNumber: trackNumber, trackType: trackType, codecId: codecId) + if let trackNumber, let codecId { + return MKVTrack(trackNumber: trackNumber, codecId: codecId) } return nil } @@ -302,7 +293,7 @@ class MKVParser { private func tryParseElement(unmodified: Bool = false) -> (elementID: UInt32, elementSize: UInt64, oldOffset: UInt64)? { - let oldOffset = self.fileHandle.offsetInFile + let oldOffset = fileHandle.offsetInFile let (elementID, elementSize) = readEBMLElement(from: fileHandle, unmodified: unmodified) return (elementID, elementSize, oldOffset: oldOffset) } diff --git a/macSup2Srt/MKV/MKVTrack.swift b/Sources/macSubtitleOCR/MKV/MKVTrack.swift similarity index 84% rename from macSup2Srt/MKV/MKVTrack.swift rename to Sources/macSubtitleOCR/MKV/MKVTrack.swift index 076b6cd..9eea5c2 100644 --- a/macSup2Srt/MKV/MKVTrack.swift +++ b/Sources/macSubtitleOCR/MKV/MKVTrack.swift @@ -1,6 +1,6 @@ // // MKVTrack.swift -// macSup2Srt +// macSubtitleOCR // // Created by Ethan Dye on 9/16/24. // Copyright © 2024 Ethan Dye. All rights reserved. @@ -10,6 +10,5 @@ import Foundation struct MKVTrack { let trackNumber: Int - let trackType: UInt8 let codecId: String } diff --git a/macSup2Srt/PGS/PGS.swift b/Sources/macSubtitleOCR/PGS/PGS.swift similarity index 76% rename from macSup2Srt/PGS/PGS.swift rename to Sources/macSubtitleOCR/PGS/PGS.swift index a0a511e..b54f6b7 100644 --- a/macSup2Srt/PGS/PGS.swift +++ b/Sources/macSubtitleOCR/PGS/PGS.swift @@ -1,6 +1,6 @@ // // PGS.swift -// macSup2Srt +// macSubtitleOCR // // Created by Ethan Dye on 9/2/24. // Copyright © 2024 Ethan Dye. All rights reserved. @@ -9,48 +9,30 @@ import CoreGraphics import Foundation import ImageIO -import UniformTypeIdentifiers -public class PGS { - // MARK: - Lifecycle - - public init() {} - - // MARK: - Functions - - // Parses a `.sup` file and returns an array of `PGSSubtitle` objects - public func parseSupFile(fromFileAt url: URL) throws -> [PGSSubtitle] { - let fileHandle = try FileHandle(forReadingFrom: url) - defer { fileHandle.closeFile() } +class PGS { + // MARK: - Properties - var subtitles = [PGSSubtitle]() - let fileLength = try fileHandle.seekToEnd() - fileHandle.seek(toFileOffset: 0) // Ensure the file handle is at the start - var headerData = fileHandle.readData(ofLength: 13) + private var subtitles = [PGSSubtitle]() - while fileHandle.offsetInFile < fileLength { - guard var subtitle = try parseNextSubtitle(fileHandle: fileHandle, headerData: &headerData) - else { - headerData = fileHandle.readData(ofLength: 13) - continue - } + // MARK: - Lifecycle - // Find the next timestamp to use as our end timestamp - while subtitle.endTimestamp <= subtitle.timestamp { - headerData = fileHandle.readData(ofLength: 13) - subtitle.endTimestamp = self.parseTimestamp(headerData) - } + init(_ url: URL) throws { + try parseSupFile(fromFileAt: url) + } - subtitles.append(subtitle) - } + // MARK: - Getters - return subtitles + func getSubtitles() -> [PGSSubtitle] { + subtitles } + // MARK: - Functions + // Converts the RGBA data to a CGImage - public func createImage(from subtitle: inout PGSSubtitle) -> CGImage? { + func createImage(index: Int) -> CGImage? { // Convert the image data to RGBA format using the palette - let rgbaData = self.imageDataToRGBA(&subtitle) + let rgbaData = imageDataToRGBA(&subtitles[index]) let bitmapInfo = CGBitmapInfo.byteOrder32Big .union(CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)) @@ -60,11 +42,11 @@ public class PGS { return nil } - return CGImage(width: subtitle.imageWidth, - height: subtitle.imageHeight, + return CGImage(width: subtitles[index].imageWidth, + height: subtitles[index].imageHeight, bitsPerComponent: 8, bitsPerPixel: 32, - bytesPerRow: subtitle.imageWidth * 4, // 4 bytes per pixel (RGBA) + bytesPerRow: subtitles[index].imageWidth * 4, // 4 bytes per pixel (RGBA) space: colorSpace, bitmapInfo: bitmapInfo, provider: provider, @@ -73,37 +55,39 @@ public class PGS { intent: .defaultIntent) } - public func saveImageAsPNG(image: CGImage, outputPath: URL) throws { - guard let destination = CGImageDestinationCreateWithURL(outputPath as CFURL, UTType.png.identifier as CFString, 1, nil) - else { - throw macSup2SrtError.fileReadError - } - CGImageDestinationAddImage(destination, image, nil) + // MARK: - Methods + + // Parses a `.sup` file and populates the subtitles array + private func parseSupFile(fromFileAt url: URL) throws { + let fileHandle = try FileHandle(forReadingFrom: url) + defer { fileHandle.closeFile() } + + let fileLength = try fileHandle.seekToEnd() + fileHandle.seek(toFileOffset: 0) // Ensure the file handle is at the start + var headerData = fileHandle.readData(ofLength: 13) + + while fileHandle.offsetInFile < fileLength { + guard var subtitle = try parseNextSubtitle(fileHandle: fileHandle, headerData: &headerData) + else { + headerData = fileHandle.readData(ofLength: 13) + continue + } + + // Find the next timestamp to use as our end timestamp + while subtitle.endTimestamp <= subtitle.timestamp { + headerData = fileHandle.readData(ofLength: 13) + subtitle.endTimestamp = parseTimestamp(headerData) + } - if !CGImageDestinationFinalize(destination) { - throw macSup2SrtError.fileReadError + subtitles.append(subtitle) } } - // MARK: - Methods - private func parseTimestamp(_ data: Data) -> TimeInterval { let pts = (Int(data[2]) << 24 | Int(data[3]) << 16 | Int(data[4]) << 8 | Int(data[5])) return TimeInterval(pts) / 90000.0 // 90 kHz clock } - // Parses the Presentation Composition Segment (PCS) to extract the height and width of the video. - // PCS structure (simplified): - // 0x14: Segment Type; already checked by the caller - // 2 bytes: Width - // 2 bytes: Height - private func parsePCS(_ data: Data) -> (width: Int, height: Int) { - let width = Int(data[0]) << 8 | Int(data[1]) - let height = Int(data[2]) << 8 | Int(data[3]) - - return (width: width, height: height) - } - // Converts the image data to RGBA format using the palette private func imageDataToRGBA(_ subtitle: inout PGSSubtitle) -> Data { let bytesPerPixel = 4 @@ -183,7 +167,7 @@ public class PGS { if p1, p2 { p1 = false p2 = false - subtitle.timestamp = self.parseTimestamp(headerData) + subtitle.timestamp = parseTimestamp(headerData) return subtitle } } diff --git a/macSup2Srt/PGS/PGSError.swift b/Sources/macSubtitleOCR/PGS/PGSError.swift similarity index 69% rename from macSup2Srt/PGS/PGSError.swift rename to Sources/macSubtitleOCR/PGS/PGSError.swift index 97c1079..e13be06 100644 --- a/macSup2Srt/PGS/PGSError.swift +++ b/Sources/macSubtitleOCR/PGS/PGSError.swift @@ -1,13 +1,12 @@ // // PGSError.swift -// macSup2Srt +// macSubtitleOCR // // Created by Ethan Dye on 9/12/24. // Copyright © 2024 Ethan Dye. All rights reserved. // -public enum PGSError: Error { +enum PGSError: Error { case invalidFormat case fileReadError - case unsupportedFormat } diff --git a/Sources/macSubtitleOCR/PGS/PGSSubtitle.swift b/Sources/macSubtitleOCR/PGS/PGSSubtitle.swift new file mode 100644 index 0000000..10c8875 --- /dev/null +++ b/Sources/macSubtitleOCR/PGS/PGSSubtitle.swift @@ -0,0 +1,18 @@ +// +// PGSSubtitle.swift +// macSubtitleOCR +// +// Created by Ethan Dye on 9/16/24. +// Copyright © 2024 Ethan Dye. All rights reserved. +// + +import Foundation + +struct PGSSubtitle { + var timestamp: TimeInterval = 0 + var imageWidth: Int = 0 + var imageHeight: Int = 0 + var imageData: Data = .init() + var imagePalette: [UInt8] = [] + var endTimestamp: TimeInterval = 0 +} diff --git a/Sources/macSubtitleOCR/PGS/Parsers/ODS.swift b/Sources/macSubtitleOCR/PGS/Parsers/ODS.swift new file mode 100644 index 0000000..9c69e8d --- /dev/null +++ b/Sources/macSubtitleOCR/PGS/Parsers/ODS.swift @@ -0,0 +1,66 @@ +// +// ODS.swift +// macSubtitleOCR +// +// Created by Ethan Dye on 9/12/24. +// Copyright © 2024 Ethan Dye. All rights reserved. +// + +import Foundation + +class ODS { + // MARK: - Properties + + private var objectDataLength: Int = 0 + private var objectWidth: Int = 0 + private var objectHeight: Int = 0 + private var imageData: Data = .init() + + // MARK: - Lifecycle + + init(_ data: Data) throws { + try parseODS(data) + } + + // MARK: - Getters + + func getObjectWidth() -> Int { + objectWidth + } + + func getObjectHeight() -> Int { + objectHeight + } + + func getImageData() -> Data { + imageData + } + + // MARK: - Methods + + // Parses the Object Definition Segment (ODS) to extract the subtitle image bitmap. + // ODS structure (simplified): + // 0x17: Segment Type; already checked by the caller + // 2 bytes: Object ID (unused by us) + // 1 byte: Version number (unused by us) + // 1 byte: Sequence flag (should be 0x80 for new object, 0x00 for continuation) (unused by us) + // 3 bytes: Object data length + // 2 bytes: Object width + // 2 bytes: Object height + // Rest: Image data (run-length encoded, RLE) + private func parseODS(_ data: Data) throws { + // let objectID = Int(data[0]) << 8 | Int(data[1]) + objectDataLength = Int(data[4]) << 16 | Int(data[5]) << 8 | Int(data[6]) + + // PGS includes the width and height as part of the image data length calculations + guard objectDataLength <= data.count - 7 else { + throw macSubtitleOCRError.invalidFormat + } + + objectWidth = Int(data[7]) << 8 | Int(data[8]) + objectHeight = Int(data[9]) << 8 | Int(data[10]) + + let rleImageData = RLEData(data: data.subdata(in: 11 ..< data.endIndex), width: objectWidth, height: objectHeight) + imageData = try rleImageData.decode() + } +} diff --git a/macSup2Srt/PGS/Parsers/PDS.swift b/Sources/macSubtitleOCR/PGS/Parsers/PDS.swift similarity index 70% rename from macSup2Srt/PGS/Parsers/PDS.swift rename to Sources/macSubtitleOCR/PGS/Parsers/PDS.swift index ebe6133..9a64bd4 100644 --- a/macSup2Srt/PGS/Parsers/PDS.swift +++ b/Sources/macSubtitleOCR/PGS/Parsers/PDS.swift @@ -1,6 +1,6 @@ // // PDS.swift -// macSup2Srt +// macSubtitleOCR // // Created by Ethan Dye on 9/8/24. // Copyright © 2024 Ethan Dye. All rights reserved. @@ -9,52 +9,40 @@ import Foundation import simd -public class PDS { +class PDS { // MARK: - Properties - private var id: UInt8 = 0 - private var version: UInt8 = 0 private var palette = [UInt8](repeating: 0, count: 1024) // MARK: - Lifecycle init(_ data: Data) throws { guard data.count >= 8 else { - throw macSup2SrtError.invalidFormat + throw PGSError.invalidFormat } - self.id = data[0] - self.version = data[1] - try self.parsePDS(data) + try parsePDS(data) } // MARK: - Getters - public func getID() -> UInt8 { - return self.id + func getPalette() -> [UInt8] { + palette } - public func getVersion() -> UInt8 { - return self.version - } - - public func getPalette() -> [UInt8] { - return self.palette - } - - // MARK: - Parser + // MARK: - Methods // Parses the Palette Definition Segment (PDS) to extract the RGBA palette. // PDS structure: // 1 byte: Segment Type (0x16); already checked by the caller - // 1 byte: Palette ID - // 1 byte: Palette Version + // 1 byte: Palette ID (unused by us) + // 1 byte: Palette Version (unused by us) // Followed by a series of palette entries: // Each entry is 5 bytes: (Index, Y, Cr, Cb, Alpha) private func parsePDS(_ data: Data) throws { // Start reading after the first 2 bytes (Palette ID and Version) if (data.count - 2) % 5 != 0 { print("Invalid Palette Data Segment Length: \(data.count)") - throw macSup2SrtError.invalidFormat + throw PGSError.invalidFormat } var i = 2 @@ -66,13 +54,13 @@ public class PDS { let alpha = data[i + 4] // Convert YCrCb to RGB - let rgb = self.yCrCbToRGB(y: y, cr: cr, cb: cb) + let rgb = yCrCbToRGB(y: y, cr: cr, cb: cb) // Store RGBA values to palette table - self.palette[Int(index) * 4 + 0] = rgb.red - self.palette[Int(index) * 4 + 1] = rgb.green - self.palette[Int(index) * 4 + 2] = rgb.blue - self.palette[Int(index) * 4 + 3] = alpha + palette[Int(index) * 4 + 0] = rgb.red + palette[Int(index) * 4 + 1] = rgb.green + palette[Int(index) * 4 + 2] = rgb.blue + palette[Int(index) * 4 + 3] = alpha i += 5 } } diff --git a/Sources/macSubtitleOCR/PGS/Parsers/RLE/RLEData.swift b/Sources/macSubtitleOCR/PGS/Parsers/RLE/RLEData.swift new file mode 100644 index 0000000..6ae722b --- /dev/null +++ b/Sources/macSubtitleOCR/PGS/Parsers/RLE/RLEData.swift @@ -0,0 +1,79 @@ +// +// RLEData.swift +// macSubtitleOCR +// +// Created by Ethan Dye on 9/19/24. +// Copyright © 2024 Ethan Dye. All rights reserved. +// + +import Foundation + +class RLEData { + // MARK: - Properties + + private var width: Int + private var height: Int + private var data: Data + + // MARK: - Lifecycle + + init(data: Data, width: Int, height: Int) { + self.width = width + self.height = height + self.data = data + } + + // MARK: - Functions + + func decode() throws -> Data { + var stderr = StandardErrorOutputStream() + let rleBitmapEnd = data.endIndex + var pixelCount = 0 + var lineCount = 0 + var buf = 0 + + var image = Data() + + while buf < rleBitmapEnd, lineCount < height { + var color: UInt8 = data[buf] + buf += 1 + var run = 1 + + if color == 0x00 { + let flags = data[buf] + buf += 1 + run = Int(flags & 0x3F) + if flags & 0x40 != 0 { + run = (run << 8) + Int(data[buf]) + buf += 1 + } + color = (flags & 0x80) != 0 ? data[buf] : 0 + if (flags & 0x80) != 0 { + buf += 1 + } + } + + // Ensure run is valid and doesn't exceed pixel buffer + if run > 0, pixelCount + run <= width * height { + // Fill the pixel data with the decoded color + image.append(contentsOf: repeatElement(color, count: run)) + pixelCount += run + } else if run == 0 { + // New Line: Check if pixels align correctly + if pixelCount % width > 0 { + print("Error: Decoded \(pixelCount % width) pixels, but line should be \(width) pixels.", to: &stderr) + throw RLEDataError.invalidData + } + lineCount += 1 + } + } + + // Check if we decoded enough pixels + if pixelCount < width * height { + print("Error: Insufficient RLE data for subtitle.", to: &stderr) + throw RLEDataError.insufficientData + } + + return image + } +} diff --git a/Sources/macSubtitleOCR/PGS/Parsers/RLE/RLEDataError.swift b/Sources/macSubtitleOCR/PGS/Parsers/RLE/RLEDataError.swift new file mode 100644 index 0000000..43126b8 --- /dev/null +++ b/Sources/macSubtitleOCR/PGS/Parsers/RLE/RLEDataError.swift @@ -0,0 +1,12 @@ +// +// RLEDataError.swift +// macSubtitleOCR +// +// Created by Ethan Dye on 9/19/24. +// Copyright © 2024 Ethan Dye. All rights reserved. +// + +enum RLEDataError: Error { + case invalidData + case insufficientData +} diff --git a/Sources/macSubtitleOCR/SRT/SRT.swift b/Sources/macSubtitleOCR/SRT/SRT.swift new file mode 100644 index 0000000..d73a5a3 --- /dev/null +++ b/Sources/macSubtitleOCR/SRT/SRT.swift @@ -0,0 +1,60 @@ +// +// SRT.swift +// macSubtitleOCR +// +// Created by Ethan Dye on 9/2/24. +// Copyright © 2024 Ethan Dye. All rights reserved. +// + +import Foundation + +class SRT { + // MARK: - Properties + + private var subtitles: [SRTSubtitle] = [] + + // MARK: - Getters / Setters + + func appendSubtitle(_ subtitle: SRTSubtitle) { + subtitles.append(subtitle) + } + + // MARK: - Functions + + // Writes the SRT object to the file at the given URL + func write(toFileAt url: URL) throws { + let srtContent = encode() + do { + try srtContent.write(to: url, atomically: true, encoding: .utf8) + } catch { + throw SRTError.fileWriteError + } + } + + // MARK: - Methods + + // Encodes the SRT object into SRT format and returns it as a string + private func encode() -> String { + var srtContent = "" + + for subtitle in subtitles { + let startTime = formatTime(subtitle.startTime) + let endTime = formatTime(subtitle.endTime) + + srtContent += "\(subtitle.index)\n" + srtContent += "\(startTime) --> \(endTime)\n" + srtContent += "\(subtitle.text)\n\n" + } + + return srtContent + } + + private func formatTime(_ time: TimeInterval) -> String { + let hours = Int(time) / 3600 + let minutes = (Int(time) % 3600) / 60 + let seconds = Int(time) % 60 + let milliseconds = Int((time - TimeInterval(Int(time))) * 1000) + + return String(format: "%02d:%02d:%02d,%03d", hours, minutes, seconds, milliseconds) + } +} diff --git a/Sources/macSubtitleOCR/SRT/SRTError.swift b/Sources/macSubtitleOCR/SRT/SRTError.swift new file mode 100644 index 0000000..edb95a9 --- /dev/null +++ b/Sources/macSubtitleOCR/SRT/SRTError.swift @@ -0,0 +1,11 @@ +// +// SRTError.swift +// macSubtitleOCR +// +// Created by Ethan Dye on 9/19/24. +// Copyright © 2024 Ethan Dye. All rights reserved. +// + +enum SRTError: Error { + case fileWriteError +} diff --git a/Sources/macSubtitleOCR/SRT/SRTSubtitle.swift b/Sources/macSubtitleOCR/SRT/SRTSubtitle.swift new file mode 100644 index 0000000..6af9dca --- /dev/null +++ b/Sources/macSubtitleOCR/SRT/SRTSubtitle.swift @@ -0,0 +1,16 @@ +// +// SRTSubtitle.swift +// macSubtitleOCR +// +// Created by Ethan Dye on 9/16/24. +// Copyright © 2024 Ethan Dye. All rights reserved. +// + +import Foundation + +struct SRTSubtitle { + var index: Int + var startTime: TimeInterval + var endTime: TimeInterval + var text: String +} diff --git a/macSup2Srt/macSup2Srt.swift b/Sources/macSubtitleOCR/macSubtitleOCR.swift similarity index 57% rename from macSup2Srt/macSup2Srt.swift rename to Sources/macSubtitleOCR/macSubtitleOCR.swift index 0ef493c..83a244f 100644 --- a/macSup2Srt/macSup2Srt.swift +++ b/Sources/macSubtitleOCR/macSubtitleOCR.swift @@ -1,6 +1,6 @@ // -// macSup2Srt.swift -// macSup2Srt +// macSubtitleOCR.swift +// macSubtitleOCR // // Created by Ethan Dye on 9/2/24. // Copyright © 2024 Ethan Dye. All rights reserved. @@ -12,9 +12,9 @@ import os import UniformTypeIdentifiers import Vision -// The main struct representing the macSup2Srt command-line tool. +// The main struct representing the macSubtitleOCR command-line tool. @main -struct macSup2Srt: ParsableCommand { +struct macSubtitleOCR: ParsableCommand { // MARK: - Properties @Argument(help: "Input .sup subtitle file") @@ -45,25 +45,24 @@ struct macSup2Srt: ParsableCommand { mutating func run() throws { // Setup utilities - let logger = Logger(subsystem: "github.ecdye.macSup2Srt", category: "main") + let logger = Logger(subsystem: "github.ecdye.macSubtitleOCR", category: "main") let manager = FileManager.default // Setup options - let inFile = self.sup - let revision = self.setOCRRevision() - let recognitionLevel = self.setOCRMode() - let languages = self.language.split(separator: ",").map { String($0) } + let inFile = sup + let revision = setOCRRevision() + let recognitionLevel = setOCRMode() + let languages = language.split(separator: ",").map { String($0) } // Setup data variables var subIndex = 1 var jsonStream: [Any] = [] - var inSubStream: [PGSSubtitle] - var outSubStream: [SrtSubtitle] = [] + let srtStream = SRT() - if self.sup.hasSuffix(".mkv") { + if sup.hasSuffix(".mkv") { let mkvParser = try MKVParser(filePath: sup) var trackNumber: Int? - guard let tracks = mkvParser.parseTracks() else { throw macSup2SrtError.invalidFormat } + guard let tracks = mkvParser.parseTracks() else { throw macSubtitleOCRError.invalidFormat } for track in tracks { logger.debug("Found subtitle track: \(track.trackNumber), Codec: \(track.codecId)") if track.codecId == "S_HDMV/PGS" { @@ -71,39 +70,36 @@ struct macSup2Srt: ParsableCommand { break // TODO: Implement ability to extract all PGS tracks in file } } - self.sup = try mkvParser.getSubtitleTrackData(trackNumber: trackNumber!, outPath: self.sup)! + sup = try mkvParser.getSubtitleTrackData(trackNumber: trackNumber!, outPath: sup)! mkvParser.closeFile() } - // Initialize the decoder - let PGS = PGS() - inSubStream = try PGS.parseSupFile(fromFileAt: URL(fileURLWithPath: self.sup)) + // Open the PGS data stream + let PGS = try PGS(URL(fileURLWithPath: sup)) - for var subtitle in inSubStream { - if subtitle.imageWidth == 0 && subtitle.imageHeight == 0 { + for subtitle in PGS.getSubtitles() { + if subtitle.imageWidth == 0, subtitle.imageHeight == 0 { logger.debug("Skipping subtitle index \(subIndex) with empty image data!") continue } - guard let subImage = PGS.createImage(from: &subtitle) + guard let subImage = PGS.createImage(index: subIndex - 1) else { - logger.info("Could not create image from decoded data for index \(subIndex)! Skipping...") + logger.info("Could not create image for index \(subIndex)! Skipping...") continue } // Save subtitle image as PNG if imageDirectory is provided - if let imageDirectory = imageDirectory { + if let imageDirectory { let outputDirectory = URL(fileURLWithPath: imageDirectory) do { - try manager.createDirectory(at: outputDirectory, - withIntermediateDirectories: false, - attributes: nil) + try manager.createDirectory(at: outputDirectory, withIntermediateDirectories: false, attributes: nil) } catch CocoaError.fileWriteFileExists { // Folder already existed } let pngPath = outputDirectory.appendingPathComponent("subtitle_\(subIndex).png") - try PGS.saveImageAsPNG(image: subImage, outputPath: pngPath) + try saveImageAsPNG(image: subImage, outputPath: pngPath) } // Perform text recognition @@ -120,17 +116,16 @@ struct macSup2Srt: ParsableCommand { let stringRange = string.startIndex ..< string.endIndex let boxObservation = try? candidate?.boundingBox(for: stringRange) let boundingBox = boxObservation?.boundingBox ?? .zero - let rect = VNImageRectForNormalizedRect(boundingBox, - subtitle.imageWidth, - subtitle.imageHeight) - - let line: [String: Any] = ["text": string, - "confidence": confidence, - "x": Int(rect.minX), - "width": Int(rect.size.width), - "y": Int(CGFloat(subtitle.imageHeight) - rect.minY - rect - .size.height), - "height": Int(rect.size.height)] + let rect = VNImageRectForNormalizedRect(boundingBox, subtitle.imageWidth, subtitle.imageHeight) + + let line: [String: Any] = [ + "text": string, + "confidence": confidence, + "x": Int(rect.minX), + "width": Int(rect.size.width), + "y": Int(CGFloat(subtitle.imageHeight) - rect.minY - rect.size.height), + "height": Int(rect.size.height), + ] subtitleLines.append(line) subtitleText += string @@ -140,22 +135,23 @@ struct macSup2Srt: ParsableCommand { } } - let subtitleData: [String: Any] = ["image": subIndex, - "lines": subtitleLines, - "text": subtitleText] + let subtitleData: [String: Any] = [ + "image": subIndex, + "lines": subtitleLines, + "text": subtitleText, + ] jsonStream.append(subtitleData) - let newSubtitle = SrtSubtitle(index: subIndex, - startTime: subtitle.timestamp, - endTime: subtitle.endTimestamp, - text: subtitleText) - - outSubStream.append(newSubtitle) + // Append subtitle to SRT stream + srtStream.appendSubtitle(SRTSubtitle(index: subIndex, + startTime: subtitle.timestamp, + endTime: subtitle.endTimestamp, + text: subtitleText)) } request.recognitionLevel = recognitionLevel - request.usesLanguageCorrection = self.languageCorrection + request.usesLanguageCorrection = languageCorrection request.revision = revision request.recognitionLanguages = languages @@ -164,10 +160,9 @@ struct macSup2Srt: ParsableCommand { subIndex += 1 } - if let json = json { + if let json { // Convert subtitle data to JSON - let jsonData = try JSONSerialization.data(withJSONObject: jsonStream, - options: [.prettyPrinted, .sortedKeys]) + let jsonData = try JSONSerialization.data(withJSONObject: jsonStream, options: [.prettyPrinted, .sortedKeys]) let jsonString = String(data: jsonData, encoding: .utf8) ?? "[]" // Write JSON to file @@ -176,33 +171,42 @@ struct macSup2Srt: ParsableCommand { encoding: .utf8) } - if self.saveSup, inFile.hasSuffix(".mkv") { + if saveSup, inFile.hasSuffix(".mkv") { try manager.moveItem( - at: URL(fileURLWithPath: self.sup), - to: URL(fileURLWithPath: inFile).deletingPathExtension().appendingPathExtension("sup") - ) + at: URL(fileURLWithPath: sup), + to: URL(fileURLWithPath: inFile).deletingPathExtension().appendingPathExtension("sup")) } - // Encode subtitles to SRT file - try SRT().encode(subtitles: outSubStream, - toFileAt: URL(fileURLWithPath: self.srt)) + try srtStream.write(toFileAt: URL(fileURLWithPath: srt)) } // MARK: - Methods private func setOCRMode() -> VNRequestTextRecognitionLevel { - if self.fastMode { - return VNRequestTextRecognitionLevel.fast + if fastMode { + VNRequestTextRecognitionLevel.fast } else { - return VNRequestTextRecognitionLevel.accurate + VNRequestTextRecognitionLevel.accurate } } private func setOCRRevision() -> Int { if #available(macOS 13, *) { - return VNRecognizeTextRequestRevision3 + VNRecognizeTextRequestRevision3 } else { - return VNRecognizeTextRequestRevision2 + VNRecognizeTextRequestRevision2 + } + } + + private func saveImageAsPNG(image: CGImage, outputPath: URL) throws { + guard let destination = CGImageDestinationCreateWithURL(outputPath as CFURL, UTType.png.identifier as CFString, 1, nil) + else { + throw macSubtitleOCRError.fileCreationError + } + CGImageDestinationAddImage(destination, image, nil) + + if !CGImageDestinationFinalize(destination) { + throw macSubtitleOCRError.fileWriteError } } } diff --git a/macSup2Srt/macSup2SrtError.swift b/Sources/macSubtitleOCR/macSubtitleOCRError.swift similarity index 52% rename from macSup2Srt/macSup2SrtError.swift rename to Sources/macSubtitleOCR/macSubtitleOCRError.swift index 0d520e9..af27ee8 100644 --- a/macSup2Srt/macSup2SrtError.swift +++ b/Sources/macSubtitleOCR/macSubtitleOCRError.swift @@ -1,14 +1,14 @@ // -// macSup2SrtError.swift -// macSup2Srt +// macSubtitleOCRError.swift +// macSubtitleOCR // // Created by Ethan Dye on 9/16/24. // Copyright © 2024 Ethan Dye. All rights reserved. // -public enum macSup2SrtError: Error { +enum macSubtitleOCRError: Error { case invalidFormat case fileReadError - case unsupportedFormat - case invalidFile + case fileCreationError + case fileWriteError } diff --git a/tests/expectedOutput/test.json b/Tests/macSubtitleOCRTests/Resources/expectedOutput/test.json similarity index 100% rename from tests/expectedOutput/test.json rename to Tests/macSubtitleOCRTests/Resources/expectedOutput/test.json diff --git a/tests/expectedOutput/test.srt b/Tests/macSubtitleOCRTests/Resources/expectedOutput/test.srt similarity index 100% rename from tests/expectedOutput/test.srt rename to Tests/macSubtitleOCRTests/Resources/expectedOutput/test.srt diff --git a/tests/testFiles/test.mkv b/Tests/macSubtitleOCRTests/Resources/testFiles/test.mkv similarity index 100% rename from tests/testFiles/test.mkv rename to Tests/macSubtitleOCRTests/Resources/testFiles/test.mkv diff --git a/tests/testFiles/test.sup b/Tests/macSubtitleOCRTests/Resources/testFiles/test.sup similarity index 100% rename from tests/testFiles/test.sup rename to Tests/macSubtitleOCRTests/Resources/testFiles/test.sup diff --git a/Tests/macSubtitleOCRTests/macSubtitleOCRTests.swift b/Tests/macSubtitleOCRTests/macSubtitleOCRTests.swift new file mode 100644 index 0000000..75c5f3c --- /dev/null +++ b/Tests/macSubtitleOCRTests/macSubtitleOCRTests.swift @@ -0,0 +1,113 @@ +// +// macSubtitleOCRTests.swift +// macSubtitleOCR +// +// Created by Ethan Dye on 9/18/24. +// Copyright © 2024 Ethan Dye. All rights reserved. +// + +import Foundation +@testable import macSubtitleOCR +import Testing + +// MARK: - Tests + +@Test func pgsMKV() throws { + // Setup files + let manager = FileManager.default + let srtPath = (manager.temporaryDirectory.path + "/test.srt") + let jsonPath = (manager.temporaryDirectory.path + "/test.json") + let mkvPath = Bundle.module.url(forResource: "test.mkv", withExtension: nil)!.absoluteString.replacing("file://", with: "") + let goodSRTPath = Bundle.module.url(forResource: "test.srt", withExtension: nil)!.absoluteString.replacing("file://", with: "") + let goodJSONPath = Bundle.module.url(forResource: "test.json", withExtension: nil)!.absoluteString.replacing("file://", with: "") + + // Run tests + let options = [mkvPath, srtPath, "--json", jsonPath, "--language-correction"] + var runner = try macSubtitleOCR.parseAsRoot(options) + try runner.run() + + // Compare output + let srtExpectedOutput = try String(contentsOfFile: goodSRTPath, encoding: .utf8) + let srtActualOutput = try String(contentsOfFile: srtPath, encoding: .utf8) + let jsonExpectedOutput = try String(contentsOfFile: goodJSONPath, encoding: .utf8) + let jsonActualOutput = try String(contentsOfFile: jsonPath, encoding: .utf8) + + let srtMatch = similarityPercentage(srtExpectedOutput, srtActualOutput) + let jsonMatch = similarityPercentage(jsonExpectedOutput, jsonActualOutput) + + #expect(srtMatch >= 90.0) + #expect(jsonMatch >= 90.0) +} + +@Test func pgsSUP() throws { + // Setup files + let manager = FileManager.default + let srtPath = (manager.temporaryDirectory.path + "/test.srt") + let jsonPath = (manager.temporaryDirectory.path + "/test.json") + let supPath = Bundle.module.url(forResource: "test.sup", withExtension: nil)!.absoluteString.replacing("file://", with: "") + let goodSRTPath = Bundle.module.url(forResource: "test.srt", withExtension: nil)!.absoluteString.replacing("file://", with: "") + let goodJSONPath = Bundle.module.url(forResource: "test.json", withExtension: nil)!.absoluteString.replacing("file://", with: "") + + // Run tests + let options = [supPath, srtPath, "--json", jsonPath, "--language-correction"] + var runner = try macSubtitleOCR.parseAsRoot(options) + try runner.run() + + // Compare output + let srtExpectedOutput = try String(contentsOfFile: goodSRTPath, encoding: .utf8) + let srtActualOutput = try String(contentsOfFile: srtPath, encoding: .utf8) + let jsonExpectedOutput = try String(contentsOfFile: goodJSONPath, encoding: .utf8) + let jsonActualOutput = try String(contentsOfFile: jsonPath, encoding: .utf8) + + let srtMatch = similarityPercentage(srtExpectedOutput, srtActualOutput) + let jsonMatch = similarityPercentage(jsonExpectedOutput, jsonActualOutput) + + #expect(srtMatch >= 90.0) + #expect(jsonMatch >= 90.0) +} + +// MARK: - Helpers + +// Function to compute the Levenshtein Distance between two strings +func levenshteinDistance(_ lhs: String, _ rhs: String) -> Int { + let lhsChars = Array(lhs) + let rhsChars = Array(rhs) + let lhsLength = lhsChars.count + let rhsLength = rhsChars.count + + var distanceMatrix = [[Int]](repeating: [Int](repeating: 0, count: rhsLength + 1), count: lhsLength + 1) + + // Initialize the matrix + for i in 0 ... lhsLength { + distanceMatrix[i][0] = i + } + for j in 0 ... rhsLength { + distanceMatrix[0][j] = j + } + + // Compute the distance + for i in 1 ... lhsLength { + for j in 1 ... rhsLength { + let cost = lhsChars[i - 1] == rhsChars[j - 1] ? 0 : 1 + distanceMatrix[i][j] = min( + distanceMatrix[i - 1][j] + 1, // Deletion + distanceMatrix[i][j - 1] + 1, // Insertion + distanceMatrix[i - 1][j - 1] + cost // Substitution + ) + } + } + + return distanceMatrix[lhsLength][rhsLength] +} + +// Function to calculate the similarity percentage +func similarityPercentage(_ lhs: String, _ rhs: String) -> Double { + let distance = levenshteinDistance(lhs, rhs) + let maxLength = max(lhs.count, rhs.count) + + if maxLength == 0 { + return 100.0 // Both strings are empty, they match 100% + } + + return (1.0 - Double(distance) / Double(maxLength)) * 100 +} diff --git a/macSup2Srt.xcodeproj/project.pbxproj b/macSup2Srt.xcodeproj/project.pbxproj deleted file mode 100644 index 7e597e5..0000000 --- a/macSup2Srt.xcodeproj/project.pbxproj +++ /dev/null @@ -1,253 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXBuildFile section */ - 8A7F7F7E2C859E2F00ADF827 /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = 8A7F7F7D2C859E2F00ADF827 /* ArgumentParser */; settings = {ATTRIBUTES = (Required, ); }; }; -/* End PBXBuildFile section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 8A7F7F682C859D6800ADF827 /* CopyFiles */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = /usr/share/man/man1/; - dstSubfolderSpec = 0; - files = ( - ); - runOnlyForDeploymentPostprocessing = 1; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 8A7F7F6A2C859D6800ADF827 /* macSup2Srt */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = macSup2Srt; sourceTree = BUILT_PRODUCTS_DIR; }; - 8AE9B7352C9402BE00AB2BB5 /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = ""; }; - 8AE9B7362C9402BE00AB2BB5 /* .swiftformat */ = {isa = PBXFileReference; lastKnownFileType = text; path = .swiftformat; sourceTree = ""; }; - 8AE9B7372C9402BE00AB2BB5 /* LICENSE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = LICENSE.md; sourceTree = ""; }; - 8AE9B7382C9402BE00AB2BB5 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; - 8AE9B7392C9408C200AB2BB5 /* CONTRIBUTING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFileSystemSynchronizedRootGroup section */ - 8ABD3DC12C994974004A1509 /* .github */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = .github; - sourceTree = ""; - }; - 8AEDFD062C9483D700BCEAB8 /* macSup2Srt */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = macSup2Srt; - sourceTree = ""; - }; - 8AEDFD0F2C94843F00BCEAB8 /* tests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = tests; - sourceTree = ""; - }; -/* End PBXFileSystemSynchronizedRootGroup section */ - -/* Begin PBXFrameworksBuildPhase section */ - 8A7F7F672C859D6800ADF827 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 8A7F7F7E2C859E2F00ADF827 /* ArgumentParser in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 8A7F7F612C859D6800ADF827 = { - isa = PBXGroup; - children = ( - 8ABD3DC12C994974004A1509 /* .github */, - 8AEDFD062C9483D700BCEAB8 /* macSup2Srt */, - 8AEDFD0F2C94843F00BCEAB8 /* tests */, - 8AE9B7392C9408C200AB2BB5 /* CONTRIBUTING.md */, - 8AE9B7352C9402BE00AB2BB5 /* .gitignore */, - 8AE9B7362C9402BE00AB2BB5 /* .swiftformat */, - 8AE9B7372C9402BE00AB2BB5 /* LICENSE.md */, - 8AE9B7382C9402BE00AB2BB5 /* README.md */, - 8A7F7F6B2C859D6800ADF827 /* Products */, - ); - sourceTree = ""; - }; - 8A7F7F6B2C859D6800ADF827 /* Products */ = { - isa = PBXGroup; - children = ( - 8A7F7F6A2C859D6800ADF827 /* macSup2Srt */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 8A7F7F692C859D6800ADF827 /* macSup2Srt */ = { - isa = PBXNativeTarget; - buildConfigurationList = 8A7F7F712C859D6800ADF827 /* Build configuration list for PBXNativeTarget "macSup2Srt" */; - buildPhases = ( - 8A7F7F662C859D6800ADF827 /* Sources */, - 8A7F7F672C859D6800ADF827 /* Frameworks */, - 8A7F7F682C859D6800ADF827 /* CopyFiles */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 8ABD3DC12C994974004A1509 /* .github */, - 8AEDFD062C9483D700BCEAB8 /* macSup2Srt */, - 8AEDFD0F2C94843F00BCEAB8 /* tests */, - ); - name = macSup2Srt; - packageProductDependencies = ( - 8A7F7F7D2C859E2F00ADF827 /* ArgumentParser */, - ); - productName = macSup2Srt; - productReference = 8A7F7F6A2C859D6800ADF827 /* macSup2Srt */; - productType = "com.apple.product-type.tool"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 8A7F7F622C859D6800ADF827 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1600; - LastUpgradeCheck = 1600; - TargetAttributes = { - 8A7F7F692C859D6800ADF827 = { - CreatedOnToolsVersion = 16.0; - }; - }; - }; - buildConfigurationList = 8A7F7F652C859D6800ADF827 /* Build configuration list for PBXProject "macSup2Srt" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 8A7F7F612C859D6800ADF827; - packageReferences = ( - 8A7F7F7C2C859E2F00ADF827 /* XCRemoteSwiftPackageReference "swift-argument-parser" */, - ); - preferredProjectObjectVersion = 77; - productRefGroup = 8A7F7F6B2C859D6800ADF827 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 8A7F7F692C859D6800ADF827 /* macSup2Srt */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXSourcesBuildPhase section */ - 8A7F7F662C859D6800ADF827 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 8A7F7F6F2C859D6800ADF827 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - MACOSX_DEPLOYMENT_TARGET = 15.0; - SDKROOT = macosx; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 8A7F7F702C859D6800ADF827 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - MACOSX_DEPLOYMENT_TARGET = 15.0; - SDKROOT = macosx; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 8A7F7F722C859D6800ADF827 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - ENABLE_TESTABILITY = YES; - MACOSX_DEPLOYMENT_TARGET = 15.0; - PRODUCT_BUNDLE_IDENTIFIER = github.ecdye.macSup2Srt; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 8A7F7F732C859D6800ADF827 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - ENABLE_TESTABILITY = NO; - MACOSX_DEPLOYMENT_TARGET = 15.0; - PRODUCT_BUNDLE_IDENTIFIER = github.ecdye.macSup2Srt; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 8A7F7F652C859D6800ADF827 /* Build configuration list for PBXProject "macSup2Srt" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 8A7F7F6F2C859D6800ADF827 /* Debug */, - 8A7F7F702C859D6800ADF827 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 8A7F7F712C859D6800ADF827 /* Build configuration list for PBXNativeTarget "macSup2Srt" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 8A7F7F722C859D6800ADF827 /* Debug */, - 8A7F7F732C859D6800ADF827 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCRemoteSwiftPackageReference section */ - 8A7F7F7C2C859E2F00ADF827 /* XCRemoteSwiftPackageReference "swift-argument-parser" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/apple/swift-argument-parser.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.5.0; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 8A7F7F7D2C859E2F00ADF827 /* ArgumentParser */ = { - isa = XCSwiftPackageProductDependency; - package = 8A7F7F7C2C859E2F00ADF827 /* XCRemoteSwiftPackageReference "swift-argument-parser" */; - productName = ArgumentParser; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 8A7F7F622C859D6800ADF827 /* Project object */; -} diff --git a/macSup2Srt.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/macSup2Srt.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/macSup2Srt.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/macSup2Srt.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macSup2Srt.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/macSup2Srt.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/macSup2Srt.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/macSup2Srt.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index 0c67376..0000000 --- a/macSup2Srt.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/macSup2Srt.xcodeproj/xcshareddata/xcschemes/macSup2Srt.xcscheme b/macSup2Srt.xcodeproj/xcshareddata/xcschemes/macSup2Srt.xcscheme deleted file mode 100644 index 5f93b11..0000000 --- a/macSup2Srt.xcodeproj/xcshareddata/xcschemes/macSup2Srt.xcscheme +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/macSup2Srt/PGS/PGSSubtitle.swift b/macSup2Srt/PGS/PGSSubtitle.swift deleted file mode 100644 index ab83746..0000000 --- a/macSup2Srt/PGS/PGSSubtitle.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// PGSSubtitle.swift -// macSup2Srt -// -// Created by Ethan Dye on 9/16/24. -// Copyright © 2024 Ethan Dye. All rights reserved. -// - -import Foundation - -public struct PGSSubtitle { - public var timestamp: TimeInterval = 0 - public var imageWidth: Int = 0 - public var imageHeight: Int = 0 - public var imageData: Data = .init() - public var imagePalette: [UInt8] = [] - public var endTimestamp: TimeInterval = 0 -} diff --git a/macSup2Srt/PGS/Parsers/ODS.swift b/macSup2Srt/PGS/Parsers/ODS.swift deleted file mode 100644 index 7c07f0c..0000000 --- a/macSup2Srt/PGS/Parsers/ODS.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// ODS.swift -// macSup2Srt -// -// Created by Ethan Dye on 9/12/24. -// Copyright © 2024 Ethan Dye. All rights reserved. -// - -import Foundation - -public class ODS { - // MARK: - Properties - - private var objectID: Int = 0 - private var version: Int = 0 - private var sequenceFlag: Int = 0 - private var objectDataLength: Int = 0 - private var objectWidth: Int = 0 - private var objectHeight: Int = 0 - private var imageData: Data = .init() - - // MARK: - Lifecycle - - init(_ data: Data) throws { - (self.objectWidth, self.objectHeight, self.imageData) = try self.parseODS(data) - self.objectID = 0 - self.version = 0 - self.sequenceFlag = 0 - } - - // MARK: - Getters - - public func getObjectID() -> Int { - return self.objectID - } - - public func getVersion() -> Int { - return self.version - } - - public func getSequenceFlag() -> Int { - return self.sequenceFlag - } - - public func getObjectDataLength() -> Int { - return self.objectDataLength - } - - public func getObjectWidth() -> Int { - return self.objectWidth - } - - public func getObjectHeight() -> Int { - return self.objectHeight - } - - public func getImageData() -> Data { - return self.imageData - } - - // MARK: - Parser - - // Parses the Object Definition Segment (ODS) to extract the subtitle image bitmap. - // ODS structure (simplified): - // 0x17: Segment Type; already checked by the caller - // 2 bytes: Object ID - // 1 byte: Version number - // 1 byte: Sequence flag (should be 0x80 for new object, 0x00 for continuation) - // 3 bytes: Object data length - // 2 bytes: Object width - // 2 bytes: Object height - // Rest: Image data (run-length encoded, RLE) - private func parseODS(_ data: Data) throws -> (width: Int, height: Int, imageData: Data) { - // let objectID = Int(data[0]) << 8 | Int(data[1]) - let objectDataLength = - Int(data[4]) << 16 | Int(data[5]) << 8 | Int(data[6]) - - // PGS includes the width and height as part of the image data length calculations - guard objectDataLength <= data.count - 7 else { - throw macSup2SrtError.invalidFormat - } - - let width = Int(data[7]) << 8 | Int(data[8]) - let height = Int(data[9]) << 8 | Int(data[10]) - var imageData = data.subdata(in: 11 ..< data.endIndex) - - imageData = try decodeRLE(data: imageData, width: width, height: height) - - return (width: width, height: height, imageData: imageData) - } -} diff --git a/macSup2Srt/PGS/Parsers/RLE.swift b/macSup2Srt/PGS/Parsers/RLE.swift deleted file mode 100644 index 3364543..0000000 --- a/macSup2Srt/PGS/Parsers/RLE.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// RLE.swift -// macSup2Srt -// -// Created by Ethan Dye on 9/2/24. -// Copyright © 2024 Ethan Dye. All rights reserved. -// - -import Foundation - -func decodeRLE(data: Data, width: Int, height: Int) throws -> Data { - let rleBitmapEnd = data.endIndex - var pixelCount = 0 - var lineCount = 0 - var buf = 0 - - var image = Data() - - while buf < rleBitmapEnd && lineCount < height { - var color: UInt8 = data[buf] - buf += 1 - var run = 1 - - if color == 0x00 { - let flags = data[buf] - buf += 1 - run = Int(flags & 0x3F) - if flags & 0x40 != 0 { - run = (run << 8) + Int(data[buf]) - buf += 1 - } - color = (flags & 0x80) != 0 ? data[buf] : 0 - if (flags & 0x80) != 0 { - buf += 1 - } - } - - // Ensure run is valid and doesn't exceed pixel buffer - if run > 0 && pixelCount + run <= width * height { - // Fill the pixel data with the decoded color - image.append(contentsOf: repeatElement(color, count: run)) - pixelCount += run - } else if run == 0 { - // New Line: Check if pixels align correctly - if pixelCount % width > 0 { - print("Error: Decoded \(pixelCount % width) pixels, but line should be \(width) pixels.") - throw RLEDecodeError.invalidData - } - lineCount += 1 - } - } - - // Check if we decoded enough pixels - if pixelCount < width * height { - print("Error: Insufficient RLE data for subtitle.") - throw RLEDecodeError.insufficientData - } - - return image -} - -enum RLEDecodeError: Error { - case invalidData - case insufficientData -} diff --git a/macSup2Srt/SRT/SRT.swift b/macSup2Srt/SRT/SRT.swift deleted file mode 100644 index e3d2ad5..0000000 --- a/macSup2Srt/SRT/SRT.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// SRT.swift -// macSup2Srt -// -// Created by Ethan Dye on 9/2/24. -// Copyright © 2024 Ethan Dye. All rights reserved. -// - -import Foundation - -public class SRT { - public init() {} - - // MARK: Functions - - // MARK: - Decoding - - // Decodes subtitles from a string containing the SRT content - public func decode(from content: String) throws -> [SrtSubtitle] { - var subtitles = [SrtSubtitle]() - - // Split the content by subtitle blocks - let blocks = content.components(separatedBy: "\n\n") - - for block in blocks { - let lines = block.components(separatedBy: .newlines).filter { !$0.isEmpty } - - guard lines.count >= 2 else { - continue - } - - // Parse index - guard let index = Int(lines[0]) else { - throw SRTError.invalidFormat - } - - // Parse times - let timeComponents = lines[1].components(separatedBy: " --> ") - guard timeComponents.count == 2, - let startTime = parseTime(timeComponents[0]), - let endTime = parseTime(timeComponents[1]) - else { - throw SRTError.invalidTimeFormat - } - - // Combine remaining lines as the subtitle text - var text = "" - if lines.count <= 3 { - text = lines[2...].joined(separator: "\n") - } - - // Create and append the subtitle - let subtitle = SrtSubtitle(index: index, startTime: startTime, endTime: endTime, text: text) - subtitles.append(subtitle) - } - - return subtitles - } - - // Decodes subtitles from an SRT file at the given URL - public func decode(fromFileAt url: URL) throws -> [SrtSubtitle] { - do { - // Read the file content into a string - let content = try String(contentsOf: url, encoding: .utf8) - // Decode the content into subtitles - return try self.decode(from: content) - } catch { - throw SRTError.fileReadError - } - } - - // MARK: - Re-Encoding - - // Re-encodes an array of `Subtitle` objects into SRT format and returns it as a string - public func encode(subtitles: [SrtSubtitle]) -> String { - var srtContent = "" - - for subtitle in subtitles { - let startTime = self.formatTime(subtitle.startTime) - let endTime = self.formatTime(subtitle.endTime) - - srtContent += "\(subtitle.index)\n" - srtContent += "\(startTime) --> \(endTime)\n" - srtContent += "\(subtitle.text)\n\n" - } - - return srtContent - } - - // Re-encodes an array of `Subtitle` objects into SRT format and writes it to a file at the given URL - public func encode(subtitles: [SrtSubtitle], toFileAt url: URL) throws { - let srtContent = self.encode(subtitles: subtitles) - - do { - try srtContent.write(to: url, atomically: true, encoding: .utf8) - } catch { - throw SRTError.fileWriteError - } - } - - // MARK: - Helper Methods - - private func parseTime(_ timeString: String) -> TimeInterval? { - let components = timeString.components(separatedBy: [":", ","]) - guard components.count == 4, - let hours = Double(components[0]), - let minutes = Double(components[1]), - let seconds = Double(components[2]), - let milliseconds = Double(components[3]) - else { - return nil - } - - return (hours * 3600) + (minutes * 60) + seconds + (milliseconds / 1000) - } - - private func formatTime(_ time: TimeInterval) -> String { - let hours = Int(time) / 3600 - let minutes = (Int(time) % 3600) / 60 - let seconds = Int(time) % 60 - let milliseconds = Int((time - TimeInterval(Int(time))) * 1000) - - return String(format: "%02d:%02d:%02d,%03d", hours, minutes, seconds, milliseconds) - } -} - -public enum SRTError: Error { - case invalidFormat - case invalidTimeFormat - case fileNotFound - case fileReadError - case fileWriteError -} diff --git a/macSup2Srt/SRT/SrtSubtitle.swift b/macSup2Srt/SRT/SrtSubtitle.swift deleted file mode 100644 index 3e9f061..0000000 --- a/macSup2Srt/SRT/SrtSubtitle.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// SrtSubtitle.swift -// macSup2Srt -// -// Created by Ethan Dye on 9/16/24. -// Copyright © 2024 Ethan Dye. All rights reserved. -// - -import Foundation - -public struct SrtSubtitle { - public var index: Int - public var startTime: TimeInterval - public var endTime: TimeInterval - public var text: String -}