davinci-resolve-mcp 2.115.0 → 2.116.0
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/docs/guides/native-drt-authoring.md +110 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/server/author-interchange.mjs +34 -1
- package/resolve-advanced/server/tools/drt.mjs +1 -1
- package/resolve-advanced/vendor/drp-format/__tests__/place-transition.test.js +3 -1
- package/resolve-advanced/vendor/drp-format/assemble-timeline.js +1 -0
- package/resolve-advanced/vendor/drp-format/place-transition.js +8 -3
- package/resolve-advanced/vendor/drp-format/templates/transition-cross-fade-r19.xml +22 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Native DRT Authoring — offline timelines that import AND render
|
|
2
|
+
|
|
3
|
+
The `drt` tool in the advanced server (`drt.assemble`,
|
|
4
|
+
`drt.assemble_from_interchange`) writes a **native-schema `.drt`** — a real
|
|
5
|
+
Resolve project archive that `ImportTimelineFromFile` accepts — entirely
|
|
6
|
+
offline. Unlike XML/AAF/OTIO interchange (which lands offline and needs a
|
|
7
|
+
relink), a correctly assembled `.drt` arrives **linked and rendering**,
|
|
8
|
+
because it carries native media-pool descriptors captured from a live Resolve.
|
|
9
|
+
|
|
10
|
+
Everything below was measured live on Studio 19.1.3.7 and render-verified
|
|
11
|
+
(frame luma via ffmpeg signalstats; audio via rendered RMS). Version numbers
|
|
12
|
+
mark the release where each capability shipped.
|
|
13
|
+
|
|
14
|
+
## The core doctrine: readback is blind, render is truth
|
|
15
|
+
|
|
16
|
+
The recurring bug class of this entire subsystem: a structure that imports
|
|
17
|
+
fine, **reads back fine through every API**, and renders black or silent.
|
|
18
|
+
Structural readback cannot detect any of these:
|
|
19
|
+
|
|
20
|
+
- media clips without native pool descriptors (black)
|
|
21
|
+
- Fusion title comps whose bytes were edited offline (black — see below)
|
|
22
|
+
- audio clips cloned from a donor with the donor's identity (silent)
|
|
23
|
+
- audio tracks added by cloning (silent — no Fairlight strip)
|
|
24
|
+
- a timemap in the wrong generation's encoding (plays at 100%, no warning)
|
|
25
|
+
|
|
26
|
+
Always verify with a render: video by frame luma, audio by RMS in the target
|
|
27
|
+
window. `render.verify_output` covers the container-level checks.
|
|
28
|
+
|
|
29
|
+
## Prerequisites
|
|
30
|
+
|
|
31
|
+
- **Per media file, once**: `media_pool.capture_media_template` (Python
|
|
32
|
+
server, live Resolve required once). It caches the file's native pool
|
|
33
|
+
`<Element>` + MediaRef under `~/.config/davinci-resolve-mcp/media-templates/`.
|
|
34
|
+
After that, all assembly for that file is fully offline.
|
|
35
|
+
- Pass `targetAppVersion` (e.g. `"19.1.3"`) when the importing host is
|
|
36
|
+
pre-21: it selects the r19-generation templates and version stamps.
|
|
37
|
+
|
|
38
|
+
## What you can author (all render-verified on 19.1.3)
|
|
39
|
+
|
|
40
|
+
| Capability | Spec surface | Since |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| Media cuts, multi-source | `media: [{mediaFilePath, spec, cuts}]` | v2.106–2.107 |
|
|
43
|
+
| Multi-track video (V2+ stacking) | `cuts[].track` (video-only above V1) | v2.112 |
|
|
44
|
+
| Cross-dissolves | `transitions: [{track, atFrame, durationFrames}]` | v2.111 |
|
|
45
|
+
| Audio cross-fades | `transitions[].trackType: 'audio'` | v2.116 |
|
|
46
|
+
| Constant retimes, forward | `cuts[].speed` (e.g. `0.5`) | v2.113 |
|
|
47
|
+
| Constant retimes, reverse | `cuts[].reverse` | v2.114 |
|
|
48
|
+
| Audio placements, A1–A8 | `cuts[].audioOnly + track` | v2.115 |
|
|
49
|
+
| Built-in generators | `elements: [{type:'generator', generatorName}]` | v2.110 |
|
|
50
|
+
| Fusion titles | `elements: [{type:'title', text}]` — **21-gen hosts only** | v2.108 |
|
|
51
|
+
|
|
52
|
+
`assemble_from_interchange` drives the same engine from an EDL / OTIO /
|
|
53
|
+
FCP7-XML / AAF plus a `sourceMap`, and returns an honesty ledger
|
|
54
|
+
(`authoredTransitions`, `droppedTransitions` with reasons, `authoredRetimes`,
|
|
55
|
+
`flattenedRetimes`, `authoredAudioEvents`, `upperTrackCutsVideoOnly`).
|
|
56
|
+
|
|
57
|
+
## The laws (why the constraints are what they are)
|
|
58
|
+
|
|
59
|
+
**Fusion comp cache law (titles).** On 19.x, an imported Fusion comp renders
|
|
60
|
+
only via the machine's disk cache, keyed to the comp blob's *exact
|
|
61
|
+
compressed bytes*. Any offline byte change — even an identity recompression —
|
|
62
|
+
misses the cache and renders black; live Fusion render of imported comps
|
|
63
|
+
produces no frames on 19. Therefore offline title *text* cannot be authored
|
|
64
|
+
for pre-21 hosts. The working flow: assemble everything else offline, then
|
|
65
|
+
set title text post-import with `timeline.set_title_text` (live-verified on
|
|
66
|
+
19.1.3). Generators are exempt — plain `Sm2TiGenerator`, no comp — and
|
|
67
|
+
render everywhere (Solid Color, SMPTE Color Bar, Grey Scale verified).
|
|
68
|
+
|
|
69
|
+
**Fairlight strip law (audio).** Audio tracks cannot be grown offline: the
|
|
70
|
+
per-timeline Fairlight model (`FLStudioModelBA` in the pool's
|
|
71
|
+
`Sm2Sequence.FieldsBlob`) holds one mixer strip per audio track, and a
|
|
72
|
+
cloned track without a strip is mute. The r19 media template is captured
|
|
73
|
+
with **8 mono audio tracks** (strips included); audio placements beyond
|
|
74
|
+
track 8 refuse with instructions to re-capture a bigger template. Explicit
|
|
75
|
+
`audioOnly` cuts suppress the A1 convenience mirror (which otherwise
|
|
76
|
+
mirrors track-1 video cuts).
|
|
77
|
+
|
|
78
|
+
**Timemap generation split (retimes).** `Sm2TimeMap` keyframes are protobuf
|
|
79
|
+
points on 21 but keyed-dicts on 19, and 19 *silently ignores* the protobuf
|
|
80
|
+
form (clip plays 100%). The r19 keyed encoder is byte-exact against
|
|
81
|
+
Resolve's own output. The map spans the whole source stretched by 1/speed;
|
|
82
|
+
the clip's `<In>`/`<Duration>` window into it in record-domain frames.
|
|
83
|
+
Reverse is the same map with the Y endpoints swapped, and `In` then measures
|
|
84
|
+
from the source end.
|
|
85
|
+
|
|
86
|
+
**Timeline origin.** Template timelines start at frame 86400
|
|
87
|
+
(01:00:00:00 @ 24fps). Clips placed before the origin are silently dropped
|
|
88
|
+
by Resolve on import — `startFrame` is timeline-absolute.
|
|
89
|
+
|
|
90
|
+
**Dissolve geometry.** A transition is authored only when the predecessor
|
|
91
|
+
ends exactly at the cut and both sides have handle media for the centered
|
|
92
|
+
span (incoming `srcIn ≥ dur/2`; outgoing `srcIn + dur + dur/2 ≤ frameCount`).
|
|
93
|
+
Everything else stays in `droppedTransitions` with the reason.
|
|
94
|
+
|
|
95
|
+
## Verification checklist for a delivered .drt
|
|
96
|
+
|
|
97
|
+
1. `timeline.import_timeline_checked` — expect `linked == total` for media
|
|
98
|
+
(generators legitimately count as offline).
|
|
99
|
+
2. Render a probe range; check frame luma at cut boundaries, dissolve
|
|
100
|
+
midpoints (expect the blend average), and retime windows.
|
|
101
|
+
3. For audio: RMS per window (silence = -inf is a failed placement).
|
|
102
|
+
4. Never trust `created_new: false` — a same-named timeline already in the
|
|
103
|
+
project is returned as "success" (internal-name-wins law).
|
|
104
|
+
|
|
105
|
+
## References
|
|
106
|
+
|
|
107
|
+
- `docs/reference/api-limitations.md` — the measured laws in report form
|
|
108
|
+
- `resolve-advanced/vendor/drp-format/` — the codec layer (each module's
|
|
109
|
+
header documents its measured ground truth)
|
|
110
|
+
- Guides: `conforming-an-avid-aaf.md` for the AAF ingest side
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "2.
|
|
40
|
+
VERSION = "2.116.0"
|
|
41
41
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
42
42
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
43
43
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -299,6 +299,7 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
299
299
|
const audioTrackNum = (t) => { const m = /^A(\d+)?$/.exec(String(t)); return m && m[1] ? parseInt(m[1], 10) : 1; };
|
|
300
300
|
const audioPlacements = [];
|
|
301
301
|
const audioRetimesSkipped = [];
|
|
302
|
+
const audioTransCandidates = [];
|
|
302
303
|
for (const e of auds) {
|
|
303
304
|
const recIn = ORIGIN + (toTl(e.recIn, e.fps) - minRec);
|
|
304
305
|
const recOut = ORIGIN + (toTl(e.recOut, e.fps) - minRec);
|
|
@@ -309,7 +310,16 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
309
310
|
}
|
|
310
311
|
const track = audioTrackNum(e.track);
|
|
311
312
|
const cut = { startFrame: recIn, durationFrames, srcIn: toTl(e.srcIn ?? 0, e.fps), audioOnly: true, track };
|
|
312
|
-
|
|
313
|
+
if (e.transition) {
|
|
314
|
+
let d = Math.max(2, toTl(e.transition.duration || 0, e.fps) || 2);
|
|
315
|
+
d += d % 2;
|
|
316
|
+
audioTransCandidates.push({
|
|
317
|
+
atFrame: recIn, durationFrames: d, track,
|
|
318
|
+
index: e.index, type: e.transition.type, rawDuration: e.transition.duration,
|
|
319
|
+
source: e.source, srcIn: cut.srcIn,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
audioPlacements.push({ start: recIn, end: recOut, index: e.index, track, source: e.source, srcIn: cut.srcIn, durationFrames });
|
|
313
323
|
if (!perSource.has(e.source)) perSource.set(e.source, []);
|
|
314
324
|
perSource.get(e.source).push(cut);
|
|
315
325
|
}
|
|
@@ -384,6 +394,29 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
384
394
|
}
|
|
385
395
|
transitions.push({ track: c.track, atFrame: c.atFrame, durationFrames: c.durationFrames });
|
|
386
396
|
}
|
|
397
|
+
// Audio cross-fades, same geometry rules (render-verified on 19.1.3.7 via
|
|
398
|
+
// the harvested cross-fade template: the highpass RMS ramps, not steps).
|
|
399
|
+
for (const c of audioTransCandidates) {
|
|
400
|
+
const prev = audioPlacements.find((pl) => pl.track === c.track && pl.end === c.atFrame);
|
|
401
|
+
if (!prev) {
|
|
402
|
+
droppedTransitions.push({ index: c.index, type: c.type, duration: c.rawDuration, trackType: 'audio', reason: 'no abutting predecessor at the cut' });
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const half = c.durationFrames / 2;
|
|
406
|
+
const bHandle = c.srcIn >= half;
|
|
407
|
+
const aSpec = sourceMap[prev.source] && sourceMap[prev.source].spec;
|
|
408
|
+
const aFrames = aSpec && Number(aSpec.frameCount);
|
|
409
|
+
const aHandle = Number.isFinite(aFrames) ? prev.srcIn + prev.durationFrames + half <= aFrames : false;
|
|
410
|
+
if (!bHandle || !aHandle) {
|
|
411
|
+
droppedTransitions.push({
|
|
412
|
+
index: c.index, type: c.type, duration: c.rawDuration, trackType: 'audio',
|
|
413
|
+
reason: `insufficient handles for a centered ${c.durationFrames}f cross-fade` +
|
|
414
|
+
`${bHandle ? '' : ' (incoming srcIn < half)'}${aHandle ? '' : ' (outgoing tail media < half)'}`,
|
|
415
|
+
});
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
transitions.push({ track: c.track, atFrame: c.atFrame, durationFrames: c.durationFrames, trackType: 'audio' });
|
|
419
|
+
}
|
|
387
420
|
|
|
388
421
|
return {
|
|
389
422
|
spec: { timelineName, media, ...(transitions.length ? { transitions } : {}) },
|
|
@@ -46,7 +46,7 @@ const assembleSchema = z.object({
|
|
|
46
46
|
spec: z
|
|
47
47
|
.object({})
|
|
48
48
|
.passthrough()
|
|
49
|
-
.describe("assembleTimeline spec: { timelineName?, media?: {mediaFilePath, spec:{width,height,frameCount,fps}, cuts:[{startFrame,durationFrames,srcIn?,track? (1-based video track; >1 = video-only, render-verified stacking),speed?/reverse? (constant retime, e.g. 0.5, forward or backwards; video-only; readback+render-verified on 19),audioOnly?+track? (explicit AUDIO placement on audio track 1-8; presence suppresses the A1 mirror; render-verified on 19)}]} | [same, ...] (multi-source needs media_pool.capture_media_template run once per file), elements?: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, generatorName? ('Solid Color'|'SMPTE Color Bar'|'Grey Scale' render-verified on 19), ...}]
|
|
49
|
+
.describe("assembleTimeline spec: { timelineName?, media?: {mediaFilePath, spec:{width,height,frameCount,fps}, cuts:[{startFrame,durationFrames,srcIn?,track? (1-based video track; >1 = video-only, render-verified stacking),speed?/reverse? (constant retime, e.g. 0.5, forward or backwards; video-only; readback+render-verified on 19),audioOnly?+track? (explicit AUDIO placement on audio track 1-8; presence suppresses the A1 mirror; render-verified on 19)}]} | [same, ...] (multi-source needs media_pool.capture_media_template run once per file), transitions?: [{track, atFrame, durationFrames?, trackType? ('video' dissolve | 'audio' cross-fade, both render-verified on 19)}], elements?: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, generatorName? ('Solid Color'|'SMPTE Color Bar'|'Grey Scale' render-verified on 19), ...}] }. startFrame is timeline-absolute (origin 86400)."),
|
|
50
50
|
outputPath: z.string().describe('Absolute path where the importable .drt will be written'),
|
|
51
51
|
targetAppVersion: z
|
|
52
52
|
.union([z.string(), z.number()])
|
|
@@ -44,5 +44,7 @@ test('placeTransition errors when no abutting boundary at atFrame', async () =>
|
|
|
44
44
|
test('placeTransition validates args', async () => {
|
|
45
45
|
const buf = await synth2();
|
|
46
46
|
await assert.rejects(() => placeTransition(buf, { track: 1, atFrame: 100, durationFrames: 1 }), /durationFrames/);
|
|
47
|
-
|
|
47
|
+
// trackType 'audio' is now supported (harvested cross-fade template,
|
|
48
|
+
// v2.116.0); an unknown trackType still refuses.
|
|
49
|
+
await assert.rejects(() => placeTransition(buf, { track: 1, atFrame: 100, trackType: 'subtitle' }), /video or audio/);
|
|
48
50
|
});
|
|
@@ -181,6 +181,7 @@ async function assembleTimeline(spec = {}) {
|
|
|
181
181
|
if (!tr || typeof tr !== 'object') throw new TypeError(`assembleTimeline: transitions[${i}] must be an object`);
|
|
182
182
|
({ buffer } = await placeTransition(buffer, {
|
|
183
183
|
track: tr.track, atFrame: tr.atFrame, durationFrames: tr.durationFrames,
|
|
184
|
+
trackType: tr.trackType || 'video',
|
|
184
185
|
}));
|
|
185
186
|
}
|
|
186
187
|
|
|
@@ -33,6 +33,11 @@ function splitItems(itemsInner) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
const TEMPLATE_PATH = path.join(__dirname, 'templates', 'transition-cross-dissolve.xml');
|
|
36
|
+
// Audio cross-fade, harvested from a live 19.1.3.7 XMEML import of an FCP7
|
|
37
|
+
// KGAudioTransCrossFade (render-verified: the highpass RMS ramps through the
|
|
38
|
+
// junction instead of stepping). PrettyType reads "Final Cut Pro 7" — that is
|
|
39
|
+
// what Resolve itself stores for it, and it renders.
|
|
40
|
+
const AUDIO_TEMPLATE_PATH = path.join(__dirname, 'templates', 'transition-cross-fade-r19.xml');
|
|
36
41
|
|
|
37
42
|
const clipStart = (c) => { const m = c.match(/<Start>(\d+)<\/Start>/); return m ? parseInt(m[1], 10) : null; };
|
|
38
43
|
const clipDuration = (c) => { const m = c.match(/<Duration>(\d+)<\/Duration>/); return m ? parseInt(m[1], 10) : null; };
|
|
@@ -45,7 +50,7 @@ const clipDuration = (c) => { const m = c.match(/<Duration>(\d+)<\/Duration>/);
|
|
|
45
50
|
* @param {number} opts.track - 1-based video track.
|
|
46
51
|
* @param {number} opts.atFrame - the cut frame (where one clip ends and the next begins).
|
|
47
52
|
* @param {number} [opts.durationFrames=24] - transition length (even number recommended; centered).
|
|
48
|
-
* @param {'video'} [opts.trackType='video'] - audio cross-fade
|
|
53
|
+
* @param {'video'|'audio'} [opts.trackType='video'] - 'audio' places the harvested cross-fade (render-verified on 19.1.3).
|
|
49
54
|
* @param {string} [opts.timelineUuid]
|
|
50
55
|
* @returns {Promise<{buffer:Buffer, entry:string, timelineUuid:string, track:number,
|
|
51
56
|
* atFrame:number, start:number, durationFrames:number, transitionDbId:string|null}>}
|
|
@@ -55,7 +60,7 @@ async function placeTransition(drpInput, opts = {}) {
|
|
|
55
60
|
if (!Number.isInteger(track) || track < 1) throw new TypeError('placeTransition: track must be a positive integer');
|
|
56
61
|
if (!Number.isInteger(atFrame)) throw new TypeError('placeTransition: atFrame must be an integer');
|
|
57
62
|
if (!Number.isInteger(durationFrames) || durationFrames < 2) throw new TypeError('placeTransition: durationFrames must be an integer >= 2');
|
|
58
|
-
if (trackType !== 'video') throw new Error('placeTransition:
|
|
63
|
+
if (trackType !== 'video' && trackType !== 'audio') throw new Error('placeTransition: trackType must be video or audio');
|
|
59
64
|
|
|
60
65
|
const zip = await loadDrpZip(drpInput);
|
|
61
66
|
const { entry, xml: seqXml, seqId } = await selectTargetSeq(zip, timelineUuid);
|
|
@@ -71,7 +76,7 @@ async function placeTransition(drpInput, opts = {}) {
|
|
|
71
76
|
}
|
|
72
77
|
if (leftIdx < 0) throw new Error(`placeTransition: no abutting clip boundary at frame ${atFrame} on track ${track}`);
|
|
73
78
|
|
|
74
|
-
let trans = fs.readFileSync(TEMPLATE_PATH, 'utf8').trim();
|
|
79
|
+
let trans = fs.readFileSync(trackType === 'audio' ? AUDIO_TEMPLATE_PATH : TEMPLATE_PATH, 'utf8').trim();
|
|
75
80
|
trans = freshDbIds(trans);
|
|
76
81
|
const start = atFrame - Math.floor(durationFrames / 2); // centered (AlignmentType 2)
|
|
77
82
|
trans = trans.replace(/<Start>\d+<\/Start>/, `<Start>${start}</Start>`);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<Element>
|
|
2
|
+
<Sm2TiTransition DbId="43e55250-3117-4f6e-abf0-ce028e94bf71">
|
|
3
|
+
<FieldsBlob>000000020000001b8012180000002c789c636660640001e62b75aa0c04000033c3017f</FieldsBlob>
|
|
4
|
+
<PrettyType>Final Cut Pro 7</PrettyType>
|
|
5
|
+
<Name/>
|
|
6
|
+
<Start>36</Start>
|
|
7
|
+
<Duration>24</Duration>
|
|
8
|
+
<LinkedItemSync/>
|
|
9
|
+
<WasDisbanded>false</WasDisbanded>
|
|
10
|
+
<MarkersBA/>
|
|
11
|
+
<UiMemento>0</UiMemento>
|
|
12
|
+
<Flags>0</Flags>
|
|
13
|
+
<PriorityIndex>0</PriorityIndex>
|
|
14
|
+
<EffectFiltersBA>00000002000001418128b52ffd60ea02b50900d24e3631608bda00016b1886611886618aaa90c45a8b2c4bb74e225968cc77bbadcdb43f34bdb7510008cd286032991041dcafba3a6db563c78e1f207ad278bd56901d3be66054645e53f64ec70e0cfdd6395ec67bbd5ad6a24a3d7f9d724c23acdc237c3969bcd14eafb71f24ba8e1ffab3d3eb4423ac3212140da3e3030d6c4098b087fe98b5b46efaa5cf4b7da7f3948a06887acc1863ab5b85577fe4cb10e35c6c4c1b8b08035e8d6996843765b48b674648a9f9ac295016da65d268eb7bb08f0c615802d148a3258514040c7448221f13001601828924325a012300e9f0abc6e90af86f499c4a043085b2b044a134bcb479940460f4f9df4213e27805a1a8862fe8bcb86058bb0b23aeab01486d4073cdcda64fc1d02980a688b20aada10ad6ead65663955acbce363a856d7330570bd2da6206</EffectFiltersBA>
|
|
15
|
+
<ImportExportMetadataBA/>
|
|
16
|
+
<RenderTextEnabled>true</RenderTextEnabled>
|
|
17
|
+
<RenderTextGanged>true</RenderTextGanged>
|
|
18
|
+
<RenderTextPrefixed>true</RenderTextPrefixed>
|
|
19
|
+
<AlignmentType>2</AlignmentType>
|
|
20
|
+
<Position>2</Position>
|
|
21
|
+
</Sm2TiTransition>
|
|
22
|
+
</Element>
|
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.
|
|
90
|
+
VERSION = "2.116.0"
|
|
91
91
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
92
92
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
93
93
|
logger.info(f"Detected platform: {get_platform()}")
|