Transcript Maker
-Paste a YouTube URL and get a transcript
+
+
+
+
+ Transcript Maker
+Paste a YouTube URL, get a transcript and summarize with AI
+
diff --git a/app/static/style.css b/app/static/style.css
index dfc1553..c899c2b 100644
--- a/app/static/style.css
+++ b/app/static/style.css
@@ -90,16 +90,12 @@ body {
display: flex;
flex-direction: column;
min-height: 100vh;
+ justify-content: flex-start;
+ padding-top: 64px;
}
.app[data-state="idle"] {
- justify-content: center;
- padding-bottom: 20vh;
-}
-
-.app[data-state="active"] {
- justify-content: flex-start;
- padding-top: 64px;
+ padding-top: 15vh;
}
.hero {
@@ -108,6 +104,25 @@ body {
/* ─── Logo ─── */
+.logo-group {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-left: -12px;
+ margin-bottom: 24px;
+}
+
+.logo-icon {
+ height: 72px;
+ width: auto;
+ flex-shrink: 0;
+}
+
+.logo-text {
+ display: flex;
+ flex-direction: column;
+}
+
.logo {
font-size: 1.75rem;
font-weight: 700;
@@ -121,8 +136,7 @@ body {
.subtitle {
color: var(--text-muted);
font-size: 0.925rem;
- margin-top: 6px;
- margin-bottom: 24px;
+ margin-top: 2px;
}
/* ─── Command bar ─── */
@@ -131,7 +145,7 @@ body {
display: flex;
align-items: center;
gap: 8px;
- padding: 6px 6px 6px 16px;
+ padding: 6px 10px 6px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
@@ -139,8 +153,8 @@ body {
}
.command-bar:focus-within {
- border-color: var(--border-focus);
- box-shadow: 0 0 0 3px var(--amber-dim), 0 8px 32px rgba(0, 0, 0, 0.3);
+ border-color: rgba(160, 184, 32, 0.4);
+ box-shadow: 0 0 0 3px rgba(160, 184, 32, 0.15), 0 8px 32px rgba(0, 0, 0, 0.3);
transform: translateY(-1px);
}
@@ -266,7 +280,7 @@ button:active:not(:disabled) {
align-items: center;
gap: 6px;
padding: 8px 18px;
- background: var(--amber);
+ background: #a0b820;
color: #131318;
flex-shrink: 0;
}
@@ -277,7 +291,7 @@ button:active:not(:disabled) {
}
#transcribe-btn:hover:not(:disabled) {
- background: #d4943a;
+ background: #8ea016;
}
#transcribe-btn:disabled {
@@ -615,7 +629,7 @@ button:active:not(:disabled) {
.history-card.expanded .quick-copy {
opacity: 1;
- align-self: flex-end;
+ align-self: flex-start;
}
/* Card actions — hidden by default, shown when expanded or error */
@@ -625,6 +639,7 @@ button:active:not(:disabled) {
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
+ margin-bottom: 6px;
}
.history-card.expanded .card-actions {
@@ -635,6 +650,10 @@ button:active:not(:disabled) {
display: flex;
}
+.history-card .card-actions:has(+ .summarize-prompt) {
+ margin-bottom: 16px;
+}
+
.history-card .card-actions button {
display: flex;
align-items: center;
@@ -666,6 +685,46 @@ button:active:not(:disabled) {
color: var(--red);
}
+/* Quick summarize — header-level star button next to copy */
+
+.history-card .quick-summarize {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ padding: 4px 10px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ line-height: 1;
+ color: var(--text-muted);
+ background: var(--surface-hover);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ cursor: pointer;
+ flex-shrink: 0;
+ align-self: center;
+ opacity: 0;
+ transition: opacity 200ms ease, border-color 200ms ease, background 200ms ease, align-self 200ms ease;
+}
+
+.history-card .quick-summarize .unicorn-icon {
+ font-size: 0.95rem;
+}
+
+.history-card:hover .quick-summarize {
+ opacity: 1;
+}
+
+.history-card .quick-summarize:hover {
+ border-color: rgba(108, 142, 239, 0.3);
+ background: var(--blue-dim);
+}
+
+.history-card.expanded .quick-summarize {
+ opacity: 1;
+ align-self: flex-start;
+}
+
/* ─── Expandable card body ─── */
.history-card.expandable {
@@ -705,6 +764,174 @@ button:active:not(:disabled) {
background: rgba(255, 255, 255, 0.2);
}
+/* ─── Card tabs ─── */
+
+.card-tabs {
+ width: 100%;
+ display: flex;
+ gap: 0;
+ padding: 0 16px;
+ border-bottom: 1px solid var(--border);
+}
+
+.card-tab:first-child {
+ padding-left: 0;
+}
+
+.card-tab {
+ padding: 8px 8px;
+ margin-bottom: -1px;
+ font-size: 0.68rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ background: none;
+ color: var(--text-dim);
+ border: none;
+ border-bottom: 2px solid transparent;
+ border-radius: 0;
+ cursor: pointer;
+ transition: color 200ms ease, border-color 200ms ease;
+}
+
+.card-tab:hover {
+ color: var(--text-muted);
+}
+
+.card-tab.active {
+ color: var(--blue);
+ border-bottom-color: var(--blue);
+}
+
+.card-tabs ~ .card-body,
+.card-tabs ~ .card-summary {
+ border-top: none;
+}
+
+/* ─── Summarize prompt ─── */
+
+.summarize-prompt {
+ width: 100%;
+ padding: 0;
+}
+
+.summarize-bar {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px 12px 10px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ transition: border-color 300ms ease, box-shadow 300ms ease;
+}
+
+.summarize-bar:focus-within {
+ border-color: rgba(108, 142, 239, 0.4);
+ box-shadow: 0 0 0 3px rgba(108, 142, 239, 0.1);
+}
+
+.summarize-input {
+ width: 100%;
+ padding: 0;
+ font-family: var(--font);
+ font-size: 0.825rem;
+ line-height: 1.5;
+ background: transparent;
+ color: var(--text);
+ border: none;
+ outline: none;
+ resize: none;
+}
+
+.summarize-input::placeholder {
+ color: var(--text-dim);
+}
+
+.summarize-bar-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 6px;
+}
+
+.summarize-generate-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 14px;
+ font-size: 0.78rem;
+ font-weight: 500;
+ background: var(--blue);
+ color: #fff;
+ border: 1px solid var(--blue);
+ border-radius: var(--radius);
+ flex-shrink: 0;
+}
+
+.summarize-generate-btn svg {
+ width: 14px;
+ height: 14px;
+}
+
+.summarize-generate-btn:hover:not(:disabled) {
+ background: #5a7de0;
+}
+
+.summarize-generate-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.summarize-cancel-btn {
+ padding: 7px 14px;
+ font-size: 0.78rem;
+ font-weight: 500;
+ background: var(--surface-hover);
+ color: var(--text-muted);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ flex-shrink: 0;
+}
+
+.summarize-cancel-btn:hover {
+ color: var(--text);
+ border-color: var(--border-hover);
+}
+
+/* ─── Card summary ─── */
+
+.card-summary {
+ width: 100%;
+ padding: 12px 16px;
+ border-top: 1px solid var(--border);
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.card-summary::-webkit-scrollbar {
+ width: 6px;
+}
+
+.card-summary::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.card-summary::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 3px;
+}
+
+.card-summary::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.2);
+}
+
+.card-summary-text {
+ font-size: 0.85rem;
+ line-height: 1.7;
+ color: var(--text-muted);
+ white-space: pre-wrap;
+}
+
/* ─── Toast ─── */
.toast {
@@ -794,18 +1021,22 @@ button:active:not(:disabled) {
padding: 0 12px;
}
- .app[data-state="idle"] {
- padding-bottom: 10vh;
+ .app {
+ padding-top: 32px;
}
- .app[data-state="active"] {
- padding-top: 32px;
+ .app[data-state="idle"] {
+ padding-top: 20vh;
}
.logo {
font-size: 1.375rem;
}
+ .logo-icon {
+ height: 44px;
+ }
+
.command-bar {
flex-wrap: wrap;
padding: 6px;
diff --git a/app/summarizer.py b/app/summarizer.py
new file mode 100644
index 0000000..de1b22e
--- /dev/null
+++ b/app/summarizer.py
@@ -0,0 +1,33 @@
+import logging
+
+from openai import AsyncOpenAI
+
+from app.config import settings
+
+logger = logging.getLogger(__name__)
+client = AsyncOpenAI(api_key=settings.openai_api_key)
+
+SYSTEM_MESSAGE = (
+ "You are a helpful assistant that summarizes transcripts. "
+ "Produce clear, well-structured summaries. Use bullet points for key points when appropriate. "
+ "Be concise but capture all important information."
+)
+
+
+async def summarize_text(text: str, prompt: str = "") -> str:
+ """Summarize transcript text using OpenAI Chat Completions."""
+ prompt = prompt.strip()
+ user_content = f"{prompt}\n\n---\n\n{text}"
+
+ logger.info("Summarizing %d chars with model %s", len(text), settings.summarize_model)
+ response = await client.chat.completions.create(
+ model=settings.summarize_model,
+ messages=[
+ {"role": "system", "content": SYSTEM_MESSAGE},
+ {"role": "user", "content": user_content},
+ ],
+ temperature=0.3,
+ )
+ summary = response.choices[0].message.content.strip()
+ logger.info("Summary generated: %d chars", len(summary))
+ return summary
diff --git a/docs/banner.svg b/docs/banner.svg
new file mode 100644
index 0000000..d9fc179
--- /dev/null
+++ b/docs/banner.svg
@@ -0,0 +1,53 @@
+
diff --git a/pyproject.toml b/pyproject.toml
index 724c5c2..8fdaaa5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -2,7 +2,9 @@
name = "transcript-maker"
version = "0.1.0"
description = "Paste a YouTube URL, get a transcript."
-authors = []
+authors = ["dmitry-kostin"]
+license = "MIT"
+repository = "https://github.com/dmitry-kostin/transcript-maker"
package-mode = false
[tool.poetry.dependencies]
diff --git a/tests/test_api_endpoints.py b/tests/test_api_endpoints.py
index b1b6bfb..12cbe7f 100644
--- a/tests/test_api_endpoints.py
+++ b/tests/test_api_endpoints.py
@@ -4,7 +4,7 @@
from fastapi.testclient import TestClient
from app.main import create_app
-from app.history import create_record, complete_record, RESULTS_DIR
+from app.history import create_record, complete_record, get_summary, RESULTS_DIR
from app.transcriber import prepare_chunks, MAX_CHUNK_DURATION_SECONDS
import app.history as history_mod
@@ -125,6 +125,68 @@ def test_in_progress_blocked(self, client, tmp_path, monkeypatch):
assert res.status_code == 409
+class TestSummarizeEndpoint:
+ def test_invalid_id(self, client):
+ res = client.post("/api/history/ZZZZZZZZ/summarize", json={"prompt": ""})
+ assert res.status_code == 400
+
+ def test_not_done_record(self, client, tmp_path, monkeypatch):
+ results_dir = tmp_path / "results"
+ results_dir.mkdir(exist_ok=True)
+ monkeypatch.setattr(history_mod, "RESULTS_DIR", results_dir)
+ rid = create_record("Test", "https://youtube.com/watch?v=abc", 60)
+ res = client.post(f"/api/history/{rid}/summarize", json={"prompt": ""})
+ assert res.status_code == 400
+
+ def test_successful_summarize(self, client, tmp_path, monkeypatch):
+ results_dir = tmp_path / "results"
+ results_dir.mkdir(exist_ok=True)
+ monkeypatch.setattr(history_mod, "RESULTS_DIR", results_dir)
+ rid = create_record("Test", "https://youtube.com/watch?v=abc", 60)
+ complete_record(rid, "This is a test transcript with multiple words.")
+ with patch("app.api.summarize_text", return_value="Mocked summary"):
+ res = client.post(f"/api/history/{rid}/summarize", json={"prompt": "Custom"})
+ assert res.status_code == 200
+ data = res.json()
+ assert data["summary"] == "Test\n\nMocked summary"
+ # Verify it was saved
+ saved = get_summary(rid)
+ assert saved is not None
+ assert saved["summary"] == "Test\n\nMocked summary"
+
+ def test_get_summary(self, client, tmp_path, monkeypatch):
+ results_dir = tmp_path / "results"
+ results_dir.mkdir(exist_ok=True)
+ monkeypatch.setattr(history_mod, "RESULTS_DIR", results_dir)
+ rid = create_record("Test", "https://youtube.com/watch?v=abc", 60)
+ complete_record(rid, "Transcript text")
+ with patch("app.api.summarize_text", return_value="The summary"):
+ client.post(f"/api/history/{rid}/summarize", json={"prompt": ""})
+ res = client.get(f"/api/history/{rid}/summary")
+ assert res.status_code == 200
+ assert res.json()["summary"] == "Test\n\nThe summary"
+
+ def test_get_summary_404(self, client, tmp_path, monkeypatch):
+ results_dir = tmp_path / "results"
+ results_dir.mkdir(exist_ok=True)
+ monkeypatch.setattr(history_mod, "RESULTS_DIR", results_dir)
+ rid = create_record("Test", "https://youtube.com/watch?v=abc", 60)
+ complete_record(rid, "Text")
+ res = client.get(f"/api/history/{rid}/summary")
+ assert res.status_code == 404
+
+ def test_demo_summarize(self, client, tmp_path, monkeypatch):
+ results_dir = tmp_path / "results"
+ results_dir.mkdir(exist_ok=True)
+ monkeypatch.setattr(history_mod, "RESULTS_DIR", results_dir)
+ rid = create_record("Test", "https://youtube.com/watch?v=abc", 60)
+ complete_record(rid, "Transcript text")
+ res = client.post(f"/api/demo/history/{rid}/summarize", json={"prompt": ""})
+ assert res.status_code == 200
+ data = res.json()
+ assert "Key Points" in data["summary"]
+
+
class TestPrepareChunks:
def test_small_file_short_duration_no_chunking(self, tmp_path):
"""File under size AND duration limits → no chunking."""
diff --git a/tests/test_history.py b/tests/test_history.py
index d33b5a0..076da45 100644
--- a/tests/test_history.py
+++ b/tests/test_history.py
@@ -6,6 +6,7 @@
delete_record, get_result_path,
cleanup_stale_records,
save_audio, get_audio_path,
+ save_summary, get_summary, delete_summary,
)
@@ -271,3 +272,77 @@ def test_delete_removes_audio(self, tmp_results, tmp_path):
delete_record(rid)
assert not cached.exists()
assert get_audio_path(rid) is None
+
+
+class TestSummaryCRUD:
+ def test_save_and_get(self, tmp_results):
+ rid = create_record("Vid", "https://youtube.com/watch?v=abc", 60)
+ complete_record(rid, "Some transcript text")
+ assert save_summary(rid, "This is the summary", "Custom prompt") is True
+ result = get_summary(rid)
+ assert result is not None
+ assert result["summary"] == "This is the summary"
+ assert result["prompt"] == "Custom prompt"
+ assert result["created_at"] != ""
+
+ def test_get_nonexistent(self, tmp_results):
+ rid = create_record("Vid", "https://youtube.com/watch?v=abc", 60)
+ assert get_summary(rid) is None
+
+ def test_get_invalid_id(self, tmp_results):
+ assert get_summary("not-hex!") is None
+
+ def test_save_for_nonexistent_record(self, tmp_results):
+ assert save_summary("00000000", "Summary text") is False
+
+ def test_delete_summary(self, tmp_results):
+ rid = create_record("Vid", "https://youtube.com/watch?v=abc", 60)
+ complete_record(rid, "Some text")
+ save_summary(rid, "Summary")
+ assert get_summary(rid) is not None
+ delete_summary(rid)
+ assert get_summary(rid) is None
+
+ def test_delete_record_removes_summary(self, tmp_results):
+ rid = create_record("Vid", "https://youtube.com/watch?v=abc", 60)
+ complete_record(rid, "Some text")
+ save_summary(rid, "Summary")
+ assert get_summary(rid) is not None
+ delete_record(rid)
+ assert get_summary(rid) is None
+
+ def test_overwrite_summary(self, tmp_results):
+ rid = create_record("Vid", "https://youtube.com/watch?v=abc", 60)
+ complete_record(rid, "Some text")
+ save_summary(rid, "First summary", "Prompt 1")
+ save_summary(rid, "Second summary", "Prompt 2")
+ result = get_summary(rid)
+ assert result["summary"] == "Second summary"
+ assert result["prompt"] == "Prompt 2"
+
+ def test_summary_not_in_history_glob(self, tmp_results):
+ rid = create_record("Vid", "https://youtube.com/watch?v=abc", 60)
+ complete_record(rid, "Some text")
+ save_summary(rid, "Summary")
+ records = get_history()
+ # Only the real record should appear, not the summary sidecar
+ assert len(records) == 1
+ assert records[0]["id"] == rid
+
+ def test_has_summary_flag_in_history(self, tmp_results):
+ rid = create_record("Vid", "https://youtube.com/watch?v=abc", 60)
+ complete_record(rid, "Some text")
+ records = get_history()
+ assert records[0]["has_summary"] is False
+ save_summary(rid, "Summary")
+ records = get_history()
+ assert records[0]["has_summary"] is True
+
+ def test_has_summary_flag_in_get_record(self, tmp_results):
+ rid = create_record("Vid", "https://youtube.com/watch?v=abc", 60)
+ complete_record(rid, "Some text")
+ record = get_record(rid)
+ assert record["has_summary"] is False
+ save_summary(rid, "Summary")
+ record = get_record(rid)
+ assert record["has_summary"] is True