holobench 1.19.0__py2.py3-none-any.whl → 1.30.1__py2.py3-none-any.whl

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.
Files changed (46) hide show
  1. bencher/__init__.py +12 -1
  2. bencher/bench_report.py +6 -109
  3. bencher/bench_runner.py +1 -1
  4. bencher/bencher.py +103 -57
  5. bencher/example/benchmark_data.py +0 -4
  6. bencher/example/example_composable_container.py +106 -0
  7. bencher/example/example_composable_container2.py +160 -0
  8. bencher/example/example_consts.py +39 -0
  9. bencher/example/example_custom_sweep2.py +42 -0
  10. bencher/example/example_dataframe.py +48 -0
  11. bencher/example/example_image.py +32 -17
  12. bencher/example/example_image1.py +81 -0
  13. bencher/example/example_levels2.py +37 -0
  14. bencher/example/example_simple_float.py +15 -25
  15. bencher/example/example_simple_float2d.py +29 -0
  16. bencher/example/example_strings.py +3 -2
  17. bencher/example/example_video.py +2 -11
  18. bencher/example/meta/example_meta.py +2 -2
  19. bencher/example/meta/example_meta_cat.py +2 -2
  20. bencher/example/meta/example_meta_float.py +1 -1
  21. bencher/example/meta/example_meta_levels.py +2 -2
  22. bencher/optuna_conversions.py +3 -2
  23. bencher/plotting/plt_cnt_cfg.py +1 -0
  24. bencher/results/bench_result.py +3 -1
  25. bencher/results/bench_result_base.py +58 -8
  26. bencher/results/composable_container/composable_container_base.py +25 -12
  27. bencher/results/composable_container/composable_container_dataframe.py +52 -0
  28. bencher/results/composable_container/composable_container_panel.py +17 -18
  29. bencher/results/composable_container/composable_container_video.py +163 -55
  30. bencher/results/dataset_result.py +227 -0
  31. bencher/results/holoview_result.py +15 -7
  32. bencher/results/optuna_result.py +4 -3
  33. bencher/results/panel_result.py +1 -1
  34. bencher/results/video_summary.py +104 -99
  35. bencher/utils.py +28 -2
  36. bencher/variables/__init__.py +0 -0
  37. bencher/variables/inputs.py +24 -1
  38. bencher/variables/parametrised_sweep.py +6 -4
  39. bencher/variables/results.py +29 -1
  40. bencher/variables/time.py +22 -0
  41. bencher/video_writer.py +20 -74
  42. {holobench-1.19.0.dist-info → holobench-1.30.1.dist-info}/METADATA +77 -35
  43. {holobench-1.19.0.dist-info → holobench-1.30.1.dist-info}/RECORD +46 -33
  44. {holobench-1.19.0.dist-info → holobench-1.30.1.dist-info}/WHEEL +1 -1
  45. holobench-1.30.1.dist-info/licenses/LICENSE +21 -0
  46. resource/bencher +0 -0
@@ -11,8 +11,8 @@ def example_meta_levels(
11
11
  title="Using Levels to define sample density",
12
12
  description="Sample levels let you perform parameter sweeps without having to decide how many samples to take when defining the class. If you perform a sweep at level 2, then all the points are reused when sampling at level 3. The higher levels reuse the points from lower levels to avoid having to recompute potentially expensive samples. The other advantage is that it enables a workflow where you can quickly see the results of the sweep at a low resolution to sense check the code, and then run it at a high level to get the fidelity you want. When calling a sweep at a high level, you can publish the intermediate lower level results as the computiation continues so that you can track the progress of the computation and end the sweep early when you have sufficient resolution",
13
13
  input_vars=[
14
- BenchMeta.param.float_vars.with_sample_values([1, 2]),
15
- BenchMeta.param.level.with_sample_values([2, 3, 4]),
14
+ bch.p("float_vars", [1, 2]),
15
+ bch.p("level", [2, 3, 4]),
16
16
  ],
17
17
  const_vars=[
18
18
  BenchMeta.param.categorical_vars.with_const(0),
@@ -1,4 +1,5 @@
1
1
  from typing import List
2
+ import logging
2
3
 
3
4
  import optuna
4
5
  import panel as pn
@@ -158,8 +159,8 @@ def summarise_optuna_study(study: optuna.study.Study) -> pn.pane.panel:
158
159
  row.append(plot_param_importances(study))
159
160
  try:
160
161
  row.append(plot_pareto_front(study))
161
- except Exception:
162
- pass
162
+ except Exception as e: # pylint: disable=broad-except
163
+ logging.exception(e)
163
164
 
164
165
  row.append(
165
166
  pn.pane.Markdown(f"```\nBest value: {study.best_value}\nParams: {study.best_params}```")
@@ -47,6 +47,7 @@ class PltCntCfg(param.Parameterized):
47
47
  plt_cnt_cfg.float_vars = []
48
48
 
49
49
  for iv in bench_cfg.input_vars:
50
+ type_allocated = False
50
51
  if isinstance(iv, (IntSweep, FloatSweep, TimeSnapshot)):
51
52
  # if "IntSweep" in typestr or "FloatSweep" in typestr:
52
53
  plt_cnt_cfg.float_vars.append(iv)
@@ -7,15 +7,17 @@ from bencher.results.video_summary import VideoSummaryResult
7
7
  from bencher.results.panel_result import PanelResult
8
8
  from bencher.results.plotly_result import PlotlyResult
9
9
  from bencher.results.holoview_result import HoloviewResult
10
+ from bencher.results.dataset_result import DataSetResult
10
11
  from bencher.utils import listify
11
12
 
12
13
 
13
- class BenchResult(PlotlyResult, HoloviewResult, VideoSummaryResult):
14
+ class BenchResult(PlotlyResult, HoloviewResult, VideoSummaryResult, DataSetResult):
14
15
  """Contains the results of the benchmark and has methods to cast the results to various datatypes and graphical representations"""
15
16
 
16
17
  def __init__(self, bench_cfg) -> None:
17
18
  PlotlyResult.__init__(self, bench_cfg)
18
19
  HoloviewResult.__init__(self, bench_cfg)
20
+ # DataSetResult.__init__(self.bench_cfg)
19
21
 
20
22
  @staticmethod
21
23
  def default_plot_callbacks():
@@ -19,9 +19,7 @@ from bencher.variables.results import ResultVar
19
19
  from bencher.plotting.plot_filter import VarRange, PlotFilter
20
20
  from bencher.utils import listify
21
21
 
22
- from bencher.variables.results import (
23
- ResultReference,
24
- )
22
+ from bencher.variables.results import ResultReference, ResultDataSet
25
23
 
26
24
  from bencher.results.composable_container.composable_container_panel import ComposableContainerPanel
27
25
 
@@ -242,6 +240,46 @@ class BenchResultBase(OptunaResult):
242
240
  row.append(plot_callback(rv))
243
241
  return row.get()
244
242
 
243
+ @staticmethod
244
+ def zip_results1D(args): # pragma: no cover
245
+ first_el = [a[0] for a in args]
246
+ out = pn.Column()
247
+ for a in zip(*first_el):
248
+ row = pn.Row()
249
+ row.append(a[0])
250
+ for a1 in range(1, len(a[1])):
251
+ row.append(a[a1][1])
252
+ out.append(row)
253
+ return out
254
+
255
+ @staticmethod
256
+ def zip_results1D1(panel_list): # pragma: no cover
257
+ container_args = {"styles": {}}
258
+ container_args["styles"]["border-bottom"] = f"{2}px solid grey"
259
+ print(panel_list)
260
+ out = pn.Column()
261
+ for a in zip(*panel_list):
262
+ row = pn.Row(**container_args)
263
+ row.append(a[0][0])
264
+ for a1 in range(0, len(a)):
265
+ row.append(a[a1][1])
266
+ out.append(row)
267
+ return out
268
+
269
+ @staticmethod
270
+ def zip_results1D2(panel_list): # pragma: no cover
271
+ if panel_list is not None:
272
+ print(panel_list)
273
+ primary = panel_list[0]
274
+ secondary = panel_list[1:]
275
+ for i in range(len(primary)):
276
+ print(type(primary[i]))
277
+ if isinstance(primary[i], (pn.Column, pn.Row)):
278
+ for j in range(len(secondary)):
279
+ primary[i].append(secondary[j][i][1])
280
+ return primary
281
+ return panel_list
282
+
245
283
  def map_plot_panes(
246
284
  self,
247
285
  plot_callback: callable,
@@ -250,6 +288,7 @@ class BenchResultBase(OptunaResult):
250
288
  result_var: ResultVar = None,
251
289
  result_types=None,
252
290
  pane_collection: pn.pane = None,
291
+ zip_results=False,
253
292
  **kwargs,
254
293
  ) -> Optional[pn.Row]:
255
294
  if hv_dataset is None:
@@ -271,6 +310,9 @@ class BenchResultBase(OptunaResult):
271
310
  target_dimension=target_dimension,
272
311
  )
273
312
  )
313
+
314
+ if zip_results:
315
+ return self.zip_results1D2(row.get())
274
316
  return row.get()
275
317
 
276
318
  def filter(
@@ -365,7 +407,7 @@ class BenchResultBase(OptunaResult):
365
407
  sliced = dataset.isel({selected_dim: i})
366
408
  label_val = sliced.coords[selected_dim].values.item()
367
409
  inner_container = ComposableContainerPanel(
368
- outer_container.name,
410
+ name=outer_container.name,
369
411
  width=num_dims - target_dimension,
370
412
  var_name=selected_dim,
371
413
  var_value=label_val,
@@ -405,15 +447,23 @@ class BenchResultBase(OptunaResult):
405
447
  return da_ds.values.squeeze().item()
406
448
  return da.expand_dims(dim).values[0]
407
449
 
408
- def ds_to_container(
450
+ def ds_to_container( # pylint: disable=too-many-return-statements
409
451
  self, dataset: xr.Dataset, result_var: Parameter, container, **kwargs
410
452
  ) -> Any:
411
453
  val = self.zero_dim_da_to_val(dataset[result_var.name])
454
+ if isinstance(result_var, ResultDataSet):
455
+ ref = self.dataset_list[val]
456
+ if ref is not None:
457
+ if container is not None:
458
+ return container(ref.obj)
459
+ return ref.obj
460
+ return None
412
461
  if isinstance(result_var, ResultReference):
413
462
  ref = self.object_index[val]
414
- val = ref.obj
415
- if ref.container is not None:
416
- return ref.container(val, **kwargs)
463
+ if ref is not None:
464
+ val = ref.obj
465
+ if ref.container is not None:
466
+ return ref.container(val, **kwargs)
417
467
  if container is not None:
418
468
  return container(val, styles={"background": "white"}, **kwargs)
419
469
  try:
@@ -1,18 +1,38 @@
1
- from enum import Enum, auto
2
- from typing import Any
1
+ from enum import auto
2
+ from typing import Any, List
3
+ from dataclasses import dataclass, field
4
+ from strenum import StrEnum
3
5
  from bencher.results.float_formatter import FormatFloat
4
6
 
5
7
 
6
8
  # TODO enable these options
7
- class ComposeType(Enum):
9
+ class ComposeType(StrEnum):
8
10
  right = auto() # append the container to the right (creates a row)
9
11
  down = auto() # append the container below (creates a column)
10
- overlay = auto() # overlay on top of the current container (alpha blending)
11
12
  sequence = auto() # display the container after (in time)
13
+ overlay = auto() # overlay on top of the current container (alpha blending)
12
14
 
15
+ def flip(self):
16
+ match self:
17
+ case ComposeType.right:
18
+ return ComposeType.down
19
+ case ComposeType.down:
20
+ return ComposeType.right
21
+ case _:
22
+ raise RuntimeError("cannot flip this type")
23
+
24
+ @staticmethod
25
+ def from_horizontal(horizontal: bool):
26
+ return ComposeType.right if horizontal else ComposeType.down
13
27
 
28
+
29
+ @dataclass(kw_only=True)
14
30
  class ComposableContainerBase:
15
- """A base class for renderer backends. A composable renderr"""
31
+ """A base class for renderer backends. A composable renderer"""
32
+
33
+ compose_method: ComposeType = ComposeType.right
34
+ container: List[Any] = field(default_factory=list)
35
+ label_len: int = 0
16
36
 
17
37
  @staticmethod
18
38
  def label_formatter(var_name: str, var_value: int | float | str) -> str:
@@ -36,13 +56,6 @@ class ComposableContainerBase:
36
56
  return f"{var_value}"
37
57
  return None
38
58
 
39
- def __init__(
40
- self, horizontal: bool = True, compose_method: ComposeType = ComposeType.right
41
- ) -> None:
42
- self.horizontal: bool = horizontal
43
- self.compose_method = compose_method
44
- self.container = []
45
-
46
59
  def append(self, obj: Any) -> None:
47
60
  """Add an object to the container. The relationship between the objects is defined by the ComposeType
48
61
 
@@ -0,0 +1,52 @@
1
+ from dataclasses import dataclass
2
+ import panel as pn
3
+ import xarray as xr
4
+ from bencher.results.composable_container.composable_container_base import ComposableContainerBase
5
+ from bencher.results.composable_container.composable_container_base import ComposeType
6
+
7
+
8
+ @dataclass(kw_only=True)
9
+ class ComposableContainerDataset(ComposableContainerBase):
10
+ name: str = None
11
+ var_name: str = None
12
+ var_value: str = None
13
+ width: int = None
14
+ background_col: str = None
15
+ horizontal: bool = True
16
+
17
+ def __post_init__(
18
+ self,
19
+ ) -> None:
20
+ container_args = {
21
+ "name": self.name,
22
+ "styles": {},
23
+ }
24
+
25
+ if self.width is not None:
26
+ container_args["styles"]["border-bottom"] = f"{self.width}px solid grey"
27
+ if self.background_col is not None:
28
+ container_args["styles"]["background"] = self.background_col
29
+
30
+ if self.horizontal:
31
+ self.container = pn.Column(**container_args)
32
+ align = ("center", "center")
33
+ else:
34
+ self.container = pn.Row(**container_args)
35
+ align = ("end", "center")
36
+
37
+ label = self.label_formatter(self.var_name, self.var_value)
38
+ if label is not None:
39
+ self.label_len = len(label)
40
+ side = pn.pane.Markdown(label, align=align)
41
+ self.append(side)
42
+
43
+ def render(self, **kwargs): # pylint: disable=unused-argument
44
+ match self.compose_method:
45
+ case ComposeType.right:
46
+ return xr.concat(self.container, 0)
47
+ case ComposeType.down:
48
+ return xr.concat(self.container, 1)
49
+ case ComposeType.sequence:
50
+ return xr.concat(self.container, 2)
51
+ # case ComposeType.overlay:
52
+ # return xr.Dataset.mean()
@@ -1,39 +1,38 @@
1
1
  import panel as pn
2
2
  from bencher.results.composable_container.composable_container_base import ComposableContainerBase
3
+ from dataclasses import dataclass
3
4
 
4
5
 
6
+ @dataclass(kw_only=True)
5
7
  class ComposableContainerPanel(ComposableContainerBase):
6
- def __init__(
8
+ name: str = None
9
+ var_name: str = None
10
+ var_value: str = None
11
+ width: int = None
12
+ background_col: str = None
13
+ horizontal: bool = True
14
+
15
+ def __post_init__(
7
16
  self,
8
- name: str = None,
9
- var_name: str = None,
10
- var_value: str = None,
11
- width: int = None,
12
- background_col: str = None,
13
- horizontal: bool = True,
14
17
  ) -> None:
15
- super().__init__(horizontal)
16
-
17
18
  container_args = {
18
- "name": name,
19
+ "name": self.name,
19
20
  "styles": {},
20
21
  }
21
22
 
22
- self.name = name
23
-
24
- if width is not None:
25
- container_args["styles"]["border-bottom"] = f"{width}px solid grey"
26
- if background_col is not None:
27
- container_args["styles"]["background"] = background_col
23
+ if self.width is not None:
24
+ container_args["styles"]["border-bottom"] = f"{self.width}px solid grey"
25
+ if self.background_col is not None:
26
+ container_args["styles"]["background"] = self.background_col
28
27
 
29
- if horizontal:
28
+ if self.horizontal:
30
29
  self.container = pn.Column(**container_args)
31
30
  align = ("center", "center")
32
31
  else:
33
32
  self.container = pn.Row(**container_args)
34
33
  align = ("end", "center")
35
34
 
36
- label = self.label_formatter(var_name, var_value)
35
+ label = self.label_formatter(self.var_name, self.var_value)
37
36
  if label is not None:
38
37
  self.label_len = len(label)
39
38
  side = pn.pane.Markdown(label, align=align)
@@ -1,76 +1,184 @@
1
+ from __future__ import annotations
1
2
  import numpy as np
3
+ from copy import deepcopy
4
+ from pathlib import Path
5
+ from dataclasses import dataclass
2
6
  from moviepy.editor import (
3
7
  ImageClip,
4
8
  CompositeVideoClip,
5
9
  clips_array,
6
10
  concatenate_videoclips,
7
11
  VideoClip,
12
+ VideoFileClip,
8
13
  )
14
+ from moviepy.video.fx.margin import margin
9
15
 
10
- from bencher.results.composable_container.composable_container_base import ComposableContainerBase
16
+ from bencher.results.composable_container.composable_container_base import (
17
+ ComposableContainerBase,
18
+ ComposeType,
19
+ )
11
20
  from bencher.video_writer import VideoWriter
12
21
 
13
22
 
23
+ @dataclass()
24
+ class RenderCfg:
25
+ compose_method: ComposeType = ComposeType.sequence
26
+ var_name: str = None
27
+ var_value: str = None
28
+ background_col: tuple[int, int, int] = (255, 255, 255)
29
+ duration: float = 10.0
30
+ duration_target: bool = True
31
+ min_frame_duration: float = 1.0 / 30
32
+ max_frame_duration: float = 2.0
33
+ margin: int = 0
34
+
35
+
36
+ @dataclass
14
37
  class ComposableContainerVideo(ComposableContainerBase):
15
- def __init__(
16
- self,
17
- name: str = None,
18
- var_name: str = None,
19
- var_value: str = None,
20
- background_col: tuple[3] = (255, 255, 255),
21
- horizontal: bool = True,
22
- target_duration: float = None,
23
- ) -> None:
24
- super().__init__(horizontal)
25
- self.name = name
26
- self.container = []
27
- self.background_col = background_col
28
- self.target_duration = 10 if target_duration is None else target_duration
29
- self.var_name = var_name
30
-
31
- self.label = self.label_formatter(var_name, var_value)
32
- if self.label is not None:
33
- self.label_len = len(self.label)
34
-
35
- # label = self.label_formatter(var_name, var_value)
36
- # if label is not None:
37
- # self.label_len = len(label)
38
- # side = pn.pane.Markdown(label, align=align)
39
- # self.append(side)
40
-
41
- def append(self, obj: VideoClip | str) -> None:
42
- if isinstance(obj, VideoClip):
43
- self.container.append(obj)
38
+ def append(self, obj: VideoClip | ImageClip | str | np.ndarray) -> None:
39
+ """Appends an image or video to the container
40
+
41
+ Args:
42
+ obj (VideoClip | ImageClip | str | np.ndarray): Any representation of an image or video
43
+
44
+ Raises:
45
+ RuntimeWarning: if file format is not recognised
46
+ """
47
+
48
+ # print(f"append obj: {type(obj)}, {obj}")
49
+ if obj is not None:
50
+ if isinstance(obj, VideoClip):
51
+ self.container.append(obj)
52
+ elif isinstance(obj, ComposableContainerVideo):
53
+ self.container.append(obj.render())
54
+ elif isinstance(obj, np.ndarray):
55
+ self.container.append(ImageClip(obj))
56
+ else:
57
+ path = Path(obj)
58
+ extension = str.lower(path.suffix)
59
+ if extension in [".jpg", ".jepg", ".png"]:
60
+ self.container.append(ImageClip(obj))
61
+ elif extension in [".mpeg", ".mpg", ".mp4", ".webm"]:
62
+ # print(obj)
63
+ self.container.append(VideoFileClip(obj))
64
+ else:
65
+ raise RuntimeWarning(f"unsupported filetype {extension}")
66
+ else:
67
+ raise RuntimeWarning("No data passed to ComposableContainerVideo.append()")
68
+
69
+ def calculate_duration(self, frames, render_cfg: RenderCfg):
70
+ if render_cfg.duration_target:
71
+ # calculate duration based on fps constraints
72
+ duration = 10.0 if render_cfg.duration is None else render_cfg.duration
73
+ frame_duration = duration / frames
74
+ if render_cfg.min_frame_duration is not None:
75
+ frame_duration = max(frame_duration, render_cfg.min_frame_duration)
76
+ if render_cfg.max_frame_duration is not None:
77
+ frame_duration = min(frame_duration, render_cfg.max_frame_duration)
78
+ duration = frame_duration * frames
44
79
  else:
45
- # if self.label is not None:
46
- # img_obj = np.array(VideoWriter.label_image(obj, self.label))
47
- # else:
48
- # img_obj = obj
49
- self.container.append(ImageClip(obj))
80
+ duration = render_cfg.duration
81
+ frame_duration = duration / float(frames)
82
+
83
+ print("max_frame_duration", render_cfg.max_frame_duration)
84
+ print("DURATION", duration)
85
+
86
+ return duration, frame_duration
87
+
88
+ def render(self, render_cfg: RenderCfg = None, **kwargs) -> CompositeVideoClip:
89
+ """Composes the images/videos into a single image/video based on the type of compose method
50
90
 
51
- def render(self, concatenate: bool = False) -> CompositeVideoClip:
52
- fps = len(self.container) / self.target_duration
53
- fps = max(fps, 1.0) # never slower that 1 seconds per frame
54
- fps = min(fps, 30.0)
91
+ Args:
92
+ compose_method (ComposeType, optional): optionally override the default compose type. Defaults to None.
93
+
94
+ Returns:
95
+ CompositeVideoClip: A composite video clip containing the images/videos added via append()
96
+ """
97
+ if render_cfg is None:
98
+ render_cfg = RenderCfg(**kwargs)
99
+
100
+ print("rc", render_cfg)
101
+ _, frame_duration = self.calculate_duration(float(len(self.container)), render_cfg)
102
+ out = None
103
+ print(f"using compose type{render_cfg.compose_method}")
104
+ max_duration = 0.0
55
105
 
56
106
  for i in range(len(self.container)):
57
- self.container[i].duration = 1.0 / fps
58
- if concatenate:
59
- out = concatenate_videoclips(self.container)
60
- else:
61
- if self.horizontal:
62
- clips = [self.container]
63
- else:
64
- clips = [[c] for c in self.container]
65
- out = clips_array(clips, bg_color=self.background_col)
66
-
67
- if self.label is not None:
68
- label = ImageClip(np.array(VideoWriter.create_label(self.label)))
69
- con2 = ComposableContainerVideo(
70
- background_col=self.background_col,
71
- horizontal=not self.horizontal,
107
+ if self.container[i].duration is None:
108
+ self.container[i].duration = frame_duration
109
+ max_duration = max(max_duration, self.container[i].duration)
110
+ match render_cfg.compose_method:
111
+ case ComposeType.right | ComposeType.down:
112
+ for i in range(len(self.container)):
113
+ self.container[i] = self.extend_clip(self.container[i], max_duration)
114
+ self.container[i] = margin(
115
+ self.container[i], top=render_cfg.margin, color=render_cfg.background_col
116
+ )
117
+
118
+ if render_cfg.compose_method == ComposeType.right:
119
+ clips = [self.container]
120
+ else:
121
+ clips = [[c] for c in self.container]
122
+ out = clips_array(clips, bg_color=render_cfg.background_col)
123
+ if out.duration is None:
124
+ out.duration = max_duration
125
+ case ComposeType.sequence:
126
+ out = concatenate_videoclips(
127
+ self.container, bg_color=render_cfg.background_col, method="compose"
128
+ )
129
+ # case ComposeType.overlay:
130
+ # for i in range(len(self.container)):
131
+ # self.container[i].alpha = 1./len(self.container)
132
+ # out = CompositeVideoClip(self.container, bg_color=render_args.background_col)
133
+ # out.duration = fps
134
+ case _:
135
+ raise RuntimeError("This compose type is not supported")
136
+
137
+ label = self.label_formatter(render_cfg.var_name, render_cfg.var_value)
138
+ if label is not None:
139
+ # print("adding label")
140
+ label = ImageClip(
141
+ np.array(VideoWriter.create_label(label, color=render_cfg.background_col))
72
142
  )
143
+ label.duration = out.duration
144
+ label_compose = ComposeType.down
145
+ if render_cfg.compose_method == ComposeType.down:
146
+ label_compose = ComposeType.right
147
+ con2 = ComposableContainerVideo()
73
148
  con2.append(label)
74
149
  con2.append(out)
75
- return con2.render()
150
+ return con2.render(
151
+ RenderCfg(
152
+ background_col=render_cfg.background_col,
153
+ compose_method=label_compose,
154
+ duration=out.duration,
155
+ duration_target=False, # want exact duration
156
+ )
157
+ )
76
158
  return out
159
+
160
+ def to_video(
161
+ self,
162
+ render_args: RenderCfg = None,
163
+ ) -> str:
164
+ """Returns the composite video clip as a webm file path
165
+
166
+ Returns:
167
+ str: webm filepath
168
+ """
169
+ return VideoWriter().write_video_raw(self.render(render_args))
170
+
171
+ def deep(self):
172
+ return deepcopy(self)
173
+
174
+ def extend_clip(self, clip: VideoClip, desired_duration: float):
175
+ if clip.duration < desired_duration:
176
+ return concatenate_videoclips(
177
+ [
178
+ clip,
179
+ ImageClip(
180
+ clip.get_frame(clip.duration), duration=desired_duration - clip.duration
181
+ ),
182
+ ]
183
+ )
184
+ return clip