capacitor-video-transcoder 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CapacitorVideoTranscoder.podspec +18 -0
- package/Package.swift +39 -0
- package/README.md +238 -2
- package/android/build.gradle +68 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/kotlin/io/github/maximbelov/videotranscoder/TranscodeOptions.kt +122 -0
- package/android/src/main/kotlin/io/github/maximbelov/videotranscoder/VideoTranscoder.kt +265 -0
- package/android/src/main/kotlin/io/github/maximbelov/videotranscoder/VideoTranscoderPlugin.kt +51 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/docs.json +221 -0
- package/dist/esm/definitions.d.ts +120 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +6 -0
- package/dist/esm/web.js +10 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +24 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +27 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Package.swift +22 -0
- package/ios/Sources/VideoTranscoderCore/Geometry.swift +61 -0
- package/ios/Sources/VideoTranscoderCore/TranscodeOptions.swift +107 -0
- package/ios/Sources/VideoTranscoderCore/Transcoder.swift +388 -0
- package/ios/Sources/VideoTranscoderPlugin/VideoTranscoderPlugin.swift +73 -0
- package/ios/Tests/VideoTranscoderCoreTests/OptionsAndGeometryTests.swift +146 -0
- package/ios/Tests/VideoTranscoderCoreTests/TranscoderTests.swift +234 -0
- package/ios/Tests/VideoTranscoderPluginTests/VideoTranscoderPluginTests.swift +32 -0
- package/package.json +101 -4
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { registerPlugin } from '@capacitor/core';
|
|
2
|
+
const VideoTranscoder = registerPlugin('VideoTranscoder', {
|
|
3
|
+
web: () => import('./web').then((m) => new m.VideoTranscoderWeb()),
|
|
4
|
+
});
|
|
5
|
+
export * from './definitions';
|
|
6
|
+
export { VideoTranscoder };
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAIjD,MAAM,eAAe,GAAG,cAAc,CAAwB,iBAAiB,EAAE;IAC/E,GAAG,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,kBAAkB,EAAE,CAAC;CACnE,CAAC,CAAC;AAEH,cAAc,eAAe,CAAC;AAC9B,OAAO,EAAE,eAAe,EAAE,CAAC","sourcesContent":["import { registerPlugin } from '@capacitor/core';\n\nimport type { VideoTranscoderPlugin } from './definitions';\n\nconst VideoTranscoder = registerPlugin<VideoTranscoderPlugin>('VideoTranscoder', {\n web: () => import('./web').then((m) => new m.VideoTranscoderWeb()),\n});\n\nexport * from './definitions';\nexport { VideoTranscoder };\n"]}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { WebPlugin } from '@capacitor/core';
|
|
2
|
+
import type { TranscodeOptions, TranscodeResult, VideoTranscoderPlugin } from './definitions';
|
|
3
|
+
export declare class VideoTranscoderWeb extends WebPlugin implements VideoTranscoderPlugin {
|
|
4
|
+
transcode(options: TranscodeOptions): Promise<TranscodeResult>;
|
|
5
|
+
cancel(): Promise<void>;
|
|
6
|
+
}
|
package/dist/esm/web.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { WebPlugin } from '@capacitor/core';
|
|
2
|
+
export class VideoTranscoderWeb extends WebPlugin {
|
|
3
|
+
async transcode(options) {
|
|
4
|
+
throw this.unimplemented(`Cannot transcode ${options.source} on the web: it needs the platform's video encoder.`);
|
|
5
|
+
}
|
|
6
|
+
async cancel() {
|
|
7
|
+
throw this.unimplemented('cancel is not available on the web.');
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=web.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"web.js","sourceRoot":"","sources":["../../src/web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAI5C,MAAM,OAAO,kBAAmB,SAAQ,SAAS;IAC/C,KAAK,CAAC,SAAS,CAAC,OAAyB;QACvC,MAAM,IAAI,CAAC,aAAa,CAAC,oBAAoB,OAAO,CAAC,MAAM,qDAAqD,CAAC,CAAC;IACpH,CAAC;IAED,KAAK,CAAC,MAAM;QACV,MAAM,IAAI,CAAC,aAAa,CAAC,qCAAqC,CAAC,CAAC;IAClE,CAAC;CACF","sourcesContent":["import { WebPlugin } from '@capacitor/core';\n\nimport type { TranscodeOptions, TranscodeResult, VideoTranscoderPlugin } from './definitions';\n\nexport class VideoTranscoderWeb extends WebPlugin implements VideoTranscoderPlugin {\n async transcode(options: TranscodeOptions): Promise<TranscodeResult> {\n throw this.unimplemented(`Cannot transcode ${options.source} on the web: it needs the platform's video encoder.`);\n }\n\n async cancel(): Promise<void> {\n throw this.unimplemented('cancel is not available on the web.');\n }\n}\n"]}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var core = require('@capacitor/core');
|
|
4
|
+
|
|
5
|
+
const VideoTranscoder = core.registerPlugin('VideoTranscoder', {
|
|
6
|
+
web: () => Promise.resolve().then(function () { return web; }).then((m) => new m.VideoTranscoderWeb()),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
class VideoTranscoderWeb extends core.WebPlugin {
|
|
10
|
+
async transcode(options) {
|
|
11
|
+
throw this.unimplemented(`Cannot transcode ${options.source} on the web: it needs the platform's video encoder.`);
|
|
12
|
+
}
|
|
13
|
+
async cancel() {
|
|
14
|
+
throw this.unimplemented('cancel is not available on the web.');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
var web = /*#__PURE__*/Object.freeze({
|
|
19
|
+
__proto__: null,
|
|
20
|
+
VideoTranscoderWeb: VideoTranscoderWeb
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
exports.VideoTranscoder = VideoTranscoder;
|
|
24
|
+
//# sourceMappingURL=plugin.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin.cjs.js","sources":["esm/index.js","esm/web.js"],"sourcesContent":["import { registerPlugin } from '@capacitor/core';\nconst VideoTranscoder = registerPlugin('VideoTranscoder', {\n web: () => import('./web').then((m) => new m.VideoTranscoderWeb()),\n});\nexport * from './definitions';\nexport { VideoTranscoder };\n//# sourceMappingURL=index.js.map","import { WebPlugin } from '@capacitor/core';\nexport class VideoTranscoderWeb extends WebPlugin {\n async transcode(options) {\n throw this.unimplemented(`Cannot transcode ${options.source} on the web: it needs the platform's video encoder.`);\n }\n async cancel() {\n throw this.unimplemented('cancel is not available on the web.');\n }\n}\n//# sourceMappingURL=web.js.map"],"names":["registerPlugin","WebPlugin"],"mappings":";;;;AACK,MAAC,eAAe,GAAGA,mBAAc,CAAC,iBAAiB,EAAE;AAC1D,IAAI,GAAG,EAAE,MAAM,mDAAe,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,kBAAkB,EAAE,CAAC;AACtE,CAAC;;ACFM,MAAM,kBAAkB,SAASC,cAAS,CAAC;AAClD,IAAI,MAAM,SAAS,CAAC,OAAO,EAAE;AAC7B,QAAQ,MAAM,IAAI,CAAC,aAAa,CAAC,CAAC,iBAAiB,EAAE,OAAO,CAAC,MAAM,CAAC,mDAAmD,CAAC,CAAC;AACzH,IAAI;AACJ,IAAI,MAAM,MAAM,GAAG;AACnB,QAAQ,MAAM,IAAI,CAAC,aAAa,CAAC,qCAAqC,CAAC;AACvE,IAAI;AACJ;;;;;;;"}
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
var capacitorVideoTranscoder = (function (exports, core) {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const VideoTranscoder = core.registerPlugin('VideoTranscoder', {
|
|
5
|
+
web: () => Promise.resolve().then(function () { return web; }).then((m) => new m.VideoTranscoderWeb()),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
class VideoTranscoderWeb extends core.WebPlugin {
|
|
9
|
+
async transcode(options) {
|
|
10
|
+
throw this.unimplemented(`Cannot transcode ${options.source} on the web: it needs the platform's video encoder.`);
|
|
11
|
+
}
|
|
12
|
+
async cancel() {
|
|
13
|
+
throw this.unimplemented('cancel is not available on the web.');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
var web = /*#__PURE__*/Object.freeze({
|
|
18
|
+
__proto__: null,
|
|
19
|
+
VideoTranscoderWeb: VideoTranscoderWeb
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
exports.VideoTranscoder = VideoTranscoder;
|
|
23
|
+
|
|
24
|
+
return exports;
|
|
25
|
+
|
|
26
|
+
})({}, capacitorExports);
|
|
27
|
+
//# sourceMappingURL=plugin.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin.js","sources":["esm/index.js","esm/web.js"],"sourcesContent":["import { registerPlugin } from '@capacitor/core';\nconst VideoTranscoder = registerPlugin('VideoTranscoder', {\n web: () => import('./web').then((m) => new m.VideoTranscoderWeb()),\n});\nexport * from './definitions';\nexport { VideoTranscoder };\n//# sourceMappingURL=index.js.map","import { WebPlugin } from '@capacitor/core';\nexport class VideoTranscoderWeb extends WebPlugin {\n async transcode(options) {\n throw this.unimplemented(`Cannot transcode ${options.source} on the web: it needs the platform's video encoder.`);\n }\n async cancel() {\n throw this.unimplemented('cancel is not available on the web.');\n }\n}\n//# sourceMappingURL=web.js.map"],"names":["registerPlugin","WebPlugin"],"mappings":";;;AACK,UAAC,eAAe,GAAGA,mBAAc,CAAC,iBAAiB,EAAE;IAC1D,IAAI,GAAG,EAAE,MAAM,mDAAe,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,kBAAkB,EAAE,CAAC;IACtE,CAAC;;ICFM,MAAM,kBAAkB,SAASC,cAAS,CAAC;IAClD,IAAI,MAAM,SAAS,CAAC,OAAO,EAAE;IAC7B,QAAQ,MAAM,IAAI,CAAC,aAAa,CAAC,CAAC,iBAAiB,EAAE,OAAO,CAAC,MAAM,CAAC,mDAAmD,CAAC,CAAC;IACzH,IAAI;IACJ,IAAI,MAAM,MAAM,GAAG;IACnB,QAAQ,MAAM,IAAI,CAAC,aAAa,CAAC,qCAAqC,CAAC;IACvE,IAAI;IACJ;;;;;;;;;;;"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// swift-tools-version: 5.9
|
|
2
|
+
import PackageDescription
|
|
3
|
+
|
|
4
|
+
// The transcoder without the Capacitor bridge, so that `swift test --package-path ios` runs its tests on a Mac: no simulator, no
|
|
5
|
+
// phone. AVAssetReader and AVAssetWriter are the same on macOS, so the end-to-end tests transcode real files here. The plugin
|
|
6
|
+
// itself is the package at the root of the repository, which builds these same sources.
|
|
7
|
+
let package = Package(
|
|
8
|
+
name: "VideoTranscoderCore",
|
|
9
|
+
platforms: [.iOS(.v15), .macOS(.v13)],
|
|
10
|
+
products: [
|
|
11
|
+
.library(name: "VideoTranscoderCore", targets: ["VideoTranscoderCore"])
|
|
12
|
+
],
|
|
13
|
+
targets: [
|
|
14
|
+
.target(
|
|
15
|
+
name: "VideoTranscoderCore",
|
|
16
|
+
path: "Sources/VideoTranscoderCore"),
|
|
17
|
+
.testTarget(
|
|
18
|
+
name: "VideoTranscoderCoreTests",
|
|
19
|
+
dependencies: ["VideoTranscoderCore"],
|
|
20
|
+
path: "Tests/VideoTranscoderCoreTests")
|
|
21
|
+
]
|
|
22
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
public struct VideoSize: Equatable {
|
|
5
|
+
public let width: Int
|
|
6
|
+
public let height: Int
|
|
7
|
+
|
|
8
|
+
public init(width: Int, height: Int) {
|
|
9
|
+
self.width = width
|
|
10
|
+
self.height = height
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/// The arithmetic of the output: its size, its rotation, its audio format. Pure, so it is tested without a video.
|
|
15
|
+
public enum Geometry {
|
|
16
|
+
/// The size that fits a video into `maxSize` on its longer side, keeping the aspect ratio and never scaling up. Both sides are
|
|
17
|
+
/// rounded down to an even number (H.264 4:2:0 halves them for the chroma planes), and are at least 2. Integer arithmetic, so
|
|
18
|
+
/// 1920x1080 into 1280 is exactly 1280x720 and not 1280x718 from a 719.999 rounded down.
|
|
19
|
+
public static func outputSize(width: Int, height: Int, maxSize: Int) -> VideoSize {
|
|
20
|
+
let w = Int64(max(abs(width), 1))
|
|
21
|
+
let h = Int64(max(abs(height), 1))
|
|
22
|
+
let longer = max(w, h)
|
|
23
|
+
let limit = Int64(max(maxSize, 2))
|
|
24
|
+
func fit(_ side: Int64) -> Int {
|
|
25
|
+
let scaled = longer > limit ? side * limit / longer : side
|
|
26
|
+
return Int(max(2, scaled / 2 * 2))
|
|
27
|
+
}
|
|
28
|
+
return VideoSize(width: fit(w), height: fit(h))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// The clockwise rotation a track transform applies, in degrees: 0, 90, 180 or 270.
|
|
32
|
+
public static func rotationDegrees(of transform: CGAffineTransform) -> Int {
|
|
33
|
+
let degrees = Int((atan2(Double(transform.b), Double(transform.a)) * 180 / .pi).rounded())
|
|
34
|
+
return ((degrees % 360) + 360) % 360 / 90 * 90
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// A stored size as a player shows it, with the track's rotation applied.
|
|
38
|
+
public static func displayedSize(_ size: VideoSize, rotationDegrees: Int) -> VideoSize {
|
|
39
|
+
rotationDegrees % 180 == 0 ? size : VideoSize(width: size.height, height: size.width)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// The sample rates the AAC encoder takes.
|
|
43
|
+
static let aacSampleRates: [Double] = [8_000, 11_025, 12_000, 16_000, 22_050, 24_000, 32_000, 44_100, 48_000]
|
|
44
|
+
|
|
45
|
+
/// The AAC format an audio track that is not AAC is encoded to: mono stays mono, stereo stays stereo, more channels are mixed
|
|
46
|
+
/// down to stereo; the sample rate is the source's when AAC has it, else the next one up, at most 48 kHz. The bitrate is left
|
|
47
|
+
/// to the encoder, which picks one that suits the sample rate: a fixed 128 kbit/s is refused at 16 kHz ("Cannot Encode Media").
|
|
48
|
+
public static func aacFormat(sourceChannels: Int, sourceSampleRate: Double) -> (channels: Int, sampleRate: Double) {
|
|
49
|
+
let channels = sourceChannels <= 1 ? 1 : 2
|
|
50
|
+
let sampleRate = aacSampleRates.first { $0 >= sourceSampleRate.rounded() } ?? 48_000
|
|
51
|
+
return (channels, sampleRate)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Progress from 0 to 1 for a position in a clip, held below 1 until the file is finished.
|
|
55
|
+
public static func progress(seconds: Double, of duration: Double) -> Double {
|
|
56
|
+
guard duration > 0, seconds.isFinite else {
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
59
|
+
return min(max(seconds / duration, 0), 0.99)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import CoreFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// The `code` a call is rejected with. The raw values are the ones `src/definitions.ts` documents.
|
|
5
|
+
public enum TranscodeErrorCode: String {
|
|
6
|
+
case invalidArgument = "INVALID_ARGUMENT"
|
|
7
|
+
case notFound = "NOT_FOUND"
|
|
8
|
+
case noVideo = "NO_VIDEO"
|
|
9
|
+
case busy = "BUSY"
|
|
10
|
+
case cancelled = "CANCELLED"
|
|
11
|
+
case failed = "FAILED"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public struct TranscodeError: Error, Equatable, CustomStringConvertible {
|
|
15
|
+
public let code: TranscodeErrorCode
|
|
16
|
+
public let message: String
|
|
17
|
+
|
|
18
|
+
public init(_ code: TranscodeErrorCode, _ message: String) {
|
|
19
|
+
self.code = code
|
|
20
|
+
self.message = message
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public var description: String { "\(code.rawValue): \(message)" }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// What a `transcode` call asked for, checked and with the defaults filled in.
|
|
27
|
+
public struct TranscodeOptions: Equatable {
|
|
28
|
+
public static let defaultMaxSize = 1280
|
|
29
|
+
public static let defaultMaxDurationMs = 30_000
|
|
30
|
+
public static let defaultVideoBitrate = 1_800_000
|
|
31
|
+
public static let minimumMaxSize = 16
|
|
32
|
+
|
|
33
|
+
public let source: URL
|
|
34
|
+
/// `nil` when the call named none: a new file in the temporary directory is used.
|
|
35
|
+
public let destination: URL?
|
|
36
|
+
public let maxSize: Int
|
|
37
|
+
public let maxDurationMs: Int
|
|
38
|
+
public let videoBitrate: Int
|
|
39
|
+
|
|
40
|
+
public init(
|
|
41
|
+
source: URL,
|
|
42
|
+
destination: URL? = nil,
|
|
43
|
+
maxSize: Int = TranscodeOptions.defaultMaxSize,
|
|
44
|
+
maxDurationMs: Int = TranscodeOptions.defaultMaxDurationMs,
|
|
45
|
+
videoBitrate: Int = TranscodeOptions.defaultVideoBitrate
|
|
46
|
+
) {
|
|
47
|
+
self.source = source
|
|
48
|
+
self.destination = destination
|
|
49
|
+
self.maxSize = maxSize
|
|
50
|
+
self.maxDurationMs = maxDurationMs
|
|
51
|
+
self.videoBitrate = videoBitrate
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// The options of a call as the bridge hands them over. Anything wrong is an `INVALID_ARGUMENT` that says what.
|
|
55
|
+
public static func parse(_ raw: [String: Any]) throws -> TranscodeOptions {
|
|
56
|
+
guard let source = raw["source"] as? String, !source.isEmpty else {
|
|
57
|
+
throw TranscodeError(.invalidArgument, "source is required: an absolute path or a file:// URL")
|
|
58
|
+
}
|
|
59
|
+
var destination: URL?
|
|
60
|
+
if let value = raw["destination"], !(value is NSNull) {
|
|
61
|
+
guard let text = value as? String else {
|
|
62
|
+
throw TranscodeError(.invalidArgument, "destination must be a string")
|
|
63
|
+
}
|
|
64
|
+
destination = try fileURL(text, name: "destination")
|
|
65
|
+
}
|
|
66
|
+
return TranscodeOptions(
|
|
67
|
+
source: try fileURL(source, name: "source"),
|
|
68
|
+
destination: destination,
|
|
69
|
+
maxSize: try wholeNumber(raw["maxSize"], name: "maxSize", minimum: minimumMaxSize, fallback: defaultMaxSize),
|
|
70
|
+
maxDurationMs: try wholeNumber(raw["maxDurationMs"], name: "maxDurationMs", minimum: 1, fallback: defaultMaxDurationMs),
|
|
71
|
+
videoBitrate: try wholeNumber(raw["videoBitrate"], name: "videoBitrate", minimum: 1, fallback: defaultVideoBitrate)
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// An absolute path or a `file://` URL as a file URL. Anything else (a relative path, `content://`, `http://`) is refused.
|
|
76
|
+
public static func fileURL(_ value: String, name: String) throws -> URL {
|
|
77
|
+
var path = value
|
|
78
|
+
if value.hasPrefix("file://") {
|
|
79
|
+
path = String(value.dropFirst("file://".count))
|
|
80
|
+
if path.hasPrefix("localhost/") {
|
|
81
|
+
path = String(path.dropFirst("localhost".count))
|
|
82
|
+
}
|
|
83
|
+
path = path.removingPercentEncoding ?? path
|
|
84
|
+
}
|
|
85
|
+
guard path.hasPrefix("/"), path.count > 1 else {
|
|
86
|
+
throw TranscodeError(.invalidArgument, "\(name) must be an absolute path or a file:// URL, not \(value)")
|
|
87
|
+
}
|
|
88
|
+
return URL(fileURLWithPath: path).standardizedFileURL
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
static func wholeNumber(_ value: Any?, name: String, minimum: Int, fallback: Int) throws -> Int {
|
|
92
|
+
guard let value, !(value is NSNull) else {
|
|
93
|
+
return fallback
|
|
94
|
+
}
|
|
95
|
+
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else {
|
|
96
|
+
throw TranscodeError(.invalidArgument, "\(name) must be a number")
|
|
97
|
+
}
|
|
98
|
+
let double = number.doubleValue
|
|
99
|
+
guard double.isFinite, double.rounded() == double, double <= Double(Int32.max) else {
|
|
100
|
+
throw TranscodeError(.invalidArgument, "\(name) must be a whole number, not \(number)")
|
|
101
|
+
}
|
|
102
|
+
guard Int(double) >= minimum else {
|
|
103
|
+
throw TranscodeError(.invalidArgument, "\(name) must be at least \(minimum), not \(number)")
|
|
104
|
+
}
|
|
105
|
+
return Int(double)
|
|
106
|
+
}
|
|
107
|
+
}
|