diff --git a/capcut_agent/pipeline.py b/capcut_agent/pipeline.py index e95164d..3d87a9e 100644 --- a/capcut_agent/pipeline.py +++ b/capcut_agent/pipeline.py @@ -519,9 +519,15 @@ async def process_paste( # 컷 소속이 지정됐으면 그 컷 구간 안, 아니면 폴더 전체 균등 배치 cards = cut_cards or _load_comment_cards(comments_dir, timeline_dur, fixed=cards_fixed) if cards: - yield {"type": "log", - "msg": f"댓글 카드 {len(cards)}개 하단 삽입" - + ("(컷별 배치)" if cut_cards else "(전체 균등)")} + msg = f"댓글 카드 {len(cards)}개 하단 삽입" + if cut_cards: + # 무음 제거로 컷이 짧아지면 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( lambda: build_bg_template_draft( diff --git a/capcut_agent/recommend.py b/capcut_agent/recommend.py index f7e4b60..ac48e9b 100644 --- a/capcut_agent/recommend.py +++ b/capcut_agent/recommend.py @@ -21,8 +21,14 @@ CARD_SEC = 3.0 def quotas_for(cuts) -> List[int]: - """컷별 카드 장수 — max(1, round(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장.""" - return [max(1, round((c["end"] - c["start"]) / CARD_SEC)) for c in cuts] + """컷별 카드 장수 — max(1, floor(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장. + + 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]]], diff --git a/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md b/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md index c1554b3..e5c6522 100644 --- a/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md +++ b/docs/superpowers/plans/2026-08-04-컷별-댓글-추천.md @@ -160,7 +160,7 @@ Gemini를 호출하지 않으므로 단독 검증이 된다. - Consumes: `comments.match_ranges` - Produces: - `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]]` — 컷별 `[{"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": "아까랑 완전 스타일이 달라"}, {"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": 10.0, "bottom": ""}]) == [3] # round(10/3)=3 +assert quotas_for([{"start": 0.0, "end": 10.0, "bottom": ""}]) == [3] # floor(10/3)=3 cs = [ {"idx": 0, "likeCount": 50, "times": [101.0]}, # 컷0 언급 @@ -226,8 +226,8 @@ CARD_SEC = 3.0 def quotas_for(cuts) -> List[int]: - """컷별 카드 장수 — max(1, round(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장.""" - return [max(1, round((c["end"] - c["start"]) / CARD_SEC)) for c in cuts] + """컷별 카드 장수 — max(1, floor(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장.""" + 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]], @@ -584,14 +584,14 @@ multi = {"paste": {"cuts": [ {"start": 100.0, "end": 105.0, "bottom": "가", "effect": ""}, {"start": 200.0, "end": 206.0, "bottom": "나", "effect": ""}]}} cuts3, need3 = recommend.build_highlight_cuts(multi, cs, key="") -assert need3 == 4, need3 -assert cuts3[0]["picks"] == [{"idx": 1, "why": "ts"}, {"idx": 0, "why": "ts"}] +assert need3 == 3, need3 # floor(5/3)=1 + floor(6/3)=2 +assert cuts3[0]["picks"] == [{"idx": 1, "why": "ts"}] # quota 1 → 좋아요 높은 idx1 만 assert cuts3[1]["picks"] == [] assert cuts3[1]["bottom"] == "나" and cuts3[1]["sec"] == 6.0 # 댓글이 아예 없어도 터지지 않는다 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") ``` @@ -1293,7 +1293,7 @@ for tr in j["tracks"]: | §4.1 모드 분기 | Task 5 (`is_whole`) | | §4.2 컷 있는 모드 — Gemini + 타임스탬프 우선 + 중복 제거 | Task 2, 4, 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 | | §5 데이터 스키마 (`cuts[]`, `picks[].why`, `card_cuts`) | Task 5, 6, 8 | | §6 변경 파일 6개 | Task 1~9 | diff --git a/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md b/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md index fd319a5..75a4487 100644 --- a/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md +++ b/docs/superpowers/specs/2026-08-04-컷별-댓글-추천-design.md @@ -91,8 +91,11 @@ ### 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 다를 수 있으나 컷 경계에 맞추는 쪽이 맞다). +round가 아니라 floor인 이유: 빌드 단계(`_cards_by_cut`)의 컷당 장수 상한도 같은 +`max(1, floor(컷길이/3))`이라, round를 쓰면 5초 컷처럼 여기서 2장을 고르고도 +빌드에서 상한(1장)에 걸려 1장이 말없이 버려지는 불일치가 생긴다. ### 4.4 배치 — 시간은 파이프라인이 계산한다