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:
1 parent
032782ff67
commit
f492caeb8b
20 files changed
+1982
-22
No files matched your search
@@ -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)")
|
||||
@@ -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)")
|
||||
@@ -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.`);
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user