summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJames E. Blair <corvus@gnu.org>2019-04-27 16:35:10 (GMT)
committerJames E. Blair <corvus@gnu.org>2019-04-27 16:35:10 (GMT)
commitf166277b69e07a942a70101a8d79032aac6be4d1 (patch)
treede4a4a8e631cbb3c3d36f2ccd9b1c75fb2dd9cb8
Initial commitHEAD0.0.1master
-rw-r--r--.gitignore12
-rw-r--r--.stestr.conf3
-rw-r--r--README.rst72
-rw-r--r--editty/__init__.py0
-rw-r--r--editty/editty.py1134
-rw-r--r--editty/program.py388
-rw-r--r--editty/segment.py290
-rw-r--r--editty/source.py177
-rw-r--r--requirements.txt3
-rw-r--r--setup.cfg44
-rw-r--r--setup.py20
-rw-r--r--test-requirements.txt1
-rw-r--r--tests/__init__.py0
-rw-r--r--tests/test.py191
-rw-r--r--tox.ini8
15 files changed, 2343 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..94096b2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
1*~
2venv/
3venv3/
4.eggs/
5editty.egg-info
6*.pyc
7.tox/
8.stestr/
9AUTHORS
10ChangeLog
11build/
12dist/
diff --git a/.stestr.conf b/.stestr.conf
new file mode 100644
index 0000000..f90b1f5
--- /dev/null
+++ b/.stestr.conf
@@ -0,0 +1,3 @@
1[DEFAULT]
2test_path=tests
3top_dir=./
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..1919b98
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,72 @@
1Editty
2======
3
4Editty is a terminal-based non-linear editor for terminal recordings.
5
6Installation
7------------
8
9Source
10~~~~~~
11
12When installing from source, it is recommended (but not required) to
13install Editty in a virtualenv. To set one up::
14
15 virtualenv --python=python3 editty-env
16 source editty-env/bin/activate
17
18To install the latest version from pypi::
19
20 pip install editty
21
22To install from a git checkout::
23
24 pip install .
25
26In order to use the dissolve transition, you must run Editty in a
27256-color capable terminal, such as gnome-terminal or xterm.
28
29Usage
30-----
31
32After installing Editty, you should be able to run it by invoking
33``editty``. If you installed it in a virtualenv, you can invoke it
34without activating the virtualenv with ``/path/to/editty-venv/bin/editty``.
35which you may wish to add to your shell aliases. Use ``editty
36--help`` to see a list of command line options available.
37
38Once running, pressing the F1 key will show help text.
39
40Editty can load recordings in the formats produced by the ``script``
41and ``ttyrec`` commands. It renders output in ``ttyrec`` format.
42
43Editty does not modify the original source files, but rather
44references them by path name. Editty stores the edit decision list in
45its own JSON-based file format (use the ``.edit`` extension).
46
47To exit, press CTRL-q.
48
49Source
50------
51
52Git repo: http://git.inaugust.com/cgit/editty/
53
54Contributing
55------------
56
57To send your latest commit as a patch, run::
58
59 git send-email --to corvus@gnu.org --annotate -1
60
61Or, if you don't have ``git send-email`` configured, run::
62
63 git format-patch --stdout HEAD~1 > /tmp/editty.patch
64
65And send `/tmp/editty.patch` to corvus@gnu.org using your email
66client.
67
68License
69-------
70
71Editty is licensed under the GPLv3 or later. Please see the COPYING
72file for details.
diff --git a/editty/__init__.py b/editty/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/editty/__init__.py
diff --git a/editty/editty.py b/editty/editty.py
new file mode 100644
index 0000000..cf4449b
--- /dev/null
+++ b/editty/editty.py
@@ -0,0 +1,1134 @@
1# -*- coding: utf-8 -*-
2# Copyright (C) 2019 James E. Blair <corvus@gnu.org>
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17import argparse
18import copy
19import os
20import sys
21import struct
22import time
23import threading
24import json
25import uuid
26try:
27 import queue
28except:
29 import Queue as queue
30import logging
31import math
32
33import urwid
34
35from editty.segment import *
36from editty.program import *
37import editty.source
38
39PALETTE = [
40 ('reversed', 'standout', ''),
41
42 ('timeline-title', 'dark cyan', ''),
43 ('selected-timeline-title', 'light cyan', ''),
44
45 ('timeline-1-text', 'dark cyan', ''),
46 ('timeline-1', 'black', 'dark cyan'),
47 ('timeline-1-selection', 'black,standout', 'dark cyan'),
48 ('selected-timeline-1-text', 'light cyan', ''),
49 ('selected-timeline-1', 'black', 'light cyan'),
50 ('selected-timeline-1-selection', 'black,standout', 'light cyan'),
51
52 ('timeline-2-text', 'dark magenta', ''),
53 ('timeline-2', 'black', 'dark magenta'),
54 ('timeline-2-selection', 'black,standout', 'dark magenta'),
55 ('selected-timeline-2-text', 'light magenta', ''),
56 ('selected-timeline-2', 'black', 'light magenta'),
57 ('selected-timeline-2-selection', 'black,standout', 'light magenta'),
58
59 ('timeline-3-text', 'dark green', ''),
60 ('timeline-3', 'black', 'dark green'),
61 ('timeline-3-selection', 'black,standout', 'dark green'),
62 ('selected-timeline-3-text', 'light green', ''),
63 ('selected-timeline-3', 'black', 'light green'),
64 ('selected-timeline-3-selection', 'black,standout', 'light green'),
65
66 ('timeline-4-text', 'brown', ''),
67 ('timeline-4', 'black', 'brown'),
68 ('timeline-4-selection', 'black,standout', 'brown'),
69 ('selected-timeline-4-text', 'yellow', ''),
70 ('selected-timeline-4', 'black', 'yellow'),
71 ('selected-timeline-4-selection', 'black,standout', 'yellow'),
72
73 ('timeline-black-text', 'dark gray', ''),
74 ('timeline-black', 'black', 'dark gray'),
75 ('timeline-black-selection', 'black,standout', 'dark gray'),
76 ('selected-timeline-black-text', 'light gray', ''),
77 ('selected-timeline-black', 'black', 'light gray'),
78 ('selected-timeline-black-selection', 'black,standout', 'light gray'),
79
80 ('start-timecode', 'dark cyan', ''),
81 ('selected-start-timecode', 'light cyan', ''),
82 ('current-timecode', 'light red', ''),
83 ('end-timecode', 'dark cyan', ''),
84 ('selected-end-timecode', 'light cyan', ''),
85]
86
87FOCUS_MAP = {}
88for x in PALETTE:
89 name = x[0]
90 if 'selected-'+name in [y[0] for y in PALETTE]:
91 FOCUS_MAP[name]='selected-'+name
92
93class Monitor(urwid.Widget):
94 _selectable = False
95 _sizing = frozenset([urwid.widget.FIXED])
96
97 signals = ['closed', 'beep', 'leds', 'title']
98
99 def __init__(self, editor, size):
100 self.log = logging.getLogger('monitor')
101 self.editor = editor
102 self.size = size
103 self.term_modes = urwid.TermModes()
104 self.term = urwid.TermCanvas(size[0], size[1], self)
105 self.term.modes.main_charset = urwid.vterm.CHARSET_UTF8
106 self.off_canvas = urwid.TextCanvas(maxcol=size[0], text=[b'' for x in range(size[1])])
107 self.off_canvas.cacheable = False
108 self.off = False
109
110 def render(self, size, focus=False):
111 if self.off:
112 return self.off_canvas
113 else:
114 return self.term
115
116 def pack(self, size, focus=False):
117 self.log.debug("pack")
118 return self.size
119
120 def rows(self, size, focus=False):
121 return self.size[1]
122
123 def keypress(self, size, key):
124 return None
125
126 def beep(self):
127 self._emit('beep')
128
129 def set_title(self, title):
130 self._emit('title', title)
131
132 def setFrame(self, frame):
133 self.term.term = [line[:] for line in frame.content]
134 self.term.set_term_cursor(frame.cursor[0], frame.cursor[1])
135 self.editor.loop.draw_screen()
136
137 def setTimecode(self, start, current, end):
138 if start is not None:
139 self._setTimecode(self.editor.screen.start_timecode,
140 'selected-start-timecode', start)
141 else:
142 self._setTimecode(self.editor.screen.start_timecode,
143 'start-timecode', 0.0)
144 self._setTimecode(self.editor.screen.timecode,
145 'current-timecode', current)
146 if end is not None:
147 self._setTimecode(self.editor.screen.end_timecode,
148 'selected-end-timecode', end)
149 else:
150 self._setTimecode(self.editor.screen.end_timecode,
151 'end-timecode', 0.0)
152
153 def _setTimecode(self, widget, attr, seconds):
154 hours, seconds = divmod(seconds, 60*60)
155 minutes, seconds = divmod(seconds, 60)
156 tc = '%02i:%02i:%09.6f' % (hours, minutes, seconds)
157 widget.set_text((attr, tc))
158
159class Timeline(urwid.Text):
160 _selectable = True
161
162 def __init__(self):
163 super(Timeline, self).__init__('')
164 self.log = logging.getLogger('timeline')
165 self.uuid = str(uuid.uuid4())
166 self.set_text('AB'*60)
167 self.log.debug("init")
168 self.scale = 1.0 # one second per char
169 self.hoffset = 0
170 self.current_width = 0
171 self.framecounts = []
172 self.framecolors = []
173 self.start_time = None
174 self.end_time = None
175 self.current_time = 0.0
176 self.current_frame = None
177 self.monitor = None
178 self.play_queue = queue.Queue()
179 self._thread = threading.Thread(target=self.run)
180 self._thread.daemon=True
181 self._thread.start()
182 self.playing = False
183 self.color = 'timeline-black'
184
185 def toJSON(self):
186 if self.program:
187 program = self.program.uuid
188 else:
189 program = None
190 return dict(uuid=self.uuid,
191 color=self.color,
192 program=program)
193
194 @classmethod
195 def fromJSON(cls, data):
196 t = Timeline()
197 t.uuid = data['uuid']
198 t.color = data['color']
199 return t
200
201 def setProgram(self, program):
202 self.program = program
203 self.setScale(self.scale)
204 for fi in program:
205 self.color = fi.frame.timeline_color
206 break
207
208 def setMonitor(self, monitor):
209 self.monitor = monitor
210 self.updateMonitor()
211
212 def updateMonitor(self, update_frame=True):
213 if not self.monitor:
214 return
215 if update_frame and self.current_frame:
216 self.monitor.setFrame(self.current_frame.frame)
217 self.monitor.setTimecode(self.start_time, self.current_time, self.end_time)
218 screen = self.monitor.editor.screen
219 if self.current_frame:
220 screen.md_segment.set_text(str(self.current_frame.segment_index+1))
221 screen.md_type.set_text(self.current_frame.segment.__class__.__name__)
222 if hasattr(self.current_frame.segment, 'source'):
223 screen.md_source.set_text(self.current_frame.segment.source.title)
224 else:
225 screen.md_source.set_text('')
226 screen.md_segment_start.set_text('%0.6f' % (self.current_frame.segment.start))
227 screen.md_segment_end.set_text('%0.6f' % (self.current_frame.segment.end))
228 screen.md_segment_duration.set_text('%0.6f' % (self.current_frame.segment.duration))
229 screen.md_frame.set_text(str(self.current_frame.frame_index+1))
230 screen.md_cursor.set_text(self.current_frame.segment.visible_cursor and 'visible' or 'hidden')
231
232 def setScale(self, scale):
233 self.scale = scale
234 count = int(math.ceil(self.program.length / self.scale))
235 self.framecounts = [0 for x in range(count)]
236 self.framecolors = ['' for x in range(count)]
237 elapsed = 0.0
238 for fi in self.program:
239 pos = int(math.floor(fi.timecode / self.scale))
240 self.framecounts[pos] += 1
241 self.framecolors[pos] = fi.frame.timeline_color
242 for i in range(len(self.framecolors)):
243 if i and not self.framecolors[i]:
244 self.framecolors[i] = self.framecolors[i-1]
245 self.set_text('')
246
247 def pack(self, size, focus=False):
248 self.log.debug("pack %s %s", size, focus)
249
250 def rows(self, size, focus=False):
251 return 1
252
253 def render(self, size, focus=False):
254 title = '%-10s' % self.program.title[:10]
255 title_len = len(title)
256 text_attr = self.color + '-text'
257 s = [(text_attr, title)]
258 current_pos = int(math.floor(self.current_time / self.scale))
259 start_pos = end_pos = current_pos
260
261 range_points = 0
262 if self.start_time is not None:
263 start_pos = int(math.floor(self.start_time / self.scale))
264 range_points += 1
265 if self.end_time is not None:
266 end_pos = int(math.floor(self.end_time / self.scale))
267 range_points += 1
268 selection_range = sorted([start_pos, end_pos])
269
270 self.current_width = size[0]-title_len-2
271 if current_pos > self.current_width+self.hoffset-1:
272 self.hoffset = current_pos-self.current_width+1
273 if current_pos < self.hoffset:
274 self.hoffset = current_pos
275 left_arrow = self.hoffset>0 and '<' or ' '
276 right_arrow = self.hoffset+self.current_width<len(self.framecounts) and '>' or ' '
277 s.append((text_attr, left_arrow))
278 for i in range(self.hoffset, self.hoffset+self.current_width):
279 if i < len(self.framecounts):
280 if self.framecounts[i] > 1:
281 char = '•' #▪▬■·•‧
282 elif self.framecounts[i] > 0:
283 char = '‧'
284 else:
285 char = ' '
286 attr = self.framecolors[i] or self.color
287 else:
288 char = ' '
289 attr = ''
290 if selection_range[0] <= i < selection_range[1]:
291 if range_points == 2 and i == current_pos:
292 pass
293 else:
294 attr += '-selection'
295 elif i == current_pos:
296 attr += '-selection'
297 s.append((attr, char))
298 s.append((text_attr, right_arrow))
299 self.set_text(s)
300 return super(Timeline, self).render(size, focus)
301
302 def updateCurrentFrame(self):
303 pass
304
305 def move(self, offset):
306 self.log.debug('move %s time %s', offset, self.current_time)
307 if abs(offset) >= 1:
308 new_time = self.current_time + (offset * self.scale)
309 else:
310 new_time = self.current_time + offset
311 new_time = max(0.0, min(self.program.length, new_time))
312 if abs(offset) >= 1:
313 new_time = math.floor(new_time / self.scale) * self.scale
314 # Find the closest frame no later than the new time
315 (prv, cur, nxt) = self.program.getFramesAtTimecode(new_time)
316 self.log.debug('move %s %s %s', prv, cur, nxt)
317 self.current_time = new_time
318 if cur is None:
319 if prv is None:
320 self.current_frame = None
321 else:
322 self.current_frame = prv
323 else:
324 self.current_frame = cur
325 # If our cell has at least one frame in it, jump ahead to the
326 # first frame.
327 if abs(offset) >= 1 and self.current_time > cur.timecode and nxt:
328 if self.current_time + self.scale > nxt.timecode:
329 self.current_time = nxt.timecode
330 self.current_frame = nxt
331 self.updateMonitor()
332 self.set_text('')
333 self.log.debug('move %s time %s', offset, self.current_time)
334
335 def setStart(self):
336 self.start_time = self.current_time
337 self.set_text('')
338 self.updateMonitor(update_frame=False)
339
340 def setEnd(self):
341 if self.current_time + self.scale > self.program.length:
342 # If we're in the last cell, the user probably meant to go
343 # to the end.
344 self.end_time = self.program.length
345 else:
346 self.end_time = self.current_time
347 self.set_text('')
348 self.updateMonitor(update_frame=False)
349
350 def clearSelection(self):
351 self.start_time = None
352 self.end_time = None
353 self.set_text('')
354 self.updateMonitor(update_frame=False)
355
356 # Edit
357 def cut(self):
358 self.log.debug("cut")
359 if self.start_time is None or self.end_time is None:
360 return
361 self.monitor.editor.saveUndo("Cut", (self.program,))
362 saved = self.program.cut(self.start_time, self.end_time)
363 self.monitor.editor.setClipboard(saved)
364 if self.current_time > self.end_time:
365 self.current_time -= (self.end_time - self.start_time)
366 elif self.current_time > self.start_time:
367 self.current_time = self.start_time
368 self.clearSelection()
369 self.setScale(self.scale)
370 self.move(0)
371
372 def insert(self):
373 self.log.debug("insert")
374 if self.monitor.editor.clipboard is None:
375 return
376 self.monitor.editor.saveUndo("Insert", (self.program,))
377 self.program.insert(self.current_time, self.monitor.editor.clipboard)
378 self.setScale(self.scale)
379 self.move(0)
380
381 def append(self):
382 self.log.debug("append")
383 if self.monitor.editor.clipboard is None:
384 return
385 self.monitor.editor.saveUndo("Append", (self.program,))
386 self.program.append(self.monitor.editor.clipboard)
387 self.setScale(self.scale)
388 self.move(0)
389
390 def dissolve(self):
391 self.log.debug("dissolve")
392 if self.start_time is None or self.end_time is None:
393 return
394 self.monitor.editor.saveUndo("Dissolve", (self.program,))
395 try:
396 self.program.dissolve(self.start_time, self.end_time)
397 self.clearSelection()
398 self.setScale(self.scale)
399 self.move(0)
400 except Exception as e:
401 self.monitor.editor.undo()
402 self.monitor.editor.message("Error", str(e))
403
404 def toggleCursor(self):
405 self.log.debug("cursor")
406 self.monitor.editor.saveUndo("Toggle Cursor", (self.program,))
407 self.current_frame.segment.visible_cursor = not self.current_frame.segment.visible_cursor
408 self.move(0)
409
410 # Playback
411 def play(self):
412 self.log.debug("play")
413 self.playing = True
414 if self.end_time is not None:
415 end = self.end_time
416 else:
417 end = self.program.length
418 if self.start_time is not None:
419 start = self.start_time
420 else:
421 if self.current_time == end:
422 start = 0.0
423 else:
424 start = self.current_time
425 self.play_queue.put((start, end))
426
427 def stop(self):
428 self.log.debug("stop")
429 self.playing = False
430
431 # Playback thread
432 def run(self):
433 while True:
434 item = self.play_queue.get()
435 if item is None:
436 return
437 self._runPlay(item)
438 self.playing = False
439
440 def _runClock(self, start_wallclock_time, start_timecode, end_delay):
441 while True:
442 if not self.playing: return
443 cur_wallclock_time = time.time()
444 if cur_wallclock_time >= end_delay: break
445 elapsed_wallclock_time = cur_wallclock_time - start_wallclock_time
446 self.current_time = start_timecode + elapsed_wallclock_time
447 self.updateMonitor(update_frame=False)
448 self.set_text('')
449 self.monitor.editor.loop.draw_screen()
450 time.sleep(0)
451
452 def _runPlay(self, item):
453 start_timecode, end_timecode = item
454 self.log.debug("play %s %s", start_timecode, end_timecode)
455 self.current_time = start_timecode
456 self.move(0)
457 self.monitor.editor.loop.draw_screen()
458 start_wallclock_time = time.time()
459 first = True
460 for fi in self.program.getFrames(start_timecode, end_timecode):
461 end_delay = start_wallclock_time + fi.timecode - start_timecode
462 self._runClock(start_wallclock_time, start_timecode, end_delay)
463 if not self.playing: return
464 self.current_frame = fi
465 if not first:
466 self.monitor.setFrame(self.current_frame.frame)
467 self.monitor.editor.loop.draw_screen()
468 else:
469 first = False
470 end_delay = start_wallclock_time + end_timecode - start_timecode
471 self._runClock(start_wallclock_time, start_timecode, end_delay)
472 if not self.playing: return
473 self.current_time = end_timecode
474 self.updateMonitor()
475 self.monitor.editor.loop.draw_screen()
476 self.log.debug("done play %s", self.current_time)
477
478 def keypress(self, size, key):
479 self.log.debug(repr(key))
480 if self.playing and key != ' ':
481 return None
482 if key == (' '):
483 if self.playing:
484 self.stop()
485 else:
486 self.play()
487 elif key == '[':
488 self.setStart()
489 elif key == ']':
490 self.setEnd()
491 elif key == 'esc':
492 self.clearSelection()
493 elif key == 'right':
494 self.move(1)
495 elif key == 'meta right':
496 self.move(10)
497 elif key == 'shift right':
498 self.move(0.01)
499 elif key == 'left':
500 self.move(-1)
501 elif key == 'meta left':
502 self.move(-10)
503 elif key == 'shift left':
504 self.move(-0.01)
505 elif key == '=':
506 self.setScale(self.scale / 2)
507 elif key == '-':
508 self.setScale(self.scale * 2)
509 elif key == 'x':
510 self.cut()
511 elif key == 'i':
512 self.insert()
513 elif key == 'a':
514 self.append()
515 elif key == 'd':
516 self.dissolve()
517 elif key == 'C':
518 self.toggleCursor()
519 elif key == 'ctrl r':
520 self.monitor.editor.render(self.program)
521 return key
522
523class Screen(urwid.WidgetWrap):
524 def __init__(self, editor, size):
525 super(Screen, self).__init__(urwid.Pile([]))
526 self.log = logging.getLogger('screen')
527 self.editor = editor
528 self.size = size
529
530 self.monitor = Monitor(editor, self.size)
531 self.start_timecode = urwid.Text('00:00:00.000000', align='center')
532 self.timecode = urwid.Text('00:00:00.000000', align='center')
533 self.end_timecode = urwid.Text('00:00:00.000000', align='center')
534 self.timecode_cols = urwid.Columns([
535 ('weight', 1, urwid.Text(' ')),
536 (15, self.start_timecode),
537 (17, urwid.Text(' ')),
538 (15, self.timecode),
539 (17, urwid.Text(' ')),
540 (15, self.end_timecode),
541 ('weight', 1, urwid.Text(' ')),
542 ])
543 self.timeline = None
544 self.md_segment = urwid.Text('')
545 self.md_type = urwid.Text('')
546 self.md_source = urwid.Text('')
547 self.md_segment_start = urwid.Text('')
548 self.md_segment_end = urwid.Text('')
549 self.md_segment_duration = urwid.Text('')
550 self.md_frame = urwid.Text('')
551 self.md_cursor = urwid.Text('')
552 metadata_pile = urwid.Pile([
553 ('pack', urwid.Columns([(11, urwid.Text('Segment: ')), ('weight', 1, self.md_segment)])),
554 ('pack', urwid.Columns([(11, urwid.Text('Type: ')), ('weight', 1, self.md_type)])),
555 ('pack', urwid.Columns([(11, urwid.Text('Source: ')), ('weight', 1, self.md_source)])),
556 ('pack', urwid.Columns([(11, urwid.Text('Start: ')), ('weight', 1, self.md_segment_start)])),
557 ('pack', urwid.Columns([(11, urwid.Text('End: ')), ('weight', 1, self.md_segment_end)])),
558 ('pack', urwid.Columns([(11, urwid.Text('Duration: ')), ('weight', 1, self.md_segment_duration)])),
559 ('pack', urwid.Columns([(11, urwid.Text('Frame: ')), ('weight', 1, self.md_frame)])),
560 ('pack', urwid.Columns([(11, urwid.Text('Cursor: ')), ('weight', 1, self.md_cursor)])),
561 ])
562 program_box = urwid.LineBox(self.monitor, "Monitor")
563 metadata_box = urwid.LineBox(metadata_pile)
564 border = 2
565 monitor_columns = urwid.Columns([
566 #('weight', 1, urwid.Filler(urwid.Text(''))),
567 (self.size[0]+border, program_box),
568 metadata_box,
569 #('weight', 1, urwid.Filler(urwid.Text(''))),
570 ])
571 lw = urwid.SimpleFocusListWalker([])
572 self.timelines = urwid.ListBox(lw)
573 urwid.connect_signal(lw, 'modified', self._updateFocus)
574 self.main = urwid.Pile([])
575 self.main.contents.append((monitor_columns, ('given', self.size[1]+border)))
576 self.main.contents.append((self.timecode_cols, ('pack', None)))
577 self.main.contents.append((self.timelines, ('weight', 1)))
578 self.main.contents.append((urwid.Filler(urwid.Text(u'')), ('weight', 1)))
579 self.main.set_focus(2)
580
581 self._w.contents.append((self.main, ('weight', 1)))
582 self._w.set_focus(0)
583
584 def addTimeline(self, program, timeline=None):
585 if timeline is None:
586 timeline = Timeline()
587 timeline.setProgram(program)
588 timeline.setMonitor(self.monitor)
589 w = urwid.AttrMap(timeline, None, focus_map=FOCUS_MAP)
590 self.timelines.body.append(w)
591 self.timeline = timeline
592 self.timelines.set_focus(len(self.timelines.body)-1)
593
594 def getTimelines(self):
595 for w in self.timelines.body:
596 yield w.original_widget
597
598 def removeTimeline(self, timeline):
599 if timeline == self.timeline:
600 self.timeline = None
601 for w in self.timelines.body:
602 if w.original_widget == timeline:
603 self.timelines.body.remove(w)
604 return
605
606 def _updateFocus(self):
607 self.log.debug("update focus")
608 if self.timeline:
609 self.log.debug("clear monitor %s", self.timeline)
610 self.timeline.setMonitor(None)
611 if self.timelines.focus:
612 self.timeline = self.timelines.focus.original_widget
613 self.log.debug("set focus %s", self.timeline)
614 self.timeline.setMonitor(self.monitor)
615 self.log.debug("set monitor %s", self.timeline)
616 else:
617 self.log.debug("no timeline in focus")
618
619 def keypress(self, size, key):
620 self.log.debug(repr(key))
621 if self.timeline and self.timeline.playing and key != ' ':
622 return None
623 if key == 'ctrl l':
624 self.editor.load()
625 elif key == 'ctrl o':
626 self.editor.open()
627 elif key == 'ctrl _':
628 self.editor.undo()
629 elif key == 'ctrl s':
630 self.editor.save()
631 return super(Screen, self).keypress(size, key)
632
633class MainLoop(urwid.MainLoop):
634 def __init__(self, *args, **kw):
635 self._screen_lock = threading.RLock()
636 super(MainLoop, self).__init__(*args, **kw)
637
638 def draw_screen(self):
639 with self._screen_lock:
640 super(MainLoop, self).draw_screen()
641
642class UndoRecord:
643 def __init__(self, description, programs):
644 self.description = description
645 self.programs = programs
646
647class FixedButton(urwid.Button):
648 def sizing(self):
649 return frozenset([urwid.FIXED])
650
651 def pack(self, size, focus=False):
652 return (len(self.get_label())+4, 1)
653
654class LoadDialog(urwid.WidgetWrap):
655 def __init__(self, editor):
656 self.editor = editor
657 self.ok = FixedButton("Load")
658 self.cancel = FixedButton("Cancel")
659 urwid.connect_signal(self.ok, 'click', editor._finishLoad, self)
660 urwid.connect_signal(self.cancel, 'click', editor._clearPopup, self)
661
662 file_types = []
663 self.type_buttons = []
664 self.button_types = {}
665 for ft in editty.source.all_types:
666 b = urwid.RadioButton(file_types, ft.name)
667 self.type_buttons.append(b)
668 self.button_types[b] = ft
669 for b in self.type_buttons:
670 urwid.connect_signal(b, 'postchange', self.setType)
671 self.current_type = None
672
673 buttons = urwid.Columns([
674 ('pack', self.ok),
675 ('pack', urwid.Text(' ')),
676 ('pack', self.cancel),
677 ])
678 self.stream_file = urwid.Edit("")
679 self.timing_file = urwid.Edit("Timing file: ")
680 self.listbox = urwid.ListBox([
681 urwid.Text('File type:'),
682 ] + self.type_buttons + [
683 urwid.Text(''),
684 self.stream_file,
685 urwid.Text(''),
686 buttons,
687 ])
688 self.setType(self.current_type, self)
689 super(LoadDialog, self).__init__(urwid.LineBox(self.listbox, 'Load'))
690
691 def setType(self, button, dialog):
692 # Only handle the second event
693 selected = [b for b in self.type_buttons if b.state]
694 if len(selected) > 1:
695 return
696
697 selected = selected[0]
698 self.current_type = self.button_types[selected]
699
700 if not self.current_type.timing:
701 if self.timing_file in self.listbox.body:
702 self.listbox.body.remove(self.timing_file)
703 else:
704 if self.timing_file not in self.listbox.body:
705 loc = self.listbox.body.index(self.stream_file)
706 self.listbox.body.insert(loc+1, self.timing_file)
707 self.stream_file.set_caption('%s file: ' % self.current_type.name)
708
709 def keypress(self, size, key):
710 if key == 'esc':
711 self.editor._clearPopup()
712 return None
713 if key == 'enter':
714 if(self.current_type.timing and
715 self.listbox.focus is self.stream_file):
716 return self.keypress(size, 'down')
717 if(self.listbox.focus is self.timing_file or
718 self.listbox.focus is self.stream_file):
719 return self.ok.keypress(size, key)
720 return super(LoadDialog, self).keypress(size, key)
721
722class SaveDialog(urwid.WidgetWrap):
723 def __init__(self, editor):
724 self.editor = editor
725 self.ok = FixedButton("Save")
726 self.cancel = FixedButton("Cancel")
727 urwid.connect_signal(self.ok, 'click', editor._finishSave, self)
728 urwid.connect_signal(self.cancel, 'click', editor._clearPopup, self)
729 buttons = urwid.Columns([
730 ('pack', self.ok),
731 ('pack', urwid.Text(' ')),
732 ('pack', self.cancel),
733 ])
734 self.project_file = urwid.Edit("Project file: ")
735 self.listbox = urwid.ListBox([
736 self.project_file,
737 urwid.Text(''),
738 buttons,
739 ])
740 super(SaveDialog, self).__init__(urwid.LineBox(self.listbox, 'Save'))
741
742 def keypress(self, size, key):
743 if key == 'esc':
744 self.editor._clearPopup()
745 return None
746 if key == 'enter':
747 if(self.listbox.focus is self.project_file):
748 return self.ok.keypress(size, key)
749 return super(SaveDialog, self).keypress(size, key)
750
751class OpenDialog(urwid.WidgetWrap):
752 def __init__(self, editor):
753 self.editor = editor
754 self.ok = FixedButton("Open")
755 self.cancel = FixedButton("Cancel")
756 urwid.connect_signal(self.ok, 'click', editor._finishOpen, self)
757 urwid.connect_signal(self.cancel, 'click', editor._clearPopup, self)
758 buttons = urwid.Columns([
759 ('pack', self.ok),
760 ('pack', urwid.Text(' ')),
761 ('pack', self.cancel),
762 ])
763 self.project_file = urwid.Edit("Project file: ")
764 self.listbox = urwid.ListBox([
765 self.project_file,
766 urwid.Text(''),
767 buttons,
768 ])
769 super(OpenDialog, self).__init__(urwid.LineBox(self.listbox, 'Open'))
770
771 def keypress(self, size, key):
772 if key == 'esc':
773 self.editor._clearPopup()
774 return None
775 if key == 'enter':
776 if(self.listbox.focus is self.project_file):
777 return self.ok.keypress(size, key)
778 return super(OpenDialog, self).keypress(size, key)
779
780class RenderDialog(urwid.WidgetWrap):
781 def __init__(self, editor, program):
782 self.editor = editor
783 self.program = program
784 self.ok = FixedButton("Render")
785 self.cancel = FixedButton("Cancel")
786 urwid.connect_signal(self.ok, 'click', editor._finishRender, self)
787 urwid.connect_signal(self.cancel, 'click', editor._clearPopup, self)
788 buttons = urwid.Columns([
789 ('pack', self.ok),
790 ('pack', urwid.Text(' ')),
791 ('pack', self.cancel),
792 ])
793 self.ttyrec_file = urwid.Edit("Ttyrec file: ")
794 self.listbox = urwid.ListBox([
795 self.ttyrec_file,
796 urwid.Text(''),
797 buttons,
798 ])
799 super(RenderDialog, self).__init__(urwid.LineBox(self.listbox, 'Render'))
800
801 def keypress(self, size, key):
802 if key == 'esc':
803 self.editor._clearPopup()
804 return None
805 if key == 'enter':
806 if(self.listbox.focus is self.ttyrec_file):
807 return self.ok.keypress(size, key)
808 return super(RenderDialog, self).keypress(size, key)
809
810class QuitDialog(urwid.WidgetWrap):
811 def __init__(self, editor):
812 self.editor = editor
813 self.yes = FixedButton("Yes")
814 self.no = FixedButton("No")
815 urwid.connect_signal(self.yes, 'click', editor._quit, self)
816 urwid.connect_signal(self.no, 'click', editor._clearPopup, self)
817 buttons = urwid.Columns([
818 ('pack', self.yes),
819 ('pack', urwid.Text(' ')),
820 ('pack', self.no),
821 ])
822 self.listbox = urwid.ListBox([
823 urwid.Text('Are you sure you want to quit?\n'),
824 buttons,
825 ])
826 super().__init__(urwid.LineBox(self.listbox, 'Quit'))
827
828 def keypress(self, size, key):
829 if key == 'esc':
830 self.editor._clearPopup()
831 return None
832 return super().keypress(size, key)
833
834class MessageDialog(urwid.WidgetWrap):
835 def __init__(self, editor, title, message):
836 self.editor = editor
837 ok = FixedButton("OK")
838 urwid.connect_signal(ok, 'click', editor._clearPopup, self)
839 buttons = urwid.Columns([
840 ('pack', ok),
841 ])
842 listbox = urwid.ListBox([
843 urwid.Text(message),
844 urwid.Text(''),
845 buttons,
846 ])
847 super(MessageDialog, self).__init__(urwid.LineBox(listbox, title))
848
849 def keypress(self, size, key):
850 if key == 'esc':
851 self.editor._clearPopup()
852 return None
853 return super().keypress(size, key)
854
855class Editor(object):
856 def __init__(self, args):
857 self.size = (args.width, args.height)
858 if args.debuglog:
859 logfile = os.path.expanduser(args.debuglog)
860 logging.basicConfig(filename=logfile, level=logging.DEBUG)
861 self.log = logging.getLogger('editor')
862 self.screen = Screen(self, self.size)
863 self.loop = MainLoop(self.screen, palette=PALETTE,
864 unhandled_input=self.unhandledInput)
865 self.loop.screen.tty_signal_keys(start='undefined', stop='undefined')
866 self.loop.screen.set_terminal_properties(colors=256)
867 self.loop.screen.start()
868 self.clipboard = None
869 self.programs = []
870 self.undo_history = []
871 self.timeline_color_generator = self._timelineColorGenerator()
872 self.output_program = Program('Output')
873 if args.project:
874 self._open(args.project)
875
876 def _timelineColorGenerator(self):
877 while True:
878 for i in range(1, 5):
879 yield 'timeline-%i' % i
880
881 def setSize(self, size):
882 self.size = size
883 self.screen = Screen(self, self.size)
884 self.loop.widget = self.screen
885
886 def help(self):
887 msg = ('Use the arrow keys to move left and right in the timeline. '
888 'Use meta-arrows to move longer distances and shift-arrows '
889 'to move shorter distances.\n\n')
890 for key, desc in [
891 ('CTRL-l', 'Load terminal recording'),
892 ('CTRL-o', 'Open Editty program file'),
893 ('CTRL-s', 'Save Editty program file'),
894 ('CTRL-r', 'Render'),
895 ('CTRL-_', 'Undo'),
896 ('CTRL-q', 'Quit'),
897 ('SPACE', 'Play'),
898 ('[', 'Set selection start'),
899 (']', 'Set selection end'),
900 ('ESC', 'Clear start/end points'),
901 ('ARROW', 'Move left/right'),
902 ('META-ARROW', 'Move 10x left/right'),
903 ('SHIFT-ARROW', 'Move 0.01x left/right'),
904 ('=', 'Zoom in timescale'),
905 ('-', 'Zoom out timescale'),
906 ('x', 'Cut selection to clipboard'),
907 ('a', 'Append clipboard contents to end of timeline'),
908 ('i', 'Insert clipboard contents to end of timeline'),
909 ('d', 'Dissolve between start and end of selection'),
910 ('C', 'Toggle whether cursor is visible in this segment'),
911 ]:
912 msg += '%-11s %s\n' % (key, desc)
913 self.message("Help", msg, min_width=60, width=30, height=50)
914
915 def unhandledInput(self, key):
916 if key == 'f1':
917 self.help()
918 elif key == 'ctrl q':
919 self.quit()
920
921 def quit(self):
922 dialog = QuitDialog(self)
923 self.screen.monitor.off = True
924 overlay = urwid.Overlay(dialog, self.screen,
925 'center', ('relative', 50),
926 'middle', ('relative', 25))
927 self.loop.widget = overlay
928
929 def _quit(self, *args, **kw):
930 raise urwid.ExitMainLoop()
931
932 def saveUndo(self, description, programs):
933 # Make an undo record, programs is a list of programs which
934 # are about to change.
935 saved_programs = []
936 program_timelines = {}
937 for timeline in self.screen.getTimelines():
938 self.log.debug("save undo %s %s", timeline.program, timeline.uuid)
939 if timeline.program:
940 program_timelines[timeline.program] = timeline.uuid
941 for p in self.programs:
942 timeline_uuid = program_timelines.get(p)
943 if p in programs:
944 p = p.copy()
945 saved_programs.append((p, timeline_uuid))
946 ur = UndoRecord(description, saved_programs)
947 self.undo_history.append(ur)
948
949 def undo(self):
950 undorecord = self.undo_history.pop()
951 current_timelines = set(self.screen.getTimelines())
952 self.programs = []
953 self.log.debug("undo %s", undorecord.description)
954 self.log.debug("undo %s", undorecord.programs)
955 self.log.debug("current %s", current_timelines)
956 for (program, timeline_uuid) in undorecord.programs:
957 self.programs.append(program)
958 for t in current_timelines:
959 self.log.debug("undo %s %s", t.uuid, timeline_uuid)
960 if t.uuid == timeline_uuid:
961 self.log.debug("found")
962 current_timelines.remove(t)
963 t.setProgram(program)
964 break
965 else:
966 self.log.debug("undo added new timeline for %s", program)
967 self.screen.addTimeline(program)
968 for t in current_timelines:
969 self.log.debug("removed unused timeline %s", t)
970 self.screen.removeTimeline(t)
971
972 def setClipboard(self, program):
973 self.clipboard = program
974
975 def run(self):
976 self.loop.run()
977
978 def _clearPopup(self, *args, **kw):
979 self.screen.monitor.off = False
980 self.loop.widget = self.screen
981 self.loop.draw_screen()
982
983 def message(self, title, message, width=50, height=25,
984 min_width=None, min_height=None):
985 dialog = MessageDialog(self, title, message)
986 self.screen.monitor.off = True
987 overlay = urwid.Overlay(dialog, self.screen,
988 'center', ('relative', width),
989 'middle', ('relative', height),
990 min_width=min_width,
991 min_height=min_height)
992
993 self.loop.widget = overlay
994
995 def load(self):
996 dialog = LoadDialog(self)
997 self.screen.monitor.off = True
998 overlay = urwid.Overlay(dialog, self.screen,
999 'center', ('relative', 50),
1000 'middle', ('relative', 25))
1001 self.loop.widget = overlay
1002
1003 def _load(self, file_type, stream_file, timing_file, program=None):
1004 color = next(self.timeline_color_generator)
1005 source = file_type.load(self.size, stream_file, timing_file, color)
1006 if program is None:
1007 program = Program(source.title)
1008 program.append(Clip(source, 0.0, source.length))
1009 self.saveUndo("Load %s" % stream_file, [])
1010 self.programs.append(program)
1011 self.screen.addTimeline(program)
1012
1013 def _finishLoad(self, button, dialog):
1014 self._clearPopup()
1015 try:
1016 self._load(dialog.current_type,
1017 dialog.stream_file.edit_text,
1018 dialog.timing_file.edit_text)
1019 except Exception as e:
1020 self.message("Error", str(e))
1021
1022 def save(self):
1023 dialog = SaveDialog(self)
1024 self.screen.monitor.off = True
1025 overlay = urwid.Overlay(dialog, self.screen,
1026 'center', ('relative', 50),
1027 'middle', ('relative', 25))
1028 self.loop.widget = overlay
1029
1030 def _save(self, project_file):
1031 data = {'version': 1}
1032 data['size'] = self.size
1033 data['sources'] = []
1034 data['programs'] = []
1035 data['timelines'] = []
1036 sources = set()
1037 programs = set()
1038 for timeline in self.screen.getTimelines():
1039 data['timelines'].append(timeline.toJSON())
1040 if timeline.program:
1041 programs.add(timeline.program)
1042 for program in programs:
1043 data['programs'].append(program.toJSON())
1044 for segment in program.segments:
1045 if hasattr(segment, 'source'):
1046 sources.add(segment.source)
1047 for source in sources:
1048 data['sources'].append(source.toJSON())
1049 with open(project_file, 'w') as f:
1050 f.write(json.dumps(data))
1051
1052 def _finishSave(self, button, dialog):
1053 pf = dialog.project_file.edit_text
1054 self._clearPopup()
1055 self.log.debug("save %s", pf)
1056 try:
1057 self._save(pf)
1058 except Exception as e:
1059 self.message("Error", str(e))
1060
1061 def open(self):
1062 dialog = OpenDialog(self)
1063 self.screen.monitor.off = True
1064 overlay = urwid.Overlay(dialog, self.screen,
1065 'center', ('relative', 50),
1066 'middle', ('relative', 25))
1067 self.loop.widget = overlay
1068
1069 def _open(self, project_file):
1070 with open(project_file, 'r') as f:
1071 data = json.loads(f.read())
1072
1073 for timeline in list(self.screen.getTimelines()):
1074 self.screen.removeTimeline(timeline)
1075 size = data['size']
1076 self.setSize(size)
1077 sources = {}
1078 for source in data['sources']:
1079 sc = editty.source.SourceClip.fromJSON(source)
1080 sources[sc.uuid] = sc
1081 programs = {}
1082 for program in data['programs']:
1083 p = Program.fromJSON(program, sources)
1084 programs[p.uuid] = p
1085 self.programs.append(p)
1086 for timeline in data['timelines']:
1087 t = Timeline.fromJSON(timeline)
1088 program = programs.get(timeline['program'])
1089 self.screen.addTimeline(program, timeline=t)
1090
1091 def _finishOpen(self, button, dialog):
1092 pf = dialog.project_file.edit_text
1093 self._clearPopup()
1094 self.log.debug("open %s", pf)
1095 try:
1096 self._open(pf)
1097 except Exception as e:
1098 self.message("Error", str(e))
1099
1100 def render(self, program):
1101 dialog = RenderDialog(self, program)
1102 self.screen.monitor.off = True
1103 overlay = urwid.Overlay(dialog, self.screen,
1104 'center', ('relative', 50),
1105 'middle', ('relative', 25))
1106 self.loop.widget = overlay
1107
1108 def _render(self, ttyrec, program):
1109 program.render_ttyrec(self.size, ttyrec)
1110
1111 def _finishRender(self, button, dialog):
1112 tf = dialog.ttyrec_file.edit_text
1113 program = dialog.program
1114 self._clearPopup()
1115 self.log.debug("render %s", tf)
1116 try:
1117 self._render(tf, program)
1118 except Exception as e:
1119 self.message("Error", str(e))
1120
1121def main():
1122 parser = argparse.ArgumentParser(
1123 description='')
1124 parser.add_argument('--width', type=int, default=80,
1125 help='Screen width')
1126 parser.add_argument('--height', type=int, default=24,
1127 help='Screen height')
1128 parser.add_argument('--debuglog',
1129 help='Debug log file path')
1130 parser.add_argument('project', nargs='?',
1131 help='project file')
1132 args = parser.parse_args()
1133 e = Editor(args)
1134 e.run()
diff --git a/editty/program.py b/editty/program.py
new file mode 100644
index 0000000..f73e263
--- /dev/null
+++ b/editty/program.py
@@ -0,0 +1,388 @@
1# -*- coding: utf-8 -*-
2# Copyright (C) 2019 James E. Blair <corvus@gnu.org>
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17import logging
18import tempfile
19import struct
20import uuid
21
22from editty.segment import *
23
24class FrameInfo:
25 def __init__(self, **kw):
26 self.__dict__.update(kw)
27
28class Program:
29 def __init__(self, title='Untitled'):
30 self.log = logging.getLogger('program')
31 self.title = title
32 self.segments = []
33 self.length = 0.0
34 self.uuid = str(uuid.uuid4())
35
36 def toJSON(self):
37 return dict(uuid=self.uuid,
38 title=self.title,
39 segments=[x.toJSON() for x in self.segments])
40
41 @classmethod
42 def fromJSON(cls, data, sources):
43 p = Program(data['title'])
44 p.uuid = data['uuid']
45 for segment in data['segments']:
46 log = logging.getLogger('program')
47 log.debug(segment)
48 p.append(Segment.fromJSON(segment, sources))
49 return p
50
51 def copy(self):
52 p = Program()
53 p.title = self.title
54 p.segments = [s.copy() for s in self.segments]
55 p.length = self.length
56 return p
57
58 def cut(self, start, end):
59 self.log.debug("cut %s %s", start, end)
60 elapsed = 0.0
61 cut_program = Program("Cut of %s" % self.title)
62 for segment in self.segments[:]:
63 segment_start = elapsed
64 segment_end = segment_start + segment.duration
65 segment_duration = segment.duration
66 self.log.debug("consider segment %s %s %s", segment, segment_start, segment_end)
67 # [xxx]
68 #[===========]
69 if segment_start < start and segment_end > end:
70 # The segment should be split and the middle removed
71 self.log.debug("split and remove segment %s" % segment)
72 # Save it for the clipboard
73 clipboard_copy = segment.copy()
74 clipboard_copy.start += start - segment_start
75 clipboard_copy.end -= segment_end - end
76 cut_program.append(clipboard_copy)
77 # Make the cut
78 segment_index = self.segments.index(segment)
79 new_segment = segment.copy()
80 self.segments.insert(segment_index+1, new_segment)
81 segment.end -= segment_end - start
82 new_segment.start += end - segment_start
83 # No more segments apply
84 break
85 # [xxxxxxxxxxx]
86 #[-----][=====][-----]
87 # [xxxxx]
88 #[-----][=====][-----]
89 elif segment_start >= start and segment_end <= end:
90 # The entire segment should be removed
91 self.log.debug("remove segment %s" % segment)
92 # Save it for the clipboard
93 clipboard_copy = segment.copy()
94 cut_program.append(clipboard_copy)
95 # Make the cut
96 self.segments.remove(segment)
97 # [xxxx]
98 #[-----][=====][-----]
99 # [xx]
100 #[-----][=====][-----]
101 elif segment_start < start and segment_end >= start:
102 # TODO: if segment_end == start, we may not need this segment in some cases
103 # Move back the end of this segment
104 delta = segment_end - start
105 self.log.debug("move end of segment %s %s", segment, delta)
106 # Save it for the clipboard
107 clipboard_copy = segment.copy()
108 clipboard_copy.start += start - segment_start
109 cut_program.append(clipboard_copy)
110 # Make the cut
111 segment.end -= delta
112 # [xxxx]
113 #[-----][=====][-----]
114 # [xx]
115 #[-----][=====][-----]
116 elif segment_end > end and segment_start <= end:
117 # TODO: if segment_start == end, we may not need this segment in some cases
118 # Move up the start of this segment
119 delta = end - segment_start
120 self.log.debug("move start of segment %s %s", segment, delta)
121 # Save it for the clipboard
122 clipboard_copy = segment.copy()
123 clipboard_copy.end -= segment_end - end
124 cut_program.append(clipboard_copy)
125 # Make the cut
126 segment.start += delta
127 elapsed += segment_duration
128 for segment in self.segments:
129 self.log.debug("segment %s %s %s", segment, segment.start, segment.end)
130 self.updateLength()
131 return cut_program
132
133 def insert(self, timecode, program):
134 self.log.debug("insert %s %s", timecode, program)
135 elapsed = 0.0
136 for segment in self.segments[:]:
137 segment_start = elapsed
138 segment_end = segment_start + segment.duration
139 self.log.debug("consider segment %s %s %s", segment, segment_start, segment_end)
140 # [xx]
141 #[======]
142 if timecode > segment_end:
143 pass
144 elif timecode < segment_start:
145 pass
146 # [xxx]
147 #[===========]
148 elif segment_start < timecode < segment_end:
149 # The segment should be split and the program inserted in the middle
150 self.log.debug("split and insert program %s" % segment)
151 segment_index = self.segments.index(segment)
152 new_segment = segment.copy()
153 segment.end -= segment_end - timecode
154 new_segment.start += timecode - segment_start
155 self.segments = (self.segments[:segment_index+1] +
156 program.segments +
157 [new_segment] +
158 self.segments[segment_index+1:])
159 # No more segments apply
160 break
161 #[xx]
162 #[=====]
163 elif segment_start == timecode:
164 # The program should be inserted before the current segment
165 self.log.debug("prepend segment %s" % segment)
166 segment_index = self.segments.index(segment)
167 self.segments = (self.segments[:segment_index] +
168 program.segments +
169 self.segments[segment_index:])
170 # No more segments apply
171 break
172 # [xx]
173 #[=====]
174 elif segment_end == timecode:
175 # The program should be appended after the current segment
176 self.log.debug("append segment %s", segment)
177 segment_index = self.segments.index(segment)
178 self.segments = (self.segments[:segment_index+1] +
179 program.segments +
180 self.segments[segment_index+1:])
181 # No more segments apply
182 break
183 elapsed += segment.duration
184 for segment in self.segments:
185 self.log.debug("segment %s %s %s", segment, segment.start, segment.end)
186 self.updateLength()
187
188 def append(self, obj):
189 if isinstance(obj, Segment):
190 self.segments.append(obj)
191 elif isinstance(obj, Program):
192 self.segments.extend(obj.segments)
193 else:
194 raise Exception("Can not add %s to Program" % repr(obj))
195 self.updateLength()
196
197 def dissolve(self, start, end):
198 self.cut(start, end)
199 (prev_fi, cur_fi, next_fi) = self.getFramesAtTimecode(start)
200 if (not isinstance(prev_fi.segment, Clip) or
201 not isinstance(cur_fi.segment, Clip)):
202 raise Exception("Dissolve is only supported between two clips")
203 dis = Dissolve(prev_fi.segment.source,
204 prev_fi.segment.end,
205 cur_fi.segment.source,
206 cur_fi.segment.start,
207 end-start)
208 prog = Program()
209 prog.append(dis)
210 self.insert(start, prog)
211
212 def updateLength(self):
213 self.length = 0.0
214 for segment in self.segments:
215 self.length += segment.duration
216
217 def getFramesAtTimecode(self, timecode):
218 # Return the frame before, at, and after the timecode
219 prev_frame = None
220 cur_frame = None
221 next_frame = None
222 for fi in self:
223 if fi.timecode > timecode:
224 next_frame = fi
225 return (prev_frame, cur_frame, next_frame)
226 prev_frame = cur_frame
227 cur_frame = fi
228 return (prev_frame, cur_frame, next_frame)
229
230 def __iter__(self):
231 previous_segment_duration = 0.0
232 for si, segment in enumerate(self.segments):
233 for (fi, (timecode, frame)) in enumerate(segment):
234 yield FrameInfo(timecode=timecode + previous_segment_duration,
235 frame_index=fi,
236 segment_index=si,
237 segment=segment,
238 frame=frame)
239 previous_segment_duration += segment.duration
240
241 def getFrames(self, start, end):
242 for fi in self:
243 if end is not None and fi.timecode > end:
244 return
245 if start is not None and fi.timecode < start:
246 continue
247 start = None
248 yield fi
249
250 # TODO: this is currently unused
251 def render_script(self, size, stream_fn, timing_fn):
252 class LoadWidget(urwid.Widget):
253 term_modes = urwid.TermModes()
254 def beep(self): pass
255 def set_title(self, title): pass
256 class MyScreen(urwid.raw_display.Screen):
257 def signal_init(self): pass
258 def signal_restore(self): pass
259 canv = urwid.TermCanvas(size[0], size[1], LoadWidget())
260 canv.modes.main_charset = urwid.vterm.CHARSET_UTF8
261 with tempfile.TemporaryFile() as screen_in:
262 with open(stream_fn, 'w') as screen_out:
263 with open(timing_fn, 'w') as timing_out:
264 screen_out.write("Rendered by Editty\n")
265 screen = MyScreen(screen_in, screen_out)
266 screen.start()
267 elapsed = 0.0
268 written = 0
269 for fi in self:
270 canv.term = [line[:] for line in fi.frame.content]
271 canv.set_term_cursor(fi.frame.cursor[0], fi.frame.cursor[1])
272 screen.draw_screen(size, canv)
273 screen._screen_buf_canvas=None
274 screen_out.flush()
275 current_pos = screen_out.tell()
276 delta_bytes = current_pos - written
277 delta_time = fi.timecode - elapsed
278 timing_out.write('%0.6f %i\n' % (delta_time, delta_bytes))
279 written = current_pos
280 elapsed = fi.timecode
281 screen.stop()
282 screen_out.write("\n\nRendered by Editty\n")
283
284 # TODO: this is currently unused
285 def render_asciicast(self, size, cast_fn):
286 class LoadWidget(urwid.Widget):
287 term_modes = urwid.TermModes()
288 def beep(self): pass
289 def set_title(self, title): pass
290 class MyScreen(urwid.raw_display.Screen):
291 def signal_init(self): pass
292 def signal_restore(self): pass
293 canv = urwid.TermCanvas(size[0], size[1], LoadWidget())
294 canv.modes.main_charset = urwid.vterm.CHARSET_UTF8
295 show_cursor_escape = urwid.escape.SHOW_CURSOR
296 outdata = dict(version=1,
297 duration=self.length,
298 title="",
299 height=size[1],
300 width=size[0],
301 command=None,
302 stdout=[])
303 stdout = outdata['stdout']
304 with tempfile.TemporaryFile() as screen_in:
305 with tempfile.TemporaryFile('w+') as screen_out:
306 with open(cast_fn, 'w') as cast_out:
307 screen = MyScreen(screen_in, screen_out)
308 screen.start()
309 elapsed = 0.0
310 for fi in self:
311 canv.term = [line[:] for line in fi.frame.content]
312 canv.set_term_cursor(fi.frame.cursor[0], fi.frame.cursor[1])
313 if fi.segment.visible_cursor:
314 urwid.escape.SHOW_CURSOR = show_cursor_escape
315 else:
316 urwid.escape.SHOW_CURSOR = ''
317 screen.draw_screen(size, canv)
318 screen._screen_buf_canvas=None
319 screen_out.flush()
320 delta_bytes = screen_out.tell()
321 screen_out.seek(0)
322 data = screen_out.read()
323 self.log.debug("read %s chars %s bytes of %s" % (len(data), len(data.encode('utf8')), delta_bytes))
324 screen_out.seek(0)
325 screen_out.truncate()
326 if len(data.encode('utf8')) != delta_bytes:
327 raise Exception("Short read")
328 delta_time = fi.timecode - elapsed
329 stdout.append([delta_time, data])
330 self.log.debug("frame %s %s", delta_time, len(data))
331 elapsed = fi.timecode
332 cast_out.write(json.dumps(outdata))
333 screen.stop()
334 urwid.escape.SHOW_CURSOR = show_cursor_escape
335
336 def render_ttyrec(self, size, ttyrec_fn):
337 class LoadWidget(urwid.Widget):
338 term_modes = urwid.TermModes()
339 def beep(self): pass
340 def set_title(self, title): pass
341 class MyScreen(urwid.raw_display.Screen):
342 def signal_init(self): pass
343 def signal_restore(self): pass
344 canv = urwid.TermCanvas(size[0], size[1], LoadWidget())
345 canv.modes.main_charset = urwid.vterm.CHARSET_UTF8
346 show_cursor_escape = urwid.escape.SHOW_CURSOR
347 with tempfile.TemporaryFile() as screen_in:
348 with tempfile.TemporaryFile('w+') as screen_out:
349 with open(ttyrec_fn, 'wb') as ttyrec_out:
350 screen = MyScreen(screen_in, screen_out)
351 screen.start()
352 first = True
353 for fi in self:
354 canv.term = [line[:] for line in fi.frame.content]
355 canv.set_term_cursor(fi.frame.cursor[0], fi.frame.cursor[1])
356 if fi.segment.visible_cursor:
357 urwid.escape.SHOW_CURSOR = show_cursor_escape
358 else:
359 urwid.escape.SHOW_CURSOR = ''
360 screen.draw_screen(size, canv)
361 screen._screen_buf_canvas=None
362 screen_out.flush()
363 delta_bytes = screen_out.tell()
364 screen_out.seek(0)
365 data = screen_out.read()
366 self.log.debug("read %s chars %s bytes of %s" % (len(data), len(data.encode('utf8')), delta_bytes))
367 screen_out.seek(0)
368 screen_out.truncate()
369 if len(data.encode('utf8')) != delta_bytes:
370 raise Exception("Short read")
371 if first:
372 prefix = '\x1b%%G\x1b[8;%s;%st' % (size[1], size[0])
373 data = prefix + data
374 first = False
375 tc_secs, tc_usecs = map(int, ('%0.6f' % fi.timecode).split('.'))
376 data = data.encode('utf8')
377 ttyrec_out.write(struct.pack('<III',
378 tc_secs, tc_usecs, len(data)))
379 ttyrec_out.write(data)
380 self.log.debug("frame %s %s %s", tc_secs, tc_usecs, len(data))
381 elapsed = fi.timecode
382 if elapsed < self.length:
383 data = '\x1b[1;1H'.encode('utf8')
384 tc_secs, tc_usecs = map(int, ('%0.6f' % self.length).split('.'))
385 ttyrec_out.write(struct.pack('<III',
386 tc_secs, tc_usecs, 0))
387 screen.stop()
388 urwid.escape.SHOW_CURSOR = show_cursor_escape
diff --git a/editty/segment.py b/editty/segment.py
new file mode 100644
index 0000000..dd6faab
--- /dev/null
+++ b/editty/segment.py
@@ -0,0 +1,290 @@
1# -*- coding: utf-8 -*-
2# Copyright (C) 2019 James E. Blair <corvus@gnu.org>
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17import logging
18import uuid
19
20import urwid
21
22from editty.source import Frame
23
24class Segment(object):
25 def __init__(self):
26 super().__init__()
27 self.visible_cursor = True
28
29 def __repr__(self):
30 return '<%s from %s to %s (%s seconds)>' % (self.__class__.__name__, self.start, self.end, self.duration)
31
32 @classmethod
33 def fromJSON(cls, data, sources):
34 if data['type'] == 'clip':
35 ret = Clip.fromJSON(data, sources)
36 elif data['type'] == 'freeze-frame':
37 ret = FreezeFrame.fromJSON(data, sources)
38 elif data['type'] == 'black':
39 ret = Black.fromJSON(data, sources)
40 elif data['type'] == 'dissolve':
41 ret = Dissolve.fromJSON(data, sources)
42 else:
43 raise Exception("Unknown segment type: %s" % data.get('type'))
44 ret.visible_cursor = data.get('visible_cursor', True)
45 return ret
46
47 def toJSON(self):
48 return dict(visible_cursor=self.visible_cursor)
49
50 def updateCopy(self, copy):
51 copy.visible_cursor = self.visible_cursor
52 return copy
53
54class Clip(Segment):
55 def __init__(self, source, start, end, **kw):
56 super().__init__(**kw)
57 self.source = source
58 self.start = start
59 self.end = end
60
61 def toJSON(self):
62 d = super().toJSON()
63 d.update(dict(type='clip',
64 source=self.source.uuid,
65 start=self.start,
66 end=self.end))
67 return d
68
69 @classmethod
70 def fromJSON(cls, data, sources):
71 return Clip(sources[data['source']],
72 data['start'],
73 data['end'])
74
75 @property
76 def duration(self):
77 return self.end - self.start
78
79 def copy(self):
80 ret = Clip(self.source, self.start, self.end)
81 return super().updateCopy(ret)
82
83 def __iter__(self):
84 for fi in self.source.getFrames(self.start, self.end):
85 yield (fi[0]-self.start, fi[1])
86
87class Still(Segment):
88 def __init__(self, duration):
89 super(Still, self).__init__()
90 self.duration = duration
91
92 @property
93 def start(self):
94 return 0.0
95
96 @start.setter
97 def start(self, start):
98 self.duration -= start
99
100 @property
101 def end(self):
102 return self.duration
103
104 @end.setter
105 def end(self, end):
106 delta = end - self.duration
107 self.duration -= delta
108
109class FreezeFrame(Still):
110 def __init__(self, source, timecode, duration):
111 super(FreezeFrame, self).__init__(duration)
112 self.source = source
113 self.start = 0.0
114 self.timecode = timecode
115 self.end = duration
116
117 def toJSON(self):
118 return dict(type='freeze-frame',
119 source=self.source.uuid,
120 timecode=self.timecode,
121 duration=self.end)
122
123 @classmethod
124 def fromJSON(cls, data, sources):
125 return FreezeFrame(sources[data['source']],
126 data['timecode'],
127 data['duration'])
128
129 def copy(self):
130 ret = FreezeFrame(self.source, self.timecode, self.duration)
131 return super().updateCopy(ret)
132
133class Black(Still):
134 def __init__(self, duration):
135 super(Black, self).__init__(duration)
136 self.start = 0.0
137 self.end = duration
138
139 def toJSON(self):
140 return dict(type='black',
141 duration=self.end)
142
143 @classmethod
144 def fromJSON(cls, data, sources):
145 return Black(data['duration'])
146
147 def copy(self):
148 ret = Black(self.duration)
149 return super().updateCopy(ret)
150
151class Dissolve(Segment):
152 def __init__(self, start_source, start_timecode, end_source, end_timecode, duration, **kw):
153 super().__init__(**kw)
154 self.log = logging.getLogger('program')
155 self.start_source = start_source
156 self.start_timecode = start_timecode
157 self.end_source = end_source
158 self.end_timecode = end_timecode
159 self.duration = duration
160 self._cache = []
161 self._update()
162
163 def copy(self):
164 ret = Dissolve(self.start_source, self.start_timecode,
165 self.end_source, self.end_timecode, self.duration)
166 return super().updateCopy(ret)
167
168 @classmethod
169 def fromJSON(cls, data, sources):
170 return Dissolve(sources[data['start_source']],
171 data['start_timecode'],
172 sources[data['end_source']],
173 data['end_timecode'],
174 data['duration'])
175
176 def toJSON(self):
177 d = super().toJSON()
178 d.update(dict(type='dissolve',
179 start_source=self.start_source.uuid,
180 start_timecode=self.start_timecode,
181 end_source=self.end_source.uuid,
182 end_timecode=self.end_timecode,
183 duration=self.duration))
184 return d
185
186 @property
187 def start(self):
188 return 0.0
189
190 @start.setter
191 def start(self, start):
192 self.duration -= start
193 self._update()
194
195 @property
196 def end(self):
197 return self.duration
198
199 @end.setter
200 def end(self, end):
201 delta = end - self.duration
202 self.duration -= delta
203 self._update()
204
205 def __iter__(self):
206 for x in self._cache:
207 yield x
208
209 def _update(self):
210 start = list(self.start_source.getFrames(self.start_timecode, self.start_timecode))[0]
211 end = list(self.end_source.getFrames(self.end_timecode, self.end_timecode))[0]
212 self._cache = []
213 num_frames = int(self.duration * 10)
214 for tween_index in range(num_frames):
215 tween_frame = self._render(start[1], end[1], tween_index / (self.duration*10.0))
216 self._cache.append((self.start+(tween_index/10.0), tween_frame))
217
218 def _fixrgb(self, rgb, background):
219 ret = []
220 for i in range(len(rgb)):
221 if rgb[i] is None:
222 ret.append(background[i])
223 else:
224 ret.append(rgb[i])
225 return ret
226
227 def _render(self, start, end, progress):
228 line_list = []
229 attr_list = []
230 line_text = ''
231 line_attrs = []
232 current_attr = [None, 0]
233 current_rgb = None
234 current_props = None
235 ret_content = []
236 background = urwid.AttrSpec('light gray', 'black')
237 for line_i in range(len(start.content)):
238 ret_line = []
239 for char_i in range(len(start.content[line_i])):
240 if line_i == 1 and char_i == 0:
241 self.log.debug("tween %s %s", start.content[line_i][char_i], end.content[line_i][char_i])
242 oldattr, oldcs, oldchar = start.content[line_i][char_i]
243 newattr, newcs, newchar = end.content[line_i][char_i]
244 if oldattr is None:
245 oldrgb = background.get_rgb_values()
246 else:
247 oldrgb = oldattr.get_rgb_values()
248 oldrgb = self._fixrgb(oldrgb, background.get_rgb_values())
249 if newattr is None:
250 newrgb = background.get_rgb_values()
251 else:
252 newrgb = newattr.get_rgb_values()
253 newrgb = self._fixrgb(newrgb, background.get_rgb_values())
254 if newchar == b' ' and oldchar != b' ':
255 char = oldchar
256 charattr = oldattr
257 newrgb = newrgb[3:]*2
258 elif oldchar == b' ' and newchar != b' ':
259 char = newchar
260 charattr = newattr
261 oldrgb = oldrgb[3:]*2
262 elif progress >= 0.5:
263 char = newchar
264 charattr = newattr
265 else:
266 char = oldchar
267 charattr = oldattr
268 rgb = []
269 props = []
270 if charattr and charattr.bold:
271 props.append('bold')
272 if charattr and charattr.underline:
273 props.append('underline')
274 if charattr and charattr.standout:
275 props.append('standout')
276 if charattr and charattr.blink:
277 props.append('blink')
278 for x in range(len(oldrgb)):
279 rgb.append(int(((newrgb[x]-oldrgb[x])*progress)+oldrgb[x])>>4)
280 fg = ', '.join(props + ['#%x%x%x' % tuple(rgb[:3])])
281 bg = '#%x%x%x' % tuple(rgb[3:])
282 attr = urwid.AttrSpec(fg, bg)
283 ret_line.append((attr, oldcs, char))
284 ret_content.append(ret_line)
285 if progress > 0.5:
286 which = end
287 else:
288 which = start
289 frame = Frame(None, which.timeline_color, content=ret_content, cursor=which.cursor)
290 return frame
diff --git a/editty/source.py b/editty/source.py
new file mode 100644
index 0000000..1a26407
--- /dev/null
+++ b/editty/source.py
@@ -0,0 +1,177 @@
1# -*- coding: utf-8 -*-
2# Copyright (C) 2019 James E. Blair <corvus@gnu.org>
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17import os
18import logging
19import time
20import struct
21import uuid
22
23import urwid
24
25class Frame:
26 def __init__(self, term, timeline_color, content=None, cursor=None):
27 if content is not None:
28 self.content = content
29 else:
30 self.content = [line[:] for line in term.content()]
31 if cursor is not None:
32 self.cursor = cursor
33 else:
34 self.cursor = term.term_cursor[:]
35 self.timeline_color = timeline_color
36 # TODO: cursor visibility, current color
37
38class SourceClip:
39 def __init__(self, size, title, file_type, stream_fn, timing_fn, timeline_color):
40 self.title = 'Untitled'
41 self.size = size
42 self.frames = []
43 self.times = []
44 self.length = 0.0
45 self.file_type = file_type
46 self.stream_fn = stream_fn
47 self.timing_fn = timing_fn
48 self.uuid = str(uuid.uuid4())
49 self.timeline_color = timeline_color
50
51 def addFrame(self, timecode, frame):
52 self.frames.append(frame)
53 self.times.append(timecode)
54 self.length = timecode
55
56 def toJSON(self):
57 return dict(type=self.file_type,
58 uuid=self.uuid,
59 size=self.size,
60 stream=self.stream_fn,
61 timing=self.timing_fn,
62 color=self.timeline_color)
63
64 @classmethod
65 def fromJSON(cls, data):
66 ft = getFileType(data['type'])
67 sc = ft.load(data['size'], data['stream'], data['timing'], data['color'])
68 sc.uuid = data['uuid']
69 return sc
70
71 def getFrames(self, start, end):
72 # In case we need to supply the frame before the start:
73 prev = None
74 yielded = False
75 for fi in zip(self.times, self.frames):
76 if end is not None and fi[0] > end:
77 if not yielded and prev is not None:
78 yield (start, prev[1])
79 return
80 if start is not None:
81 if fi[0] < start:
82 prev = fi
83 continue
84 if prev is not None and fi[0] > start:
85 yield (start, prev[1])
86 yielded = True
87 start = None
88 yield fi
89 yielded = True
90
91class FileType:
92 timing = False
93
94 def __init__(self):
95 self.log = logging.getLogger('file')
96
97 def _loadCanvas(self, size, stream_fn, timing_fn, timeline_color):
98 title = os.path.split(stream_fn)[-1]
99 source_clip = SourceClip(size, title, self.name, stream_fn, timing_fn, timeline_color)
100 class LoadWidget(urwid.Widget):
101 term_modes = urwid.TermModes()
102 def beep(self): pass
103 def set_title(self, title): pass
104 canv = urwid.TermCanvas(size[0], size[1], LoadWidget())
105 canv.modes.main_charset = urwid.vterm.CHARSET_UTF8
106
107 return (source_clip, canv)
108
109class ScriptFile(FileType):
110 name = 'Script'
111 timing = True
112
113 def load(self, size, stream_fn, timing_fn, timeline_color):
114 self.log.debug('Loading %s %s', stream_fn, timing_fn)
115 source_clip, canvas = self._loadCanvas(size, stream_fn, timing_fn, timeline_color)
116 start = time.time()
117 buffer_pos = 0
118 timecode = 0.0
119 with open(stream_fn, 'rb') as s:
120 stream = s.read()
121 i = stream.find(b'\n')
122 stream = stream[i+1:]
123 with open(timing_fn) as f:
124 for i, line in enumerate(f):
125 delay, count = line.strip().split(' ')
126 delay = float(delay)
127 count = int(count)
128 timecode += delay
129 data = stream[buffer_pos:buffer_pos+count]
130 canvas.addstr(data)
131 buffer_pos += count
132 source_clip.addFrame(timecode, Frame(canvas, timeline_color))
133 end = time.time()
134 self.log.debug('Finished loading %s', end-start)
135 return source_clip
136
137class TtyrecFile(FileType):
138 name = 'Ttyrec'
139
140 def load(self, size, stream_fn, timing_fn, timeline_color):
141 self.log.debug('Loading %s %s', stream_fn, timing_fn)
142 source_clip, canvas = self._loadCanvas(size, stream_fn, timing_fn, timeline_color)
143 with open(stream_fn, 'rb') as ttyrec_in:
144 start_time = None
145 while True:
146 header = ttyrec_in.read(12)
147 if not header:
148 self.log.debug("no header")
149 break
150 tc_secs, tc_usecs, dlen = struct.unpack('<III', header)
151 timecode = tc_secs + tc_usecs/1000000.0
152 if start_time is None:
153 # If the first frame is less than a year from the
154 # epoch, assume we are 0-based, otherwise, assume
155 # it's epoch-based and use the first timecode as
156 # the basis.
157 if timecode > 365*24*60*60:
158 start_time = timecode
159 else:
160 start_time = 0.0
161 data = ttyrec_in.read(dlen)
162 if len(data) != dlen:
163 raise Exception("short read")
164 canvas.addstr(data)
165 self.log.debug("Frame %0.6f %i" % (timecode, dlen))
166 source_clip.addFrame(timecode-start_time, Frame(canvas, timeline_color))
167 return source_clip
168
169all_types = [
170 TtyrecFile(),
171 ScriptFile(),
172]
173
174def getFileType(name):
175 for ft in all_types:
176 if ft.name == name:
177 return ft
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..33442ab
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
1pbr>=1.0.0
2
3urwid
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..cb2c14c
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,44 @@
1# Copyright (C) 2019 James E. Blair <corvus@gnu.org>
2#
3# This program is free software: you can redistribute it and/or modify
4# it under the terms of the GNU General Public License as published by
5# the Free Software Foundation, either version 3 of the License, or
6# (at your option) any later version.
7#
8# This program is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program. If not, see <http://www.gnu.org/licenses/>.
15
16[metadata]
17name = editty
18summary = Terminal-based non-linear editor for terminal recordings
19description-file =
20 README.rst
21author = James E. Blair
22author-email = corvus@gnu.org
23classifier =
24 Environment :: Console
25 Intended Audience :: Developers
26 Intended Audience :: Education
27 Intended Audience :: End Users/Desktop
28 Intended Audience :: Information Technology
29 Intended Audience :: System Administrators
30 License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
31 Programming Language :: Python
32 Topic :: Multimedia :: Graphics :: Presentation
33
34[pbr]
35warnerrors = True
36
37[entry_points]
38console_scripts =
39 editty = editty.editty:main
40
41[build_sphinx]
42source-dir = doc/source
43build-dir = doc/build
44all_files = 1
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..5cc296f
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,20 @@
1# Copyright (C) 2019 James E. Blair <corvus@gnu.org>
2#
3# This program is free software: you can redistribute it and/or modify
4# it under the terms of the GNU General Public License as published by
5# the Free Software Foundation, either version 3 of the License, or
6# (at your option) any later version.
7#
8# This program is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program. If not, see <http://www.gnu.org/licenses/>.
15
16import setuptools
17
18setuptools.setup(
19 setup_requires=['pbr'],
20 pbr=True)
diff --git a/test-requirements.txt b/test-requirements.txt
new file mode 100644
index 0000000..939f75f
--- /dev/null
+++ b/test-requirements.txt
@@ -0,0 +1 @@
stestr
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/__init__.py
diff --git a/tests/test.py b/tests/test.py
new file mode 100644
index 0000000..9cf45f7
--- /dev/null
+++ b/tests/test.py
@@ -0,0 +1,191 @@
1# -*- coding: utf-8 -*-
2# Copyright (C) 2019 James E. Blair <corvus@gnu.org>
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17import logging
18import struct
19import tempfile
20
21import testtools
22import fixtures
23
24from editty.segment import *
25from editty.program import *
26import editty.source
27
28class BaseTestCase(testtools.TestCase):
29 def setUp(self):
30 super().setUp()
31 fs = '%(asctime)s %(levelname)s [%(name)s] %(message)s'
32 self.useFixture(fixtures.FakeLogger(level=logging.DEBUG,
33 format=fs))
34 self.log = logging.getLogger("test")
35
36class FileTypeTests:
37 def get_frames(self, source):
38 ret = []
39 for (timecode, frame) in source.getFrames(0, source.length):
40 ret.append((timecode, b''.join([x[2] for x in frame.content[0]]).strip()))
41 return ret
42
43 def test_load(self):
44 source = self.setup()
45 self.assertEqual(
46 [(1.0, b'a'), (2.0, b'ab'), (3.0, b'abc'), (4.0, b'abcd')],
47 self.get_frames(source))
48
49class TestScriptFile(BaseTestCase, FileTypeTests):
50 def setup(self):
51 size = (80, 24)
52 with tempfile.NamedTemporaryFile() as stream:
53 with tempfile.NamedTemporaryFile() as timing:
54 stream.write(b"\nabcd")
55 for x in range(4):
56 timing.write(b"1.0 1\n")
57 stream.flush()
58 timing.flush()
59 source = editty.source.ScriptFile().load(
60 size, stream.name, timing.name, 'color')
61 return source
62
63class TestTtyrecFile(BaseTestCase, FileTypeTests):
64 def setup(self):
65 size = (80, 24)
66 frames = [(1.0, b'a'), (2.0, b'b'), (3.0, b'c'), (4.0, b'd')]
67 with tempfile.NamedTemporaryFile() as stream:
68 for (timecode, data) in frames:
69 tc_secs, tc_usecs = map(int, ('%0.6f' % timecode).split('.'))
70 stream.write(struct.pack('<III',
71 tc_secs, tc_usecs, len(data)))
72 stream.write(data)
73 stream.flush()
74 source = editty.source.TtyrecFile().load(
75 size, stream.name, None, 'color')
76 return source
77
78class TestProgram(BaseTestCase):
79 size = (80, 24)
80
81 def setup(self, color='1'):
82 with tempfile.NamedTemporaryFile() as stream:
83 with tempfile.NamedTemporaryFile() as timing:
84 stream.write(b"\nabcd")
85 for x in range(4):
86 timing.write(b"1.0 1\n")
87 stream.flush()
88 timing.flush()
89 source = editty.source.ScriptFile().load(
90 self.size, stream.name, timing.name, color)
91 program = Program(source.title)
92 program.append(Clip(source, 0.0, source.length))
93 return program
94
95 def get_frames(self, program, start, end, color=False):
96 ret = []
97 for fi in program.getFrames(start, end):
98 if color:
99 ret.append((fi.timecode, b''.join([x[2] for x in fi.frame.content[0]]).strip(), fi.frame.timeline_color))
100 else:
101 ret.append((fi.timecode, b''.join([x[2] for x in fi.frame.content[0]]).strip()))
102 return ret
103
104 def test_playback(self):
105 program = self.setup()
106 frames = self.get_frames(program, 0, program.length)
107 self.assertEqual(
108 [(1.0, b'a'), (2.0, b'ab'), (3.0, b'abc'), (4.0, b'abcd')],
109 frames)
110
111 def test_cut(self):
112 p1 = self.setup('1')
113 self.assertEqual(
114 [(1.0, b'a'), (2.0, b'ab'), (3.0, b'abc'), (4.0, b'abcd')],
115 self.get_frames(p1, None, None))
116
117 cut = p1.cut(2.0, 3.0)
118 self.assertEqual(
119 [(1.0, b'a'), (2.0, b'ab'), (2.0, b'abc'), (3.0, b'abcd')],
120 self.get_frames(p1, None, None))
121 self.assertEqual(
122 [(0.0, b'ab'), (1.0, b'abc')],
123 self.get_frames(cut, None, None))
124
125 p2 = self.setup('2')
126 p2.insert(2.5, cut)
127 self.log.debug(self.get_frames(p2, None, None, True))
128
129 cut = p2.cut(2.0, 4.0)
130 p3 = self.setup('3')
131 self.log.debug("cut")
132 for s in cut.segments:
133 self.log.debug(s)
134 self.log.debug(self.get_frames(cut, None, None, True))
135 self.assertEqual(
136 [(0.0, b'ab', '2'), (0.5, b'ab', '1'), (1.5, b'abc', '1'),
137 (1.5, b'ab', '2'), (2.0, b'abc', '2')],
138 self.get_frames(cut, None, None, True))
139 p3.insert(2.5, cut)
140 self.log.debug(self.get_frames(p3, None, None, True))
141 self.assertEqual(
142 [(1.0, b'a', '3'), (2.0, b'ab', '3'),
143 (2.5, b'ab', '2'),
144 (3.0, b'ab', '1'), (4.0, b'abc', '1'),
145 (4.0, b'ab', '2'), (4.5, b'abc', '2'),
146 (4.5, b'ab', '3'), (5.0, b'abc', '3'), (6.0, b'abcd', '3')],
147 self.get_frames(p3, None, None, True))
148
149 def test_repeated_cut(self):
150 p1 = self.setup('1')
151 self.assertEqual(
152 [(1.0, b'a'), (2.0, b'ab'), (3.0, b'abc'), (4.0, b'abcd')],
153 self.get_frames(p1, None, None))
154
155 cut = p1.cut(2.0, 3.0)
156 self.assertEqual(
157 [(1.0, b'a'), (2.0, b'ab'), (2.0, b'abc'), (3.0, b'abcd')],
158 self.get_frames(p1, None, None))
159 self.assertEqual(
160 [(0.0, b'ab'), (1.0, b'abc')],
161 self.get_frames(cut, None, None))
162 p2 = Program('2')
163 self.log.debug('append')
164 for s in cut.segments:
165 self.log.debug(s)
166 p2.append(cut)
167 self.log.debug('appended')
168 for s in p2.segments:
169 self.log.debug(s)
170 self.log.debug(self.get_frames(p2, None, None, True))
171
172 cut = p1.cut(2.2, 2.7) # 3.2 - 3.7
173 self.log.debug(self.get_frames(p1, None, None))
174 self.assertEqual(
175 [(1.0, b'a'), (2.0, b'ab'), (2.0, b'abc'), (2.2, b'abc'), (2.5, b'abcd')],
176 self.get_frames(p1, None, None))
177 self.log.debug(self.get_frames(cut, None, None))
178 self.assertEqual(
179 [(0.0, b'abc')],
180 self.get_frames(cut, None, None))
181 self.log.debug('append')
182 for s in cut.segments:
183 self.log.debug(s)
184 p2.append(cut)
185 self.log.debug('appended')
186 for s in p2.segments:
187 self.log.debug(s)
188 self.log.debug(self.get_frames(p2, None, None, True))
189 self.assertEqual(
190 [(0.0, b'ab', '1'), (1.0, b'abc', '1'), (1.0, b'abc', '1')],
191 self.get_frames(p2, None, None, True))
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..764a7fa
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,8 @@
1[testenv]
2basepython = python3
3usedevelop = True
4deps =
5 -r{toxinidir}/requirements.txt
6 -r{toxinidir}/test-requirements.txt
7commands =
8 stestr run {posargs}