v0.15: Live Photo 生产规则 — 平台限制、拼图版式、长视频处理、发布提醒、可见文案规则

新增 Live Photo 正式能力:单视频、二宫格、三宫格、四宫格、三连拼图、
长视频低成本诊断。增加 references/live-photo-production.md 生产规范、
scripts/ 工具链(contact sheet / .pvt 打包 / Apple metadata)、
validator R8 visual bounds + R9 title gap、文档约束测试。

由 Codex 完成主体实现。
This commit is contained in:
郭浩 committed 2026-07-01 16:11:34 +08:00
1 parent 032782ff67
commit f492caeb8b
20 files changed
+1982 -22

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
import Foundation
import ImageIO
guard CommandLine.arguments.count == 4 else {
fputs("Usage: swift add-livephoto-maker-note.swift <input.jpg> <output.jpg> <asset-id>\n", stderr)
exit(2)
}
let inputURL = URL(fileURLWithPath: CommandLine.arguments[1])
let outputURL = URL(fileURLWithPath: CommandLine.arguments[2])
let assetIdentifier = CommandLine.arguments[3]
guard let source = CGImageSourceCreateWithURL(inputURL as CFURL, nil) else {
fputs("Could not open input image\n", stderr)
exit(1)
}
guard let imageType = CGImageSourceGetType(source) else {
fputs("Could not determine image type\n", stderr)
exit(1)
}
let imageCount = CGImageSourceGetCount(source)
guard let destination = CGImageDestinationCreateWithURL(outputURL as CFURL, imageType, imageCount, nil) else {
fputs("Could not create output image\n", stderr)
exit(1)
}
for index in 0..<imageCount {
var properties = (CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [String: Any]) ?? [:]
var makerApple = (properties[kCGImagePropertyMakerAppleDictionary as String] as? [String: Any]) ?? [:]
makerApple["17"] = assetIdentifier
properties[kCGImagePropertyMakerAppleDictionary as String] = makerApple
CGImageDestinationAddImageFromSource(destination, source, index, properties as CFDictionary)
}
guard CGImageDestinationFinalize(destination) else {
fputs("Could not finalize output image\n", stderr)
exit(1)
}
print("Wrote Apple MakerNote asset identifier: \(assetIdentifier)")
+140
View File
@@ -0,0 +1,140 @@
import Foundation
import AVFoundation
import CoreMedia
guard CommandLine.arguments.count == 4 else {
fputs("Usage: swift add-livephoto-mov-metadata.swift <input.mov> <output.mov> <asset-id>\n", stderr)
exit(2)
}
let inputURL = URL(fileURLWithPath: CommandLine.arguments[1])
let outputURL = URL(fileURLWithPath: CommandLine.arguments[2])
let assetIdentifier = CommandLine.arguments[3]
try? FileManager.default.removeItem(at: outputURL)
func quickTimeMetadataItem(key: String, value: NSCopying & NSObjectProtocol, dataType: String) -> AVMutableMetadataItem {
let item = AVMutableMetadataItem()
item.keySpace = .quickTimeMetadata
item.key = key as NSString
item.value = value
item.dataType = dataType
return item
}
let asset = AVURLAsset(url: inputURL)
let semaphore = DispatchSemaphore(value: 0)
var didFail = false
Task {
do {
let tracks = try await asset.loadTracks(withMediaType: .video)
guard let videoTrack = tracks.first else {
throw NSError(domain: "LivePhotoMetadata", code: 1, userInfo: [NSLocalizedDescriptionKey: "No video track"])
}
let reader = try AVAssetReader(asset: asset)
let readerOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: nil)
readerOutput.alwaysCopiesSampleData = false
guard reader.canAdd(readerOutput) else {
throw NSError(domain: "LivePhotoMetadata", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add reader output"])
}
reader.add(readerOutput)
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mov)
let formatDescriptions = try await videoTrack.load(.formatDescriptions)
let sourceFormatHint = formatDescriptions.first
let writerInput = AVAssetWriterInput(mediaType: .video, outputSettings: nil, sourceFormatHint: sourceFormatHint)
writerInput.expectsMediaDataInRealTime = false
guard writer.canAdd(writerInput) else {
throw NSError(domain: "LivePhotoMetadata", code: 3, userInfo: [NSLocalizedDescriptionKey: "Cannot add writer input"])
}
writer.add(writerInput)
let contentIdentifier = quickTimeMetadataItem(
key: "com.apple.quicktime.content.identifier",
value: assetIdentifier as NSString,
dataType: "com.apple.metadata.datatype.UTF-8"
)
writer.metadata = [contentIdentifier]
let metadataSpec: [String: Any] = [
kCMMetadataFormatDescriptionMetadataSpecificationKey_Identifier as String: "mdta/com.apple.quicktime.still-image-time",
kCMMetadataFormatDescriptionMetadataSpecificationKey_DataType as String: "com.apple.metadata.datatype.int8"
]
var metadataDescription: CMFormatDescription?
let status = CMMetadataFormatDescriptionCreateWithMetadataSpecifications(
allocator: kCFAllocatorDefault,
metadataType: kCMMetadataFormatType_Boxed,
metadataSpecifications: [metadataSpec] as CFArray,
formatDescriptionOut: &metadataDescription
)
guard status == noErr, let metadataDescription else {
throw NSError(domain: "LivePhotoMetadata", code: 4, userInfo: [NSLocalizedDescriptionKey: "Cannot create metadata description"])
}
let metadataInput = AVAssetWriterInput(mediaType: .metadata, outputSettings: nil, sourceFormatHint: metadataDescription)
let metadataAdaptor = AVAssetWriterInputMetadataAdaptor(assetWriterInput: metadataInput)
guard writer.canAdd(metadataInput) else {
throw NSError(domain: "LivePhotoMetadata", code: 5, userInfo: [NSLocalizedDescriptionKey: "Cannot add metadata input"])
}
writer.add(metadataInput)
guard writer.startWriting() else {
throw writer.error ?? NSError(domain: "LivePhotoMetadata", code: 6)
}
guard reader.startReading() else {
throw reader.error ?? NSError(domain: "LivePhotoMetadata", code: 7)
}
writer.startSession(atSourceTime: .zero)
let stillImageTime = quickTimeMetadataItem(
key: "com.apple.quicktime.still-image-time",
value: NSNumber(value: Int8(-1)),
dataType: "com.apple.metadata.datatype.int8"
)
let stillGroup = AVTimedMetadataGroup(
items: [stillImageTime],
timeRange: CMTimeRange(start: .zero, duration: CMTime(value: 1, timescale: 600))
)
guard metadataAdaptor.append(stillGroup) else {
throw writer.error ?? NSError(domain: "LivePhotoMetadata", code: 8, userInfo: [NSLocalizedDescriptionKey: "Could not append still image time metadata"])
}
metadataInput.markAsFinished()
let queue = DispatchQueue(label: "livephoto.video.writer")
writerInput.requestMediaDataWhenReady(on: queue) {
while writerInput.isReadyForMoreMediaData {
if let sample = readerOutput.copyNextSampleBuffer() {
if !writerInput.append(sample) {
didFail = true
reader.cancelReading()
writerInput.markAsFinished()
writer.cancelWriting()
semaphore.signal()
return
}
} else {
writerInput.markAsFinished()
writer.finishWriting {
semaphore.signal()
}
return
}
}
}
} catch {
didFail = true
fputs("Error: \(error.localizedDescription)\n", stderr)
semaphore.signal()
}
}
semaphore.wait()
if didFail {
exit(1)
}
print("Wrote QuickTime Live Photo metadata: \(assetIdentifier)")
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
import fs from "node:fs";
const checks = [];
function fileText(file) {
return fs.readFileSync(file, "utf8");
}
function mustInclude(file, needle, label) {
checks.push(() => {
const ok = fileText(file).includes(needle);
return {
ok,
label,
detail: ok ? `${file} includes "${needle}"` : `${file} must include "${needle}"`,
};
});
}
function mustNotMatch(file, pattern, label) {
checks.push(() => {
const ok = !pattern.test(fileText(file));
return {
ok,
label,
detail: ok ? `${file} does not match ${pattern}` : `${file} must not match ${pattern}`,
};
});
}
mustInclude(
"SKILL.md",
"Generated work must live in a task folder, not in the skill root.",
"root output guardrail in SKILL.md",
);
mustInclude(
"SKILL.md",
"local-tests/<slug>/",
"default task folder in SKILL.md",
);
mustInclude(
"SKILL.md",
"Triple Live Photo",
"triple Live Photo capability in SKILL.md",
);
mustInclude(
"SKILL.md",
"material-first Live Photo puzzle layouts",
"material-first puzzle capability in SKILL.md",
);
mustInclude(
"SKILL.md",
"use the M16 Image-Led Cover / text-on-image rules",
"single-video text uses M16 overlay rules",
);
mustInclude(
"SKILL.md",
"Do not invent extra kicker, meta, hairlines",
"single-video text must not invent extra overlay copy",
);
mustInclude(
"SKILL.md",
"platform limits (`5s` Xiaohongshu, `3s` WeChat Official Account)",
"platform publishing reminder in SKILL.md",
);
mustInclude(
"SKILL.md",
"Audience-facing copy must describe the user's actual scene",
"audience-facing copy guardrail in SKILL.md",
);
mustInclude(
"SKILL.md",
"Do not add non-template ornaments just to satisfy an automated density warning",
"non-template ornament guardrail in SKILL.md",
);
mustNotMatch(
"SKILL.md",
/Create a task folder in the current workspace/,
"no root-level task-folder instruction in SKILL.md",
);
mustInclude(
"references/production-workflow.md",
"Create a task folder under `local-tests/` by default",
"production workflow uses local-tests",
);
mustInclude(
"references/production-workflow.md",
"Do not create generated task folders or rendered assets in the skill root",
"production workflow forbids skill-root outputs",
);
mustInclude(
"references/live-photo-production.md",
"## Live Photo Information Budget",
"Live Photo information budget section",
);
mustInclude(
"references/live-photo-production.md",
"## Triple Live Photo Collage",
"triple collage section",
);
mustInclude(
"references/live-photo-production.md",
"## Material-First Puzzle Layouts",
"material-first puzzle section",
);
mustInclude(
"references/live-photo-production.md",
"Do not render single-video text as generic subtitles.",
"single-video text is not subtitle-style",
);
mustInclude(
"references/live-photo-production.md",
"Do not invent extra kicker, meta, hairlines",
"single-video overlay must not invent extra copy",
);
mustInclude(
"references/live-photo-production.md",
"Four-grid",
"four-grid puzzle guidance",
);
mustInclude(
"references/live-photo-production.md",
"## Long Video Intake",
"long video intake section",
);
mustInclude(
"references/live-photo-production.md",
"Web-sourced free videos are only for making our own demo/promo cases",
"user-supplied video is the normal path",
);
mustInclude(
"references/live-photo-production.md",
"Audience-facing copy should name the real scene in the video.",
"Live Photo visible-copy guardrail",
);
mustInclude(
"references/category-cookbook.md",
"## Live Photo Scene Library",
"category scene library section",
);
mustInclude(
"references/category-cookbook.md",
"This is not a fixed template list.",
"category library stays heuristic",
);
mustInclude(
"PRODUCT.md",
"## 10. Live Photo 复盘(2026-07-01)",
"product doc records Live Photo retrospective",
);
mustInclude(
"PRODUCT.md",
"把内部制作要求写成观众可见文案",
"product doc records visible-copy failure mode",
);
mustInclude(
"HANDOFF.md",
"### v0.15 · 2026-07-01",
"handoff records Live Photo version history",
);
mustInclude(
"HANDOFF.md",
"不要把制作要求当成观众可见内容",
"handoff records Live Photo execution pitfall",
);
let failed = 0;
for (const run of checks) {
const result = run();
const marker = result.ok ? "PASS" : "FAIL";
console.log(`${marker} ${result.label}`);
if (!result.ok) {
failed += 1;
console.log(` ${result.detail}`);
}
}
if (failed > 0) {
console.error(`\n${failed} skill doc check(s) failed.`);
process.exit(1);
}
console.log(`\n${checks.length} skill doc checks passed.`);
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Create a tiled contact sheet from a video for cheap Live Photo QA."""
from __future__ import annotations
import argparse
import json
import math
import subprocess
from pathlib import Path
def run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(cmd, check=True, text=True, capture_output=True)
def probe_duration(video: Path) -> float:
result = run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"json",
str(video),
]
)
data = json.loads(result.stdout)
duration = float(data["format"]["duration"])
if duration <= 0:
raise SystemExit(f"invalid duration for {video}: {duration}")
return duration
def main() -> None:
parser = argparse.ArgumentParser(description="Make a tiled frame strip for video QA.")
parser.add_argument("video", type=Path, help="Input video path.")
parser.add_argument("output", type=Path, help="Output JPG/PNG contact sheet path.")
parser.add_argument("--frames", type=int, default=12, help="Number of frames to sample.")
parser.add_argument("--cols", type=int, default=4, help="Tile columns.")
parser.add_argument("--start", type=float, default=0.0, help="Start time in seconds.")
parser.add_argument("--duration", type=float, help="Inspection window duration in seconds.")
parser.add_argument("--thumb-width", type=int, default=320, help="Thumbnail width in pixels.")
parser.add_argument("--padding", type=int, default=12, help="Padding between tiles.")
parser.add_argument("--margin", type=int, default=12, help="Outer tile margin.")
parser.add_argument("--color", default="white", help="Tile background color.")
args = parser.parse_args()
video = args.video.expanduser().resolve()
output = args.output.expanduser().resolve()
if not video.exists():
raise SystemExit(f"missing video: {video}")
if args.frames < 1:
raise SystemExit("--frames must be >= 1")
if args.cols < 1:
raise SystemExit("--cols must be >= 1")
source_duration = probe_duration(video)
window = args.duration if args.duration is not None else max(0.1, source_duration - args.start)
if window <= 0:
raise SystemExit("--duration must be > 0")
output.parent.mkdir(parents=True, exist_ok=True)
rows = math.ceil(args.frames / args.cols)
fps_expr = f"{args.frames}/{window:.6f}"
vf = (
f"fps={fps_expr},"
f"scale={args.thumb_width}:-1:flags=lanczos,"
f"tile={args.cols}x{rows}:padding={args.padding}:margin={args.margin}:color={args.color}"
)
cmd = ["ffmpeg", "-y"]
if args.start > 0:
cmd += ["-ss", f"{args.start:.6f}"]
cmd += ["-t", f"{window:.6f}", "-i", str(video), "-vf", vf, "-frames:v", "1", str(output)]
run(cmd)
print(output)
if __name__ == "__main__":
main()
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Package a JPG/MOV pair as an AirDrop-friendly Live Photo .pvt bundle.
Run with:
UV_CACHE_DIR=/private/tmp/uv-cache \
UV_TOOL_DIR=/private/tmp/uv-tools \
uvx --from makelive python scripts/package-live-photo.py IMG.JPG IMG.MOV
"""
from __future__ import annotations
import argparse
from pathlib import Path
from makelive import save_live_photo_pair_as_pvt
def main() -> None:
parser = argparse.ArgumentParser(description="Package a JPG/MOV pair as a Live Photo .pvt bundle.")
parser.add_argument("jpg", type=Path, help="Key photo path, usually 1080x1440 JPG.")
parser.add_argument("mov", type=Path, help="Paired H.264 MOV path.")
args = parser.parse_args()
jpg = args.jpg.expanduser().resolve()
mov = args.mov.expanduser().resolve()
if not jpg.exists():
raise SystemExit(f"missing JPG: {jpg}")
if not mov.exists():
raise SystemExit(f"missing MOV: {mov}")
asset_id, pvt_path = save_live_photo_pair_as_pvt(str(jpg), str(mov))
print(asset_id)
print(pvt_path)
if __name__ == "__main__":
main()