diff --git a/darkdraw/load_dur.py b/darkdraw/load_dur.py index f36eb97..bbd3dbf 100644 --- a/darkdraw/load_dur.py +++ b/darkdraw/load_dur.py @@ -78,6 +78,72 @@ def open_dur(vd, p): ) rows.append(d) + frame_ids = {r['id'] for r in rows if r.get('type') == 'frame'} + frame_rows = [r for r in rows if r.get('type') == 'frame'] + + # Merge duplicate elements across frames + merged = {} # (x, y, text, color) -> set of frame ids + for r in rows: + if r.get('type') == 'frame': + continue + key = (r['x'], r['y'], r['text'], r['color']) + merged.setdefault(key, set()).add(r['frame']) + + element_rows = [] + for (x, y, text, color), frames in merged.items(): + d = dict(x=x, y=y, text=text, color=color) + if frames != frame_ids: + d['frame'] = ' '.join(sorted(frames, key=int)) + element_rows.append(d) + + rows = frame_rows + element_rows + + rows = _combine_duplicate_frames(rows) + ddwoutput = '\n'.join(json.dumps(r) for r in rows) + '\n' return DrawingSheet(p.name, source=Path(str(p.with_suffix('.ddw')), fptext=io.StringIO(ddwoutput))).drawing + + +def _combine_duplicate_frames(rows): + 'Replace later duplicate frames with another instance of the first matching frame.' + def sig(fid): + return frozenset( + (r['x'], r['y'], r['text'], r['color']) + for r in rows + if not r.get('type') and fid in r.get('frame', '').split() + ) + + sigs = {} + rename_map = {} # dup_id -> first_id + for r in rows: + if r.get('type') != 'frame': continue + s = sig(r['id']) + if not s: continue + if s in sigs: + rename_map[r['id']] = sigs[s] + else: + sigs[s] = r['id'] + + if not rename_map: return rows + + out = [] + dup_ids = set(rename_map) + for r in rows: + if r.get('type') == 'frame': + if r['id'] in dup_ids: + r = {**r, 'id': rename_map[r['id']]} + out.append(r) + else: + ids = r.get('frame', '').split() + if not ids: + out.append(r) + continue + new_ids = [x for x in ids if x not in dup_ids] + if not new_ids: + continue + if new_ids == ids: + out.append(r) + else: + out.append({**r, 'frame': ' '.join(new_ids)}) + return out diff --git a/darkdraw/save_dur.py b/darkdraw/save_dur.py new file mode 100644 index 0000000..d941373 --- /dev/null +++ b/darkdraw/save_dur.py @@ -0,0 +1,200 @@ +import json +import gzip + +from visidata import VisiData, vd, dispwidth + +from .load_dur import durdraw_color16_fg_map, durdraw_color16_bg_map + + +vd.option('dur_color_format', '256', 'color format for .dur output: "16" or "256"') + + +_KNOWN_ATTRS = {'bold', 'dim', 'italic', 'underline', 'blink', 'reverse', 'standout', 'strikethrough'} + + +def _invert_map(m): + out = {} + for k, v in m.items(): + out.setdefault(v, k) + return out + + +_inv_fg16 = _invert_map(durdraw_color16_fg_map) +_inv_bg16 = _invert_map(durdraw_color16_bg_map) + + +def _parse_color(s, dropped_attrs): + fg, bg = 7, 0 + if not s: + return fg, bg + parts = s.split(' on ') + fg_toks = parts[0].split() + bg_toks = parts[1].split() if len(parts) > 1 else [] + + def _take_int(toks): + n = None + for t in toks: + try: + n = int(t) + break + except ValueError: + if t in _KNOWN_ATTRS: + dropped_attrs.add(t) + return n + + f = _take_int(fg_toks) + b = _take_int(bg_toks) + if f is not None: fg = f + if b is not None: bg = b + return fg, bg + + +def _to_dur_color(fg, bg, fmt, lossy_colors): + if fmt == '16': + if fg in _inv_fg16: + dfg = _inv_fg16[fg] + else: + lossy_colors.add(fg) + dfg = 1 + if bg in _inv_bg16: + dbg = _inv_bg16[bg] + else: + lossy_colors.add(bg) + dbg = 0 + return dfg, dbg + return fg, bg + + +def _resolve_sheet(vs): + 'Return DrawingSheet whether vs is Drawing or DrawingSheet.' + rows = getattr(vs, 'rows', None) + src = getattr(vs, 'source', None) + if rows is not None and any((getattr(r, 'type', '') or '') == 'frame' for r in rows): + return vs + if src is not None and hasattr(src, 'rows'): + return src + return vs + + +@VisiData.api +def save_dur(vd, p, vs): + src = _resolve_sheet(vs) + fmt = vd.options.dur_color_format + if fmt not in ('16', '256'): + vd.fail(f'dur_color_format must be "16" or "256" (got {fmt!r})') + + rows = list(src.rows) + + # SAUCE → name / artist + name = '' + artist = '' + for r in rows: + if (r.get('frame') or '') != 'SAUCE_record': + continue + if r.get('type') == 'Title': + name = (r.get('text') or '').strip() + elif r.get('type') == 'Author': + artist = (r.get('text') or '').strip() + + frames = [r for r in rows if (r.get('type') or '') == 'frame'] + synthesized_frame = False + if not frames: + from visidata import AttrDict + frames = [AttrDict(id='1', duration_ms=0)] + synthesized_frame = True + + disabled_tags = getattr(vs, 'disabled_tags', set()) or set() + + # Flatten elements via iterdeep (resolves refs/groups). Skip frames + disabled-tagged. + elements = [] + for r, x, y, parents in src.iterdeep(rows): + typ = (r.get('type') or '') + if typ: # group/ref/sauce-style rows + continue + if (r.get('frame') or '') == 'SAUCE_record': + continue + text = r.get('text') or '' + if not text: + continue + tags = (r.get('tags') or '').split() + if disabled_tags and any(t in disabled_tags for t in tags): + continue + elements.append((r, x, y, text)) + + if not elements: + vd.fail('no elements to save') + + cols = max(x + dispwidth(t) for _, x, _, t in elements) + lines = max(y + 1 for _, _, y, _ in elements) + + # Framerate from shortest frame + durs = [f.duration_ms for f in frames if f.duration_ms] + if durs: + min_dur = min(durs) + framerate = 1000.0 / min_dur + else: + min_dur = None + framerate = 10.0 + + dropped_attrs = set() + lossy_colors = set() + + dur_frames = [] + for n, f in enumerate(frames, start=1): + contents = [[' '] * cols for _ in range(lines)] + colormap = [[[1, 0] for _ in range(lines)] for _ in range(cols)] + + fid = f.get('id') if hasattr(f, 'get') else getattr(f, 'id', None) + for r, x, y, text in elements: + rframe = r.get('frame') or '' + if not synthesized_frame and rframe and (fid is None or str(fid) not in rframe.split()): + continue + fg, bg = _parse_color(r.get('color') or '', dropped_attrs) + dfg, dbg = _to_dur_color(fg, bg, fmt, lossy_colors) + w = dispwidth(text) or 1 + if 0 <= y < lines: + for i, ch in enumerate(text): + cx = x + i + if 0 <= cx < cols: + contents[y][cx] = ch + for i in range(w): + cx = x + i + if 0 <= cx < cols: + colormap[cx][y] = [dfg, dbg] + + dur_dur = f.get('duration_ms') if hasattr(f, 'get') else getattr(f, 'duration_ms', 0) + if min_dur and dur_dur and dur_dur != min_dur: + delay = dur_dur / 1000.0 + else: + delay = 0 + + dur_frames.append({ + 'frameNumber': n, + 'delay': delay, + 'contents': [''.join(line) for line in contents], + 'colorMap': colormap, + }) + + movie = { + 'DurMovie': { + 'formatVersion': 7, + 'colorFormat': fmt, + 'preferredFont': 'fixed', + 'encoding': 'utf-8', + 'name': name, + 'artist': artist, + 'framerate': framerate, + 'sizeX': cols, + 'sizeY': lines, + 'extra': None, + 'frames': dur_frames, + } + } + + if dropped_attrs: + vd.warning(f'.dur drop unsupported attrs: {", ".join(sorted(dropped_attrs))}') + if lossy_colors: + vd.warning(f'.dur 16-color mode lossy for colors: {sorted(lossy_colors)}') + + with gzip.open(str(p), 'wt', encoding='utf-8') as fp: + json.dump(movie, fp, indent=2)