Skip to content

Commit 2f5aab2

Browse files
committed
Implement v2 timeline format
1 parent 45bea45 commit 2f5aab2

File tree

8 files changed

+230
-102
lines changed

8 files changed

+230
-102
lines changed

src/edit.nim

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ proc setOutput(userOut, `export`, path: string): (string, string) =
127127
of ".mlt": myExport = "shotcut"
128128
of ".kdenlive": myExport = "kdenlive"
129129
of ".json", ".v1": myExport = "v1"
130+
of ".v2": myExport = "v2"
130131
of ".v3": myExport = "v3"
131132
else: myExport = "default"
132133

@@ -138,6 +139,7 @@ proc setOutput(userOut, `export`, path: string): (string, string) =
138139
of "v1":
139140
if ext != ".json":
140141
ext = ".v1"
142+
of "v2": ext = ".v2"
141143
of "v3": ext = ".v3"
142144
else: discard
143145

@@ -221,7 +223,7 @@ proc editMedia*(args: var mainArgs) =
221223
error "You need to give auto-editor an input file."
222224
let inputExt = splitFile(args.input).ext
223225

224-
if inputExt in [".v1", ".v3", ".json"]:
226+
if inputExt in [".v1", ".v2", ".v3", ".json"]:
225227
tlV3 = readJson(readFile(args.input), interner)
226228
applyArgs(tlV3, args)
227229
else:
@@ -286,7 +288,7 @@ proc editMedia*(args: var mainArgs) =
286288
return
287289

288290
case exportKind:
289-
of "v1", "v3":
291+
of "v1", "v2", "v3":
290292
exportJsonTl(tlV3, exportKind, output)
291293
return
292294
of "premiere":
@@ -347,23 +349,18 @@ proc editMedia*(args: var mainArgs) =
347349
debug &"Temp Directory: {tempDir}"
348350

349351
if args.`export` == "clip-sequence":
350-
if not isSome(tlV3.chunks):
352+
if not isSome(tlV3.clips2):
351353
error "Timeline too complex to use clip-sequence export"
352354

353-
let chunks: seq[(int64, int64, float64)] = tlV3.chunks.unsafeGet()
354-
355-
proc padChunk(chunk: (int64, int64, float64), total: int64): seq[(int64, int64, float64)] =
356-
let start = (if chunk[0] == 0'i64: @[] else: @[(0'i64, chunk[0], 99999.0)])
357-
let `end` = (if chunk[1] == total: @[] else: @[(chunk[1], total, 99999.0)])
358-
return start & @[chunk] & `end`
359-
360-
func appendFilename(path: string, val: string): string =
355+
func appendFilename(path, val: string): string =
361356
let (dir, name, ext) = splitFile(path)
362357
return (dir / name) & val & ext
363358

364-
const black = RGBColor(red: 0, green: 0, blue: 0)
365-
let totalFrames: int64 = chunks[^1][1] - 1
366-
var clipNum = 0
359+
let allClips2: seq[Clip2] = tlV3.clips2.unsafeGet()
360+
var clips2: seq[Clip2] = @[]
361+
for clip in allClips2:
362+
if tlV3.effects[clip.effect].kind != actCut:
363+
clips2.add(clip)
367364

368365
let unique = tlV3.uniqueSources()
369366
var src: ptr string
@@ -373,16 +370,12 @@ proc editMedia*(args: var mainArgs) =
373370
if src == nil:
374371
error "Trying to render an empty timeline"
375372
let mi = initMediaInfo(src[])
373+
const black = RGBColor(red: 0, green: 0, blue: 0)
376374

377-
for chunk in chunks:
378-
if chunk[2] <= 0 or chunk[2] >= 99999:
379-
continue
380-
381-
let paddedChunks = padChunk(chunk, totalFrames)
382-
var myTimeline = toNonLinear(src, tlV3.tb, black, mi, paddedChunks)
375+
for clipNum, clip2 in clips2.pairs:
376+
var myTimeline = toNonLinear2(src, tlV3.tb, black, mi, @[clip2], tlV3.effects)
383377
applyArgs(myTimeline, args)
384378
makeMedia(args, myTimeline, appendFilename(output, &"-{clipNum}"), rule, bar)
385-
clipNum += 1
386379
else:
387380
makeMedia(args, tlV3, output, rule, bar)
388381

src/exports/json.nim

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
1-
import std/json
2-
import std/options
3-
import std/sequtils
1+
import std/[json, options, sequtils]
42

53
import ../timeline
64
import ../log
75
import ../util/color
86

9-
func `%`*(self: v1): JsonNode =
7+
8+
func effectToString(act: Action): string =
9+
case act.kind
10+
of actNil: "nil"
11+
of actCut: "cut"
12+
of actSpeed: "speed:" & $act.val
13+
of actPitch: "pitch:" & $act.val
14+
of actVolume: "volume:" & $act.val
15+
16+
func `%`(self: v1): JsonNode =
1017
var jsonChunks = self.chunks.mapIt(%[%it[0], %it[1], %it[2]])
1118
return %* {"version": "1", "source": self.source, "chunks": jsonChunks}
1219

13-
func effectToString(act: Action): string =
14-
if act.kind == actSpeed:
15-
return "speed:" & $act.val
16-
elif act.kind == actPitch:
17-
return "pitch:" & $act.val
18-
elif act.kind == actVolume:
19-
return "volume:" & $act.val
20-
else:
21-
return ""
20+
func `%`(self: v2): JsonNode =
21+
let jsonClips = self.clips.mapIt(%[%it.start, %it.`end`, %it.`effect`])
22+
let jsonEffects = self.effects.mapIt(%effectToString(it))
23+
return %* {
24+
"version": "2",
25+
"source": self.source,
26+
"tb": $self.tb.num & "/" & $self.tb.den,
27+
"effects": jsonEffects,
28+
"clips": jsonClips,
29+
}
2230

2331
func `%`*(self: v3): JsonNode =
2432
var videoTracks = newJArray()
@@ -60,7 +68,7 @@ func `%`*(self: v3): JsonNode =
6068
return %* {
6169
"version": "3",
6270
"timebase": $self.tb.num & "/" & $self.tb.den,
63-
"background": self.background.toString,
71+
"background": self.bg.toString,
6472
"resolution": [self.res[0], self.res[1]],
6573
"samplerate": self.sr,
6674
"layout": self.layout,
@@ -71,18 +79,33 @@ func `%`*(self: v3): JsonNode =
7179
proc exportJsonTl*(tlV3: v3, `export`: string, output: string) =
7280
var tlJson: JsonNode
7381

74-
if `export` == "v1":
75-
if tlV3.chunks.isNone:
82+
if `export` == "v1" or `export` == "v2":
83+
if tlV3.clips2.isNone:
7684
error "No chunks available for export"
7785

78-
let chunks = tlV3.chunks.unsafeGet()
7986
var source: string = ""
8087
if tlV3.v.len > 0 and tlV3.v[0].len > 0:
8188
source = tlV3.v[0].c[0].src[]
8289
elif tlV3.a.len > 0 and tlV3.a[0].len > 0:
8390
source = tlV3.a[0].c[0].src[]
8491

85-
tlJson = %v1(chunks: chunks, source: source)
92+
let clips2 = tlV3.clips2.unsafeGet()
93+
let tb = tlV3.tb
94+
if `export` == "v2":
95+
tlJson = %v2(source: source, tb: tb, clips: clips2, effects: tlV3.effects)
96+
else:
97+
var chunks: seq[(int64, int64, float64)] = @[]
98+
for clip2 in clips2:
99+
var speed = 1.0
100+
let effect = tlV3.effects[clip2.effect]
101+
if effect.kind == actCut:
102+
speed = 99999.0
103+
elif effect.kind == actSpeed or effect.kind == actPitch:
104+
speed = effect.val.float64
105+
106+
chunks.add (clip2.start, clip2.`end`, speed)
107+
108+
tlJson = %v1(source: source, chunks: chunks)
86109
else:
87110
tlJson = %tlV3
88111

src/exports/shotcut.nim

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ proc shotcut_write_mlt*(output: string, tl: v3) =
7070

7171
let resource_prop = newElement("property")
7272
resource_prop.attrs = {"name": "resource"}.toXmlAttributes()
73-
resource_prop.add(newText(tl.background.toString))
73+
resource_prop.add(newText(tl.bg.toString))
7474
producer.add(resource_prop)
7575

7676
let service_prop = newElement("property")

src/imports/json.nim

Lines changed: 56 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,41 @@
1-
import std/json
2-
import std/strutils
1+
import std/[strformat, strutils, json]
32

43
import ../log
54
import ../timeline
65
import ../ffmpeg
76
import ../media
87
import ../util/color
98

9+
proc parseEffect(val: string): Action =
10+
# Parse effect strings like "speed:2.0", "volume:1.5", or simple "cut", "nil"
11+
if val == "cut":
12+
return Action(kind: actCut)
13+
if val == "nil":
14+
return Action(kind: actNil)
15+
16+
let parts = val.split(":")
17+
if parts.len == 2:
18+
let effectType = parts[0]
19+
let effectVal = parseFloat(parts[1])
20+
case effectType
21+
of "speed": return Action(kind: actSpeed, val: effectVal)
22+
of "volume": return Action(kind: actVolume, val: effectVal)
23+
of "pitch": return Action(kind: actPitch, val: effectVal)
24+
else: error &"unknown action: {effectType}"
25+
26+
error &"unknown action: {val}"
27+
1028
proc parseClip(node: JsonNode, interner: var StringInterner, effects: var seq[Action]): Clip =
1129
result.src = interner.intern(node["src"].getStr())
1230
result.start = node["start"].getInt()
1331
result.dur = node["dur"].getInt()
1432
result.offset = node["offset"].getInt()
1533
result.stream = node["stream"].getInt().int32
1634

17-
# Parse effects array and find/add to effects list
1835
var clipAction = Action(kind: actNil)
1936
if node.hasKey("effects") and node["effects"].kind == JArray:
2037
for effectNode in node["effects"]:
21-
let effectStr = effectNode.getStr()
22-
# Parse effect strings like "speed:2.0", "volume:1.5"
23-
let parts = effectStr.split(":")
24-
if parts.len == 2:
25-
let effectType = parts[0]
26-
let effectVal = parseFloat(parts[1])
27-
case effectType
28-
of "speed":
29-
clipAction = Action(kind: actSpeed, val: effectVal)
30-
of "volume":
31-
clipAction = Action(kind: actVolume, val: effectVal)
32-
of "pitch":
33-
clipAction = Action(kind: actPitch, val: effectVal)
34-
else:
35-
discard
38+
clipAction = parseEffect(effectNode.getStr())
3639

3740
# Find or add the action to the effects list
3841
let effectIndex = effects.find(clipAction)
@@ -55,7 +58,7 @@ proc parseV3*(jsonNode: JsonNode, interner: var StringInterner): v3 =
5558
error("sr/bg bad structure")
5659

5760
result.sr = jsonNode["samplerate"].getInt().cint
58-
result.background = parseColor(jsonNode["background"].getStr())
61+
result.bg = parseColor(jsonNode["background"].getStr())
5962

6063
if not jsonNode.hasKey("resolution") or jsonNode["resolution"].kind != JArray:
6164
error("'resolution' has bad structure")
@@ -89,6 +92,34 @@ proc parseV3*(jsonNode: JsonNode, interner: var StringInterner): v3 =
8992
track.c.add(parseClip(audioNode, interner, result.effects))
9093
result.a.add(track)
9194

95+
proc parseV2*(jsonNode: JsonNode, interner: var StringInterner): v3 =
96+
let input = jsonNode["source"].getStr()
97+
let ptrInput = intern(interner, input)
98+
var effects: seq[Action]
99+
var clips: seq[Clip2]
100+
let tb: AVRational = jsonNode["tb"].getStr()
101+
102+
if jsonNode.hasKey("clips") and jsonNode["clips"].kind == JArray:
103+
for chunkNode in jsonNode["clips"]:
104+
if chunkNode.kind == JArray and chunkNode.len >= 3:
105+
let start: int64 = chunkNode[0].getInt()
106+
let `end`: int64 = chunkNode[1].getInt()
107+
let effect = uint32(chunkNode[2].getInt())
108+
clips.add Clip2(start: start, `end`: `end`, effect: effect)
109+
110+
if jsonNode.hasKey("effects") and jsonNode["effects"].kind == JArray:
111+
for effectNode in jsonNode["effects"]:
112+
# TODO: Support multiple actions
113+
let clipAction = parseEffect(effectNode.getStr())
114+
let effectIndex = effects.find(clipAction)
115+
if effectIndex == -1:
116+
effects.add(clipAction)
117+
118+
let mi = initMediaInfo(input)
119+
let bg = RGBColor(red: 0, green: 0, blue: 0)
120+
result = toNonLinear2(ptrInput, tb, bg, mi, clips, effects)
121+
122+
92123
proc parseV1*(jsonNode: JsonNode, interner: var StringInterner): v3 =
93124
var chunks: seq[(int64, int64, float64)] = @[]
94125

@@ -113,10 +144,10 @@ proc parseV1*(jsonNode: JsonNode, interner: var StringInterner): v3 =
113144

114145
proc readJson*(jsonStr: string, interner: var StringInterner): v3 =
115146
let jsonNode = parseJson(jsonStr)
116-
117147
let version: string = jsonNode["version"].getStr("unknown")
118-
if version == "3":
119-
return parseV3(jsonNode, interner)
120-
if version == "1":
121-
return parseV1(jsonNode, interner)
122-
error("Unsupported version")
148+
149+
case version:
150+
of "3": return parseV3(jsonNode, interner)
151+
of "2": return parseV2(jsonNode, interner)
152+
of "1": return parseV1(jsonNode, interner)
153+
else: error &"Unsupported version: {version}"

src/main.nim

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@ Options:
3131
section is less than LENGTH away. (default
3232
is "0.2s")
3333
--edit METHOD Set an expression which determines how to
34-
make auto edits
34+
make auto edits. (default is "audio")
3535
--when-normal ACTION When the video is not silent (defined by --edit)
36-
do an action. The default action being 'nil'.
36+
do an action. The default action being 'nil'
3737
--when-silent ACTION When the video is silent (defined by --edit)
38-
do an action. The default action being 'cut'.
38+
do an action. The default action being 'cut'
3939
4040
Actions Available:
4141
nil () ; unchanged/do nothing
@@ -46,20 +46,19 @@ Options:
4646
; val: between (0-99999)
4747
pitch (val: float)
4848
; Change the speed by varying pitch.
49-
; val: between (0-99999)
50-
-ex, --export EXPORT:ATTRS? Choose the export mode. (default is "audio")
49+
; val: between [0.2-100]
50+
-ex, --export EXPORT:ATTRS? Choose the export mode.
5151
-o, --output FILE Set the name/path of the new output file
52-
-s, --silent-speed NUM Set speed of sections marked "silent" to
53-
NUM. (default is 99999)
54-
-v, --sounded-speed, --video-speed NUM
55-
Set speed of sections marked "loud" to
56-
NUM. (default is 1)
57-
--cut-out [START,STOP ...] The range of media that will be removed
58-
(cut out) completely
59-
--add-in [START,STOP ...] The range of media that will be added in,
60-
will apply --video-speed
52+
--cut-out [START,STOP ...] The range of time that will be cut (removed)
53+
completely
54+
--add-in [START,STOP ...] The range of time that will be leaved "as is"
6155
--set-speed, --set-speed-for-range [SPEED,START,STOP ...]
62-
Set the SPEED for a given range
56+
Set a SPEED for a given range in time
57+
-s, --silent-speed NUM [Deprecated] Set speed of sections marked
58+
"silent" to NUM. (default is 99999)
59+
-v, --sounded-speed, --video-speed NUM
60+
[Deprecated] Set speed of sections marked "loud"
61+
to NUM. (default is 1)
6362
6463
Timeline Options:
6564
-tb, --time-base, -r, -fps, --frame-rate NUM

src/render/audio.nim

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ proc createFilterGraph(effect: Action, sr: int, layout: string): (ptr AVFilterGr
300300
if remainingSpeed > 1.0 or remainingSpeed < 1.0:
301301
filters.add &"atempo={remainingSpeed}"
302302
of actPitch:
303-
let clampedSpeed = max(0.5, min(100.0, effect.val))
303+
let clampedSpeed = max(0.2, min(100.0, effect.val))
304304
filters.add &"asetrate={sr}*{clampedSpeed}"
305305
filters.add &"aresample={sr}"
306306
of actVolume:
@@ -567,6 +567,8 @@ proc makeAudioFrames(fmt: AVSampleFormat, tl: v3, frameSize: int, layerIndices:
567567
let tb = tl.tb
568568
let sr = tl.sr
569569

570+
conWrite "Creating audio"
571+
570572
# Collect all unique audio sources from specified layers
571573
for layerIndex in layerIndices:
572574
if layerIndex < tl.a.len:

src/render/video.nim

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ proc makeNewVideoFrames*(output: var OutputContainer, tl: v3, args: mainArgs):
252252

253253
let pixFmtName = $av_get_pix_fmt_name(pix_fmt)
254254
let graphTb = av_inv_q(targetFps)
255-
let bg = tl.background.toString
255+
let bg = tl.bg.toString
256256
let globalScaleArgs = &"{tl.res[0]}:{tl.res[1]}:force_original_aspect_ratio=decrease:eval=frame"
257257

258258
if needsScaling:
@@ -297,7 +297,7 @@ proc makeNewVideoFrames*(output: var OutputContainer, tl: v3, args: mainArgs):
297297
let i = int(round(float(sourceFramePos) * speed))
298298
objList.add VideoFrame(index: i, src: obj.src)
299299

300-
if tl.chunks.isSome:
300+
if tl.clips2.isSome:
301301
# When there can be valid gaps in the timeline and no objects for this frame.
302302
frame = av_frame_clone(nullFrame)
303303
# else, use the last frame or process objects

0 commit comments

Comments
 (0)