컷별 카드 장수 공식을 floor로 통일 — round와 어긋나 카드가 조용히 버려지던 문제 수정

recommend.quotas_for()는 round, pipeline._cards_by_cut()의 컷당 장수 상한은 floor를
써서 둘이 어긋났다. 5초 컷처럼 round가 floor보다 큰 쪽으로 갈리는 컷은 UI가 카드
2장을 고르게 하고도 빌드 단계에서 상한(1장)에 걸려 뒤 1장이 로그 없이 버려졌다.
사용자 결정에 따라 floor로 통일(카드 한 장이 항상 3초 이상 — 기존
_load_comment_cards 규칙과 동일)하고, 무음 제거로 컷이 압축돼 여전히 카드가
버려지는 경우(이건 불가피)를 컷별 배치 로그에 표시해 더 이상 조용히 사라지지
않게 했다. 관련 설계/계획 문서의 공식·테스트 기대값도 floor 기준으로 맞췄다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-04 15:05:14 +09:00
parent 754692a343
commit c39b72518f
4 changed files with 30 additions and 15 deletions

View File

@ -519,9 +519,15 @@ async def process_paste(
# 컷 소속이 지정됐으면 그 컷 구간 안, 아니면 폴더 전체 균등 배치 # 컷 소속이 지정됐으면 그 컷 구간 안, 아니면 폴더 전체 균등 배치
cards = cut_cards or _load_comment_cards(comments_dir, timeline_dur, fixed=cards_fixed) cards = cut_cards or _load_comment_cards(comments_dir, timeline_dur, fixed=cards_fixed)
if cards: if cards:
yield {"type": "log", msg = f"댓글 카드 {len(cards)}개 하단 삽입"
"msg": f"댓글 카드 {len(cards)}개 하단 삽입" if cut_cards:
+ ("(컷별 배치)" if cut_cards else "(전체 균등)")} # 무음 제거로 컷이 짧아지면 quotas_for(원본 길이)가 고른 장수보다
# _cards_by_cut(압축 후 길이)의 상한이 작아질 수 있다 — 그 차이를 조용히 삼키지 않는다.
dropped = len(card_cuts or []) - len(cut_cards)
msg += "(컷별 배치" + (f", {dropped}장은 컷 길이가 짧아 제외)" if dropped > 0 else ")")
else:
msg += "(전체 균등)"
yield {"type": "log", "msg": msg}
path = await asyncio.to_thread( path = await asyncio.to_thread(
lambda: build_bg_template_draft( lambda: build_bg_template_draft(

View File

@ -21,8 +21,14 @@ CARD_SEC = 3.0
def quotas_for(cuts) -> List[int]: def quotas_for(cuts) -> List[int]:
"""컷별 카드 장수 — max(1, round(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장.""" """컷별 카드 장수 — max(1, floor(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장.
return [max(1, round((c["end"] - c["start"]) / CARD_SEC)) for c in cuts]
round 아니라 floor 이유: `pipeline._cards_by_cut` 컷당 장수 상한(무음 제거
길이 기준)·`pipeline._load_comment_cards` 규칙과 같은 공식이어야, 여기서 고른 카드가
빌드 단계에서 말없이 잘려나가지 않는다(round 쓰면 5 컷처럼 상한보다 1 골라
조용히 버려지는 경우가 생겼다).
"""
return [max(1, int((c["end"] - c["start"]) // CARD_SEC)) for c in cuts]
def build_cut_picks(cuts, comments, ai_picks: Optional[Dict[int, List[int]]], def build_cut_picks(cuts, comments, ai_picks: Optional[Dict[int, List[int]]],

View File

@ -160,7 +160,7 @@ Gemini를 호출하지 않으므로 단독 검증이 된다.
- Consumes: `comments.match_ranges` - Consumes: `comments.match_ranges`
- Produces: - Produces:
- `CARD_SEC = 3.0` - `CARD_SEC = 3.0`
- `quotas_for(cuts) -> List[int]` — 컷별 카드 장수 `max(1, round(len/CARD_SEC))` - `quotas_for(cuts) -> List[int]` — 컷별 카드 장수 `max(1, floor(len/CARD_SEC))`
- `build_cut_picks(cuts, comments, ai_picks, quotas) -> List[List[dict]]` - `build_cut_picks(cuts, comments, ai_picks, quotas) -> List[List[dict]]`
— 컷별 `[{"idx": int, "why": "ts"|"ai"}]` — 컷별 `[{"idx": int, "why": "ts"|"ai"}]`
@ -172,9 +172,9 @@ from capcut_agent.recommend import quotas_for, build_cut_picks
cuts = [{"start": 100.0, "end": 105.0, "bottom": "아까랑 완전 스타일이 달라"}, cuts = [{"start": 100.0, "end": 105.0, "bottom": "아까랑 완전 스타일이 달라"},
{"start": 200.0, "end": 206.0, "bottom": "우리에게 익숙한 평냥은"}] {"start": 200.0, "end": 206.0, "bottom": "우리에게 익숙한 평냥은"}]
assert quotas_for(cuts) == [2, 2], quotas_for(cuts) assert quotas_for(cuts) == [1, 2], quotas_for(cuts) # floor(5/3)=1, floor(6/3)=2
assert quotas_for([{"start": 0.0, "end": 1.0, "bottom": ""}]) == [1] # 짧아도 최소 1 assert quotas_for([{"start": 0.0, "end": 1.0, "bottom": ""}]) == [1] # 짧아도 최소 1
assert quotas_for([{"start": 0.0, "end": 10.0, "bottom": ""}]) == [3] # round(10/3)=3 assert quotas_for([{"start": 0.0, "end": 10.0, "bottom": ""}]) == [3] # floor(10/3)=3
cs = [ cs = [
{"idx": 0, "likeCount": 50, "times": [101.0]}, # 컷0 언급 {"idx": 0, "likeCount": 50, "times": [101.0]}, # 컷0 언급
@ -226,8 +226,8 @@ CARD_SEC = 3.0
def quotas_for(cuts) -> List[int]: def quotas_for(cuts) -> List[int]:
"""컷별 카드 장수 — max(1, round(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장.""" """컷별 카드 장수 — max(1, floor(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장."""
return [max(1, round((c["end"] - c["start"]) / CARD_SEC)) for c in cuts] return [max(1, int((c["end"] - c["start"]) // CARD_SEC)) for c in cuts]
def build_cut_picks(cuts, comments, ai_picks: Dict[int, List[int]], def build_cut_picks(cuts, comments, ai_picks: Dict[int, List[int]],
@ -584,14 +584,14 @@ multi = {"paste": {"cuts": [
{"start": 100.0, "end": 105.0, "bottom": "가", "effect": ""}, {"start": 100.0, "end": 105.0, "bottom": "가", "effect": ""},
{"start": 200.0, "end": 206.0, "bottom": "나", "effect": ""}]}} {"start": 200.0, "end": 206.0, "bottom": "나", "effect": ""}]}}
cuts3, need3 = recommend.build_highlight_cuts(multi, cs, key="") cuts3, need3 = recommend.build_highlight_cuts(multi, cs, key="")
assert need3 == 4, need3 assert need3 == 3, need3 # floor(5/3)=1 + floor(6/3)=2
assert cuts3[0]["picks"] == [{"idx": 1, "why": "ts"}, {"idx": 0, "why": "ts"}] assert cuts3[0]["picks"] == [{"idx": 1, "why": "ts"}] # quota 1 → 좋아요 높은 idx1 만
assert cuts3[1]["picks"] == [] assert cuts3[1]["picks"] == []
assert cuts3[1]["bottom"] == "나" and cuts3[1]["sec"] == 6.0 assert cuts3[1]["bottom"] == "나" and cuts3[1]["sec"] == 6.0
# 댓글이 아예 없어도 터지지 않는다 # 댓글이 아예 없어도 터지지 않는다
c4, n4 = recommend.build_highlight_cuts(multi, [], key="") c4, n4 = recommend.build_highlight_cuts(multi, [], key="")
assert n4 == 4 and all(x["picks"] == [] for x in c4) assert n4 == 3 and all(x["picks"] == [] for x in c4)
print("Task5 OK") print("Task5 OK")
``` ```
@ -1293,7 +1293,7 @@ for tr in j["tracks"]:
| §4.1 모드 분기 | Task 5 (`is_whole`) | | §4.1 모드 분기 | Task 5 (`is_whole`) |
| §4.2 컷 있는 모드 — Gemini + 타임스탬프 우선 + 중복 제거 | Task 2, 4, 5 | | §4.2 컷 있는 모드 — Gemini + 타임스탬프 우선 + 중복 제거 | Task 2, 4, 5 |
| §4.2 통짜 — 슬롯 배정 + 좋아요 메움 | Task 1, 5 | | §4.2 통짜 — 슬롯 배정 + 좋아요 메움 | Task 1, 5 |
| §4.3 `quota = max(1, round(len/3))`, `need = Σ quota` | Task 2, 5 | | §4.3 `quota = max(1, floor(len/3))`, `need = Σ quota` | Task 2, 5 |
| §4.4 `card_cuts` 전달 + `placements` 계산 + 무음 재매핑 | Task 7, 8, 9 | | §4.4 `card_cuts` 전달 + `placements` 계산 + 무음 재매핑 | Task 7, 8, 9 |
| §5 데이터 스키마 (`cuts[]`, `picks[].why`, `card_cuts`) | Task 5, 6, 8 | | §5 데이터 스키마 (`cuts[]`, `picks[].why`, `card_cuts`) | Task 5, 6, 8 |
| §6 변경 파일 6개 | Task 1~9 | | §6 변경 파일 6개 | Task 1~9 |

View File

@ -91,8 +91,11 @@
### 4.3 카드 장수 ### 4.3 카드 장수
컷별 `quota_i = max(1, round(len_i / 3))`. 하이라이트의 `need = Σ quota_i` 컷별 `quota_i = max(1, floor(len_i / 3))`. 하이라이트의 `need = Σ quota_i`
(기존 `int(total // 3)`을 대체 — 값이 ±1 다를 수 있으나 컷 경계에 맞추는 쪽이 맞다). (기존 `int(total // 3)`을 대체 — 값이 ±1 다를 수 있으나 컷 경계에 맞추는 쪽이 맞다).
round가 아니라 floor인 이유: 빌드 단계(`_cards_by_cut`)의 컷당 장수 상한도 같은
`max(1, floor(컷길이/3))`이라, round를 쓰면 5초 컷처럼 여기서 2장을 고르고도
빌드에서 상한(1장)에 걸려 1장이 말없이 버려지는 불일치가 생긴다.
### 4.4 배치 — 시간은 파이프라인이 계산한다 ### 4.4 배치 — 시간은 파이프라인이 계산한다