Line Script App — Master Skill
Tổng quan dự án
Web app chuyên dụng cho Script Supervisor và đoàn phim độc lập Việt Nam. Cho phép:
- Upload và xem kịch bản PDF
- Kẻ tuyến (Script Lining) chuẩn ngành trực tiếp lên PDF
- Tự động tạo Shotlist từ các đường kẻ tuyến (auto-extract text từ PDF)
- Quản lý nhiều dự án phim
- Làm việc nhóm với phân quyền Admin / Member
Người dùng chính: Đạo diễn, Script Supervisor, đoàn phim ~5 người.
Thiết bị: Desktop, iPad (Apple Pencil), iPhone, Android — tất cả qua trình duyệt (PWA).
Tech Stack — ĐÃ CHỐT, KHÔNG THAY ĐỔI
| Layer |
Công nghệ |
Ghi chú |
| Frontend |
React 18 + Vite + TypeScript |
PWA enabled |
| PDF Viewer |
PDF.js (Mozilla) |
Render PDF lên canvas |
| Canvas / Drawing |
Fabric.js |
Vẽ line, annotation lên PDF |
| Apple Pencil |
Pointer Events API |
Qua Fabric.js, pressure-aware |
| Backend API |
Cloudflare Workers (TypeScript) |
Serverless |
| Database |
Cloudflare D1 (SQLite) |
Lưu users, projects, shots, annotations |
| File Storage |
Cloudflare R2 |
Lưu file PDF kịch bản |
| Auth |
JWT tự build (bcrypt + jose) |
Không dùng OAuth cho login |
| Hosting |
Cloudflare Pages |
Frontend deploy |
| Dev workflow |
VS Code → GitHub → Cloudflare |
Quy trình bất biến |
| Google Sheets Sync |
Google Sheets API v4 |
Sync shotlist 2 chiều (opt-in) |
| Storyboard Storage |
Google Drive API v3 |
Lưu ảnh storyboard, app chỉ dùng URL |
Nguyên tắc quan trọng:
- Ưu tiên công cụ miễn phí hoàn toàn — Cloudflare free tier đủ dùng cho team ~5 người
- KHÔNG dùng Supabase, Firebase, Vercel, hay bất kỳ service có thể bị pause/charge
- KHÔNG thêm dependency mới nếu không cần thiết — keep it lean
- Google Sheets/Drive là tính năng opt-in — user phải chủ động kết nối, không bắt buộc
Kiến trúc hệ thống
┌─────────────────────────────────────────┐
│ Cloudflare Pages │
│ React + Vite + TypeScript (PWA) │
│ PDF.js viewer + Fabric.js canvas │
└────────────────┬────────────────────────┘
│ fetch / REST API
┌────────────────▼────────────────────────┐
│ Cloudflare Workers │
│ TypeScript API endpoints │
│ Auth (JWT) + Business Logic │
└──────┬───────────┬──────────────┬───────┘
│ │ │
┌──────▼──────┐ ┌──▼──────┐ ┌────▼──────────────┐
│ Cloudflare │ │Cloudflare│ │ Google APIs │
│ D1 │ │ R2 │ │ (opt-in) │
│ (Database) │ │ (PDFs) │ │ Sheets API v4 │
└─────────────┘ └─────────┘ │ Drive API v3 │
└────────┬───────────┘
│ Apps Script
│ onEdit() webhook
┌────────▼───────────┐
│ Google Sheets │
│ (Shotlist sync) │
│ Google Drive │
│ (Storyboard imgs) │
└────────────────────┘
Database Schema (D1 SQLite)
-- Users & Auth
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT DEFAULT 'pending' CHECK (role IN ('super_admin', 'owner', 'member', 'pending')),
created_at INTEGER DEFAULT (unixepoch())
);
-- Invite tokens
CREATE TABLE invite_tokens (
token TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects(id),
created_by TEXT REFERENCES users(id),
email TEXT,
used_by TEXT REFERENCES users(id),
expires_at INTEGER NOT NULL,
created_at INTEGER DEFAULT (unixepoch())
);
-- Dự án phim
CREATE TABLE projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
owner_id TEXT REFERENCES users(id),
created_at INTEGER DEFAULT (unixepoch())
);
-- Thành viên dự án
CREATE TABLE project_members (
project_id TEXT REFERENCES projects(id),
user_id TEXT REFERENCES users(id),
PRIMARY KEY (project_id, user_id)
);
-- File kịch bản PDF
CREATE TABLE scripts (
id TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects(id),
name TEXT NOT NULL,
r2_key TEXT NOT NULL,
page_count INTEGER,
uploaded_by TEXT REFERENCES users(id),
created_at INTEGER DEFAULT (unixepoch())
);
-- Đường kẻ tuyến (Script Lines) — SCHEMA MỚI (migration 0006)
CREATE TABLE script_lines (
id TEXT PRIMARY KEY,
script_id TEXT REFERENCES scripts(id),
user_id TEXT REFERENCES users(id),
page_number INTEGER NOT NULL,
x_position REAL NOT NULL, -- % trục X (0-1), có thể update khi move ngang
y_start REAL NOT NULL, -- % trục Y điểm bắt đầu (0-1)
y_end REAL NOT NULL, -- % trục Y điểm kết thúc (0-1)
color TEXT DEFAULT '#000000',
segments_json TEXT, -- JSON: [{ type: 'straight'|'zigzag', y_start: 0.1, y_end: 0.3 }, ...]
-- Mỗi segment là một đoạn trên line với kiểu riêng
-- Bracket mark tự động chèn tại điểm chuyển tiếp giữa segments
continues_to_next_page BOOLEAN DEFAULT FALSE, -- true → hiển thị ↓ arrow thay End Bracket
continues_from_prev_page BOOLEAN DEFAULT FALSE, -- true → không có Start Bracket trên (hoặc ↑ indicator)
setup_number TEXT, -- e.g. '24', '24A', '24B'
created_at INTEGER DEFAULT (unixepoch())
);
-- GHI CHÚ line_type cũ đã bị loại bỏ — thay bằng segments_json
-- Mỗi line có thể có nhiều đoạn straight/zigzag trên cùng 1 đường
-- Scene markers (T-shaped, dùng để đánh dấu ranh giới cảnh)
CREATE TABLE scene_markers (
id TEXT PRIMARY KEY,
script_id TEXT REFERENCES scripts(id),
user_id TEXT REFERENCES users(id),
page_number INTEGER NOT NULL,
y_position REAL NOT NULL,
x_offset REAL DEFAULT 0,
created_at INTEGER DEFAULT (unixepoch())
);
-- Shots (Shotlist)
CREATE TABLE shots (
id TEXT PRIMARY KEY,
script_id TEXT REFERENCES scripts(id),
line_id TEXT REFERENCES script_lines(id),
user_id TEXT REFERENCES users(id),
shot_number INTEGER NOT NULL,
scene_number TEXT,
location TEXT,
int_ext TEXT CHECK (int_ext IN ('INT', 'EXT', 'INT/EXT')),
day_night TEXT CHECK (day_night IN ('DAY', 'NIGHT', 'DAWN', 'DUSK')),
description TEXT, -- AUTO-EXTRACTED từ PDF (chỉ đoạn STRAIGHT, bỏ qua zigzag)
-- Hiển thị blur/mờ trong shotlist, max-height giới hạn
user_notes TEXT, -- Do người dùng tự viết — đây là phần CHÍNH trong shotlist
dialogue TEXT,
subjects TEXT,
script_time TEXT,
shot_size TEXT,
shot_type TEXT,
side TEXT,
angle TEXT,
movement TEXT,
lens TEXT,
notes TEXT,
storyboard_drive_id TEXT,
storyboard_view_url TEXT,
sheets_row_index INTEGER,
updated_at INTEGER DEFAULT (unixepoch()),
created_at INTEGER DEFAULT (unixepoch())
);
-- Google OAuth tokens
CREATE TABLE google_tokens (
user_id TEXT PRIMARY KEY REFERENCES users(id),
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
expiry INTEGER NOT NULL,
sheets_id TEXT,
drive_folder_id TEXT,
updated_at INTEGER DEFAULT (unixepoch())
);
Line Script — Spec Chi Tiết (ĐÃ CHỐT 2026-03-31, CẬP NHẬT 2026-03-31)
Input / Device Compatibility — QUAN TRỌNG
App phải hoạt động đồng nhất trên cả 3 thiết bị:
| Thiết bị |
Click/Tap |
Context menu |
Move |
| Desktop (mouse) |
click chuột trái |
right-click |
drag chuột |
| iPad (touch) |
tap |
long-press ~500ms |
drag ngón tay |
| iPad (Apple Pencil) |
tap bút |
long-press bút |
drag bút |
Nguyên tắc xử lý event:
- Dùng Pointer Events API thống nhất (
pointerdown, pointermove, pointerup) — không dùng riêng touch/mouse events
- Context menu:
contextmenu event cho desktop, long-press (pointerdown hold > 500ms, không move) cho iPad
- Fabric.js:
canvas.allowTouchScrolling = false trên iPad để tránh cuộn trang khi vẽ
- KHÔNG phân biệt
pointerType === 'mouse' vs 'touch' vs 'pen' trừ khi cần thiết
Drawing Model: Click-Click (không phải drag)
- Click/Tap lần 1 → Label box (1/1, 1A...) xuất hiện ở đầu line, là điểm bắt đầu (không có start bracket riêng — label box ĐÃ là marker đầu)
- Di chuột/ngón tay → Ghost line preview theo chiều dọc (X cố định)
- Tab key / nút Split trên toolbar → toggle segment type straight ↔ zigzag → Bracket Mark tự chèn
- Click/Tap lần 2 → Line hoàn thành + End Bracket ở dưới
- Nếu y_end > ~95% page → ↓ arrow thay End Bracket,
continues_to_next_page = true
Continuation Across Pages
- x_position ±3% match với line
continues_to_next_page = true trang trước → auto-detect continuation
- Line continuation: không có label box trên đầu (hoặc ↑ nhỏ)
Post-Draw Editing — Select Mode
- Move ngang: kéo line trái/phải (lockMovementY) — PATCH API
x_position
- Desktop: drag chuột; iPad: drag ngón tay/bút
- Visual khi chọn line: Hiển thị dashed circular handles tại endpoint đầu và cuối (giống tham chiếu ảnh). Opacity ring xung quanh endpoint.
- Split:
- Desktop: right-click → "Split" trong context menu
- iPad: long-press → context menu → "Split"
- Toolbar: nút "Split" → click vào điểm trên line → tách segment tại đó
- Xóa: right-click/long-press → "Xóa line + shot"
- Undo/Redo: Cmd+Z / Cmd+Shift+Z (desktop) + 2 nút visual trên toolbar
Shot Label Box — Visual Design
┌─────┐
│ 1/1 │ ← nền trắng, border màu line, text màu line, font lớn hơn (14px+)
│ W │ ← shot_size (nếu có)
└──┬──┘
│ ← line bắt đầu từ đây (label IS the start point)
│
- Nằm TRÊN ĐỈNH line (không có start bracket riêng)
- Di chuyển CÙNG với line khi kéo ngang
- Format:
sceneNum/shotNum (e.g. "1/1") trên canvas
- Bên Shotlist tab: vẫn hiển thị đầy đủ scene info
- Bên line script canvas: chỉ hiện shot order number
Visual Rendering
| Element |
Mô tả |
| Line stroke |
strokeWidth: ~2px (mỏng, giống chuẩn ngành) |
| Straight segment |
Đường thẳng liền nét |
| Zigzag segment |
Custom SVG path /\/\/\/ |
| Start |
Label box (không có bracket riêng) |
| End Bracket |
Gạch ngang ~10px tại y_end |
| Transition Bracket |
Gạch ngang ~10px tại điểm chuyển |
| Continues ↓ |
Mũi tên xuống khi continues_to_next_page = true |
| Selection visual |
Dashed circular handle (opacity ring) tại endpoints khi selected |
Zigzag = Không lấy text vào description
- Extract text chỉ từ straight segments — zigzag bị bỏ qua
Toolbar Tools (ĐÃ CHỐT)
| Tool |
Phím tắt |
Mô tả |
| Select |
V |
Chọn/move line, hiện handles. Ẩn straight/zigzag buttons. |
| Draw |
L |
Click-click vẽ line |
| Split |
- |
Click vào line đang có → tách segment tại điểm đó |
| Scene |
S |
Thêm scene marker ngang |
| Text |
T |
Thêm text annotation box (draggable, resizable, color options) |
| Straight |
- |
Initial segment type = straight (ẩn khi Select mode) |
| Zigzag |
- |
Initial segment type = zigzag (ẩn khi Select mode) |
| Colors |
- |
6 preset + custom color picker |
| Undo |
Cmd+Z |
Undo action cuối |
| Redo |
Cmd+Shift+Z |
Redo |
Scene Marker — Visual Design
Đường ngang chạy ngang trang, gồm:
- Dashed horizontal line suốt chiều rộng trang
- Bên trái: box nhỏ "1/1 W" (scene/shot + shot size) có thể drag ngang
- Kế đó: số cảnh (1, 2...)
- Kế đó: tên cảnh auto-extract từ PDF (INT./EXT. format)
Text Annotation Tool
- Thêm text box tự do lên PDF canvas
- Draggable (kéo bất cứ đâu)
- Resizable (kéo góc để resize)
- Chọn được màu chữ
- Gõ text vào trong
- Lưu vào
annotations table (type = 'drawing', fabric_json)
- Có thể xóa qua right-click/long-press
Shotlist — Spec Chi Tiết (ĐÃ CHỐT 2026-03-31, CẬP NHẬT 2026-03-31)
Shot numbering
- Canvas label: hiện
sceneNum/shotNum theo vị trí line trên trang (computed client-side)
- Shotlist tab (ShotlistPanel): hiện đầy đủ scene info (scene_number, location, INT/EXT...)
- Khi vẽ line xong, shot tự động được tạo với shot_number đúng thứ tự — không hiện "Untitled" nữa
- Header shotlist item hiện:
Shot [shot_number] nếu chưa có scene_number
- Khi user fill vào scene_number → hiện đầy đủ
Description 2 phần
Phần 1 — Auto (từ PDF, chỉ straight segments):
- Field
description (DB) — hiển thị blur/mờ, read-only, max-height giới hạn, hover để unblur
Phần 2 — User notes (phần chính):
- Field
user_notes (DB) — editable, hiển thị nổi bật
Nút "→ Đến kịch bản"
- Click → navigate đến đúng page + flash highlight line trên canvas
Undo/Redo
- Cmd+Z / Cmd+Shift+Z
- 2 nút visual (↩ ↪) trên toolbar
- Stack lưu: thêm line, xóa line, move line, add segment break, add annotation
Quy tắc AI khi làm việc với project này
- Luôn thảo luận và confirm trước khi generate code (theo CLAUDE.md)
- Update skill file này ngay sau khi chốt spec/tính năng mới — để bất kỳ AI nào cũng nắm context
- Không thay đổi tech stack đã chốt
- Ưu tiên công cụ miễn phí
- Quy trình: VS Code → GitHub → Cloudflare
Files quan trọng
| File |
Chức năng |
frontend/src/components/viewer/ScriptCanvas.tsx |
Fabric.js canvas, vẽ line, drag, context menu |
frontend/src/components/viewer/LineToolbar.tsx |
Toolbar tool selection, color picker |
frontend/src/components/viewer/PDFViewer.tsx |
PDF render, text extraction, zoom/page |
frontend/src/components/viewer/ShotlistPanel.tsx |
Shotlist UI, edit, export |
frontend/src/components/viewer/viewer.css |
Tất cả styles cho viewer |
workers/src/routes/lines.ts |
CRUD API cho script_lines |
workers/src/routes/shots.ts |
CRUD API cho shots + Google Sheets sync |
workers/src/routes/scenes.ts |
CRUD API cho scene_markers |
workers/migrations/ |
D1 SQLite migrations |
Pseudocode — Drawing Logic (Fabric.js)
// State khi đang vẽ
type DrawingState = {
phase: 'idle' | 'preview' // idle: chưa click, preview: đã click 1 lần
startX: number // X cố định (normalized 0-1)
startY: number // Y điểm click 1 (normalized 0-1)
currentSegments: Array<{ type: 'straight' | 'zigzag', y_start: number, y_end: number }>
currentSegmentType: 'straight' | 'zigzag' // type của đoạn đang kéo
}
// Click 1: đặt start point
onClick_first(y, x) {
drawShotBox(x, y) // ô vuông label shot
drawStartBracket(x, y) // gạch ngang ~12px
state.phase = 'preview'
state.startX = x
state.startY = y
state.currentSegmentType = selectedToolType // straight hoặc zigzag
}
// Di chuột: ghost line
onMouseMove(y) {
if (state.phase !== 'preview') return
renderGhostLine(state.startX, state.startY, y, state.currentSegments, state.currentSegmentType)
}
// Tab: toggle segment type mid-draw
onTabKey(currentY) {
if (state.phase !== 'preview') return
state.currentSegments.push({ type: state.currentSegmentType, y_start: lastSegmentEnd, y_end: currentY })
drawBracketAt(state.startX, currentY) // bracket tại điểm chuyển
state.currentSegmentType = toggle(state.currentSegmentType)
}
// Click 2: kết thúc
onClick_second(y) {
state.currentSegments.push({ type: state.currentSegmentType, y_start: lastSegmentEnd, y_end: y })
const continuesNext = y > 0.95
if (continuesNext) {
drawArrowDown(state.startX, y)
} else {
drawEndBracket(state.startX, y)
}
saveToDB({ x_position: state.startX, y_start: state.startY, y_end: y,
segments_json: state.currentSegments, continues_to_next_page: continuesNext })
state.phase = 'idle'
}
// Auto-detect continuation khi tạo line mới
onLineSaved(newLine) {
const prevPageLine = linesOnPrevPage.find(l =>
l.continues_to_next_page && Math.abs(l.x_position - newLine.x_position) < 0.03
)
if (prevPageLine) {
newLine.continues_from_prev_page = true
// Update DB
}
}
1---2name: line-script-app3description: Master skill cho dự án Line Script App — web app làm phim dành cho Script Supervisor và đoàn phim độc lập. Dùng skill này cho BẤT KỲ tác vụ nào liên quan đến project này: viết code mới, sửa bug, thêm tính năng, thiết kế database, setup Cloudflare, cấu hình auth, xử lý PDF, vẽ line script, tạo shotlist, hay deploy. Trigger khi người dùng nhắc đến: line script, script lining, shotlist, kịch bản phim, PDF viewer, Fabric.js, Cloudflare Workers/D1/R2, Google Sheets sync, storyboard, Google Drive, hoặc bất kỳ phần nào của app này.4---5
6# Line Script App — Master Skill
7
8## Tổng quan dự án
9
10Web app chuyên dụng cho **Script Supervisor** và đoàn phim độc lập Việt Nam. Cho phép:
11- Upload và xem kịch bản PDF
12- Kẻ tuyến (Script Lining) chuẩn ngành trực tiếp lên PDF
13- Tự động tạo Shotlist từ các đường kẻ tuyến (auto-extract text từ PDF)
14- Quản lý nhiều dự án phim
15- Làm việc nhóm với phân quyền Admin / Member
16
17**Người dùng chính:** Đạo diễn, Script Supervisor, đoàn phim ~5 người.
18**Thiết bị:** Desktop, iPad (Apple Pencil), iPhone, Android — tất cả qua trình duyệt (PWA).
19
20---
21
22## Tech Stack — ĐÃ CHỐT, KHÔNG THAY ĐỔI
23
24| Layer | Công nghệ | Ghi chú |
25|---|---|---|
26| Frontend | React 18 + Vite + TypeScript | PWA enabled |
27| PDF Viewer | PDF.js (Mozilla) | Render PDF lên canvas |
28| Canvas / Drawing | Fabric.js | Vẽ line, annotation lên PDF |
29| Apple Pencil | Pointer Events API | Qua Fabric.js, pressure-aware |
30| Backend API | Cloudflare Workers (TypeScript) | Serverless |
31| Database | Cloudflare D1 (SQLite) | Lưu users, projects, shots, annotations |
32| File Storage | Cloudflare R2 | Lưu file PDF kịch bản |
33| Auth | JWT tự build (bcrypt + jose) | Không dùng OAuth cho login |
34| Hosting | Cloudflare Pages | Frontend deploy |
35| Dev workflow | VS Code → GitHub → Cloudflare | Quy trình bất biến |
36| Google Sheets Sync | Google Sheets API v4 | Sync shotlist 2 chiều (opt-in) |
37| Storyboard Storage | Google Drive API v3 | Lưu ảnh storyboard, app chỉ dùng URL |
38
39**Nguyên tắc quan trọng:**
40- Ưu tiên công cụ **miễn phí hoàn toàn** — Cloudflare free tier đủ dùng cho team ~5 người
41- KHÔNG dùng Supabase, Firebase, Vercel, hay bất kỳ service có thể bị pause/charge
42- KHÔNG thêm dependency mới nếu không cần thiết — keep it lean
43- Google Sheets/Drive là tính năng **opt-in** — user phải chủ động kết nối, không bắt buộc
44
45---
46
47## Kiến trúc hệ thống
48
49```text
50┌─────────────────────────────────────────┐
51│ Cloudflare Pages │
52│ React + Vite + TypeScript (PWA) │
53│ PDF.js viewer + Fabric.js canvas │
54└────────────────┬────────────────────────┘
55 │ fetch / REST API
56┌────────────────▼────────────────────────┐
57│ Cloudflare Workers │
58│ TypeScript API endpoints │
59│ Auth (JWT) + Business Logic │
60└──────┬───────────┬──────────────┬───────┘
61 │ │ │
62┌──────▼──────┐ ┌──▼──────┐ ┌────▼──────────────┐
63│ Cloudflare │ │Cloudflare│ │ Google APIs │
64│ D1 │ │ R2 │ │ (opt-in) │
65│ (Database) │ │ (PDFs) │ │ Sheets API v4 │
66└─────────────┘ └─────────┘ │ Drive API v3 │
67 └────────┬───────────┘
68 │ Apps Script
69 │ onEdit() webhook
70 ┌────────▼───────────┐
71 │ Google Sheets │
72 │ (Shotlist sync) │
73 │ Google Drive │
74 │ (Storyboard imgs) │
75 └────────────────────┘
76```
77
78---
79
80## Database Schema (D1 SQLite)
81
82```sql
83-- Users & Auth
84CREATE TABLE users (
85 id TEXT PRIMARY KEY,
86 email TEXT UNIQUE NOT NULL,
87 password_hash TEXT NOT NULL,
88 name TEXT NOT NULL,
89 role TEXT DEFAULT 'pending' CHECK (role IN ('super_admin', 'owner', 'member', 'pending')),
90 created_at INTEGER DEFAULT (unixepoch())
91);
92
93-- Invite tokens
94CREATE TABLE invite_tokens (
95 token TEXT PRIMARY KEY,
96 project_id TEXT REFERENCES projects(id),
97 created_by TEXT REFERENCES users(id),
98 email TEXT,
99 used_by TEXT REFERENCES users(id),
100 expires_at INTEGER NOT NULL,
101 created_at INTEGER DEFAULT (unixepoch())
102);
103
104-- Dự án phim
105CREATE TABLE projects (
106 id TEXT PRIMARY KEY,
107 name TEXT NOT NULL,
108 description TEXT,
109 owner_id TEXT REFERENCES users(id),
110 created_at INTEGER DEFAULT (unixepoch())
111);
112
113-- Thành viên dự án
114CREATE TABLE project_members (
115 project_id TEXT REFERENCES projects(id),
116 user_id TEXT REFERENCES users(id),
117 PRIMARY KEY (project_id, user_id)
118);
119
120-- File kịch bản PDF
121CREATE TABLE scripts (
122 id TEXT PRIMARY KEY,
123 project_id TEXT REFERENCES projects(id),
124 name TEXT NOT NULL,
125 r2_key TEXT NOT NULL,
126 page_count INTEGER,
127 uploaded_by TEXT REFERENCES users(id),
128 created_at INTEGER DEFAULT (unixepoch())
129);
130
131-- Đường kẻ tuyến (Script Lines) — SCHEMA MỚI (migration 0006)
132CREATE TABLE script_lines (
133 id TEXT PRIMARY KEY,
134 script_id TEXT REFERENCES scripts(id),
135 user_id TEXT REFERENCES users(id),
136 page_number INTEGER NOT NULL,
137 x_position REAL NOT NULL, -- % trục X (0-1), có thể update khi move ngang
138 y_start REAL NOT NULL, -- % trục Y điểm bắt đầu (0-1)
139 y_end REAL NOT NULL, -- % trục Y điểm kết thúc (0-1)
140 color TEXT DEFAULT '#000000',
141 segments_json TEXT, -- JSON: [{ type: 'straight'|'zigzag', y_start: 0.1, y_end: 0.3 }, ...]
142 -- Mỗi segment là một đoạn trên line với kiểu riêng
143 -- Bracket mark tự động chèn tại điểm chuyển tiếp giữa segments
144 continues_to_next_page BOOLEAN DEFAULT FALSE, -- true → hiển thị ↓ arrow thay End Bracket
145 continues_from_prev_page BOOLEAN DEFAULT FALSE, -- true → không có Start Bracket trên (hoặc ↑ indicator)
146 setup_number TEXT, -- e.g. '24', '24A', '24B'
147 created_at INTEGER DEFAULT (unixepoch())
148);
149-- GHI CHÚ line_type cũ đã bị loại bỏ — thay bằng segments_json
150-- Mỗi line có thể có nhiều đoạn straight/zigzag trên cùng 1 đường
151
152-- Scene markers (T-shaped, dùng để đánh dấu ranh giới cảnh)
153CREATE TABLE scene_markers (
154 id TEXT PRIMARY KEY,
155 script_id TEXT REFERENCES scripts(id),
156 user_id TEXT REFERENCES users(id),
157 page_number INTEGER NOT NULL,
158 y_position REAL NOT NULL,
159 x_offset REAL DEFAULT 0,
160 created_at INTEGER DEFAULT (unixepoch())
161);
162
163-- Shots (Shotlist)
164CREATE TABLE shots (
165 id TEXT PRIMARY KEY,
166 script_id TEXT REFERENCES scripts(id),
167 line_id TEXT REFERENCES script_lines(id),
168 user_id TEXT REFERENCES users(id),
169 shot_number INTEGER NOT NULL,
170 scene_number TEXT,
171 location TEXT,
172 int_ext TEXT CHECK (int_ext IN ('INT', 'EXT', 'INT/EXT')),
173 day_night TEXT CHECK (day_night IN ('DAY', 'NIGHT', 'DAWN', 'DUSK')),
174 description TEXT, -- AUTO-EXTRACTED từ PDF (chỉ đoạn STRAIGHT, bỏ qua zigzag)
175 -- Hiển thị blur/mờ trong shotlist, max-height giới hạn
176 user_notes TEXT, -- Do người dùng tự viết — đây là phần CHÍNH trong shotlist
177 dialogue TEXT,
178 subjects TEXT,
179 script_time TEXT,
180 shot_size TEXT,
181 shot_type TEXT,
182 side TEXT,
183 angle TEXT,
184 movement TEXT,
185 lens TEXT,
186 notes TEXT,
187 storyboard_drive_id TEXT,
188 storyboard_view_url TEXT,
189 sheets_row_index INTEGER,
190 updated_at INTEGER DEFAULT (unixepoch()),
191 created_at INTEGER DEFAULT (unixepoch())
192);
193
194-- Google OAuth tokens
195CREATE TABLE google_tokens (
196 user_id TEXT PRIMARY KEY REFERENCES users(id),
197 access_token TEXT NOT NULL,
198 refresh_token TEXT NOT NULL,
199 expiry INTEGER NOT NULL,
200 sheets_id TEXT,
201 drive_folder_id TEXT,
202 updated_at INTEGER DEFAULT (unixepoch())
203);
204```
205
206---
207
208## Line Script — Spec Chi Tiết (ĐÃ CHỐT 2026-03-31, CẬP NHẬT 2026-03-31)
209
210### Input / Device Compatibility — QUAN TRỌNG
211
212App phải hoạt động đồng nhất trên cả 3 thiết bị:
213
214| Thiết bị | Click/Tap | Context menu | Move |
215|---|---|---|---|
216| Desktop (mouse) | click chuột trái | right-click | drag chuột |
217| iPad (touch) | tap | long-press ~500ms | drag ngón tay |
218| iPad (Apple Pencil) | tap bút | long-press bút | drag bút |
219
220**Nguyên tắc xử lý event:**
221- Dùng **Pointer Events API** thống nhất (`pointerdown`, `pointermove`, `pointerup`) — không dùng riêng touch/mouse events
222- Context menu: `contextmenu` event cho desktop, `long-press` (pointerdown hold > 500ms, không move) cho iPad
223- Fabric.js: `canvas.allowTouchScrolling = false` trên iPad để tránh cuộn trang khi vẽ
224- KHÔNG phân biệt `pointerType === 'mouse'` vs `'touch'` vs `'pen'` trừ khi cần thiết
225
226---
227
228### Drawing Model: Click-Click (không phải drag)
229
2301. **Click/Tap lần 1** → Label box (1/1, 1A...) xuất hiện ở đầu line, là điểm bắt đầu (không có start bracket riêng — label box ĐÃ là marker đầu)
2312. **Di chuột/ngón tay** → Ghost line preview theo chiều dọc (X cố định)
2323. **Tab key / nút Split trên toolbar** → toggle segment type straight ↔ zigzag → Bracket Mark tự chèn
2334. **Click/Tap lần 2** → Line hoàn thành + End Bracket ở dưới
234 - Nếu y_end > ~95% page → ↓ arrow thay End Bracket, `continues_to_next_page = true`
235
236### Continuation Across Pages
237
238- x_position ±3% match với line `continues_to_next_page = true` trang trước → auto-detect continuation
239- Line continuation: không có label box trên đầu (hoặc ↑ nhỏ)
240
241### Post-Draw Editing — Select Mode
242
243- **Move ngang**: kéo line trái/phải (lockMovementY) — PATCH API `x_position`
244 - Desktop: drag chuột; iPad: drag ngón tay/bút
245- **Visual khi chọn line**: Hiển thị dashed circular handles tại endpoint đầu và cuối (giống tham chiếu ảnh). Opacity ring xung quanh endpoint.
246- **Split**:
247 - Desktop: right-click → "Split" trong context menu
248 - iPad: long-press → context menu → "Split"
249 - Toolbar: nút "Split" → click vào điểm trên line → tách segment tại đó
250- **Xóa**: right-click/long-press → "Xóa line + shot"
251- **Undo/Redo**: Cmd+Z / Cmd+Shift+Z (desktop) + 2 nút visual trên toolbar
252
253### Shot Label Box — Visual Design
254
255```
256┌─────┐
257│ 1/1 │ ← nền trắng, border màu line, text màu line, font lớn hơn (14px+)
258│ W │ ← shot_size (nếu có)
259└──┬──┘
260 │ ← line bắt đầu từ đây (label IS the start point)
261 │
262```
263
264- Nằm TRÊN ĐỈNH line (không có start bracket riêng)
265- Di chuyển CÙNG với line khi kéo ngang
266- Format: `sceneNum/shotNum` (e.g. "1/1") trên canvas
267- Bên Shotlist tab: vẫn hiển thị đầy đủ scene info
268- Bên line script canvas: chỉ hiện shot order number
269
270### Visual Rendering
271
272| Element | Mô tả |
273|---|---|
274| Line stroke | strokeWidth: **~2px** (mỏng, giống chuẩn ngành) |
275| Straight segment | Đường thẳng liền nét |
276| Zigzag segment | Custom SVG path `/\/\/\/` |
277| Start | Label box (không có bracket riêng) |
278| End Bracket | Gạch ngang ~10px tại y_end |
279| Transition Bracket | Gạch ngang ~10px tại điểm chuyển |
280| Continues ↓ | Mũi tên xuống khi `continues_to_next_page = true` |
281| Selection visual | Dashed circular handle (opacity ring) tại endpoints khi selected |
282
283### Zigzag = Không lấy text vào description
284
285- Extract text chỉ từ **straight** segments — zigzag bị bỏ qua
286
287---
288
289### Toolbar Tools (ĐÃ CHỐT)
290
291| Tool | Phím tắt | Mô tả |
292|---|---|---|
293| Select | V | Chọn/move line, hiện handles. Ẩn straight/zigzag buttons. |
294| Draw | L | Click-click vẽ line |
295| Split | - | Click vào line đang có → tách segment tại điểm đó |
296| Scene | S | Thêm scene marker ngang |
297| Text | T | Thêm text annotation box (draggable, resizable, color options) |
298| Straight | - | Initial segment type = straight (ẩn khi Select mode) |
299| Zigzag | - | Initial segment type = zigzag (ẩn khi Select mode) |
300| Colors | - | 6 preset + custom color picker |
301| Undo | Cmd+Z | Undo action cuối |
302| Redo | Cmd+Shift+Z | Redo |
303
304### Scene Marker — Visual Design
305
306Đường ngang chạy ngang trang, gồm:
307- Dashed horizontal line suốt chiều rộng trang
308- Bên trái: box nhỏ "1/1 W" (scene/shot + shot size) có thể drag ngang
309- Kế đó: số cảnh (1, 2...)
310- Kế đó: tên cảnh auto-extract từ PDF (INT./EXT. format)
311
312### Text Annotation Tool
313
314- Thêm text box tự do lên PDF canvas
315- Draggable (kéo bất cứ đâu)
316- Resizable (kéo góc để resize)
317- Chọn được màu chữ
318- Gõ text vào trong
319- Lưu vào `annotations` table (type = 'drawing', fabric_json)
320- Có thể xóa qua right-click/long-press
321
322---
323
324## Shotlist — Spec Chi Tiết (ĐÃ CHỐT 2026-03-31, CẬP NHẬT 2026-03-31)
325
326### Shot numbering
327
328- **Canvas label**: hiện `sceneNum/shotNum` theo vị trí line trên trang (computed client-side)
329- **Shotlist tab (ShotlistPanel)**: hiện đầy đủ scene info (scene_number, location, INT/EXT...)
330- Khi vẽ line xong, shot tự động được tạo với shot_number đúng thứ tự — không hiện "Untitled" nữa
331 - Header shotlist item hiện: `Shot [shot_number]` nếu chưa có scene_number
332 - Khi user fill vào scene_number → hiện đầy đủ
333
334### Description 2 phần
335
336**Phần 1 — Auto (từ PDF, chỉ straight segments):**
337- Field `description` (DB) — hiển thị blur/mờ, read-only, max-height giới hạn, hover để unblur
338
339**Phần 2 — User notes (phần chính):**
340- Field `user_notes` (DB) — editable, hiển thị nổi bật
341
342### Nút "→ Đến kịch bản"
343- Click → navigate đến đúng page + flash highlight line trên canvas
344
345### Undo/Redo
346- Cmd+Z / Cmd+Shift+Z
347- 2 nút visual (↩ ↪) trên toolbar
348- Stack lưu: thêm line, xóa line, move line, add segment break, add annotation
349
350---
351
352## Quy tắc AI khi làm việc với project này
353
3541. **Luôn thảo luận và confirm trước** khi generate code (theo CLAUDE.md)
3552. **Update skill file này** ngay sau khi chốt spec/tính năng mới — để bất kỳ AI nào cũng nắm context
3563. Không thay đổi tech stack đã chốt
3574. Ưu tiên công cụ miễn phí
3585. Quy trình: VS Code → GitHub → Cloudflare
359
360---
361
362## Files quan trọng
363
364| File | Chức năng |
365|---|---|
366| `frontend/src/components/viewer/ScriptCanvas.tsx` | Fabric.js canvas, vẽ line, drag, context menu |
367| `frontend/src/components/viewer/LineToolbar.tsx` | Toolbar tool selection, color picker |
368| `frontend/src/components/viewer/PDFViewer.tsx` | PDF render, text extraction, zoom/page |
369| `frontend/src/components/viewer/ShotlistPanel.tsx` | Shotlist UI, edit, export |
370| `frontend/src/components/viewer/viewer.css` | Tất cả styles cho viewer |
371| `workers/src/routes/lines.ts` | CRUD API cho script_lines |
372| `workers/src/routes/shots.ts` | CRUD API cho shots + Google Sheets sync |
373| `workers/src/routes/scenes.ts` | CRUD API cho scene_markers |
374| `workers/migrations/` | D1 SQLite migrations |
375
376---
377
378## Pseudocode — Drawing Logic (Fabric.js)
379
380```typescript
381// State khi đang vẽ
382type DrawingState = {
383 phase: 'idle' | 'preview' // idle: chưa click, preview: đã click 1 lần
384 startX: number // X cố định (normalized 0-1)
385 startY: number // Y điểm click 1 (normalized 0-1)
386 currentSegments: Array<{ type: 'straight' | 'zigzag', y_start: number, y_end: number }>
387 currentSegmentType: 'straight' | 'zigzag' // type của đoạn đang kéo
388}
389
390// Click 1: đặt start point
391onClick_first(y, x) {
392 drawShotBox(x, y) // ô vuông label shot
393 drawStartBracket(x, y) // gạch ngang ~12px
394 state.phase = 'preview'
395 state.startX = x
396 state.startY = y
397 state.currentSegmentType = selectedToolType // straight hoặc zigzag
398}
399
400// Di chuột: ghost line
401onMouseMove(y) {
402 if (state.phase !== 'preview') return
403 renderGhostLine(state.startX, state.startY, y, state.currentSegments, state.currentSegmentType)
404}
405
406// Tab: toggle segment type mid-draw
407onTabKey(currentY) {
408 if (state.phase !== 'preview') return
409 state.currentSegments.push({ type: state.currentSegmentType, y_start: lastSegmentEnd, y_end: currentY })
410 drawBracketAt(state.startX, currentY) // bracket tại điểm chuyển
411 state.currentSegmentType = toggle(state.currentSegmentType)
412}
413
414// Click 2: kết thúc
415onClick_second(y) {
416 state.currentSegments.push({ type: state.currentSegmentType, y_start: lastSegmentEnd, y_end: y })
417 const continuesNext = y > 0.95
418 if (continuesNext) {
419 drawArrowDown(state.startX, y)
420 } else {
421 drawEndBracket(state.startX, y)
422 }
423 saveToDB({ x_position: state.startX, y_start: state.startY, y_end: y,
424 segments_json: state.currentSegments, continues_to_next_page: continuesNext })
425 state.phase = 'idle'
426}
427
428// Auto-detect continuation khi tạo line mới
429onLineSaved(newLine) {
430 const prevPageLine = linesOnPrevPage.find(l =>
431 l.continues_to_next_page && Math.abs(l.x_position - newLine.x_position) < 0.03
432 )
433 if (prevPageLine) {
434 newLine.continues_from_prev_page = true
435 // Update DB
436 }
437}
438```