openjutsu
영상에서 원클릭으로 인물 교체, ComfyUI·MiniMax H3 기반 오픈소스 로컬 힉스필드 겐주츠 대안, FLUX.3 Klein 재조명 지원
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Text in video, with tasteful defaults: title cards, credits, captions with a tiny attribution, lower thirds, context notes, and calligrams — from one layout model, rendered with Pillow, composited with ffmpeg.
pip install tituli # Pillow only; ffmpeg on PATH for video output
from tituli import Frame, title_card, caption, render
frame = Frame.blank((1920, 1080), color="#101014")
render(
title_card("Il pleut", "Apollinaire, 1918", kicker="Episode 3", frame=frame), frame
).save("title.png")
frame = Frame.from_image(
"still.jpg", delivery="youtube"
) # add avoid=burns.salient_box to keep off the subject
render(
caption("Eliza Hamilton", "Ralph Earl, 1787 · public domain", frame=frame), frame
).save("captioned.png")
Or from the shell (pip install tituli[cli]):
python -m tituli title_card "The Apple" "a concrete poem" --background "#101014" --out title.png
python -m tituli credits credits.json --mode crawl --out credits.mp4
python -m tituli caption still.jpg "Angelica Schuyler Church" "John Trumbull, 1785" --out cap.png
python -m tituli calligram "round and round the apple goes" --shape circle --out calligram.png
Every use case fills a Layout — placed Runs (a string, a baseline origin, an angle, a resolved face, a colour) over Plates (scrims, rules) — on a Frame. A block of prose is one run per line; a calligram is one run per glyph. The humble cases are the degenerate calligram. render(layout, frame) gives an RGBA overlay (frame without a background) or an RGB composite; tituli.video puts either onto footage.
"A video that is text" and "text over a video" are the same problem; what differs is how much the layout engine knows about the picture. Frame carries all four rungs, every one optional, and the engine asks rather than branching:
| rung | the Frame knows | so the engine can |
|---|---|---|
| a | its size | place by anchor in the title-safe area; assume the worst → white on a dark scrim |
| b | a solid colour (Frame.blank(size, color=…)) | pick ink by WCAG contrast; no scrim |
| c | the pixels (Frame.from_image(still) or Frame.over(stills)) | sample luminance under the candidate box; ink by contrast; scrim only if the patch is mid-toned or busy |
| d | c + regions to keep clear (avoid=) | rank the nine anchor positions by how little they cover the subject |
Frame.over(stills) samples every frame a camera move will pass through and reports the worst instant, so a scrim over a Ken Burns pan is as light as the whole window allows. delivery="youtube" hard-excludes the bottom 22 % (subtitle track + control bar) from placement — modelled as named reserved zones, not a rule to remember.
A scrim darkens a region; for a large centred title over moving footage that is too heavy. halo=True on any compose builder (caption, title_card, lower_third, note; --halo on the CLI, "halo": true in overlay-video items) glows under the glyphs instead — the glyphs' own alpha, dilated, blurred and laid under them in the opposite of the ink, sized in em — and stands in for the automatic scrim (scrim=True still forces one). Halo(spread=, blur=, gain=, opacity=, color=) tunes it; layout.with_halo() puts one under any layout.
shape/wrap → fit → measure → decide ink → cut the scrim to the measured block → compose. Your words are set complete or not at all: caption, lower_third and note shrink the type until it fits and raise TextDoesNotFit rather than ship a cut label — because 1981 · Centr… is not a shortened label, it is a wrong one, and only the caller can choose between shorter wording, more lines and no overlay. Text whose length you genuinely don't control (a licence template pasted into an artist field) still truncates, asked for by name: on_overflow="truncate". And never burn text into stills that a camera move will pan and zoom — composite onto the finished motion video (tituli.video.overlay).
| call | what it makes |
|---|---|
title_card(title, subtitle, kicker=…, frame=…) | opening card; title fitted (shrunk, never clipped) to 80 % of the safe width |
caption(text, attribution, frame=…) | museum label: what's on screen + a small credit line; subject-avoiding; corner-falloff scrim |
lower_third(name, role, frame=…) | who is speaking |
note(lines, headline=…, frame=…) | editorial context: a headline over equal-weight lines ("what Hamilton is") |
intertitle(text, frame=…) | silent-film card, serif italic |
Credits.from_dict(…) → credits_cards / credits_crawl | structured roll: sections, role/name pairs on a gutter, tracked small-cap headings; never truncates (paginates, or raises if you cap the cards) |
on_path(text, shape="circle"|"wave"|SVG d|Path) | glyphs riding any path, rotated to the tangent or kept upright |
rain(lines) | Apollinaire's Il pleut: upright letters stepping down fanning streaks (the 1918 measurements as defaults) |
in_shape(text, mask) | prose poured into a silhouette |
schedule_labels(spans, label_for, suppressed_by=cards) | one label per shot with first-appearance, repeat-gap and suppression rules inside the loop |
run_outline(run) | a placed run's glyph contours as one SVG d string in frame pixels (pip install tituli[outlines]) — text drawn with no font engine; a missing glyph raises MissingGlyphError |
TextStyle(features=("tnum",)) | OpenType features by tag; face.applied_features says which the font has. One-for-one substitutions (tnum, lnum, zero, smcp …) are applied by measurement and run_outline from the font's tables (tituli[outlines]); the Pillow engine refuses a run they change (it would draw other glyphs), HarfBuzzEngine draws them |
stack(blocks, frame=…, gap=…) / |
All sizes are fractions of frame height, so a style reads the same at 720p and 4K. The presets (tituli.style) are one type ramp shared by overlays and the end card, so a film is one design.
A reserved zone binds every anchor, not just anchor="auto": with delivery="youtube" a block that would land in the subtitle band is slid the shortest way clear of it, keeping the anchor you named. anchor="bottom-left" therefore means "as low as the platform allows".
schedule_labels derives labels from a cut with the three rules built in. A label suppressed by a heavier overlay is truncated to the time before it, kept if what remains is readable, and otherwise skipped without being recorded as shown — so the portrait a cold viewer most needs named still gets labelled the next time it appears. resolve() applies the identical rule to hand-built overlays (one overlay per slot at a time; equal weights colliding raise). label_for returns a Label or the explicit UNLABELLED; None raises — "no caption" is a stated choice, never the cheap default, because an unlabelled still beside a labelled one is an implicit claim.
| # | seam | v1 default (no new dependency) | replacement you can point at |
|---|---|---|---|
| 1 | subject avoidance — Frame.from_image(avoid=) | none (boxes you pass) | burns.salient_box, any burns.FacesDetector (pip install tituli[saliency]) |
| 2 | the rasteriser — render(engine=) | PillowEngine (FreeType via Pillow, bitmap rotation) | HarfBuzzEngine — shaping + outline transforms (pip install tituli[shaping]) Pass the same engine= to block/wrap/measure/fit_size/glossed/along_path/glyph_columns/fill_shape/on_path/rain/in_shape/credits_cards/credits_crawl/run_outline — and the compose builders, video.overlay and the CLI's --engine — so the engine that draws a text also measures it (stack/grid place by the runs' measured widths). Each run records who measured it (Run.measured_by), and drawing or outlining it with another engine raises; HarfBuzzEngine hands the file-less embedded font to Pillow, so those runs are stamped "pillow". A custom engine names itself with name (a subclass overriding length must restate it) and may delegate faces with measurer(face). |
| 3 | the shape — on_path(shape=) | named presets | an SVG d string or any Path (a traced outline) |
| 4 | delivery zones — Frame(delivery=) | none | DELIVERY_RESERVED["youtube"]; add a target, not a rule |
| 5 | the graph — tituli.bodies | plain dict from body_for() | annot://schema/text-overlay/v1 registered with lacing (pip install tituli[lacing]) |
Surface for v1: CLI (python -m tituli, cw.dispatch over tituli.tools._dispatch_funcs). MCP/HTTP would project the same list. Agent skill shipped (tituli/data/skills/tituli).
NOT seams: the scrim design, the anchor grid, the type ramp, the ffmpeg encode args — written directly, on purpose.
an — thorwhalen/an#155 asked whether per-glyph text belongs in an or a sibling package. tituli is the sibling: it owns typesetting for video (shaping, metrics, wrap, contrast, safe area, reserved zones, path placement, the title/credits/caption conventions) and emits placed glyphs (Run with unit=glyph|word|line and an index). an keeps motion as structure and consumes those placements as its option-2 "svg_sprite per glyph, converted at compile time": run_outline turns each run into SVG path data, so an's runtime never learns what text is. tituli's own time envelope is deliberately small (fades, stagger, crawl).muvid keeps its lyric-video vocabulary and ASS burn-in; its calligram archetype's streak solver is tituli.rain, generalised over real glyph metrics and any frame, and text_on_path / concrete_page ceilings (a hardcoded sine; centred rows only; a 0.62-em character estimate) are what on_path / in_shape / real Face.length remove.braidio.video.credits_card is the plain-list case: Credits.from_lines(lines) → credits_cards, same never-truncate rule, designed type.burns owns saliency and the Ken Burns move; tituli only consumes salient_box.mixing owns subtitle (SRT) burn-in; tituli does not do subtitles.overlays.py (captions + context cards → transparent PNG → one ffmpeg overlay chain) was the seed for caption, note, schedule_labels and video.overlay, with its lessons kept: per-input -loop 1 -t, fade on the still's own clock, -c:a copy, the corner scrim cut to the block, and the suppression check inside the scheduler.A caption is an annotation on the image: reference = MediaRef(asset_id=<image hash>), body annot://schema/text-overlay/v1 (tituli.bodies.body_for(layout, frame, text=…) builds it; register() registers it, lazily, only with tituli[lacing]). For the (image, audio-segment) pair no N-ary reference is invented: as artful.PanelBody does, the pair annotation's reference is the interval on the segment and provenance.was_derived_from lists both the image asset_id and the caption annotation id. The body records unlabelled=True when a still was deliberately left without a label.
Title-safe 90 % (SMPTE ST 2046-1); WCAG 4.5:1 aimed for, 3:1 floor; sans working set Helvetica Neue → Inter → Helvetica → Avenir Next → Roboto → … → DejaVu Sans; ≥ 36 px-equivalent at 1080p for anything meant to be read; credits cards hold ≥ 3 s, crawls ≈ 97 px/s at 1080p; reveals 300–500 ms. Sources and the full rationale: misc/docs/style.md. No fonts ship in the package — system discovery with Pillow's embedded Aileron as the fallback, so a bare CI box still renders. When output must not depend on what is installed, ask for family=(EMBEDDED,): it never scans the system, and face_digest(face) (sha256 of the font's bytes) is the identity to record.
| extra | adds |
|---|---|
shaping | uharfbuzz + freetype-py: ligatures, kerning, complex scripts, outline rotation |
saliency | burns for avoid=salient_box |
outlines | fonttools: run_outline (glyph contours as SVG path data) |
lacing | the body schema |
cli | cw for python -m tituli |
gh skill install thorwhalen/tituli tituli — or, after pip install, link tituli/data/skills/tituli into your agent's skills directory.
grid(blocks, columns=…)several blocks composed into one Layout — a page that renders once and times like any overlay; gaps are fractions of frame height (one per seam if the page needs a rhythm) |
glossed([("カ", "ka"), …], frame=…) | a row of big glyphs, each with its reading centred under it, on one pitch |
block(text, …, break_at=" · ") | a a · b · c list that wraps only between items (never バス / bus across a line); U+00A0 binds two words in any wrap |
tituli.video.still / overlay / crawl / frames_to_video | ffmpeg output; only overlay/fade/crop needed (plus tpad/adelay for overlay(pad_start=…)) — never drawtext/libass. overlay takes layouts, payloads, or a frame-sized image (TimedOverlay(None, 0, 3, image="title.png", fade_in=0)) |